From 8eeeb893450a0bffe72a7345dfd1ae6be2706812 Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Wed, 17 Dec 2025 20:53:28 +0100 Subject: [PATCH 01/11] fix(Table): add support for clickable rows --- packages/css/src/table.css | 20 ++++++- packages/react/src/components/table/index.ts | 1 + .../src/components/table/table-helper.ts | 27 +++++++++ .../src/components/table/table.stories.tsx | 60 ++++++++++++++++++- packages/react/src/utilities/dom.ts | 56 +++++++++++++++++ 5 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 packages/react/src/components/table/table-helper.ts create mode 100644 packages/react/src/utilities/dom.ts diff --git a/packages/css/src/table.css b/packages/css/src/table.css index d835943d22..71b6d030e9 100644 --- a/packages/css/src/table.css +++ b/packages/css/src/table.css @@ -1,5 +1,6 @@ .ds-table { --dsc-table-background--hover: var(--ds-color-surface-hover); + --dsc-table-background--active: var(--ds-color-surface-active); --dsc-table-background--zebra: var(--ds-color-surface-tinted); --dsc-table-background: transparent; --dsc-table-border-color: var(--ds-color-border-subtle); @@ -192,9 +193,26 @@ } /** - * States + * Clickable + */ + & > tbody > tr:has([data-clickable='row']) { + cursor: pointer; + } + + /** + * States and Clickable */ @media (hover: hover) and (pointer: fine) { + /* Hover a row, that has [data-clickable="row"], but not hovering non-data-clickable-row-button/link */ + & > tbody:not(:has(:is(a, button, label, input, select, textarea, [role='button']):not([data-clickable='row']):hover)) { + & > tr:has([data-clickable='row']):hover > :is(td, th) { + background: var(--dsc-table-background--hover); + } + & > tr:has([data-clickable='row']):active > :is(td, th) { + background: var(--dsc-table-background--active); + } + } + &[data-hover] > tbody > tr:hover > :is(th, td) { background: var(--dsc-table-background--hover); } diff --git a/packages/react/src/components/table/index.ts b/packages/react/src/components/table/index.ts index c0b747b385..4d40f21d5a 100644 --- a/packages/react/src/components/table/index.ts +++ b/packages/react/src/components/table/index.ts @@ -12,6 +12,7 @@ import type { TableHeaderCellProps } from './table-header-cell'; import { TableHeaderCell } from './table-header-cell'; import type { TableRowProps } from './table-row'; import { TableRow } from './table-row'; +import './table-helper'; type Table = typeof TableRoot & { /** diff --git a/packages/react/src/components/table/table-helper.ts b/packages/react/src/components/table/table-helper.ts new file mode 100644 index 0000000000..74c1745c71 --- /dev/null +++ b/packages/react/src/components/table/table-helper.ts @@ -0,0 +1,27 @@ +import { on, onLoaded } from '../../utilities/dom'; + +const CLICKABLE = '[data-clickable="row"]'; +const SKIP = + 'a,button,label,input,select,textarea,dialog,[role="button"],[popover],[contenteditable]'; + +// Forward click to data-clickable="row" element +const handleTableRowClick = (event: Partial) => { + const isValidUserClick = event.isTrusted && event.target instanceof Element; + const isValidMouseButton = event.type === 'click' || event.button === 1; + const isNewTab = event.button === 1 || event.metaKey || event.ctrlKey; + + if (isValidUserClick && isValidMouseButton) { + const target = event.target.closest('tr')?.querySelector(CLICKABLE); + + if (target instanceof HTMLElement && !event.target.closest(SKIP)) { + if (target instanceof HTMLAnchorElement && isNewTab) + return window.open(target.href, undefined, target.rel); // If middle click or cmd/ctrl click on link, open in new tab + event.stopImmediatePropagation?.(); // We'll trigger a new click event anyway, so prevent actions on this one + target.click(); // Forward click to the clickable element + } + } +}; + +onLoaded('table-helper', () => [ + on(window, 'click auxclick', handleTableRowClick, true), // Use capture to ensure we run before other click listeners +]); diff --git a/packages/react/src/components/table/table.stories.tsx b/packages/react/src/components/table/table.stories.tsx index c2fe824f4d..afcef67a76 100644 --- a/packages/react/src/components/table/table.stories.tsx +++ b/packages/react/src/components/table/table.stories.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import type { TableHeaderCellProps } from '../../'; -import { Checkbox, Table, Textfield } from '../../'; +import { Button, Checkbox, Link, Table, Textfield } from '../../'; import { useCheckboxGroup } from '../../utilities'; type Story = StoryFn; @@ -405,3 +405,61 @@ WithBorder.args = { WithBorder.parameters = { customStyles: { display: 'grid', gap: '1rem' }, }; + +export const WithClickableRows: Story = (args) => { + return ( + + + + + Navn + Stilling + Kommentar + + + + + + + + Kari Nordmann + Rådgiver + + + + + + + + + Ola Nordmann + Rådgiver + + + + + + + + Lenke + + + Jens Nordmann + Rådgiver + + + + + +
+ ); +}; +WithClickableRows.parameters = { + docs: { + source: { + type: 'code', + }, + }, +}; diff --git a/packages/react/src/utilities/dom.ts b/packages/react/src/utilities/dom.ts new file mode 100644 index 0000000000..d5ec7b5baf --- /dev/null +++ b/packages/react/src/utilities/dom.ts @@ -0,0 +1,56 @@ +export const isBrowser = () => + typeof window !== 'undefined' && typeof document !== 'undefined'; + +/** + * on + * @param el The Element to use as EventTarget + * @param types A space separated string of event types + * @param listener An event listener function or listener object + */ +export const on = ( + el: Node | Window | ShadowRoot, + ...rest: Parameters +): (() => void) => { + const [types, ...options] = rest; + for (const type of types.split(' ')) el.addEventListener(type, ...options); + return () => off(el, ...rest); +}; + +/** + * off + * @param el The Element to use as EventTarget + * @param types A space separated string of event types + * @param listener An event listener function or listener object + */ +export const off = ( + el: Node | Window | ShadowRoot, + ...rest: Parameters +): void => { + const [types, ...options] = rest; + for (const type of types.split(' ')) el.removeEventListener(type, ...options); +}; + +declare global { + interface Window { + _dsHotReloadCleanup?: Map void>>; + } +} + +/** + * onLoaded + * @description Runs a callback when window is loaded in browser, and ensures cleanup when hot-reloading + * @param key The key to identify setup and corresponding cleanup + * @param callback The callback to run when the page is ready + */ +export const onLoaded = (key: string, setup: () => Array<() => void>) => { + if (!isBrowser()) return; // Skip if not in modern browser environment, but on each call as Vitest might have unloaded jsdom between tests + if (!window._dsHotReloadCleanup) window._dsHotReloadCleanup = new Map(); // Hot reload cleanup support supporting all build tools + + const run = () => { + window._dsHotReloadCleanup?.get(key)?.map((cleanup) => cleanup()); // Run previous cleanup + window._dsHotReloadCleanup?.set(key, setup()); // Store new cleanup + }; + + if (document.readyState !== 'complete') on(window, 'load', run); + else document.fonts?.ready?.then(run) || setTimeout(run, 0); // Prefer fonts ready promise if available, but fallback to setTimeout +}; From f2ced493d233bfd8e451d0dae3c7c54fd57c8ada Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Thu, 18 Dec 2025 08:07:41 +0100 Subject: [PATCH 02/11] fix(Table): rename onload to onhotreload --- packages/react/src/components/table/table-helper.ts | 4 ++-- packages/react/src/utilities/dom.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/react/src/components/table/table-helper.ts b/packages/react/src/components/table/table-helper.ts index 74c1745c71..a4ce9738a3 100644 --- a/packages/react/src/components/table/table-helper.ts +++ b/packages/react/src/components/table/table-helper.ts @@ -1,4 +1,4 @@ -import { on, onLoaded } from '../../utilities/dom'; +import { on, onHotReload } from '../../utilities/dom'; const CLICKABLE = '[data-clickable="row"]'; const SKIP = @@ -22,6 +22,6 @@ const handleTableRowClick = (event: Partial) => { } }; -onLoaded('table-helper', () => [ +onHotReload('table-helper', () => [ on(window, 'click auxclick', handleTableRowClick, true), // Use capture to ensure we run before other click listeners ]); diff --git a/packages/react/src/utilities/dom.ts b/packages/react/src/utilities/dom.ts index d5ec7b5baf..1f627805a6 100644 --- a/packages/react/src/utilities/dom.ts +++ b/packages/react/src/utilities/dom.ts @@ -30,6 +30,7 @@ export const off = ( for (const type of types.split(' ')) el.removeEventListener(type, ...options); }; +// Used to store cleanup functions for hot-reloading declare global { interface Window { _dsHotReloadCleanup?: Map void>>; @@ -37,12 +38,12 @@ declare global { } /** - * onLoaded + * hotReload * @description Runs a callback when window is loaded in browser, and ensures cleanup when hot-reloading * @param key The key to identify setup and corresponding cleanup * @param callback The callback to run when the page is ready */ -export const onLoaded = (key: string, setup: () => Array<() => void>) => { +export const onHotReload = (key: string, setup: () => Array<() => void>) => { if (!isBrowser()) return; // Skip if not in modern browser environment, but on each call as Vitest might have unloaded jsdom between tests if (!window._dsHotReloadCleanup) window._dsHotReloadCleanup = new Map(); // Hot reload cleanup support supporting all build tools From 6ae53b8c0790901fc99e5f85a96aec8d20a788b1 Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Thu, 18 Dec 2025 08:08:39 +0100 Subject: [PATCH 03/11] docs: correct jsdoc renaming --- packages/react/src/utilities/dom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/utilities/dom.ts b/packages/react/src/utilities/dom.ts index 1f627805a6..c19bfc5045 100644 --- a/packages/react/src/utilities/dom.ts +++ b/packages/react/src/utilities/dom.ts @@ -38,7 +38,7 @@ declare global { } /** - * hotReload + * onHotReload * @description Runs a callback when window is loaded in browser, and ensures cleanup when hot-reloading * @param key The key to identify setup and corresponding cleanup * @param callback The callback to run when the page is ready From f8d2a789c21a32861fd54175a876a611ae839552 Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Tue, 6 Jan 2026 14:29:23 +0100 Subject: [PATCH 04/11] fix: move to data-clickdelegate attribute --- packages/css/src/table.css | 15 ++---- packages/react/src/components/index.ts | 2 + packages/react/src/components/table/index.ts | 1 - .../src/components/table/table-helper.ts | 27 ----------- .../src/components/table/table.stories.tsx | 12 ++--- .../react/src/utilities/click-delegate.ts | 48 +++++++++++++++++++ 6 files changed, 60 insertions(+), 45 deletions(-) delete mode 100644 packages/react/src/components/table/table-helper.ts create mode 100644 packages/react/src/utilities/click-delegate.ts diff --git a/packages/css/src/table.css b/packages/css/src/table.css index 71b6d030e9..a0c568961f 100644 --- a/packages/css/src/table.css +++ b/packages/css/src/table.css @@ -203,20 +203,13 @@ * States and Clickable */ @media (hover: hover) and (pointer: fine) { - /* Hover a row, that has [data-clickable="row"], but not hovering non-data-clickable-row-button/link */ - & > tbody:not(:has(:is(a, button, label, input, select, textarea, [role='button']):not([data-clickable='row']):hover)) { - & > tr:has([data-clickable='row']):hover > :is(td, th) { - background: var(--dsc-table-background--hover); - } - & > tr:has([data-clickable='row']):active > :is(td, th) { - background: var(--dsc-table-background--active); - } - } - + & > tbody > tr:has(.\:click-delegate-hover), &[data-hover] > tbody > tr:hover > :is(th, td) { background: var(--dsc-table-background--hover); } - + & > tbody > tr:has(.\:click-delegate-active) { + background: var(--dsc-table-background--active); + } & > thead > tr > [aria-sort]:hover button { background: var(--dsc-table-header-background--hover); } diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 58fbcfa7ce..3609a1bc68 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -1,3 +1,5 @@ +import '../utilities/click-delegate'; + export type { AlertProps } from './alert/alert'; export { Alert } from './alert/alert'; diff --git a/packages/react/src/components/table/index.ts b/packages/react/src/components/table/index.ts index 4d40f21d5a..c0b747b385 100644 --- a/packages/react/src/components/table/index.ts +++ b/packages/react/src/components/table/index.ts @@ -12,7 +12,6 @@ import type { TableHeaderCellProps } from './table-header-cell'; import { TableHeaderCell } from './table-header-cell'; import type { TableRowProps } from './table-row'; import { TableRow } from './table-row'; -import './table-helper'; type Table = typeof TableRoot & { /** diff --git a/packages/react/src/components/table/table-helper.ts b/packages/react/src/components/table/table-helper.ts deleted file mode 100644 index a4ce9738a3..0000000000 --- a/packages/react/src/components/table/table-helper.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { on, onHotReload } from '../../utilities/dom'; - -const CLICKABLE = '[data-clickable="row"]'; -const SKIP = - 'a,button,label,input,select,textarea,dialog,[role="button"],[popover],[contenteditable]'; - -// Forward click to data-clickable="row" element -const handleTableRowClick = (event: Partial) => { - const isValidUserClick = event.isTrusted && event.target instanceof Element; - const isValidMouseButton = event.type === 'click' || event.button === 1; - const isNewTab = event.button === 1 || event.metaKey || event.ctrlKey; - - if (isValidUserClick && isValidMouseButton) { - const target = event.target.closest('tr')?.querySelector(CLICKABLE); - - if (target instanceof HTMLElement && !event.target.closest(SKIP)) { - if (target instanceof HTMLAnchorElement && isNewTab) - return window.open(target.href, undefined, target.rel); // If middle click or cmd/ctrl click on link, open in new tab - event.stopImmediatePropagation?.(); // We'll trigger a new click event anyway, so prevent actions on this one - target.click(); // Forward click to the clickable element - } - } -}; - -onHotReload('table-helper', () => [ - on(window, 'click auxclick', handleTableRowClick, true), // Use capture to ensure we run before other click listeners -]); diff --git a/packages/react/src/components/table/table.stories.tsx b/packages/react/src/components/table/table.stories.tsx index afcef67a76..0b30a454e2 100644 --- a/packages/react/src/components/table/table.stories.tsx +++ b/packages/react/src/components/table/table.stories.tsx @@ -418,9 +418,9 @@ export const WithClickableRows: Story = (args) => { - + - + Kari Nordmann Rådgiver @@ -428,9 +428,9 @@ export const WithClickableRows: Story = (args) => { - + - @@ -440,9 +440,9 @@ export const WithClickableRows: Story = (args) => { - + - + Lenke diff --git a/packages/react/src/utilities/click-delegate.ts b/packages/react/src/utilities/click-delegate.ts new file mode 100644 index 0000000000..d7988d5292 --- /dev/null +++ b/packages/react/src/utilities/click-delegate.ts @@ -0,0 +1,48 @@ +// Adding support for click deletagtion, following +// https://open-ui.org/components/link-area-delegation-explainer/ +// and https://github.com/openui/open-ui/issues/1104#issuecomment-3151387080 +import { on, onHotReload } from './dom'; + +const ATTR_CLICKDELEGATE = 'data-clickdelegate'; +const CSS_CLICKDELEGATE = `[${ATTR_CLICKDELEGATE}]`; +const SKIP = + 'a,button,label,input,select,textarea,dialog,[role="button"],[popover],[contenteditable]'; + +const handleClickDelegate = (event: MouseEvent) => { + const isNewTab = event.button === 1 || event.metaKey || event.ctrlKey; + const isUserLeftOrMiddleClick = event.isTrusted && event.button < 2; + const delegateTarget = isUserLeftOrMiddleClick && getDelegateTarget(event); + + if (delegateTarget instanceof HTMLAnchorElement && isNewTab) + window.open(delegateTarget.href, undefined, delegateTarget.rel); // If middle click or cmd/ctrl click on link, open in new tab + else if ( + delegateTarget instanceof HTMLElement && + !delegateTarget.contains(event.target as Node) // Only proxy event if delegated target isn't the original target + ) { + event.stopImmediatePropagation(); // We'll trigger a new click event anyway, so prevent actions on this one + delegateTarget.click(); // Forward click to the clickable element + } +}; + +let HOVER: Element | undefined; +const handleMouseOver = (event: Event) => { + const delegateTarget = getDelegateTarget(event); + if (HOVER === delegateTarget) return; // No change + if (HOVER) HOVER.classList.remove(':click-delegate-hover'); + if (delegateTarget) delegateTarget.classList.add(':click-delegate-hover'); + HOVER = delegateTarget; +}; + +const getDelegateTarget = ({ target: el }: Event) => { + const scope = el instanceof Element ? el.closest(CSS_CLICKDELEGATE) : null; + const id = scope?.getAttribute(ATTR_CLICKDELEGATE); + const target = id && document.getElementById(id); + const skip = target && (el as Element).closest(SKIP); // Ignore if interactive + + return ((!skip || skip === target) && target) || undefined; +}; + +onHotReload('click-delegate', () => [ + on(window, 'click auxclick', handleClickDelegate as EventListener, true), // Use capture to ensure we run before other click listeners + on(document, 'mouseover', handleMouseOver, { passive: true }), // Use passive for better performance +]); From 632e9304bc691422084033106b8a5114dd58ba85 Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Tue, 6 Jan 2026 14:38:39 +0100 Subject: [PATCH 05/11] chore: cleanup --- packages/css/src/table.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/css/src/table.css b/packages/css/src/table.css index a0c568961f..4bc93ce5fe 100644 --- a/packages/css/src/table.css +++ b/packages/css/src/table.css @@ -195,7 +195,7 @@ /** * Clickable */ - & > tbody > tr:has([data-clickable='row']) { + & > tbody > tr[data-clickdelegate] { cursor: pointer; } From a4e041a88666f908b43546b2b2134652f9580e80 Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Wed, 7 Jan 2026 08:15:13 +0100 Subject: [PATCH 06/11] fix: move to data-clicktarget attribute --- .../react/src/components/table/table.stories.tsx | 12 ++++++------ packages/react/src/utilities/click-delegate.ts | 9 ++++----- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/react/src/components/table/table.stories.tsx b/packages/react/src/components/table/table.stories.tsx index 0b30a454e2..d9eb78a564 100644 --- a/packages/react/src/components/table/table.stories.tsx +++ b/packages/react/src/components/table/table.stories.tsx @@ -418,9 +418,9 @@ export const WithClickableRows: Story = (args) => { - + - + Kari Nordmann Rådgiver @@ -428,9 +428,9 @@ export const WithClickableRows: Story = (args) => { - + - @@ -440,9 +440,9 @@ export const WithClickableRows: Story = (args) => { - + - + Lenke diff --git a/packages/react/src/utilities/click-delegate.ts b/packages/react/src/utilities/click-delegate.ts index d7988d5292..62c55d95e1 100644 --- a/packages/react/src/utilities/click-delegate.ts +++ b/packages/react/src/utilities/click-delegate.ts @@ -3,8 +3,8 @@ // and https://github.com/openui/open-ui/issues/1104#issuecomment-3151387080 import { on, onHotReload } from './dom'; -const ATTR_CLICKDELEGATE = 'data-clickdelegate'; -const CSS_CLICKDELEGATE = `[${ATTR_CLICKDELEGATE}]`; +const CLICKDELEGATE = '[data-clickdelegate]'; +const CLICKTARGET = '[data-clicktarget]'; const SKIP = 'a,button,label,input,select,textarea,dialog,[role="button"],[popover],[contenteditable]'; @@ -34,9 +34,8 @@ const handleMouseOver = (event: Event) => { }; const getDelegateTarget = ({ target: el }: Event) => { - const scope = el instanceof Element ? el.closest(CSS_CLICKDELEGATE) : null; - const id = scope?.getAttribute(ATTR_CLICKDELEGATE); - const target = id && document.getElementById(id); + const scope = el instanceof Element ? el.closest(CLICKDELEGATE) : null; + const target = scope?.querySelector(CLICKTARGET); const skip = target && (el as Element).closest(SKIP); // Ignore if interactive return ((!skip || skip === target) && target) || undefined; From 35ccf57d1978248c8c92911abca60cf25cf83118 Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Wed, 7 Jan 2026 10:40:55 +0100 Subject: [PATCH 07/11] chore: avoid tree shaking --- packages/react/src/components/index.ts | 6 +++++- packages/react/src/utilities/click-delegate.ts | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index 3609a1bc68..ee3efc7a06 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -1,4 +1,8 @@ -import '../utilities/click-delegate'; +import { avoidTreeShaking } from '../utilities/click-delegate'; + +// Temporary workaround to avoid tree-shaking of click-delegate utility +// Remove when https://github.com/digdir/designsystemet/issues/4367 is solved +export function _avoidTreeShaking() { avoidTreeShaking() }; export type { AlertProps } from './alert/alert'; export { Alert } from './alert/alert'; diff --git a/packages/react/src/utilities/click-delegate.ts b/packages/react/src/utilities/click-delegate.ts index 62c55d95e1..140067c703 100644 --- a/packages/react/src/utilities/click-delegate.ts +++ b/packages/react/src/utilities/click-delegate.ts @@ -45,3 +45,7 @@ onHotReload('click-delegate', () => [ on(window, 'click auxclick', handleClickDelegate as EventListener, true), // Use capture to ensure we run before other click listeners on(document, 'mouseover', handleMouseOver, { passive: true }), // Use passive for better performance ]); + +// Temporary workaround to avoid tree-shaking of click-delegate utility +// Remove when https://github.com/digdir/designsystemet/issues/4367 is solved +export const avoidTreeShaking = () => {}; \ No newline at end of file From 555d4db613ee5c3ff61bfea5f871e11d0fa7328d Mon Sep 17 00:00:00 2001 From: eirikbacker Date: Wed, 7 Jan 2026 11:01:42 +0100 Subject: [PATCH 08/11] docs: add www docs --- .../app/content/components/table/en/code.mdx | 20 ++++++++++++++++ .../content/components/table/en/overview.mdx | 5 ++++ .../app/content/components/table/no/code.mdx | 20 ++++++++++++++++ .../content/components/table/no/overview.mdx | 5 ++++ .../components/table/table.stories.tsx | 24 ++++++++++++++++++- 5 files changed, 73 insertions(+), 1 deletion(-) diff --git a/apps/www/app/content/components/table/en/code.mdx b/apps/www/app/content/components/table/en/code.mdx index 299c816652..7ba428714c 100644 --- a/apps/www/app/content/components/table/en/code.mdx +++ b/apps/www/app/content/components/table/en/code.mdx @@ -49,6 +49,11 @@ This is especially useful in tables with pagination or other dynamically updated +### Clickable rows +By adding `data-clickdelegate` on a `` and `data-clicktarget` on a ` + + `; + + const link = container.querySelector('a')!; + const button = container.querySelector('button')!; + const linkClickSpy = vi.fn(); + link.addEventListener('click', linkClickSpy); + + handleClickDelegate(createMockEvent(button, { button: 0 })); + + expect(linkClickSpy).not.toHaveBeenCalled(); + }); + + it('should not delegate click when clicking directly on the target', () => { + container.innerHTML = ` +
+ Link +
+ `; + + const link = container.querySelector('a')!; + const clickSpy = vi.fn(); + link.addEventListener('click', clickSpy); + + handleClickDelegate(createMockEvent(link, { button: 0 })); + + // Should not trigger an extra click since target contains event.target + expect(clickSpy).not.toHaveBeenCalled(); + }); + + it('should not delegate right clicks (button: 2)', () => { + container.innerHTML = ` +
+ Link + Click me +
+ `; + + const link = container.querySelector('a')!; + const content = container.querySelector('.content')!; + const clickSpy = vi.fn(); + link.addEventListener('click', clickSpy); + + handleClickDelegate(createMockEvent(content, { button: 2 })); + + expect(clickSpy).not.toHaveBeenCalled(); + }); + + it('should not delegate click to nested interactive elements', () => { + container.innerHTML = ` +
+ Link + +
+ `; + + const link = container.querySelector('a')!; + const input = container.querySelector('input')!; + const clickSpy = vi.fn(); + link.addEventListener('click', clickSpy); + + handleClickDelegate(createMockEvent(input, { button: 0 })); + + expect(clickSpy).not.toHaveBeenCalled(); + }); + + it('should work with button as target', () => { + container.innerHTML = ` +
+ + Click me +
+ `; + + const button = container.querySelector('button')!; + const content = container.querySelector('.content')!; + const clickSpy = vi.fn(); + button.addEventListener('click', clickSpy); + + handleClickDelegate(createMockEvent(content, { button: 0 })); + + expect(clickSpy).toHaveBeenCalled(); + }); + + it('should not delegate when no clicktarget is present', () => { + container.innerHTML = ` + + `; + + const link = container.querySelector('a')!; + const content = container.querySelector('.content')!; + const clickSpy = vi.fn(); + link.addEventListener('click', clickSpy); + + handleClickDelegate(createMockEvent(content, { button: 0 })); + + expect(clickSpy).not.toHaveBeenCalled(); + }); + + it('should call stopImmediatePropagation when delegating', () => { + container.innerHTML = ` +
+ Link + Click me +
+ `; + + const content = container.querySelector('.content')!; + const event = createMockEvent(content, { button: 0 }); + + handleClickDelegate(event); + + expect(event.stopImmediatePropagation).toHaveBeenCalled(); + }); + + describe('middle click / new tab behavior', () => { + it('should open link in new tab on middle click', () => { + const windowOpenSpy = vi + .spyOn(window, 'open') + .mockImplementation(() => null); + + container.innerHTML = ` +
+ Link + Click me +
+ `; + + const content = container.querySelector('.content')!; + + handleClickDelegate(createMockEvent(content, { button: 1 })); + + expect(windowOpenSpy).toHaveBeenCalledWith( + 'https://example.com/page', + undefined, + 'noopener', + ); + + windowOpenSpy.mockRestore(); + }); + + it('should open link in new tab on ctrl+click', () => { + const windowOpenSpy = vi + .spyOn(window, 'open') + .mockImplementation(() => null); + + container.innerHTML = ` +
+ Link + Click me +
+ `; + + const content = container.querySelector('.content')!; + + handleClickDelegate( + createMockEvent(content, { button: 0, ctrlKey: true }), + ); + + expect(windowOpenSpy).toHaveBeenCalledWith( + 'https://example.com/page', + undefined, + 'noopener', + ); + + windowOpenSpy.mockRestore(); + }); + + it('should open link in new tab on meta+click', () => { + const windowOpenSpy = vi + .spyOn(window, 'open') + .mockImplementation(() => null); + + container.innerHTML = ` +
+ Link + Click me +
+ `; + + const content = container.querySelector('.content')!; + + handleClickDelegate( + createMockEvent(content, { button: 0, metaKey: true }), + ); + + expect(windowOpenSpy).toHaveBeenCalledWith( + 'https://example.com/page', + undefined, + 'noopener', + ); + + windowOpenSpy.mockRestore(); + }); + }); + + describe('skipped interactive elements', () => { + const interactiveElements = [ + { tag: 'a', attrs: 'href="#"', name: 'anchor' }, + { tag: 'button', attrs: '', name: 'button' }, + { tag: 'label', attrs: '', name: 'label' }, + { tag: 'input', attrs: '', name: 'input' }, + { tag: 'select', attrs: '', name: 'select' }, + { tag: 'textarea', attrs: '', name: 'textarea' }, + { tag: 'dialog', attrs: '', name: 'dialog' }, + { tag: 'div', attrs: 'role="button"', name: 'role=button' }, + { tag: 'div', attrs: 'popover', name: 'popover' }, + { tag: 'div', attrs: 'contenteditable', name: 'contenteditable' }, + ]; + + interactiveElements.forEach(({ tag, attrs, name }) => { + it(`should not delegate click from ${name} element`, () => { + container.innerHTML = ` +
+ + <${tag} ${attrs} class="interactive">Interactive +
+ `; + + const target = container.querySelector('[data-clicktarget]')!; + const interactive = container.querySelector('.interactive')!; + const clickSpy = vi.fn(); + target.addEventListener('click', clickSpy); + + handleClickDelegate(createMockEvent(interactive, { button: 0 })); + + expect(clickSpy).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/react/src/utilities/click-delegate.ts b/packages/react/src/utilities/click-delegate.ts index f2fbc7e4a9..743b40af6f 100644 --- a/packages/react/src/utilities/click-delegate.ts +++ b/packages/react/src/utilities/click-delegate.ts @@ -1,15 +1,16 @@ // Adding support for click deletagtion, following // https://open-ui.org/components/link-area-delegation-explainer/ // and https://github.com/openui/open-ui/issues/1104#issuecomment-3151387080 -import { on, onHotReload } from './dom'; + import { useEffect } from 'react'; +import { on, onHotReload } from './dom'; const CLICKDELEGATE = '[data-clickdelegate]'; const CLICKTARGET = '[data-clicktarget]'; const SKIP = 'a,button,label,input,select,textarea,dialog,[role="button"],[popover],[contenteditable]'; -const handleClickDelegate = (event: MouseEvent) => { +export const handleClickDelegate = (event: MouseEvent) => { const isNewTab = event.button === 1 || event.metaKey || event.ctrlKey; const isUserLeftOrMiddleClick = event.isTrusted && event.button < 2; const delegateTarget = isUserLeftOrMiddleClick && getDelegateTarget(event); @@ -45,9 +46,17 @@ const getDelegateTarget = ({ target: el }: Event) => { // Temporary "useClickDelegate" workaround to avoid tree-shaking of click-delegate utility // When https://github.com/digdir/designsystemet/issues/4367 is solved, we can add onHotReload directly in this file without calling useClickDelegate from components export function useClickDelegate() { - useEffect(() => - onHotReload('click-delegate', () => [ - on(window, 'click auxclick', handleClickDelegate as EventListener, true), // Use capture to ensure we run before other click listeners - on(document, 'mouseover', handleMouseOver, { passive: true }), // Use passive for better performance - ]), []); -} \ No newline at end of file + useEffect( + () => + onHotReload('click-delegate', () => [ + on( + window, + 'click auxclick', + handleClickDelegate as EventListener, + true, + ), // Use capture to ensure we run before other click listeners + on(document, 'mouseover', handleMouseOver, { passive: true }), // Use passive for better performance + ]), + [], + ); +} diff --git a/packages/react/src/utilities/index.ts b/packages/react/src/utilities/index.ts index cb51685d48..4b593622b7 100644 --- a/packages/react/src/utilities/index.ts +++ b/packages/react/src/utilities/index.ts @@ -1,9 +1,9 @@ +export { useClickDelegate } from './click-delegate'; // TMP workaround to avoid tree-shaking export type { UseCheckboxGroupProps, UsePaginationProps, UseRadioGroupProps, } from './hooks'; -export { useClickDelegate } from './click-delegate'; // TMP workaround to avoid tree-shaking export { useCheckboxGroup, useDebounceCallback,