diff --git a/.changeset/plenty-singers-matter.md b/.changeset/plenty-singers-matter.md new file mode 100644 index 0000000000..68211250fe --- /dev/null +++ b/.changeset/plenty-singers-matter.md @@ -0,0 +1,6 @@ +--- +"@digdir/designsystemet-css": patch +"@digdir/designsystemet-react": patch +--- + +Field: Adds `` component wrapping and connecting internal form elements for better accessibility diff --git a/packages/css/field.css b/packages/css/field.css new file mode 100644 index 0000000000..14d1368623 --- /dev/null +++ b/packages/css/field.css @@ -0,0 +1,7 @@ +.ds-field { + display: contents; + + & > * + * { + margin-top: var(--ds-spacing-2); + } +} diff --git a/packages/css/index.css b/packages/css/index.css index bc4c797265..8a7be7c55e 100644 --- a/packages/css/index.css +++ b/packages/css/index.css @@ -14,6 +14,7 @@ @import url('./popover.css') layer(ds.components); @import url('./skiplink.css') layer(ds.components); @import url('./accordion.css') layer(ds.components); +@import url('./field.css') layer(ds.components); @import url('./switch.css') layer(ds.components); @import url('./checkbox.css') layer(ds.components); @import url('./radio.css') layer(ds.components); diff --git a/packages/react/src/components/ValidationMessage/ValidationMessage.tsx b/packages/react/src/components/ValidationMessage/ValidationMessage.tsx index dfc45ece26..f6c1871c9f 100644 --- a/packages/react/src/components/ValidationMessage/ValidationMessage.tsx +++ b/packages/react/src/components/ValidationMessage/ValidationMessage.tsx @@ -30,10 +30,11 @@ export const ValidationMessage = forwardRef< return ( ); diff --git a/packages/react/src/components/form/Field/Field.mdx b/packages/react/src/components/form/Field/Field.mdx new file mode 100644 index 0000000000..17035526b1 --- /dev/null +++ b/packages/react/src/components/form/Field/Field.mdx @@ -0,0 +1,8 @@ +import { Meta, Controls, Primary } from '@storybook/blocks'; + +import * as FieldStories from './Field.stories'; + + + + + diff --git a/packages/react/src/components/form/Field/Field.stories.tsx b/packages/react/src/components/form/Field/Field.stories.tsx new file mode 100644 index 0000000000..4ed156eb6d --- /dev/null +++ b/packages/react/src/components/form/Field/Field.stories.tsx @@ -0,0 +1,81 @@ +import type { Meta, StoryFn } from '@storybook/react'; + +import { useEffect } from 'react'; +import { Label } from '../../Label'; + +import { Field } from '.'; +import { ValidationMessage } from '../../ValidationMessage'; +import { Input } from '../Input'; +import { Select } from '../Select'; +import { Textarea } from '../Textarea'; + +type Story = StoryFn; + +export default { + title: 'Komponenter/Field', + component: Field, + argTypes: { + type: { + control: { type: 'radio' }, + options: ['textarea', 'input', 'select', false], + mapping: { textarea: Textarea, input: Input, select: Select }, + }, + }, +} as Meta; + +// TMP toggles to test a11yField utility +const toggles = { + type: 'textarea', + inputId: '', + label: true, + labelFor: '', + description: true, + descriptionId: '', + validation: true, + validationId: '', + moveToBody: false, +}; + +export const Preview: Story = (args) => { + const { + type, + inputId, + label, + labelFor, + description, + descriptionId, + validation, + validationId, + moveToBody, + } = args as typeof toggles; + const Component = type as keyof JSX.IntrinsicElements; + + useEffect(() => { + const label = document.querySelector('label'); + const valid = document.querySelector('[data-my-validation]'); + const field = document.querySelector('[data-my-field]'); + const root = moveToBody ? document.body : field; + if (valid && valid.parentElement !== root) root?.append(valid); + if (label && label.parentElement !== root) root?.prepend(label); + }, [moveToBody]); + + return ( + + {label && } + {description && ( + + Beskrivelse + + )} + {type && } + {validation && ( + + Feilmelding + + )} + + ); +}; + +// @ts-expect-error ts2559: Preview.args uses more properties for testing than what is supported by +Preview.args = toggles; diff --git a/packages/react/src/components/form/Field/Field.tsx b/packages/react/src/components/form/Field/Field.tsx new file mode 100644 index 0000000000..f049723132 --- /dev/null +++ b/packages/react/src/components/form/Field/Field.tsx @@ -0,0 +1,19 @@ +import { useMergeRefs } from '@floating-ui/react'; +import cl from 'clsx/lite'; +import type { HTMLAttributes } from 'react'; +import { forwardRef, useEffect, useRef } from 'react'; +import { fieldObserver } from './fieldObserver'; + +export type FieldProps = HTMLAttributes; +export const Field = forwardRef(function Field( + { className, ...rest }, + ref, +) { + const fieldRef = useRef(null); + const mergedRefs = useMergeRefs([fieldRef, ref]); + useEffect(() => fieldObserver(fieldRef.current), []); + + return ( +
+ ); +}); diff --git a/packages/react/src/components/form/Field/FieldDescription.tsx b/packages/react/src/components/form/Field/FieldDescription.tsx new file mode 100644 index 0000000000..b5a1c26592 --- /dev/null +++ b/packages/react/src/components/form/Field/FieldDescription.tsx @@ -0,0 +1,11 @@ +import type { HTMLAttributes } from 'react'; +import { forwardRef } from 'react'; + +export type FieldDescriptionProps = HTMLAttributes; + +export const FieldDescription = forwardRef< + HTMLDivElement, + FieldDescriptionProps +>(function FieldDescription(rest, ref) { + return
; +}); diff --git a/packages/react/src/components/form/Field/fieldObserver.test.tsx b/packages/react/src/components/form/Field/fieldObserver.test.tsx new file mode 100644 index 0000000000..14c3818926 --- /dev/null +++ b/packages/react/src/components/form/Field/fieldObserver.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; + +import { Input, Label } from '../..'; +import { fieldObserver } from './fieldObserver'; + +describe('fieldObserver', () => { + it('connects input and label', () => { + const { container } = render( +
+ + +
, + ); + fieldObserver(container); + + const label = screen.getByText('Navn'); + const input = screen.getByLabelText('Navn'); + + expect(label).toHaveAttribute('for', input.id); + }); +}); diff --git a/packages/react/src/components/form/Field/fieldObserver.ts b/packages/react/src/components/form/Field/fieldObserver.ts new file mode 100644 index 0000000000..f2ff512e7f --- /dev/null +++ b/packages/react/src/components/form/Field/fieldObserver.ts @@ -0,0 +1,93 @@ +export function fieldObserver(fieldElement: HTMLElement | null) { + if (!fieldElement) return; + + const elements = new Map(); + const uuid = `:${Math.round(Date.now() + Math.random() * 100).toString(36)}`; + let input: Element | null = null; + + const process = (mutations: Partial[]) => { + const changed: Node[] = []; + const removed: Node[] = []; + + // Merge MutationRecords + for (const mutation of mutations) { + if (mutation.attributeName) changed.push(mutation.target ?? fieldElement); + changed.push(...(mutation.addedNodes || [])); + removed.push(...(mutation.removedNodes || [])); + } + + // Register elements + for (const el of changed) { + if (!isElement(el)) continue; + + if (isLabel(el)) elements.set(el, el.htmlFor); + else if (el.hasAttribute('data-field')) elements.set(el, el.id); + else if (isFormAssociated(el)) input = el; + } + + // Reset removed elements + for (const el of removed) { + if (!isElement(el)) continue; + + if (input === el) input = null; + if (elements.has(el)) { + setAttr(el, isLabel(el) ? 'for' : 'id', elements.get(el)); + elements.delete(el); + } + } + + // Connect elements + const inputId = input?.id || uuid; + const describedbyIds: string[] = []; + for (const [el, value] of elements) { + const descriptionType = el.getAttribute('data-field'); + const id = descriptionType ? `${inputId}:${descriptionType}` : inputId; + + if (!value) setAttr(el, isLabel(el) ? 'for' : 'id', id); // Ensure we have a value + if (descriptionType === 'validation') + describedbyIds.unshift(el.id); // Validations to the front + else if (descriptionType) describedbyIds.push(el.id); // Other descriptions to the back + } + + setAttr(input, 'id', inputId); + setAttr(input, 'aria-describedby', describedbyIds.join(' ')); + }; + + const observer = createOptimizedMutationObserver(process); + observer.observe(fieldElement, { + attributeFilter: ['id', 'for', 'aria-describedby'], + attributes: true, + childList: true, + subtree: true, + }); + + process([{ addedNodes: fieldElement.querySelectorAll('*') }]); // Initial setup + observer.takeRecords(); // Clear initial setup queue + return () => observer.disconnect(); +} + +// Utilities +const isElement = (node: Node) => node instanceof Element; +const isLabel = (node: Node) => node instanceof HTMLLabelElement; +const isFormAssociated = (node: Node): node is Element => + 'validity' in node && !(node instanceof HTMLButtonElement); + +const setAttr = (el: Element | null, name: string, value?: string | null) => + value ? el?.setAttribute(name, value) : el?.removeAttribute(name); + +// Speed up MutationObserver by debouncing, clearing internal queue after changes and only running when page is visible +function createOptimizedMutationObserver(callback: MutationCallback) { + const queue: MutationRecord[] = []; + const observer = new MutationObserver((mutations) => { + if (!queue.length) requestAnimationFrame(process); + queue.push(...mutations); + }); + + const process = () => { + callback(queue, observer); + queue.length = 0; // Reset queue + observer.takeRecords(); // Clear queue due to DOM changes in callback + }; + + return observer; +} diff --git a/packages/react/src/components/form/Field/index.ts b/packages/react/src/components/form/Field/index.ts new file mode 100644 index 0000000000..181a3c84bc --- /dev/null +++ b/packages/react/src/components/form/Field/index.ts @@ -0,0 +1,21 @@ +import { Field as FieldParent } from './Field'; +import { FieldDescription } from './FieldDescription'; + +/** + * @example + * + * + * Description + * + * Validation message + * + */ +const Field = Object.assign(FieldParent, { + Description: FieldDescription, +}); + +Field.Description.displayName = 'Field.Description'; + +export type { FieldProps } from './Field'; +export type { FieldDescriptionProps } from './FieldDescription'; +export { Field, FieldDescription }; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 8d3b084e17..d89e66ea27 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -19,6 +19,7 @@ export * from './Chip'; export * from './Pagination'; export * from './SkipLink'; export * from './Tooltip'; +export * from './form/Field'; export * from './form/Checkbox'; export * from './form/Radio'; export * from './form/Fieldset';