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..d68c048 100644 --- a/example/src/domain/machinery/Form.js +++ b/example/src/domain/machinery/Form.js @@ -7,24 +7,25 @@ export function FormValues({ form }) { } export function FormTextInput({ field, label }) { - const { name, state, eventHandlers } = useFormField(field) - return + const { name, state, eventHandlers, ref: inputRef } = useFormField(field) + return } export function FormNumberInput({ field, label }) { - const { name, state, eventHandlers } = 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 } = useBooleanFormField(field) + const { name, state, eventHandlers, ref: inputRef } = useBooleanFormField(field) const { value } = state console.log(`[${name}] render checkbox field`) return ( {option.label} ``` +#### 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..aa826d3 --- /dev/null +++ b/src/a11y.js @@ -0,0 +1,86 @@ +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 errorFields = findAllErrorFields(form) + const sortedFields = errorFields.sort(byDomOrder) + const firstErrorField = getFirstItem(sortedFields) + + firstErrorField?.ref.current?.focus() +} + +function findAllErrorFields(field) { + const state = field.state.get() + + if (field.type === 'basic' && state.error && field.ref?.current) return [field] + + return [ + ...(field.fields ? Object.values(field.fields) : []), + ...(state.children || []) + ].flatMap(findAllErrorFields) +} + +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 +} + +function nodeAPrecedesNodeB(nodeA, nodeB) { + return nodeB.compareDocumentPosition(nodeA) & Node.DOCUMENT_POSITION_PRECEDING +} + +function getFirstItem(array) { + return array?.[0] ?? undefined +} + +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..045225b 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -99,17 +99,17 @@ 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) { - 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)