From 0a584c365ecae1b7cf3976bd3efbe402fb76954f Mon Sep 17 00:00:00 2001 From: Michelle Date: Wed, 21 Jan 2026 10:36:26 +0100 Subject: [PATCH 1/4] feat: clippy link web component --- packages/clippy-components/package.json | 2 + .../src/clippy-link/README.md | 86 +++++ .../src/clippy-link/index.test.ts | 185 ++++++++++ .../src/clippy-link/index.ts | 144 ++++++++ .../web-components/clippy-link.stories.tsx | 334 ++++++++++++++++++ packages/theme-wizard-app/package.json | 2 - .../src/components/template-link/index.ts | 24 -- .../src/components/wizard-index-page/index.ts | 2 +- .../components/wizard-style-guide/index.ts | 2 +- pnpm-lock.yaml | 12 +- 10 files changed, 759 insertions(+), 34 deletions(-) create mode 100644 packages/clippy-components/src/clippy-link/README.md create mode 100644 packages/clippy-components/src/clippy-link/index.test.ts create mode 100644 packages/clippy-components/src/clippy-link/index.ts create mode 100644 packages/clippy-storybook/src/web-components/clippy-link.stories.tsx delete mode 100644 packages/theme-wizard-app/src/components/template-link/index.ts diff --git a/packages/clippy-components/package.json b/packages/clippy-components/package.json index 86d5be1be..8338bd2da 100644 --- a/packages/clippy-components/package.json +++ b/packages/clippy-components/package.json @@ -47,6 +47,8 @@ "@nl-design-system-candidate/button-css": "1.0.0", "@nl-design-system-candidate/code-css": "2.0.3", "@nl-design-system-candidate/color-sample-css": "1.0.4", + "@nl-design-system-candidate/heading-css": "1.1.3", + "@nl-design-system-candidate/link-css": "2.0.3", "@utrecht/button-css": "3.0.1", "@utrecht/combobox-css": "2.0.1", "@utrecht/listbox-css": "2.0.1", diff --git a/packages/clippy-components/src/clippy-link/README.md b/packages/clippy-components/src/clippy-link/README.md new file mode 100644 index 000000000..0e0697f35 --- /dev/null +++ b/packages/clippy-components/src/clippy-link/README.md @@ -0,0 +1,86 @@ +# `` + +Link (anker) met NL Design System link styles. Gebaseerd op het [NL Design System Link candidate](https://github.com/nl-design-system/candidate/blob/main/packages/components-react/link-react/src/link.tsx) component. + +De linktekst komt uit de standaard slot content. + +## Voorbeeld + +```html +Lees meer +``` + +## Class attribuut + +Je kunt een `class` attribuut op het `` element zetten, deze wordt doorgestuurd naar het onderliggende `` element en voegt zo extra styling toe. + +```html +Lees meer +``` + +## Externe link + +```html +Voorbeeldsite +``` + +## Disabled link + +Wanneer `disabled` actief is, gedraagt de link zich als “uitgeschakeld”: +- `href`, `target` en `rel` worden niet gerenderd op het onderliggende `` +- `aria-disabled="true"` wordt gezet +- `role="link"` wordt gezet +- `tabindex="0"` wordt gezet op het host element + +```html +Lees meer +``` + +## Attribuut-forwarding (`aria-*` / `data-*`) + +Standaard worden alleen `aria-*` en `data-*` attributes die je op `` zet, doorgestuurd naar het onderliggende ``. + +```html +Lees meer +``` + +Wil je *alle* (niet-interne) host attributes doorgeven, zet dan `forward-attributes="all"`. + +```html +Lees meer +``` + +> **Let op:** `class` en `style` worden niet doorgestuurd, ook niet met `forward-attributes="all"`. + +## Extra properties via `restProps` (property-only) + +Sommige ``-properties zijn beschikbaar via `restProps` (dit is **geen** attribute-API; alleen via JavaScript). Deze properties worden via property bindings doorgestuurd naar het onderliggende `` element: + +- `download` +- `hreflang` +- `ping` +- `referrerPolicy` +- `type` + +```js +const el = document.querySelector('clippy-link'); +el.restProps = { + download: 'bestand.pdf', + hreflang: 'nl', + ping: 'https://example.com/ping', + referrerPolicy: 'no-referrer', + type: 'text/html', +}; +``` + +## API + +- **`href`**: string (standaard `""`) — wordt (wanneer niet `disabled`) doorgegeven aan het onderliggende ``. +- **`target`**: string (standaard `""`) — wordt (wanneer niet `disabled`) doorgegeven aan `` (bijv. `_blank`). +- **`rel`**: string (standaard `""`) — wordt (wanneer niet `disabled`) doorgegeven aan ``. +- **`current`**: string (standaard `""`) — alternatief voor `aria-current`; zet `aria-current` en voegt class `nl-link--current` toe. +- **`inline-box`** (`inline-box` attribute): boolean (standaard `false`) — voegt class `nl-link--inline-box` toe. +- **`disabled`**: boolean (standaard `false`) — voegt class `nl-link--disabled` toe, verwijdert `href/target/rel` van het onderliggende ``, zet `aria-disabled="true"`, `role="link"` en `tabindex="0"` op het host element. +- **`forward-attributes`**: `"aria-data"` (default) | `"all"` — bepaalt welke host attributes worden doorgestuurd naar het onderliggende ``. +- **`class`**: string (attribute op host) — wordt doorgestuurd naar het onderliggende `` via classMap. +- **`restProps`**: object (property-only) — extra (getypte) properties voor het onderliggende ``, via property bindings: `download`, `hreflang`, `referrerPolicy`, `ping`, `type`. diff --git a/packages/clippy-components/src/clippy-link/index.test.ts b/packages/clippy-components/src/clippy-link/index.test.ts new file mode 100644 index 000000000..a6843b1c6 --- /dev/null +++ b/packages/clippy-components/src/clippy-link/index.test.ts @@ -0,0 +1,185 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import './index'; + +const tag = 'clippy-link'; + +type LitUpdatable = HTMLElement & { updateComplete: Promise }; + +describe(`<${tag}>`, () => { + beforeEach(() => { + document.body.innerHTML = `<${tag}>`; + }); + + it('renders an anchor element', async () => { + const el = document.querySelector(tag); + expect(el).not.toBeNull(); + + await customElements.whenDefined(tag); + + const a = el!.shadowRoot?.querySelector('a'); + expect(a).not.toBeNull(); + expect(a?.classList.contains('nl-link')).toBe(true); + }); + + + it('sets href on the rendered anchor', async () => { + const el = document.querySelector(tag) as HTMLElement & { href: string }; + expect(el).not.toBeNull(); + + await customElements.whenDefined(tag); + + el.href = '/example'; + await (el as unknown as LitUpdatable).updateComplete; + + const a = el.shadowRoot?.querySelector('a'); + expect(a).not.toBeNull(); + expect(a?.getAttribute('href')).toBe('/example'); + }); + + + it('forwards restProps to the inner anchor element', async () => { + document.body.innerHTML = `<${tag}>`; + interface ClippyLinkRestProps extends HTMLElement { + restProps: Record; + updateComplete: Promise; + } + const el = document.querySelector(tag) as ClippyLinkRestProps; + expect(el).not.toBeNull(); + + await customElements.whenDefined(tag); + + el.restProps = { + download: 'file.pdf', + hreflang: 'en', + ping: 'https://example.com/ping', + referrerPolicy: 'no-referrer', + type: 'application/pdf', + }; + await el.updateComplete; + + const a = el.shadowRoot?.querySelector('a'); + expect(a).not.toBeNull(); + expect(a?.download).toBe('file.pdf'); + expect(a?.hreflang).toBe('en'); + expect(a?.ping).toBe('https://example.com/ping'); + expect(a?.referrerPolicy).toBe('no-referrer'); + expect(a?.type).toBe('application/pdf'); + }); + + it('applies className to the inner anchor element', async () => { + document.body.innerHTML = `<${tag}>`; + const el = document.querySelector(tag) as HTMLElement & { className: string; updateComplete: Promise }; + expect(el).not.toBeNull(); + + await customElements.whenDefined(tag); + + el.className = 'my-extra-class'; + await el.updateComplete; + + const a = el.shadowRoot?.querySelector('a'); + expect(a).not.toBeNull(); + expect(a?.classList.contains('my-extra-class')).toBe(true); + }); + + it('adds nl-link--current class when current is set', async () => { + document.body.innerHTML = `<${tag} current="page">`; + const el = document.querySelector(tag) as HTMLElement & { current: string }; + expect(el).not.toBeNull(); + + await customElements.whenDefined(tag); + await (el as unknown as LitUpdatable).updateComplete; + + const a = el.shadowRoot?.querySelector('a'); + expect(a).not.toBeNull(); + expect(a?.classList.contains('nl-link--current')).toBe(true); + expect(a?.getAttribute('aria-current')).toBe('page'); + }); + + it('adds nl-link--inline-box class when inline-box is set', async () => { + document.body.innerHTML = `<${tag} inline-box>`; + const el = document.querySelector(tag) as HTMLElement & { inlineBox: boolean }; + expect(el).not.toBeNull(); + + await customElements.whenDefined(tag); + await (el as unknown as LitUpdatable).updateComplete; + + const a = el.shadowRoot?.querySelector('a'); + expect(a).not.toBeNull(); + expect(a?.classList.contains('nl-link--inline-box')).toBe(true); + }); + + it('disabled removes href/target and marks link as disabled', async () => { + document.body.innerHTML = `<${tag} href="/x" target="_blank" disabled>Lees meer`; + const el = document.querySelector(tag) as HTMLElement & { disabled: boolean }; + expect(el).not.toBeNull(); + + await customElements.whenDefined(tag); + await (el as unknown as LitUpdatable).updateComplete; + + const a = el.shadowRoot?.querySelector('a'); + expect(a).not.toBeNull(); + expect(a?.classList.contains('nl-link--disabled')).toBe(true); + expect(a?.getAttribute('aria-disabled')).toBe('true'); + expect(a?.getAttribute('href')).toBeNull(); + expect(a?.getAttribute('target')).toBeNull(); + expect(a?.getAttribute('tabindex')).toBe('0'); + expect(a?.getAttribute('role')).toBe('link'); + }); + + it('forwards non-component attributes to the rendered anchor', async () => { + document.body.innerHTML = `<${tag} href="/x" aria-label="Meer info" data-testid="link">Lees meer`; + const el = document.querySelector(tag); + expect(el).not.toBeNull(); + + await customElements.whenDefined(tag); + await (el as unknown as LitUpdatable).updateComplete; + + const a = el!.shadowRoot?.querySelector('a'); + expect(a).not.toBeNull(); + expect(a?.getAttribute('aria-label')).toBe('Meer info'); + expect(a?.dataset['testid']).toBe('link'); + + el!.removeAttribute('aria-label'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(a?.getAttribute('aria-label')).toBeNull(); + }); + + it('does not forward non aria/data attributes by default', async () => { + document.body.innerHTML = `<${tag} href="/x" title="Tooltip">Lees meer`; + const el = document.querySelector(tag); + expect(el).not.toBeNull(); + + await customElements.whenDefined(tag); + await (el as unknown as LitUpdatable).updateComplete; + + const a = el!.shadowRoot?.querySelector('a'); + expect(a).not.toBeNull(); + expect(a?.getAttribute('title')).toBeNull(); + }); + + it('forwards non aria/data attributes when forward-attributes is set to all, and cleans up when switching back', async () => { + document.body.innerHTML = `<${tag} href="/x" forward-attributes="all" title="Tooltip">Lees meer`; + const el = document.querySelector(tag); + expect(el).not.toBeNull(); + + await customElements.whenDefined(tag); + await (el as unknown as LitUpdatable).updateComplete; + + const a = el!.shadowRoot?.querySelector('a'); + expect(a).not.toBeNull(); + + // Forwarded when policy is "all" + expect(a?.getAttribute('title')).toBe('Tooltip'); + // The policy attribute itself must never be forwarded + expect(a?.hasAttribute('forward-attributes')).toBe(false); + + // Switch policy back to default and ensure cleanup happens. + el!.setAttribute('forward-attributes', 'aria-data'); + await (el as unknown as LitUpdatable).updateComplete; + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(a?.getAttribute('title')).toBeNull(); + }); + +}); diff --git a/packages/clippy-components/src/clippy-link/index.ts b/packages/clippy-components/src/clippy-link/index.ts new file mode 100644 index 000000000..21455d583 --- /dev/null +++ b/packages/clippy-components/src/clippy-link/index.ts @@ -0,0 +1,144 @@ +import linkCss from '@nl-design-system-candidate/link-css/link.css?inline'; +import { html, LitElement, unsafeCSS, type PropertyValues } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; + +type ForwardAttributes = 'aria-data' | 'all'; + +type AnchorRestProps = Partial>; + +@customElement('clippy-link') +export class ClippyLink extends LitElement { + private static readonly blockedAttributes = new Set([ + 'href', + 'target', + 'rel', + 'current', + 'inline-box', + 'disabled', + 'forward-attributes', + 'class', + 'style', + 'download', + 'hreflang', + 'referrerPolicy', + 'ping', + 'type', + ]); + + @property() href = ''; + @property() target = ''; + @property() rel = ''; + + @property() current = ''; + @property({ attribute: 'inline-box', type: Boolean }) inlineBox = false; + @property({ type: Boolean }) disabled = false; + + @property({ + attribute: 'forward-attributes', + converter: { + fromAttribute: (value: string | null): ForwardAttributes => (value === 'all' ? 'all' : 'aria-data'), + }, + type: String, + }) + forwardAttributes: ForwardAttributes = 'aria-data'; + + @property({ attribute: false }) override className = ''; + + @property({ attribute: false }) restProps: AnchorRestProps = {}; + + @query('a') private readonly anchorEl?: HTMLAnchorElement; + private attributeObserver?: MutationObserver; + + static override readonly styles = [unsafeCSS(linkCss)]; + + override connectedCallback() { + super.connectedCallback(); + this.attributeObserver = new MutationObserver(() => this.forwardNonComponentAttributes()); + this.attributeObserver.observe(this, { attributes: true }); + } + + override disconnectedCallback() { + this.attributeObserver?.disconnect(); + this.attributeObserver = undefined; + super.disconnectedCallback(); + } + + override firstUpdated() { + this.forwardNonComponentAttributes(); + } + + override updated(changedProperties: PropertyValues) { + if (changedProperties.has('forwardAttributes')) { + this.forwardNonComponentAttributes(); + } + } + + private isAllowedToForwardAttribute(name: string) { + if (ClippyLink.blockedAttributes.has(name)) return false; + if (this.forwardAttributes === 'all') return true; + return name.startsWith('aria-') || name.startsWith('data-'); + } + + private forwardNonComponentAttributes() { + const a = this.anchorEl; + if (!a) return; + + // Remove all forwarded attributes first + for (const name of a.getAttributeNames()) { + if (name === 'class' || name === 'style') continue; + if (!ClippyLink.blockedAttributes.has(name)) { + a.removeAttribute(name); + } + } + + // Forward host attributes + for (const name of this.getAttributeNames()) { + if (!this.isAllowedToForwardAttribute(name)) continue; + const value = this.getAttribute(name); + if (value !== null) a.setAttribute(name, value); + } + } + + override render() { + const enabled = !this.disabled; + + const classes = { + 'nl-link': true, + 'nl-link--current': Boolean(this.current), + 'nl-link--disabled': this.disabled, + 'nl-link--inline-box': this.inlineBox, + }; + + if (this.className) { + (classes as Record)[this.className] = true; + } + + return html` + + + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'clippy-link': ClippyLink; + } +} \ No newline at end of file diff --git a/packages/clippy-storybook/src/web-components/clippy-link.stories.tsx b/packages/clippy-storybook/src/web-components/clippy-link.stories.tsx new file mode 100644 index 000000000..29183fb32 --- /dev/null +++ b/packages/clippy-storybook/src/web-components/clippy-link.stories.tsx @@ -0,0 +1,334 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import '@nl-design-system-community/clippy-components/src/clippy-link/index.ts'; +import readme from '@nl-design-system-community/clippy-components/src/clippy-link/README.md?raw'; +import { html } from 'lit'; +import React from 'react'; +import { templateToHtml } from '../utils/templateToHtml'; + +type ForwardAttributes = 'aria-data' | 'all'; + +interface LinkStoryArgs { + ariaLabel: string; + className: string; + content: string; + current: string; + dataTestid: string; + disabled: boolean; + forwardAttributes: ForwardAttributes; + href: string; + inlineBox: boolean; + rel: string; + target: string; + restDownload: string; + restHreflang: string; + restPing: string; + restReferrerPolicy: string; + restType: string; +} + +const createTemplate = (args: LinkStoryArgs) => { + const inlineBox = args.inlineBox ? ' inline-box' : ''; + const disabled = args.disabled ? ' disabled' : ''; + const forwardAttributes = args.forwardAttributes === 'all' ? ' forward-attributes="all"' : ''; + const href = args.href ? ` href="${args.href}"` : ''; + const target = args.target ? ` target="${args.target}"` : ''; + const rel = args.rel ? ` rel="${args.rel}"` : ''; + const current = args.current ? ` current="${args.current}"` : ''; + const className = args.className ? ` class="${args.className}"` : ''; + const ariaLabel = args.ariaLabel ? ` aria-label="${args.ariaLabel}"` : ''; + const dataTestid = args.dataTestid ? ` data-testid="${args.dataTestid}"` : ''; + const restDownload = args.restDownload ? ` rest-download="${args.restDownload}"` : ''; + const restHreflang = args.restHreflang ? ` rest-hreflang="${args.restHreflang}"` : ''; + const restPing = args.restPing ? ` rest-ping="${args.restPing}"` : ''; + const restReferrerPolicy = args.restReferrerPolicy ? ` rest-referrer-policy="${args.restReferrerPolicy}"` : ''; + const restType = args.restType ? ` rest-type="${args.restType}"` : ''; + + return html`${args.content}`; +}; + +type ClippyLinkElement = HTMLElement & { + current: string; + disabled: boolean; + href: string; + rel: string; + target: string; + className: string; + restProps: { + download?: string; + hreflang?: string; + ping?: string; + referrerPolicy?: string; + type?: string; + }; +}; + +const syncClippyLink = (el: ClippyLinkElement, args: LinkStoryArgs) => { + el.href = args.href; + el.target = args.target; + el.rel = args.rel; + el.current = args.current; + el.disabled = args.disabled; + el.className = args.className; + + if (args.href) el.setAttribute('href', args.href); + else el.removeAttribute('href'); + + if (args.target) el.setAttribute('target', args.target); + else el.removeAttribute('target'); + + if (args.rel) el.setAttribute('rel', args.rel); + else el.removeAttribute('rel'); + + if (args.current) el.setAttribute('current', args.current); + else el.removeAttribute('current'); + + if (args.className) el.setAttribute('class', args.className); + else el.removeAttribute('class'); + + if (args.disabled) el.setAttribute('disabled', ''); + else el.removeAttribute('disabled'); + + el.toggleAttribute('inline-box', args.inlineBox); + if (args.forwardAttributes === 'all') el.setAttribute('forward-attributes', 'all'); + else el.removeAttribute('forward-attributes'); + + if (args.ariaLabel) el.setAttribute('aria-label', args.ariaLabel); + else el.removeAttribute('aria-label'); + + if (args.dataTestid) el.dataset['testid'] = args.dataTestid; + else delete el.dataset['testid']; + + el.restProps = { + download: args.restDownload || undefined, + hreflang: args.restHreflang || undefined, + ping: args.restPing || undefined, + referrerPolicy: args.restReferrerPolicy || undefined, + type: args.restType || undefined, + }; +}; + +const ClippyLinkStory = (args: LinkStoryArgs) => { + const ref = React.useRef(null); + + React.useEffect(() => { + const el = ref.current; + if (!el) return; + syncClippyLink(el, args); + }, [ + args.ariaLabel, + args.className, + args.current, + args.dataTestid, + args.disabled, + args.forwardAttributes, + args.href, + args.inlineBox, + args.rel, + args.target, + args.restDownload, + args.restHreflang, + args.restPing, + args.restReferrerPolicy, + args.restType, + ]); + + return React.createElement('clippy-link', { ref }, args.content); +}; + +const meta = { + id: 'clippy-link', + args: { + ariaLabel: 'Meer info', + className: '', + content: 'Voorbeeldsite', + current: '', + dataTestid: 'link', + disabled: false, + forwardAttributes: 'aria-data', + href: 'https://example.com', + inlineBox: false, + rel: 'noopener noreferrer', + restDownload: '', + restHreflang: '', + restPing: '', + restReferrerPolicy: '', + restType: '', + target: '_blank', + }, + argTypes: { + ariaLabel: { + name: 'aria-label', + defaultValue: '', + description: 'Forwarded to the rendered (when forward-attributes allows it)', + type: { + name: 'string', + required: false, + }, + }, + className: { + name: 'class', + defaultValue: '', + description: 'Extra class applied to the host element (forwarded to via class attribute)', + type: { name: 'string', required: false }, + }, + content: { + name: 'Content', + defaultValue: '', + description: 'Text', + type: { + name: 'string', + required: true, + }, + }, + current: { + name: 'Current', + defaultValue: '', + description: 'Alternative for aria-current (e.g. "page")', + type: { + name: 'string', + required: false, + }, + }, + dataTestid: { + name: 'data-testid', + defaultValue: '', + description: 'Forwarded to the rendered (when forward-attributes allows it)', + type: { + name: 'string', + required: false, + }, + }, + disabled: { + name: 'Disabled', + control: { type: 'boolean' }, + defaultValue: false, + description: 'Disable link behavior (adds nl-link--disabled class)', + type: { + name: 'boolean', + required: false, + }, + }, + forwardAttributes: { + name: 'Forward attributes', + control: { type: 'select' }, + defaultValue: 'aria-data', + description: 'Which host attributes are forwarded to the inner ', + options: ['aria-data', 'all'] satisfies ForwardAttributes[], + type: { + name: 'string', + required: false, + }, + }, + href: { + name: 'Href', + defaultValue: '', + description: 'URL where the link points to', + type: { + name: 'string', + required: true, + }, + }, + inlineBox: { + name: 'Inline box', + control: { type: 'boolean' }, + defaultValue: false, + description: 'Render as inline-box (adds nl-link--inline-box class)', + type: { + name: 'boolean', + required: false, + }, + }, + rel: { + name: 'Rel', + defaultValue: '', + description: 'Relationship between the current document and the linked URL', + type: { + name: 'string', + required: false, + }, + }, + restDownload: { + name: 'restProps.download', + defaultValue: '', + description: 'Property-only forwarded to the inner (download)', + type: { + name: 'string', + required: false, + }, + }, + restHreflang: { + name: 'restProps.hreflang', + defaultValue: '', + description: 'Property-only forwarded to the inner (hreflang)', + type: { + name: 'string', + required: false, + }, + }, + restPing: { + name: 'restProps.ping', + defaultValue: '', + description: 'Property-only forwarded to the inner (ping)', + type: { + name: 'string', + required: false, + }, + }, + restReferrerPolicy: { + name: 'restProps.referrerPolicy', + defaultValue: '', + description: 'Property-only forwarded to the inner (referrerPolicy)', + type: { + name: 'string', + required: false, + }, + }, + restType: { + name: 'restProps.type', + defaultValue: '', + description: 'Property-only forwarded to the inner (type)', + type: { + name: 'string', + required: false, + }, + }, + target: { + name: 'Target', + defaultValue: '', + description: 'Where to display the linked URL (e.g. _blank)', + type: { + name: 'string', + required: false, + }, + }, + }, + parameters: { + docs: { + description: { + component: readme, + }, + }, + }, + render: (args: LinkStoryArgs) => React.createElement(ClippyLinkStory, args), + tags: ['autodocs'], + title: 'Clippy/Link', +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: 'Link', + parameters: { + docs: { + source: { + transform: (_code: string, storyContext: { args: LinkStoryArgs }) => { + const template = createTemplate(storyContext.args); + return templateToHtml(template); + }, + type: 'code', + }, + }, + }, +}; diff --git a/packages/theme-wizard-app/package.json b/packages/theme-wizard-app/package.json index e920e0924..fc659d671 100644 --- a/packages/theme-wizard-app/package.json +++ b/packages/theme-wizard-app/package.json @@ -49,8 +49,6 @@ "@nl-design-system-candidate/code-css": "2.0.5", "@nl-design-system-candidate/color-sample-css": "1.0.4", "@nl-design-system-candidate/data-badge-css": "1.0.5", - "@nl-design-system-candidate/heading-css": "1.1.3", - "@nl-design-system-candidate/link-css": "2.0.3", "@nl-design-system-candidate/paragraph-css": "2.1.3", "@nl-design-system-candidate/skip-link-css": "1.0.5", "@nl-design-system-community/clippy-components": "workspace:*", diff --git a/packages/theme-wizard-app/src/components/template-link/index.ts b/packages/theme-wizard-app/src/components/template-link/index.ts deleted file mode 100644 index d42858c02..000000000 --- a/packages/theme-wizard-app/src/components/template-link/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import linkCss from '@nl-design-system-candidate/link-css/link.css?inline'; -import { html, LitElement, unsafeCSS } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; - -@customElement('template-link') -export class TemplateLink extends LitElement { - @property() href: string = ''; - - static override readonly styles = [unsafeCSS(linkCss)]; - - override render() { - return html` - - - - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'template-link': TemplateLink; - } -} diff --git a/packages/theme-wizard-app/src/components/wizard-index-page/index.ts b/packages/theme-wizard-app/src/components/wizard-index-page/index.ts index a26d6f659..4885a6de8 100644 --- a/packages/theme-wizard-app/src/components/wizard-index-page/index.ts +++ b/packages/theme-wizard-app/src/components/wizard-index-page/index.ts @@ -24,7 +24,7 @@ import '../template-case-card'; import '../template-color-swatch'; import '../template-heading'; import '../template-link-list'; -import '../template-link'; +import '@nl-design-system-community/clippy-components/src/template-link/index.js'; import '../template-page-header'; import '../template-paragraph'; import '../template-side-nav'; diff --git a/packages/theme-wizard-app/src/components/wizard-style-guide/index.ts b/packages/theme-wizard-app/src/components/wizard-style-guide/index.ts index be24678a5..40e93c7e5 100644 --- a/packages/theme-wizard-app/src/components/wizard-style-guide/index.ts +++ b/packages/theme-wizard-app/src/components/wizard-style-guide/index.ts @@ -410,7 +410,7 @@ export class WizardStyleGuide extends LitElement { - docs + docs ${isUsed ? nothing diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbca36c2f..3d3f2e113 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,12 @@ importers: '@nl-design-system-candidate/color-sample-css': specifier: 1.0.4 version: 1.0.4 + '@nl-design-system-candidate/heading-css': + specifier: 1.1.3 + version: 1.1.3 + '@nl-design-system-candidate/link-css': + specifier: 2.0.3 + version: 2.0.3 '@utrecht/button-css': specifier: 3.0.1 version: 3.0.1 @@ -385,12 +391,6 @@ importers: '@nl-design-system-candidate/data-badge-css': specifier: 1.0.5 version: 1.0.5 - '@nl-design-system-candidate/heading-css': - specifier: 1.1.3 - version: 1.1.3 - '@nl-design-system-candidate/link-css': - specifier: 2.0.3 - version: 2.0.3 '@nl-design-system-candidate/paragraph-css': specifier: 2.1.3 version: 2.1.3 From 0022272296f83c8ff40f8c5fc6eb9a964343c356 Mon Sep 17 00:00:00 2001 From: Michelle Date: Wed, 21 Jan 2026 13:42:49 +0100 Subject: [PATCH 2/4] feat: clippy link enhancements --- packages/clippy-components/package.json | 1 + .../src/clippy-link/index.test.ts | 137 +++++++----------- .../src/clippy-link/index.ts | 82 +++++++---- .../web-components/clippy-link.stories.tsx | 35 +++-- packages/theme-wizard-app/package.json | 1 + .../src/components/wizard-index-page/index.ts | 10 +- .../src/components/wizard-layout/index.ts | 19 ++- .../src/components/wizard-layout/styles.ts | 2 - .../components/wizard-style-guide/index.ts | 35 +++-- .../src/layouts/PageLayout.astro | 20 +-- pnpm-lock.yaml | 3 + 11 files changed, 168 insertions(+), 177 deletions(-) diff --git a/packages/clippy-components/package.json b/packages/clippy-components/package.json index 8338bd2da..6018ead6d 100644 --- a/packages/clippy-components/package.json +++ b/packages/clippy-components/package.json @@ -40,6 +40,7 @@ "test-build": "vitest --run --coverage", "test-install-browsers": "playwright install chromium", "test:unit": "vitest --run", + "test:watch": "vitest --watch", "preview": "vite preview" }, "dependencies": { diff --git a/packages/clippy-components/src/clippy-link/index.test.ts b/packages/clippy-components/src/clippy-link/index.test.ts index a6843b1c6..ed52e5bbc 100644 --- a/packages/clippy-components/src/clippy-link/index.test.ts +++ b/packages/clippy-components/src/clippy-link/index.test.ts @@ -3,93 +3,51 @@ import './index'; const tag = 'clippy-link'; -type LitUpdatable = HTMLElement & { updateComplete: Promise }; - describe(`<${tag}>`, () => { beforeEach(() => { document.body.innerHTML = `<${tag}>`; }); - it('renders an anchor element', async () => { + it('renders an anchor with base class and sets href', async () => { + document.body.innerHTML = `<${tag} href="/example">`; const el = document.querySelector(tag); expect(el).not.toBeNull(); await customElements.whenDefined(tag); + await el?.updateComplete; - const a = el!.shadowRoot?.querySelector('a'); + const a = el?.shadowRoot?.querySelector('a'); expect(a).not.toBeNull(); expect(a?.classList.contains('nl-link')).toBe(true); - }); - - - it('sets href on the rendered anchor', async () => { - const el = document.querySelector(tag) as HTMLElement & { href: string }; - expect(el).not.toBeNull(); - - await customElements.whenDefined(tag); - - el.href = '/example'; - await (el as unknown as LitUpdatable).updateComplete; - - const a = el.shadowRoot?.querySelector('a'); - expect(a).not.toBeNull(); expect(a?.getAttribute('href')).toBe('/example'); }); - - it('forwards restProps to the inner anchor element', async () => { - document.body.innerHTML = `<${tag}>`; - interface ClippyLinkRestProps extends HTMLElement { - restProps: Record; - updateComplete: Promise; - } - const el = document.querySelector(tag) as ClippyLinkRestProps; - expect(el).not.toBeNull(); - - await customElements.whenDefined(tag); - - el.restProps = { - download: 'file.pdf', - hreflang: 'en', - ping: 'https://example.com/ping', - referrerPolicy: 'no-referrer', - type: 'application/pdf', - }; - await el.updateComplete; - - const a = el.shadowRoot?.querySelector('a'); - expect(a).not.toBeNull(); - expect(a?.download).toBe('file.pdf'); - expect(a?.hreflang).toBe('en'); - expect(a?.ping).toBe('https://example.com/ping'); - expect(a?.referrerPolicy).toBe('no-referrer'); - expect(a?.type).toBe('application/pdf'); - }); - - it('applies className to the inner anchor element', async () => { - document.body.innerHTML = `<${tag}>`; - const el = document.querySelector(tag) as HTMLElement & { className: string; updateComplete: Promise }; + it('forwards anchor-specific attributes via host attributes', async () => { + document.body.innerHTML = `<${tag} download="file.pdf" hreflang="en" ping="https://example.com/ping" referrerpolicy="no-referrer" type="application/pdf">`; + const el = document.querySelector(tag); expect(el).not.toBeNull(); await customElements.whenDefined(tag); + await el?.updateComplete; - el.className = 'my-extra-class'; - await el.updateComplete; - - const a = el.shadowRoot?.querySelector('a'); + const a = el!.shadowRoot?.querySelector('a'); expect(a).not.toBeNull(); - expect(a?.classList.contains('my-extra-class')).toBe(true); + expect(a?.getAttribute('download')).toBe('file.pdf'); + expect(a?.getAttribute('hreflang')).toBe('en'); + expect(a?.getAttribute('ping')).toBe('https://example.com/ping'); + expect(a?.getAttribute('referrerpolicy')).toBe('no-referrer'); + expect(a?.getAttribute('type')).toBe('application/pdf'); }); - it('adds nl-link--current class when current is set', async () => { - document.body.innerHTML = `<${tag} current="page">`; - const el = document.querySelector(tag) as HTMLElement & { current: string }; + it('adds nl-link--current class when aria-current is set on the host', async () => { + document.body.innerHTML = `<${tag} aria-current="page">`; + const el = document.querySelector(tag); expect(el).not.toBeNull(); await customElements.whenDefined(tag); - await (el as unknown as LitUpdatable).updateComplete; + await el?.updateComplete; - const a = el.shadowRoot?.querySelector('a'); + const a = el?.shadowRoot?.querySelector('a'); expect(a).not.toBeNull(); expect(a?.classList.contains('nl-link--current')).toBe(true); expect(a?.getAttribute('aria-current')).toBe('page'); @@ -97,26 +55,26 @@ describe(`<${tag}>`, () => { it('adds nl-link--inline-box class when inline-box is set', async () => { document.body.innerHTML = `<${tag} inline-box>`; - const el = document.querySelector(tag) as HTMLElement & { inlineBox: boolean }; + const el = document.querySelector(tag); expect(el).not.toBeNull(); await customElements.whenDefined(tag); - await (el as unknown as LitUpdatable).updateComplete; + await el?.updateComplete; - const a = el.shadowRoot?.querySelector('a'); + const a = el?.shadowRoot?.querySelector('a'); expect(a).not.toBeNull(); expect(a?.classList.contains('nl-link--inline-box')).toBe(true); }); it('disabled removes href/target and marks link as disabled', async () => { document.body.innerHTML = `<${tag} href="/x" target="_blank" disabled>Lees meer`; - const el = document.querySelector(tag) as HTMLElement & { disabled: boolean }; + const el = document.querySelector(tag); expect(el).not.toBeNull(); await customElements.whenDefined(tag); - await (el as unknown as LitUpdatable).updateComplete; + await el?.updateComplete; - const a = el.shadowRoot?.querySelector('a'); + const a = el?.shadowRoot?.querySelector('a'); expect(a).not.toBeNull(); expect(a?.classList.contains('nl-link--disabled')).toBe(true); expect(a?.getAttribute('aria-disabled')).toBe('true'); @@ -132,42 +90,36 @@ describe(`<${tag}>`, () => { expect(el).not.toBeNull(); await customElements.whenDefined(tag); - await (el as unknown as LitUpdatable).updateComplete; + await el?.updateComplete; - const a = el!.shadowRoot?.querySelector('a'); + const a = el?.shadowRoot?.querySelector('a'); expect(a).not.toBeNull(); expect(a?.getAttribute('aria-label')).toBe('Meer info'); expect(a?.dataset['testid']).toBe('link'); - el!.removeAttribute('aria-label'); - await new Promise((resolve) => setTimeout(resolve, 0)); + el?.removeAttribute('aria-label'); + await el?.updateComplete; expect(a?.getAttribute('aria-label')).toBeNull(); }); - it('does not forward non aria/data attributes by default', async () => { + it('forwards non aria/data attributes only when forward-attributes="all", and cleans up when switching back', async () => { document.body.innerHTML = `<${tag} href="/x" title="Tooltip">Lees meer`; const el = document.querySelector(tag); expect(el).not.toBeNull(); await customElements.whenDefined(tag); - await (el as unknown as LitUpdatable).updateComplete; + await el?.updateComplete; const a = el!.shadowRoot?.querySelector('a'); expect(a).not.toBeNull(); - expect(a?.getAttribute('title')).toBeNull(); - }); - it('forwards non aria/data attributes when forward-attributes is set to all, and cleans up when switching back', async () => { - document.body.innerHTML = `<${tag} href="/x" forward-attributes="all" title="Tooltip">Lees meer`; - const el = document.querySelector(tag); - expect(el).not.toBeNull(); - - await customElements.whenDefined(tag); - await (el as unknown as LitUpdatable).updateComplete; + // Not forwarded by default + expect(a?.getAttribute('title')).toBeNull(); - const a = el!.shadowRoot?.querySelector('a'); - expect(a).not.toBeNull(); + // Forwarded when policy is "all" + el!.setAttribute('forward-attributes', 'all'); + await el?.updateComplete; // Forwarded when policy is "all" expect(a?.getAttribute('title')).toBe('Tooltip'); @@ -176,10 +128,23 @@ describe(`<${tag}>`, () => { // Switch policy back to default and ensure cleanup happens. el!.setAttribute('forward-attributes', 'aria-data'); - await (el as unknown as LitUpdatable).updateComplete; - await new Promise((resolve) => setTimeout(resolve, 0)); + await el?.updateComplete; expect(a?.getAttribute('title')).toBeNull(); }); + it('forwards host class to inner anchor', async () => { + document.body.innerHTML = `<${tag}>`; + const el = document.querySelector(tag); + expect(el).not.toBeNull(); + + await customElements.whenDefined(tag); + + if (el) el.className = 'my-extra-class'; + await el?.updateComplete; + + const a = el?.shadowRoot?.querySelector('a'); + expect(a).not.toBeNull(); + expect(a?.classList.contains('my-extra-class')).toBe(true); + }); }); diff --git a/packages/clippy-components/src/clippy-link/index.ts b/packages/clippy-components/src/clippy-link/index.ts index 21455d583..e0f42f9ea 100644 --- a/packages/clippy-components/src/clippy-link/index.ts +++ b/packages/clippy-components/src/clippy-link/index.ts @@ -6,32 +6,28 @@ import { ifDefined } from 'lit/directives/if-defined.js'; type ForwardAttributes = 'aria-data' | 'all'; -type AnchorRestProps = Partial>; - @customElement('clippy-link') export class ClippyLink extends LitElement { private static readonly blockedAttributes = new Set([ 'href', 'target', 'rel', - 'current', + 'role', + 'tabindex', + 'aria-disabled', 'inline-box', 'disabled', 'forward-attributes', 'class', 'style', - 'download', - 'hreflang', - 'referrerPolicy', - 'ping', - 'type', ]); + private ariaCurrentValue = ''; + @property() href = ''; @property() target = ''; @property() rel = ''; - @property() current = ''; @property({ attribute: 'inline-box', type: Boolean }) inlineBox = false; @property({ type: Boolean }) disabled = false; @@ -46,8 +42,6 @@ export class ClippyLink extends LitElement { @property({ attribute: false }) override className = ''; - @property({ attribute: false }) restProps: AnchorRestProps = {}; - @query('a') private readonly anchorEl?: HTMLAnchorElement; private attributeObserver?: MutationObserver; @@ -55,8 +49,12 @@ export class ClippyLink extends LitElement { override connectedCallback() { super.connectedCallback(); - this.attributeObserver = new MutationObserver(() => this.forwardNonComponentAttributes()); + this.attributeObserver = new MutationObserver(() => { + this.syncAriaCurrent(); + this.forwardNonComponentAttributes(); + }); this.attributeObserver.observe(this, { attributes: true }); + this.syncAriaCurrent(); } override disconnectedCallback() { @@ -66,6 +64,7 @@ export class ClippyLink extends LitElement { } override firstUpdated() { + this.syncAriaCurrent(); this.forwardNonComponentAttributes(); } @@ -73,11 +72,20 @@ export class ClippyLink extends LitElement { if (changedProperties.has('forwardAttributes')) { this.forwardNonComponentAttributes(); } + this.syncAriaCurrent(); + } + + private syncAriaCurrent() { + this.ariaCurrentValue = this.getAttribute('aria-current') ?? ''; } private isAllowedToForwardAttribute(name: string) { if (ClippyLink.blockedAttributes.has(name)) return false; if (this.forwardAttributes === 'all') return true; + const lowerName = name.toLowerCase(); + if (lowerName === 'download' || lowerName === 'hreflang' || lowerName === 'referrerpolicy' || lowerName === 'ping' || lowerName === 'type') { + return true; + } return name.startsWith('aria-') || name.startsWith('data-'); } @@ -85,9 +93,10 @@ export class ClippyLink extends LitElement { const a = this.anchorEl; if (!a) return; - // Remove all forwarded attributes first + // Remove all previously forwarded attributes first (keep template-owned ones set via render()). for (const name of a.getAttributeNames()) { if (name === 'class' || name === 'style') continue; + if (!ClippyLink.blockedAttributes.has(name)) { a.removeAttribute(name); } @@ -102,34 +111,43 @@ export class ClippyLink extends LitElement { } override render() { - const enabled = !this.disabled; + const disabled = this.disabled; + + const href = disabled ? undefined : this.href; + const target = disabled || !this.target ? undefined : this.target; + const rel = disabled || !this.rel ? undefined : this.rel; + + const ariaDisabled = disabled ? 'true' : undefined; + const role = disabled ? 'link' : undefined; + console.log("disabled: ", disabled); + const tabIndex = disabled ? '0' : undefined; const classes = { 'nl-link': true, - 'nl-link--current': Boolean(this.current), - 'nl-link--disabled': this.disabled, + 'nl-link--current': Boolean(this.ariaCurrentValue), + 'nl-link--disabled': disabled, 'nl-link--inline-box': this.inlineBox, }; - if (this.className) { - (classes as Record)[this.className] = true; - } + const hostClasses = (this.className || '') + .trim() + .split(/\s+/) + .filter(Boolean) + .reduce>((acc, name) => { + acc[name] = true; + return acc; + }, {}); + const mergedClasses = { ...classes, ...hostClasses }; return html` diff --git a/packages/clippy-storybook/src/web-components/clippy-link.stories.tsx b/packages/clippy-storybook/src/web-components/clippy-link.stories.tsx index 29183fb32..ee557c5df 100644 --- a/packages/clippy-storybook/src/web-components/clippy-link.stories.tsx +++ b/packages/clippy-storybook/src/web-components/clippy-link.stories.tsx @@ -11,7 +11,7 @@ interface LinkStoryArgs { ariaLabel: string; className: string; content: string; - current: string; + ariaCurrent: string; dataTestid: string; disabled: boolean; forwardAttributes: ForwardAttributes; @@ -33,7 +33,7 @@ const createTemplate = (args: LinkStoryArgs) => { const href = args.href ? ` href="${args.href}"` : ''; const target = args.target ? ` target="${args.target}"` : ''; const rel = args.rel ? ` rel="${args.rel}"` : ''; - const current = args.current ? ` current="${args.current}"` : ''; + const ariaCurrent = args.ariaCurrent ? ` aria-current="${args.ariaCurrent}"` : ''; const className = args.className ? ` class="${args.className}"` : ''; const ariaLabel = args.ariaLabel ? ` aria-label="${args.ariaLabel}"` : ''; const dataTestid = args.dataTestid ? ` data-testid="${args.dataTestid}"` : ''; @@ -43,11 +43,11 @@ const createTemplate = (args: LinkStoryArgs) => { const restReferrerPolicy = args.restReferrerPolicy ? ` rest-referrer-policy="${args.restReferrerPolicy}"` : ''; const restType = args.restType ? ` rest-type="${args.restType}"` : ''; - return html`${args.content}`; + return html`${args.content}`; }; type ClippyLinkElement = HTMLElement & { - current: string; + ariaCurrent: string; disabled: boolean; href: string; rel: string; @@ -66,7 +66,6 @@ const syncClippyLink = (el: ClippyLinkElement, args: LinkStoryArgs) => { el.href = args.href; el.target = args.target; el.rel = args.rel; - el.current = args.current; el.disabled = args.disabled; el.className = args.className; @@ -79,8 +78,8 @@ const syncClippyLink = (el: ClippyLinkElement, args: LinkStoryArgs) => { if (args.rel) el.setAttribute('rel', args.rel); else el.removeAttribute('rel'); - if (args.current) el.setAttribute('current', args.current); - else el.removeAttribute('current'); + if (args.ariaCurrent) el.setAttribute('aria-current', args.ariaCurrent); + else el.removeAttribute('aria-current'); if (args.className) el.setAttribute('class', args.className); else el.removeAttribute('class'); @@ -117,7 +116,7 @@ const ClippyLinkStory = (args: LinkStoryArgs) => { }, [ args.ariaLabel, args.className, - args.current, + args.ariaCurrent, args.dataTestid, args.disabled, args.forwardAttributes, @@ -138,10 +137,10 @@ const ClippyLinkStory = (args: LinkStoryArgs) => { const meta = { id: 'clippy-link', args: { + ariaCurrent: '', ariaLabel: 'Meer info', className: '', content: 'Voorbeeldsite', - current: '', dataTestid: 'link', disabled: false, forwardAttributes: 'aria-data', @@ -156,6 +155,15 @@ const meta = { target: '_blank', }, argTypes: { + ariaCurrent: { + name: 'aria-current', + defaultValue: '', + description: 'Marks the link as current; used for styling and forwarded to the inner .', + type: { + name: 'string', + required: false, + }, + }, ariaLabel: { name: 'aria-label', defaultValue: '', @@ -180,15 +188,6 @@ const meta = { required: true, }, }, - current: { - name: 'Current', - defaultValue: '', - description: 'Alternative for aria-current (e.g. "page")', - type: { - name: 'string', - required: false, - }, - }, dataTestid: { name: 'data-testid', defaultValue: '', diff --git a/packages/theme-wizard-app/package.json b/packages/theme-wizard-app/package.json index fc659d671..37eaf9d10 100644 --- a/packages/theme-wizard-app/package.json +++ b/packages/theme-wizard-app/package.json @@ -49,6 +49,7 @@ "@nl-design-system-candidate/code-css": "2.0.5", "@nl-design-system-candidate/color-sample-css": "1.0.4", "@nl-design-system-candidate/data-badge-css": "1.0.5", + "@nl-design-system-candidate/heading-css": "1.1.3", "@nl-design-system-candidate/paragraph-css": "2.1.3", "@nl-design-system-candidate/skip-link-css": "1.0.5", "@nl-design-system-community/clippy-components": "workspace:*", diff --git a/packages/theme-wizard-app/src/components/wizard-index-page/index.ts b/packages/theme-wizard-app/src/components/wizard-index-page/index.ts index 4885a6de8..025d6cf06 100644 --- a/packages/theme-wizard-app/src/components/wizard-index-page/index.ts +++ b/packages/theme-wizard-app/src/components/wizard-index-page/index.ts @@ -24,7 +24,7 @@ import '../template-case-card'; import '../template-color-swatch'; import '../template-heading'; import '../template-link-list'; -import '@nl-design-system-community/clippy-components/src/template-link/index.js'; +import '@nl-design-system-community/clippy-components/clippy-link'; import '../template-page-header'; import '../template-paragraph'; import '../template-side-nav'; @@ -215,13 +215,13 @@ export class WizardIndexPage extends LitElement { name=${`basis.color.${colorKey}`} .colorToken=${this.theme.at(`basis.color.${colorKey}.color-default`)} > - docs - + `, diff --git a/packages/theme-wizard-app/src/components/wizard-layout/index.ts b/packages/theme-wizard-app/src/components/wizard-layout/index.ts index 03eb676f5..86b7d0746 100644 --- a/packages/theme-wizard-app/src/components/wizard-layout/index.ts +++ b/packages/theme-wizard-app/src/components/wizard-layout/index.ts @@ -1,5 +1,6 @@ import maTheme from '@nl-design-system-community/ma-design-tokens/dist/theme.css?inline'; import linkCss from '@utrecht/link-css/dist/index.css?inline'; +import '@nl-design-system-community/clippy-components/clippy-link'; import { html, LitElement, unsafeCSS, nothing } from 'lit'; import { customElement } from 'lit/decorators.js'; import { t } from '../../i18n'; @@ -51,20 +52,22 @@ export class WizardLayout extends LitElement {
diff --git a/packages/theme-wizard-app/src/components/wizard-layout/styles.ts b/packages/theme-wizard-app/src/components/wizard-layout/styles.ts index d8c3f9d50..631b70ee0 100644 --- a/packages/theme-wizard-app/src/components/wizard-layout/styles.ts +++ b/packages/theme-wizard-app/src/components/wizard-layout/styles.ts @@ -38,8 +38,6 @@ export default css` } .wizard-layout__nav-item { - --utrecht-link-color: #fff; - --utrecht-link-text-decoration: none; align-content: center; border-block-start-style: solid; diff --git a/packages/theme-wizard-app/src/components/wizard-style-guide/index.ts b/packages/theme-wizard-app/src/components/wizard-style-guide/index.ts index 40e93c7e5..576d9ff8f 100644 --- a/packages/theme-wizard-app/src/components/wizard-style-guide/index.ts +++ b/packages/theme-wizard-app/src/components/wizard-style-guide/index.ts @@ -2,6 +2,7 @@ import type { ClippyModal } from '@nl-design-system-community/clippy-components/ import type { DesignToken } from 'style-dictionary/types'; import { consume } from '@lit/context'; import '@nl-design-system-community/clippy-components/clippy-html-image'; +import '@nl-design-system-community/clippy-components/clippy-link'; import colorSampleCss from '@nl-design-system-candidate/color-sample-css/color-sample.css?inline'; import dataBadgeCss from '@nl-design-system-candidate/data-badge-css/data-badge.css?inline'; import headingCss from '@nl-design-system-candidate/heading-css/heading.css?inline'; @@ -125,7 +126,7 @@ export class WizardStyleGuide extends LitElement { } #handleNavClick(event: Event): void { - const link = (event.target as Element).closest('a[href^="#"]'); + const link = (event.target as Element).closest('clippy-link[href^="#"], a[href^="#"]'); if (!link) return; const href = link.getAttribute('href'); @@ -451,9 +452,9 @@ export class WizardStyleGuide extends LitElement { ${this.#renderFontFamilyExample(displayValue)} ${googleFontsSpecimen ? html` - + ${t('tokens.showOnGoogleFonts')} - + ` : nothing} @@ -492,9 +493,9 @@ export class WizardStyleGuide extends LitElement { - + docs - + ${fontFamilies.every((family) => family.isUsed) ? nothing @@ -556,9 +557,9 @@ export class WizardStyleGuide extends LitElement { - + docs - + ${fontSizes.every((size) => size.isUsed) ? nothing @@ -604,7 +605,7 @@ export class WizardStyleGuide extends LitElement { - docs + docs `; @@ -620,7 +621,9 @@ export class WizardStyleGuide extends LitElement {
${t('styleGuide.sections.space.title')} - docs + + docs + ${spacingData.map(({ space, tokens }) => { @@ -683,9 +686,9 @@ export class WizardStyleGuide extends LitElement { - + docs - + @@ -731,7 +734,7 @@ export class WizardStyleGuide extends LitElement {
${t('styleGuide.sections.components.title')} - docs + docs ${Object.entries(components).map( @@ -915,10 +918,10 @@ export class WizardStyleGuide extends LitElement { return html`
diff --git a/packages/theme-wizard-templates/src/layouts/PageLayout.astro b/packages/theme-wizard-templates/src/layouts/PageLayout.astro index e24f712f4..61cb87a5f 100644 --- a/packages/theme-wizard-templates/src/layouts/PageLayout.astro +++ b/packages/theme-wizard-templates/src/layouts/PageLayout.astro @@ -50,15 +50,15 @@ const { sideNav = true, title, description } = Astro.props; Direct naar de hoofdinhoud - + Gemeente Voorbeeld - + - Contact + Contact - + Jeroen van Drouwen @@ -106,21 +106,21 @@ const { sideNav = true, title, description } = Astro.props;
Bel - 453 453 + 453 453 (maandag tot en met vrijdag van 09.00 tot 17.00 uur) of stuur een e-mail naar - + vragen@gemeentevoorbeeld.nl - + .
- - +
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d3f2e113..f3e7cabf2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -391,6 +391,9 @@ importers: '@nl-design-system-candidate/data-badge-css': specifier: 1.0.5 version: 1.0.5 + '@nl-design-system-candidate/heading-css': + specifier: 1.1.3 + version: 1.1.3 '@nl-design-system-candidate/paragraph-css': specifier: 2.1.3 version: 2.1.3 From 3d1b9d4f5c6ae384fe778a75187048bea3a71929 Mon Sep 17 00:00:00 2001 From: Michelle Date: Wed, 21 Jan 2026 18:54:32 +0100 Subject: [PATCH 3/4] feat: simplify without forwarding --- .../src/clippy-link/index.test.ts | 51 +------ .../src/clippy-link/index.ts | 128 +++------------- .../web-components/clippy-link.stories.tsx | 141 +----------------- 3 files changed, 28 insertions(+), 292 deletions(-) diff --git a/packages/clippy-components/src/clippy-link/index.test.ts b/packages/clippy-components/src/clippy-link/index.test.ts index ed52e5bbc..a4b6544a4 100644 --- a/packages/clippy-components/src/clippy-link/index.test.ts +++ b/packages/clippy-components/src/clippy-link/index.test.ts @@ -22,7 +22,7 @@ describe(`<${tag}>`, () => { expect(a?.getAttribute('href')).toBe('/example'); }); - it('forwards anchor-specific attributes via host attributes', async () => { + it('does not forward arbitrary anchor-specific attributes from the host', async () => { document.body.innerHTML = `<${tag} download="file.pdf" hreflang="en" ping="https://example.com/ping" referrerpolicy="no-referrer" type="application/pdf">`; const el = document.querySelector(tag); expect(el).not.toBeNull(); @@ -32,11 +32,11 @@ describe(`<${tag}>`, () => { const a = el!.shadowRoot?.querySelector('a'); expect(a).not.toBeNull(); - expect(a?.getAttribute('download')).toBe('file.pdf'); - expect(a?.getAttribute('hreflang')).toBe('en'); - expect(a?.getAttribute('ping')).toBe('https://example.com/ping'); - expect(a?.getAttribute('referrerpolicy')).toBe('no-referrer'); - expect(a?.getAttribute('type')).toBe('application/pdf'); + expect(a?.getAttribute('download')).toBeNull(); + expect(a?.getAttribute('hreflang')).toBeNull(); + expect(a?.getAttribute('ping')).toBeNull(); + expect(a?.getAttribute('referrerpolicy')).toBeNull(); + expect(a?.getAttribute('type')).toBeNull(); }); it('adds nl-link--current class when aria-current is set on the host', async () => { @@ -84,7 +84,7 @@ describe(`<${tag}>`, () => { expect(a?.getAttribute('role')).toBe('link'); }); - it('forwards non-component attributes to the rendered anchor', async () => { + it('does not forward non-component aria/data attributes to the rendered anchor', async () => { document.body.innerHTML = `<${tag} href="/x" aria-label="Meer info" data-testid="link">Lees meer`; const el = document.querySelector(tag); expect(el).not.toBeNull(); @@ -94,43 +94,8 @@ describe(`<${tag}>`, () => { const a = el?.shadowRoot?.querySelector('a'); expect(a).not.toBeNull(); - expect(a?.getAttribute('aria-label')).toBe('Meer info'); - expect(a?.dataset['testid']).toBe('link'); - - el?.removeAttribute('aria-label'); - await el?.updateComplete; - expect(a?.getAttribute('aria-label')).toBeNull(); - }); - - it('forwards non aria/data attributes only when forward-attributes="all", and cleans up when switching back', async () => { - document.body.innerHTML = `<${tag} href="/x" title="Tooltip">Lees meer`; - const el = document.querySelector(tag); - expect(el).not.toBeNull(); - - await customElements.whenDefined(tag); - await el?.updateComplete; - - const a = el!.shadowRoot?.querySelector('a'); - expect(a).not.toBeNull(); - - // Not forwarded by default - expect(a?.getAttribute('title')).toBeNull(); - - // Forwarded when policy is "all" - el!.setAttribute('forward-attributes', 'all'); - await el?.updateComplete; - - // Forwarded when policy is "all" - expect(a?.getAttribute('title')).toBe('Tooltip'); - // The policy attribute itself must never be forwarded - expect(a?.hasAttribute('forward-attributes')).toBe(false); - - // Switch policy back to default and ensure cleanup happens. - el!.setAttribute('forward-attributes', 'aria-data'); - await el?.updateComplete; - - expect(a?.getAttribute('title')).toBeNull(); + expect(a?.dataset['testid']).toBeUndefined(); }); it('forwards host class to inner anchor', async () => { diff --git a/packages/clippy-components/src/clippy-link/index.ts b/packages/clippy-components/src/clippy-link/index.ts index e0f42f9ea..98aad16c0 100644 --- a/packages/clippy-components/src/clippy-link/index.ts +++ b/packages/clippy-components/src/clippy-link/index.ts @@ -1,126 +1,31 @@ import linkCss from '@nl-design-system-candidate/link-css/link.css?inline'; -import { html, LitElement, unsafeCSS, type PropertyValues } from 'lit'; -import { customElement, property, query } from 'lit/decorators.js'; +import { html, LitElement, nothing, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; - -type ForwardAttributes = 'aria-data' | 'all'; @customElement('clippy-link') export class ClippyLink extends LitElement { - private static readonly blockedAttributes = new Set([ - 'href', - 'target', - 'rel', - 'role', - 'tabindex', - 'aria-disabled', - 'inline-box', - 'disabled', - 'forward-attributes', - 'class', - 'style', - ]); - - private ariaCurrentValue = ''; - @property() href = ''; @property() target = ''; @property() rel = ''; @property({ attribute: 'inline-box', type: Boolean }) inlineBox = false; @property({ type: Boolean }) disabled = false; - - @property({ - attribute: 'forward-attributes', - converter: { - fromAttribute: (value: string | null): ForwardAttributes => (value === 'all' ? 'all' : 'aria-data'), - }, - type: String, - }) - forwardAttributes: ForwardAttributes = 'aria-data'; - + @property({ attribute: 'aria-current' }) ariaCurrentValue: string = ''; @property({ attribute: false }) override className = ''; - @query('a') private readonly anchorEl?: HTMLAnchorElement; - private attributeObserver?: MutationObserver; - static override readonly styles = [unsafeCSS(linkCss)]; - override connectedCallback() { - super.connectedCallback(); - this.attributeObserver = new MutationObserver(() => { - this.syncAriaCurrent(); - this.forwardNonComponentAttributes(); - }); - this.attributeObserver.observe(this, { attributes: true }); - this.syncAriaCurrent(); - } - - override disconnectedCallback() { - this.attributeObserver?.disconnect(); - this.attributeObserver = undefined; - super.disconnectedCallback(); - } - - override firstUpdated() { - this.syncAriaCurrent(); - this.forwardNonComponentAttributes(); - } - - override updated(changedProperties: PropertyValues) { - if (changedProperties.has('forwardAttributes')) { - this.forwardNonComponentAttributes(); - } - this.syncAriaCurrent(); - } - - private syncAriaCurrent() { - this.ariaCurrentValue = this.getAttribute('aria-current') ?? ''; - } - - private isAllowedToForwardAttribute(name: string) { - if (ClippyLink.blockedAttributes.has(name)) return false; - if (this.forwardAttributes === 'all') return true; - const lowerName = name.toLowerCase(); - if (lowerName === 'download' || lowerName === 'hreflang' || lowerName === 'referrerpolicy' || lowerName === 'ping' || lowerName === 'type') { - return true; - } - return name.startsWith('aria-') || name.startsWith('data-'); - } - - private forwardNonComponentAttributes() { - const a = this.anchorEl; - if (!a) return; - - // Remove all previously forwarded attributes first (keep template-owned ones set via render()). - for (const name of a.getAttributeNames()) { - if (name === 'class' || name === 'style') continue; - - if (!ClippyLink.blockedAttributes.has(name)) { - a.removeAttribute(name); - } - } - - // Forward host attributes - for (const name of this.getAttributeNames()) { - if (!this.isAllowedToForwardAttribute(name)) continue; - const value = this.getAttribute(name); - if (value !== null) a.setAttribute(name, value); - } - } - override render() { const disabled = this.disabled; - const href = disabled ? undefined : this.href; - const target = disabled || !this.target ? undefined : this.target; - const rel = disabled || !this.rel ? undefined : this.rel; - - const ariaDisabled = disabled ? 'true' : undefined; - const role = disabled ? 'link' : undefined; - console.log("disabled: ", disabled); - const tabIndex = disabled ? '0' : undefined; + // When disabled, remove href/target/rel and keep it focusable for accessibility. + const href = disabled ? nothing : this.href || nothing; + const target = disabled || !this.target ? nothing : this.target; + const rel = disabled || !this.rel ? nothing : this.rel; + const role = disabled ? 'link' : nothing; + const tabIndex = disabled ? 0 : nothing; + const ariaCurrent = this.ariaCurrentValue || nothing; const classes = { 'nl-link': true, @@ -142,12 +47,13 @@ export class ClippyLink extends LitElement { return html` diff --git a/packages/clippy-storybook/src/web-components/clippy-link.stories.tsx b/packages/clippy-storybook/src/web-components/clippy-link.stories.tsx index ee557c5df..90d659877 100644 --- a/packages/clippy-storybook/src/web-components/clippy-link.stories.tsx +++ b/packages/clippy-storybook/src/web-components/clippy-link.stories.tsx @@ -5,61 +5,35 @@ import { html } from 'lit'; import React from 'react'; import { templateToHtml } from '../utils/templateToHtml'; -type ForwardAttributes = 'aria-data' | 'all'; - interface LinkStoryArgs { - ariaLabel: string; className: string; content: string; ariaCurrent: string; - dataTestid: string; disabled: boolean; - forwardAttributes: ForwardAttributes; href: string; inlineBox: boolean; rel: string; target: string; - restDownload: string; - restHreflang: string; - restPing: string; - restReferrerPolicy: string; - restType: string; } const createTemplate = (args: LinkStoryArgs) => { const inlineBox = args.inlineBox ? ' inline-box' : ''; const disabled = args.disabled ? ' disabled' : ''; - const forwardAttributes = args.forwardAttributes === 'all' ? ' forward-attributes="all"' : ''; const href = args.href ? ` href="${args.href}"` : ''; const target = args.target ? ` target="${args.target}"` : ''; const rel = args.rel ? ` rel="${args.rel}"` : ''; const ariaCurrent = args.ariaCurrent ? ` aria-current="${args.ariaCurrent}"` : ''; const className = args.className ? ` class="${args.className}"` : ''; - const ariaLabel = args.ariaLabel ? ` aria-label="${args.ariaLabel}"` : ''; - const dataTestid = args.dataTestid ? ` data-testid="${args.dataTestid}"` : ''; - const restDownload = args.restDownload ? ` rest-download="${args.restDownload}"` : ''; - const restHreflang = args.restHreflang ? ` rest-hreflang="${args.restHreflang}"` : ''; - const restPing = args.restPing ? ` rest-ping="${args.restPing}"` : ''; - const restReferrerPolicy = args.restReferrerPolicy ? ` rest-referrer-policy="${args.restReferrerPolicy}"` : ''; - const restType = args.restType ? ` rest-type="${args.restType}"` : ''; - return html`${args.content}`; + return html`${args.content}`; }; type ClippyLinkElement = HTMLElement & { - ariaCurrent: string; disabled: boolean; href: string; rel: string; target: string; className: string; - restProps: { - download?: string; - hreflang?: string; - ping?: string; - referrerPolicy?: string; - type?: string; - }; }; const syncClippyLink = (el: ClippyLinkElement, args: LinkStoryArgs) => { @@ -81,29 +55,10 @@ const syncClippyLink = (el: ClippyLinkElement, args: LinkStoryArgs) => { if (args.ariaCurrent) el.setAttribute('aria-current', args.ariaCurrent); else el.removeAttribute('aria-current'); - if (args.className) el.setAttribute('class', args.className); - else el.removeAttribute('class'); - if (args.disabled) el.setAttribute('disabled', ''); else el.removeAttribute('disabled'); el.toggleAttribute('inline-box', args.inlineBox); - if (args.forwardAttributes === 'all') el.setAttribute('forward-attributes', 'all'); - else el.removeAttribute('forward-attributes'); - - if (args.ariaLabel) el.setAttribute('aria-label', args.ariaLabel); - else el.removeAttribute('aria-label'); - - if (args.dataTestid) el.dataset['testid'] = args.dataTestid; - else delete el.dataset['testid']; - - el.restProps = { - download: args.restDownload || undefined, - hreflang: args.restHreflang || undefined, - ping: args.restPing || undefined, - referrerPolicy: args.restReferrerPolicy || undefined, - type: args.restType || undefined, - }; }; const ClippyLinkStory = (args: LinkStoryArgs) => { @@ -114,21 +69,13 @@ const ClippyLinkStory = (args: LinkStoryArgs) => { if (!el) return; syncClippyLink(el, args); }, [ - args.ariaLabel, args.className, args.ariaCurrent, - args.dataTestid, args.disabled, - args.forwardAttributes, args.href, args.inlineBox, args.rel, args.target, - args.restDownload, - args.restHreflang, - args.restPing, - args.restReferrerPolicy, - args.restType, ]); return React.createElement('clippy-link', { ref }, args.content); @@ -138,36 +85,19 @@ const meta = { id: 'clippy-link', args: { ariaCurrent: '', - ariaLabel: 'Meer info', className: '', content: 'Voorbeeldsite', - dataTestid: 'link', disabled: false, - forwardAttributes: 'aria-data', href: 'https://example.com', inlineBox: false, rel: 'noopener noreferrer', - restDownload: '', - restHreflang: '', - restPing: '', - restReferrerPolicy: '', - restType: '', target: '_blank', }, argTypes: { ariaCurrent: { name: 'aria-current', defaultValue: '', - description: 'Marks the link as current; used for styling and forwarded to the inner .', - type: { - name: 'string', - required: false, - }, - }, - ariaLabel: { - name: 'aria-label', - defaultValue: '', - description: 'Forwarded to the rendered (when forward-attributes allows it)', + description: 'Marks the link as current; used for styling and set on the inner .', type: { name: 'string', required: false, @@ -176,7 +106,7 @@ const meta = { className: { name: 'class', defaultValue: '', - description: 'Extra class applied to the host element (forwarded to via class attribute)', + description: 'Extra class applied to the host element and used for the inner styling', type: { name: 'string', required: false }, }, content: { @@ -188,15 +118,6 @@ const meta = { required: true, }, }, - dataTestid: { - name: 'data-testid', - defaultValue: '', - description: 'Forwarded to the rendered (when forward-attributes allows it)', - type: { - name: 'string', - required: false, - }, - }, disabled: { name: 'Disabled', control: { type: 'boolean' }, @@ -207,17 +128,6 @@ const meta = { required: false, }, }, - forwardAttributes: { - name: 'Forward attributes', - control: { type: 'select' }, - defaultValue: 'aria-data', - description: 'Which host attributes are forwarded to the inner ', - options: ['aria-data', 'all'] satisfies ForwardAttributes[], - type: { - name: 'string', - required: false, - }, - }, href: { name: 'Href', defaultValue: '', @@ -246,51 +156,6 @@ const meta = { required: false, }, }, - restDownload: { - name: 'restProps.download', - defaultValue: '', - description: 'Property-only forwarded to the inner (download)', - type: { - name: 'string', - required: false, - }, - }, - restHreflang: { - name: 'restProps.hreflang', - defaultValue: '', - description: 'Property-only forwarded to the inner (hreflang)', - type: { - name: 'string', - required: false, - }, - }, - restPing: { - name: 'restProps.ping', - defaultValue: '', - description: 'Property-only forwarded to the inner (ping)', - type: { - name: 'string', - required: false, - }, - }, - restReferrerPolicy: { - name: 'restProps.referrerPolicy', - defaultValue: '', - description: 'Property-only forwarded to the inner (referrerPolicy)', - type: { - name: 'string', - required: false, - }, - }, - restType: { - name: 'restProps.type', - defaultValue: '', - description: 'Property-only forwarded to the inner (type)', - type: { - name: 'string', - required: false, - }, - }, target: { name: 'Target', defaultValue: '', From 712bd786ece1d59f5ab132a10f87abcdd3778256 Mon Sep 17 00:00:00 2001 From: Michelle Date: Wed, 21 Jan 2026 20:20:10 +0100 Subject: [PATCH 4/4] fix: lint --- packages/theme-wizard-app/src/components/wizard-layout/styles.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/theme-wizard-app/src/components/wizard-layout/styles.ts b/packages/theme-wizard-app/src/components/wizard-layout/styles.ts index 631b70ee0..0b076d272 100644 --- a/packages/theme-wizard-app/src/components/wizard-layout/styles.ts +++ b/packages/theme-wizard-app/src/components/wizard-layout/styles.ts @@ -38,7 +38,6 @@ export default css` } .wizard-layout__nav-item { - align-content: center; border-block-start-style: solid; border-block-start-width: 4px;