diff --git a/packages/clippy-components/package.json b/packages/clippy-components/package.json index 86d5be1be..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": { @@ -47,6 +48,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..a4b6544a4 --- /dev/null +++ b/packages/clippy-components/src/clippy-link/index.test.ts @@ -0,0 +1,115 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import './index'; + +const tag = 'clippy-link'; + +describe(`<${tag}>`, () => { + beforeEach(() => { + document.body.innerHTML = `<${tag}>`; + }); + + 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'); + expect(a).not.toBeNull(); + expect(a?.classList.contains('nl-link')).toBe(true); + expect(a?.getAttribute('href')).toBe('/example'); + }); + + 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(); + + await customElements.whenDefined(tag); + await el?.updateComplete; + + const a = el!.shadowRoot?.querySelector('a'); + expect(a).not.toBeNull(); + 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 () => { + document.body.innerHTML = `<${tag} aria-current="page">`; + 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(); + 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); + expect(el).not.toBeNull(); + + await customElements.whenDefined(tag); + await el?.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); + expect(el).not.toBeNull(); + + await customElements.whenDefined(tag); + await el?.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('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(); + + await customElements.whenDefined(tag); + await el?.updateComplete; + + const a = el?.shadowRoot?.querySelector('a'); + expect(a).not.toBeNull(); + expect(a?.getAttribute('aria-label')).toBeNull(); + expect(a?.dataset['testid']).toBeUndefined(); + }); + + 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 new file mode 100644 index 000000000..98aad16c0 --- /dev/null +++ b/packages/clippy-components/src/clippy-link/index.ts @@ -0,0 +1,68 @@ +import linkCss from '@nl-design-system-candidate/link-css/link.css?inline'; +import { html, LitElement, nothing, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +@customElement('clippy-link') +export class ClippyLink extends LitElement { + @property() href = ''; + @property() target = ''; + @property() rel = ''; + + @property({ attribute: 'inline-box', type: Boolean }) inlineBox = false; + @property({ type: Boolean }) disabled = false; + @property({ attribute: 'aria-current' }) ariaCurrentValue: string = ''; + @property({ attribute: false }) override className = ''; + + static override readonly styles = [unsafeCSS(linkCss)]; + + override render() { + const disabled = this.disabled; + + // 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, + 'nl-link--current': Boolean(this.ariaCurrentValue), + 'nl-link--disabled': disabled, + 'nl-link--inline-box': this.inlineBox, + }; + + const hostClasses = (this.className || '') + .trim() + .split(/\s+/) + .filter(Boolean) + .reduce>((acc, name) => { + acc[name] = true; + return acc; + }, {}); + const mergedClasses = { ...classes, ...hostClasses }; + + 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..90d659877 --- /dev/null +++ b/packages/clippy-storybook/src/web-components/clippy-link.stories.tsx @@ -0,0 +1,198 @@ +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'; + +interface LinkStoryArgs { + className: string; + content: string; + ariaCurrent: string; + disabled: boolean; + href: string; + inlineBox: boolean; + rel: string; + target: string; +} + +const createTemplate = (args: LinkStoryArgs) => { + const inlineBox = args.inlineBox ? ' inline-box' : ''; + const disabled = args.disabled ? ' disabled' : ''; + 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}"` : ''; + + return html`${args.content}`; +}; + +type ClippyLinkElement = HTMLElement & { + disabled: boolean; + href: string; + rel: string; + target: string; + className: string; +}; + +const syncClippyLink = (el: ClippyLinkElement, args: LinkStoryArgs) => { + el.href = args.href; + el.target = args.target; + el.rel = args.rel; + 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.ariaCurrent) el.setAttribute('aria-current', args.ariaCurrent); + else el.removeAttribute('aria-current'); + + if (args.disabled) el.setAttribute('disabled', ''); + else el.removeAttribute('disabled'); + + el.toggleAttribute('inline-box', args.inlineBox); +}; + +const ClippyLinkStory = (args: LinkStoryArgs) => { + const ref = React.useRef(null); + + React.useEffect(() => { + const el = ref.current; + if (!el) return; + syncClippyLink(el, args); + }, [ + args.className, + args.ariaCurrent, + args.disabled, + args.href, + args.inlineBox, + args.rel, + args.target, + ]); + + return React.createElement('clippy-link', { ref }, args.content); +}; + +const meta = { + id: 'clippy-link', + args: { + ariaCurrent: '', + className: '', + content: 'Voorbeeldsite', + disabled: false, + href: 'https://example.com', + inlineBox: false, + rel: 'noopener noreferrer', + target: '_blank', + }, + argTypes: { + ariaCurrent: { + name: 'aria-current', + defaultValue: '', + description: 'Marks the link as current; used for styling and set on the inner .', + type: { + name: 'string', + required: false, + }, + }, + className: { + name: 'class', + defaultValue: '', + description: 'Extra class applied to the host element and used for the inner styling', + type: { name: 'string', required: false }, + }, + content: { + name: 'Content', + defaultValue: '', + description: 'Text', + type: { + name: 'string', + required: true, + }, + }, + disabled: { + name: 'Disabled', + control: { type: 'boolean' }, + defaultValue: false, + description: 'Disable link behavior (adds nl-link--disabled class)', + type: { + name: 'boolean', + 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, + }, + }, + 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..37eaf9d10 100644 --- a/packages/theme-wizard-app/package.json +++ b/packages/theme-wizard-app/package.json @@ -50,7 +50,6 @@ "@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..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 '../template-link'; +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..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,9 +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; border-block-start-width: 4px; 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..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'); @@ -410,7 +411,7 @@ export class WizardStyleGuide extends LitElement { - docs + docs ${isUsed ? nothing @@ -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 bbca36c2f..f3e7cabf2 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 @@ -388,9 +394,6 @@ importers: '@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