From 309e91c6348f468ed5eedcf1657920fa31a3d4d0 Mon Sep 17 00:00:00 2001 From: Anneke Sinnema Date: Wed, 7 Oct 2026 11:48:51 +0200 Subject: [PATCH] feat(#144): make form fields required by default Signed-off-by: Anneke Sinnema --- .changeset/required-form-fields.md | 8 ++ packages/angular/.storybook/preview.ts | 1 + .../lib/ui/checkbox/src/lib/hlm-checkbox.ts | 17 +++++ .../lib/ui/field/src/lib/hlm-field-label.ts | 18 ++++- .../ui/field/src/lib/hlm-field-required.ts | 46 ++++++++++++ .../src/lib/ui/field/src/lib/hlm-field.css | 9 +++ .../lib/ui/field/src/lib/hlm-field.stories.ts | 61 +++++++++++++++ .../src/lib/ui/field/src/lib/hlm-field.ts | 19 ++++- .../src/lib/hlm-input-group-input.ts | 2 +- .../src/lib/hlm-input-group-textarea.ts | 2 +- .../lib/ui/input-otp/src/lib/hlm-input-otp.ts | 14 ++++ .../lib/ui/input/src/lib/hlm-input.stories.ts | 38 ++++++++-- .../src/lib/ui/input/src/lib/hlm-input.ts | 15 +++- .../src/lib/hlm-native-select.ts | 14 ++++ .../ui/radio-group/src/lib/hlm-radio-group.ts | 4 + .../ui/select/src/lib/hlm-select-trigger.ts | 14 ++++ .../src/lib/ui/switch/src/lib/hlm-switch.ts | 17 +++++ .../textarea/src/lib/hlm-textarea.stories.ts | 26 +++++-- .../lib/ui/textarea/src/lib/hlm-textarea.ts | 16 +++- packages/contracts/src/field.ts | 10 ++- packages/contracts/src/index.ts | 2 +- packages/react/.storybook/preview.ts | 1 + .../src/components/ui/checkbox/checkbox.tsx | 4 + .../src/components/ui/field/field-context.ts | 19 +++++ .../src/components/ui/field/field.module.css | 8 ++ .../src/components/ui/field/field.stories.tsx | 59 +++++++++++++++ .../react/src/components/ui/field/field.tsx | 56 +++++++++++--- .../src/components/ui/input-otp/input-otp.tsx | 4 + .../src/components/ui/input/input.stories.tsx | 28 ++++++- .../react/src/components/ui/input/input.tsx | 4 + .../ui/native-select/native-select.tsx | 11 ++- .../components/ui/radio-group/radio-group.tsx | 4 + .../react/src/components/ui/select/select.tsx | 4 + .../react/src/components/ui/switch/switch.tsx | 4 + .../ui/textarea/textarea.stories.tsx | 28 ++++++- .../src/components/ui/textarea/textarea.tsx | 12 ++- .../04-validatie-van-formulieren.mdx | 2 +- .../05-verplichte-en-optionele-velden.mdx | 75 +++++++++++++++++++ ...deo-en-audio.mdx => 06-video-en-audio.mdx} | 0 ...groting.mdx => 07-touch-en-vergroting.mdx} | 0 .../01-overzicht.mdx | 0 ...gankelijkheid-in-andere-design-systems.mdx | 0 .../04-je-werk-testen/02-handmatig-testen.mdx | 4 +- .../30-validatie-van-formulieren.mdx | 5 +- 44 files changed, 639 insertions(+), 46 deletions(-) create mode 100644 .changeset/required-form-fields.md create mode 100644 packages/angular/src/lib/ui/field/src/lib/hlm-field-required.ts create mode 100644 packages/react/src/components/ui/field/field-context.ts create mode 100644 packages/storybook-config/docs/curve/02-voor-designers/05-verplichte-en-optionele-velden.mdx rename packages/storybook-config/docs/curve/02-voor-designers/{05-video-en-audio.mdx => 06-video-en-audio.mdx} (100%) rename packages/storybook-config/docs/curve/02-voor-designers/{06-touch-en-vergroting.mdx => 07-touch-en-vergroting.mdx} (100%) rename packages/storybook-config/docs/curve/02-voor-designers/{07-meer-leren => 08-meer-leren}/01-overzicht.mdx (100%) rename packages/storybook-config/docs/curve/02-voor-designers/{07-meer-leren => 08-meer-leren}/02-toegankelijkheid-in-andere-design-systems.mdx (100%) diff --git a/.changeset/required-form-fields.md b/.changeset/required-form-fields.md new file mode 100644 index 00000000..36da7823 --- /dev/null +++ b/.changeset/required-form-fields.md @@ -0,0 +1,8 @@ +--- +'@surfnet/curve-react': minor +'@surfnet/curve-angular': minor +--- + +Form fields are now required by default. Input, Textarea, NativeSelect, Select, Checkbox, Switch, RadioGroup and InputOTP get `aria-required="true"` unless they sit in an optional field, so screen readers announce them as required. Mark the exceptions with `` (React) or `
` (Angular): the label then shows an "(optioneel)" suffix (override with `optionalText`) and the control drops `aria-required`. An explicit `aria-required` on a control still wins. This only affects what is announced, not validation. + +**Action needed:** add `optional` to the Fields in your forms that are not required, or they will be announced as required. diff --git a/packages/angular/.storybook/preview.ts b/packages/angular/.storybook/preview.ts index 5ccf13c9..e984f6bb 100644 --- a/packages/angular/.storybook/preview.ts +++ b/packages/angular/.storybook/preview.ts @@ -49,6 +49,7 @@ export default { 'Toegankelijk ontwerpen', 'Microcopy', 'Validatie van formulieren', + 'Verplichte en optionele velden', 'Touch en vergroting', 'Meer leren', ['Overzicht', 'Toegankelijkheid in andere design systems'], diff --git a/packages/angular/src/lib/ui/checkbox/src/lib/hlm-checkbox.ts b/packages/angular/src/lib/ui/checkbox/src/lib/hlm-checkbox.ts index 670ed721..50d6ebf9 100644 --- a/packages/angular/src/lib/ui/checkbox/src/lib/hlm-checkbox.ts +++ b/packages/angular/src/lib/ui/checkbox/src/lib/hlm-checkbox.ts @@ -20,6 +20,11 @@ import type { ChangeFn, TouchFn } from '@spartan-ng/brain/forms'; import { HlmIcon } from '../../../icon/src'; import { hlm } from '../../../utils/src'; import type { ClassValue } from 'clsx'; +import { + ariaRequiredAttribute, + injectAriaRequired, + syncAriaRequired, +} from '../../../field/src/lib/hlm-field-required'; export const HLM_CHECKBOX_VALUE_ACCESSOR = { provide: NG_VALUE_ACCESSOR, @@ -40,6 +45,7 @@ export const HLM_CHECKBOX_VALUE_ACCESSOR = { 'data-slot': 'checkbox', '[attr.aria-label]': 'null', '[attr.aria-labelledby]': 'null', + '[attr.aria-required]': 'null', '[attr.data-disabled]': '_disabled() ? "" : null', }, template: ` @@ -117,6 +123,17 @@ export class HlmCheckbox implements ControlValueAccessor { protected readonly _disabled = linkedSignal(this.disabled); + /** Explicit `aria-required`; when absent, the surrounding field decides (required by default). */ + public readonly ariaRequiredOverride = input(undefined, { + alias: 'aria-required', + transform: ariaRequiredAttribute, + }); + + constructor() { + // CURVE: required by default unless inside an optional field (issue #144). + syncAriaRequired('button[role="checkbox"]', injectAriaRequired(this.ariaRequiredOverride)); + } + private readonly _brnCheckbox = viewChild.required(BrnCheckbox); private readonly _spartanInvalid = computed( diff --git a/packages/angular/src/lib/ui/field/src/lib/hlm-field-label.ts b/packages/angular/src/lib/ui/field/src/lib/hlm-field-label.ts index 29d0560c..fb7e6488 100644 --- a/packages/angular/src/lib/ui/field/src/lib/hlm-field-label.ts +++ b/packages/angular/src/lib/ui/field/src/lib/hlm-field-label.ts @@ -1,13 +1,27 @@ -import { Directive } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; import { HlmLabel } from '../../../label/src'; import { classes } from '../../../utils/src'; +import { HlmField } from './hlm-field'; -@Directive({ +@Component({ selector: '[hlmFieldLabel],hlm-field-label', hostDirectives: [HlmLabel], host: { 'data-slot': 'field-label' }, + changeDetection: ChangeDetectionStrategy.OnPush, + // CURVE: "(optioneel)" suffix when the surrounding field is optional (issue #144). + template: ` + + @if (_field?.optional()) { + {{ optionalText() }} + } + `, }) export class HlmFieldLabel { + protected readonly _field = inject(HlmField, { optional: true }); + + /** Suffix shown when the surrounding field is `optional`. */ + public readonly optionalText = input('(optioneel)'); + constructor() { // Adds to hlmLabel's `curve-label`; `group/field-label` and `peer/field-label` stay as hooks. classes(() => 'curve-field-label group/field-label peer/field-label'); diff --git a/packages/angular/src/lib/ui/field/src/lib/hlm-field-required.ts b/packages/angular/src/lib/ui/field/src/lib/hlm-field-required.ts new file mode 100644 index 00000000..a81794c5 --- /dev/null +++ b/packages/angular/src/lib/ui/field/src/lib/hlm-field-required.ts @@ -0,0 +1,46 @@ +import { + afterRenderEffect, + computed, + ElementRef, + inject, + Renderer2, + signal, + type Signal, +} from '@angular/core'; +import { HlmField } from './hlm-field'; + +// CURVE: fields are required by default (issue #144). Internal: not re-exported from the +// field barrel, so `hlmField optional` stays the only public way to mark a field optional. + +/** + * `aria-required` for a form control: an explicit value wins, otherwise the surrounding + * `hlmField` decides (required unless `optional`). Returns `'true'` or `null` for `[attr.aria-required]`. + */ +export function injectAriaRequired( + own: Signal = signal(undefined), +): Signal<'true' | null> { + const field = inject(HlmField, { optional: true }); + return computed(() => ((own() ?? !field?.optional()) ? 'true' : null)); +} + +/** Writes `aria-required` onto an element a Brain component renders inside this host. */ +export function syncAriaRequired(selector: string, value: Signal<'true' | null>): void { + const host = inject>(ElementRef); + const renderer = inject(Renderer2); + afterRenderEffect(() => { + const required = value(); + const el = host.nativeElement.querySelector(selector); + if (!el) return; + if (required) { + renderer.setAttribute(el, 'aria-required', required); + } else { + renderer.removeAttribute(el, 'aria-required'); + } + }); +} + +/** Parses an `aria-required` input; an absent value stays `undefined` so the Field default applies. */ +export function ariaRequiredAttribute(value: unknown): boolean | undefined { + if (value === undefined || value === null) return undefined; + return value !== false && value !== 'false'; +} diff --git a/packages/angular/src/lib/ui/field/src/lib/hlm-field.css b/packages/angular/src/lib/ui/field/src/lib/hlm-field.css index b9a4abfb..5baf103e 100644 --- a/packages/angular/src/lib/ui/field/src/lib/hlm-field.css +++ b/packages/angular/src/lib/ui/field/src/lib/hlm-field.css @@ -100,6 +100,15 @@ line-height: 1.375; } +/* "(optioneel)" suffix. Label gap is 8px; issue #144 asks for 4px to the label text. */ +.curve-field-optional { + margin-inline-start: calc(var(--spacing, 0.25rem) * -1); + font-size: 0.875rem; + line-height: 1; + font-weight: 400; + color: var(--muted-foreground); +} + .curve-field-label:has(:is([data-state='checked'], [data-checked]:not([data-checked='false']))) { border-color: color-mix(in oklab, var(--primary) 30%, transparent); background-color: color-mix(in oklab, var(--primary) 5%, transparent); diff --git a/packages/angular/src/lib/ui/field/src/lib/hlm-field.stories.ts b/packages/angular/src/lib/ui/field/src/lib/hlm-field.stories.ts index 33e700ff..25a911f4 100644 --- a/packages/angular/src/lib/ui/field/src/lib/hlm-field.stories.ts +++ b/packages/angular/src/lib/ui/field/src/lib/hlm-field.stories.ts @@ -37,9 +37,19 @@ const meta: Meta = { defaultValue: { summary: fieldContract.defaults.orientations }, }, }, + optional: { + control: 'boolean', + description: fieldContract.props.necessities + .map((necessity) => `\`${necessity}\` — ${fieldContract.docs.necessities[necessity]}`) + .join('\n\n'), + table: { + defaultValue: { summary: 'false' }, + }, + }, }, args: { orientation: fieldContract.defaults.orientations, + optional: false, }, }; @@ -119,3 +129,54 @@ export const Group: Story = { `, }), }; + +/** + * Fields are required by default. Mark the exception with `optional`: the label gets an + * "(optioneel)" suffix and the control loses `aria-required`. + */ +export const Optional: Story = { + render: () => ({ + template: ` +
+
+ + +
+
+ `, + }), +}; + +/** + * Required and optional fields side by side. Only the optional ones are marked; `optionalText` + * swaps the suffix for an English form. A consent checkbox stays required, a newsletter one is optional. + */ +export const MixedForm: Story = { + render: () => ({ + template: ` +
+
+ + +
+
+ + +
+
+ + +

Only used if we can't reach you by email.

+
+
+ + +
+
+ + +
+
+ `, + }), +}; diff --git a/packages/angular/src/lib/ui/field/src/lib/hlm-field.ts b/packages/angular/src/lib/ui/field/src/lib/hlm-field.ts index d186b8a8..5f98fc27 100644 --- a/packages/angular/src/lib/ui/field/src/lib/hlm-field.ts +++ b/packages/angular/src/lib/ui/field/src/lib/hlm-field.ts @@ -1,7 +1,8 @@ -import { Directive, input } from '@angular/core'; +import { booleanAttribute, Directive, input } from '@angular/core'; +import type { BooleanInput } from '@angular/cdk/coercion'; import { BrnField } from '@spartan-ng/brain/field'; import { classes } from '../../../utils/src'; -import type { FieldOrientationName } from '@surfnet/curve-contracts'; +import type { FieldNecessityName, FieldOrientationName } from '@surfnet/curve-contracts'; const fieldOrientationClasses = { vertical: 'curve-field--vertical', @@ -9,6 +10,13 @@ const fieldOrientationClasses = { responsive: 'curve-field--responsive', } satisfies Record; +// CURVE: fields are required by default (issue #144). `optional` drives both the label's +// "(optioneel)" suffix and the controls' `aria-required`. +const fieldNecessityAttrs = { + required: null, + optional: 'true', +} satisfies Record; + export type FieldVariants = { orientation?: FieldOrientationName | null }; @Directive({ @@ -19,11 +27,18 @@ export type FieldVariants = { orientation?: FieldOrientationName | null }; host: { 'data-slot': 'field', '[attr.data-orientation]': 'orientation()', + '[attr.data-optional]': '_optionalAttr()', }, }) export class HlmField { public readonly orientation = input('vertical'); + /** Mark this field optional. Fields are required by default. */ + public readonly optional = input(false, { transform: booleanAttribute }); + + protected readonly _optionalAttr = () => + fieldNecessityAttrs[this.optional() ? 'optional' : 'required']; + constructor() { // `group/field` stays as a hook for consumers' Tailwind. classes(() => [ diff --git a/packages/angular/src/lib/ui/input-group/src/lib/hlm-input-group-input.ts b/packages/angular/src/lib/ui/input-group/src/lib/hlm-input-group-input.ts index 37bcd859..2b51b79b 100644 --- a/packages/angular/src/lib/ui/input-group/src/lib/hlm-input-group-input.ts +++ b/packages/angular/src/lib/ui/input-group/src/lib/hlm-input-group-input.ts @@ -4,7 +4,7 @@ import { classes } from '../../../utils/src'; @Directive({ selector: 'input[hlmInputGroupInput]', - hostDirectives: [HlmInput], + hostDirectives: [{ directive: HlmInput, inputs: ['aria-required'] }], host: { 'data-slot': 'input-group-control' }, }) export class HlmInputGroupInput { diff --git a/packages/angular/src/lib/ui/input-group/src/lib/hlm-input-group-textarea.ts b/packages/angular/src/lib/ui/input-group/src/lib/hlm-input-group-textarea.ts index f5764886..8a877df7 100644 --- a/packages/angular/src/lib/ui/input-group/src/lib/hlm-input-group-textarea.ts +++ b/packages/angular/src/lib/ui/input-group/src/lib/hlm-input-group-textarea.ts @@ -4,7 +4,7 @@ import { classes } from '../../../utils/src'; @Directive({ selector: 'textarea[hlmInputGroupTextarea]', - hostDirectives: [HlmTextarea], + hostDirectives: [{ directive: HlmTextarea, inputs: ['aria-required'] }], host: { 'data-slot': 'input-group-control' }, }) export class HlmInputGroupTextarea { diff --git a/packages/angular/src/lib/ui/input-otp/src/lib/hlm-input-otp.ts b/packages/angular/src/lib/ui/input-otp/src/lib/hlm-input-otp.ts index 2283a5c0..fb84872a 100644 --- a/packages/angular/src/lib/ui/input-otp/src/lib/hlm-input-otp.ts +++ b/packages/angular/src/lib/ui/input-otp/src/lib/hlm-input-otp.ts @@ -1,6 +1,11 @@ import { afterRenderEffect, Directive, ElementRef, inject, input, Renderer2 } from '@angular/core'; import { BrnInputOtp } from '@spartan-ng/brain/input-otp'; import { classes } from '../../../utils/src'; +import { + ariaRequiredAttribute, + injectAriaRequired, + syncAriaRequired, +} from '../../../field/src/lib/hlm-field-required'; @Directive({ selector: 'brn-input-otp[hlmInputOtp], brn-input-otp[hlm]', @@ -8,6 +13,7 @@ import { classes } from '../../../utils/src'; 'data-slot': 'input-otp', // Consume aria-label on the host so it can be forwarded to the inner . '[attr.aria-label]': 'null', + '[attr.aria-required]': 'null', }, }) export class HlmInputOtp { @@ -23,8 +29,16 @@ export class HlmInputOtp { /** Screen-reader announcement when every slot is filled, including after paste. */ public readonly completeAnnouncement = input('Verification code complete'); + /** Explicit `aria-required`; when absent, the surrounding field decides (required by default). */ + public readonly ariaRequiredOverride = input(undefined, { + alias: 'aria-required', + transform: ariaRequiredAttribute, + }); + constructor() { classes(() => 'curve-input-otp'); + // CURVE: required by default unless inside an optional field (issue #144). + syncAriaRequired('input[data-slot="input-otp"]', injectAriaRequired(this.ariaRequiredOverride)); afterRenderEffect(() => { const label = this.ariaLabel(); diff --git a/packages/angular/src/lib/ui/input/src/lib/hlm-input.stories.ts b/packages/angular/src/lib/ui/input/src/lib/hlm-input.stories.ts index 9aa5b416..ebc2b609 100644 --- a/packages/angular/src/lib/ui/input/src/lib/hlm-input.stories.ts +++ b/packages/angular/src/lib/ui/input/src/lib/hlm-input.stories.ts @@ -8,16 +8,32 @@ import { import { argsToTemplate, type Meta, moduleMetadata, type StoryObj } from '@storybook/angular'; import { inputContract } from '@surfnet/curve-contracts'; import { HlmInput, HlmInputImports } from '..'; -import { HlmButton, HlmLabel } from '../../../../../public-api'; +import { HlmButton, HlmFieldImports, HlmLabel } from '../../../../../public-api'; -const meta: Meta = { +/** Native `` attributes the stories expose as controls, on top of HlmInput's own inputs. */ +type InputArgs = HlmInput & { + type: string; + placeholder: string; + disabled: boolean; + /** Story-only: drives the wrapping `hlmField optional`. */ + optional: boolean; +}; + +const meta: Meta = { title: 'Components/Input', // Unverified: shadcn/ui WCAG 2.2 AA audit (thefrontkit, 2026). tags: ['a11y-minor'], component: HlmInput, decorators: [ moduleMetadata({ - imports: [HlmInputImports, HlmLabel, HlmButton, FormsModule, ReactiveFormsModule], + imports: [ + HlmInputImports, + HlmFieldImports, + HlmLabel, + HlmButton, + FormsModule, + ReactiveFormsModule, + ], }), ], parameters: { @@ -39,23 +55,33 @@ const meta: Meta = { }, placeholder: { control: 'text' }, disabled: { control: 'boolean' }, + optional: { + control: 'boolean', + description: + 'Fields are required by default (`aria-required="true"`). Story control for the wrapping `hlmField optional`: the label gets "(optioneel)" and the control drops `aria-required`.', + table: { defaultValue: { summary: 'false' } }, + }, }, args: { type: 'text', placeholder: 'Type something…', disabled: false, + optional: false, }, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; -/** The default input — tweak it via the controls. */ +/** The default input with its label — tweak it via the controls; toggle `optional` to see the label suffix. */ export const Default: Story = { render: ({ ...args }) => ({ props: args, template: ` - +
+ + +
`, }), }; diff --git a/packages/angular/src/lib/ui/input/src/lib/hlm-input.ts b/packages/angular/src/lib/ui/input/src/lib/hlm-input.ts index 97c6ca3b..d6b8bf9f 100644 --- a/packages/angular/src/lib/ui/input/src/lib/hlm-input.ts +++ b/packages/angular/src/lib/ui/input/src/lib/hlm-input.ts @@ -1,4 +1,8 @@ -import { Directive } from '@angular/core'; +import { Directive, input } from '@angular/core'; +import { + ariaRequiredAttribute, + injectAriaRequired, +} from '../../../field/src/lib/hlm-field-required'; import { BrnFieldControlDescribedBy } from '@spartan-ng/brain/field'; import { BrnInput } from '@spartan-ng/brain/input'; import { classes } from '../../../utils/src'; @@ -9,8 +13,17 @@ import { classes } from '../../../utils/src'; { directive: BrnInput, inputs: ['id', 'forceInvalid'] }, BrnFieldControlDescribedBy, ], + host: { '[attr.aria-required]': '_ariaRequired()' }, }) export class HlmInput { + /** Explicit `aria-required`; when absent, the surrounding field decides (required by default). */ + public readonly ariaRequiredOverride = input(undefined, { + alias: 'aria-required', + transform: ariaRequiredAttribute, + }); + + protected readonly _ariaRequired = injectAriaRequired(this.ariaRequiredOverride); + constructor() { classes(() => 'curve-input'); } diff --git a/packages/angular/src/lib/ui/native-select/src/lib/hlm-native-select.ts b/packages/angular/src/lib/ui/native-select/src/lib/hlm-native-select.ts index 6b99d810..231dafdd 100644 --- a/packages/angular/src/lib/ui/native-select/src/lib/hlm-native-select.ts +++ b/packages/angular/src/lib/ui/native-select/src/lib/hlm-native-select.ts @@ -16,6 +16,10 @@ import { phosphorCaretDown } from '@ng-icons/phosphor-icons/regular'; import { BrnFieldControl, provideBrnLabelable } from '@spartan-ng/brain/field'; import { type ChangeFn, type TouchFn } from '@spartan-ng/brain/forms'; import { classes, hlm } from '../../../utils/src'; +import { + ariaRequiredAttribute, + injectAriaRequired, +} from '../../../field/src/lib/hlm-field-required'; import type { ClassValue } from 'clsx'; import type { NativeSelectSizeName } from '@surfnet/curve-contracts'; @@ -38,6 +42,7 @@ export const HLM_NATIVE_SELECT_VALUE_ACCESSOR = { host: { 'data-slot': 'native-select-wrapper', '[attr.data-size]': 'size()', + '[attr.aria-required]': 'null', }, template: ` +
+ + +
`, }), }; diff --git a/packages/angular/src/lib/ui/textarea/src/lib/hlm-textarea.ts b/packages/angular/src/lib/ui/textarea/src/lib/hlm-textarea.ts index 2e0336a0..5fecd57d 100644 --- a/packages/angular/src/lib/ui/textarea/src/lib/hlm-textarea.ts +++ b/packages/angular/src/lib/ui/textarea/src/lib/hlm-textarea.ts @@ -1,4 +1,8 @@ -import { Directive } from '@angular/core'; +import { Directive, input } from '@angular/core'; +import { + ariaRequiredAttribute, + injectAriaRequired, +} from '../../../field/src/lib/hlm-field-required'; import { BrnFieldControlDescribedBy } from '@spartan-ng/brain/field'; import { BrnTextarea } from '@spartan-ng/brain/textarea'; import { classes } from '../../../utils/src'; @@ -9,9 +13,17 @@ import { classes } from '../../../utils/src'; { directive: BrnTextarea, inputs: ['id', 'forceInvalid'] }, BrnFieldControlDescribedBy, ], - host: { 'data-slot': 'textarea' }, + host: { 'data-slot': 'textarea', '[attr.aria-required]': '_ariaRequired()' }, }) export class HlmTextarea { + /** Explicit `aria-required`; when absent, the surrounding field decides (required by default). */ + public readonly ariaRequiredOverride = input(undefined, { + alias: 'aria-required', + transform: ariaRequiredAttribute, + }); + + protected readonly _ariaRequired = injectAriaRequired(this.ariaRequiredOverride); + constructor() { classes(() => 'curve-textarea'); } diff --git a/packages/contracts/src/field.ts b/packages/contracts/src/field.ts index 457f103a..7c9ed27a 100644 --- a/packages/contracts/src/field.ts +++ b/packages/contracts/src/field.ts @@ -3,19 +3,27 @@ import { defineContract } from './define-contract.js'; export const fieldContract = defineContract({ props: { orientations: ['vertical', 'horizontal', 'responsive'], + necessities: ['required', 'optional'], }, defaults: { orientations: 'vertical', + necessities: 'required', }, docs: { description: - 'A form-field wrapper that lays out a label, control, description, and error message.', + 'A form-field wrapper that lays out a label, control, description, and error message. Fields are required by default; mark the exceptions with `optional`.', orientations: { vertical: 'Label stacked above the control — the default.', horizontal: 'Label beside the control — e.g. a checkbox row.', responsive: 'Stacks on narrow containers, switches to horizontal when space allows.', }, + necessities: { + required: 'The default. The control gets `aria-required="true"`; the label shows no marker.', + optional: + 'The label gets an "(optioneel)" suffix and the control no `aria-required`. Validation stays with the app.', + }, }, }); export type FieldOrientationName = (typeof fieldContract.props.orientations)[number]; +export type FieldNecessityName = (typeof fieldContract.props.necessities)[number]; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d001c896..776f5136 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -20,7 +20,7 @@ export { datePickerContract } from './date-picker.js'; export { dialogContract } from './dialog.js'; export { dropdownMenuContract, type DropdownMenuItemVariantName } from './dropdown-menu.js'; export { emptyContract, type EmptyMediaVariantName } from './empty.js'; -export { fieldContract, type FieldOrientationName } from './field.js'; +export { fieldContract, type FieldNecessityName, type FieldOrientationName } from './field.js'; export { hoverCardContract } from './hover-card.js'; export { inputContract } from './input.js'; export { inputGroupContract } from './input-group.js'; diff --git a/packages/react/.storybook/preview.ts b/packages/react/.storybook/preview.ts index 7ed91d21..a28bac83 100644 --- a/packages/react/.storybook/preview.ts +++ b/packages/react/.storybook/preview.ts @@ -49,6 +49,7 @@ export default { 'Toegankelijk ontwerpen', 'Microcopy', 'Validatie van formulieren', + 'Verplichte en optionele velden', 'Touch en vergroting', 'Meer leren', ['Overzicht', 'Toegankelijkheid in andere design systems'], diff --git a/packages/react/src/components/ui/checkbox/checkbox.tsx b/packages/react/src/components/ui/checkbox/checkbox.tsx index 61ea54a0..567b8f8a 100644 --- a/packages/react/src/components/ui/checkbox/checkbox.tsx +++ b/packages/react/src/components/ui/checkbox/checkbox.tsx @@ -4,13 +4,17 @@ import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'; import { CheckIcon } from '@phosphor-icons/react'; import { cn } from '@/lib/utils'; +import { useAriaRequired } from '@/components/ui/field/field-context'; import styles from './checkbox.module.css'; function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { + // CURVE: required by default unless inside (issue #144). + const ariaRequired = useAriaRequired(props['aria-required']); return ( diff --git a/packages/react/src/components/ui/field/field-context.ts b/packages/react/src/components/ui/field/field-context.ts new file mode 100644 index 00000000..ecb3dc6d --- /dev/null +++ b/packages/react/src/components/ui/field/field-context.ts @@ -0,0 +1,19 @@ +'use client'; + +import { createContext, useContext } from 'react'; +import type { FieldNecessityName } from '@surfnet/curve-contracts'; + +// CURVE: fields are required by default (issue #144). Internal: not re-exported from the +// barrel, so `Field optional` stays the only public way to mark a field optional. +export const FieldContext = createContext<{ necessity: FieldNecessityName } | null>(null); + +/** `true` unless inside a ``. Outside a Field: required. */ +export function useFieldRequired() { + return useContext(FieldContext)?.necessity !== 'optional'; +} + +/** `aria-required` for a form control: an explicit value wins, otherwise the Field decides. */ +export function useAriaRequired(own: React.AriaAttributes['aria-required']) { + const required = useFieldRequired(); + return own ?? (required || undefined); +} diff --git a/packages/react/src/components/ui/field/field.module.css b/packages/react/src/components/ui/field/field.module.css index 7c946f25..6ad5db38 100644 --- a/packages/react/src/components/ui/field/field.module.css +++ b/packages/react/src/components/ui/field/field.module.css @@ -105,6 +105,14 @@ .fieldLabel > [data-slot='field'] { padding: calc(var(--spacing, 0.25rem) * 3); } +.optional { + /* Label gap is 8px; issue #144 asks for 4px between label text and the suffix. */ + margin-inline-start: calc(var(--spacing, 0.25rem) * -1); + font-size: 0.875rem; + line-height: 1; + font-weight: 400; + color: var(--muted-foreground); +} .fieldTitle { display: flex; diff --git a/packages/react/src/components/ui/field/field.stories.tsx b/packages/react/src/components/ui/field/field.stories.tsx index 6cbb7f8f..35b6ec91 100644 --- a/packages/react/src/components/ui/field/field.stories.tsx +++ b/packages/react/src/components/ui/field/field.stories.tsx @@ -40,9 +40,19 @@ const meta = { defaultValue: { summary: fieldContract.defaults.orientations }, }, }, + optional: { + control: 'boolean', + description: fieldContract.props.necessities + .map((necessity) => `\`${necessity}\` — ${fieldContract.docs.necessities[necessity]}`) + .join('\n\n'), + table: { + defaultValue: { summary: 'false' }, + }, + }, }, args: { orientation: fieldContract.defaults.orientations, + optional: false, }, } satisfies Meta; @@ -98,3 +108,52 @@ export const Group: Story = { ), }; + +/** + * Fields are required by default. Mark the exception with `optional`: the label gets an + * "(optioneel)" suffix and the control loses `aria-required`. + */ +export const Optional: Story = { + render: () => ( + + Telefoonnummer + + + ), +}; + +/** + * Required and optional fields side by side. Only the optional ones are marked; `optionalText` + * swaps the suffix for an English form. A consent checkbox stays required, a newsletter one is optional. + */ +export const MixedForm: Story = { + render: () => ( + + + Full name + + + + Email + + + + + Phone number + + + Only used if we can't reach you by email. + + + + + Send me the newsletter + + + + + I accept the terms of use + + + ), +}; diff --git a/packages/react/src/components/ui/field/field.tsx b/packages/react/src/components/ui/field/field.tsx index 3c7095f5..c96b85f8 100644 --- a/packages/react/src/components/ui/field/field.tsx +++ b/packages/react/src/components/ui/field/field.tsx @@ -1,12 +1,13 @@ 'use client'; import { useMemo } from 'react'; -import type { FieldOrientationName } from '@surfnet/curve-contracts'; +import type { FieldNecessityName, FieldOrientationName } from '@surfnet/curve-contracts'; import { cn } from '@/lib/utils'; import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; +import { FieldContext, useFieldRequired } from './field-context'; import styles from './field.module.css'; function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) { @@ -32,6 +33,13 @@ function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) { return
; } +// CURVE: `Field optional` drives both the label's "(optioneel)" suffix and the controls' +// `aria-required` (issue #144); see ./field-context. +const fieldNecessityAttrs = { + required: undefined, + optional: 'true', +} satisfies Record; + export type FieldVariantsOptions = { orientation?: FieldOrientationName; className?: string; @@ -44,17 +52,27 @@ function fieldVariants({ className }: FieldVariantsOptions = {}) { function Field({ className, orientation = 'vertical', + optional = false, ...props -}: React.ComponentProps<'div'> & FieldVariantsOptions) { +}: React.ComponentProps<'div'> & + FieldVariantsOptions & { + /** Mark this field optional. Fields are required by default. */ + optional?: boolean; + }) { + const necessity: FieldNecessityName = optional ? 'optional' : 'required'; + const context = useMemo(() => ({ necessity }), [necessity]); // CURVE: a11y — no role="group" (upstream adds it): a single field isn't a group, and inside a // choice-card label it blanks the label's text for axe. Group fields with FieldSet instead. return ( -
+ +
+ ); } @@ -64,8 +82,26 @@ function FieldContent({ className, ...props }: React.ComponentProps<'div'>) { ); } -function FieldLabel({ className, ...props }: React.ComponentProps) { - return