Component Architecture & Storybook Integration
apps/docs/src/content/docs/pre-planning/components Click to copy apps/docs/src/content/docs/pre-planning/components Section Owner: Senior Frontend Engineer Last Updated: 2026-02-13 This section documents the component architecture, design patterns, and implementation strategy for the WC-2026 enterprise web component library.
Table of Contents
Section titled “Table of Contents”- Lit 3.x Component Architecture
- Component Library Structure
- Storybook Integration
- TypeScript & JSDoc Strategy
- Drupal Integration Documentation Strategy
- Testing Implementation
- Theming & Design Tokens
3.1 Lit 3.x Component Architecture
Section titled “3.1 Lit 3.x Component Architecture”3.1.1 Why Lit for This Project
Section titled “3.1.1 Why Lit for This Project”Lit is the optimal choice for an enterprise healthcare content hub targeting Drupal integration for several reasons:
- Framework-agnostic: Lit components are standard Web Components. They work in Drupal TWIG templates, React portals, Angular wrappers, or plain HTML with zero framework coupling.
- Lightweight: The core library is approximately 5KB minified and compressed. For a public-facing healthcare content hub where performance directly impacts user trust and SEO, this matters.
- Google-backed with enterprise adoption: UI5 Web Components (SAP), Vaadin, and numerous enterprise design systems are built on Lit.
- Native browser APIs: No virtual DOM diffing. Lit touches only the dynamic parts of the UI during updates, yielding predictable performance.
- Shadow DOM encapsulation: Critical when injecting components into a Drupal theme where CSS conflicts are common.
3.1.2 Core Architectural Principles
Section titled “3.1.2 Core Architectural Principles”Every component in this library follows these principles:
- Single Responsibility: Each component does one thing well. A
wc-content-cardrenders a content card — it does not fetch data, manage routing, or handle global state. - Composition Over Inheritance: Use slot-based composition and reactive controllers rather than deep class hierarchies.
- Progressive Enhancement: Components must render meaningful content even before JavaScript executes (critical for healthcare SEO and accessibility).
- Explicit Contracts: Every public property, event, slot, and CSS custom property is documented in JSDoc, enforced by TypeScript, and surfaced in the Custom Elements Manifest.
3.1.3 Reactive Property Patterns
Section titled “3.1.3 Reactive Property Patterns”Lit’s reactive properties form the data-binding contract between a component and its consumers (including Drupal TWIG templates).
import { LitElement, html, css } from 'lit';import { customElement, property, state } from 'lit/decorators.js';
/** * A content card component for healthcare article previews. * * @element wc-content-card * @slot - Default slot for card body content * @slot media - Slot for hero image or video * @slot actions - Slot for CTA buttons * * @csspart card - The outer card container * @csspart header - The card header area * @csspart body - The card body area * * @cssprop [--wc-card-radius=8px] - Card border radius * @cssprop [--wc-card-padding=1.5rem] - Card internal padding * @cssprop [--wc-card-bg=var(--wc-surface)] - Card background color * @cssprop [--wc-card-shadow=0 2px 8px rgba(0,0,0,0.1)] - Card shadow * * @fires wc-card-click - Fired when the card is activated (click or Enter key) */@customElement('wc-content-card')export class WcContentCard extends LitElement { /** * The card's heading text. Required. * Maps to Drupal field: `node.title` */ @property({ type: String }) heading = '';
/** * Summary or teaser text for the card. * Maps to Drupal field: `node.field_summary.value` */ @property({ type: String }) summary = '';
/** * URL for the full article. When set, the entire card becomes a link. * Maps to Drupal path: `node.url` */ @property({ type: String }) href = '';
/** * Publication date in ISO 8601 format. * Maps to Drupal field: `node.created` */ @property({ type: String, attribute: 'publish-date' }) publishDate = '';
/** * Content category label (e.g., "Mental Health", "Patient Resources"). * Maps to Drupal field: `node.field_category.name` */ @property({ type: String }) category = '';
/** * Estimated reading time in minutes. * Maps to Drupal computed field or custom module. */ @property({ type: Number, attribute: 'read-time' }) readTime = 0;
/** * Visual variant of the card. */ @property({ type: String, reflect: true }) variant: 'default' | 'featured' | 'compact' = 'default';
/** Internal loading state -- not exposed as attribute. */ @state() private _imageLoaded = false;
// ... render method below in section 3.1.6}Key patterns demonstrated above:
- Attribute naming: Use kebab-case attributes (
publish-date,read-time) that map to camelCase properties. This is critical for Drupal TWIG templates where attributes are the primary interface. - Reflect: Use
reflect: trueonly for properties that affect CSS selectors (likevariant), not for data properties. - @state(): Internal state that should not be set externally. Never creates an HTML attribute.
- Default values: Every property has a sensible default so the component never renders in an undefined state.
3.1.4 Reactive Controllers for Shared Behavior
Section titled “3.1.4 Reactive Controllers for Shared Behavior”Reactive controllers are the primary mechanism for code reuse across components. They are preferred over mixins because they maintain their own identity, support multiple instances per component, and avoid prototype chain pollution.
import { ReactiveController, ReactiveControllerHost } from 'lit';
/** * Controller that detects reduced-motion preference and exposes it to the host. * Critical for healthcare accessibility -- users with vestibular disorders * must not be subjected to unexpected motion. */export class ReducedMotionController implements ReactiveController { host: ReactiveControllerHost;
/** Whether the user prefers reduced motion. */ prefersReducedMotion = false;
private _mediaQuery: MediaQueryList | null = null;
constructor(host: ReactiveControllerHost) { this.host = host; host.addController(this); }
hostConnected(): void { this._mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); this.prefersReducedMotion = this._mediaQuery.matches; this._mediaQuery.addEventListener('change', this._onChange); }
hostDisconnected(): void { this._mediaQuery?.removeEventListener('change', this._onChange); }
private _onChange = (e: MediaQueryListEvent): void => { this.prefersReducedMotion = e.matches; this.host.requestUpdate(); };}/** * Controller that manages intersection observer for lazy loading. * Reused across content cards, media components, and image galleries. */export class IntersectionController implements ReactiveController { host: ReactiveControllerHost & HTMLElement; isVisible = false;
private _observer: IntersectionObserver | null = null; private _options: IntersectionObserverInit;
constructor( host: ReactiveControllerHost & HTMLElement, options: IntersectionObserverInit = { threshold: 0.1 } ) { this.host = host; this._options = options; host.addController(this); }
hostConnected(): void { this._observer = new IntersectionObserver(([entry]) => { this.isVisible = entry.isIntersecting; this.host.requestUpdate(); }, this._options); this._observer.observe(this.host); }
hostDisconnected(): void { this._observer?.disconnect(); }}Controllers we will build for this project:
| Controller | Purpose | Used By |
|---|---|---|
IntersectionController | Lazy loading, scroll-triggered animations | Cards, media, images |
ReducedMotionController | Accessibility: respect prefers-reduced-motion | All animated components |
FormValidationController | Shared validation logic with ElementInternals | All form components |
MediaQueryController | Responsive behavior without CSS-only solutions | Navigation, layouts |
FocusTrapController | Modal/dialog focus management | Modals, menus, drawers |
AnnounceController | Screen reader live region announcements | Forms, notifications |
3.1.5 Context Protocol for Shared State
Section titled “3.1.5 Context Protocol for Shared State”The @lit/context package implements the W3C Community Context Protocol, enabling data sharing across component subtrees without manual property threading. This is particularly valuable in a Drupal context where component nesting is determined by TWIG templates, not JavaScript.
import { createContext } from '@lit/context';
/** Theme context shared across the entire component tree. */export interface WcTheme { mode: 'light' | 'dark' | 'high-contrast'; scale: 'default' | 'large'; locale: string;}
export const themeContext = createContext<WcTheme>(Symbol('wc-theme'));import { LitElement, html } from 'lit';import { customElement, property } from 'lit/decorators.js';import { provide } from '@lit/context';import { themeContext, type WcTheme } from './contexts/theme-context.js';
/** * Theme provider that wraps the component tree. * Placed once in the Drupal theme's page template. * * @element wc-theme-provider * @slot - All themed content */@customElement('wc-theme-provider')export class WcThemeProvider extends LitElement { @provide({ context: themeContext }) @property({ attribute: false }) theme: WcTheme = { mode: 'light', scale: 'default', locale: 'en-US', };
render() { return html`<slot></slot>`; }}import { consume } from '@lit/context';import { themeContext, type WcTheme } from './contexts/theme-context.js';
// Inside any descendant component:@consume({ context: themeContext, subscribe: true })@property({ attribute: false })theme?: WcTheme;When to use Context vs. Properties vs. Events:
| Mechanism | Use When | Healthcare Example |
|---|---|---|
| Properties | Direct parent-child data binding | Card receiving heading from TWIG |
| Context | Data needed by many descendants, set once at top | Theme mode, locale, analytics config |
| Events | Child communicates upward to unknown ancestors | Card click, form submission, navigation |
3.1.6 Event System Architecture
Section titled “3.1.6 Event System Architecture”Custom events follow a strict contract. Every event type is defined in TypeScript, documented in JSDoc, and surfaced in the Custom Elements Manifest.
/** * Event detail for card interaction events. */export interface CardClickDetail { /** The href of the clicked card */ href: string; /** The card heading text */ heading: string; /** Whether the user used keyboard activation */ keyboard: boolean;}
/** * Custom event map for wc-content-card. * Enables type-safe addEventListener in TypeScript consumers. */export interface WcContentCardEventMap { 'wc-card-click': CustomEvent<CardClickDetail>;}// Inside the component's render method:
render() { const tag = this.href ? 'a' : 'div';
return html` <${tag} part="card" class="card card--${this.variant}" href=${this.href || nothing} role=${this.href ? nothing : 'button'} tabindex=${this.href ? nothing : '0'} @click=${this._handleActivate} @keydown=${this._handleKeydown} > <div part="header" class="card__header"> <slot name="media"></slot> ${this.category ? html` <span class="card__category">${this.category}</span> ` : nothing} </div> <div part="body" class="card__body"> <h3 class="card__heading">${this.heading}</h3> ${this.summary ? html` <p class="card__summary">${this.summary}</p> ` : nothing} <slot></slot> </div> <div class="card__footer"> ${this.readTime > 0 ? html` <span class="card__read-time">${this.readTime} min read</span> ` : nothing} ${this.publishDate ? html` <time class="card__date" datetime=${this.publishDate}> ${this._formatDate(this.publishDate)} </time> ` : nothing} <slot name="actions"></slot> </div> </${tag}> `;}
private _handleActivate(e: Event): void { this.dispatchEvent(new CustomEvent<CardClickDetail>('wc-card-click', { bubbles: true, composed: true, // Crosses shadow DOM boundaries detail: { href: this.href, heading: this.heading, keyboard: false, }, }));}
private _handleKeydown(e: KeyboardEvent): void { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.dispatchEvent(new CustomEvent<CardClickDetail>('wc-card-click', { bubbles: true, composed: true, detail: { href: this.href, heading: this.heading, keyboard: true, }, })); }}Event naming conventions:
- Prefix all events with
wc-to prevent collision with native events and other libraries - Use kebab-case:
wc-card-click,wc-form-submit,wc-nav-toggle - Always set
composed: trueso events cross shadow DOM boundaries (required for Drupal event listeners) - Always set
bubbles: trueso events can be caught at any ancestor level
3.1.7 Slot-Based Composition Patterns
Section titled “3.1.7 Slot-Based Composition Patterns”Slots are the primary composition mechanism. They allow Drupal TWIG templates to inject arbitrary content into predefined areas of a component.
Named Slot Strategy:
/** * Article layout component that structures long-form healthcare content. * * @element wc-article-layout * @slot hero - Full-width hero image or video area * @slot breadcrumb - Breadcrumb navigation * @slot sidebar - Sidebar content (table of contents, related articles) * @slot author - Author bio section * @slot - Default slot for article body content * @slot footer - Article footer (tags, share buttons, related content) */@customElement('wc-article-layout')export class WcArticleLayout extends LitElement { static styles = css` :host { display: block; max-width: var(--wc-article-max-width, 1200px); margin: 0 auto; }
.layout { display: grid; grid-template-columns: 1fr; gap: var(--wc-spacing-lg, 2rem); }
@media (min-width: 768px) { .layout--with-sidebar { grid-template-columns: 1fr var(--wc-sidebar-width, 300px); } } `;
@property({ type: Boolean, attribute: 'has-sidebar', reflect: true }) hasSidebar = false;
render() { return html` <slot name="breadcrumb"></slot> <slot name="hero"></slot> <div class="layout ${this.hasSidebar ? 'layout--with-sidebar' : ''}"> <article class="layout__main"> <slot name="author"></slot> <div class="layout__content"> <slot></slot> </div> </article> ${this.hasSidebar ? html` <aside class="layout__sidebar"> <slot name="sidebar"></slot> </aside> ` : nothing} </div> <slot name="footer"></slot> `; }}Slotchange Detection for Progressive Enhancement:
connectedCallback(): void { super.connectedCallback(); this.shadowRoot?.addEventListener('slotchange', this._onSlotChange);}
private _onSlotChange = (e: Event): void => { const slot = e.target as HTMLSlot; const assignedNodes = slot.assignedNodes({ flatten: true }); // Adjust layout based on which slots have content if (slot.name === 'sidebar') { this.hasSidebar = assignedNodes.length > 0; }};3.1.8 Shadow DOM Strategy
Section titled “3.1.8 Shadow DOM Strategy”Not every component needs Shadow DOM. The decision matrix:
| Use Shadow DOM | Use Light DOM | Rationale |
|---|---|---|
| Design system primitives (buttons, cards, inputs) | Layout wrappers | Encapsulation protects internal styles |
| Components with complex internal structure | Simple text formatting components | Prevents Drupal theme CSS bleed |
| Components with internal state/logic | Components that primarily pass through content | Shadow DOM provides clean internal API |
Light DOM opt-in (when needed):
@customElement('wc-prose')export class WcProse extends LitElement { /** Render to light DOM so Drupal CKEditor content styles apply. */ protected createRenderRoot(): HTMLElement { return this; }
render() { return html`<slot></slot>`; }}3.1.9 Form-Associated Custom Elements
Section titled “3.1.9 Form-Associated Custom Elements”Healthcare forms (patient intake, appointment requests, feedback) require full form participation. Lit components achieve this through the ElementInternals API.
import { LitElement, html, css } from 'lit';import { customElement, property, state, query } from 'lit/decorators.js';
/** * Accessible text input for healthcare forms. * Fully participates in native <form> elements and FormData. * * @element wc-text-input * @fires wc-input - Fired on each input event with current value * @fires wc-change - Fired on blur when value has changed */@customElement('wc-text-input')export class WcTextInput extends LitElement { /** Enable form association. */ static formAssociated = true;
private _internals: ElementInternals;
@property({ type: String }) label = ''; @property({ type: String }) name = ''; @property({ type: String }) value = ''; @property({ type: String }) type: 'text' | 'email' | 'tel' | 'url' = 'text'; @property({ type: Boolean }) required = false; @property({ type: String, attribute: 'error-message' }) errorMessage = ''; @property({ type: String, attribute: 'help-text' }) helpText = '';
@state() private _touched = false; @state() private _invalid = false;
@query('input') private _input!: HTMLInputElement;
constructor() { super(); this._internals = this.attachInternals(); }
/** Allow the form to read this component's value. */ get form(): HTMLFormElement | null { return this._internals.form; } get validity(): ValidityState { return this._internals.validity; } get validationMessage(): string { return this._internals.validationMessage; }
private _updateFormValue(): void { this._internals.setFormValue(this.value); }
private _validate(): void { if (this.required && !this.value) { this._internals.setValidity( { valueMissing: true }, this.errorMessage || `${this.label} is required`, this._input ); this._invalid = true; } else { this._internals.setValidity({}); this._invalid = false; } }
private _onInput(e: Event): void { const target = e.target as HTMLInputElement; this.value = target.value; this._updateFormValue(); this._validate(); this.dispatchEvent(new CustomEvent('wc-input', { bubbles: true, composed: true, detail: { value: this.value, name: this.name }, })); }
private _onBlur(): void { this._touched = true; this._validate(); this.dispatchEvent(new CustomEvent('wc-change', { bubbles: true, composed: true, detail: { value: this.value, name: this.name }, })); }
updated(changedProperties: Map<string, unknown>): void { if (changedProperties.has('value')) { this._updateFormValue(); } }
render() { const inputId = `input-${this.name}`; const helpId = `help-${this.name}`; const errorId = `error-${this.name}`; const showError = this._touched && this._invalid;
return html` <div class="field ${showError ? 'field--error' : ''}"> <label for=${inputId} class="field__label"> ${this.label} ${this.required ? html`<span class="field__required" aria-hidden="true">*</span>` : nothing} </label> ${this.helpText ? html` <div id=${helpId} class="field__help">${this.helpText}</div> ` : nothing} <input id=${inputId} type=${this.type} name=${this.name} .value=${this.value} ?required=${this.required} aria-invalid=${showError ? 'true' : 'false'} aria-describedby=${[ this.helpText ? helpId : '', showError ? errorId : '', ].filter(Boolean).join(' ') || nothing} @input=${this._onInput} @blur=${this._onBlur} /> ${showError ? html` <div id=${errorId} class="field__error" role="alert"> ${this._internals.validationMessage} </div> ` : nothing} </div> `; }}Key accessibility requirements for healthcare forms (WCAG 2.1 AA minimum):
- Every input must have a visible, programmatically-associated
<label>(WCAG 1.3.1) - Error messages must be announced via
role="alert"oraria-live(WCAG 3.3.1) - Error messages must describe what went wrong, not just flag the error (WCAG 3.3.3)
- Required fields must be indicated both visually and programmatically (WCAG 3.3.2)
- Focus must move to the first error field on form submission (WCAG 3.3.1)
- Color alone must not indicate state — errors need icons and text, not just red borders (WCAG 1.4.1)
3.2 Component Library Structure
Section titled “3.2 Component Library Structure”3.2.1 Atomic Design Hierarchy
Section titled “3.2.1 Atomic Design Hierarchy”The library follows an adapted Atomic Design methodology. The hierarchy maps directly to Drupal’s content architecture.
src/ components/ atoms/ # Smallest indivisible UI elements wc-button/ wc-button.ts # Component class wc-button.styles.ts # Scoped styles wc-button.test.ts # Unit tests wc-button.stories.ts # Storybook stories index.ts # Public exports wc-icon/ wc-badge/ wc-text-input/ wc-textarea/ wc-select/ wc-checkbox/ wc-radio/ wc-toggle/ wc-avatar/ wc-spinner/ wc-tag/ wc-tooltip/ wc-sr-only/ # Screen-reader-only text
molecules/ # Combinations of atoms wc-search-bar/ # Input + button + icon wc-form-field/ # Label + input + help + error wc-breadcrumb/ wc-pagination/ wc-media-object/ # Image + text layout wc-alert/ # Icon + message + dismiss wc-accordion-item/ wc-tab-item/ wc-dropdown-menu/
organisms/ # Complex, self-contained sections wc-content-card/ # Hero slot + heading + summary + meta wc-article-layout/ # Full article page structure wc-header/ # Site header with nav wc-footer/ # Site footer wc-nav-primary/ # Primary navigation with mega-menu wc-nav-mobile/ # Mobile navigation drawer wc-hero-banner/ # Full-width hero with CTA wc-card-grid/ # Responsive grid of content cards wc-form/ # Form wrapper with validation wc-accordion/ # Accordion group wc-tabs/ # Tab group wc-modal/ # Modal dialog wc-media-gallery/ # Image/video gallery wc-table/ # Accessible data table wc-sidebar/ # Sidebar widget area
templates/ # Page-level layout components wc-page-layout/ # Base page grid (header/main/footer) wc-article-page/ # Article content page layout wc-landing-page/ # Landing page layout wc-search-results-page/ # Search results layout
controllers/ # Reactive controllers intersection.controller.ts reduced-motion.controller.ts form-validation.controller.ts media-query.controller.ts focus-trap.controller.ts announce.controller.ts
contexts/ # Lit context definitions theme-context.ts locale-context.ts
directives/ # Custom Lit directives safe-html.directive.ts # Sanitized HTML rendering external-link.directive.ts # Auto-add external link indicators
styles/ # Shared styles and design tokens tokens/ colors.ts spacing.ts typography.ts elevation.ts breakpoints.ts shared/ reset.styles.ts # Minimal reset for shadow DOM typography.styles.ts # Shared type styles focus.styles.ts # Focus ring styles (critical for a11y) themes/ light.css # Light mode token values dark.css # Dark mode token values high-contrast.css # High contrast mode (WCAG AAA)
types/ # Shared TypeScript types events.ts # All custom event detail interfaces props.ts # Shared property type unions a11y.ts # ARIA attribute type helpers
utils/ # Pure utility functions date-format.ts string-sanitize.ts id-generator.ts
index.ts # Main entry -- exports all components3.2.2 Naming Conventions
Section titled “3.2.2 Naming Conventions”| Entity | Convention | Example |
|---|---|---|
| Custom element tag | wc-[name] (kebab-case, wc prefix) | wc-content-card |
| TypeScript class | Wc[Name] (PascalCase with Wc prefix) | WcContentCard |
| File names | wc-[name].ts (match tag name) | wc-content-card.ts |
| CSS custom properties | --wc-[component]-[property] | --wc-card-radius |
| CSS parts | Descriptive, no prefix needed | card, header, body |
| Events | wc-[component]-[action] | wc-card-click |
| Slots | Semantic name or empty for default | media, actions, (default) |
| Controllers | [Name]Controller | IntersectionController |
| Contexts | [name]Context | themeContext |
3.2.3 Healthcare Content Hub Component Inventory
Section titled “3.2.3 Healthcare Content Hub Component Inventory”These components are designed specifically for the healthcare content hub use case:
Content Discovery Components:
| Component | Drupal Mapping | Purpose |
|---|---|---|
wc-content-card | Node teaser view mode | Blog/article preview card |
wc-card-grid | Views block output | Responsive grid of content cards |
wc-hero-banner | Paragraph: Hero | Full-width hero with CTA |
wc-search-bar | Search API form | Site search with autocomplete |
wc-breadcrumb | System breadcrumb block | Navigation breadcrumbs |
wc-pagination | Views pager | Page navigation for lists |
Content Consumption Components:
| Component | Drupal Mapping | Purpose |
|---|---|---|
wc-article-layout | Node full view mode | Long-form article structure |
wc-accordion | Paragraph: FAQ | Expandable FAQ sections |
wc-tabs | Paragraph: Tabbed Content | Tabbed information panels |
wc-table | Field: Table | Accessible data tables |
wc-media-gallery | Paragraph: Gallery | Image/video galleries |
Navigation Components:
| Component | Drupal Mapping | Purpose |
|---|---|---|
wc-header | Header block region | Site header |
wc-nav-primary | Main menu block | Desktop navigation |
wc-nav-mobile | Main menu block (mobile) | Mobile navigation drawer |
wc-footer | Footer block region | Site footer |
Form Components (Accessibility-Critical):
| Component | Drupal Mapping | Purpose |
|---|---|---|
wc-text-input | Form API: textfield | Text input with validation |
wc-textarea | Form API: textarea | Multi-line text input |
wc-select | Form API: select | Dropdown selection |
wc-checkbox | Form API: checkbox | Checkbox with label |
wc-radio | Form API: radios | Radio button group |
wc-form | Webform / Form API | Form wrapper with submit handling |
3.3 Storybook Integration
Section titled “3.3 Storybook Integration”3.3.1 Storybook Configuration
Section titled “3.3.1 Storybook Configuration”The project now uses Storybook 10.x (10.2.8) with CSF Factories support. The upgrade from 8.x brings improved performance, better Web Components integration, and native support for modern story formats.
.storybook/main.ts:
import type { StorybookConfig } from '@storybook/web-components-vite';
const config: StorybookConfig = { stories: ['../src/**/*.stories.ts'], addons: [ '@storybook/addon-essentials', // Controls, actions, viewport, backgrounds '@storybook/addon-a11y', // axe-core accessibility audit per story '@storybook/addon-links', // Cross-story navigation '@storybook/addon-designs', // Figma embed integration '@storybook/addon-interactions', // Play function testing ], framework: { name: '@storybook/web-components-vite', options: {}, }, docs: { autodocs: true, // Auto-generate docs from CEM },};
export default config;.storybook/preview.ts:
import type { Preview } from '@storybook/web-components';import { setCustomElementsManifest } from '@storybook/web-components';import customElements from '../custom-elements.json';
// Register the Custom Elements Manifest so Storybook can// auto-generate controls, docs tables, and source snippets.setCustomElementsManifest(customElements);
// Import global design tokensimport '../src/styles/themes/light.css';import '../src/styles/themes/dark.css';
const preview: Preview = { tags: ['autodocs'], parameters: { // Healthcare-specific viewport presets viewport: { viewports: { mobile: { name: 'Mobile', styles: { width: '375px', height: '812px' } }, tablet: { name: 'Tablet', styles: { width: '768px', height: '1024px' } }, desktop: { name: 'Desktop', styles: { width: '1280px', height: '900px' } }, largeDesktop: { name: 'Large Desktop', styles: { width: '1920px', height: '1080px' } }, }, }, backgrounds: { values: [ { name: 'Light', value: '#ffffff' }, { name: 'Dark', value: '#1a1a2e' }, { name: 'High Contrast', value: '#000000' }, ], }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/i, }, }, }, // Global decorators for theme wrapping decorators: [ (story, context) => { const theme = context.globals.theme ?? 'light'; return ` <wc-theme-provider> <div data-theme="${theme}" style="padding: 1rem;"> ${story()} </div> </wc-theme-provider> `; }, ], globalTypes: { theme: { description: 'Global theme for components', toolbar: { title: 'Theme', icon: 'paintbrush', items: [ { value: 'light', title: 'Light' }, { value: 'dark', title: 'Dark' }, { value: 'high-contrast', title: 'High Contrast' }, ], dynamicTitle: true, }, }, },};
export default preview;3.3.2 Custom Elements Manifest Generation
Section titled “3.3.2 Custom Elements Manifest Generation”The Custom Elements Manifest (CEM) is the bridge between component source code and Storybook’s auto-documentation. It is generated from JSDoc annotations and TypeScript types — this is why 100% JSDoc coverage is non-negotiable.
custom-elements-manifest.config.mjs:
import { litPlugin } from '@custom-elements-manifest/analyzer/src/features/analyse-phase/creators/lit.js';
export default { globs: ['src/components/**/*.ts'], exclude: ['**/*.test.ts', '**/*.stories.ts', '**/*.styles.ts'], outdir: '.', litelement: true, plugins: [ litPlugin(), // Plugin to extract @fires, @slot, @csspart, @cssprop JSDoc tags ],};Build integration:
{ "scripts": { "cem": "cem analyze", "cem:watch": "cem analyze --watch", "storybook": "npm run cem && storybook dev -p 6006", "build-storybook": "npm run cem && storybook build" }}The CEM is regenerated before every Storybook session, ensuring documentation always reflects the current source code.
3.3.3 Story Structure with wc-storybook-helpers
Section titled “3.3.3 Story Structure with wc-storybook-helpers”The wc-storybook-helpers package by Burton Smith auto-generates Storybook controls from the Custom Elements Manifest, eliminating manual argTypes maintenance.
import type { Meta, StoryObj } from '@storybook/web-components';import { getWcStorybookHelpers } from 'wc-storybook-helpers';import './wc-content-card.js';
const { events, args, argTypes, template } = getWcStorybookHelpers('wc-content-card');
const meta: Meta = { title: 'Organisms/Content Card', component: 'wc-content-card', args: { ...args, heading: 'Understanding Anxiety: A Patient Guide', summary: 'Learn about the symptoms, causes, and evidence-based treatments for anxiety disorders.', category: 'Mental Health', href: '/articles/understanding-anxiety', publishDate: '2026-02-10', readTime: 8, variant: 'default', }, argTypes, parameters: { actions: { handles: events }, docs: { description: { component: `A content card for healthcare article previews. Used in content listing pages,search results, and related content sections.
**Drupal Integration**: Maps to the \`node--article--teaser\` view mode.See the [Drupal Integration](#drupal-integration) tab for TWIG examples. `, }, }, }, render: (renderArgs) => template(renderArgs),};
export default meta;type Story = StoryObj;
/** * Default card with all required props. * This is the most common usage in article listing pages. */export const Default: Story = {};
/** * Featured variant with larger visual treatment. * Used for hero articles and editorial picks. */export const Featured: Story = { args: { variant: 'featured', heading: 'New Telehealth Services Now Available', summary: 'We are expanding our virtual care options to serve you better, including mental health counseling and specialist consultations.', category: 'Announcements', },};
/** * Compact variant for sidebar and related content areas. */export const Compact: Story = { args: { variant: 'compact', heading: '5 Tips for Better Sleep', category: 'Wellness', readTime: 3, },};
/** * Card with media slot populated (hero image). */export const WithImage: Story = { render: (renderArgs) => ` <wc-content-card heading="${renderArgs.heading}" summary="${renderArgs.summary}" category="${renderArgs.category}" href="${renderArgs.href}" publish-date="${renderArgs.publishDate}" read-time="${renderArgs.readTime}" > <img slot="media" src="https://placehold.co/600x300/e2e8f0/64748b?text=Health+Article" alt="" loading="lazy" /> </wc-content-card> `,};
/** * Card without optional props, demonstrating graceful degradation. * Components must remain visually coherent with minimal data. */export const MinimalData: Story = { args: { heading: 'Article Title Only', summary: '', category: '', href: '', publishDate: '', readTime: 0, },};
/** * Accessibility: Focus state demonstration. * Interactive test showing keyboard navigation behavior. */export const FocusState: Story = { play: async ({ canvasElement }) => { const card = canvasElement.querySelector('wc-content-card'); card?.focus(); },};
/** * Grid of cards demonstrating responsive layout. */export const CardGrid: Story = { render: () => ` <wc-card-grid columns="3"> <wc-content-card heading="Understanding Anxiety" summary="Learn about symptoms, causes, and treatments." category="Mental Health" read-time="8" ></wc-content-card> <wc-content-card heading="Heart Health Basics" summary="Preventive care for cardiovascular wellness." category="Cardiology" read-time="5" ></wc-content-card> <wc-content-card heading="Nutrition After 50" summary="Dietary guidelines for healthy aging." category="Nutrition" read-time="6" ></wc-content-card> </wc-card-grid> `,};3.3.4 Documentation Strategy in Storybook
Section titled “3.3.4 Documentation Strategy in Storybook”Each component has multiple documentation surfaces:
Auto-generated (from CEM + JSDoc):
- Props/attributes table with types, defaults, and descriptions
- Events table
- Slots table
- CSS custom properties table
- CSS parts table
- Source code snippets
Hand-written (in stories file or MDX):
- Usage guidelines and when to use vs. not use
- Drupal integration examples (TWIG templates)
- Accessibility notes specific to the component
- Design rationale and UX guidelines
MDX Documentation Page Example:
{/* src/components/organisms/wc-content-card/wc-content-card.docs.mdx */}
import { Meta, Canvas, Story, Controls, Source } from '@storybook/blocks';import * as CardStories from './wc-content-card.stories';
<Meta of={CardStories} />
# Content Card
The content card is the primary content discovery element in the healthcarecontent hub. It renders article previews in listing pages, search results,and sidebar widgets.
## Usage Guidelines
### When to Use- Article listing pages (blog index, category pages)- Search result items- Related content sections in article sidebars- Editorial pick sections on landing pages
### When NOT to Use- Navigational links (use `wc-nav-*` components)- Alert/notification content (use `wc-alert`)- Promotional banners (use `wc-hero-banner`)
## Interactive Demo
<Canvas of={CardStories.Default} /><Controls of={CardStories.Default} />
## Variants
<Canvas of={CardStories.Featured} /><Canvas of={CardStories.Compact} />
## Drupal Integration
In your Drupal theme, override the node teaser template:
<Source language="twig" code={`{# templates/node--article--teaser.html.twig #}<wc-content-card heading="{{ label[0]['#title'] ?? node.label }}" summary="{{ content.field_summary|render|striptags|trim }}" category="{{ node.field_category.entity.label }}" href="{{ url }}" publish-date="{{ node.createdtime|date('c') }}" read-time="{{ content.field_read_time|render|striptags|trim }}" variant="{{ is_promoted ? 'featured' : 'default' }}"> {% if content.field_media|render|trim is not empty %} <div slot="media"> {{ content.field_media }} </div> {% endif %}
{% if content.field_tags|render|trim is not empty %} <div slot="actions"> {{ content.field_tags }} </div> {% endif %}</wc-content-card>`} />
## Accessibility Checklist
| Requirement | Implementation | WCAG ||---|---|---|| Card heading is semantic | Uses `<h3>` inside shadow DOM | 1.3.1 || Interactive card is keyboard accessible | `tabindex="0"`, Enter/Space activation | 2.1.1 || Focus indicator visible | Custom focus ring via `:focus-visible` | 2.4.7 || Color is not sole indicator | Category uses text + optional color | 1.4.1 || Link purpose clear from context | Heading text describes destination | 2.4.4 || Images have appropriate alt text | Consumer responsibility via slot | 1.1.1 |3.3.5 Storybook Add-ons for Healthcare Context
Section titled “3.3.5 Storybook Add-ons for Healthcare Context”| Add-on | Purpose | Healthcare Relevance |
|---|---|---|
@storybook/addon-a11y | axe-core accessibility audit | WCAG 2.1 AA compliance is mandatory for healthcare |
@storybook/addon-viewport | Responsive testing | Patients access on mobile, tablet, desktop |
@storybook/addon-interactions | Play function test execution | Verify keyboard navigation, form flows |
@storybook/addon-designs | Figma frame embedding | Design review in context |
@storybook/addon-links | Cross-story navigation | Link related components (card -> card grid) |
3.4 TypeScript & JSDoc Strategy
Section titled “3.4 TypeScript & JSDoc Strategy”3.4.1 TypeScript Configuration
Section titled “3.4.1 TypeScript Configuration”{ "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", "lib": ["ES2022", "DOM", "DOM.Iterable"],
// Strict mode -- every flag enabled, no exceptions "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "exactOptionalPropertyTypes": true,
// Lit decorator support "experimentalDecorators": true, "useDefineForClassFields": false,
// Output "declaration": true, "declarationMap": true, "sourceMap": true, "outDir": "./dist", "rootDir": "./src",
// No implicit any -- the entire library is fully typed "noImplicitAny": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true,
// Plugin for template type checking "plugins": [ { "name": "ts-lit-plugin", "strict": true, "rules": { "no-unknown-tag-name": "error", "no-missing-import": "error", "no-unknown-attribute": "error", "no-unknown-property": "error", "no-unknown-event": "error", "no-unknown-slot": "error", "no-invalid-css": "warning" } } ] }, "include": ["src/**/*.ts"], "exclude": ["src/**/*.test.ts", "src/**/*.stories.ts"]}Key decisions:
experimentalDecorators: truewithuseDefineForClassFields: false: Required for Lit’s decorator syntax. Standard TC39 decorators with theaccessorkeyword are supported in Lit but produce less optimal compiler output as of 2026. We will migrate to standard decorators when the output matches.ts-lit-plugin: Provides template-level type checking insidehtmltagged template literals. It validates tag names, attributes, properties, events, and slot names against the Custom Elements Manifest. This catches errors at author time rather than runtime.- Full strict mode: No exceptions. Healthcare software demands the highest confidence in type safety.
3.4.2 JSDoc Coverage Requirements
Section titled “3.4.2 JSDoc Coverage Requirements”Every public API surface must have JSDoc documentation. The CEM analyzer extracts this documentation to generate Storybook controls, IDE autocomplete, and API docs.
Required JSDoc tags by entity:
| Entity | Required Tags | Example |
|---|---|---|
| Component class | @element, @slot, @csspart, @cssprop, @fires | See section 3.1.3 |
| Public property | Description, Drupal field mapping | /** The card heading. Maps to node.title */ |
| Public method | @param, @returns | Standard JSDoc |
| Custom event | @fires on class, interface for detail | See section 3.1.6 |
| Controller | Class description, public property/method docs | See section 3.1.4 |
| Type/Interface | Description of each property | Standard JSDoc |
Enforcement:
{ "scripts": { "lint:jsdoc": "eslint --rule '{\"jsdoc/require-jsdoc\": \"error\", \"jsdoc/require-description\": \"error\", \"jsdoc/require-param\": \"error\", \"jsdoc/require-returns\": \"error\"}' src/" }}3.4.3 Type Definitions for Consumers
Section titled “3.4.3 Type Definitions for Consumers”The library ships TypeScript declarations so that TypeScript-based consumers get full type safety.
/** * Union of all custom event names emitted by WC components. * Useful for creating type-safe event listeners. */export type WcEventName = | 'wc-card-click' | 'wc-form-submit' | 'wc-nav-toggle' | 'wc-modal-open' | 'wc-modal-close' | 'wc-input' | 'wc-change' | 'wc-search-submit' | 'wc-accordion-toggle' | 'wc-tab-change';
/** * Maps event names to their detail types. * Enables type-safe event handling: * * @example * ```typescript * element.addEventListener('wc-card-click', (e: WcEvent<'wc-card-click'>) => { * console.log(e.detail.href); // fully typed * }); * ``` */export interface WcEventDetailMap { 'wc-card-click': CardClickDetail; 'wc-form-submit': FormSubmitDetail; 'wc-nav-toggle': NavToggleDetail; 'wc-modal-open': ModalEventDetail; 'wc-modal-close': ModalEventDetail; 'wc-input': InputChangeDetail; 'wc-change': InputChangeDetail; 'wc-search-submit': SearchSubmitDetail; 'wc-accordion-toggle': AccordionToggleDetail; 'wc-tab-change': TabChangeDetail;}
/** * Type-safe custom event helper. */export type WcEvent<T extends WcEventName> = CustomEvent<WcEventDetailMap[T]>;Global type augmentation for HTML element references:
declare global { interface HTMLElementTagNameMap { 'wc-content-card': import('../components/organisms/wc-content-card/wc-content-card.js').WcContentCard; 'wc-button': import('../components/atoms/wc-button/wc-button.js').WcButton; 'wc-text-input': import('../components/atoms/wc-text-input/wc-text-input.js').WcTextInput; // ... all components }}This enables document.querySelector('wc-content-card') to return the correct type in consuming applications.
3.5 Drupal Integration Documentation Strategy
Section titled “3.5 Drupal Integration Documentation Strategy”3.5.1 Where Documentation Lives
Section titled “3.5.1 Where Documentation Lives”Drupal integration documentation exists in three places, each serving a different audience:
| Location | Audience | Content |
|---|---|---|
| Storybook MDX pages | Frontend developers, Drupal themers | TWIG template examples, prop mapping tables |
drupal/ directory in repo | Drupal site builders | Reference TWIG templates, library definitions, README |
| Custom Elements Manifest | IDEs, tooling, automated docs | Machine-readable API surface |
3.5.2 Prop Mapping Strategy (Web Component Attributes <-> Drupal Field Data)
Section titled “3.5.2 Prop Mapping Strategy (Web Component Attributes <-> Drupal Field Data)”Every component includes a Drupal Integration section in its Storybook documentation with a prop mapping table:
## Drupal Prop Mapping
| WC Attribute | Type | Drupal Source | TWIG Expression ||-----------------|--------|----------------------------------------|----------------------------------------------------|| `heading` | String | Node title | `{{ label[0]['#title'] \| default(node.label) }}` || `summary` | String | field_summary (Plain text) | `{{ content.field_summary\|render\|striptags\|trim }}` || `category` | String | field_category (Term reference) | `{{ node.field_category.entity.label }}` || `href` | String | Node canonical URL | `{{ url }}` || `publish-date` | String | Node created date (ISO 8601) | `{{ node.createdtime\|date('c') }}` || `read-time` | Number | field_read_time (Integer) | `{{ content.field_read_time\|render\|striptags }}` || `variant` | String | Derived from view mode or is_promoted | `{{ is_promoted ? 'featured' : 'default' }}` |3.5.3 Reference TWIG Templates
Section titled “3.5.3 Reference TWIG Templates”The repository includes a drupal/ directory with reference TWIG templates that Drupal themers can copy and adapt.
drupal/ README.md # Setup instructions wc-components.libraries.yml # Drupal library definition templates/ node--article--teaser.html.twig # Content card integration node--article--full.html.twig # Article layout integration block--system-main-menu.html.twig # Navigation integration views-view-unformatted.html.twig # Card grid integration page.html.twig # Page layout integration modules/ wc_components/ # Optional Drupal module wc_components.info.yml wc_components.libraries.yml wc_components.moduleExample: Content Card in Node Teaser Template:
{# drupal/templates/node--article--teaser.html.twig #}
{# Content Card Integration ======================== This template maps Drupal's article node fields to the wc-content-card web component attributes.
Required fields: - title (core) - field_summary (Plain text, required) - field_category (Term reference, required)
Optional fields: - field_media (Media reference) - field_read_time (Integer) - field_tags (Term reference, multi-value)
Component docs: [Storybook URL]/organisms-content-card--docs#}
{%- set card_heading = label[0]['#title'] | default(node.label) -%}{%- set card_summary = content.field_summary|render|striptags|trim -%}{%- set card_category = node.field_category.entity.label -%}{%- set card_href = url -%}{%- set card_date = node.createdtime|date('c') -%}{%- set card_read_time = content.field_read_time|render|striptags|trim -%}{%- set card_variant = is_promoted ? 'featured' : 'default' -%}
<wc-content-card heading="{{ card_heading }}" summary="{{ card_summary }}" category="{{ card_category }}" href="{{ card_href }}" publish-date="{{ card_date }}" read-time="{{ card_read_time }}" variant="{{ card_variant }}" {{ attributes }}> {# Media slot: hero image/video #} {% if content.field_media|render|trim is not empty %} <div slot="media"> {{ content.field_media }} </div> {% endif %}
{# Actions slot: tags, share button, etc. #} {% if content.field_tags|render|trim is not empty %} <div slot="actions"> {{ content.field_tags }} </div> {% endif %}</wc-content-card>3.5.4 Drupal Library Definition
Section titled “3.5.4 Drupal Library Definition”wc-components: version: VERSION js: /path/to/dist/wc-components.js: type: external minified: true attributes: type: module css: theme: /path/to/dist/themes/light.css: {} dependencies: - core/once3.5.5 Event Handling in Drupal Context
Section titled “3.5.5 Event Handling in Drupal Context”Web component events cross shadow DOM boundaries (due to composed: true) and can be caught by Drupal behaviors.
(function (Drupal) { 'use strict';
/** * Behavior: Track content card clicks for analytics. */ Drupal.behaviors.wcCardTracking = { attach(context) { const cards = once('wc-card-tracking', 'wc-content-card', context); cards.forEach((card) => { card.addEventListener('wc-card-click', (event) => { const { href, heading, keyboard } = event.detail;
// Google Analytics 4 event if (typeof gtag === 'function') { gtag('event', 'content_card_click', { content_title: heading, content_url: href, interaction_method: keyboard ? 'keyboard' : 'mouse', }); }
// Drupal event for other modules to hook into const drupalEvent = new CustomEvent('wc:analytics', { bubbles: true, detail: { type: 'card_click', ...event.detail }, }); document.dispatchEvent(drupalEvent); }); }); }, };
/** * Behavior: Handle search form submission. */ Drupal.behaviors.wcSearch = { attach(context) { const searchBars = once('wc-search', 'wc-search-bar', context); searchBars.forEach((searchBar) => { searchBar.addEventListener('wc-search-submit', (event) => { const { query } = event.detail; // Redirect to Drupal Search API results page window.location.href = `/search?keys=${encodeURIComponent(query)}`; }); }); }, };})(Drupal);3.5.6 Server-Side Rendering Considerations
Section titled “3.5.6 Server-Side Rendering Considerations”Lit provides experimental SSR support via @lit-labs/ssr, which renders web components to Declarative Shadow DOM on the server. For Drupal integration, this would involve a containerized Node.js service that processes the page HTML before it reaches the client.
Current recommendation: Do not implement SSR for initial delivery. The reasons:
@lit-labs/ssris still experimental and does not support@lit/context.- The containerized service architecture adds operational complexity.
- Lit components with proper
loading="lazy"images and meaningful fallback content in slots perform adequately for Core Web Vitals without SSR. - SSR can be added later as a performance optimization once the component library is stable.
Progressive enhancement strategy instead:
- Use meaningful HTML in slots (not empty slots) so content is visible before JS loads.
- Use
<noscript>fallbacks for critical content. - Ensure the Drupal TWIG template renders useful content even if the web component JS fails to load.
3.6 Testing Implementation
Section titled “3.6 Testing Implementation”3.6.1 Testing Pyramid
Section titled “3.6.1 Testing Pyramid” /\ / \ E2E Tests (Playwright) / \ Cross-browser, full user flows /______\ ~10% of test effort / \ / \ Integration Tests (WTR) / \ Component composition, slot behavior, /______________\ form participation, event bubbling / \ ~30% of test effort / \ / \ Unit Tests (WTR + @open-wc/testing) / \ Property reactivity, rendering, /__________________________\ internal state, controllers ~60% of test effort3.6.2 Web Test Runner Configuration
Section titled “3.6.2 Web Test Runner Configuration”Web Test Runner (WTR) executes tests in real browsers, which is essential for web components that depend on Shadow DOM, Custom Elements, and browser APIs.
import { playwrightLauncher } from '@web/test-runner-playwright';import { esbuildPlugin } from '@web/dev-server-esbuild';
export default { files: 'src/**/*.test.ts', nodeResolve: true, plugins: [ esbuildPlugin({ ts: true, target: 'auto' }), ], browsers: [ playwrightLauncher({ product: 'chromium' }), playwrightLauncher({ product: 'firefox' }), playwrightLauncher({ product: 'webkit' }), ], coverageConfig: { report: true, reportDir: 'coverage', threshold: { statements: 80, branches: 80, functions: 80, lines: 80, }, }, testFramework: { config: { timeout: 5000, }, },};3.6.3 Component Unit Test Example
Section titled “3.6.3 Component Unit Test Example”import { html, fixture, expect, oneEvent } from '@open-wc/testing';import { sendKeys } from '@web/test-runner-commands';import type { WcContentCard } from './wc-content-card.js';import './wc-content-card.js';
describe('wc-content-card', () => { // ── Rendering ──────────────────────────────────────────────
it('renders with default properties', async () => { const el = await fixture<WcContentCard>(html` <wc-content-card heading="Test Heading"></wc-content-card> `);
expect(el).to.exist; expect(el.heading).to.equal('Test Heading'); expect(el.variant).to.equal('default'); });
it('renders heading text in shadow DOM', async () => { const el = await fixture<WcContentCard>(html` <wc-content-card heading="My Article"></wc-content-card> `);
const heading = el.shadowRoot?.querySelector('.card__heading'); expect(heading?.textContent).to.equal('My Article'); });
it('does not render summary when empty', async () => { const el = await fixture<WcContentCard>(html` <wc-content-card heading="Title" summary=""></wc-content-card> `);
const summary = el.shadowRoot?.querySelector('.card__summary'); expect(summary).to.be.null; });
it('renders read time when greater than zero', async () => { const el = await fixture<WcContentCard>(html` <wc-content-card heading="Title" read-time="5"></wc-content-card> `);
const readTime = el.shadowRoot?.querySelector('.card__read-time'); expect(readTime?.textContent).to.contain('5 min read'); });
// ── Variants ───────────────────────────────────────────────
it('reflects variant attribute', async () => { const el = await fixture<WcContentCard>(html` <wc-content-card heading="Title" variant="featured"></wc-content-card> `);
expect(el.getAttribute('variant')).to.equal('featured'); });
// ── Events ─────────────────────────────────────────────────
it('fires wc-card-click on click', async () => { const el = await fixture<WcContentCard>(html` <wc-content-card heading="Click Test" href="/test" ></wc-content-card> `);
const listener = oneEvent(el, 'wc-card-click'); el.click(); const event = await listener;
expect(event.detail.href).to.equal('/test'); expect(event.detail.heading).to.equal('Click Test'); expect(event.detail.keyboard).to.be.false; });
it('fires wc-card-click with keyboard=true on Enter', async () => { const el = await fixture<WcContentCard>(html` <wc-content-card heading="KB Test" href="/kb"></wc-content-card> `);
const card = el.shadowRoot?.querySelector('[part="card"]') as HTMLElement; card.focus();
const listener = oneEvent(el, 'wc-card-click'); await sendKeys({ press: 'Enter' }); const event = await listener;
expect(event.detail.keyboard).to.be.true; });
it('event is composed (crosses shadow DOM boundary)', async () => { const el = await fixture<WcContentCard>(html` <wc-content-card heading="Composed Test"></wc-content-card> `);
const listener = oneEvent(el, 'wc-card-click'); el.click(); const event = await listener;
expect(event.composed).to.be.true; expect(event.bubbles).to.be.true; });
// ── Slots ──────────────────────────────────────────────────
it('renders slotted media content', async () => { const el = await fixture<WcContentCard>(html` <wc-content-card heading="Slot Test"> <img slot="media" src="test.jpg" alt="Test" /> </wc-content-card> `);
const slot = el.shadowRoot?.querySelector('slot[name="media"]') as HTMLSlotElement; const assigned = slot.assignedElements(); expect(assigned).to.have.length(1); expect(assigned[0].tagName).to.equal('IMG'); });
// ── Accessibility ──────────────────────────────────────────
it('is accessible with default props', async () => { const el = await fixture<WcContentCard>(html` <wc-content-card heading="Accessible Card" summary="This card should pass axe checks." category="Health" href="/article" ></wc-content-card> `);
await expect(el).to.be.accessible(); });
it('has role=button when no href is provided', async () => { const el = await fixture<WcContentCard>(html` <wc-content-card heading="No Link"></wc-content-card> `);
const card = el.shadowRoot?.querySelector('[part="card"]'); expect(card?.getAttribute('role')).to.equal('button'); });
it('has no role when href is provided (native link semantics)', async () => { const el = await fixture<WcContentCard>(html` <wc-content-card heading="With Link" href="/article"></wc-content-card> `);
const card = el.shadowRoot?.querySelector('[part="card"]'); expect(card?.getAttribute('role')).to.be.null; });});3.6.4 Controller Unit Test Example
Section titled “3.6.4 Controller Unit Test Example”import { html, fixture, expect, waitUntil } from '@open-wc/testing';import { LitElement } from 'lit';import { customElement } from 'lit/decorators.js';import { IntersectionController } from './intersection.controller.js';
@customElement('test-intersection-host')class TestHost extends LitElement { intersection = new IntersectionController(this);
render() { return html`<div>${this.intersection.isVisible ? 'visible' : 'hidden'}</div>`; }}
describe('IntersectionController', () => { it('initializes with isVisible=false', async () => { const el = await fixture<TestHost>(html` <test-intersection-host></test-intersection-host> `);
expect(el.intersection.isVisible).to.be.false; });
it('cleans up observer on disconnect', async () => { const el = await fixture<TestHost>(html` <test-intersection-host></test-intersection-host> `);
el.remove(); // Verify no errors occur after disconnect expect(() => el.intersection.isVisible).to.not.throw(); });});3.6.5 Visual Regression Testing
Section titled “3.6.5 Visual Regression Testing”Chromatic integrates directly with Storybook to capture visual snapshots of every story across browsers.
Setup:
{ "scripts": { "chromatic": "npx chromatic --project-token=$CHROMATIC_PROJECT_TOKEN", "chromatic:ci": "npx chromatic --project-token=$CHROMATIC_PROJECT_TOKEN --exit-zero-on-changes --auto-accept-changes main" }}What gets tested:
- Every story in every theme (light, dark, high-contrast)
- Every viewport size (mobile, tablet, desktop)
- Every variant of every component
- Hover/focus/active states via play functions
Workflow:
- Developer creates/modifies a component
- PR triggers Chromatic build
- Chromatic captures screenshots of all affected stories
- Visual diffs are reviewed in Chromatic UI
- Approved changes become the new baseline
3.6.6 Accessibility Testing Strategy
Section titled “3.6.6 Accessibility Testing Strategy”Accessibility testing happens at three levels:
Level 1: Author-Time (IDE)
ts-lit-pluginwarns about missing ARIA attributes and invalid roles in templates.- ESLint with
eslint-plugin-lit-a11ycatches common accessibility issues.
Level 2: Story-Time (Storybook)
@storybook/addon-a11yruns axe-core against every story on each load.- Every component has a dedicated “Accessibility” story that demonstrates focus management, keyboard navigation, and screen reader behavior.
Level 3: CI/CD (Automated)
- Chromatic accessibility regression testing detects new WCAG violations per pull request.
- Web Test Runner runs
@open-wc/testing’sexpect(el).to.be.accessible()assertion (powered by axe-core) in every component test.
Level 4: Manual Audit (Quarterly)
- Screen reader testing with NVDA (Windows), VoiceOver (macOS/iOS), TalkBack (Android).
- Keyboard-only navigation audit of all interactive components.
- Color contrast verification at all three theme modes.
- Focus order verification in complex component compositions.
Healthcare-specific accessibility requirements:
| Requirement | Standard | Our Approach |
|---|---|---|
| Form error identification | WCAG 3.3.1 | role="alert" on error messages, aria-invalid on inputs |
| Labels and instructions | WCAG 3.3.2 | Every input has visible label + help-text attribute |
| Error suggestion | WCAG 3.3.3 | Error messages describe how to fix, not just what’s wrong |
| Error prevention | WCAG 3.3.4 | Confirmation dialogs on destructive actions |
| Consistent navigation | WCAG 3.2.3 | Navigation components render identically across pages |
| Focus visible | WCAG 2.4.7 | Custom focus ring on all interactive elements, 3:1 contrast ratio |
| Target size | WCAG 2.5.8 | Minimum 44x44px touch targets on all interactive elements |
3.6.7 Test Script Configuration
Section titled “3.6.7 Test Script Configuration”{ "scripts": { "test": "wtr", "test:watch": "wtr --watch", "test:coverage": "wtr --coverage", "test:ci": "wtr --ci", "test:a11y": "wtr --grep 'accessible'", "test:e2e": "playwright test", "test:visual": "npm run chromatic", "test:all": "npm run test:ci && npm run test:e2e && npm run test:visual" }}3.7 Theming & Design Tokens
Section titled “3.7 Theming & Design Tokens”3.7.1 Token Architecture
Section titled “3.7.1 Token Architecture”Design tokens are defined as CSS custom properties, organized by category. They form the contract between the design system and the component library.
:root,[data-theme="light"] { /* ── Color Tokens ────────────────────────────────────── */ --wc-color-primary: #1e40af; --wc-color-primary-hover: #1e3a8a; --wc-color-primary-active: #1d4ed8; --wc-color-on-primary: #ffffff;
--wc-color-secondary: #059669; --wc-color-secondary-hover: #047857;
--wc-color-surface: #ffffff; --wc-color-surface-raised: #f8fafc; --wc-color-on-surface: #1e293b; --wc-color-on-surface-muted: #64748b;
--wc-color-border: #e2e8f0; --wc-color-border-strong: #cbd5e1;
--wc-color-error: #dc2626; --wc-color-warning: #d97706; --wc-color-success: #16a34a; --wc-color-info: #2563eb;
/* ── Typography Tokens ───────────────────────────────── */ --wc-font-family-body: 'Inter', system-ui, -apple-system, sans-serif; --wc-font-family-heading: 'Inter', system-ui, -apple-system, sans-serif;
--wc-font-size-xs: 0.75rem; --wc-font-size-sm: 0.875rem; --wc-font-size-base: 1rem; --wc-font-size-lg: 1.125rem; --wc-font-size-xl: 1.25rem; --wc-font-size-2xl: 1.5rem; --wc-font-size-3xl: 1.875rem; --wc-font-size-4xl: 2.25rem;
--wc-line-height-tight: 1.25; --wc-line-height-normal: 1.5; --wc-line-height-relaxed: 1.75;
/* ── Spacing Tokens ──────────────────────────────────── */ --wc-spacing-xs: 0.25rem; --wc-spacing-sm: 0.5rem; --wc-spacing-md: 1rem; --wc-spacing-lg: 1.5rem; --wc-spacing-xl: 2rem; --wc-spacing-2xl: 3rem; --wc-spacing-3xl: 4rem;
/* ── Elevation Tokens ────────────────────────────────── */ --wc-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --wc-shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07); --wc-shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
/* ── Border Tokens ───────────────────────────────────── */ --wc-radius-sm: 4px; --wc-radius-md: 8px; --wc-radius-lg: 12px; --wc-radius-full: 9999px;
/* ── Focus Token (critical for a11y) ─────────────────── */ --wc-focus-ring: 0 0 0 3px rgba(30, 64, 175, 0.4); --wc-focus-ring-offset: 2px;
/* ── Transition Tokens ───────────────────────────────── */ --wc-transition-fast: 150ms ease; --wc-transition-normal: 250ms ease; --wc-transition-slow: 350ms ease;}[data-theme="dark"] { --wc-color-primary: #60a5fa; --wc-color-primary-hover: #93bbfd; --wc-color-primary-active: #3b82f6; --wc-color-on-primary: #1e293b;
--wc-color-surface: #1e293b; --wc-color-surface-raised: #334155; --wc-color-on-surface: #f1f5f9; --wc-color-on-surface-muted: #94a3b8;
--wc-color-border: #334155; --wc-color-border-strong: #475569;
--wc-color-error: #f87171; --wc-color-warning: #fbbf24; --wc-color-success: #4ade80; --wc-color-info: #60a5fa;
--wc-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); --wc-shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4); --wc-shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
--wc-focus-ring: 0 0 0 3px rgba(96, 165, 250, 0.4);}[data-theme="high-contrast"] { --wc-color-primary: #ffffff; --wc-color-on-primary: #000000;
--wc-color-surface: #000000; --wc-color-surface-raised: #1a1a1a; --wc-color-on-surface: #ffffff; --wc-color-on-surface-muted: #e5e5e5;
--wc-color-border: #ffffff; --wc-color-border-strong: #ffffff;
--wc-color-error: #ff6b6b; --wc-color-warning: #ffd93d; --wc-color-success: #6bff6b; --wc-color-info: #6bb5ff;
--wc-focus-ring: 0 0 0 3px #ffffff;}3.7.2 Theme Switching at Component Level
Section titled “3.7.2 Theme Switching at Component Level”Components consume tokens via CSS custom properties with fallback values. Theme switching requires zero JavaScript inside components — it is purely CSS.
// Shared focus styles used by all interactive componentsimport { css } from 'lit';
export const focusStyles = css` :host(:focus-visible) { outline: none; box-shadow: var(--wc-focus-ring); outline-offset: var(--wc-focus-ring-offset, 2px); }`;// Component using design tokensstatic styles = [ focusStyles, css` :host { display: block; font-family: var(--wc-font-family-body); color: var(--wc-color-on-surface); }
.card { background: var(--wc-card-bg, var(--wc-color-surface)); border: 1px solid var(--wc-color-border); border-radius: var(--wc-card-radius, var(--wc-radius-md)); padding: var(--wc-card-padding, var(--wc-spacing-lg)); box-shadow: var(--wc-card-shadow, var(--wc-shadow-sm)); transition: box-shadow var(--wc-transition-fast); }
.card:hover { box-shadow: var(--wc-shadow-md); }
/* Reduced motion support */ @media (prefers-reduced-motion: reduce) { .card { transition: none; } } `,];3.7.3 CSS Shadow Parts for External Styling
Section titled “3.7.3 CSS Shadow Parts for External Styling”CSS Shadow Parts expose internal elements for targeted styling by consumers, providing a controlled escape hatch from Shadow DOM encapsulation.
/* In the consuming Drupal theme's CSS */
/* Style the card header area differently for featured content */wc-content-card[variant="featured"]::part(header) { min-height: 200px; background: linear-gradient(135deg, var(--wc-color-primary), var(--wc-color-secondary));}
/* Style the card body in a sidebar context */.sidebar wc-content-card::part(body) { padding: var(--wc-spacing-sm);}Summary: Architecture Highlights
Section titled “Summary: Architecture Highlights”The following architectural decisions define the WC-2026 component library:
-
Custom Elements Manifest as the single source of truth: One
cem analyzecommand generates the JSON that powers Storybook controls, IDE autocomplete, Drupal documentation, and API reference. This eliminates documentation drift. -
Reactive controllers over mixins: Controllers are composable, testable, and avoid prototype chain pollution. Domain-specific controllers (form validation, screen reader announcements, reduced motion) encapsulate reusable behavior.
-
Form-associated custom elements with ElementInternals: Native
<form>participation without wrapper components or hidden inputs. This provides a clean integration path for healthcare form compliance. -
Three-layer accessibility strategy: Author-time (IDE), story-time (Storybook addon), and CI (Chromatic regression) catch accessibility issues before they reach production. Healthcare applications require this level of rigor.
-
Drupal integration is first-class, not an afterthought: Prop mapping tables, reference TWIG templates, and Drupal behavior examples ship with the component library. The Storybook documentation includes TWIG examples alongside JavaScript examples.
-
Progressive enhancement over SSR: Rather than adding the operational complexity of a containerized SSR service, components are designed to degrade gracefully with meaningful slot content. SSR can be layered on later when
@lit-labs/ssrmatures. -
Token-based theming with zero-JS switching: Light, dark, and high-contrast themes are pure CSS custom property overrides. Components do not need to know or care which theme is active.
Sources
Section titled “Sources”- Lit Official Documentation
- Lit Cheat Sheet
- Reactive Controllers - Lit
- Context - Lit
- Styles - Lit
- Decorators - Lit
- Documenting Web Components With Storybook (James Ives, 2025)
- Custom Elements Manifest: The Killer Feature (Dave Rupert, 2025)
- Custom Elements Manifest Analyzer
- Server Rendering Lit Web Components with Drupal (Benny Powers)
- Storybook Documentation
- CSF Factories Documentation
- Chromatic Accessibility Regression Testing
- Storybook Accessibility Testing Docs
- Testing - Lit
- Open WC Testing Helpers
- Form-Associated Custom Elements (Benny Powers)
- ElementInternals - MDN
- Drupal Single-Directory Components
- Drupal Twig Components Module
- Web Components Drupal Module
- Lit Starter TypeScript (GitHub)
- Why Web Components are Making a Comeback in 2025
- Atomic Design Methodology (Brad Frost)
- ARIA Authoring Practices Guide (W3C)