diff --git a/.changeset/sharp-bulldogs-run.md b/.changeset/sharp-bulldogs-run.md new file mode 100644 index 0000000000..eb7aa31724 --- /dev/null +++ b/.changeset/sharp-bulldogs-run.md @@ -0,0 +1,5 @@ +--- +"@digdir/designsystemet-web": patch +--- + +**All components:** Renders instantly for easier test setup diff --git a/apps/themebuilder/app/_components/color-preview/color-preview.tsx b/apps/themebuilder/app/_components/color-preview/color-preview.tsx index 3786c4a09d..6c16b9a2ca 100644 --- a/apps/themebuilder/app/_components/color-preview/color-preview.tsx +++ b/apps/themebuilder/app/_components/color-preview/color-preview.tsx @@ -56,7 +56,7 @@ export const ColorPreview = () => {
{t('colorPreview.view')}
setView(value as ViewType)} diff --git a/apps/themebuilder/app/_components/previews/previews.tsx b/apps/themebuilder/app/_components/previews/previews.tsx index 133d7f67fb..9e2a672569 100644 --- a/apps/themebuilder/app/_components/previews/previews.tsx +++ b/apps/themebuilder/app/_components/previews/previews.tsx @@ -36,7 +36,7 @@ export const Previews = () => { <>
setTheme(v as keyof typeof themes)} > @@ -48,7 +48,7 @@ export const Previews = () => { setColorScheme(v as ColorScheme)} > diff --git a/apps/www/app/_components/live-component/live-components.tsx b/apps/www/app/_components/live-component/live-components.tsx index 5167abc5ea..6300550f56 100644 --- a/apps/www/app/_components/live-component/live-components.tsx +++ b/apps/www/app/_components/live-component/live-components.tsx @@ -184,7 +184,7 @@ const Editor = ({ live, html, id, hidden, language }: EditorProps) => { setShowHTML(v === 'true')} diff --git a/apps/www/app/content/blog/en/designsystemet-web-pre2.mdx b/apps/www/app/content/blog/en/designsystemet-web-pre2.mdx index d460b128b0..007c466d4f 100644 --- a/apps/www/app/content/blog/en/designsystemet-web-pre2.mdx +++ b/apps/www/app/content/blog/en/designsystemet-web-pre2.mdx @@ -35,7 +35,7 @@ Please check all tests and console to look for errors or unexpected behavior. `ToggleGroup` still uses `ToggleGroup.Item` in React, but now internally does not create ` + ), -]; +}; -Aria.play = async () => {}; +export const WithDynamicCSSTooltipText: Story = { + args: { + content: 'Kopier', + }, + render: () => { + const tooltipRef = useRef(null); + const [tooltipContent, setTooltipContent] = useState(''); + + // Tooltip text from css variable + useEffect(() => { + if (typeof window === 'undefined' || !tooltipRef.current) return; + const content = getComputedStyle(tooltipRef.current) + .getPropertyValue('--ds-tooltip-content') + .replace(/^["']|["']$/g, '') + .trim(); + setTooltipContent(content); + }, []); + + return ( + + + + ); + }, +}; diff --git a/packages/react/src/components/tooltip/tooltip.tsx b/packages/react/src/components/tooltip/tooltip.tsx index 47a2a4d1bd..f993708a1e 100644 --- a/packages/react/src/components/tooltip/tooltip.tsx +++ b/packages/react/src/components/tooltip/tooltip.tsx @@ -61,18 +61,19 @@ export type TooltipProps = MergeRight< export const Tooltip = forwardRef( function Tooltip( { content, placement = 'top', autoPlacement = true, ...rest }, - _ref, + ref, ) { /* check if children is a string */ const isString = typeof rest.children === 'string'; return ( {isString ? {rest.children} : rest.children} diff --git a/packages/react/stories/typography.stories.tsx b/packages/react/stories/typography.stories.tsx index 0dd0264d4c..adf0c406b5 100644 --- a/packages/react/stories/typography.stories.tsx +++ b/packages/react/stories/typography.stories.tsx @@ -24,7 +24,7 @@ const Controls = ({ size, setSize }: ControlsProps) => { Størrelse (data-size) setSize(val as Size)} > diff --git a/packages/web/README.md b/packages/web/README.md index 118bc3440d..dcbc4006d0 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -15,7 +15,7 @@ - [``](#ds-pagination) - [``](#ds-suggestion) - [``](#ds-tabs) -- [`data-toggle-group`](#data-toggle-group) +- [`ToggleGroup`](#togglegroup) - [`data-tooltip`](#data-tooltip) - [`data-clickdelegatefor`](#data-clickdelegatefor) - [`readonly`](#readonly) @@ -177,13 +177,13 @@ Extends `u-tabs` from u-elements. See documentation for [u-tabs](https://u-eleme ``` -## `data-toggle-group` +## ToggleGroup This is implemented differently from `ToggleGroup` in the react package. -An observer will look for `[data-toggle-group]` and add proper arrow navigation plus Enter-key support. +An observer will look for toggle group CSS and add proper arrow navigation plus Enter-key support. ```html -
+
`; - await waitForField(); }; describe('Field component', () => { - it('should add id and connect label and input', async () => { - await renderDefault(); + it('should add id and connect label and input', () => { + render(); const label = document.querySelector('label'); const input = document.querySelector('input'); @@ -33,8 +28,8 @@ describe('Field component', () => { ); }); - it('should set aria-invalid when validation message is present', async () => { - await renderDefault(); + it('should set aria-invalid when validation message is present', () => { + render(); const input = document.querySelector('input'); @@ -42,13 +37,12 @@ describe('Field component', () => { expect(input).toHaveAttribute('aria-invalid', 'true'); }); - test('should update counter live region', async () => { + test('should update counter live region', () => { document.body.innerHTML = `

`; - await waitForField(); const textarea = document.querySelector('textarea'); const counter = document.querySelector('[data-field="counter"]'); @@ -67,7 +61,6 @@ describe('Field component', () => {

`; - await waitForField(); const textarea = document.querySelector('textarea'); const counter = document.querySelector('[data-field="counter"]'); @@ -76,12 +69,8 @@ describe('Field component', () => { expect(counter?.getAttribute('data-label')).toBe('13 tegn for mye'); counter?.setAttribute('data-limit', '10'); + await new Promise((resolve) => setTimeout(resolve, 0)); // Let MutationObserver in JS Event Loop run - expect( - vi.waitUntil( - () => counter?.getAttribute('data-label') === '23 tegn for mye', - 2000, - ), - ).toBeTruthy(); + expect(counter?.getAttribute('data-label')).toBe('23 tegn for mye'); }); }); diff --git a/packages/web/src/field/field.ts b/packages/web/src/field/field.ts index 252f642421..3a12aeb530 100644 --- a/packages/web/src/field/field.ts +++ b/packages/web/src/field/field.ts @@ -5,7 +5,6 @@ import { customElements, DSElement, debounce, - isBrowser, isWindows, on, onHotReload, @@ -22,78 +21,94 @@ declare global { } } -const INDETERMINATE = 'data-indeterminate'; -const FIELDS = new Set(); // Set of Field -const COUNTS = new WeakMap(); // Using WeakMap so removed inputs/counts does not cause memory leaks -const FIELDSETS = isBrowser() ? document.getElementsByTagName('fieldset') : []; -const HAS_FIELD_SIZING = isBrowser() && CSS.supports('field-sizing', 'content'); +const ATTR_DESCRIBEDBY = 'aria-describedby'; +const ATTR_INDETERMINATE = 'data-indeterminate'; const COUNTER_DEBOUNCE = isWindows() ? 800 : 200; // Longer debounce on Windows due to NVDA performance -const HAS_VALIDATION = new WeakSet(); // Used to store inputs that have/had validation elements to manage aria-invalid - -const handleMutations = debounce(() => { - for (const el of FIELDSETS) { - const labelledby = `${useId(el.querySelector('legend'))} ${useId(el.querySelector(':scope > :is([data-field="description"],legend + p)'))}`; - attr(el, 'aria-labelledby', labelledby.trim() || null); +const COUNTS = new WeakMap(); // Using WeakMap so removed inputs/counts does not cause memory leaks +const FIELDS = new Map(); // Map of Field and its describedby IDs so we can identify the ones we add/remove +const VALIDATIONS = new WeakMap(); // Used to ensure we only take control of aria-invalid if there current is or has been a validation element +const WARNING_MULTIPLE_INPUTS = `Fields should only have one input element. Use
to group multiple fields:`; + +const handleFieldMutations = (_doc: Node, records: MutationRecord[] = []) => { + for (const { target } of records) { + const isFieldset = target instanceof HTMLFieldSetElement; + for (const [field] of FIELDS) + if (isFieldset ? target.contains(field) : field.contains(target)) + handleFieldMutation(field); } - for (const field of FIELDS) { - const descs: Element[] = []; - const labels: HTMLLabelElement[] = []; - let input: HTMLInputElement | undefined; - let counter: Element | undefined; - let hasValidation = false; - let invalid = false; - - for (const el of field.getElementsByTagName('*')) { - if (el instanceof HTMLLabelElement) labels.push(el); - if ((el as HTMLElement).hidden) continue; // Skip hidden elements except labels - if (isInputLike(el)) { - if (input) - warn( - `Fields should only have one input element. Use
to group multiple fields:`, - field, - ); - else input = el; // Only register if visible input - } else { - const type = el.getAttribute('data-field'); // Using getAttribute instead of attr for best performance - if (type === 'counter') counter = el; - if (type === 'validation') { - descs.unshift(el); - hasValidation = true; - invalid = invalid || isInvalid(el); - } else if (type) descs.push(el); // Adds both counter and descriptions - } +}; + +const handleFieldMutation = (field: DSFieldElement) => { + const labels: HTMLLabelElement[] = []; + const nextDescs: string[] = []; // Keep track of descriptions we are adding in this mutation + const prevDescs = FIELDS.get(field) || []; // Retrieve previously managed IDs for this field + let input: HTMLInputElement | undefined; + let counter: Element | undefined; + let hasValidation = false; + let invalid = false; + + for (const el of field.getElementsByTagName('*')) { + if (el instanceof HTMLLabelElement) labels.push(el); + if ((el as HTMLElement).hidden) continue; // Skip hidden elements except labels + if (isInputLike(el)) { + if (input) warn(WARNING_MULTIPLE_INPUTS, field); + else input = el; // Only register if visible input + } else { + const type = el.getAttribute('data-field'); // Using getAttribute instead of attr for best performance + if (type === 'counter') counter = el; + if (type === 'validation') { + nextDescs.unshift(useId(el)); + hasValidation = true; + invalid = invalid || isInvalid(el); + } else if (type) nextDescs.push(useId(el)); // Adds both counter and descriptions } + } - if (!input) warn(`Field is missing input element:`, field); - else { - if (counter) COUNTS.set(input, counter); - for (const label of labels) attr(label, 'for', useId(input)); + if (!input) warn(`Field is missing input element:`, field); + else { + if (counter) COUNTS.set(input, counter); + for (const label of labels) attr(label, 'for', useId(input)); - const isBoolish = input.type === 'radio' || input.type === 'checkbox'; - const fieldsetValidation = field - .closest('fieldset') - ?.querySelector(':scope > [data-field="validation"]'); - if (fieldsetValidation && !fieldsetValidation?.hidden) { - hasValidation = true; - invalid = invalid || isInvalid(fieldsetValidation); - descs.unshift(fieldsetValidation); - } - - const indeterminate = attr(input, INDETERMINATE); - if (indeterminate) input.indeterminate = indeterminate === 'true'; - - attr(field, 'data-clickdelegatefor', isBoolish ? useId(input) : null); // Expand click area to ds-field if radio/checkbox - attr(input, 'aria-describedby', descs.map(useId).join(' ') || null); - if (hasValidation || HAS_VALIDATION.has(input)) { - HAS_VALIDATION[hasValidation ? 'add' : 'delete'](input); // Track if field has validation elements to avoid managing aria-invalid on every mutation - attr(input, 'aria-invalid', `${invalid}`); // Only manage aria-invalid when field has validation elements - } - updateField(input); // Update counter and textarea sizing + const fieldsetValidation = field + .closest('fieldset') + ?.querySelector(':scope > [data-field="validation"]'); + + // Connect fieldset validation to inputs + if (fieldsetValidation && !fieldsetValidation?.hidden) { + hasValidation = true; + invalid = invalid || isInvalid(fieldsetValidation); + nextDescs.unshift(useId(fieldsetValidation)); + } + + // Add support for data-indeterminate attribute as this normally can only be set by javascript + const indeterminate = attr(input, ATTR_INDETERMINATE); + if (indeterminate) input.indeterminate = indeterminate === 'true'; + + // Expand click area to ds-field if radio/checkbox + const isBoolish = input.type === 'radio' || input.type === 'checkbox'; + if (isBoolish) attr(field, 'data-clickdelegatefor', useId(input)); + + // Setup aria-describedby, but repsect existing ids in aria-describedby + const describedby = attr(input, ATTR_DESCRIBEDBY)?.trim().split(/\s+/); + const keep = describedby?.filter((id) => !prevDescs.includes(id)) || []; // Find non-ds-field-managed aria-describedby IDs + attr(input, ATTR_DESCRIBEDBY, [...nextDescs, ...keep].join(' ') || null); + FIELDS.set(field, nextDescs); + + // Only manage aria-invalid when field has validation elements + const hadValidation = VALIDATIONS.has(input); + if (hasValidation && !hadValidation) { + VALIDATIONS.set(input, attr(input, 'aria-invalid')); // Store previous attribute to enable reverting state + attr(input, 'aria-invalid', 'true'); + } else if (!hasValidation && hadValidation) { + attr(input, 'aria-invalid', VALIDATIONS.get(input)); // Revert to previous state if validation element was removed + VALIDATIONS.delete(input); } + + handleFieldInput(input); // Update counter and textarea sizing } -}, 0); // Debounce to merge multiple mutations +}; -const updateField = (e: Event | Element) => { +const handleFieldInput = (e: Event | Element) => { const input = ((e as Event).target || e) as HTMLInputElement; const counter = COUNTS.get(input); @@ -114,7 +129,7 @@ const updateField = (e: Event | Element) => { if ((e as Event).type === 'input' && label) debouncedCounterLiveRegion(input, label); // Debounce live region to avoid NVDA interupting announcing typed text } - if (!HAS_FIELD_SIZING && input instanceof HTMLTextAreaElement) { + if (input instanceof HTMLTextAreaElement) { input.style.setProperty('--_ds-field-sizing', 'auto'); input.style.setProperty('--_ds-field-sizing', `${input.scrollHeight}px`); } @@ -134,8 +149,8 @@ const isInputLike = (el: unknown): el is HTMLInputElement => // Custom element is used to performantly keep track of fields on the page export class DSFieldElement extends DSElement { connectedCallback() { - FIELDS.add(this); // Register field - handleMutations(); // Initial setup + FIELDS.set(this, []); // Register field + handleFieldMutation(this); // Initial setup } disconnectedCallback() { FIELDS.delete(this); @@ -144,16 +159,15 @@ export class DSFieldElement extends DSElement { customElements.define('ds-field', DSFieldElement); -// Listen for hidden to detect hidden validations, and listen for value to detect controlled React inputs onHotReload('field', () => [ - on(document, 'input', updateField, QUICK_EVENT), - onMutation(document, handleMutations, { + on(document, 'input', handleFieldInput, QUICK_EVENT), + onMutation(document, handleFieldMutations, { attributeFilter: [ 'data-field', 'data-limit', - 'hidden', - 'value', - INDETERMINATE, + 'hidden', // Needed to check validation visibility + 'value', // Needed to detect changes in controlled React inputs as they do not trigger input events + ATTR_INDETERMINATE, ], attributes: true, childList: true, diff --git a/packages/web/src/fieldset/fieldset.ts b/packages/web/src/fieldset/fieldset.ts new file mode 100644 index 0000000000..c9ae91ebe7 --- /dev/null +++ b/packages/web/src/fieldset/fieldset.ts @@ -0,0 +1,32 @@ +import { + attr, + isBrowser, + onHotReload, + onMutation, + useId, +} from '../utils/utils'; + +const FIELDSETS = isBrowser() ? document.getElementsByTagName('fieldset') : []; + +// NOTE: +//
descriptions should be accessible to screen reader users. However, using aria-describedby +// on
causes all child elements to inherit the same description, resulting in redundant and confusing announcements. +// To avoid this, we use aria-labelledby to reference both the legend and the description. +// aria-labelledby is only announced when screen readers enter the fieldset, not when navigating its child elements. +// This means the accessible name of
includes both the legend and description, which may differ from some test expectations, +// but as of March 2026, this approach provides the best user experience across assistive technologies. +// This approach is also verified by the chief of accessibility at NRK and the accessibility expert at NAV +const handleFieldsetMutations = () => { + for (const el of FIELDSETS) { + if (el.hasAttribute('aria-labelledby')) continue; // Speed up by skipping labelled fieldsets + const labelledby = `${useId(el.querySelector('legend'))} ${useId(el.querySelector(':scope > :is([data-field="description"],legend + p)'))}`; + attr(el, 'aria-labelledby', labelledby.trim() || null); + } +}; + +onHotReload('fieldset', () => [ + onMutation(document, handleFieldsetMutations, { + childList: true, + subtree: true, + }), +]); diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 5b6dce595f..a43f027e3f 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -3,6 +3,7 @@ import { isBrowser } from './utils/utils'; import '@u-elements/u-details/polyfill'; // Polyfill for
element for Android Firefox + Talkback import './clickdelegatefor/clickdelegatefor'; import './dialog/dialog'; +import './fieldset/fieldset'; import './popover/popover'; import './readonly/readonly'; import './toggle-group/toggle-group'; diff --git a/packages/web/src/pagination/pagination.ts b/packages/web/src/pagination/pagination.ts index 3f3b7283fe..1bb3558b0a 100644 --- a/packages/web/src/pagination/pagination.ts +++ b/packages/web/src/pagination/pagination.ts @@ -3,7 +3,6 @@ import { attrOrCSS, customElements, DSElement, - debounce, onMutation, warn, } from '../utils/utils'; @@ -46,14 +45,13 @@ export class DSPaginationElement extends DSElement { attr(this, ATTR_LABEL, attrOrCSS(this, ATTR_LABEL)); attr(this, 'role', 'navigation'); - this._render = debounce(() => render(this), 0); // Debounce groups mutation observer calls and attributeChangedCallback calls - this._unmutate = onMutation(this, this._render, { + this._unmutate = onMutation(this, render, { childList: true, subtree: true, }); } attributeChangedCallback() { - this._render?.(); + if (this._unmutate) render(this); // Ensure we do not run any renders before connectedCallback } disconnectedCallback() { this._unmutate?.(); diff --git a/packages/web/src/popover/popover.ts b/packages/web/src/popover/popover.ts index ca5ded4a2b..14827cbe99 100644 --- a/packages/web/src/popover/popover.ts +++ b/packages/web/src/popover/popover.ts @@ -8,7 +8,7 @@ import { shift, size, } from '@floating-ui/dom'; -import { attr, on, onHotReload, QUICK_EVENT } from '../utils/utils'; +import { attr, getCSSProp, on, onHotReload, QUICK_EVENT } from '../utils/utils'; declare global { interface GlobalEventHandlersEventMap { @@ -96,9 +96,6 @@ onHotReload('popover', () => [ on(document, 'toggle ds-toggle-source', handleToggle, QUICK_EVENT), // Use capture since the toggle event does not bubble ]); -const getCSSProp = (el: Element, prop: string) => - getComputedStyle(el).getPropertyValue(prop).trim(); - const arrowPseudo = () => ({ name: 'arrowPseudo', fn(data: MiddlewareState) { diff --git a/packages/web/src/readonly/readonly.ts b/packages/web/src/readonly/readonly.ts index 52a3779eb3..70db46d0b0 100644 --- a/packages/web/src/readonly/readonly.ts +++ b/packages/web/src/readonly/readonly.ts @@ -8,8 +8,11 @@ const isReadOnly = (el: unknown): el is HTMLInputElement | HTMLSelectElement => // If radio buttons, move focus without changing checked state const handleKeyDown = (e: Event & Partial) => { if (e.key !== 'Tab' && isReadOnly(e.target)) { - e.preventDefault(); - if (e.key?.startsWith('Arrow') && attr(e.target, 'type') === 'radio') { + const isArrow = e.key?.startsWith('Arrow'); // Always control arrow keys + const isModifier = e.altKey || e.ctrlKey || e.metaKey; // Allow modifier keys so native functions like CMD + D to bookmark etc. still works + + if (isArrow || !isModifier) e.preventDefault(); // Prevent changing - + Option 1 `; - const suggestion = document.querySelector( - 'ds-suggestion', - ) as SuggestionElement; - - vi.runAllTimers(); - await vi.waitUntil(() => { - const input = suggestion.querySelector('input'); - const list = suggestion.querySelector('u-datalist'); - return Boolean( - input?.getAttribute('popovertarget') && - list?.getAttribute('popover') === 'manual', - ); - }); - - return suggestion; + return document.querySelector('ds-suggestion') as DSSuggestionElement; }; describe('suggestion component', () => { it('sets placeholder, popovertarget, and popover attributes', async () => { - const suggestion = await renderSuggestion(); + const suggestion = render(); const input = suggestion.querySelector('input') as HTMLInputElement; const list = suggestion.querySelector('u-datalist') as HTMLElement; + await new Promise((resolve) => setTimeout(resolve, 0)); // Let mutation observer run + expect(input).toHaveAttribute('placeholder', ' '); expect(list.id).toBeTruthy(); expect(input).toHaveAttribute('popovertarget', list.id); expect(list).toHaveAttribute('popover', 'manual'); }); - it('dispatches ds-toggle-source when opened', async () => { - const suggestion = await renderSuggestion(); + it('dispatches ds-toggle-source when opened', () => { + const suggestion = render(); const input = suggestion.querySelector('input') as HTMLInputElement; const list = suggestion.querySelector('u-datalist') as HTMLElement; @@ -60,7 +44,7 @@ describe('suggestion component', () => { event.newState = 'open'; suggestion.dispatchEvent(event); - await vi.waitUntil(() => Boolean(detail)); + // await vi.waitUntil(() => Boolean(detail)); expect(detail).toBe(input); }); diff --git a/packages/web/src/suggestion/suggestion.ts b/packages/web/src/suggestion/suggestion.ts index 561ecde106..06689a119d 100644 --- a/packages/web/src/suggestion/suggestion.ts +++ b/packages/web/src/suggestion/suggestion.ts @@ -16,13 +16,12 @@ declare global { } export class DSSuggestionElement extends UHTMLComboboxElement { - _unmutate?: ReturnType; // Using underscore instead of private fields for backwards compatibility _render?: () => void; + _unmutate?: ReturnType; // Using underscore instead of private fields for backwards compatibility connectedCallback() { super.connectedCallback(); - this._render = () => render(this); - this._unmutate = onMutation(this, this._render, { childList: true }); // .control and .list are direct children of the custom element + this._unmutate = onMutation(this, render, { childList: true }); // .control and .list are direct children of the custom element on(this, 'toggle', polyfillToggleSource, QUICK_EVENT); } disconnectedCallback() { diff --git a/packages/web/src/toggle-group/toggle-group.test.ts b/packages/web/src/toggle-group/toggle-group.test.ts index c80063c140..4062339b79 100644 --- a/packages/web/src/toggle-group/toggle-group.test.ts +++ b/packages/web/src/toggle-group/toggle-group.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it, vi } from 'vitest'; -const renderToggleGroup = async () => { +const render = () => { document.body.innerHTML = ` -
+ +
`; - vi.runAllTimers(); - - const group = document.querySelector('[data-toggle-group]') as HTMLElement; - const inputs = [...group.querySelectorAll('input')] as HTMLInputElement[]; - - await vi.waitUntil( - () => group.getAttribute('aria-label') === 'Tekstjustering', - ); + const group = document.querySelector('fieldset') as HTMLFieldSetElement; + const inputs = [...group.querySelectorAll('input')]; return { group, inputs }; }; describe('toggle-group behavior', () => { - it('sets aria-label from data-toggle-group', async () => { - const { group } = await renderToggleGroup(); - + it('sets aria-label from aria-label', () => { + const { group } = render(); expect(group).toHaveAttribute('aria-label', 'Tekstjustering'); }); - it('clicks input on Enter', async () => { - const { inputs } = await renderToggleGroup(); + it('clicks input on Enter', () => { + const { inputs } = render(); const clickSpy = vi.spyOn(inputs[0], 'click'); @@ -52,10 +48,9 @@ describe('toggle-group behavior', () => { }); it('moves focus with arrow keys and wraps', async () => { - const { inputs } = await renderToggleGroup(); + const { inputs } = render(); inputs[0].focus(); - inputs[0].dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }), ); diff --git a/packages/web/src/toggle-group/toggle-group.ts b/packages/web/src/toggle-group/toggle-group.ts index 98937d3c6e..a9b09f1e26 100644 --- a/packages/web/src/toggle-group/toggle-group.ts +++ b/packages/web/src/toggle-group/toggle-group.ts @@ -1,32 +1,17 @@ -import { - attr, - attrOrCSS, - debounce, - on, - onHotReload, - onMutation, -} from '../utils/utils'; - -const ATTR_TOGGLEGROUP = 'data-toggle-group'; -const SELECTOR_TOGGLEGROUP = `[${ATTR_TOGGLEGROUP}]`; - -const handleAriaAttributes = debounce(() => { - for (const group of document.querySelectorAll(SELECTOR_TOGGLEGROUP)) - attr(group, 'aria-label', attrOrCSS(group, ATTR_TOGGLEGROUP)); -}, 0); // Debounce to merge multiple mutations +import { attr, getCSSProp, on, onHotReload, warn } from '../utils/utils'; const handleKeydown = (event: Partial) => { - const group = - event.target instanceof HTMLInputElement && - event.target.closest(SELECTOR_TOGGLEGROUP); + const { key, target: el } = event; + const group = el instanceof HTMLInputElement && el.closest('fieldset'); - if (!group) return; - if (event.key === 'Enter') event.target.click(); // Forward Enter, but no need to listen for space key, as this is handled by the browser - if (event.key?.startsWith('Arrow')) { + if (!group || !getCSSProp(el, '--_ds-toggle-group')) return; + if (!attr(group, 'aria-label')) warn('Missing aria-label on:', group); + if (key === 'Enter') el.click(); // Forward Enter, but no need to listen for space key, as this is handled by the browser + if (key?.startsWith('Arrow')) { event.preventDefault?.(); const inputs = [...group.getElementsByTagName('input')]; - const index = inputs.indexOf(event.target); - const move = event.key.match(/Arrow(Right|Down)/) ? 1 : -1; + const index = inputs.indexOf(el); + const move = key.match(/Arrow(Right|Down)/) ? 1 : -1; let nextIndex = index; for (let i = 0; i < inputs.length; i++) { @@ -39,12 +24,4 @@ const handleKeydown = (event: Partial) => { } }; -onHotReload('toggle-group', () => [ - on(document, 'keydown', handleKeydown), - onMutation(document, handleAriaAttributes, { - attributeFilter: [ATTR_TOGGLEGROUP], - attributes: true, - childList: true, - subtree: true, - }), -]); +onHotReload('toggle-group', () => [on(document, 'keydown', handleKeydown)]); diff --git a/packages/web/src/tooltip/tooltip.ts b/packages/web/src/tooltip/tooltip.ts index 294ddc13dd..6600f9f314 100644 --- a/packages/web/src/tooltip/tooltip.ts +++ b/packages/web/src/tooltip/tooltip.ts @@ -2,7 +2,6 @@ import { announce, attr, attrOrCSS, - debounce, isBrowser, on, onHotReload, @@ -43,12 +42,12 @@ export const setTooltipElement = (el?: HTMLElement | null) => { TIP = el || undefined; }; -const handleAriaAttributes = debounce(() => { +const handleAriaAttributes = () => { for (const el of document.querySelectorAll(SELECTOR_TOOLTIP)) { - const aria = el.getAttribute(ARIA_LABEL) || el.getAttribute(ARIA_DESC); // Using getAttribute for best performance - const text = el.getAttribute(ATTR_TOOLTIP) || attrOrCSS(el, ATTR_TOOLTIP); // Only parse CSS if attribute is empty for better performance + const text = attrOrCSS(el, ATTR_TOOLTIP); - if (aria !== text) { + if (!text) return; // Early return if no tooltip text + if (text !== (el.getAttribute(ARIA_LABEL) || el.getAttribute(ARIA_DESC))) { const hasText = attr(el, 'role') !== 'img' && el.textContent?.trim(); // If role="img", ignore text attr(el, ATTR_TOOLTIP, text); // Set data-tooltip attribute to speed up future mutations attr(el, ARIA_LABEL, hasText ? null : text); // Set aria-label if element does not have text @@ -65,7 +64,7 @@ const handleAriaAttributes = debounce(() => { if (document.activeElement === el) announce(text); // Only announce if focus is on the button } } -}, 0); // Debounce to merge multiple mutations +}; const handleInterest = ({ type, target }: Event) => { clearTimeout(HOVER_TIMER); @@ -85,6 +84,7 @@ const handleInterest = ({ type, target }: Event) => { const color = source.closest(SELECTOR_COLOR); // Match source color of source element const scheme = source.closest(SELECTOR_SCHEME); // Match source color-scheme of source element const isReset = color !== scheme && color?.contains(scheme as Node); // If data-scheme is closer to target, it will reset data-color + clearTimeout(SKIP_TIMER); attr(TIP, 'popover', 'manual'); // Ensure popover behavior attr(TIP, ATTR_SCHEME, scheme?.getAttribute(ATTR_SCHEME) || null); // Fallback to null to reset if not scheme found diff --git a/packages/web/src/utils/utils.ts b/packages/web/src/utils/utils.ts index e8db0513ba..2e7ed21552 100644 --- a/packages/web/src/utils/utils.ts +++ b/packages/web/src/utils/utils.ts @@ -62,7 +62,17 @@ export const attr = ( return null; }; -const STRIP_SURROUNDING_QUOTES = /^["']|["']$/g; // Matches surrounding single or double quotes +/** + * getCSSProp + * @description Retrieves and CSS property value and trims it + * @param el The Element to read attributes/CSS from + * @param name Attribute or CSS property to get + * @return string CSS property value + */ +export const getCSSProp = (el: Element, prop: string) => + getComputedStyle(el).getPropertyValue(prop).trim(); + +const STRIP_QUOTES = /^["']|["']$/g; // Matches surrounding single or double quotes /** * attrOrCSS * @description Retrieves and updates attribute based on attribute or CSS property value @@ -72,12 +82,10 @@ const STRIP_SURROUNDING_QUOTES = /^["']|["']$/g; // Matches surrounding single o */ export const attrOrCSS = (el: Element, name: string) => { let value = attr(el, name); - if (!value) { - const prop = getComputedStyle(el).getPropertyValue(`--_ds-${name}`); - value = prop.replace(STRIP_SURROUNDING_QUOTES, '').trim() || null; - } + if (!value) + value = getCSSProp(el, `--_ds-${name}`).replace(STRIP_QUOTES, '').trim(); if (!value) warn(`Missing ${name} on:`, el); - return value; + return value || null; }; /** @@ -131,29 +139,23 @@ export const onHotReload = (key: string, setup: () => Array<() => void>) => { }; /** - * Speed up MutationObserver by debouncing and only running when page is visible + * MutationObserver wrapper with automatic cleanup and option to skip mutations while updating textContent * @return new MutaionObserver */ let SKIP_MUTATIONS = false; -export const onMutation = ( - el: Node, - callback: (observer: MutationObserver) => void, +export const onMutation = ( + el: T, + callback: (el: T, records?: MutationRecord[]) => void, options: MutationObserverInit, ) => { - let queue = 0; - const onFrame = () => { - if (!el.isConnected) return cleanup(); // Stop observing if element is removed from DOM - callback(observer); - observer.takeRecords(); // Clear records in case mutations happened during callback - queue = 0; - }; - const cleanup = () => observer?.disconnect?.(); - const observer = new MutationObserver(() => { - if (!SKIP_MUTATIONS && !queue) queue = requestAnimationFrame(onFrame); // requestAnimationFrame only runs when page is visible + const cleanup = () => observer.disconnect(); + const observer = new MutationObserver((records) => { + if (!isBrowser() || !el.isConnected) return cleanup(); // Stop observing if element is removed from DOM or document is removed by jdsom tests + if (!SKIP_MUTATIONS) callback(el, records); }); observer.observe(el, options); - requestAnimationFrame(onFrame); // Initial run when page is visible and children has mounted + callback(el); // Initial is run instantly to make test markup predictable return cleanup; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52d2113a34..ac5840ac66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -688,9 +688,6 @@ importers: '@u-elements/u-tabs': specifier: ^0.1.2 version: 0.1.2 - invokers-polyfill: - specifier: ^0.6.1 - version: 0.6.1 devDependencies: '@custom-elements-manifest/analyzer': specifier: ^0.11.0 @@ -704,6 +701,9 @@ importers: custom-element-vs-code-integration: specifier: ^1.5.0 version: 1.5.0(prettier@3.8.1) + invokers-polyfill: + specifier: ^0.6.1 + version: 0.6.1 rimraf: specifier: ^6.1.3 version: 6.1.3