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 ``, ` ` or ` `, you can "forward" clicks on the row down to the chosen interactive element.
+
+
+
## HTML
It is recommended to read about [`` on MDN (mozilla.org)](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/table) for a thorough review of HTML tables and their semantics.
@@ -80,6 +85,21 @@ Note that we apply `type="button"` to avoid the button behaving like a submit bu
```
+### Clickable rows
+By adding `data-clickdelegate` on a `` and `data-clicktarget` on a ``, ` ` or ` `, you can "forward" clicks on the row down to the chosen interactive element.
+
+```html
+
+```
+
## CSS variables and data attributes
diff --git a/apps/www/app/content/components/table/en/overview.mdx b/apps/www/app/content/components/table/en/overview.mdx
index a067d29eb9..3c6f3f47fa 100644
--- a/apps/www/app/content/components/table/en/overview.mdx
+++ b/apps/www/app/content/components/table/en/overview.mdx
@@ -45,6 +45,11 @@ When numbers appear in a table and are meant to be compared, align them to the r
+### Clickable rows
+A clickable row requires an interactive element that can be the "target" of the click. This can be a button, a link, or a form element like a checkbox.
+
+
+
## Guidelines
Use the `Table` to structure and present data clearly in rows and columns.
- Content in tables should be left-aligned, except for numbers, which should be right-aligned to make comparison easier.
diff --git a/apps/www/app/content/components/table/no/code.mdx b/apps/www/app/content/components/table/no/code.mdx
index 4edb41aba1..1ce952e0c2 100644
--- a/apps/www/app/content/components/table/no/code.mdx
+++ b/apps/www/app/content/components/table/no/code.mdx
@@ -49,6 +49,11 @@ Det er spesielt nyttig i tabeller med paginering eller annen dynamisk oppdaterin
+### Klikkbare rader
+Ved å legge `data-clickdelegate` på en `` og `data-clicktarget` på ``, ` ` eller ` `, kan du "videresende" klikk på rad til det valgte interaktive elementet.
+
+
+
## HTML
Det anbefales å lese om [`` på MDN (mozilla.org)](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/table) for en grundig gjennomgang av HTML-tabeller og deres semantikk.
@@ -79,6 +84,21 @@ Merk at vi legger på `type="button"` for å unngå at knappen oppfører seg som
```
+### Klikkbare rader
+Ved å legge `data-clickdelegate` på en `` og `data-clicktarget` på ``, `` eller ` `, kan du "videresende" klikk på rad til det valgte interaktive elementet.
+
+```html
+
+```
+
## CSS variabler og data-attributter
diff --git a/apps/www/app/content/components/table/no/overview.mdx b/apps/www/app/content/components/table/no/overview.mdx
index b4d3251663..d499bc2295 100644
--- a/apps/www/app/content/components/table/no/overview.mdx
+++ b/apps/www/app/content/components/table/no/overview.mdx
@@ -45,6 +45,11 @@ Når det er tall i en tabell som skal sammenlignes, plasser tallene til høyre i
+### Klikkbare rader
+En klikkbar rad, krever et interaktivt element som kan være "målet" for klikket. Dette kan være en knapp, en lenke eller et skjemaelement som en avkrysningsboks.
+
+
+
## Retningslinjer
Bruk `Table` når du skal organisere og vise data strukturert i rader og kolonner.
- Innhold i tabeller bør være venstrejustert, med unntak av tall, som bør høyrejusteres for å gjøre det lettere å sammenligne tallverdier.
diff --git a/apps/www/app/content/components/table/table.stories.tsx b/apps/www/app/content/components/table/table.stories.tsx
index 391fed1deb..1fcaddd555 100644
--- a/apps/www/app/content/components/table/table.stories.tsx
+++ b/apps/www/app/content/components/table/table.stories.tsx
@@ -1,4 +1,9 @@
-import { Table, type TableHeaderCellProps } from '@digdir/designsystemet-react';
+import {
+ Button,
+ Input,
+ Table,
+ type TableHeaderCellProps,
+} from '@digdir/designsystemet-react';
import { useState } from 'react';
export const Preview = () => {
@@ -424,3 +429,28 @@ export const NumbersEn = () => (
);
+
+export const Clickable = () => (
+
+);
diff --git a/packages/css/src/table.css b/packages/css/src/table.css
index d835943d22..4bc93ce5fe 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,13 +193,23 @@
}
/**
- * States
+ * Clickable
+ */
+ & > tbody > tr[data-clickdelegate] {
+ cursor: pointer;
+ }
+
+ /**
+ * States and Clickable
*/
@media (hover: hover) and (pointer: fine) {
+ & > 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/table/table.stories.tsx b/packages/react/src/components/table/table.stories.tsx
index c2fe824f4d..d9eb78a564 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
+
+
+
+
+
+
+ alert('Knappeklikk')}>
+ Knapp
+
+
+ Ola Nordmann
+ Rådgiver
+
+
+
+
+
+
+
+ Lenke
+
+
+ Jens Nordmann
+ Rådgiver
+
+
+
+
+
+
+ );
+};
+WithClickableRows.parameters = {
+ docs: {
+ source: {
+ type: 'code',
+ },
+ },
+};
diff --git a/packages/react/src/components/table/table.tsx b/packages/react/src/components/table/table.tsx
index 0df5e00e62..a730a2f008 100644
--- a/packages/react/src/components/table/table.tsx
+++ b/packages/react/src/components/table/table.tsx
@@ -2,6 +2,7 @@ import cl from 'clsx/lite';
import type { TableHTMLAttributes } from 'react';
import { forwardRef } from 'react';
import type { DefaultProps } from '../../types';
+import { useClickDelegate } from '../../utilities'; // TMP workaround to avoid tree-shaking
export type TableProps = {
/**
@@ -64,6 +65,8 @@ export const Table = forwardRef(function Table(
},
ref,
) {
+ useClickDelegate(); // TMP workaround to avoid tree-shaking
+
return (
{
+ const {
+ button = 0,
+ isTrusted = true,
+ metaKey = false,
+ ctrlKey = false,
+ } = options;
+ return {
+ target,
+ button,
+ isTrusted,
+ metaKey,
+ ctrlKey,
+ stopImmediatePropagation: vi.fn(),
+ } as unknown as MouseEvent;
+};
+
+describe('handleClickDelegate', () => {
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ container.remove();
+ });
+
+ it('should delegate click to target element', () => {
+ 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).toHaveBeenCalled();
+ });
+
+ it('should not delegate click when clicking on interactive elements', () => {
+ container.innerHTML = `
+
+ `;
+
+ 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 = `
+
+ `;
+
+ 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 = `
+
+ `;
+
+ 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 = `
+
+ `;
+
+ 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 Target
+ 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 = `
+
+ `;
+
+ 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 = `
+
+ `;
+
+ 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 = `
+
+ `;
+
+ 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 = `
+
+ `;
+
+ 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 = `
+
+ Target
+ <${tag} ${attrs} class="interactive">Interactive${tag}>
+
+ `;
+
+ 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
new file mode 100644
index 0000000000..743b40af6f
--- /dev/null
+++ b/packages/react/src/utilities/click-delegate.ts
@@ -0,0 +1,62 @@
+// 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 { 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]';
+
+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);
+
+ 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(CLICKDELEGATE) : null;
+ const target = scope?.querySelector(CLICKTARGET);
+ const skip = target && (el as Element).closest(SKIP); // Ignore if interactive
+
+ return ((!skip || skip === target) && target) || undefined;
+};
+
+// 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
+ ]),
+ [],
+ );
+}
diff --git a/packages/react/src/utilities/dom.ts b/packages/react/src/utilities/dom.ts
new file mode 100644
index 0000000000..c19bfc5045
--- /dev/null
+++ b/packages/react/src/utilities/dom.ts
@@ -0,0 +1,57 @@
+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);
+};
+
+// Used to store cleanup functions for hot-reloading
+declare global {
+ interface Window {
+ _dsHotReloadCleanup?: Map void>>;
+ }
+}
+
+/**
+ * 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
+ */
+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
+
+ 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
+};
diff --git a/packages/react/src/utilities/index.ts b/packages/react/src/utilities/index.ts
index 1a563c4374..4b593622b7 100644
--- a/packages/react/src/utilities/index.ts
+++ b/packages/react/src/utilities/index.ts
@@ -1,3 +1,4 @@
+export { useClickDelegate } from './click-delegate'; // TMP workaround to avoid tree-shaking
export type {
UseCheckboxGroupProps,
UsePaginationProps,