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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user