Pull form fields and alert banners into shared components

FormField owns the .field markup and wires id/aria-invalid/aria-describedby
onto its control; Alert owns role=alert banners. Auth, Join and the partner
invite panel drop their one-off error classes for the shared alert style.
VacationDaysInput and ParentProfileInput move onto the .field primitives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 22:02:09 +02:00
parent 1a02bdcdbf
commit e9d7cee66b
21 changed files with 348 additions and 277 deletions
@@ -14,6 +14,7 @@ import type { PersonalData } from '@/types'
import { QRCodeModal } from '@/components/modals/QRCodeModal' import { QRCodeModal } from '@/components/modals/QRCodeModal'
import { ParentBalanceForm, type ParentBalanceInitial } from '@/components/modals/ParentBalanceForm' import { ParentBalanceForm, type ParentBalanceInitial } from '@/components/modals/ParentBalanceForm'
import { LoadingSpinner } from '@/components/common/LoadingSpinner' import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { Alert } from '@/components/common/Alert'
import './balance-flow.css' import './balance-flow.css'
export interface BalanceFlowProps { export interface BalanceFlowProps {
@@ -325,11 +326,7 @@ export function BalanceFlow({
disabled={isAddingDeducedPartner} disabled={isAddingDeducedPartner}
/> />
</div> </div>
{deducedPartnerError && ( {deducedPartnerError && <Alert variant="error">{deducedPartnerError}</Alert>}
<div role="alert" className="alert alert--error">
{deducedPartnerError}
</div>
)}
<div className="actions"> <div className="actions">
<button <button
type="button" type="button"
@@ -3,6 +3,8 @@ import type { LeaveWish, LeaveWishScope, PlannedLeaveDay } from '@/types'
import { useTranslation, type TranslationKey, type Locale } from '@/i18n' import { useTranslation, type TranslationKey, type Locale } from '@/i18n'
import { resolveWishName } from '@/utils/wishName' import { resolveWishName } from '@/utils/wishName'
import { Icon } from '@/components/common/Icon' import { Icon } from '@/components/common/Icon'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import './period-popover.css' import './period-popover.css'
export interface PeriodPopoverSavePayload { export interface PeriodPopoverSavePayload {
@@ -375,11 +377,7 @@ export function PeriodPopover({
</div> </div>
<div className="modal__body"> <div className="modal__body">
{(error || localError) && ( {(error || localError) && <Alert variant="error">{error || localError}</Alert>}
<div className="alert alert--error" role="alert">
{error || localError}
</div>
)}
{!wish && ( {!wish && (
<p className="period-popover__adhoc-notice" data-testid="period-adhoc-notice"> <p className="period-popover__adhoc-notice" data-testid="period-adhoc-notice">
@@ -388,12 +386,8 @@ export function PeriodPopover({
)} )}
<div className="stack"> <div className="stack">
<div className="field"> <FormField id="period-name" label={t('calendar.periodNameLabel')}>
<label htmlFor="period-name" className="field__label">
{t('calendar.periodNameLabel')}
</label>
<input <input
id="period-name"
type="text" type="text"
value={form.name} value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
@@ -403,14 +397,10 @@ export function PeriodPopover({
data-testid="period-name-input" data-testid="period-name-input"
aria-label={t('calendar.periodNameAria')} aria-label={t('calendar.periodNameAria')}
/> />
</div> </FormField>
<div className="field"> <FormField id="period-start" label={t('calendar.periodStartLabel')}>
<label htmlFor="period-start" className="field__label">
{t('calendar.periodStartLabel')}
</label>
<input <input
id="period-start"
type="date" type="date"
value={form.startDate} value={form.startDate}
onChange={(e) => setForm((prev) => ({ ...prev, startDate: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, startDate: e.target.value }))}
@@ -418,14 +408,10 @@ export function PeriodPopover({
disabled={isSaving} disabled={isSaving}
data-testid="period-start-input" data-testid="period-start-input"
/> />
</div> </FormField>
<div className="field"> <FormField id="period-end" label={t('calendar.periodEndLabel')}>
<label htmlFor="period-end" className="field__label">
{t('calendar.periodEndLabel')}
</label>
<input <input
id="period-end"
type="date" type="date"
value={form.endDate} value={form.endDate}
onChange={(e) => setForm((prev) => ({ ...prev, endDate: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, endDate: e.target.value }))}
@@ -433,14 +419,10 @@ export function PeriodPopover({
disabled={isSaving} disabled={isSaving}
data-testid="period-end-input" data-testid="period-end-input"
/> />
</div> </FormField>
<div className="field"> <FormField id="period-parent" label={t('wishEditor.parentLabel')}>
<label htmlFor="period-parent" className="field__label">
{t('wishEditor.parentLabel')}
</label>
<select <select
id="period-parent"
value={parentSelectValue} value={parentSelectValue}
onChange={(e) => { onChange={(e) => {
const value = e.target.value const value = e.target.value
@@ -462,7 +444,7 @@ export function PeriodPopover({
</option> </option>
))} ))}
</select> </select>
</div> </FormField>
<div className="field"> <div className="field">
<label className="period-popover__checkbox-label"> <label className="period-popover__checkbox-label">
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Alert } from './Alert'
describe('Alert', () => {
it.each([
['error', 'alert--error'],
['success', 'alert--success'],
['warning', 'alert--warning'],
['info', 'alert--info'],
] as const)('renders the %s variant with role="alert" and the matching class', (variant, cls) => {
render(<Alert variant={variant}>Something happened</Alert>)
const el = screen.getByRole('alert')
expect(el).toHaveClass('alert', cls)
expect(el.tagName).toBe('DIV')
expect(el).toHaveTextContent('Something happened')
})
it('renders without a data-testid when none is given', () => {
render(<Alert variant="error">Oops</Alert>)
expect(screen.getByRole('alert')).not.toHaveAttribute('data-testid')
})
it('applies a testId when given', () => {
render(
<Alert variant="error" testId="save-message">
Oops
</Alert>,
)
expect(screen.getByTestId('save-message')).toBe(screen.getByRole('alert'))
})
it('appends a custom className alongside the variant class', () => {
render(
<Alert variant="info" className="settings-page__alert">
Heads up
</Alert>,
)
expect(screen.getByRole('alert')).toHaveClass('alert', 'alert--info', 'settings-page__alert')
})
})
+26
View File
@@ -0,0 +1,26 @@
import { ReactNode } from 'react'
export type AlertVariant = 'error' | 'success' | 'warning' | 'info'
export interface AlertProps {
variant: AlertVariant
children: ReactNode
className?: string
testId?: string
}
/** The shared `.alert`/`.alert--{variant}` banner (see
* `src/styles/primitives.css`) — replaces the hand-written
* `<div role="alert" className="alert alert--...">` copies scattered across
* modals/pages. */
export function Alert({ variant, children, className, testId }: AlertProps): ReactNode {
return (
<div
role="alert"
className={className ? `alert alert--${variant} ${className}` : `alert alert--${variant}`}
data-testid={testId}
>
{children}
</div>
)
}
@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { FormField } from './FormField'
describe('FormField', () => {
it('renders the label wired to the control via htmlFor/id', () => {
render(
<FormField id="name" label="Name">
<input data-testid="name-input" />
</FormField>,
)
const input = screen.getByTestId('name-input')
expect(input).toHaveAttribute('id', 'name')
expect(screen.getByText('Name')).toHaveAttribute('for', 'name')
})
it('renders no error/helper paragraph and no aria-invalid/aria-describedby when neither is given', () => {
render(
<FormField id="name" label="Name">
<input data-testid="name-input" />
</FormField>,
)
const input = screen.getByTestId('name-input')
expect(input).not.toHaveAttribute('aria-invalid')
expect(input).not.toHaveAttribute('aria-describedby')
expect(screen.queryByText(/./, { selector: '.field__error' })).not.toBeInTheDocument()
expect(screen.queryByText(/./, { selector: '.field__helper' })).not.toBeInTheDocument()
})
it('renders a helper line and describes the control by it when there is no error', () => {
render(
<FormField id="qty" label="Quantity" helperText="Whole numbers only">
<input data-testid="qty-input" />
</FormField>,
)
const input = screen.getByTestId('qty-input')
expect(input).toHaveAttribute('aria-describedby', 'qty-helper')
expect(input).not.toHaveAttribute('aria-invalid')
const helper = screen.getByText('Whole numbers only')
expect(helper).toHaveAttribute('id', 'qty-helper')
expect(helper).toHaveClass('field__helper')
})
it('renders an error line, sets aria-invalid, and describes the control by the error over the helper', () => {
render(
<FormField id="qty" label="Quantity" error="Required" helperText="Whole numbers only">
<input data-testid="qty-input" />
</FormField>,
)
const input = screen.getByTestId('qty-input')
expect(input).toHaveAttribute('aria-invalid', 'true')
expect(input).toHaveAttribute('aria-describedby', 'qty-error')
const error = screen.getByText('Required')
expect(error).toHaveAttribute('id', 'qty-error')
expect(error).toHaveClass('field__error')
// The helper still renders alongside the error (matches the hand-rolled
// ParentBalanceForm convention) — just isn't what's referenced by
// aria-describedby while an error is present.
expect(screen.getByText('Whole numbers only')).toBeInTheDocument()
})
it('appends a custom className to the field wrapper', () => {
render(
<FormField id="name" label="Name" className="onboarding__partner-name-field">
<input data-testid="name-input" />
</FormField>,
)
expect(screen.getByTestId('name-input').closest('.field')).toHaveClass(
'field',
'onboarding__partner-name-field',
)
})
it('preserves other props already on the control (value, disabled, data-testid)', () => {
render(
<FormField id="qty" label="Quantity">
<input data-testid="qty-input" value="5" disabled readOnly />
</FormField>,
)
const input = screen.getByTestId('qty-input') as HTMLInputElement
expect(input.value).toBe('5')
expect(input).toBeDisabled()
})
})
@@ -0,0 +1,73 @@
import { ReactElement, ReactNode, cloneElement } from 'react'
/** The subset of a native form control's props `FormField` needs to wire up
* — id/aria-invalid/aria-describedby — so `children` can stay typed without
* reaching for `any`. Every native control (input/select/textarea) already
* satisfies this structurally. */
interface FieldControlProps {
id?: string | undefined
'aria-invalid'?: 'true' | undefined
'aria-describedby'?: string | undefined
}
export interface FormFieldProps {
id: string
label: ReactNode
error?: ReactNode
helperText?: ReactNode
className?: string
children: ReactElement<FieldControlProps>
}
/**
* The shared `.field` markup (see `src/styles/primitives.css`) — label,
* control, an optional inline error, an optional helper line — used to be
* hand-copied across every form in the app (`ParentBalanceForm`,
* `PlannedDayModal`, `Auth`, `Join`, `Onboarding`, `Settings`,
* `PartnerInvitePanel`, `ChildBirthdayInput`, `PeriodPopover`, …). This
* centralizes it and wires the control's `id`/`aria-invalid`/
* `aria-describedby` automatically via `cloneElement`, so a caller only
* supplies the control itself (uncontrolled as to id/aria) as `children`.
*
* `aria-describedby` points at the error when present, else the helper when
* present, else nothing — matching the hand-rolled convention this replaces
* (e.g. `ParentBalanceForm`'s dubbeldagar/reserved fields, which describe by
* their helper whenever there's no error to describe by instead).
*/
export function FormField({
id,
label,
error,
helperText,
className,
children,
}: FormFieldProps): ReactNode {
const errorId = `${id}-error`
const helperId = `${id}-helper`
const describedBy = error ? errorId : helperText ? helperId : undefined
const control = cloneElement(children, {
id,
'aria-invalid': error ? 'true' : undefined,
'aria-describedby': describedBy,
})
return (
<div className={className ? `field ${className}` : 'field'}>
<label htmlFor={id} className="field__label">
{label}
</label>
{control}
{error && (
<p id={errorId} className="field__error">
{error}
</p>
)}
{helperText && (
<p id={helperId} className="field__helper">
{helperText}
</p>
)}
</div>
)
}
@@ -19,8 +19,8 @@ export function ParentProfileInput({
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<div data-testid="parent-profile-input" className="parent-profile-input"> <div data-testid="parent-profile-input" className="parent-profile-input">
<div className="parent-profile-input__field"> <div className="field parent-profile-input__field">
<label htmlFor="salary-type" className="parent-profile-input__label"> <label htmlFor="salary-type" className="field__label">
{t('parentProfileInput.salaryTypeLabel')} {t('parentProfileInput.salaryTypeLabel')}
</label> </label>
<select <select
@@ -28,7 +28,7 @@ export function ParentProfileInput({
value={parent.salaryType || ''} value={parent.salaryType || ''}
onChange={(e) => onChange('salaryType', e.target.value)} onChange={(e) => onChange('salaryType', e.target.value)}
data-testid="salary-type" data-testid="salary-type"
className="parent-profile-input__select" className="field__control"
> >
<option value="">{t('parentProfileInput.selectSalaryType')}</option> <option value="">{t('parentProfileInput.selectSalaryType')}</option>
<option value="monthly">{t('parentProfileInput.monthly')}</option> <option value="monthly">{t('parentProfileInput.monthly')}</option>
@@ -36,8 +36,8 @@ export function ParentProfileInput({
</select> </select>
</div> </div>
<div className="parent-profile-input__field"> <div className="field parent-profile-input__field">
<label htmlFor="employment-status" className="parent-profile-input__label"> <label htmlFor="employment-status" className="field__label">
{t('parentProfileInput.employmentStatusLabel')} {t('parentProfileInput.employmentStatusLabel')}
</label> </label>
<select <select
@@ -45,7 +45,7 @@ export function ParentProfileInput({
value={parent.employmentStatus || ''} value={parent.employmentStatus || ''}
onChange={(e) => onChange('employmentStatus', e.target.value)} onChange={(e) => onChange('employmentStatus', e.target.value)}
data-testid="employment-status" data-testid="employment-status"
className="parent-profile-input__select" className="field__control"
> >
<option value="">{t('parentProfileInput.selectEmploymentStatus')}</option> <option value="">{t('parentProfileInput.selectEmploymentStatus')}</option>
<option value="employed">{t('parentProfileInput.employed')}</option> <option value="employed">{t('parentProfileInput.employed')}</option>
@@ -16,8 +16,8 @@ export function VacationDaysInput({
max = 365, max = 365,
}: VacationDaysInputProps): ReactNode { }: VacationDaysInputProps): ReactNode {
return ( return (
<div data-testid="vacation-days-input" className="vacation-days-input"> <div data-testid="vacation-days-input" className="field vacation-days-input">
<label htmlFor="vacation-days-field" className="vacation-days-input__label"> <label htmlFor="vacation-days-field" className="field__label">
{label} {label}
</label> </label>
<input <input
@@ -34,13 +34,11 @@ export function VacationDaysInput({
} }
}} }}
data-testid="vacation-days-field" data-testid="vacation-days-field"
className="vacation-days-input__field" className="field__control vacation-days-input__field"
/> />
<div className="vacation-days-input__range"> <p className="field__helper">
<small>
{min} – {max} {min} – {max}
</small> </p>
</div>
</div> </div>
) )
} }
@@ -1,6 +1,8 @@
import { ReactNode, useState } from 'react' import { ReactNode, useState } from 'react'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { isApiError, type ApiRequestError } from '@/services/errors' import { isApiError, type ApiRequestError } from '@/services/errors'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import './parent-balance-form.css' import './parent-balance-form.css'
export interface ParentBalanceInitial { export interface ParentBalanceInitial {
@@ -222,19 +224,11 @@ export function ParentBalanceForm({
<div className="modal__body"> <div className="modal__body">
<p className="parent-balance-form__description">{t('parentBalance.description')}</p> <p className="parent-balance-form__description">{t('parentBalance.description')}</p>
{(error || saveError) && ( {(error || saveError) && <Alert variant="error">{error || saveError}</Alert>}
<div className="alert alert--error" role="alert">
{error || saveError}
</div>
)}
<div className="stack"> <div className="stack">
<div className="field"> <FormField id="sjukpenning-left" label={t('parentBalance.sjukpenningLeftLabel')} error={sjukpenningError}>
<label htmlFor="sjukpenning-left" className="field__label">
{t('parentBalance.sjukpenningLeftLabel')}
</label>
<input <input
id="sjukpenning-left"
type="number" type="number"
inputMode="numeric" inputMode="numeric"
min={0} min={0}
@@ -246,22 +240,11 @@ export function ParentBalanceForm({
}} }}
disabled={isSaving} disabled={isSaving}
className="field__control" className="field__control"
aria-invalid={sjukpenningError ? 'true' : undefined}
aria-describedby={sjukpenningError ? 'sjukpenning-left-error' : undefined}
/> />
{sjukpenningError && ( </FormField>
<p id="sjukpenning-left-error" className="field__error">
{sjukpenningError}
</p>
)}
</div>
<div className="field"> <FormField id="lagsta-left" label={t('parentBalance.lagstaLeftLabel')} error={lagstaError}>
<label htmlFor="lagsta-left" className="field__label">
{t('parentBalance.lagstaLeftLabel')}
</label>
<input <input
id="lagsta-left"
type="number" type="number"
inputMode="numeric" inputMode="numeric"
min={0} min={0}
@@ -273,22 +256,16 @@ export function ParentBalanceForm({
}} }}
disabled={isSaving} disabled={isSaving}
className="field__control" className="field__control"
aria-invalid={lagstaError ? 'true' : undefined}
aria-describedby={lagstaError ? 'lagsta-left-error' : undefined}
/> />
{lagstaError && ( </FormField>
<p id="lagsta-left-error" className="field__error">
{lagstaError}
</p>
)}
</div>
<div className="field"> <FormField
<label htmlFor="dubbeldag-left" className="field__label">
{t('parentBalance.dubbeldagarLeftLabel')}
</label>
<input
id="dubbeldag-left" id="dubbeldag-left"
label={t('parentBalance.dubbeldagarLeftLabel')}
error={dubbeldagarError}
helperText={t('parentBalance.dubbeldagarHelper')}
>
<input
type="number" type="number"
inputMode="numeric" inputMode="numeric"
min={0} min={0}
@@ -300,18 +277,8 @@ export function ParentBalanceForm({
}} }}
disabled={isSaving} disabled={isSaving}
className="field__control" className="field__control"
aria-invalid={dubbeldagarError ? 'true' : undefined}
aria-describedby={dubbeldagarError ? 'dubbeldag-left-error' : 'dubbeldag-left-helper'}
/> />
{dubbeldagarError && ( </FormField>
<p id="dubbeldag-left-error" className="field__error">
{dubbeldagarError}
</p>
)}
<p id="dubbeldag-left-helper" className="field__helper">
{t('parentBalance.dubbeldagarHelper')}
</p>
</div>
<button <button
type="button" type="button"
@@ -339,12 +306,13 @@ export function ParentBalanceForm({
</button> </button>
{showAdvanced && ( {showAdvanced && (
<div className="field"> <FormField
<label htmlFor="reserved-left" className="field__label">
{t('parentBalance.reservedLeftLabel')}
</label>
<input
id="reserved-left" id="reserved-left"
label={t('parentBalance.reservedLeftLabel')}
error={reservedError}
helperText={t('parentBalance.reservedLeftHelper')}
>
<input
type="number" type="number"
inputMode="numeric" inputMode="numeric"
min={0} min={0}
@@ -356,18 +324,8 @@ export function ParentBalanceForm({
}} }}
disabled={isSaving} disabled={isSaving}
className="field__control" className="field__control"
aria-invalid={reservedError ? 'true' : undefined}
aria-describedby={reservedError ? 'reserved-left-error' : 'reserved-left-helper'}
/> />
{reservedError && ( </FormField>
<p id="reserved-left-error" className="field__error">
{reservedError}
</p>
)}
<p id="reserved-left-helper" className="field__helper">
{t('parentBalance.reservedLeftHelper')}
</p>
</div>
)} )}
</div> </div>
</div> </div>
@@ -2,6 +2,8 @@ import { ReactNode, useEffect, useState } from 'react'
import type { PlannedLeaveDay } from '@/types' import type { PlannedLeaveDay } from '@/types'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { Icon } from '@/components/common/Icon' import { Icon } from '@/components/common/Icon'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import './planned-day-modal.css' import './planned-day-modal.css'
/** The default path — todo.md #10/#11: the planner decides which day type /** The default path — todo.md #10/#11: the planner decides which day type
@@ -283,11 +285,7 @@ export function PlannedDayModal({
<div className="modal__body"> <div className="modal__body">
{!isRangeMode && <p className="planned-day-modal__date">{displayDate}</p>} {!isRangeMode && <p className="planned-day-modal__date">{displayDate}</p>}
{(error || localError) && ( {(error || localError) && <Alert variant="error">{error || localError}</Alert>}
<div className="alert alert--error" role="alert">
{error || localError}
</div>
)}
{!isRangeMode && {!isRangeMode &&
excludedEntries.map((entry) => ( excludedEntries.map((entry) => (
@@ -313,12 +311,8 @@ export function PlannedDayModal({
<div className="stack"> <div className="stack">
{!forcedAdvanced && hasMultipleParents && ( {!forcedAdvanced && hasMultipleParents && (
<div className="field"> <FormField id="parent-mode-select" label={t('plannedDayModal.parentModeLabel')}>
<label htmlFor="parent-mode-select" className="field__label">
{t('plannedDayModal.parentModeLabel')}
</label>
<select <select
id="parent-mode-select"
value={parentModeSelectValue} value={parentModeSelectValue}
onChange={(e) => handleParentModeChange(e.target.value)} onChange={(e) => handleParentModeChange(e.target.value)}
className="field__control" className="field__control"
@@ -333,7 +327,7 @@ export function PlannedDayModal({
</option> </option>
))} ))}
</select> </select>
</div> </FormField>
)} )}
{!forcedAdvanced && hasMultipleParents && ( {!forcedAdvanced && hasMultipleParents && (
@@ -382,12 +376,8 @@ export function PlannedDayModal({
{showPinnedControls && ( {showPinnedControls && (
<div className="planned-day-modal__advanced" data-testid="advanced-section"> <div className="planned-day-modal__advanced" data-testid="advanced-section">
<div className="field"> <FormField id="day-type" label={t('plannedDayModal.leaveTypeLabel')}>
<label htmlFor="day-type" className="field__label">
{t('plannedDayModal.leaveTypeLabel')}
</label>
<select <select
id="day-type"
value={dayType} value={dayType}
onChange={(e) => { onChange={(e) => {
const value = e.target.value const value = e.target.value
@@ -404,7 +394,7 @@ export function PlannedDayModal({
<option value="sjukpenning">{t('plannedDayModal.sjukpenningOption')}</option> <option value="sjukpenning">{t('plannedDayModal.sjukpenningOption')}</option>
<option value="lagsta">{t('plannedDayModal.lagstaOption')}</option> <option value="lagsta">{t('plannedDayModal.lagstaOption')}</option>
</select> </select>
</div> </FormField>
{freeDaysWillStayOff && ( {freeDaysWillStayOff && (
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice"> <p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
@@ -419,12 +409,8 @@ export function PlannedDayModal({
)} )}
{hasMultipleParents && ( {hasMultipleParents && (
<div className="field"> <FormField id="parent-select" label={t('plannedDayModal.parentLabel')}>
<label htmlFor="parent-select" className="field__label">
{t('plannedDayModal.parentLabel')}
</label>
<select <select
id="parent-select"
value={selectedParentId} value={selectedParentId}
onChange={(e) => handleAdvancedParentChange(e.target.value)} onChange={(e) => handleAdvancedParentChange(e.target.value)}
className="field__control" className="field__control"
@@ -437,7 +423,7 @@ export function PlannedDayModal({
</option> </option>
))} ))}
</select> </select>
</div> </FormField>
)} )}
{dayType !== 'auto' && ( {dayType !== 'auto' && (
@@ -2,6 +2,7 @@ import { ReactNode } from 'react'
import type { ScraperStatus } from '@/hooks/useScraperFetch' import type { ScraperStatus } from '@/hooks/useScraperFetch'
import { useTranslation, type TranslationKey } from '@/i18n' import { useTranslation, type TranslationKey } from '@/i18n'
import { Icon } from '@/components/common/Icon' import { Icon } from '@/components/common/Icon'
import { Alert } from '@/components/common/Alert'
import './qrcode-modal.css' import './qrcode-modal.css'
// Every `ScraperStatus` maps to a translated `balance.status.*` label — the // Every `ScraperStatus` maps to a translated `balance.status.*` label — the
@@ -113,9 +114,9 @@ export function QRCodeModal({
)} )}
{error && ( {error && (
<div className="alert alert--error" role="alert"> <Alert variant="error">
<strong>{t('qrModal.errorLabel')}</strong> {error} <strong>{t('qrModal.errorLabel')}</strong> {error}
</div> </Alert>
)} )}
</div> </div>
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import * as api from '@/services/api' import * as api from '@/services/api'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import type { Invitation } from '@/types' import type { Invitation } from '@/types'
import { Alert } from '@/components/common/Alert'
import './partner-invite-panel.css' import './partner-invite-panel.css'
export interface PartnerInvitePanelProps { export interface PartnerInvitePanelProps {
@@ -118,11 +119,7 @@ export function PartnerInvitePanel({ workspaceId }: PartnerInvitePanelProps) {
{isCreating ? t('invite.generatingButton') : t('invite.generateButton')} {isCreating ? t('invite.generatingButton') : t('invite.generateButton')}
</button> </button>
{createError && ( {createError && <Alert variant="error">{createError}</Alert>}
<p role="alert" className="partner-invite-panel__error">
{createError}
</p>
)}
{inviteLink && ( {inviteLink && (
<div className="partner-invite-panel__link-box" data-testid="invite-link-box"> <div className="partner-invite-panel__link-box" data-testid="invite-link-box">
@@ -157,9 +154,7 @@ export function PartnerInvitePanel({ workspaceId }: PartnerInvitePanelProps) {
{isListLoading ? ( {isListLoading ? (
<p>{t('common.loading')}</p> <p>{t('common.loading')}</p>
) : listError ? ( ) : listError ? (
<p role="alert" className="partner-invite-panel__error"> <Alert variant="error">{listError}</Alert>
{listError}
</p>
) : pending.length === 0 ? ( ) : pending.length === 0 ? (
<p className="partner-invite-panel__empty">{t('invite.noPending')}</p> <p className="partner-invite-panel__empty">{t('invite.noPending')}</p>
) : ( ) : (
@@ -183,11 +178,7 @@ export function PartnerInvitePanel({ workspaceId }: PartnerInvitePanelProps) {
</ul> </ul>
)} )}
{revokeError && ( {revokeError && <Alert variant="error">{revokeError}</Alert>}
<p role="alert" className="partner-invite-panel__error">
{revokeError}
</p>
)}
</div> </div>
) )
} }
@@ -75,9 +75,3 @@
font-size: var(--fs-sm); font-size: var(--fs-sm);
color: var(--c-text-muted); color: var(--c-text-muted);
} }
.partner-invite-panel__error {
margin: 0;
font-size: var(--fs-sm);
color: var(--c-danger);
}
+9 -23
View File
@@ -4,6 +4,8 @@ import { useAuth } from '@/context/AuthContext'
import { useWorkspace } from '@/context/WorkspaceContext' import { useWorkspace } from '@/context/WorkspaceContext'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { LanguageToggle } from '@/components/layout/LanguageToggle' import { LanguageToggle } from '@/components/layout/LanguageToggle'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import './auth.css' import './auth.css'
export function Auth() { export function Auth() {
@@ -77,25 +79,17 @@ export function Auth() {
</div> </div>
<form className="auth-page__form" onSubmit={(e) => void handleSubmit(e)}> <form className="auth-page__form" onSubmit={(e) => void handleSubmit(e)}>
{isRegister && ( {isRegister && (
<div className="field"> <FormField id="auth-name" label={t('auth.nameLabel')}>
<label htmlFor="auth-name" className="field__label">
{t('auth.nameLabel')}
</label>
<input <input
id="auth-name"
className="field__control" className="field__control"
placeholder={t('auth.namePlaceholder')} placeholder={t('auth.namePlaceholder')}
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
/> />
</div> </FormField>
)} )}
<div className="field"> <FormField id="auth-email" label={t('auth.emailLabel')}>
<label htmlFor="auth-email" className="field__label">
{t('auth.emailLabel')}
</label>
<input <input
id="auth-email"
type="email" type="email"
data-testid="auth-email" data-testid="auth-email"
className="field__control" className="field__control"
@@ -103,13 +97,9 @@ export function Auth() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
/> />
</div> </FormField>
<div className="field"> <FormField id="auth-password" label={t('auth.passwordLabel')}>
<label htmlFor="auth-password" className="field__label">
{t('auth.passwordLabel')}
</label>
<input <input
id="auth-password"
type="password" type="password"
data-testid="auth-password" data-testid="auth-password"
className="field__control" className="field__control"
@@ -117,12 +107,8 @@ export function Auth() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
</div> </FormField>
{error && ( {error && <Alert variant="error">{error}</Alert>}
<p role="alert" className="auth-page__error">
{error}
</p>
)}
<button type="submit" className="btn btn--primary btn--block" data-testid="auth-submit"> <button type="submit" className="btn btn--primary btn--block" data-testid="auth-submit">
{isRegister ? t('auth.registerButton') : t('auth.loginButton')} {isRegister ? t('auth.registerButton') : t('auth.loginButton')}
</button> </button>
+5 -4
View File
@@ -29,6 +29,7 @@ import { VacationRescueTip } from '@/components/dashboard/VacationRescueTip'
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown' import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
import { TipCard } from '@/components/inputs/TipCard' import { TipCard } from '@/components/inputs/TipCard'
import { Onboarding } from '@/pages/Onboarding' import { Onboarding } from '@/pages/Onboarding'
import { Alert } from '@/components/common/Alert'
import type { RescueDay, Tip } from '@/types' import type { RescueDay, Tip } from '@/types'
/** Key matching `rescueDayGroups`' own `Map` key below — shared so a /** Key matching `rescueDayGroups`' own `Map` key below — shared so a
@@ -332,9 +333,9 @@ export function Dashboard(): ReactNode {
<Layout> <Layout>
<div data-testid="dashboard" className="dashboard"> <div data-testid="dashboard" className="dashboard">
{analyticsError && ( {analyticsError && (
<div className="alert alert--error" data-testid="error-banner" role="alert"> <Alert variant="error" testId="error-banner">
{analyticsError} {analyticsError}
</div> </Alert>
)} )}
{analytics && selectedChild && ( {analytics && selectedChild && (
@@ -371,9 +372,9 @@ export function Dashboard(): ReactNode {
<h2>{t('dashboard.leaveOverviewTitle')}</h2> <h2>{t('dashboard.leaveOverviewTitle')}</h2>
{age4Bars.length > 0 && <Age4Bars bars={age4Bars} />} {age4Bars.length > 0 && <Age4Bars bars={age4Bars} />}
{proposalError && ( {proposalError && (
<div className="alert alert--error" role="alert" data-testid="dashboard-proposal-error"> <Alert variant="error" testId="dashboard-proposal-error">
{proposalError} {proposalError}
</div> </Alert>
)} )}
<YearRows <YearRows
parents={yearRowParents} parents={yearRowParents}
+11 -25
View File
@@ -5,6 +5,8 @@ import { useWorkspace } from '@/context/WorkspaceContext'
import { redeemInvitation } from '@/services/api' import { redeemInvitation } from '@/services/api'
import { useTranslation, type TranslationKey } from '@/i18n' import { useTranslation, type TranslationKey } from '@/i18n'
import { LanguageToggle } from '@/components/layout/LanguageToggle' import { LanguageToggle } from '@/components/layout/LanguageToggle'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import './join.css' import './join.css'
type Phase = 'checking' | 'form' | 'redeeming' | 'error' | 'done' type Phase = 'checking' | 'form' | 'redeeming' | 'error' | 'done'
@@ -134,9 +136,9 @@ export function Join() {
{phase === 'error' && ( {phase === 'error' && (
<> <>
<p role="alert" className="join-page__error" data-testid="join-error"> <Alert variant="error" testId="join-error">
{errorKey ? t(errorKey) : t('invite.joinGenericError')} {errorKey ? t(errorKey) : t('invite.joinGenericError')}
</p> </Alert>
{isAuthenticated && ( {isAuthenticated && (
<button <button
type="button" type="button"
@@ -173,25 +175,17 @@ export function Join() {
</div> </div>
<form className="join-page__form" onSubmit={(e) => void handleSubmit(e)}> <form className="join-page__form" onSubmit={(e) => void handleSubmit(e)}>
{mode === 'register' && ( {mode === 'register' && (
<div className="field"> <FormField id="join-name" label={t('invite.joinNameLabel')}>
<label htmlFor="join-name" className="field__label">
{t('invite.joinNameLabel')}
</label>
<input <input
id="join-name"
className="field__control" className="field__control"
placeholder={t('invite.joinNamePlaceholder')} placeholder={t('invite.joinNamePlaceholder')}
value={displayName} value={displayName}
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
/> />
</div> </FormField>
)} )}
<div className="field"> <FormField id="join-email" label={t('invite.joinEmailLabel')}>
<label htmlFor="join-email" className="field__label">
{t('invite.joinEmailLabel')}
</label>
<input <input
id="join-email"
type="email" type="email"
data-testid="join-email" data-testid="join-email"
className="field__control" className="field__control"
@@ -199,13 +193,9 @@ export function Join() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
/> />
</div> </FormField>
<div className="field"> <FormField id="join-password" label={t('invite.joinPasswordLabel')}>
<label htmlFor="join-password" className="field__label">
{t('invite.joinPasswordLabel')}
</label>
<input <input
id="join-password"
type="password" type="password"
data-testid="join-password" data-testid="join-password"
className="field__control" className="field__control"
@@ -213,12 +203,8 @@ export function Join() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
</div> </FormField>
{errorKey && ( {errorKey && <Alert variant="error">{t(errorKey)}</Alert>}
<p role="alert" className="join-page__error">
{t(errorKey)}
</p>
)}
<button <button
type="submit" type="submit"
className="btn btn--primary btn--block" className="btn btn--primary btn--block"
+12 -28
View File
@@ -7,6 +7,8 @@ import { useTranslation, type TranslationKey, type TranslationParams } from '@/i
import { Layout } from '@/components/layout/Layout' import { Layout } from '@/components/layout/Layout'
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput' import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
import { BalanceFlow } from '@/components/balance/BalanceFlow' import { BalanceFlow } from '@/components/balance/BalanceFlow'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import './onboarding.css' import './onboarding.css'
type Step = 'child' | 'family' | 'balances' type Step = 'child' | 'family' | 'balances'
@@ -171,12 +173,13 @@ export function Onboarding(): ReactNode {
`.field__error`, no localized message), so this form owns `.field__error`, no localized message), so this form owns
validation itself via `handleCreateChild`'s field checks. */} validation itself via `handleCreateChild`'s field checks. */}
<form onSubmit={(e) => void handleCreateChild(e)} noValidate> <form onSubmit={(e) => void handleCreateChild(e)} noValidate>
<div className="field form-group"> <FormField
<label htmlFor="onboarding-child-name" className="field__label">
{t('onboarding.childNameLabel')}
</label>
<input
id="onboarding-child-name" id="onboarding-child-name"
label={t('onboarding.childNameLabel')}
error={childNameError}
className="form-group"
>
<input
type="text" type="text"
placeholder={t('onboarding.namePlaceholder')} placeholder={t('onboarding.namePlaceholder')}
value={childName} value={childName}
@@ -184,15 +187,8 @@ export function Onboarding(): ReactNode {
disabled={isCreatingChild} disabled={isCreatingChild}
className="field__control" className="field__control"
required required
aria-invalid={childNameError ? 'true' : undefined}
aria-describedby={childNameError ? 'onboarding-child-name-error' : undefined}
/> />
{childNameError && ( </FormField>
<p id="onboarding-child-name-error" className="field__error">
{childNameError}
</p>
)}
</div>
<div className="form-group"> <div className="form-group">
<ChildBirthdayInput <ChildBirthdayInput
birthDate={childBirthDate} birthDate={childBirthDate}
@@ -201,11 +197,7 @@ export function Onboarding(): ReactNode {
error={childBirthDateError} error={childBirthDateError}
/> />
</div> </div>
{childError && ( {childError && <Alert variant="error">{childError}</Alert>}
<div role="alert" className="alert alert--error">
{childError}
</div>
)}
<div className="actions"> <div className="actions">
<button type="submit" className="btn btn--primary" disabled={isCreatingChild}> <button type="submit" className="btn btn--primary" disabled={isCreatingChild}>
{isCreatingChild ? t('onboarding.addingChild') : t('onboarding.addChildButton')} {isCreatingChild ? t('onboarding.addingChild') : t('onboarding.addChildButton')}
@@ -273,11 +265,7 @@ export function Onboarding(): ReactNode {
</div> </div>
</div> </div>
{familyError && ( {familyError && <Alert variant="error">{familyError}</Alert>}
<div role="alert" className="alert alert--error">
{familyError}
</div>
)}
<div className="actions"> <div className="actions">
<button <button
@@ -306,11 +294,7 @@ export function Onboarding(): ReactNode {
onSaved={() => void refreshMembers()} onSaved={() => void refreshMembers()}
/> />
{finishError && ( {finishError && <Alert variant="error">{finishError}</Alert>}
<div role="alert" className="alert alert--error">
{finishError}
</div>
)}
<div className="actions onboarding__footer"> <div className="actions onboarding__footer">
<button <button
+5 -12
View File
@@ -23,6 +23,7 @@ import { VacationDaysTable, type VacationDaysTableRow } from '@/components/vacat
import { CalendarExceptions } from '@/components/calendar/CalendarExceptions' import { CalendarExceptions } from '@/components/calendar/CalendarExceptions'
import { BalanceFlow } from '@/components/balance/BalanceFlow' import { BalanceFlow } from '@/components/balance/BalanceFlow'
import { PartnerInvitePanel } from '@/components/settings/PartnerInvitePanel' import { PartnerInvitePanel } from '@/components/settings/PartnerInvitePanel'
import { Alert } from '@/components/common/Alert'
import { orderSelfFirst } from '@/utils/members' import { orderSelfFirst } from '@/utils/members'
import type { VacationDays } from '@/types' import type { VacationDays } from '@/types'
import './settings.css' import './settings.css'
@@ -647,13 +648,9 @@ export function Settings(): ReactNode {
<h1>{t('settings.title')}</h1> <h1>{t('settings.title')}</h1>
{saveMessage && ( {saveMessage && (
<div <Alert variant={saveMessage.type} testId="save-message">
className={`alert alert--${saveMessage.type}`}
data-testid="save-message"
role="alert"
>
{saveMessage.text} {saveMessage.text}
</div> </Alert>
)} )}
<section className="settings-panel"> <section className="settings-panel">
@@ -815,13 +812,9 @@ export function Settings(): ReactNode {
</> </>
)} )}
{memberActionError?.uuid === member.uuid && ( {memberActionError?.uuid === member.uuid && (
<p <Alert variant="error" testId={`member-action-error-${member.uuid}`}>
role="alert"
className="alert alert--error"
data-testid={`member-action-error-${member.uuid}`}
>
{memberActionError.text} {memberActionError.text}
</p> </Alert>
)} )}
</li> </li>
) )
-6
View File
@@ -58,9 +58,3 @@
.auth-page__form button[type='submit'] { .auth-page__form button[type='submit'] {
margin-top: var(--sp-2); margin-top: var(--sp-2);
} }
.auth-page__error {
margin: 0;
font-size: var(--fs-sm);
color: var(--c-danger);
}
-7
View File
@@ -62,10 +62,3 @@
.join-page__form button[type='submit'] { .join-page__form button[type='submit'] {
margin-top: var(--sp-2); margin-top: var(--sp-2);
} }
.join-page__error {
margin: 0;
font-size: var(--fs-sm);
color: var(--c-danger);
text-align: center;
}
+20 -17
View File
@@ -768,18 +768,20 @@ legend {
/* Input Components /* Input Components
* *
* `ChildBirthdayInput` now builds its fields from `.field`/`.field__label`/ * `ChildBirthdayInput`, `VacationDaysInput` and `ParentProfileInput` all
* `.field__control` (primitives.css) directly rather than its own * build their fields from `.field`/`.field__label`/`.field__control`/
* `.child-birthday-input__*` set — that set is gone from here accordingly. */ * `.field__helper` (primitives.css) directly now, rather than their own
.vacation-days-input, * bespoke `*-input__label`/`__field`/`__range`/`__help` sets — those are
.planning-goal-selector, * gone from here accordingly. What's left below are pure width modifiers
.parent-profile-input { * (combined with `.field`/`.field__control` in the JSX), the one thing the
* shared primitives don't express. `.planning-goal-selector*` has no
* corresponding component left in `src/components` — left as-is, out of
* scope for this pass. */
.planning-goal-selector {
margin-bottom: var(--sp-5); margin-bottom: var(--sp-5);
} }
.vacation-days-input__label, .planning-goal-selector__label {
.planning-goal-selector__label,
.parent-profile-input__label {
display: block; display: block;
margin-bottom: var(--sp-2); margin-bottom: var(--sp-2);
font-weight: 500; font-weight: 500;
@@ -787,16 +789,17 @@ legend {
font-size: var(--fs-base); font-size: var(--fs-base);
} }
.vacation-days-input__field, .planning-goal-selector__select {
.planning-goal-selector__select,
.parent-profile-input__field {
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
} }
.vacation-days-input__range, .vacation-days-input,
.parent-profile-input__help { .parent-profile-input {
font-size: var(--fs-sm); margin-bottom: var(--sp-5);
color: var(--c-text-muted); }
margin-top: var(--sp-2);
.vacation-days-input__field,
.parent-profile-input__field {
max-width: 400px;
} }