Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/required-form-fields.md
Original file line number Diff line number Diff line change
@@ -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 `<Field optional>` (React) or `<div hlmField optional>` (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.
1 change: 1 addition & 0 deletions packages/angular/.storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
17 changes: 17 additions & 0 deletions packages/angular/src/lib/ui/checkbox/src/lib/hlm-checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: `
Expand Down Expand Up @@ -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<boolean | undefined, unknown>(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(
Expand Down
18 changes: 16 additions & 2 deletions packages/angular/src/lib/ui/field/src/lib/hlm-field-label.ts
Original file line number Diff line number Diff line change
@@ -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: `
<ng-content />
@if (_field?.optional()) {
<span data-slot="field-optional" class="curve-field-optional">{{ optionalText() }}</span>
}
`,
})
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');
Expand Down
46 changes: 46 additions & 0 deletions packages/angular/src/lib/ui/field/src/lib/hlm-field-required.ts
Original file line number Diff line number Diff line change
@@ -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<boolean | undefined> = 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<HTMLElement>>(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';
}
9 changes: 9 additions & 0 deletions packages/angular/src/lib/ui/field/src/lib/hlm-field.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
61 changes: 61 additions & 0 deletions packages/angular/src/lib/ui/field/src/lib/hlm-field.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,19 @@ const meta: Meta<HlmField> = {
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,
},
};

Expand Down Expand Up @@ -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: `
<div class="w-full max-w-md">
<div hlmField optional class="w-72">
<label hlmFieldLabel for="phone">Telefoonnummer</label>
<input hlmInput id="phone" type="tel" autocomplete="tel" />
</div>
</div>
`,
}),
};

/**
* 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: `
<div hlmFieldGroup class="w-72">
<div hlmField>
<label hlmFieldLabel for="mixed-name">Full name</label>
<input hlmInput id="mixed-name" autocomplete="name" />
</div>
<div hlmField>
<label hlmFieldLabel for="mixed-email">Email</label>
<input hlmInput id="mixed-email" type="email" autocomplete="email" />
</div>
<div hlmField optional>
<label hlmFieldLabel for="mixed-phone" optionalText="(optional)">Phone number</label>
<input hlmInput id="mixed-phone" type="tel" autocomplete="tel" />
<p hlmFieldDescription>Only used if we can't reach you by email.</p>
</div>
<div hlmField orientation="horizontal" optional>
<hlm-checkbox inputId="mixed-newsletter" />
<label hlmFieldLabel for="mixed-newsletter" optionalText="(optional)">Send me the newsletter</label>
</div>
<div hlmField orientation="horizontal">
<hlm-checkbox inputId="mixed-terms" />
<label hlmFieldLabel for="mixed-terms">I accept the terms of use</label>
</div>
</div>
`,
}),
};
19 changes: 17 additions & 2 deletions packages/angular/src/lib/ui/field/src/lib/hlm-field.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
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',
horizontal: 'curve-field--horizontal',
responsive: 'curve-field--responsive',
} satisfies Record<FieldOrientationName, string>;

// 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<FieldNecessityName, string | null>;

export type FieldVariants = { orientation?: FieldOrientationName | null };

@Directive({
Expand All @@ -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<FieldVariants['orientation']>('vertical');

/** Mark this field optional. Fields are required by default. */
public readonly optional = input<boolean, BooleanInput>(false, { transform: booleanAttribute });

protected readonly _optionalAttr = () =>
fieldNecessityAttrs[this.optional() ? 'optional' : 'required'];

constructor() {
// `group/field` stays as a hook for consumers' Tailwind.
classes(() => [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions packages/angular/src/lib/ui/input-otp/src/lib/hlm-input-otp.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
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]',
host: {
'data-slot': 'input-otp',
// Consume aria-label on the host so it can be forwarded to the inner <input>.
'[attr.aria-label]': 'null',
'[attr.aria-required]': 'null',
},
})
export class HlmInputOtp {
Expand All @@ -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<boolean | undefined, unknown>(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();
Expand Down
38 changes: 32 additions & 6 deletions packages/angular/src/lib/ui/input/src/lib/hlm-input.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HlmInput> = {
/** Native `<input>` 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<InputArgs> = {
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: {
Expand All @@ -39,23 +55,33 @@ const meta: Meta<HlmInput> = {
},
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<HlmInput>;
type Story = StoryObj<InputArgs>;

/** 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: `
<input hlmInput type="text" class="w-72" ${argsToTemplate(args)} />
<div hlmField class="w-72" [optional]="optional">
<label hlmFieldLabel for="input-default">Naam</label>
<input hlmInput id="input-default" ${argsToTemplate(args, { exclude: ['optional'] })} />
</div>
`,
}),
};
Expand Down
Loading
Loading