From 23da43e7e73a8d837fed5ca4049fba67a6d1ef7d Mon Sep 17 00:00:00 2001 From: AlbertSmit Date: Fri, 5 Dec 2025 08:51:47 +0100 Subject: [PATCH 1/4] feat: Implement accessibility features for form error announcement and focusing invalid fields. --- index.js | 4 +++ readme.md | 40 +++++++++++++++++++++++ src/a11y.js | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/fields.js | 22 +++++++++++++ src/hooks.js | 4 +-- 5 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 src/a11y.js diff --git a/index.js b/index.js index ba84d91..a03fa56 100644 --- a/index.js +++ b/index.js @@ -14,3 +14,7 @@ export { export { array, object, } from './src/schema' +export { + FormErrorRegion, + focusFirstError, +} from './src/a11y' diff --git a/readme.md b/readme.md index 95f505c..3ffb642 100644 --- a/readme.md +++ b/readme.md @@ -110,6 +110,9 @@ _See the example for use cases_ - [FormFieldValue](#FormFieldValue) - [FormFieldsValues](#FormFieldsValues) - [FormFieldValid](#FormFieldValid) + - [FormErrorRegion](#FormErrorRegion) +- [Accessibility](#accessibility) + - [focusFirstError](#focusFirstError) ### Hooks @@ -176,6 +179,7 @@ const { name, // the fully qualified name of the form field state, // 'object' that contains the form field state eventHandlers, // 'object' that contains handlers which can be used by form elements + ref, // 'object' that contains the ref for the form field element } = useFormField(field) ``` @@ -194,6 +198,7 @@ const { |`- onBlur` | Handler for `onBlur` events| |`- onFocus` | Handler for `onFocus` events| |`- onChange` | handler for `onChange` events, accepts DOM event or value| +|`ref` | A ref object `{ current: null }` that should be passed to the form field element to support `focusFirstError` (when used)| #### useNumberFormField @@ -444,6 +449,41 @@ Because this is such a common usecase, we provide several of these components. )}> ``` +#### FormErrorRegion + +A visually hidden live region that announces form errors to screen readers. + +| Props | | +|---------|--------------------------------------------------------------------------------------| +|`form` | The form object returned by `useForm`. | +|`renderError` | (Optional) A function to render each error. Defaults to rendering the error message or id. | + +##### Example + +```jsx + +``` + +### Accessibility + +#### focusFirstError + +Focuses the first invalid field in the form. This is useful to call when a form submission fails due to validation errors. + +```js +import { focusFirstError } from '@kaliber/forms' + +// ... + +function handleSubmit(snapshot) { + if (snapshot.invalid) { + focusFirstError(form) + return + } + // ... +} +``` + ## Missing feature? If the library has a constraint that prevents you from implementing a specific feature (outside of the library) start a conversation. diff --git a/src/a11y.js b/src/a11y.js new file mode 100644 index 0000000..66a7cd0 --- /dev/null +++ b/src/a11y.js @@ -0,0 +1,88 @@ +import { useFormFieldSnapshot } from './hooks' + +/** + * A visually hidden live region that announces form errors to screen readers. + */ +export function FormErrorRegion({ form, renderError = defaultRenderError }) { + const snapshot = useFormFieldSnapshot(form) + const errors = flattenErrors(snapshot.error) + + return ( +
+
+ {errors.map((error, i) => renderError(error, i))} +
+
+ ) +} + +/** + * Focus the first invalid field in the form. + * @param {object} form - The form object returned by useForm + */ +export function focusFirstError(form) { + const firstErrorField = findFirstErrorField(form) + + if (firstErrorField && firstErrorField.ref?.current?.focus) { + firstErrorField.ref.current.focus() + } +} + +function findFirstErrorField(field) { + const state = field.state.get() + + if (!state.invalid) return null + + // If this is a basic field with an error, return it + if (field.type === 'basic' && state.error && field.ref) { + return field + } + + // Traverse object field children + if (field.fields) { + for (const childField of Object.values(field.fields)) { + const errorField = findFirstErrorField(childField) + if (errorField) return errorField + } + } + + // Traverse array field children (stored in state) + if (state.children) { + for (const childField of state.children) { + const errorField = findFirstErrorField(childField) + if (errorField) return errorField + } + } + + return null +} + +function flattenErrors(errorTree) { + if (!errorTree) return [] + if (typeof errorTree !== 'object' || (!errorTree.self && !errorTree.children)) { + return errorTree ? [errorTree] : [] + } + + const selfErrors = errorTree.self ? [errorTree.self] : [] + const childErrors = errorTree.children + ? Object.values(errorTree.children).flatMap(flattenErrors) + : [] + + return [...selfErrors, ...childErrors] +} + +function defaultRenderError(error, key) { + return
{error.message || error.id || String(error)}
+} + +const visuallyHiddenStyle = { + position: 'absolute', + width: '1px', + height: '1px', + padding: '0', + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: '0' +} diff --git a/src/fields.js b/src/fields.js index a074963..5ceb203 100644 --- a/src/fields.js +++ b/src/fields.js @@ -140,6 +140,7 @@ function createArrayFormField({ name, initialValue = [], field }) { } function createBasicFormField({ name, initialValue, field }) { + const ref = createStableRef() const initialFormFieldState = deriveFormFieldState({ value: initialValue }) const internalState = createState(initialFormFieldState) @@ -158,6 +159,7 @@ function createBasicFormField({ name, initialValue, field }) { return { type: 'basic', name, + ref, validate(context) { if (validate) validate(value.get(), context) }, @@ -237,3 +239,23 @@ function bindValidate(f, state) { function addParent(context, parent) { return { ...context, parents: [...context.parents, parent] } } + +/** + * Creates a stable ref object for use outside of React components. + * + * This is equivalent to what React.useRef() returns, but can be called + * outside of React's component lifecycle. It works because: + * + * 1. A React ref is just an object with a `current` property + * 2. React.useRef() only guarantees stable identity across re-renders + * 3. Since field objects are created once and persist, a plain object + * achieves the same stability + * + * When passed to a DOM element's `ref` prop, React will assign the + * DOM node to the `current` property automatically. + * + * @returns {{ current: null }} A ref-compatible object + */ +function createStableRef() { + return { current: null } +} diff --git a/src/hooks.js b/src/hooks.js index 70749b5..562532e 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -99,10 +99,10 @@ export function useFormFieldsValues(fields) { export function useFormField(field) { if (!field) throw new Error('No field was passed in') - const { name, eventHandlers } = field + const { name, eventHandlers, ref } = field const state = useFormFieldState(field.state) - return { name, state, eventHandlers } + return { name, state, eventHandlers, ref } } export function useNumberFormField(field) { From cb6616abb523e79fc6e59f6a686fc970d865f1cb Mon Sep 17 00:00:00 2001 From: AlbertSmit Date: Fri, 5 Dec 2025 09:31:22 +0100 Subject: [PATCH 2/4] feat: Implement `focusFirstError` by exposing `ref` from field hooks and updating example components. --- example/src/domain/Full.js | 7 +++++-- example/src/domain/machinery/Form.js | 16 ++++++++-------- readme.md | 2 +- src/hooks.js | 8 ++++---- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/example/src/domain/Full.js b/example/src/domain/Full.js index 5a812da..294e076 100644 --- a/example/src/domain/Full.js +++ b/example/src/domain/Full.js @@ -1,4 +1,4 @@ -import { object, array, useForm, useFormFieldValue, snapshot } from '@kaliber/forms' +import { object, array, useForm, useFormFieldValue, snapshot, focusFirstError } from '@kaliber/forms' import { optional, required, minLength, error, email } from '@kaliber/forms/validation' import { FormFieldValue, FormFieldsValues, FormFieldValid } from '@kaliber/forms/components' import { date, ifParentHasValue, ifFormHasValue } from './machinery/validation' @@ -78,7 +78,10 @@ export function Full() { ) function handleSubmit(snapshot) { - if (snapshot.invalid) return + if (snapshot.invalid) { + focusFirstError(form) + return + } setSubmitted(snapshot.value) } diff --git a/example/src/domain/machinery/Form.js b/example/src/domain/machinery/Form.js index 01a49ff..3f66a93 100644 --- a/example/src/domain/machinery/Form.js +++ b/example/src/domain/machinery/Form.js @@ -7,18 +7,18 @@ export function FormValues({ form }) { } export function FormTextInput({ field, label }) { - const { name, state, eventHandlers } = useFormField(field) - return + const { name, state, eventHandlers, ref } = useFormField(field) + return } export function FormNumberInput({ field, label }) { - const { name, state, eventHandlers } = useNumberFormField(field) + const { name, state, eventHandlers, ref } = useNumberFormField(field) // We use type='text' to show `number` validation - return + return } export function FormCheckbox({ field, label }) { - const { name, state, eventHandlers } = useBooleanFormField(field) + const { name, state, eventHandlers, ref } = useBooleanFormField(field) const { value } = state console.log(`[${name}] render checkbox field`) return ( @@ -27,7 +27,7 @@ export function FormCheckbox({ field, label }) { id={name} type='checkbox' checked={value || false} - {...{ name }} + {...{ ref, name }} {...eventHandlers} /> @@ -133,7 +133,7 @@ export function FormObjectField({ field, render }) { ) } -function InputBase({ type, name, label, state, eventHandlers }) { +function InputBase({ type, name, label, state, eventHandlers, ref }) { const { value } = state console.log(`[${name}] render ${type} field`) return ( @@ -141,7 +141,7 @@ function InputBase({ type, name, label, state, eventHandlers }) { diff --git a/readme.md b/readme.md index 3ffb642..f58d118 100644 --- a/readme.md +++ b/readme.md @@ -198,7 +198,7 @@ const { |`- onBlur` | Handler for `onBlur` events| |`- onFocus` | Handler for `onFocus` events| |`- onChange` | handler for `onChange` events, accepts DOM event or value| -|`ref` | A ref object `{ current: null }` that should be passed to the form field element to support `focusFirstError` (when used)| +|`ref` | A ref object `{ current: null }` that should be passed to the form field element to support `focusFirstError` (when used). Note that this is only available for basic fields. Objects and arrays do not have a ref, as `focusFirstError` will traverse them to find the first invalid basic field.| #### useNumberFormField diff --git a/src/hooks.js b/src/hooks.js index 562532e..045225b 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -106,10 +106,10 @@ export function useFormField(field) { } export function useNumberFormField(field) { - const { name, state, eventHandlers: { onChange, ...originalEventHandlers } } = useFormField(field) + const { name, state, eventHandlers: { onChange, ...originalEventHandlers }, ref } = useFormField(field) const eventHandlers = { ...originalEventHandlers, onChange: handleChange } - return { name, state, eventHandlers } + return { name, state, eventHandlers, ref } function handleChange(e) { const userValue = e.target.value @@ -119,10 +119,10 @@ export function useNumberFormField(field) { } export function useBooleanFormField(field) { - const { name, state, eventHandlers: { onChange, ...originalEventHandlers } } = useFormField(field) + const { name, state, eventHandlers: { onChange, ...originalEventHandlers }, ref } = useFormField(field) const eventHandlers = { ...originalEventHandlers, onChange: handleChange } - return { name, state, eventHandlers } + return { name, state, eventHandlers, ref } function handleChange(e) { onChange(e.target.checked) From 74285ab3b6e98d521ba5eabbed72fb1acbfec9f6 Mon Sep 17 00:00:00 2001 From: AlbertSmit Date: Fri, 5 Dec 2025 10:44:41 +0100 Subject: [PATCH 3/4] refactor: Focus the first error field based on its DOM order. --- src/a11y.js | 48 +++++++++++++++++++++++------------------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/src/a11y.js b/src/a11y.js index 66a7cd0..71bc7cb 100644 --- a/src/a11y.js +++ b/src/a11y.js @@ -21,44 +21,42 @@ export function FormErrorRegion({ form, renderError = defaultRenderError }) { * @param {object} form - The form object returned by useForm */ export function focusFirstError(form) { - const firstErrorField = findFirstErrorField(form) + const errorFields = findAllErrorFields(form) + const sortedFields = errorFields.sort(byDomOrder) + const firstErrorField = getFirstItem(sortedFields) - if (firstErrorField && firstErrorField.ref?.current?.focus) { - firstErrorField.ref.current.focus() - } + firstErrorField?.ref.current.focus() } -function findFirstErrorField(field) { +function findAllErrorFields(field) { const state = field.state.get() - if (!state.invalid) return null + if (field.type === 'basic' && state.error && field.ref?.current) return [field] - // If this is a basic field with an error, return it - if (field.type === 'basic' && state.error && field.ref) { - return field - } + return [ + ...(field.fields ? Object.values(field.fields) : []), + ...(state.children || []) + ].flatMap(findAllErrorFields) +} - // Traverse object field children - if (field.fields) { - for (const childField of Object.values(field.fields)) { - const errorField = findFirstErrorField(childField) - if (errorField) return errorField - } - } +function byDomOrder(a, b) { + const nodeA = a.ref.current + const nodeB = b.ref.current + if (!nodeA || !nodeB) return 0 + return nodeAPrecedesNodeB(nodeA, nodeB) ? -1 : 1 +} - // Traverse array field children (stored in state) - if (state.children) { - for (const childField of state.children) { - const errorField = findFirstErrorField(childField) - if (errorField) return errorField - } - } +function nodeAPrecedesNodeB(nodeA, nodeB) { + return nodeB.compareDocumentPosition(nodeA) & Node.DOCUMENT_POSITION_PRECEDING +} - return null +function getFirstItem(array) { + return array?.[0] ?? undefined } function flattenErrors(errorTree) { if (!errorTree) return [] + if (typeof errorTree !== 'object' || (!errorTree.self && !errorTree.children)) { return errorTree ? [errorTree] : [] } From c7b2db5d5e04f3bf3d6b3cb731575bb0393915e1 Mon Sep 17 00:00:00 2001 From: AlbertSmit Date: Fri, 5 Dec 2025 11:02:51 +0100 Subject: [PATCH 4/4] refactor: Rename form component `ref` prop to `inputRef` for clarity and add optional chaining to `focusFirstError`. --- example/src/domain/machinery/Form.js | 21 ++++++++++++--------- readme.md | 4 ++-- src/a11y.js | 2 +- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/example/src/domain/machinery/Form.js b/example/src/domain/machinery/Form.js index 3f66a93..d68c048 100644 --- a/example/src/domain/machinery/Form.js +++ b/example/src/domain/machinery/Form.js @@ -7,27 +7,28 @@ export function FormValues({ form }) { } export function FormTextInput({ field, label }) { - const { name, state, eventHandlers, ref } = useFormField(field) - return + const { name, state, eventHandlers, ref: inputRef } = useFormField(field) + return } export function FormNumberInput({ field, label }) { - const { name, state, eventHandlers, ref } = useNumberFormField(field) + const { name, state, eventHandlers, ref: inputRef } = useNumberFormField(field) // We use type='text' to show `number` validation - return + return } export function FormCheckbox({ field, label }) { - const { name, state, eventHandlers, ref } = useBooleanFormField(field) + const { name, state, eventHandlers, ref: inputRef } = useBooleanFormField(field) const { value } = state console.log(`[${name}] render checkbox field`) return ( @@ -35,7 +36,7 @@ export function FormCheckbox({ field, label }) { } export function FormCheckboxGroupField({ field, options, label }) { - const { name, state, eventHandlers: { onChange, ...eventHandlers } } = useFormField(field) + const { name, state, eventHandlers: { onChange, ...eventHandlers }, ref: inputRef } = useFormField(field) const { value } = state console.log(`[${name}] render checkbox group field`) @@ -49,6 +50,7 @@ export function FormCheckboxGroupField({ field, options, label }) { diff --git a/readme.md b/readme.md index f58d118..54cf71c 100644 --- a/readme.md +++ b/readme.md @@ -110,9 +110,9 @@ _See the example for use cases_ - [FormFieldValue](#FormFieldValue) - [FormFieldsValues](#FormFieldsValues) - [FormFieldValid](#FormFieldValid) - - [FormErrorRegion](#FormErrorRegion) + - [FormErrorRegion](#formerrorregion) - [Accessibility](#accessibility) - - [focusFirstError](#focusFirstError) + - [focusFirstError](#focusfirsterror) ### Hooks diff --git a/src/a11y.js b/src/a11y.js index 71bc7cb..aa826d3 100644 --- a/src/a11y.js +++ b/src/a11y.js @@ -25,7 +25,7 @@ export function focusFirstError(form) { const sortedFields = errorFields.sort(byDomOrder) const firstErrorField = getFirstItem(sortedFields) - firstErrorField?.ref.current.focus() + firstErrorField?.ref.current?.focus() } function findAllErrorFields(field) {