Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Field): start implementing field api #2502

Merged
merged 32 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6497bb8
feat(Field): start implementing field api
eirikbacker Sep 24, 2024
6e676f0
Merge branch 'next' into fix/field-api
eirikbacker Sep 25, 2024
8d66fa6
fix(Field): create a11yField utility
eirikbacker Sep 25, 2024
b3481c6
fix(Field): better demo story
eirikbacker Sep 25, 2024
9b71046
fix(Field): rename Field.Help to Field.Description
eirikbacker Sep 25, 2024
ffd7996
chore(Field): fix typo
eirikbacker Sep 25, 2024
9e4f546
Merge branch 'next' into fix/field-api
eirikbacker Sep 26, 2024
38b8c20
chore(ValidationMessage): remove api
eirikbacker Sep 26, 2024
3871051
fix(Field): use validationmessage directly
eirikbacker Sep 26, 2024
ffcfebc
Merge branch 'next' into fix/field-api
eirikbacker Sep 27, 2024
e8054ef
fix: moving to mutationobserver wip
eirikbacker Sep 27, 2024
6cf7898
Merge branch 'next' into fix/field-api
eirikbacker Sep 27, 2024
58534aa
fix: continue mutationobserver implementation
eirikbacker Sep 28, 2024
f271f37
fix: testing removing
eirikbacker Oct 1, 2024
7fcbb1e
chore: merge in next
eirikbacker Oct 11, 2024
7344069
fix: simplify
eirikbacker Oct 11, 2024
5b612af
fix: simplify
eirikbacker Oct 11, 2024
ef0d6fc
Merge branch 'next' into fix/field-api
eirikbacker Oct 11, 2024
f6c130e
fix: include css
eirikbacker Oct 11, 2024
2498865
fix: support changing attributes
eirikbacker Oct 15, 2024
59bf6d9
Merge branch 'next' into fix/field-api
eirikbacker Oct 16, 2024
2884b18
fix(Field): adjustments
eirikbacker Oct 18, 2024
017646e
Merge branch 'next' into fix/field-api
eirikbacker Oct 18, 2024
f5176f2
chore(Field): lint
eirikbacker Oct 18, 2024
c68eeac
fix(Field): PR comments
eirikbacker Oct 18, 2024
2c65b87
fix(Field): isFormAssociated
eirikbacker Oct 18, 2024
6e38e9f
chore: lint
eirikbacker Oct 18, 2024
71f285c
Merge branch 'next' into fix/field-api
eirikbacker Oct 18, 2024
30c4d6d
Create plenty-singers-matter.md
eirikbacker Oct 18, 2024
a32cd1b
fix(Field): early return in for of loop
eirikbacker Oct 18, 2024
fd946ae
fix: typo
eirikbacker Oct 18, 2024
c339c1b
chore: readability
eirikbacker Oct 18, 2024
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
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
Loading