| name | component |
| description | Implementeer een Lit + TypeScript web component |
| user-invocable | true |
| argument-hint | <component-naam> |
Implementeer een web component: $ARGUMENTS
Tech Stack
| Aspect | Technologie |
|---|
| Framework | Lit (LitElement) |
| Taal | TypeScript (.ts) |
| Prefix | nldd- |
WORKFLOW
Stap 1: Bepaal component naam
- Als gebruiker naam opgaf → gebruik die
- Converteer naar kebab-case: "Toggle Button" → "toggle-button"
- Bestandsnamen gebruiken de kale naam:
toggle-button.ts (geen prefix)
- Custom element tag krijgt
nldd- prefix: nldd-toggle-button
- Class naam:
NLDDToggleButton
Stap 2: Bestaand component check
Zoek in src/components/ of het component al bestaat.
| Situatie | Mode |
|---|
| Component bestaat niet | CREATE — nieuwe bestanden aanmaken |
| Component bestaat al | UPDATE — bestaande bestanden bijwerken |
Stap 3: CSS variabelen identificeren
Voorkeursvolgorde:
--components-{name}-* (component-specifiek)
--semantics-* (betekenisvol)
--primitives-* (alleen als backup)
Naamconventies:
- Primitives:
--primitives-{property}-{variant}-{scale}
bijv. --primitives-color-accent-750
- Semantics:
--semantics-{group}-{variant}-{state}-{element}-{element-variant}-{element-state}-{property}
bijv. --semantics-buttons-neutral-tinted-is-hovered-background-color
- Components:
--components-{component}-{variant}-{state}-{element}-{element-variant}-{element-state}-{property}
bijv. --components-checkbox-md-check-icon-size
- Context:
--context-{context}-{property}
Gedeelde variabelen voor communicatie tussen componenten. Niet gedefinieerd in settings.css.
bijv. --context-parent-background-color
- Lokaal:
--_{variant}-{state}-{element}-{element-variant}-{element-state}-{property}
Interne variabelen binnen een component. Definieer defaults in :host.
bijv. --_background-color
Het {element}-segment is de volledige BEM-elementnaam, niet afgekort: --_disclosure-icon-margin-right, niet --_disclosure-margin-right. Laat het element-segment weg voor het root-block (--_background-color). Gebruik één generieke naam als de var door meerdere elementen gedeeld wordt (bijv. --_icon-size voor __start-icon én __end-icon).
Primitives zijn basiswaarden — gebruik ze niet direct in componenten. Semantics geven context voor een groep componenten. Component variabelen zijn specifiek voor één component.
Zoek in src/assets/styles/settings.css:
grep -i "{component-naam}" src/assets/styles/settings.css
grep -i "controls.*min-size\|controls.*corner-radius" src/assets/styles/settings.css
grep -i "focus-ring" src/assets/styles/settings.css
Stap 4: Bestanden aanmaken
src/components/{categorie}/{naam}/
{naam}.ts # Component class
{naam}.styles.ts # Styles
{naam}.template.ts # Render template
{naam}.i18n.ts # Vertalingen (optioneel, bij gebruikersgerichte tekst)
{naam}.stories.ts # Storybook stories
{naam}.test.ts # Tests
COMPONENT TEMPLATE
{naam}.ts:
import { LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { styles } from './{naam}.styles.ts';
import { template } from './{naam}.template.ts';
type Size = 'xs' | 'sm' | 'md';
@customElement('nldd-{naam}')
export class NLDD{PascalName} extends LitElement {
static override styles = styles;
@property({ type: String, reflect: true })
size: Size = 'md';
@property({ type: Boolean, reflect: true })
disabled = false;
override render() {
return template(this);
}
}
declare global {
interface HTMLElementTagNameMap {
'nldd-{naam}': NLDD{PascalName};
}
}
{naam}.styles.ts:
import { css } from 'lit';
import { unsafeCSS } from 'lit';
import { breakpoints } from '../../../assets/styles/breakpoints.ts';
const smMax = unsafeCSS(breakpoints.smMax);
const mdMin = unsafeCSS(breakpoints.mdMin);
const mdMax = unsafeCSS(breakpoints.mdMax);
const lgMin = unsafeCSS(breakpoints.lgMin);
export const styles = css`
:host {
display: inline-block;
}
:host([hidden]) {
display: none;
}
:host([disabled]) {
opacity: var(--primitives-opacity-disabled);
pointer-events: none;
}
.{naam} {
appearance: none;
border: none;
margin: 0;
padding: 0;
background: none;
font: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
container-name: layout-container;
container-type: inline-size;
}
:host([size="md"]) .{naam},
:host(:not([size])) .{naam} {
min-height: var(--semantics-controls-md-min-size);
border-radius: var(--semantics-controls-md-corner-radius);
}
.{naam}__content {
@container layout-container (max-width: ${smMax}) {
}
@container layout-container (min-width: ${mdMin}) and (max-width: ${mdMax}) {
}
@container layout-container (min-width: ${lgMin}) {
}
}
.{naam}:focus-visible {
outline: none;
}
.{naam}__indicator {
position: absolute;
inset: var(--primitives-space-4);
border-radius: calc(var(--semantics-controls-md-corner-radius) - var(--primitives-space-4) / 2);
background-color: transparent;
pointer-events: none;
}
.{naam}:focus-visible .{naam}__indicator {
box-shadow: var(--semantics-focus-ring-box-shadow);
outline: var(--semantics-focus-ring-outline);
}
@media (forced-colors: active) {
.{naam}:focus-visible .{naam}__indicator {
outline: 2px solid CanvasText;
}
}
`;
{naam}.template.ts:
import { html, TemplateResult } from 'lit';
import type { NLDD{PascalName} } from './{naam}.ts';
export function template(component: NLDD{PascalName}): TemplateResult {
return html`
<button class="{naam}"
type="button"
?disabled=${component.disabled}
>
<span class="{naam}__indicator"></span>
<slot></slot>
</button>
`;
}
STORY TEMPLATE
{naam}.stories.ts:
Controls conventies
args staat altijd vóór argTypes in de default export
- Args keys: altijd camelCase (bijv.
startIcon, fullWidth)
name: het HTML attribuut in kebab-case (bijv. name: 'start-icon', name: 'full-width')
table.defaultValue.summary: altijd invullen met de default waarde
description: korte Nederlandse beschrijving
- Icon controls: gebruik
control: 'select' met options: ['(geen)', ...ICONS] plus mapping: { '(geen)': '' } — importeer ICONS uit ../../content/icon/icon.ts. Nooit een text input voor iconen.
- Alias-naam heeft voorkeur: kies bij het gebruiken van een icoon (
icon=, start-icon=, in stories én consumers) de alias-naam boven de canonieke naam als er een alias bestaat — bijv. harvest i.p.v. wheat, info i.p.v. info-circle, new-account i.p.v. person-circle-badge-plus. Aliassen zijn betekenisvoller en stabieler; ze staan in src/components/content/icon/icon-aliases.js.
- Optionele select-controls: Storybook toont anders een leeg item of letterlijk "undefined" in de dropdown. Gebruik
'(geen)' als label en mapping om dat naar de echte waarde te vertalen. Plaats '(geen)' als eerste element in options. In args staat de actual value ('' of undefined) — Storybook reverse-lookt via mapping welke label de huidige waarde representeert en toont die als geselecteerd in de UI. De render-functie ontvangt eveneens de actual value. Let op: bij opties met numerieke waarden (1, 2, ...) plaatst JS de integer-index keys altijd eerst in Object.keys, waardoor '(geen)' visueel onderaan de dropdown belandt; de selected-state werkt wel correct, dus accepteer dat als trade-off.
args: { variant: '' },
argTypes: {
variant: {
control: 'select',
options: ['(geen)', 'icon-and-text', 'text', 'icon'],
mapping: { '(geen)': '' },
table: { defaultValue: { summary: '(geen)' } },
},
},
args: { headingLevel: undefined },
argTypes: {
headingLevel: {
control: 'select',
options: ['(geen)', 1, 2, 3, 4, 5, 6],
mapping: { '(geen)': undefined },
table: { defaultValue: { summary: '(geen)' } },
},
},
- Volgorde consistent:
args, argTypes, template-destructuring en HTML-attributen in de template gebruiken dezelfde volgorde, volgens de canon hieronder.
Canonieke control volgorde
Per component: pak alleen de keys die je gebruikt en zet ze in deze volgorde. De groepering is een mentaal model — de daadwerkelijke args/argTypes is een platte lijst.
[1. Visueel dominant]
variant, size, compact, color, background, layout, panes,
iconOnly, responsive, showItemLabels, inspectorAsSheet, sidebarAsSheet, noLogo
[2. Sizing]
resize, rows, width, minWidth, maxWidth, height, minHeight, fullWidth,
itemWidth, containerSize
[3. Space]
spacing, padding, paddingInline, paddingBlock, paddingTop, paddingRight,
paddingBottom, paddingLeft
+ smPadding…, mdPadding…, lgPadding…, layoutAreaXxxxPadding…
[4. Alignment and position]
horizontalAlignment, verticalAlignment, direction, orientation, placement,
labelAlignment, top, right, bottom, left, child
[5. Main content]
text, supportingText, overline, label, supportingLabel, optional,
placeholder, number, headingLevel,
icon, startIcon, endIcon, containerColor,
quote, attribution, cite, keys,
logoTitle, logoSubtitle, logoSupportingText1, logoSupportingText2, logoHref,
websiteTitle, websiteHref, backText, backHref, dismissText
[6. Key behavior]
control, expandable
[7. A11y]
accessibleLabel
[8. Elements]
showSearchButton, hideSpinButtons, hasDragHandle, maxItems
[9. Elements content]
showText, hideText, overflowText
[10. Elements A11y]
showAccessibleLabel, hideAccessibleLabel
[11. Behavior]
modeless, movable, stickyHeader, stickyFooter, hasContent, alwaysVisible,
showLoadMore, lazyLoad, collapseAnchor, contentPriority
[12. States]
selected, checked, indeterminate, open, valid, invalid, masked, readonly, current, disabled
[13. Form]
name, value, type, min, max, step, required, total,
autocomplete, noSpellcheck, href, target, method, action, novalidate
Open punt: type staat onder Form (HTML input type, vaakste betekenis). Voor segmented-control heeft het een andere semantiek (radio/checkbox-modus); kan later via een rename naar bijv. selectionMode opgelost worden.
import { html, nothing } from 'lit';
import './{naam}.ts';
import { ICONS } from '../../content/icon/icon.ts';
export default {
title: 'Components/{Categorie}/{DisplayName}',
component: 'nldd-{naam}',
tags: ['autodocs'],
parameters: {
componentSource: {
file: 'src/components/{categorie}/{naam}/{naam}.ts',
repository: 'https://github.com/MinBZK/storybook',
},
status: { type: 'stable' },
},
args: {
size: 'md',
startIcon: '',
fullWidth: false,
disabled: false,
},
argTypes: {
size: {
control: 'select',
options: ['xs', 'sm', 'md'],
description: 'Componentmaat',
table: { defaultValue: { summary: 'md' } },
},
startIcon: {
name: 'start-icon',
control: 'select',
options: ['(geen)', ...ICONS],
mapping: { '(geen)': '' },
description: 'Icoon voor de tekst',
table: { defaultValue: { summary: '(geen)' } },
},
fullWidth: {
name: 'full-width',
control: 'boolean',
description: 'Full width',
table: { defaultValue: { summary: false } },
},
disabled: {
control: 'boolean',
description: 'Uitgeschakelde staat',
table: { defaultValue: { summary: false } },
},
},
};
const Template = ({
size,
startIcon,
fullWidth,
disabled,
}: Record<string, unknown>) => html`
<nldd-{naam}
size=${size || nothing}
start-icon=${startIcon || nothing}
?full-width=${fullWidth}
?disabled=${disabled}
>Label</nldd-{naam}>
`;
export const Default = Template.bind({});
TEST TEMPLATE
{naam}.test.ts:
import { describe, it, expect, afterEach } from 'vitest';
import { fixture, cleanup, waitForUpdate } from '../../../test-utils.ts';
import './{naam}.ts';
describe('nldd-{naam}', () => {
let el: HTMLElement;
afterEach(() => {
if (el) cleanup(el);
});
it('rendert zonder fouten', async () => {
el = await fixture('<nldd-{naam}></nldd-{naam}>');
await waitForUpdate(el);
expect(el.shadowRoot).not.toBeNull();
});
});
i18n
Zie /translation-keys skill voor alle conventies rond translation keys, types en implementatie.
SPACER COMPONENT
Gebruik <nldd-spacer> voor ruimte tussen verschillende soorten componenten die elkaar direct opvolgen:
<nldd-spacer size="32"></nldd-spacer>
<nldd-spacer sm-size="16" md-size="24" lg-size="32"></nldd-spacer>
<nldd-spacer size="flexible"></nldd-spacer>
Beschikbare sizes: 2, 4, 6, 8, 10, 12, 16, 20, 24, 28, 32, 40, 44, 48, 56, 64, 80, 96, flexible
Per-viewport overrides: sm-size (max 640px), md-size (641–1007px), lg-size (min 1008px). Niet expliciet gezet → val terug op size.
FORMATTING
Er is geen automatische formatter. Volg deze regels handmatig.
Algemeen
- Gebruik tabs voor indentatie
- Enkele aanhalingstekens voor strings
- Puntkomma's aan einde van statements
- Trailing comma's in objecten en arrays
HTML
- Gebruik HTML, niet XHTML:
> niet /> voor void elements:
<input class="checkbox__input" type="checkbox">
<input class="checkbox__input" type="checkbox" />
CSS (.styles.ts)
- Property waarden altijd op één regel, ook als ze lang zijn
- Sorteer rules per element; houd alle gedrag voor een element bij elkaar
- Property-volgorde binnen een rule: Concentric CSS (bron) — van buiten de box naar binnen. Ná de
--_* vars (die staan bovenin :host):
box-sizing, display, position, inset / top / right / bottom / left, float, clear
visibility, opacity, z-index
margin
outline, outline-offset, border, border-radius, box-shadow
background, background-*
cursor, pointer-events
width / min-width / max-width, height / min-height / max-height, overflow
padding
- layout van kinderen:
flex-*, grid-*, gap, align-*, justify-*, place-*, order, vertical-align, text-align
- inhoud:
color, font / font-*, line-height, letter-spacing, text-decoration, text-overflow, white-space, content
- gedrag/effect:
transition, transform, animation, appearance, isolation, -webkit-tap-highlight-color
Pseudo-elementen: content: '' mag bovenaan (vóór 1). Responsive breakpoint-@container/@media blijven genest, ná de properties van die rule.
- CSS nesting voor responsive breakpoint-overrides (
@container en @media met sm/md/lg) — genest in de element/:host rule. State/toegankelijkheid-@media (forced-colors, prefers-reduced-motion, hover) niet nesten — als los blok direct ná de element-rule die het wijzigt; géén aparte sectie ervoor
- Declareer alle lokale CSS variabelen (
--_*) bovenin :host, gevolgd door een lege regel die ze scheidt van de overige properties. Inclusief responsive overrides via @container nesting. Elementen gebruiken alleen var(--_foo), nooit fallbacks: niet var(--_foo, 100)
- Volgorde van de
--_* vars: in volgorde van eerste gebruik in de stylesheet (de rules staan zelf in Concentric volgorde, dus dit volgt daaruit). Niet concentric- of alfabetisch sorteren. Pas dezelfde canonieke volgorde toe in élk override-blok (:host([size=…]), :host([variant=…]), :host([expanded]…)): elk blok somt z'n subset in die volgorde op. Herordenen is risicoloos — declaratievolgorde heeft geen cascade-effect
- Gebruik nooit flex shorthand (
flex: 1), schrijf de losse properties
- Level 1 headings (
/* # Section */): 2 lege regels ervoor, 1 erna
- Level 2 headings (
/* ## Subsection */): 1 lege regel ervoor en erna
.button {
display: inline-flex;
min-height: var(--_min-height);
@container (min-width: 641px) {
padding: var(--_md-padding);
}
}
.button { display: inline-flex; }
@container (min-width: 641px) {
.button { padding: var(--_md-padding); }
}
:host {
--_min-height: var(--semantics-controls-md-min-size);
--_logo-width: var(--primitives-space-40);
@container layout-container (min-width: 641px) {
--_logo-width: var(--primitives-space-44);
}
display: inline-flex;
min-height: var(--_min-height);
}
.logo {
width: var(--_logo-width);
height: calc(var(--_logo-width) * 2);
}
:host {
display: inline-flex;
--_min-height: var(--semantics-controls-md-min-size);
min-height: var(--_min-height);
}
.logo {
--_logo-width: var(--primitives-space-40);
}
.button {
min-height: var(--_min-height, 44px);
}
Templates (.template.ts)
- Elk attribuut op een eigen regel, met twee uitzonderingen:
class staat altijd op dezelfde regel als het element
- Een element met één enkel attribuut mag op één regel
- Nooit een class op een child component — wrap het in een container die positie en size bepaalt; het child vult die container (bijv.
--_size: 100%)
- Geen lege regels in templates
- Element-content op een eigen ingesprongen regel — ook een enkele
${...}-interpolatie; de open- en sluittag staan dan op hun eigen regel. Zo blijven regels kort en tonen diffs alleen de gewijzigde inhoud, niet de hele tag-regel. Geldt voor losstaande elementen in de template-body. Een kort inline html-fragment binnen een expressie of ternary mag op één regel blijven (zie het voorbeeld hieronder); dat opsplitsen levert juist lelijke fragmenten op.
<input class="checkbox__input"
type="checkbox"
.checked=${component.checked}
?disabled=${component.disabled}
@change=${component._handleChange}
>
<div class="checkbox__box"
aria-hidden="true"
>
<slot name="header"></slot>
<span class="checkbox__icon">
<nldd-icon name="check-mark-small"></nldd-icon>
</span>
<p class="dialog__supporting-text">
${component.supportingText}
</p>
${component.hasBadge ? html`<span class="dialog__badge">${component.badge}</span>` : nothing}
<p class="dialog__supporting-text">${component.supportingText}</p>
<nldd-icon class="checkbox__icon" name="check-mark-small"></nldd-icon>
<input
class="checkbox__input"
type="checkbox"
>
Stories (.stories.ts)
- Zelfde formatting conventies als templates
BEM NAAMGEVING
Gebruik BEM (Block Element Modifier) + state classes:
.block /* Zelfstandig component */
.block__element /* Onderdeel van block */
.block--modifier /* Variant van block */
- Block: logische naam (
.pagination, .checkbox, .button)
- Element: na dubbele underscore
__ (.pagination__page-button)
- Modifier: na dubbele hyphen
-- (.button--primary)
- Geen nesting: niet
.block__element__subelement
- Modifiers zijn aanvullend, nooit vervanging van base class
Varianten vs states:
- BEM modifiers voor varianten (vast):
button--primary, button--sm
- State classes voor wisselende toestanden:
list-item.is-dragging, page-button.is-current
<button class="button button--primary">
<div class="list-item is-dragging">
<button class="pagination__page-button is-current">
SLOTTED CONTENT & HOST-CSS ISOLATIE
Slotted (light-DOM) content leeft in het document van de consument. Document-CSS (Tailwind Preflight, Bootstrap Reboot, een eigen host-reset) verslaat daardoor een component z'n ::slotted()-regels voor elke normale declaratie — ongeacht specificiteit. Zonder bescherming bloedt host-styling door en breekt de consistentie tussen overheidssites.
Getypeerde/eigen slots (specifiek element of [slot=…] waarvan de DS de styling bezit: h1, a, p, img, [slot="title"], een native <select>) → gebruik slottedReset, en bij tekst ook inheritedTextReset, uit assets/styles/slotted-reset.js. Zet de reset vooraan en je eigen declaraties erná, elk !important (anders verslaat all: revert !important je eigen waarden):
import { slottedReset, inheritedTextReset } from '../../../assets/styles/slotted-reset.js';
::slotted(:not([slot])) {
${slottedReset}
${inheritedTextReset}
color: var(--semantics-content-color) !important;
font: var(--_font) !important;
}
Andere regels die hetzelfde slotted element raken (:hover, @media, een specifiekere override) moeten dan óók !important.
Eigen shadow-tekst (tekst die het component zélf rendert: labels, waarden, ::before-content) → zet ${inheritedTextReset} op :host, als guard-blok direct ná de --_* vars en vóór de overige properties. Dat blokkeert geërfde host-typografie (letter-spacing/text-transform/text-align) die anders via body → host → :host in je shadow-tekst lekt. Géén all: revert op :host — dat zou de eigen layout van het component slopen; alleen inheritedTextReset.
:host {
--_foo: …;
${inheritedTextReset}
display: …;
}
Generieke ::slotted(*) en custom-element/icon-slots (::slotted(nldd-*), [slot="icon"]) → met rust laten. De reset zou willekeurige content (vaak een ander nldd-component met eigen :host) naar UA terugzetten; die inhoud hardent zichzelf. Leg hooguit een losse structuur-prop (flex-shrink, display) !important op waar een host functionaliteit zou breken.
Document-level componenten (*.css zoals rich-text.css via global.css) zijn een ánder geval: hun descendant-selectors (nldd-rich-text h1, specificiteit 0,0,2) verslaan Preflight's kale h1{} (0,0,1) al op specificiteit. Geen reset/!important nodig voor de Preflight-case.
text-align zit in inheritedTextReset (gelockt op start, RTL-veilig) — de host mag niet centreren of justifyen. Heeft een component alignment nodig, bied het expliciet aan: :host([align="center"]) ::slotted(…) { text-align: center !important }.
CHECKLIST
CSS:
Accessibility:
TypeScript:
Taal:
Shadow DOM:
Verificatie: