Skip to content

Commit

Permalink
fix: simplify
Browse files Browse the repository at this point in the history
  • Loading branch information
eirikbacker committed Oct 11, 2024
1 parent 7fcbb1e commit 7344069
Show file tree
Hide file tree
Showing 7 changed files with 105 additions and 119 deletions.
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
26 changes: 13 additions & 13 deletions packages/react/src/components/form/Field/Field.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { Meta, StoryFn } from '@storybook/react';

import { useEffect } from 'react';
import { Label } from '../../Typography/Label';
import { createPortal } from 'react-dom';
import { Label } from '../../Label';

import { Field } from '.';
import { ValidationMessage } from '../../Typography';
import { ValidationMessage } from '../../ValidationMessage';

type Story = StoryFn<typeof Field>;

Expand All @@ -23,9 +24,10 @@ export default {
const toggles = {
type: 'textarea',
label: true,
labelFor: '',
ariaDesribedby: '',
description: true,
descriptionId: '',
help: true,
helpId: '',
validation: true,
validationId: '',
moveToBody: false,
Expand All @@ -36,8 +38,9 @@ export const Preview: Story = (args) => {
ariaDesribedby,
type,
label,
description,
descriptionId,
labelFor,
help,
helpId,
validation,
validationId,
moveToBody,
Expand All @@ -52,19 +55,16 @@ export const Preview: Story = (args) => {
}, [moveToBody]);

return (
<Field data-my-field>
{label && <Label style={{ display: 'block' }}>Kort beskrivelse</Label>}
{description && (
<Field.Description id={descriptionId || undefined}>
Beskrivelse
</Field.Description>
)}
<Field data-my-field style={{ display: 'flex', flexDirection: 'column' }}>
{label && <Label htmlFor={labelFor || undefined}>Kort beskrivelse</Label>}
{help && <Field.Help id={helpId || undefined}>Beskrivelse</Field.Help>}
{type && <Component aria-describedby={ariaDesribedby || undefined} />}
{validation && (
<ValidationMessage data-my-validation id={validationId || undefined}>
Feilmelding
</ValidationMessage>
)}
{/* {createPortal(<div>Hei</div>, document.body)} */}
</Field>
);
};
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/components/form/Field/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useMergeRefs } from '@floating-ui/react';
import cl from 'clsx/lite';
import type { HTMLAttributes } from 'react';
import { forwardRef, useEffect, useRef } from 'react';
import { a11yField } from './a11yField';
import { fieldA11Y } from './fieldA11Y';

export type FieldProps = HTMLAttributes<HTMLDivElement>;
export const Field = forwardRef<HTMLDivElement, FieldProps>(function Field(
Expand All @@ -11,7 +11,7 @@ export const Field = forwardRef<HTMLDivElement, FieldProps>(function Field(
) {
const fieldRef = useRef<HTMLDivElement>(null);
const mergedRefs = useMergeRefs([fieldRef, ref]);
useEffect(() => a11yField(fieldRef.current), []); // Intentionally run on each render
useEffect(() => fieldA11Y(fieldRef.current), []); // Intentionally run on each render

return (
<div className={cl('ds-field', className)} ref={mergedRefs} {...rest} />
Expand Down
11 changes: 0 additions & 11 deletions packages/react/src/components/form/Field/FieldDescription.tsx

This file was deleted.

10 changes: 10 additions & 0 deletions packages/react/src/components/form/Field/FieldHelp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { HTMLAttributes } from 'react';
import { forwardRef } from 'react';

export type FieldHelpProps = HTMLAttributes<HTMLDivElement>;

export const FieldHelp = forwardRef<HTMLDivElement, FieldHelpProps>(
function FieldHelp(rest, ref) {
return <div data-field-help ref={ref} {...rest} />;
},
);
Original file line number Diff line number Diff line change
@@ -1,112 +1,98 @@
const ARIA_DESC = 'aria-describedby';
export const DATA_DESC = 'data-field-description';
export const DATA_VALID = 'data-field-validation';
export const DATA_RESET = 'data-field-reset';
const ARIA_DESC = "aria-describedby";

/**
* The cache takes a element identifier as key, and a object of element, attribute, and original value.
* If there is no original value, we create a fallback value, but do not store it.
*/
type Cache = Record<string, Entry>;
type Entry = {
el: Element | null;
attr: string;
value: string | null;
prefix?: string;
};

export const a11yField = (field: HTMLElement | null) => {
export const fieldA11Y = (field: HTMLElement | null) => {
if (!field) return;

const elements = field.getElementsByTagName("*"); // Speed up by using a live HTMLCollection instead of traversing MutationRecords
const uuid = `:${Math.round(Date.now() + Math.random() * 100).toString(36)}`;
const queue: MutationRecord[] = [];
const elements = field.getElementsByTagName('*'); // Speed up MutationObserver by refering to live HTMLCollection
const observer = new MutationObserver((mutations) => {
if (!queue.length) requestAnimationFrame(process); // Speed up MutationObserver by queuing and only running when tab is visible
queue.push(...mutations);
});

const process = () => {
let desc: undefined | Element;
let input: undefined | Element;
let label: undefined | HTMLLabelElement;
let valid: undefined | Element;
const cache: Cache = {
input: { el: null, attr: "id", value: null },
label: { el: null, attr: "for", value: null },
help: { el: null, attr: "id", value: null, prefix: "help" },
valid: { el: null, attr: "id", value: null, prefix: "valid" },
};

const observer = createOptimizedMutationObserver(() => {
// Find elements
for (const el of elements) {
if (el instanceof HTMLLabelElement) label = el;
else if ('validity' in el) input = el;
else if (el.hasAttribute(DATA_DESC)) desc = el;
else if (el.hasAttribute(DATA_VALID)) valid = el;
if (el instanceof HTMLLabelElement) update(cache, "label", el);
else if ("validity" in el) update(cache, "input", el);
else if (el.hasAttribute("data-field-help")) update(cache, "help", el);
else if (el.hasAttribute("data-field-validation"))
update(cache, "valid", el);
}

// Set attributes
if (desc && !desc.id) desc.id = `desc${uuid}`;
if (input && !input.id) input.id = uuid; // Must run before label
if (label && !label.htmlFor) label.htmlFor = input?.id || '';
if (valid && !valid.id) valid.id = `valid${uuid}`;

// Deregister elements
for (const { removedNodes } of queue)
for (const el of removedNodes)
if (el instanceof Element && el.id.endsWith(uuid))
el.removeAttribute('id');
// Set attributes or reset if no input or moved outside field
const input = cache.input.el;
const inputId = input && (cache.input.value || uuid);
for (const [key, { el, attr, value, prefix }] of Object.entries(cache)) {
if (!inputId || !field.contains(el)) reset(cache, key);
else el?.setAttribute(attr, value || `${prefix || ""}${inputId}`);
}

// Set aria-description, and make order is: validation, description, ...rest
// Set aria-description, and make order is: validation, help, ...rest
const descs =
`${valid?.id || ''} ${desc?.id || ''} ${input?.getAttribute(ARIA_DESC) || ''}`
.split(' ')
`${cache.valid.prefix}${inputId} ${cache.help.prefix}${inputId} ${input?.getAttribute(ARIA_DESC) || ""}`
.split(" ")
.filter((id, idx, all) => id && all.indexOf(id) === idx) // Remove empty and duplicates
.join(' ');
.join(" ");

if (descs) input?.setAttribute(ARIA_DESC, descs);
else input?.removeAttribute(ARIA_DESC);

queue.length = 0;
observer.takeRecords(); // Clear queue of our DOM changes
};

// let inputId: Element | undefined;
// const attrs: Record<string, string> = {}; // Map of element to [id, attr]
// const handleMutation = (mutations: Partial<MutationRecord>[]) => {
// const changed: Node[] = [];
// const removed: Node[] = [];

// // Collect all changed nodes from MutationRecords
// for (const {
// target = field,
// addedNodes = [],
// removedNodes = [],
// } of mutations) {
// changed.push(target ?? field, ...addedNodes, ...removedNodes);
// removed.push(...removedNodes);
// }

// // Run through changedNode for each ELEMENTS check
// for (const [key, attr, check] of ELEMENTS)
// for (const el of changed)
// if (el instanceof Element && check(el)) {
// const isInput = key === 'input';
// const value = el.getAttribute(attr);
// const fallback = `${attrs.input ?? (++a11yFieldId).toString(32)}${isInput || attr !== 'id' ? '' : `:${key}`}`;
// attrs[key] = value || fallback; // Store attribute value

// if (isInput) input = el.parentNode ? el : undefined; // Update connected input
// if (!removed.includes(el)) value || el.setAttribute(attr, fallback);
// else if (value === fallback) el.removeAttribute(attr); // Reset attribute if removed
// }

// // Set aria-description, and make order is: validation, description, ...rest
// const descs =
// `${attrs.valid || ''} ${attrs.desc || ''} ${input?.getAttribute(ARIA_DESC) || ''}`
// .split(' ')
// .filter((id, idx, all) => id && all.indexOf(id) === idx) // Remove empty and duplicates
// .join(' ');

// if (descs) input?.setAttribute(ARIA_DESC, descs);
// else input?.removeAttribute(ARIA_DESC);

// observer.takeRecords(); // Clear queue of our DOM changes
// };
});

observer.observe(field, {
attributeFilter: ['id', ARIA_DESC],
attributeFilter: ["id", ARIA_DESC],
attributes: true,
childList: true,
subtree: true,
});

requestAnimationFrame(process); // Inital setup
return () => observer.disconnect();
};

const update = (cache: Cache, key: string, el: Element) => {
if (cache[key].el === el) return; // TODO: What about storing reset if original attribute changes?
reset(cache, key);
cache[key].el = el;
cache[key].value = el.getAttribute(cache[key].attr);
};

const reset = (cache: Cache, key: string) => {
const { el, attr, value } = cache[key] || {};
value ? el?.setAttribute(attr, value) : el?.removeAttribute(attr);
cache[key].el = cache[key].value = null;
};

// Speed up MutationObserver by debouncing and only running when page is visible
const createOptimizedMutationObserver = (callback: () => void) => {
let queue: ReturnType<typeof requestAnimationFrame> = 0;
const observer = new MutationObserver(() => {
if (!queue) queue = requestAnimationFrame(process);
});

const process = () => {
callback();
queue = 0; // Empty queue
observer.takeRecords(); // Clear queue of our DOM changes
};

requestAnimationFrame(process); // Initial setup
return observer;
};

/*
const ELEMENTS = [
['input', 'id', (node: Node) => 'validity' in node], // Muse be first
Expand Down
14 changes: 7 additions & 7 deletions packages/react/src/components/form/Field/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Field as FieldParent } from './Field';
import { FieldDescription } from './FieldDescription';
import { Field as FieldParent } from "./Field";
import { FieldHelp } from "./FieldHelp";

/**
* @example
Expand All @@ -11,11 +11,11 @@ import { FieldDescription } from './FieldDescription';
* </Field>
*/
const Field = Object.assign(FieldParent, {
Description: FieldDescription,
Help: FieldHelp,
});

Field.Description.displayName = 'Field.Description';
Field.Help.displayName = "Field.Help";

export type { FieldProps } from './Field';
export type { FieldDescriptionProps } from './FieldDescription';
export { Field, FieldDescription };
export type { FieldProps } from "./Field";
export type { FieldHelpProps } from "./FieldHelp";
export { Field, FieldHelp };

0 comments on commit 7344069

Please sign in to comment.