` copies scattered across
+ * modals/pages. */
+export function Alert({ variant, children, className, testId }: AlertProps): ReactNode {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/frontend/src/components/common/FormField.test.tsx b/frontend/src/components/common/FormField.test.tsx
new file mode 100644
index 0000000..a9c21a1
--- /dev/null
+++ b/frontend/src/components/common/FormField.test.tsx
@@ -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(
+
+
+ ,
+ )
+ 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(
+
+
+ ,
+ )
+ 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(
+
+
+ ,
+ )
+ 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(
+
+
+ ,
+ )
+ 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(
+
+
+ ,
+ )
+ 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(
+
+
+ ,
+ )
+ const input = screen.getByTestId('qty-input') as HTMLInputElement
+ expect(input.value).toBe('5')
+ expect(input).toBeDisabled()
+ })
+})
diff --git a/frontend/src/components/common/FormField.tsx b/frontend/src/components/common/FormField.tsx
new file mode 100644
index 0000000..ff7dcbf
--- /dev/null
+++ b/frontend/src/components/common/FormField.tsx
@@ -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
+}
+
+/**
+ * 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 (
+
+
+ {control}
+ {error && (
+
+ {error}
+
+ )}
+ {helperText && (
+
+ {helperText}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/inputs/ParentProfileInput.tsx b/frontend/src/components/inputs/ParentProfileInput.tsx
index 9eb6a46..2ec0b2a 100644
--- a/frontend/src/components/inputs/ParentProfileInput.tsx
+++ b/frontend/src/components/inputs/ParentProfileInput.tsx
@@ -19,8 +19,8 @@ export function ParentProfileInput({
const { t } = useTranslation()
return (
-
-