Skip to content

Commit

Permalink
feat(Field): start implementing field api (#2502)
Browse files Browse the repository at this point in the history
  • Loading branch information
eirikbacker authored and mimarz committed Feb 21, 2025
1 parent d4202c1 commit e02d006
Show file tree
Hide file tree
Showing 12 changed files with 272 additions and 1 deletion.
6 changes: 6 additions & 0 deletions .changeset/plenty-singers-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@digdir/designsystemet-css": patch
"@digdir/designsystemet-react": patch
---

Field: Adds `<Field>` component wrapping and connecting internal form elements for better accessibility
7 changes: 7 additions & 0 deletions packages/css/field.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.ds-field {
display: contents;

& > * + * {
margin-top: var(--ds-spacing-2);
}
}
1 change: 1 addition & 0 deletions packages/css/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ export const ValidationMessage = forwardRef<

return (
<Component
ref={ref}
className={cl('ds-validation-message', className)}
data-error={error || undefined}
data-field='validation'
data-size={size}
ref={ref}
{...rest}
/>
);
Expand Down
8 changes: 8 additions & 0 deletions packages/react/src/components/form/Field/Field.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Meta, Controls, Primary } from '@storybook/blocks';

import * as FieldStories from './Field.stories';

<Meta of={FieldStories} />

<Primary />
<Controls />
81 changes: 81 additions & 0 deletions packages/react/src/components/form/Field/Field.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Field>;

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 (
<Field data-my-field>
{label && <Label htmlFor={labelFor || undefined}>Kort beskrivelse</Label>}
{description && (
<Field.Description id={descriptionId || undefined}>
Beskrivelse
</Field.Description>
)}
{type && <Component id={inputId || undefined} />}
{validation && (
<ValidationMessage data-my-validation id={validationId || undefined}>
Feilmelding
</ValidationMessage>
)}
</Field>
);
};

// @ts-expect-error ts2559: Preview.args uses more properties for testing than what is supported by <Field>
Preview.args = toggles;
19 changes: 19 additions & 0 deletions packages/react/src/components/form/Field/Field.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>;
export const Field = forwardRef<HTMLDivElement, FieldProps>(function Field(
{ className, ...rest },
ref,
) {
const fieldRef = useRef<HTMLDivElement>(null);
const mergedRefs = useMergeRefs([fieldRef, ref]);
useEffect(() => fieldObserver(fieldRef.current), []);

return (
<div className={cl('ds-field', className)} ref={mergedRefs} {...rest} />
);
});
11 changes: 11 additions & 0 deletions packages/react/src/components/form/Field/FieldDescription.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { HTMLAttributes } from 'react';
import { forwardRef } from 'react';

export type FieldDescriptionProps = HTMLAttributes<HTMLDivElement>;

export const FieldDescription = forwardRef<
HTMLDivElement,
FieldDescriptionProps
>(function FieldDescription(rest, ref) {
return <div data-field='description' ref={ref} {...rest} />;
});
22 changes: 22 additions & 0 deletions packages/react/src/components/form/Field/fieldObserver.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<div>
<Label>Navn</Label>
<Input />
</div>,
);
fieldObserver(container);

const label = screen.getByText('Navn');
const input = screen.getByLabelText('Navn');

expect(label).toHaveAttribute('for', input.id);
});
});
93 changes: 93 additions & 0 deletions packages/react/src/components/form/Field/fieldObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
export function fieldObserver(fieldElement: HTMLElement | null) {
if (!fieldElement) return;

const elements = new Map<Element, string | null>();
const uuid = `:${Math.round(Date.now() + Math.random() * 100).toString(36)}`;
let input: Element | null = null;

const process = (mutations: Partial<MutationRecord>[]) => {
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;
}
21 changes: 21 additions & 0 deletions packages/react/src/components/form/Field/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Field as FieldParent } from './Field';
import { FieldDescription } from './FieldDescription';

/**
* @example
* <Field>
* <Label>Label text</Label>
* <Field.Description>Description</Field.Description>
* <Input />
* <ValidationMessage>Validation message</ValidationMessage>
* </Field>
*/
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 };
1 change: 1 addition & 0 deletions packages/react/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down

0 comments on commit e02d006

Please sign in to comment.