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 { ParentBalanceForm, type ParentBalanceInitial } from '@/components/modals/ParentBalanceForm'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { Alert } from '@/components/common/Alert'
import './balance-flow.css'
export interface BalanceFlowProps {
@@ -325,11 +326,7 @@ export function BalanceFlow({
disabled={isAddingDeducedPartner}
/>
</div>
{deducedPartnerError && (
<div role="alert" className="alert alert--error">
{deducedPartnerError}
</div>
)}
{deducedPartnerError && <Alert variant="error">{deducedPartnerError}</Alert>}
<div className="actions">
<button
type="button"
@@ -3,6 +3,8 @@ import type { LeaveWish, LeaveWishScope, PlannedLeaveDay } from '@/types'
import { useTranslation, type TranslationKey, type Locale } from '@/i18n'
import { resolveWishName } from '@/utils/wishName'
import { Icon } from '@/components/common/Icon'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import './period-popover.css'
export interface PeriodPopoverSavePayload {
@@ -375,11 +377,7 @@ export function PeriodPopover({
</div>
<div className="modal__body">
{(error || localError) && (
<div className="alert alert--error" role="alert">
{error || localError}
</div>
)}
{(error || localError) && <Alert variant="error">{error || localError}</Alert>}
{!wish && (
<p className="period-popover__adhoc-notice" data-testid="period-adhoc-notice">
@@ -388,12 +386,8 @@ export function PeriodPopover({
)}
<div className="stack">
<div className="field">
<label htmlFor="period-name" className="field__label">
{t('calendar.periodNameLabel')}
</label>
<FormField id="period-name" label={t('calendar.periodNameLabel')}>
<input
id="period-name"
type="text"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
@@ -403,14 +397,10 @@ export function PeriodPopover({
data-testid="period-name-input"
aria-label={t('calendar.periodNameAria')}
/>
</div>
</FormField>
<div className="field">
<label htmlFor="period-start" className="field__label">
{t('calendar.periodStartLabel')}
</label>
<FormField id="period-start" label={t('calendar.periodStartLabel')}>
<input
id="period-start"
type="date"
value={form.startDate}
onChange={(e) => setForm((prev) => ({ ...prev, startDate: e.target.value }))}
@@ -418,14 +408,10 @@ export function PeriodPopover({
disabled={isSaving}
data-testid="period-start-input"
/>
</div>
</FormField>
<div className="field">
<label htmlFor="period-end" className="field__label">
{t('calendar.periodEndLabel')}
</label>
<FormField id="period-end" label={t('calendar.periodEndLabel')}>
<input
id="period-end"
type="date"
value={form.endDate}
onChange={(e) => setForm((prev) => ({ ...prev, endDate: e.target.value }))}
@@ -433,14 +419,10 @@ export function PeriodPopover({
disabled={isSaving}
data-testid="period-end-input"
/>
</div>
</FormField>
<div className="field">
<label htmlFor="period-parent" className="field__label">
{t('wishEditor.parentLabel')}
</label>
<FormField id="period-parent" label={t('wishEditor.parentLabel')}>
<select
id="period-parent"
value={parentSelectValue}
onChange={(e) => {
const value = e.target.value
@@ -462,7 +444,7 @@ export function PeriodPopover({
</option>
))}
</select>
</div>
</FormField>
<div className="field">
<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()
return (
<div data-testid="parent-profile-input" className="parent-profile-input">
<div className="parent-profile-input__field">
<label htmlFor="salary-type" className="parent-profile-input__label">
<div className="field parent-profile-input__field">
<label htmlFor="salary-type" className="field__label">
{t('parentProfileInput.salaryTypeLabel')}
</label>
<select
@@ -28,7 +28,7 @@ export function ParentProfileInput({
value={parent.salaryType || ''}
onChange={(e) => onChange('salaryType', e.target.value)}
data-testid="salary-type"
className="parent-profile-input__select"
className="field__control"
>
<option value="">{t('parentProfileInput.selectSalaryType')}</option>
<option value="monthly">{t('parentProfileInput.monthly')}</option>
@@ -36,8 +36,8 @@ export function ParentProfileInput({
</select>
</div>
<div className="parent-profile-input__field">
<label htmlFor="employment-status" className="parent-profile-input__label">
<div className="field parent-profile-input__field">
<label htmlFor="employment-status" className="field__label">
{t('parentProfileInput.employmentStatusLabel')}
</label>
<select
@@ -45,7 +45,7 @@ export function ParentProfileInput({
value={parent.employmentStatus || ''}
onChange={(e) => onChange('employmentStatus', e.target.value)}
data-testid="employment-status"
className="parent-profile-input__select"
className="field__control"
>
<option value="">{t('parentProfileInput.selectEmploymentStatus')}</option>
<option value="employed">{t('parentProfileInput.employed')}</option>
@@ -16,8 +16,8 @@ export function VacationDaysInput({
max = 365,
}: VacationDaysInputProps): ReactNode {
return (
<div data-testid="vacation-days-input" className="vacation-days-input">
<label htmlFor="vacation-days-field" className="vacation-days-input__label">
<div data-testid="vacation-days-input" className="field vacation-days-input">
<label htmlFor="vacation-days-field" className="field__label">
{label}
</label>
<input
@@ -34,13 +34,11 @@ export function VacationDaysInput({
}
}}
data-testid="vacation-days-field"
className="vacation-days-input__field"
className="field__control vacation-days-input__field"
/>
<div className="vacation-days-input__range">
<small>
{min} – {max}
</small>
</div>
<p className="field__helper">
{min} – {max}
</p>
</div>
)
}
@@ -1,6 +1,8 @@
import { ReactNode, useState } from 'react'
import { useTranslation } from '@/i18n'
import { isApiError, type ApiRequestError } from '@/services/errors'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import './parent-balance-form.css'
export interface ParentBalanceInitial {
@@ -222,19 +224,11 @@ export function ParentBalanceForm({
<div className="modal__body">
<p className="parent-balance-form__description">{t('parentBalance.description')}</p>
{(error || saveError) && (
<div className="alert alert--error" role="alert">
{error || saveError}
</div>
)}
{(error || saveError) && <Alert variant="error">{error || saveError}</Alert>}
<div className="stack">
<div className="field">
<label htmlFor="sjukpenning-left" className="field__label">
{t('parentBalance.sjukpenningLeftLabel')}
</label>
<FormField id="sjukpenning-left" label={t('parentBalance.sjukpenningLeftLabel')} error={sjukpenningError}>
<input
id="sjukpenning-left"
type="number"
inputMode="numeric"
min={0}
@@ -246,22 +240,11 @@ export function ParentBalanceForm({
}}
disabled={isSaving}
className="field__control"
aria-invalid={sjukpenningError ? 'true' : undefined}
aria-describedby={sjukpenningError ? 'sjukpenning-left-error' : undefined}
/>
{sjukpenningError && (
<p id="sjukpenning-left-error" className="field__error">
{sjukpenningError}
</p>
)}
</div>
</FormField>
<div className="field">
<label htmlFor="lagsta-left" className="field__label">
{t('parentBalance.lagstaLeftLabel')}
</label>
<FormField id="lagsta-left" label={t('parentBalance.lagstaLeftLabel')} error={lagstaError}>
<input
id="lagsta-left"
type="number"
inputMode="numeric"
min={0}
@@ -273,22 +256,16 @@ export function ParentBalanceForm({
}}
disabled={isSaving}
className="field__control"
aria-invalid={lagstaError ? 'true' : undefined}
aria-describedby={lagstaError ? 'lagsta-left-error' : undefined}
/>
{lagstaError && (
<p id="lagsta-left-error" className="field__error">
{lagstaError}
</p>
)}
</div>
</FormField>
<div className="field">
<label htmlFor="dubbeldag-left" className="field__label">
{t('parentBalance.dubbeldagarLeftLabel')}
</label>
<FormField
id="dubbeldag-left"
label={t('parentBalance.dubbeldagarLeftLabel')}
error={dubbeldagarError}
helperText={t('parentBalance.dubbeldagarHelper')}
>
<input
id="dubbeldag-left"
type="number"
inputMode="numeric"
min={0}
@@ -300,18 +277,8 @@ export function ParentBalanceForm({
}}
disabled={isSaving}
className="field__control"
aria-invalid={dubbeldagarError ? 'true' : undefined}
aria-describedby={dubbeldagarError ? 'dubbeldag-left-error' : 'dubbeldag-left-helper'}
/>
{dubbeldagarError && (
<p id="dubbeldag-left-error" className="field__error">
{dubbeldagarError}
</p>
)}
<p id="dubbeldag-left-helper" className="field__helper">
{t('parentBalance.dubbeldagarHelper')}
</p>
</div>
</FormField>
<button
type="button"
@@ -339,12 +306,13 @@ export function ParentBalanceForm({
</button>
{showAdvanced && (
<div className="field">
<label htmlFor="reserved-left" className="field__label">
{t('parentBalance.reservedLeftLabel')}
</label>
<FormField
id="reserved-left"
label={t('parentBalance.reservedLeftLabel')}
error={reservedError}
helperText={t('parentBalance.reservedLeftHelper')}
>
<input
id="reserved-left"
type="number"
inputMode="numeric"
min={0}
@@ -356,18 +324,8 @@ export function ParentBalanceForm({
}}
disabled={isSaving}
className="field__control"
aria-invalid={reservedError ? 'true' : undefined}
aria-describedby={reservedError ? 'reserved-left-error' : 'reserved-left-helper'}
/>
{reservedError && (
<p id="reserved-left-error" className="field__error">
{reservedError}
</p>
)}
<p id="reserved-left-helper" className="field__helper">
{t('parentBalance.reservedLeftHelper')}
</p>
</div>
</FormField>
)}
</div>
</div>
@@ -2,6 +2,8 @@ import { ReactNode, useEffect, useState } from 'react'
import type { PlannedLeaveDay } from '@/types'
import { useTranslation } from '@/i18n'
import { Icon } from '@/components/common/Icon'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import './planned-day-modal.css'
/** The default path — todo.md #10/#11: the planner decides which day type
@@ -283,11 +285,7 @@ export function PlannedDayModal({
<div className="modal__body">
{!isRangeMode && <p className="planned-day-modal__date">{displayDate}</p>}
{(error || localError) && (
<div className="alert alert--error" role="alert">
{error || localError}
</div>
)}
{(error || localError) && <Alert variant="error">{error || localError}</Alert>}
{!isRangeMode &&
excludedEntries.map((entry) => (
@@ -313,12 +311,8 @@ export function PlannedDayModal({
<div className="stack">
{!forcedAdvanced && hasMultipleParents && (
<div className="field">
<label htmlFor="parent-mode-select" className="field__label">
{t('plannedDayModal.parentModeLabel')}
</label>
<FormField id="parent-mode-select" label={t('plannedDayModal.parentModeLabel')}>
<select
id="parent-mode-select"
value={parentModeSelectValue}
onChange={(e) => handleParentModeChange(e.target.value)}
className="field__control"
@@ -333,7 +327,7 @@ export function PlannedDayModal({
</option>
))}
</select>
</div>
</FormField>
)}
{!forcedAdvanced && hasMultipleParents && (
@@ -382,12 +376,8 @@ export function PlannedDayModal({
{showPinnedControls && (
<div className="planned-day-modal__advanced" data-testid="advanced-section">
<div className="field">
<label htmlFor="day-type" className="field__label">
{t('plannedDayModal.leaveTypeLabel')}
</label>
<FormField id="day-type" label={t('plannedDayModal.leaveTypeLabel')}>
<select
id="day-type"
value={dayType}
onChange={(e) => {
const value = e.target.value
@@ -404,7 +394,7 @@ export function PlannedDayModal({
<option value="sjukpenning">{t('plannedDayModal.sjukpenningOption')}</option>
<option value="lagsta">{t('plannedDayModal.lagstaOption')}</option>
</select>
</div>
</FormField>
{freeDaysWillStayOff && (
<p className="planned-day-modal__hint" data-testid="free-days-stay-off-notice">
@@ -419,12 +409,8 @@ export function PlannedDayModal({
)}
{hasMultipleParents && (
<div className="field">
<label htmlFor="parent-select" className="field__label">
{t('plannedDayModal.parentLabel')}
</label>
<FormField id="parent-select" label={t('plannedDayModal.parentLabel')}>
<select
id="parent-select"
value={selectedParentId}
onChange={(e) => handleAdvancedParentChange(e.target.value)}
className="field__control"
@@ -437,7 +423,7 @@ export function PlannedDayModal({
</option>
))}
</select>
</div>
</FormField>
)}
{dayType !== 'auto' && (
@@ -2,6 +2,7 @@ import { ReactNode } from 'react'
import type { ScraperStatus } from '@/hooks/useScraperFetch'
import { useTranslation, type TranslationKey } from '@/i18n'
import { Icon } from '@/components/common/Icon'
import { Alert } from '@/components/common/Alert'
import './qrcode-modal.css'
// Every `ScraperStatus` maps to a translated `balance.status.*` label — the
@@ -113,9 +114,9 @@ export function QRCodeModal({
)}
{error && (
<div className="alert alert--error" role="alert">
<Alert variant="error">
<strong>{t('qrModal.errorLabel')}</strong> {error}
</div>
</Alert>
)}
</div>
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import * as api from '@/services/api'
import { useTranslation } from '@/i18n'
import type { Invitation } from '@/types'
import { Alert } from '@/components/common/Alert'
import './partner-invite-panel.css'
export interface PartnerInvitePanelProps {
@@ -118,11 +119,7 @@ export function PartnerInvitePanel({ workspaceId }: PartnerInvitePanelProps) {
{isCreating ? t('invite.generatingButton') : t('invite.generateButton')}
</button>
{createError && (
<p role="alert" className="partner-invite-panel__error">
{createError}
</p>
)}
{createError && <Alert variant="error">{createError}</Alert>}
{inviteLink && (
<div className="partner-invite-panel__link-box" data-testid="invite-link-box">
@@ -157,9 +154,7 @@ export function PartnerInvitePanel({ workspaceId }: PartnerInvitePanelProps) {
{isListLoading ? (
<p>{t('common.loading')}</p>
) : listError ? (
<p role="alert" className="partner-invite-panel__error">
{listError}
</p>
<Alert variant="error">{listError}</Alert>
) : pending.length === 0 ? (
<p className="partner-invite-panel__empty">{t('invite.noPending')}</p>
) : (
@@ -183,11 +178,7 @@ export function PartnerInvitePanel({ workspaceId }: PartnerInvitePanelProps) {
</ul>
)}
{revokeError && (
<p role="alert" className="partner-invite-panel__error">
{revokeError}
</p>
)}
{revokeError && <Alert variant="error">{revokeError}</Alert>}
</div>
)
}
@@ -75,9 +75,3 @@
font-size: var(--fs-sm);
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 { useTranslation } from '@/i18n'
import { LanguageToggle } from '@/components/layout/LanguageToggle'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import './auth.css'
export function Auth() {
@@ -77,25 +79,17 @@ export function Auth() {
</div>
<form className="auth-page__form" onSubmit={(e) => void handleSubmit(e)}>
{isRegister && (
<div className="field">
<label htmlFor="auth-name" className="field__label">
{t('auth.nameLabel')}
</label>
<FormField id="auth-name" label={t('auth.nameLabel')}>
<input
id="auth-name"
className="field__control"
placeholder={t('auth.namePlaceholder')}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
</FormField>
)}
<div className="field">
<label htmlFor="auth-email" className="field__label">
{t('auth.emailLabel')}
</label>
<FormField id="auth-email" label={t('auth.emailLabel')}>
<input
id="auth-email"
type="email"
data-testid="auth-email"
className="field__control"
@@ -103,13 +97,9 @@ export function Auth() {
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="auth-password" className="field__label">
{t('auth.passwordLabel')}
</label>
</FormField>
<FormField id="auth-password" label={t('auth.passwordLabel')}>
<input
id="auth-password"
type="password"
data-testid="auth-password"
className="field__control"
@@ -117,12 +107,8 @@ export function Auth() {
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && (
<p role="alert" className="auth-page__error">
{error}
</p>
)}
</FormField>
{error && <Alert variant="error">{error}</Alert>}
<button type="submit" className="btn btn--primary btn--block" data-testid="auth-submit">
{isRegister ? t('auth.registerButton') : t('auth.loginButton')}
</button>
+5 -4
View File
@@ -29,6 +29,7 @@ import { VacationRescueTip } from '@/components/dashboard/VacationRescueTip'
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
import { TipCard } from '@/components/inputs/TipCard'
import { Onboarding } from '@/pages/Onboarding'
import { Alert } from '@/components/common/Alert'
import type { RescueDay, Tip } from '@/types'
/** Key matching `rescueDayGroups`' own `Map` key below — shared so a
@@ -332,9 +333,9 @@ export function Dashboard(): ReactNode {
<Layout>
<div data-testid="dashboard" className="dashboard">
{analyticsError && (
<div className="alert alert--error" data-testid="error-banner" role="alert">
<Alert variant="error" testId="error-banner">
{analyticsError}
</div>
</Alert>
)}
{analytics && selectedChild && (
@@ -371,9 +372,9 @@ export function Dashboard(): ReactNode {
<h2>{t('dashboard.leaveOverviewTitle')}</h2>
{age4Bars.length > 0 && <Age4Bars bars={age4Bars} />}
{proposalError && (
<div className="alert alert--error" role="alert" data-testid="dashboard-proposal-error">
<Alert variant="error" testId="dashboard-proposal-error">
{proposalError}
</div>
</Alert>
)}
<YearRows
parents={yearRowParents}
+11 -25
View File
@@ -5,6 +5,8 @@ import { useWorkspace } from '@/context/WorkspaceContext'
import { redeemInvitation } from '@/services/api'
import { useTranslation, type TranslationKey } from '@/i18n'
import { LanguageToggle } from '@/components/layout/LanguageToggle'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import './join.css'
type Phase = 'checking' | 'form' | 'redeeming' | 'error' | 'done'
@@ -134,9 +136,9 @@ export function Join() {
{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')}
</p>
</Alert>
{isAuthenticated && (
<button
type="button"
@@ -173,25 +175,17 @@ export function Join() {
</div>
<form className="join-page__form" onSubmit={(e) => void handleSubmit(e)}>
{mode === 'register' && (
<div className="field">
<label htmlFor="join-name" className="field__label">
{t('invite.joinNameLabel')}
</label>
<FormField id="join-name" label={t('invite.joinNameLabel')}>
<input
id="join-name"
className="field__control"
placeholder={t('invite.joinNamePlaceholder')}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
</FormField>
)}
<div className="field">
<label htmlFor="join-email" className="field__label">
{t('invite.joinEmailLabel')}
</label>
<FormField id="join-email" label={t('invite.joinEmailLabel')}>
<input
id="join-email"
type="email"
data-testid="join-email"
className="field__control"
@@ -199,13 +193,9 @@ export function Join() {
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="join-password" className="field__label">
{t('invite.joinPasswordLabel')}
</label>
</FormField>
<FormField id="join-password" label={t('invite.joinPasswordLabel')}>
<input
id="join-password"
type="password"
data-testid="join-password"
className="field__control"
@@ -213,12 +203,8 @@ export function Join() {
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{errorKey && (
<p role="alert" className="join-page__error">
{t(errorKey)}
</p>
)}
</FormField>
{errorKey && <Alert variant="error">{t(errorKey)}</Alert>}
<button
type="submit"
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 { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
import { BalanceFlow } from '@/components/balance/BalanceFlow'
import { FormField } from '@/components/common/FormField'
import { Alert } from '@/components/common/Alert'
import './onboarding.css'
type Step = 'child' | 'family' | 'balances'
@@ -171,12 +173,13 @@ export function Onboarding(): ReactNode {
`.field__error`, no localized message), so this form owns
validation itself via `handleCreateChild`'s field checks. */}
<form onSubmit={(e) => void handleCreateChild(e)} noValidate>
<div className="field form-group">
<label htmlFor="onboarding-child-name" className="field__label">
{t('onboarding.childNameLabel')}
</label>
<FormField
id="onboarding-child-name"
label={t('onboarding.childNameLabel')}
error={childNameError}
className="form-group"
>
<input
id="onboarding-child-name"
type="text"
placeholder={t('onboarding.namePlaceholder')}
value={childName}
@@ -184,15 +187,8 @@ export function Onboarding(): ReactNode {
disabled={isCreatingChild}
className="field__control"
required
aria-invalid={childNameError ? 'true' : undefined}
aria-describedby={childNameError ? 'onboarding-child-name-error' : undefined}
/>
{childNameError && (
<p id="onboarding-child-name-error" className="field__error">
{childNameError}
</p>
)}
</div>
</FormField>
<div className="form-group">
<ChildBirthdayInput
birthDate={childBirthDate}
@@ -201,11 +197,7 @@ export function Onboarding(): ReactNode {
error={childBirthDateError}
/>
</div>
{childError && (
<div role="alert" className="alert alert--error">
{childError}
</div>
)}
{childError && <Alert variant="error">{childError}</Alert>}
<div className="actions">
<button type="submit" className="btn btn--primary" disabled={isCreatingChild}>
{isCreatingChild ? t('onboarding.addingChild') : t('onboarding.addChildButton')}
@@ -273,11 +265,7 @@ export function Onboarding(): ReactNode {
</div>
</div>
{familyError && (
<div role="alert" className="alert alert--error">
{familyError}
</div>
)}
{familyError && <Alert variant="error">{familyError}</Alert>}
<div className="actions">
<button
@@ -306,11 +294,7 @@ export function Onboarding(): ReactNode {
onSaved={() => void refreshMembers()}
/>
{finishError && (
<div role="alert" className="alert alert--error">
{finishError}
</div>
)}
{finishError && <Alert variant="error">{finishError}</Alert>}
<div className="actions onboarding__footer">
<button
+5 -12
View File
@@ -23,6 +23,7 @@ import { VacationDaysTable, type VacationDaysTableRow } from '@/components/vacat
import { CalendarExceptions } from '@/components/calendar/CalendarExceptions'
import { BalanceFlow } from '@/components/balance/BalanceFlow'
import { PartnerInvitePanel } from '@/components/settings/PartnerInvitePanel'
import { Alert } from '@/components/common/Alert'
import { orderSelfFirst } from '@/utils/members'
import type { VacationDays } from '@/types'
import './settings.css'
@@ -647,13 +648,9 @@ export function Settings(): ReactNode {
<h1>{t('settings.title')}</h1>
{saveMessage && (
<div
className={`alert alert--${saveMessage.type}`}
data-testid="save-message"
role="alert"
>
<Alert variant={saveMessage.type} testId="save-message">
{saveMessage.text}
</div>
</Alert>
)}
<section className="settings-panel">
@@ -815,13 +812,9 @@ export function Settings(): ReactNode {
</>
)}
{memberActionError?.uuid === member.uuid && (
<p
role="alert"
className="alert alert--error"
data-testid={`member-action-error-${member.uuid}`}
>
<Alert variant="error" testId={`member-action-error-${member.uuid}`}>
{memberActionError.text}
</p>
</Alert>
)}
</li>
)
-6
View File
@@ -58,9 +58,3 @@
.auth-page__form button[type='submit'] {
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'] {
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
*
* `ChildBirthdayInput` now builds its fields from `.field`/`.field__label`/
* `.field__control` (primitives.css) directly rather than its own
* `.child-birthday-input__*` set — that set is gone from here accordingly. */
.vacation-days-input,
.planning-goal-selector,
.parent-profile-input {
* `ChildBirthdayInput`, `VacationDaysInput` and `ParentProfileInput` all
* build their fields from `.field`/`.field__label`/`.field__control`/
* `.field__helper` (primitives.css) directly now, rather than their own
* bespoke `*-input__label`/`__field`/`__range`/`__help` sets — those are
* gone from here accordingly. What's left below are pure width modifiers
* (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);
}
.vacation-days-input__label,
.planning-goal-selector__label,
.parent-profile-input__label {
.planning-goal-selector__label {
display: block;
margin-bottom: var(--sp-2);
font-weight: 500;
@@ -787,16 +789,17 @@ legend {
font-size: var(--fs-base);
}
.vacation-days-input__field,
.planning-goal-selector__select,
.parent-profile-input__field {
.planning-goal-selector__select {
width: 100%;
max-width: 400px;
}
.vacation-days-input__range,
.parent-profile-input__help {
font-size: var(--fs-sm);
color: var(--c-text-muted);
margin-top: var(--sp-2);
.vacation-days-input,
.parent-profile-input {
margin-bottom: var(--sp-5);
}
.vacation-days-input__field,
.parent-profile-input__field {
max-width: 400px;
}