-
Notifications
You must be signed in to change notification settings - Fork 40
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(Field): start implementing field api (#2502)
- Loading branch information
1 parent
d4202c1
commit e02d006
Showing
12 changed files
with
272 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
.ds-field { | ||
display: contents; | ||
|
||
& > * + * { | ||
margin-top: var(--ds-spacing-2); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
81
packages/react/src/components/form/Field/Field.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
11
packages/react/src/components/form/Field/FieldDescription.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
22
packages/react/src/components/form/Field/fieldObserver.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters