From 779a3bd2a73ef9e28fa5537796f2942af307400b Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Thu, 26 Mar 2026 13:39:57 +0100 Subject: [PATCH 01/13] fix(web): test friendliness --- .../color-preview/color-preview.tsx | 2 +- .../app/_components/previews/previews.tsx | 4 +- .../live-component/live-components.tsx | 2 +- .../blog/en/designsystemet-web-pre2.mdx | 2 +- .../blog/en/designsystemet-web-release.mdx | 1 - .../blog/no/designsystemet-web-pre2.mdx | 2 +- .../blog/no/designsystemet-web-release.mdx | 1 - .../components/toggle-group/en/code.mdx | 6 +- .../components/toggle-group/no/code.mdx | 6 +- .../toggle-group/toggle-group.stories.tsx | 24 +-- packages/css/src/toggle-group.css | 3 +- .../pagination/pagination-button.tsx | 25 +-- .../components/suggestion/suggestion-list.tsx | 3 +- .../src/components/toggle-group/index.ts | 2 +- .../toggle-group/toggle-group-item.tsx | 29 ++-- .../toggle-group/toggle-group.stories.tsx | 24 +-- .../toggle-group/toggle-group.test.tsx | 28 ++-- .../components/toggle-group/toggle-group.tsx | 11 +- packages/react/stories/typography.stories.tsx | 2 +- packages/web/README.md | 8 +- packages/web/index.html | 8 +- packages/web/package.json | 6 +- packages/web/rolldown.config.js | 8 +- .../web/src/breadcrumbs/breadcrumbs.test.ts | 6 +- packages/web/src/breadcrumbs/breadcrumbs.ts | 16 +- packages/web/src/dialog/dialog.test.ts | 18 +-- packages/web/src/dialog/dialog.ts | 13 +- packages/web/src/field/field.test.ts | 27 +--- packages/web/src/field/field.ts | 147 ++++++++++-------- packages/web/src/pagination/pagination.ts | 5 +- packages/web/src/popover/popover.ts | 5 +- packages/web/src/readonly/readonly.ts | 7 +- .../web/src/suggestion/suggestion.test.ts | 38 ++--- packages/web/src/suggestion/suggestion.ts | 5 +- .../web/src/toggle-group/toggle-group.test.ts | 29 ++-- packages/web/src/toggle-group/toggle-group.ts | 43 ++--- packages/web/src/tooltip/tooltip.ts | 4 +- packages/web/src/utils/utils.ts | 36 +++-- 38 files changed, 269 insertions(+), 337 deletions(-) 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 ` `; - await flushTimers(); }; describe('Dialog behavior', () => { it('should set aria-haspopup on show-modal buttons', async () => { - await renderDefault(); + render(); const button = document.querySelector('button') as HTMLButtonElement; - await user.click(button); // Trigger mutation observer to set aria-haspopup + vi.runAllTimers(); // Skip dialog debounce + await new Promise((resolve) => setTimeout(resolve, 0)); // Let mutation observer run expect(button).toHaveAttribute('aria-haspopup', 'dialog'); }); it('should call show for --show-non-modal command', async () => { - await renderDefault(); + render(); const dialog = document.querySelector('dialog'); @@ -48,7 +42,7 @@ describe('Dialog behavior', () => { (event as Event & { command?: string }).command = '--show-non-modal'; dialog?.dispatchEvent(event); - await flushTimers(); + vi.runAllTimers(); expect(showSpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/web/src/dialog/dialog.ts b/packages/web/src/dialog/dialog.ts index 4b30961f3d..ed1ca5af53 100644 --- a/packages/web/src/dialog/dialog.ts +++ b/packages/web/src/dialog/dialog.ts @@ -1,4 +1,11 @@ -import { attr, on, onHotReload, onMutation, QUICK_EVENT } from '../utils/utils'; +import { + attr, + debounce, + on, + onHotReload, + onMutation, + QUICK_EVENT, +} from '../utils/utils'; // Polyfill closedby functionaliy in Safari // Also in Safari 26.2 where `closedBy` property is supported natively, @@ -26,10 +33,10 @@ const handleClosedbyAny = ({ }; // Ensure buttons that trigger a modeal dialog has aria-haspopup="dialog" for better screen reader experience -const handleAriaAttributes = () => { +const handleAriaAttributes = debounce(() => { for (const btn of document.querySelectorAll('button[command="show-modal"]')) attr(btn, 'aria-haspopup', 'dialog'); -}; +}, 10); // This is a non-vital enhancement, and can run after a delay to avoid blocking event loop const handleCommand = ({ command, target }: Event & { command?: string }) => command === '--show-non-modal' && diff --git a/packages/web/src/field/field.test.ts b/packages/web/src/field/field.test.ts index 69aa116a7c..46af2827ae 100644 --- a/packages/web/src/field/field.test.ts +++ b/packages/web/src/field/field.test.ts @@ -1,11 +1,7 @@ /// import { describe, expect, it, test, vi } from 'vitest'; -const waitForField = async () => { - vi.runAllTimers(); -}; - -const renderDefault = async () => { +const render = () => { document.body.innerHTML = ` @@ -13,12 +9,11 @@ const renderDefault = async () => { Dette er ein feilmelding
`; - 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..f81b2b82b2 100644 --- a/packages/web/src/field/field.ts +++ b/packages/web/src/field/field.ts @@ -26,74 +26,92 @@ 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 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(() => { +// 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. +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); } - 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 - } - } +}; - 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 handleFieldMutations = (_: unknown, mutations?: MutationRecord[]) => { + if (!mutations) return; // Initial calls are handled by connectedCallback, not mutation triggered + for (const { target } of mutations) { + const isFieldset = target instanceof HTMLFieldSetElement; + for (const field of FIELDS) + if (isFieldset ? target.contains(field) : field.contains(target)) + handleFieldMutation(field); + } +}; - const isBoolish = input.type === 'radio' || input.type === 'checkbox'; - const fieldsetValidation = field - .closest('fieldset') - ?.querySelector(':scope > [data-field="validation"]'); - if (fieldsetValidation && !fieldsetValidation?.hidden) { +const handleFieldMutation = (field: DSFieldElement) => { + 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(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 + invalid = invalid || isInvalid(el); + } else if (type) descs.push(el); // Adds both counter and descriptions } } -}, 0); // Debounce to merge multiple mutations -const updateField = (e: Event | Element) => { + 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 fieldsetValidation = field + .closest('fieldset') + ?.querySelector(':scope > [data-field="validation"]'); + + // Connect fieldset validation to inputs + if (fieldsetValidation && !fieldsetValidation?.hidden) { + hasValidation = true; + invalid = invalid || isInvalid(fieldsetValidation); + descs.unshift(fieldsetValidation); + } + + // Add support for data-indeterminate attribute as this normally can only be set by javascript + const indeterminate = attr(input, INDETERMINATE); + if (indeterminate) input.indeterminate = indeterminate === 'true'; + + // Expand click area to ds-field if radio/checkbox + const isBoolish = input.type === 'radio' || input.type === 'checkbox'; + attr(field, 'data-clickdelegatefor', isBoolish ? useId(input) : null); + attr(input, 'aria-describedby', descs.map(useId).join(' ') || null); + attr(input, 'aria-invalid', `${hasValidation && invalid}`); + handleFieldInput(input); // Update counter and textarea sizing + } +}; + +const handleFieldInput = (e: Event | Element) => { const input = ((e as Event).target || e) as HTMLInputElement; const counter = COUNTS.get(input); @@ -114,7 +132,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`); } @@ -135,7 +153,7 @@ const isInputLike = (el: unknown): el is HTMLInputElement => export class DSFieldElement extends DSElement { connectedCallback() { FIELDS.add(this); // Register field - handleMutations(); // Initial setup + handleFieldMutation(this); // Initial setup } disconnectedCallback() { FIELDS.delete(this); @@ -144,15 +162,18 @@ 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, handleFieldsetMutations, { + childList: true, + subtree: true, + }), + onMutation(document, handleFieldMutations, { attributeFilter: [ 'data-field', 'data-limit', - 'hidden', - 'value', + 'hidden', // Needed to check validation visibility + 'value', // Needed to detect changes in controlled React inputs as they do not trigger input events INDETERMINATE, ], attributes: true, diff --git a/packages/web/src/pagination/pagination.ts b/packages/web/src/pagination/pagination.ts index 3f3b7283fe..2c52c4418d 100644 --- a/packages/web/src/pagination/pagination.ts +++ b/packages/web/src/pagination/pagination.ts @@ -46,14 +46,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(this), { 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..31ca9e30ab 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(this), { 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..e27445fe76 100644 --- a/packages/web/src/tooltip/tooltip.ts +++ b/packages/web/src/tooltip/tooltip.ts @@ -46,7 +46,7 @@ export const setTooltipElement = (el?: HTMLElement | null) => { const handleAriaAttributes = debounce(() => { 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) { const hasText = attr(el, 'role') !== 'img' && el.textContent?.trim(); // If role="img", ignore text @@ -65,7 +65,7 @@ const handleAriaAttributes = debounce(() => { if (document.activeElement === el) announce(text); // Only announce if focus is on the button } } -}, 0); // Debounce to merge multiple mutations +}, 10); // Debounce to merge multiple mutations const handleInterest = ({ type, target }: Event) => { clearTimeout(HOVER_TIMER); diff --git a/packages/web/src/utils/utils.ts b/packages/web/src/utils/utils.ts index e8db0513ba..f427fbc5f8 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; }; /** @@ -137,23 +145,17 @@ export const onHotReload = (key: string, setup: () => Array<() => void>) => { let SKIP_MUTATIONS = false; export const onMutation = ( el: Node, - callback: (observer: MutationObserver) => void, + callback: (observer: MutationObserver, records?: MutationRecord[]) => void, options: MutationObserverInit, ) => { - let queue = 0; - const onFrame = () => { + const cleanup = () => observer.disconnect(); + const observer = new MutationObserver((records) => { 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 + if (!SKIP_MUTATIONS) callback(observer, records); }); observer.observe(el, options); - requestAnimationFrame(onFrame); // Initial run when page is visible and children has mounted + callback(observer); // Initial is run instantly to make test markup predictable return cleanup; }; From 95506392b9841ce36ca46f49915c0679774a8d00 Mon Sep 17 00:00:00 2001 From: Eirik Backer Date: Thu, 26 Mar 2026 14:00:16 +0100 Subject: [PATCH 02/13] Create sharp-bulldogs-run.md --- .changeset/sharp-bulldogs-run.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sharp-bulldogs-run.md 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 From b422e167806b9db520d7f29d37ddba94a0229ffd Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Thu, 26 Mar 2026 14:01:17 +0100 Subject: [PATCH 03/13] chore: update lock file --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 8f66d77215607e97e82fbbf9debdd16c437618d6 Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Fri, 27 Mar 2026 07:04:16 +0100 Subject: [PATCH 04/13] fix: remove redundant aria-invalid="false" --- packages/web/src/field/field.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/field/field.ts b/packages/web/src/field/field.ts index f81b2b82b2..fddc23824b 100644 --- a/packages/web/src/field/field.ts +++ b/packages/web/src/field/field.ts @@ -106,7 +106,7 @@ const handleFieldMutation = (field: DSFieldElement) => { const isBoolish = input.type === 'radio' || input.type === 'checkbox'; attr(field, 'data-clickdelegatefor', isBoolish ? useId(input) : null); attr(input, 'aria-describedby', descs.map(useId).join(' ') || null); - attr(input, 'aria-invalid', `${hasValidation && invalid}`); + attr(input, 'aria-invalid', hasValidation && invalid ? 'true' : null); handleFieldInput(input); // Update counter and textarea sizing } }; From 89e2c44d47ad25d92e4923d1cc450d14d5e9a212 Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Fri, 27 Mar 2026 08:34:50 +0100 Subject: [PATCH 05/13] fix: speed up tooltip, dialog buttons and errorsummary and fix react tooltip setup --- .../components/tooltip/tooltip.stories.tsx | 52 +++++++++++++++---- .../react/src/components/tooltip/tooltip.tsx | 5 +- packages/web/src/dialog/dialog.ts | 12 +++-- .../web/src/error-summary/error-summary.ts | 23 +++++--- packages/web/src/field/field.ts | 6 +-- packages/web/src/tooltip/tooltip.ts | 49 +++++++++-------- 6 files changed, 98 insertions(+), 49 deletions(-) diff --git a/packages/react/src/components/tooltip/tooltip.stories.tsx b/packages/react/src/components/tooltip/tooltip.stories.tsx index 6561369200..8655e9e203 100644 --- a/packages/react/src/components/tooltip/tooltip.stories.tsx +++ b/packages/react/src/components/tooltip/tooltip.stories.tsx @@ -1,6 +1,6 @@ import { FilesIcon } from '@navikt/aksel-icons'; import type { Meta, StoryFn, StoryObj } from '@storybook/react-vite'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { expect, within } from 'storybook/test'; import { Button } from '../../'; import { Tooltip } from './tooltip'; @@ -80,6 +80,18 @@ export const Aria: StoryFn = () => { ); }; +Aria.decorators = [ + (Story) => ( +
+ +
+ ), +]; + +Aria.play = async () => {}; + export const WithDynamicTooltipText: Story = { args: { content: 'Kopier', @@ -101,14 +113,32 @@ export const WithDynamicTooltipText: Story = { }, }; -Aria.decorators = [ - (Story) => ( -
- -
- ), -]; +export const WithCSSTooltipText: Story = { + args: { + content: 'Kopier', + }, + render: () => { + const tooltipRef = useRef(null); + const [tooltipContent, setTooltipContent] = useState(''); -Aria.play = async () => {}; + // 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/web/src/dialog/dialog.ts b/packages/web/src/dialog/dialog.ts index ed1ca5af53..302f163875 100644 --- a/packages/web/src/dialog/dialog.ts +++ b/packages/web/src/dialog/dialog.ts @@ -1,6 +1,6 @@ import { attr, - debounce, + isBrowser, on, onHotReload, onMutation, @@ -33,10 +33,12 @@ const handleClosedbyAny = ({ }; // Ensure buttons that trigger a modeal dialog has aria-haspopup="dialog" for better screen reader experience -const handleAriaAttributes = debounce(() => { - for (const btn of document.querySelectorAll('button[command="show-modal"]')) - attr(btn, 'aria-haspopup', 'dialog'); -}, 10); // This is a non-vital enhancement, and can run after a delay to avoid blocking event loop +const BUTTONS = isBrowser() ? document.getElementsByTagName('button') : []; +const handleAriaAttributes = () => { + for (const btn of BUTTONS) + if (btn.getAttribute('command')?.endsWith('-modal')) + btn.setAttribute('aria-haspopup', 'dialog'); // Using get/setAttribute for performance +}; const handleCommand = ({ command, target }: Event & { command?: string }) => command === '--show-non-modal' && diff --git a/packages/web/src/error-summary/error-summary.ts b/packages/web/src/error-summary/error-summary.ts index fc1667ea8a..c66774fdb8 100644 --- a/packages/web/src/error-summary/error-summary.ts +++ b/packages/web/src/error-summary/error-summary.ts @@ -4,6 +4,7 @@ import { DSElement, off, on, + onMutation, QUICK_EVENT, useId, } from '../utils/utils'; @@ -15,20 +16,30 @@ declare global { } export class DSErrorSummaryElement extends DSElement { + _unmutate?: () => void; // Using underscore instead of private fields for backwards compatibility + connectedCallback() { on(this, 'animationend', this, QUICK_EVENT); // Using animationend to detect when element is visible - requestAnimationFrame(() => this.handleEvent({ target: this })); // Initial setup when children has rendered - } - handleEvent({ target }: Partial) { - if (target !== this) return; // Ignore if animation event was triggered by child - const heading = this.querySelector('h2,h3,h4,h5,h6'); - if (heading) attr(this, 'aria-labelledby', useId(heading)); attr(this, 'tabindex', '-1'); + this._unmutate = onMutation(this, () => render(this), { + childList: true, + subtree: true, + }); this.focus(); } + handleEvent({ target }: Event) { + if (target === this) this.focus(); // Ignore if animation event was triggered by child + } disconnectedCallback() { off(this, 'animationend', this, QUICK_EVENT); + this._unmutate?.(); + this._unmutate = undefined; } } +const render = (self: DSErrorSummaryElement) => { + const heading = self.querySelector('h2,h3,h4,h5,h6'); + if (heading) attr(self, 'aria-labelledby', useId(heading)); +}; + customElements.define('ds-error-summary', DSErrorSummaryElement); diff --git a/packages/web/src/field/field.ts b/packages/web/src/field/field.ts index fddc23824b..800057c653 100644 --- a/packages/web/src/field/field.ts +++ b/packages/web/src/field/field.ts @@ -22,7 +22,7 @@ declare global { } } -const INDETERMINATE = 'data-indeterminate'; +const ATTR_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') : []; @@ -99,7 +99,7 @@ const handleFieldMutation = (field: DSFieldElement) => { } // Add support for data-indeterminate attribute as this normally can only be set by javascript - const indeterminate = attr(input, INDETERMINATE); + const indeterminate = attr(input, ATTR_INDETERMINATE); if (indeterminate) input.indeterminate = indeterminate === 'true'; // Expand click area to ds-field if radio/checkbox @@ -174,7 +174,7 @@ onHotReload('field', () => [ 'data-limit', 'hidden', // Needed to check validation visibility 'value', // Needed to detect changes in controlled React inputs as they do not trigger input events - INDETERMINATE, + ATTR_INDETERMINATE, ], attributes: true, childList: true, diff --git a/packages/web/src/tooltip/tooltip.ts b/packages/web/src/tooltip/tooltip.ts index e27445fe76..9d9e1dca53 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, @@ -25,7 +24,6 @@ const ATTR_COLOR = 'data-color'; const ARIA_LABEL = 'aria-label'; const ARIA_DESC = 'aria-description'; const SELECTOR_COLOR = `[${ATTR_COLOR}]`; -const SELECTOR_TOOLTIP = `[${ATTR_TOOLTIP}]`; const ATTR_SCHEME = 'data-color-scheme'; const SELECTOR_SCHEME = `[${ATTR_SCHEME}]`; const SELECTOR_INTERACTIVE = 'a,button,input,label,select,textarea,[tabindex]'; @@ -43,29 +41,35 @@ export const setTooltipElement = (el?: HTMLElement | null) => { TIP = el || undefined; }; -const handleAriaAttributes = debounce(() => { - 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 = attrOrCSS(el, ATTR_TOOLTIP); +const handleAriaAttributes = (_: unknown, records?: MutationRecord[]) => { + if (!records) + for (const el of document.querySelectorAll(`[${ATTR_TOOLTIP}]`)) render(el); // Initial setup + else + for (const { target: el } of records) + if ((el as Element).hasAttribute?.(ATTR_TOOLTIP)) render(el as Element); +}; - if (aria !== text) { - 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 - attr(el, ARIA_DESC, hasText ? text : null); // Set aria-description if element has text - if (!el.matches(SELECTOR_INTERACTIVE)) - warn('Missing tabindex="0" attribute on: ', el); - } +const render = (el: Element) => { + const aria = el.getAttribute(ARIA_LABEL) || el.getAttribute(ARIA_DESC); // Using getAttribute for best performance + const text = attrOrCSS(el, ATTR_TOOLTIP); // Using getAttribute for best performance - // If an existing tooltip has changed programmatically, update tooltip text and announce change - const isCurrent = el === SOURCE && TIP?.matches(':popover-open'); - const isChanged = isCurrent && text && TIP?.textContent !== text; // Only update if mutation is on source element and tooltip is open to avoid unnecessary updates - if (isCurrent && isChanged) { - if (TIP) setTextWithoutMutation(TIP, text); - if (document.activeElement === el) announce(text); // Only announce if focus is on the button - } + if (aria !== text) { + 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 + attr(el, ARIA_DESC, hasText ? text : null); // Set aria-description if element has text + if (!el.matches(SELECTOR_INTERACTIVE)) + warn('Missing tabindex="0" attribute on: ', el); } -}, 10); // Debounce to merge multiple mutations + + // If an existing tooltip has changed programmatically, update tooltip text and announce change + const isCurrent = el === SOURCE && TIP?.matches(':popover-open'); + const isChanged = isCurrent && text && TIP?.textContent !== text; // Only update if mutation is on source element and tooltip is open to avoid unnecessary updates + if (isCurrent && isChanged) { + if (TIP) setTextWithoutMutation(TIP, text); + if (document.activeElement === el) announce(text); // Only announce if focus is on the button + } +}; const handleInterest = ({ type, target }: Event) => { clearTimeout(HOVER_TIMER); @@ -85,6 +89,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 From 1978a21d7e6064c0e6c33d7492a74d7ab466441d Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Fri, 27 Mar 2026 10:01:22 +0100 Subject: [PATCH 06/13] fix field allow manually setting aria-invalid without vaildation message --- packages/web/src/field/field.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/web/src/field/field.ts b/packages/web/src/field/field.ts index 800057c653..5b5dced071 100644 --- a/packages/web/src/field/field.ts +++ b/packages/web/src/field/field.ts @@ -27,6 +27,7 @@ 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 COUNTER_DEBOUNCE = isWindows() ? 800 : 200; // Longer debounce on Windows due to NVDA performance +const HAS_VALIDATION = new WeakMap(); // Used to ensure we only take control of aria-invalid if there current is or has been a validation element // NOTE: //
descriptions should be accessible to screen reader users. However, using aria-describedby @@ -106,7 +107,15 @@ const handleFieldMutation = (field: DSFieldElement) => { const isBoolish = input.type === 'radio' || input.type === 'checkbox'; attr(field, 'data-clickdelegatefor', isBoolish ? useId(input) : null); attr(input, 'aria-describedby', descs.map(useId).join(' ') || null); - attr(input, 'aria-invalid', hasValidation && invalid ? 'true' : null); + + // Used to ensure we only take control of aria-invalid if there current is or has been a validation element + if (hasValidation || HAS_VALIDATION.has(input)) { + const prev = HAS_VALIDATION.get(input); + if (!hasValidation) HAS_VALIDATION.delete(input); + else HAS_VALIDATION.set(input, attr(input, 'aria-invalid')); // Store previous attribute to enable reverting state + attr(input, 'aria-invalid', invalid ? 'true' : prev); // Only manage aria-invalid when field has validation elements + } + handleFieldInput(input); // Update counter and textarea sizing } }; From 9276eea581d60187f92530240b1184dd0f85dd9a Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Fri, 27 Mar 2026 10:08:12 +0100 Subject: [PATCH 07/13] fix: clarify field validation logic --- packages/web/src/field/field.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/web/src/field/field.ts b/packages/web/src/field/field.ts index 5b5dced071..e12b7d91eb 100644 --- a/packages/web/src/field/field.ts +++ b/packages/web/src/field/field.ts @@ -108,12 +108,14 @@ const handleFieldMutation = (field: DSFieldElement) => { attr(field, 'data-clickdelegatefor', isBoolish ? useId(input) : null); attr(input, 'aria-describedby', descs.map(useId).join(' ') || null); - // Used to ensure we only take control of aria-invalid if there current is or has been a validation element - if (hasValidation || HAS_VALIDATION.has(input)) { - const prev = HAS_VALIDATION.get(input); - if (!hasValidation) HAS_VALIDATION.delete(input); - else HAS_VALIDATION.set(input, attr(input, 'aria-invalid')); // Store previous attribute to enable reverting state - attr(input, 'aria-invalid', invalid ? 'true' : prev); // Only manage aria-invalid when field has validation elements + // Only manage aria-invalid when field has validation elements + const hadValidation = HAS_VALIDATION.has(input); + if (hasValidation && !hadValidation) { + HAS_VALIDATION.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', HAS_VALIDATION.get(input)); // Revert to previous state if validation element was removed + HAS_VALIDATION.delete(input); } handleFieldInput(input); // Update counter and textarea sizing From 54c722544d51a6bc5c5af4fba8cf4a1c3f197a02 Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Fri, 27 Mar 2026 10:46:54 +0100 Subject: [PATCH 08/13] chore: add comment --- packages/web/src/field/field.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/field/field.ts b/packages/web/src/field/field.ts index e12b7d91eb..c4badedcba 100644 --- a/packages/web/src/field/field.ts +++ b/packages/web/src/field/field.ts @@ -36,6 +36,7 @@ const HAS_VALIDATION = new WeakMap(); // Used to ensure we onl // 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 From ceac39ec5d021c6e9484e9e4cfd3909f243e7780 Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Fri, 27 Mar 2026 10:48:59 +0100 Subject: [PATCH 09/13] chore: update commment --- packages/web/src/utils/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/utils/utils.ts b/packages/web/src/utils/utils.ts index f427fbc5f8..a456d1dab4 100644 --- a/packages/web/src/utils/utils.ts +++ b/packages/web/src/utils/utils.ts @@ -139,7 +139,7 @@ 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; From 22345a7ba272b5decc3ae04be1ed456155d1bbce Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Fri, 27 Mar 2026 12:56:01 +0100 Subject: [PATCH 10/13] fix: update tooltip and link styling --- packages/css/src/link.css | 8 ++-- .../components/tooltip/tooltip.stories.tsx | 13 ++++-- packages/web/index.html | 9 +++- packages/web/src/field/field.ts | 12 ++++- packages/web/src/tooltip/tooltip.ts | 45 +++++++++---------- 5 files changed, 51 insertions(+), 36 deletions(-) diff --git a/packages/css/src/link.css b/packages/css/src/link.css index d9dc378aa8..ecb389ae9a 100644 --- a/packages/css/src/link.css +++ b/packages/css/src/link.css @@ -36,12 +36,12 @@ * Ensure proper spacing if there is svg or img and span. */ &:has(> span):has(> :is(img, svg)) { - &:first-child { - margin-inline-start: var(--ds-size-1); - } - &:last-child { + & > :first-child { margin-inline-end: var(--ds-size-1); } + & > :last-child { + margin-inline-start: var(--ds-size-1); + } } /* What do we do here? */ diff --git a/packages/react/src/components/tooltip/tooltip.stories.tsx b/packages/react/src/components/tooltip/tooltip.stories.tsx index 8655e9e203..7e9354c684 100644 --- a/packages/react/src/components/tooltip/tooltip.stories.tsx +++ b/packages/react/src/components/tooltip/tooltip.stories.tsx @@ -114,9 +114,16 @@ export const WithDynamicTooltipText: Story = { }; export const WithCSSTooltipText: Story = { - args: { - content: 'Kopier', - }, + render: () => ( + + + + ), +}; + +export const WithDynamicCSSTooltipText: Story = { render: () => { const tooltipRef = useRef(null); const [tooltipContent, setTooltipContent] = useState(''); diff --git a/packages/web/index.html b/packages/web/index.html index ccb40b890c..fe6c6d2ba2 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -23,9 +23,14 @@

Test webcomponents



-

data-clickdelegatefor

+

data-clickdelegatefor

- + diff --git a/packages/web/src/field/field.ts b/packages/web/src/field/field.ts index c4badedcba..8f143b7c13 100644 --- a/packages/web/src/field/field.ts +++ b/packages/web/src/field/field.ts @@ -22,6 +22,7 @@ declare global { } } +const ATTR_DESCRIBEDBY = 'aria-describedby'; const ATTR_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 @@ -59,6 +60,7 @@ const handleFieldMutation = (field: DSFieldElement) => { const descs: Element[] = []; const labels: HTMLLabelElement[] = []; let input: HTMLInputElement | undefined; + // let descsIDs: string[] = []; let counter: Element | undefined; let hasValidation = false; let invalid = false; @@ -72,7 +74,10 @@ const handleFieldMutation = (field: DSFieldElement) => { `Fields should only have one input element. Use
to group multiple fields:`, field, ); - else input = el; // Only register if visible input + else { + input = el; // Only register if visible input + // descsIDs = attr(el, ATTR_DESCRIBEDBY)?.trim().split(/\s+/) || []; + } } else { const type = el.getAttribute('data-field'); // Using getAttribute instead of attr for best performance if (type === 'counter') counter = el; @@ -93,6 +98,9 @@ const handleFieldMutation = (field: DSFieldElement) => { .closest('fieldset') ?.querySelector(':scope > [data-field="validation"]'); + // TODO EIRIK + // console.log(descsIDs, descs); + // Connect fieldset validation to inputs if (fieldsetValidation && !fieldsetValidation?.hidden) { hasValidation = true; @@ -107,7 +115,7 @@ const handleFieldMutation = (field: DSFieldElement) => { // Expand click area to ds-field if radio/checkbox const isBoolish = input.type === 'radio' || input.type === 'checkbox'; attr(field, 'data-clickdelegatefor', isBoolish ? useId(input) : null); - attr(input, 'aria-describedby', descs.map(useId).join(' ') || null); + attr(input, ATTR_DESCRIBEDBY, descs.map(useId).join(' ') || null); // Only manage aria-invalid when field has validation elements const hadValidation = HAS_VALIDATION.has(input); diff --git a/packages/web/src/tooltip/tooltip.ts b/packages/web/src/tooltip/tooltip.ts index 9d9e1dca53..6600f9f314 100644 --- a/packages/web/src/tooltip/tooltip.ts +++ b/packages/web/src/tooltip/tooltip.ts @@ -24,6 +24,7 @@ const ATTR_COLOR = 'data-color'; const ARIA_LABEL = 'aria-label'; const ARIA_DESC = 'aria-description'; const SELECTOR_COLOR = `[${ATTR_COLOR}]`; +const SELECTOR_TOOLTIP = `[${ATTR_TOOLTIP}]`; const ATTR_SCHEME = 'data-color-scheme'; const SELECTOR_SCHEME = `[${ATTR_SCHEME}]`; const SELECTOR_INTERACTIVE = 'a,button,input,label,select,textarea,[tabindex]'; @@ -41,33 +42,27 @@ export const setTooltipElement = (el?: HTMLElement | null) => { TIP = el || undefined; }; -const handleAriaAttributes = (_: unknown, records?: MutationRecord[]) => { - if (!records) - for (const el of document.querySelectorAll(`[${ATTR_TOOLTIP}]`)) render(el); // Initial setup - else - for (const { target: el } of records) - if ((el as Element).hasAttribute?.(ATTR_TOOLTIP)) render(el as Element); -}; - -const render = (el: Element) => { - const aria = el.getAttribute(ARIA_LABEL) || el.getAttribute(ARIA_DESC); // Using getAttribute for best performance - const text = attrOrCSS(el, ATTR_TOOLTIP); // Using getAttribute for best performance +const handleAriaAttributes = () => { + for (const el of document.querySelectorAll(SELECTOR_TOOLTIP)) { + const text = attrOrCSS(el, ATTR_TOOLTIP); - if (aria !== text) { - 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 - attr(el, ARIA_DESC, hasText ? text : null); // Set aria-description if element has text - if (!el.matches(SELECTOR_INTERACTIVE)) - warn('Missing tabindex="0" attribute on: ', el); - } + 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 + attr(el, ARIA_DESC, hasText ? text : null); // Set aria-description if element has text + if (!el.matches(SELECTOR_INTERACTIVE)) + warn('Missing tabindex="0" attribute on: ', el); + } - // If an existing tooltip has changed programmatically, update tooltip text and announce change - const isCurrent = el === SOURCE && TIP?.matches(':popover-open'); - const isChanged = isCurrent && text && TIP?.textContent !== text; // Only update if mutation is on source element and tooltip is open to avoid unnecessary updates - if (isCurrent && isChanged) { - if (TIP) setTextWithoutMutation(TIP, text); - if (document.activeElement === el) announce(text); // Only announce if focus is on the button + // If an existing tooltip has changed programmatically, update tooltip text and announce change + const isCurrent = el === SOURCE && TIP?.matches(':popover-open'); + const isChanged = isCurrent && text && TIP?.textContent !== text; // Only update if mutation is on source element and tooltip is open to avoid unnecessary updates + if (isCurrent && isChanged) { + if (TIP) setTextWithoutMutation(TIP, text); + if (document.activeElement === el) announce(text); // Only announce if focus is on the button + } } }; From 370f78dd17828ac4a507bfa719ebd85c1d4d0d39 Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Fri, 27 Mar 2026 14:48:28 +0100 Subject: [PATCH 11/13] fix: add document check in mutationobserver --- packages/web/src/field/field.ts | 6 +++--- packages/web/src/utils/utils.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/web/src/field/field.ts b/packages/web/src/field/field.ts index 8f143b7c13..232c7b1f99 100644 --- a/packages/web/src/field/field.ts +++ b/packages/web/src/field/field.ts @@ -24,7 +24,7 @@ declare global { const ATTR_DESCRIBEDBY = 'aria-describedby'; const ATTR_INDETERMINATE = 'data-indeterminate'; -const FIELDS = new Set(); // Set of Field +const FIELDS = new Map(); // Map of Field and its describedby IDs so we can identify the ones we add/remove const COUNTS = new WeakMap(); // Using WeakMap so removed inputs/counts does not cause memory leaks const FIELDSETS = isBrowser() ? document.getElementsByTagName('fieldset') : []; const COUNTER_DEBOUNCE = isWindows() ? 800 : 200; // Longer debounce on Windows due to NVDA performance @@ -50,7 +50,7 @@ const handleFieldMutations = (_: unknown, mutations?: MutationRecord[]) => { if (!mutations) return; // Initial calls are handled by connectedCallback, not mutation triggered for (const { target } of mutations) { const isFieldset = target instanceof HTMLFieldSetElement; - for (const field of FIELDS) + for (const [field] of FIELDS) if (isFieldset ? target.contains(field) : field.contains(target)) handleFieldMutation(field); } @@ -172,7 +172,7 @@ 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 + FIELDS.set(this, []); // Register field handleFieldMutation(this); // Initial setup } disconnectedCallback() { diff --git a/packages/web/src/utils/utils.ts b/packages/web/src/utils/utils.ts index a456d1dab4..62163882e6 100644 --- a/packages/web/src/utils/utils.ts +++ b/packages/web/src/utils/utils.ts @@ -150,7 +150,7 @@ export const onMutation = ( ) => { const cleanup = () => observer.disconnect(); const observer = new MutationObserver((records) => { - if (!el.isConnected) return cleanup(); // Stop observing if element is removed from DOM + 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(observer, records); }); From 67d0eb78d60c50463c62a7d99d187e8ee01e0c8b Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Fri, 27 Mar 2026 16:11:58 +0100 Subject: [PATCH 12/13] fix: respect external aria-describedby --- .../components/tooltip/tooltip.stories.tsx | 13 ++-- packages/web/index.html | 9 +-- packages/web/package.json | 7 +- packages/web/src/field/field.ts | 70 ++++++------------- packages/web/src/fieldset/fieldset.ts | 32 +++++++++ packages/web/src/index.ts | 1 + packages/web/src/pagination/pagination.ts | 1 - 7 files changed, 72 insertions(+), 61 deletions(-) create mode 100644 packages/web/src/fieldset/fieldset.ts diff --git a/packages/react/src/components/tooltip/tooltip.stories.tsx b/packages/react/src/components/tooltip/tooltip.stories.tsx index 7e9354c684..6557b94658 100644 --- a/packages/react/src/components/tooltip/tooltip.stories.tsx +++ b/packages/react/src/components/tooltip/tooltip.stories.tsx @@ -18,13 +18,12 @@ export default { }, play: async (ctx) => { document.querySelector('.ds-tooltip')?.remove(); // Reset to run next test without waiting for tooltip to disappear // <== Må "nullstille"/fjerne tooltip mellom hver test - const button = ctx.canvasElement.querySelector( - '[data-tooltip]', - ) as HTMLElement; + const button = + ctx.canvasElement.querySelector('[data-tooltip]'); await new Promise((resolve) => { document.addEventListener('animationend', resolve, true); // <== Merk at vi binder event-listener før vi gjør hover - button.focus(); + button?.focus(); }); const tooltip = await within(document.body).findByText(ctx.args.content); // <== trenger ikke sjekke toBeInDocument siden denne testen krever det @@ -114,6 +113,9 @@ export const WithDynamicTooltipText: Story = { }; export const WithCSSTooltipText: Story = { + args: { + content: 'Kopier', + }, render: () => (