Skip to content

Component Building Guide

apps/docs/src/content/docs/pre-planning/building-guide Click to copy
Copied! apps/docs/src/content/docs/pre-planning/building-guide

Section Owner: Senior Frontend Engineer Last Updated: 2026-02-13 Audience: Front-end developers and designers building Web Components for Drupal integration Prerequisite Reading: 03 - Component Architecture & Storybook Integration


  1. Introduction
  2. Drupal-Friendly Component Patterns
  3. Data Structure Patterns
  4. Common Component Patterns
  5. Testing Checklist for Component Builders
  6. Anti-Patterns to Avoid
  7. Component Lifecycle & Drupal
  8. Theming Guidelines for Component Builders

This guide is a practical handbook. The Component Architecture document (section 03) describes the technical patterns and the “why.” This document describes the “how” — the specific decisions a component builder must make so that every component integrates with Drupal with minimal friction.

The core principle: Drupal renders HTML on the server. Your component receives that HTML as attributes and slot content. Design your component’s public API around this reality, and the Drupal team’s integration effort drops from days to hours.

Who should read this:

  • Front-end developers building Lit Web Components for the WC (Web Components) library
  • Designers defining component specifications that developers will implement
  • Drupal theme developers who need to understand the component contract

Conventions used in this document:

  • wc- prefix on all custom element tag names (Web Components)
  • --wc- prefix on all CSS custom properties
  • TWIG examples assume Drupal 10.3+ with standard field configurations
  • All components use Lit 3.x with TypeScript strict mode

Drupal TWIG templates pass data to Web Components through HTML attributes. Every attribute name must be:

  1. kebab-case (HTML standard)
  2. Descriptive enough to be self-documenting in a TWIG template
  3. Mapped to a specific Drupal field in the component’s JSDoc

DO — Descriptive attribute names that map to Drupal fields:

<wc-content-card
heading="Understanding Anxiety"
summary="Learn about symptoms and treatments."
publish-date="2026-02-10T00:00:00Z"
author-name="Dr. Sarah Chen"
content-type="article"
read-time="8"
category-label="Mental Health"
category-url="/categories/mental-health"
hero-image-src="/sites/default/files/anxiety.jpg"
hero-image-alt="Patient talking with therapist"
></wc-content-card>

DO NOT — Generic names that are ambiguous in a TWIG template:

<!-- BAD: What does "type" mean? Drupal content type? Visual variant? -->
<wc-content-card
title="Understanding Anxiety"
type="article"
author="Dr. Sarah Chen"
date="2026-02-10"
src="/sites/default/files/anxiety.jpg"
></wc-content-card>

Naming rules for attributes:

RuleGoodBadReason
Use kebab-casepublish-datepublishDateHTML attributes are case-insensitive
Prefix ambiguous namescontent-typetypetype conflicts with native HTML
Include the data shapehero-image-srcimageClarifies it expects a URL string
Match Drupal field semanticsauthor-nameauthorCould be a name, ID, or object
Use -url suffix for linkscategory-urlcategory-linkExplicit about the value type
Use -label suffix for display textcategory-labelcategoryDistinguishes from machine name

TypeScript property mapping:

/**
* Author's display name.
* Maps to Drupal field: `node.field_author.entity.field_display_name.value`
*/
@property({ type: String, attribute: 'author-name' })
authorName = '';
/**
* Publication date in ISO 8601 format.
* Maps to Drupal: `node.createdtime|date('c')`
*/
@property({ type: String, attribute: 'publish-date' })
publishDate = '';

5.2.2 Slot Patterns That Align with Drupal

Section titled “5.2.2 Slot Patterns That Align with Drupal”

Drupal’s field rendering model outputs HTML fragments. Slots are the mechanism for injecting those fragments into your component. The key insight: Drupal fields render as HTML, not as data. Slots accept HTML. Use slots for rendered content; use attributes for scalar values.

When to use an attribute vs. a slot:

Data TypeUseExample
Plain string (title, label)Attributeheading="My Title"
Number (count, duration)Attributeread-time="8"
URLAttributehref="/articles/my-article"
ISO date stringAttributepublish-date="2026-02-10T00:00:00Z"
Boolean flagAttribute (present/absent)featured
Enum (restricted values)Attributevariant="compact"
Rendered HTML (body text)Default slot<p>Article body...</p>
Rendered HTML (specific area)Named slot<img slot="media" ...>
Drupal field with formatterNamed slot<div slot="author">{{ content.field_author }}</div>
Complex markup (tags, buttons)Named slot<div slot="actions">{{ content.field_tags }}</div>

Named slot naming conventions:

Slot NamePurposeDrupal Mapping
(default)Primary content area{{ content.body }} or {{ content }}
mediaImage, video, or audio{{ content.field_media }}
actionsCTA buttons, linksCustom TWIG markup
metaMetadata (dates, tags){{ content.field_tags }}
headerHeader area contentCustom TWIG markup
footerFooter area contentCustom TWIG markup
sidebarSidebar contentBlock/view content
breadcrumbBreadcrumb navigation{{ drupal_block('system_breadcrumb_block') }}
iconIcon or small graphic<wc-icon> or <svg>

Slot fallback content — every slot should have meaningful fallback content that renders when the slot is empty:

render() {
return html`
<div class="card__media">
<slot name="media">
<!-- Fallback: a placeholder that shows the component works without an image -->
<div class="card__media-placeholder" aria-hidden="true"></div>
</slot>
</div>
<div class="card__body">
<h3 class="card__heading">${this.heading}</h3>
<slot>
<!-- Default slot fallback: renders summary text if no slotted content -->
${this.summary ? html`<p class="card__summary">${this.summary}</p>` : nothing}
</slot>
</div>
`;
}

Drupal behaviors attach event listeners to DOM elements. Your component’s custom events are the interface between the Web Component and Drupal’s JavaScript layer.

Event naming rules:

  1. Prefix all events with wc- to avoid collision with native DOM events
  2. Use kebab-case: wc-card-click, wc-form-submit, wc-nav-toggle
  3. Always set bubbles: true — Drupal behaviors often listen at the document level
  4. Always set composed: true — events must cross Shadow DOM boundaries
  5. Always include a typed detail payload with all relevant data

Event detail contract — always include:

// Every event must carry enough information for the Drupal behavior
// to act without querying the component for additional state.
interface CardClickDetail {
/** The URL the card links to */
href: string;
/** The card heading (useful for analytics) */
heading: string;
/** How the user activated the card */
activationMethod: 'click' | 'keyboard';
/** Drupal node ID if available */
nodeId?: string;
}

Dispatching events correctly:

private _handleActivate(e: Event): void {
// Do NOT preventDefault on the native event unless you are
// replacing its behavior entirely.
this.dispatchEvent(new CustomEvent<CardClickDetail>('wc-card-click', {
bubbles: true,
composed: true,
detail: {
href: this.href,
heading: this.heading,
activationMethod: 'click',
nodeId: this.getAttribute('data-node-id') ?? undefined,
},
}));
}

How Drupal behaviors consume these events:

// In the Drupal theme's JavaScript
(function (Drupal) {
Drupal.behaviors.wcCardTracking = {
attach(context) {
const cards = once('wc-card-tracking', 'wc-content-card', context);
cards.forEach((card) => {
card.addEventListener('wc-card-click', (event) => {
// event.detail is fully typed and documented
console.log('Card clicked:', event.detail.heading);
});
});
},
};
})(Drupal);

Every component must render useful content before JavaScript loads. This is not optional — it is a requirement for SEO, accessibility, and healthcare compliance.

Strategy: meaningful slot content is the fallback.

In Drupal, the server renders the TWIG template first. The Web Component’s JavaScript then upgrades the markup with interactivity, encapsulated styles, and enhanced behavior. If JavaScript fails to load, the slot content is still visible as plain HTML.

Before JavaScript loads:

<!-- Server-rendered HTML from Drupal TWIG -->
<wc-content-card heading="Understanding Anxiety" href="/articles/understanding-anxiety">
<img slot="media" src="/files/anxiety.jpg" alt="Therapist with patient" loading="lazy" />
<p>Learn about the symptoms, causes, and evidence-based treatments.</p>
<div slot="actions">
<a href="/articles/understanding-anxiety">Read More</a>
</div>
</wc-content-card>

Before the component’s JavaScript loads, the browser renders <wc-content-card> as an unknown element — a block-level box with its children visible in the light DOM. The image, paragraph, and link are all visible and functional.

After JavaScript loads:

The Lit component upgrades the element, attaches Shadow DOM, and projects the slot content into the designed layout with scoped styles.

CSS to handle the unregistered state:

/* In the document's global CSS (not inside Shadow DOM) */
/* Provide basic layout before component JS loads */
wc-content-card:not(:defined) {
display: block;
border: 1px solid var(--wc-color-border, #e2e8f0);
border-radius: 8px;
padding: 1rem;
overflow: hidden;
}
wc-content-card:not(:defined) [slot="media"] img {
width: 100%;
height: auto;
display: block;
}
/* Hide elements that only make sense after upgrade */
wc-content-card:not(:defined) .js-only {
display: none;
}

The :defined pseudo-class targets elements whose custom element constructor has been registered. Use :not(:defined) for pre-upgrade styles and :defined for post-upgrade adjustments.


Drupal fields are flat. A node has field_title, field_summary, field_author — not a nested JSON object. Design your component’s attribute API to mirror this flatness.

DO — flat attributes that map 1:1 to Drupal fields:

@property({ type: String }) heading = '';
@property({ type: String }) summary = '';
@property({ type: String, attribute: 'author-name' }) authorName = '';
@property({ type: String, attribute: 'author-avatar' }) authorAvatar = '';
@property({ type: String, attribute: 'publish-date' }) publishDate = '';
@property({ type: Number, attribute: 'read-time' }) readTime = 0;

DO NOT — nested JSON that forces Drupal to serialize data:

// BAD: Forces the TWIG template to JSON-encode an object
@property({ type: Object })
author = { name: '', avatar: '', bio: '' };

HTML attributes are always strings. Lit converts them via the type option in @property(), but the TWIG template always outputs a string.

Type conversion rules:

Lit Property TypeHTML Attribute ValueTWIG Output
String"value"{{ field_value }}
Number"42"{{ field_number }}
BooleanPresent = true, absent = false{% if condition %}attribute{% endif %}
// String: rendered as-is
@property({ type: String }) heading = '';
// Number: Lit parses the string to a number
@property({ type: Number, attribute: 'read-time' }) readTime = 0;
// Boolean: presence of attribute = true, absence = false
@property({ type: Boolean, reflect: true }) featured = false;

TWIG output for each type:

<wc-content-card
heading="{{ node.label }}"
read-time="{{ content.field_read_time|render|striptags|trim }}"
{{ node.isPromoted ? 'featured' : '' }}
>

Occasionally, a component needs structured data that cannot be expressed as flat attributes. Use JSON attributes sparingly and only when:

  1. The data is a collection (array of items)
  2. The items have internal structure that attributes cannot express
  3. The alternative would be 10+ attributes with numeric suffixes

Acceptable: navigation items

/**
* Navigation items as JSON array.
* Each item: { label: string, href: string, active?: boolean, children?: NavItem[] }
*
* Maps to Drupal: Menu link tree serialized via custom TWIG extension or preprocess hook.
*/
@property({ type: Array, attribute: 'items' })
items: NavItem[] = [];
{# The Drupal theme preprocessor serializes the menu tree #}
<wc-nav items='{{ menu_items_json }}'></wc-nav>

Rules for JSON attributes:

  1. Use type: Array or type: Object — Lit will JSON.parse() the attribute value
  2. Document the expected JSON schema in JSDoc
  3. Always validate the parsed data defensively (it could be malformed)
  4. Provide a meaningful empty state when the JSON is missing or invalid
  5. Prefer a Drupal preprocess hook or TWIG extension to generate the JSON — do not force the TWIG template to hand-build JSON strings

HTML boolean attributes follow the spec: the attribute is present = true, the attribute is absent = false. The attribute’s value does not matter (disabled, disabled="", and disabled="disabled" are all true).

// Correct: Lit handles boolean attribute presence/absence
@property({ type: Boolean, reflect: true }) featured = false;
@property({ type: Boolean, reflect: true }) disabled = false;
@property({ type: Boolean, reflect: true }) loading = false;

TWIG usage:

{# Correct: conditionally add the attribute #}
<wc-content-card
heading="{{ node.label }}"
{{ node.isPromoted ? 'featured' : '' }}
{{ is_loading ? 'loading' : '' }}
>

Never do this:

{# WRONG: featured="false" means featured IS present, so Lit reads it as true #}
<wc-content-card featured="false">

When an attribute accepts a restricted set of values, document the valid options and provide a default.

/**
* Visual variant of the card.
* - `default`: Standard card with border
* - `featured`: Larger card with accent border
* - `compact`: Minimal card for sidebar use
*/
@property({ type: String, reflect: true })
variant: 'default' | 'featured' | 'compact' = 'default';

Why reflect: true for enums: Reflecting the attribute allows CSS selectors like :host([variant="featured"]) to work. This enables variant-specific styling without JavaScript.

TWIG usage:

<wc-content-card variant="{{ view_mode == 'teaser_featured' ? 'featured' : 'default' }}">

Every component should work with only its required attributes. Optional attributes enhance the component but never break it.

Document required vs. optional clearly in JSDoc:

/**
* Card heading text. REQUIRED.
* The component renders an empty heading area if this is not provided.
*/
@property({ type: String }) heading = '';
/**
* Summary or teaser text. Optional.
* When empty, the summary area is not rendered.
*/
@property({ type: String }) summary = '';

Defensive rendering:

render() {
return html`
<h3 class="card__heading">${this.heading || 'Untitled'}</h3>
${this.summary
? html`<p class="card__summary">${this.summary}</p>`
: nothing}
${this.readTime > 0
? html`<span class="card__meta">${this.readTime} min read</span>`
: nothing}
`;
}

This section provides complete implementation guides for 12 components that cover the primary content hub use cases. Each component includes its full attribute API, slot structure, events, CSS custom properties, accessibility requirements, and a Drupal TWIG template example.


Purpose: The primary content discovery element. Renders article, blog post, or resource previews in listing pages, search results, and sidebar widgets.

Drupal view mode: node--article--teaser

AttributeTypeRequiredDefaultDrupal Source
headingStringYes''node.label
summaryStringNo''field_summary (Plain text)
hrefStringNo''{{ url }} (Node canonical URL)
publish-dateStringNo''node.createdtime|date('c')
author-nameStringNo''field_author.entity.field_display_name
categoryStringNo''field_category.entity.label
read-timeNumberNo0field_read_time (Integer)
variantStringNo'default'Derived from view mode or is_promoted
hero-image-srcStringNo''field_media.entity.field_media_image.entity.uri.url
hero-image-altStringNo''field_media.entity.field_media_image.alt
SlotPurposeTypical Drupal Content
(default)Additional body content{{ content.body }}
mediaHero image or video{{ content.field_media }}
actionsCTA buttons, tag links{{ content.field_tags }}
metaAdditional metadataCustom date/author markup
EventDetail TypeWhen Fired
wc-card-click{ href: string, heading: string, activationMethod: 'click' | 'keyboard' }Card activated by click or Enter/Space
PropertyDefaultPurpose
--wc-card-radiusvar(--wc-radius-md)Border radius
--wc-card-paddingvar(--wc-spacing-lg)Internal padding
--wc-card-bgvar(--wc-color-surface)Background color
--wc-card-shadowvar(--wc-shadow-sm)Box shadow
--wc-card-hover-shadowvar(--wc-shadow-md)Hover box shadow
--wc-card-border-colorvar(--wc-color-border)Border color
  • Card heading uses <h3> (configurable via heading-level attribute for list context)
  • When href is set, the card wrapper is an <a> element (native link semantics)
  • When href is absent, the card wrapper is a <div> with role="button" and tabindex="0"
  • Focus ring visible on :focus-visible with minimum 3:1 contrast ratio
  • Color is never the sole indicator of category — text label always present
{# templates/node--article--teaser.html.twig #}
{#
WC Content Card Integration
=============================
Maps Drupal article node fields to the wc-content-card web component.
Required Drupal fields:
- title (core)
- field_summary (Plain text, required)
- field_category (Term reference, single value)
Optional Drupal fields:
- field_media (Media reference: Image or Video)
- field_read_time (Integer, computed or manual)
- field_author (Entity reference: User or Author CT)
- field_tags (Term reference, multi-value)
Component docs: [Storybook URL]/organisms-content-card--docs
#}
{#-- Extract field values into TWIG variables for clarity --#}
{%- 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' -%}
{%- set card_author = node.field_author.entity.field_display_name.value | 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 }}"
author-name="{{ card_author }}"
data-node-id="{{ node.id }}"
{{ attributes }}
>
{#-- Media slot: Drupal's media field renders responsive images automatically --#}
{% if content.field_media|render|trim is not empty %}
<div slot="media">
{{ content.field_media }}
</div>
{% endif %}
{#-- Actions slot: tag links rendered by Drupal's field formatter --#}
{% if content.field_tags|render|trim is not empty %}
<div slot="actions">
{{ content.field_tags }}
</div>
{% endif %}
</wc-content-card>

Purpose: Renders the metadata banner at the top of a full article page. Displays author, publication date, reading time, categories, and social share options.

Drupal view mode: node--article--full (header region)

AttributeTypeRequiredDefaultDrupal Source
headingStringYes''node.label
author-nameStringNo''field_author.entity.field_display_name
author-avatarStringNo''field_author.entity.user_picture.entity.uri.url
publish-dateStringNo''node.createdtime|date('c')
updated-dateStringNo''node.changedtime|date('c')
read-timeNumberNo0field_read_time (Integer)
categoryStringNo''field_category.entity.label
category-urlStringNo''field_category.entity.url
SlotPurposeTypical Drupal Content
bylineCustom author/byline markupAuthor bio block
shareSocial share buttonsShare module output
breadcrumbBreadcrumb navigationSystem breadcrumb block
tagsTaxonomy term links{{ content.field_tags }}
EventDetail TypeWhen Fired
wc-share-click{ platform: string, url: string }Social share button clicked
PropertyDefaultPurpose
--wc-article-header-bgtransparentHeader background
--wc-article-header-bordervar(--wc-color-border)Bottom border color
--wc-article-header-paddingvar(--wc-spacing-xl)Internal padding
--wc-article-header-max-width720pxContent max width
  • Heading element uses appropriate level (default <h1> for article pages)
  • Author name is linked to author profile when URL is available
  • <time> element with datetime attribute for publish and updated dates
  • Reading time announced as “estimated reading time” for screen readers
{# templates/node--article--full.html.twig (header region) #}
{#
WC Article Header Integration
===============================
Renders the article metadata header above the body content.
This template shows only the header portion. The article body
and sidebar content are handled by the wc-article-layout component.
#}
{%- set article_heading = label[0]['#title'] | default(node.label) -%}
{%- set article_author = node.field_author.entity.field_display_name.value | default('') -%}
{%- set article_avatar = '' -%}
{% if node.field_author.entity.user_picture.entity %}
{%- set article_avatar = file_url(node.field_author.entity.user_picture.entity.fileuri) -%}
{% endif %}
{%- set article_date = node.createdtime|date('c') -%}
{%- set article_updated = node.changedtime|date('c') -%}
{%- set article_read_time = content.field_read_time|render|striptags|trim -%}
{%- set article_category = node.field_category.entity.label -%}
{%- set article_category_url = path('entity.taxonomy_term.canonical', {'taxonomy_term': node.field_category.entity.id}) -%}
<wc-article-header
heading="{{ article_heading }}"
author-name="{{ article_author }}"
author-avatar="{{ article_avatar }}"
publish-date="{{ article_date }}"
updated-date="{{ article_updated }}"
read-time="{{ article_read_time }}"
category="{{ article_category }}"
category-url="{{ article_category_url }}"
{{ attributes }}
>
{#-- Breadcrumb slot --#}
<nav slot="breadcrumb" aria-label="Breadcrumb">
{{ drupal_block('system_breadcrumb_block') }}
</nav>
{#-- Tags slot --#}
{% if content.field_tags|render|trim is not empty %}
<div slot="tags">
{{ content.field_tags }}
</div>
{% endif %}
{#-- Share slot: provided by a contrib module like Better Social Sharing Buttons --#}
{% if content.field_share_buttons|render|trim is not empty %}
<div slot="share">
{{ content.field_share_buttons }}
</div>
{% endif %}
</wc-article-header>

Purpose: Unified media display component supporting images, videos, and audio. Handles responsive images from Drupal image styles, lazy loading, and aspect ratio enforcement.

Drupal mapping: field--field-media-image.html.twig, field--field-media-video.html.twig

AttributeTypeRequiredDefaultDrupal Source
typeStringYes'image'Media entity bundle
srcStringYes''Image style URL or video embed URL
altStringCond.''field_media_image.alt (required for images)
widthNumberNo0Image intrinsic width
heightNumberNo0Image intrinsic height
srcsetStringNo''Drupal responsive image srcset
sizesStringNo''Drupal responsive image sizes
aspect-ratioStringNo''Aspect ratio (e.g., 16/9, 4/3)
loadingStringNo'lazy''lazy' or 'eager'
captionStringNo''field_media_image.title or custom field
video-providerStringNo'''youtube', 'vimeo', or 'self'
posterStringNo''Video poster image URL
SlotPurposeTypical Drupal Content
(default)Caption or overlay content{{ content.field_caption }}
fallbackContent shown while loading or on errorPlaceholder markup
EventDetail TypeWhen Fired
wc-media-load{ src: string, type: string }Media has loaded
wc-media-error{ src: string, error: string }Media failed to load
wc-media-play{ src: string }Video/audio started playing
PropertyDefaultPurpose
--wc-media-radiusvar(--wc-radius-md)Border radius
--wc-media-bgvar(--wc-color-surface-raised)Background (visible during load)
--wc-media-aspect-ratioautoAspect ratio override
--wc-media-object-fitcoverImage object-fit
  • Images require alt text — the component renders a console warning if type="image" and alt is empty
  • Videos include <track> element support via slot for closed captions
  • loading="lazy" by default; set loading="eager" for above-the-fold images
  • Respects prefers-reduced-motion for video autoplay
{# templates/field--field-media-image.html.twig #}
{#
WC Media Component Integration
================================
Wraps Drupal's media image field output in the wc-media component.
This template is used when the field_media_image field is displayed
using Drupal's responsive image formatter.
IMPORTANT: Drupal's responsive image formatter already generates
<picture> and <source> elements. For simple images, use the
attribute-based approach. For Drupal's responsive image output,
use the slot approach to pass through the rendered field.
#}
{% for item in items %}
{%- set media_entity = item.content['#media'] | default(null) -%}
{%- set image_alt = item.content['#item'].alt | default('') -%}
{#-- Option A: Simple image with attributes --#}
{% if item.content['#image_style'] is defined %}
<wc-media
type="image"
src="{{ item.content['#uri'] | image_style(item.content['#image_style']) }}"
alt="{{ image_alt }}"
width="{{ item.content['#width'] | default(0) }}"
height="{{ item.content['#height'] | default(0) }}"
loading="{{ loop.first ? 'eager' : 'lazy' }}"
{{ attributes }}
></wc-media>
{#-- Option B: Drupal responsive image (pass through rendered output) --#}
{% else %}
<wc-media
type="image"
alt="{{ image_alt }}"
loading="{{ loop.first ? 'eager' : 'lazy' }}"
{{ attributes }}
>
{#-- Drupal's rendered responsive image goes into the default slot --#}
{{ item.content }}
</wc-media>
{% endif %}
{% endfor %}

Purpose: Accessible text input for healthcare forms. Fully participates in native <form> elements via the ElementInternals API (form-associated custom element).

Drupal mapping: Form API textfield element

AttributeTypeRequiredDefaultDrupal Source
labelStringYes''Form element #title
nameStringYes''Form element #name
valueStringNo''Form element #default_value
typeStringNo'text''text', 'email', 'tel', 'url', 'password', 'search'
placeholderStringNo''Form element #placeholder
requiredBooleanNofalseForm element #required
disabledBooleanNofalseForm element #disabled
readonlyBooleanNofalseForm element #attributes.readonly
error-messageStringNo''Server-side validation error
help-textStringNo''Form element #description
maxlengthNumberNo0Form element #maxlength
patternStringNo''Form element #pattern
autocompleteStringNo''HTML autocomplete attribute value
SlotPurposeTypical Drupal Content
prefixIcon or text before inputIcon markup
suffixIcon or text after inputCharacter count, clear button
EventDetail TypeWhen Fired
wc-input{ value: string, name: string }On each keystroke
wc-change{ value: string, name: string }On blur when value has changed
wc-invalid{ value: string, name: string, validity: ValidityState }On validation failure
PropertyDefaultPurpose
--wc-input-border-colorvar(--wc-color-border)Input border
--wc-input-border-color-focusvar(--wc-color-primary)Focus border
--wc-input-border-color-errorvar(--wc-color-error)Error border
--wc-input-bgvar(--wc-color-surface)Input background
--wc-input-radiusvar(--wc-radius-sm)Border radius
--wc-input-paddingvar(--wc-spacing-sm) var(--wc-spacing-md)Internal padding
--wc-input-font-sizevar(--wc-font-size-base)Font size
  • Every input has a visible, programmatically associated <label> (WCAG 1.3.1)
  • Error messages use role="alert" for immediate screen reader announcement (WCAG 3.3.1)
  • Error messages describe how to fix the issue, not just what is wrong (WCAG 3.3.3)
  • Required fields indicated both visually (asterisk) and programmatically (aria-required) (WCAG 3.3.2)
  • Help text connected via aria-describedby
  • Color alone never indicates state — errors use icon + text + border change (WCAG 1.4.1)
  • Minimum 44x44px touch target (WCAG 2.5.8)
{# templates/form-element--textfield.html.twig #}
{#
WC Text Input Integration
===========================
Replaces Drupal's default form element rendering for textfield types.
This override maps Drupal Form API properties to wc-text-input attributes.
The component handles all visual rendering, validation display, and
accessibility attributes internally.
IMPORTANT: The wc-text-input component is form-associated via
ElementInternals. It participates in native <form> submission
and FormData collection without hidden inputs.
#}
{%- set input_label = element['#title'] | default('') -%}
{%- set input_name = element['#name'] | default('') -%}
{%- set input_value = element['#value'] | default('') -%}
{%- set input_type = element['#type'] | default('text') -%}
{%- set input_required = element['#required'] | default(false) -%}
{%- set input_disabled = element['#disabled'] | default(false) -%}
{%- set input_description = element['#description'] | render | striptags | trim -%}
{%- set input_error = element['#errors'] | render | striptags | trim -%}
{%- set input_maxlength = element['#maxlength'] | default(0) -%}
{%- set input_placeholder = element['#placeholder'] | default('') -%}
{%- set input_pattern = element['#pattern'] | default('') -%}
<wc-text-input
label="{{ input_label }}"
name="{{ input_name }}"
value="{{ input_value }}"
type="{{ input_type }}"
placeholder="{{ input_placeholder }}"
{{ input_required ? 'required' : '' }}
{{ input_disabled ? 'disabled' : '' }}
{% if input_description %}help-text="{{ input_description }}"{% endif %}
{% if input_error %}error-message="{{ input_error }}"{% endif %}
{% if input_maxlength > 0 %}maxlength="{{ input_maxlength }}"{% endif %}
{% if input_pattern %}pattern="{{ input_pattern }}"{% endif %}
{{ attributes }}
></wc-text-input>

Purpose: Multi-line text input for healthcare forms. Supports character counting, auto-resize, and the same validation patterns as wc-text-input.

Drupal mapping: Form API textarea element

AttributeTypeRequiredDefaultDrupal Source
labelStringYes''Form element #title
nameStringYes''Form element #name
valueStringNo''Form element #default_value
rowsNumberNo4Form element #rows
requiredBooleanNofalseForm element #required
disabledBooleanNofalseForm element #disabled
maxlengthNumberNo0Form element #maxlength
error-messageStringNo''Server-side validation error
help-textStringNo''Form element #description
auto-resizeBooleanNofalseAuto-grow with content
show-countBooleanNofalseShow character count
EventDetail TypeWhen Fired
wc-input{ value: string, name: string, length: number }On each input event
wc-change{ value: string, name: string }On blur when value changed
{# templates/form-element--textarea.html.twig #}
{%- set ta_label = element['#title'] | default('') -%}
{%- set ta_name = element['#name'] | default('') -%}
{%- set ta_value = element['#value'] | default('') -%}
{%- set ta_rows = element['#rows'] | default(4) -%}
{%- set ta_required = element['#required'] | default(false) -%}
{%- set ta_description = element['#description'] | render | striptags | trim -%}
{%- set ta_error = element['#errors'] | render | striptags | trim -%}
{%- set ta_maxlength = element['#maxlength'] | default(0) -%}
<wc-textarea
label="{{ ta_label }}"
name="{{ ta_name }}"
value="{{ ta_value }}"
rows="{{ ta_rows }}"
{{ ta_required ? 'required' : '' }}
{% if ta_description %}help-text="{{ ta_description }}"{% endif %}
{% if ta_error %}error-message="{{ ta_error }}"{% endif %}
{% if ta_maxlength > 0 %}maxlength="{{ ta_maxlength }}" show-count{% endif %}
{{ attributes }}
></wc-textarea>

Purpose: Dropdown selection for healthcare forms. Supports single and multiple selection, option groups, and search/filter for long lists.

Drupal mapping: Form API select element

AttributeTypeRequiredDefaultDrupal Source
labelStringYes''Form element #title
nameStringYes''Form element #name
valueStringNo''Form element #default_value
optionsArray (JSON)Yes[]Form element #options (serialized)
requiredBooleanNofalseForm element #required
disabledBooleanNofalseForm element #disabled
multipleBooleanNofalseForm element #multiple
placeholderStringNo'Select...'Empty option text
searchableBooleanNofalseEnable filter for long lists
error-messageStringNo''Server-side validation error
help-textStringNo''Form element #description

Options JSON format:

[
{ "value": "cardiology", "label": "Cardiology" },
{ "value": "dermatology", "label": "Dermatology" },
{
"label": "Mental Health",
"options": [
{ "value": "psychiatry", "label": "Psychiatry" },
{ "value": "psychology", "label": "Psychology" }
]
}
]
EventDetail TypeWhen Fired
wc-change{ value: string | string[], name: string }Selection changed
  • Uses role="listbox" pattern for custom rendering with full keyboard navigation
  • Arrow keys navigate options, Enter selects, Escape closes
  • Active descendant pattern for screen reader announcements
  • Searchable mode announces result count as options are filtered
{# templates/form-element--select.html.twig #}
{#
WC Select Integration
=======================
Drupal's Form API #options are an associative array.
We serialize to JSON for the component's options attribute.
For simple selects (<20 options), the flat attribute works well.
For complex selects with optgroups, preprocess in a theme hook.
#}
{#-- Serialize Drupal options to JSON --#}
{%- set options_json = [] -%}
{% for key, label in element['#options'] %}
{% if label is iterable %}
{#-- Optgroup --#}
{%- set group_options = [] -%}
{% for sub_key, sub_label in label %}
{%- set group_options = group_options|merge([{ 'value': sub_key, 'label': sub_label }]) -%}
{% endfor %}
{%- set options_json = options_json|merge([{ 'label': key, 'options': group_options }]) -%}
{% else %}
{%- set options_json = options_json|merge([{ 'value': key, 'label': label }]) -%}
{% endif %}
{% endfor %}
<wc-select
label="{{ element['#title'] | default('') }}"
name="{{ element['#name'] | default('') }}"
value="{{ element['#value'] | default('') }}"
options='{{ options_json | json_encode }}'
{{ element['#required'] | default(false) ? 'required' : '' }}
{{ element['#multiple'] | default(false) ? 'multiple' : '' }}
{% if element['#description'] %}help-text="{{ element['#description'] | render | striptags | trim }}"{% endif %}
{% if element['#errors'] %}error-message="{{ element['#errors'] | render | striptags | trim }}"{% endif %}
{{ attributes }}
></wc-select>

Purpose: Single checkbox for boolean options in healthcare forms.

Drupal mapping: Form API checkbox element

AttributeTypeRequiredDefaultDrupal Source
labelStringYes''Form element #title
nameStringYes''Form element #name
valueStringNo'on'Form element #return_value
checkedBooleanNofalseForm element #default_value
requiredBooleanNofalseForm element #required
disabledBooleanNofalseForm element #disabled
error-messageStringNo''Server-side validation error
EventDetail TypeWhen Fired
wc-change{ checked: boolean, value: string, name: string }Checkbox toggled
  • Label is always visible and clickable (WCAG 1.3.1)
  • Uses native <input type="checkbox"> inside Shadow DOM for form participation
  • Indeterminate state supported via indeterminate property
  • Minimum 44x44px touch target including label area
{# templates/form-element--checkbox.html.twig #}
<wc-checkbox
label="{{ element['#title'] | default('') }}"
name="{{ element['#name'] | default('') }}"
value="{{ element['#return_value'] | default('on') }}"
{{ element['#default_value'] ? 'checked' : '' }}
{{ element['#required'] | default(false) ? 'required' : '' }}
{{ element['#disabled'] | default(false) ? 'disabled' : '' }}
{% if element['#errors'] %}error-message="{{ element['#errors'] | render | striptags | trim }}"{% endif %}
{{ attributes }}
></wc-checkbox>

Purpose: Radio button group for mutually exclusive options. The group component manages the collection; individual radio buttons are rendered internally.

Drupal mapping: Form API radios element

AttributeTypeRequiredDefaultDrupal Source
legendStringYes''Form element #title
nameStringYes''Form element #name
valueStringNo''Form element #default_value
optionsArray (JSON)Yes[]Form element #options (serialized)
requiredBooleanNofalseForm element #required
disabledBooleanNofalseForm element #disabled
orientationStringNo'vertical''vertical' or 'horizontal'
error-messageStringNo''Server-side validation error
help-textStringNo''Form element #description

Options JSON format:

[
{ "value": "yes", "label": "Yes" },
{ "value": "no", "label": "No" },
{ "value": "unsure", "label": "Not sure", "disabled": true }
]
EventDetail TypeWhen Fired
wc-change{ value: string, name: string }Selection changed
  • Wraps radio buttons in <fieldset> with <legend> (WCAG 1.3.1)
  • Arrow key navigation within the group (WCAG standard radio pattern)
  • aria-required on the fieldset when required
{# templates/form-element--radios.html.twig #}
{%- set radio_options = [] -%}
{% for key, label in element['#options'] %}
{%- set radio_options = radio_options|merge([{ 'value': key, 'label': label }]) -%}
{% endfor %}
<wc-radio-group
legend="{{ element['#title'] | default('') }}"
name="{{ element['#name'] | default('') }}"
value="{{ element['#default_value'] | default('') }}"
options='{{ radio_options | json_encode }}'
{{ element['#required'] | default(false) ? 'required' : '' }}
{{ element['#disabled'] | default(false) ? 'disabled' : '' }}
{% if element['#description'] %}help-text="{{ element['#description'] | render | striptags | trim }}"{% endif %}
{% if element['#errors'] %}error-message="{{ element['#errors'] | render | striptags | trim }}"{% endif %}
{{ attributes }}
></wc-radio-group>

Purpose: Primary site navigation component. Consumes the Drupal menu tree and renders a responsive navigation with mobile drawer support.

Drupal mapping: block--system-main-menu.html.twig or menu--main.html.twig

AttributeTypeRequiredDefaultDrupal Source
itemsArray (JSON)Yes[]Menu link tree (serialized)
labelStringNo'Main navigation'aria-label value
orientationStringNo'horizontal''horizontal' or 'vertical'
mobile-breakpointStringNo'768px'Breakpoint for mobile drawer
active-pathStringNo''Current path for active trail

Menu items JSON format:

[
{
"label": "Home",
"href": "/",
"active": true
},
{
"label": "Services",
"href": "/services",
"children": [
{ "label": "Primary Care", "href": "/services/primary-care" },
{ "label": "Cardiology", "href": "/services/cardiology" },
{ "label": "Mental Health", "href": "/services/mental-health" }
]
},
{
"label": "Patient Portal",
"href": "https://portal.example.com",
"external": true
}
]
SlotPurposeTypical Drupal Content
logoSite logo/brandingTheme logo markup
actionsHeader action buttons (login, search)Custom TWIG markup
mobile-headerCustom mobile drawer headerBrand/close button
EventDetail TypeWhen Fired
wc-nav-toggle{ open: boolean }Mobile menu opened or closed
wc-nav-click{ href: string, label: string, level: number }Nav item clicked
PropertyDefaultPurpose
--wc-nav-bgvar(--wc-color-surface)Navigation background
--wc-nav-textvar(--wc-color-on-surface)Navigation text color
--wc-nav-active-colorvar(--wc-color-primary)Active item indicator
--wc-nav-height64pxHeader height
--wc-nav-mobile-width300pxMobile drawer width
  • <nav> landmark with aria-label
  • Submenu toggle buttons with aria-expanded and aria-haspopup
  • Arrow key navigation for submenus (WAI-ARIA menu pattern)
  • Focus trap in mobile drawer when open
  • Escape key closes mobile drawer and submenus
  • Active page indicated with aria-current="page"
{# templates/block--system-main-menu.html.twig #}
{#
WC Navigation Integration
===========================
Serializes Drupal's menu tree into the JSON format expected by wc-nav.
The menu tree is preprocessed in the theme's .theme file to generate
the JSON structure. See mytheme_preprocess_block__system_main_menu().
IMPORTANT: Active trail detection is handled by the component using
the active-path attribute. The Drupal theme passes the current path.
#}
{#-- menu_items_json is set in the theme preprocess hook --#}
<wc-nav
items='{{ menu_items_json }}'
label="{{ 'Main navigation'|t }}"
active-path="{{ path('<current>') }}"
>
{#-- Logo slot --#}
<a slot="logo" href="{{ path('<front>') }}" aria-label="{{ 'Home'|t }}">
<img src="{{ base_path ~ directory }}/logo.svg" alt="{{ site_name }}" height="40" />
</a>
{#-- Actions slot: search and login --#}
<div slot="actions">
<wc-button variant="ghost" aria-label="{{ 'Search'|t }}">
<wc-icon name="search"></wc-icon>
</wc-button>
{% if logged_in %}
<wc-button variant="secondary" href="{{ path('user.page') }}">
{{ 'My Account'|t }}
</wc-button>
{% else %}
<wc-button variant="primary" href="{{ path('user.login') }}">
{{ 'Patient Login'|t }}
</wc-button>
{% endif %}
</div>
</wc-nav>

Theme preprocess hook (PHP):

/**
* Implements hook_preprocess_block__system_main_menu().
*
* Serializes the menu tree into JSON for the wc-nav component.
*/
function mytheme_preprocess_block__system_main_menu(&$variables) {
$menu_tree = \Drupal::menuTree()->load('main', new \Drupal\Core\Menu\MenuTreeParameters());
$manipulators = [
['callable' => 'menu.default_tree_manipulators:checkAccess'],
['callable' => 'menu.default_tree_manipulators:generateIndexAndSort'],
];
$tree = \Drupal::menuTree()->transform($menu_tree, $manipulators);
$variables['menu_items_json'] = json_encode(_mytheme_build_menu_json($tree));
}
function _mytheme_build_menu_json(array $tree): array {
$items = [];
foreach ($tree as $element) {
$link = $element->link;
$item = [
'label' => $link->getTitle(),
'href' => $link->getUrlObject()->toString(),
];
if ($link->getUrlObject()->isExternal()) {
$item['external'] = true;
}
if ($element->subtree) {
$item['children'] = _mytheme_build_menu_json($element->subtree);
}
$items[] = $item;
}
return $items;
}

Purpose: Full-width hero section for landing pages. Supports background image/video, heading, subheading, and call-to-action buttons.

Drupal mapping: Paragraph type “Hero” or Layout Builder custom block

AttributeTypeRequiredDefaultDrupal Source
headingStringYes''field_hero_heading
subheadingStringNo''field_hero_subheading
bg-imageStringNo''field_hero_image.entity.uri.url
bg-colorStringNo''field_hero_bg_color (Color field)
overlay-opacityStringNo'0.5'field_hero_overlay_opacity
text-alignStringNo'center''left', 'center', 'right'
min-heightStringNo'400px'CSS min-height value
variantStringNo'default''default', 'split', 'video'
SlotPurposeTypical Drupal Content
(default)Body text / description{{ content.field_hero_body }}
actionsCTA buttons{{ content.field_hero_cta }} (Link field)
mediaBackground video or image{{ content.field_hero_video }}
badgeCorner badge or labelCustom markup
PropertyDefaultPurpose
--wc-hero-min-height400pxMinimum height
--wc-hero-paddingvar(--wc-spacing-3xl) var(--wc-spacing-xl)Internal padding
--wc-hero-text-color#ffffffText color over image
--wc-hero-overlay-colorrgba(0,0,0,0.5)Image overlay color
--wc-hero-max-content-width800pxContent area max width
{# templates/paragraph--hero.html.twig #}
{#
WC Hero Banner Integration
============================
Maps a Drupal "Hero" paragraph type to the wc-hero-banner component.
Drupal fields:
- field_hero_heading (Text, required)
- field_hero_subheading (Text, optional)
- field_hero_body (Text formatted, optional)
- field_hero_image (Media reference: Image, optional)
- field_hero_cta (Link, multi-value, optional)
- field_hero_text_align (List: left, center, right)
#}
{%- set hero_heading = content.field_hero_heading|render|striptags|trim -%}
{%- set hero_subheading = content.field_hero_subheading|render|striptags|trim -%}
{%- set hero_bg = '' -%}
{% if paragraph.field_hero_image.entity %}
{%- set hero_bg = file_url(paragraph.field_hero_image.entity.field_media_image.entity.fileuri) -%}
{% endif %}
{%- set hero_text_align = paragraph.field_hero_text_align.value | default('center') -%}
<wc-hero-banner
heading="{{ hero_heading }}"
subheading="{{ hero_subheading }}"
bg-image="{{ hero_bg }}"
text-align="{{ hero_text_align }}"
{{ attributes }}
>
{#-- Body content --#}
{% if content.field_hero_body|render|trim is not empty %}
{{ content.field_hero_body }}
{% endif %}
{#-- CTA buttons --#}
{% if content.field_hero_cta|render|trim is not empty %}
<div slot="actions">
{% for item in paragraph.field_hero_cta %}
<wc-button
variant="{{ loop.first ? 'primary' : 'secondary' }}"
href="{{ item.url }}"
>
{{ item.title }}
</wc-button>
{% endfor %}
</div>
{% endif %}
</wc-hero-banner>

Purpose: Expandable/collapsible content sections. Used for FAQs, service details, and progressive disclosure of healthcare information.

Drupal mapping: Paragraph type “FAQ” or “Accordion”

AttributeTypeRequiredDefaultDescription
multipleBooleanNofalseAllow multiple panels open simultaneously
heading-levelNumberNo3Heading level for accordion triggers (2-6)

Attribute API (Item — wc-accordion-item)

Section titled “Attribute API (Item — wc-accordion-item)”
AttributeTypeRequiredDefaultDrupal Source
headingStringYes''field_faq_question
expandedBooleanNofalsePre-expanded state
disabledBooleanNofalsePrevent interaction
SlotPurposeTypical Drupal Content
(default)Panel content{{ content.field_faq_answer }}
iconCustom expand/collapse iconCustom SVG
EventDetail TypeWhen Fired
wc-accordion-toggle{ index: number, expanded: boolean, heading: string }Panel toggled
  • Uses <details>/<summary> pattern internally for native disclosure behavior
  • aria-expanded on trigger buttons
  • aria-controls linking trigger to panel
  • Panel region has role="region" with aria-labelledby pointing to the trigger
  • Enter and Space activate triggers; no arrow key navigation between items (intentional — this is disclosure, not tabs)
{# templates/paragraph--faq.html.twig #}
{#
WC Accordion Integration
==========================
Maps a Drupal "FAQ" paragraph type to the wc-accordion component.
Drupal fields:
- field_faq_items (Paragraph reference, multi-value)
- field_faq_question (Text, required)
- field_faq_answer (Text formatted, required)
#}
<wc-accordion heading-level="3" {{ attributes }}>
{% for item in paragraph.field_faq_items %}
{%- set faq_question = item.entity.field_faq_question.value -%}
<wc-accordion-item
heading="{{ faq_question }}"
{{ loop.first ? 'expanded' : '' }}
>
{{ item.entity.field_faq_answer.value|raw }}
</wc-accordion-item>
{% endfor %}
</wc-accordion>

Purpose: System messages and notifications. Used for status messages, warnings, errors, and informational notices. In healthcare, these often communicate critical information about forms, portal status, or appointment confirmations.

Drupal mapping: status-messages.html.twig

AttributeTypeRequiredDefaultDrupal Source
typeStringYes'info''info', 'success', 'warning', 'error'
headingStringNo''Optional alert heading
dismissibleBooleanNofalseShow dismiss button
role-overrideStringNo''Override ARIA role (e.g., 'alert', 'status')
iconStringNo''Custom icon name (defaults by type)
SlotPurposeTypical Drupal Content
(default)Alert message contentDrupal status message text
actionsAction links/buttons”Dismiss” or “Learn more” links
EventDetail TypeWhen Fired
wc-alert-dismiss{ type: string }Dismiss button clicked
  • Error alerts use role="alert" for immediate announcement (WCAG 4.1.3)
  • Success/info use role="status" for polite announcement
  • Dismissible alerts: dismiss button has aria-label="Dismiss alert"
  • Icon + text + color ensure no single modality is the only indicator (WCAG 1.4.1)
{# templates/status-messages.html.twig #}
{#
WC Alert Integration
======================
Replaces Drupal's default status message rendering.
Drupal passes messages grouped by type:
- status (maps to 'success')
- warning (maps to 'warning')
- error (maps to 'error')
#}
{%- set type_map = { 'status': 'success', 'warning': 'warning', 'error': 'error' } -%}
{% for type, messages in message_list %}
{%- set alert_type = type_map[type] | default('info') -%}
{% for message in messages %}
<wc-alert
type="{{ alert_type }}"
dismissible
{{ attributes }}
>
{{ message }}
</wc-alert>
{% endfor %}
{% endfor %}

5.5 Testing Checklist for Component Builders

Section titled “5.5 Testing Checklist for Component Builders”

Before marking any component as “Drupal-ready,” verify every item on this checklist.

  • All attributes use kebab-case naming
  • All attributes are documented in JSDoc with Drupal field mapping
  • Boolean attributes follow the HTML spec (present = true, absent = false)
  • Enum attributes have a documented list of valid values with a sensible default
  • reflect: true is set only on attributes used in CSS selectors (variants, states)
  • Every attribute has a default value that results in a non-broken render
  • Default slot accepts arbitrary HTML and renders it correctly
  • Named slots have meaningful fallback content when empty
  • Slot names are documented in JSDoc using @slot tags
  • Component renders correctly with all optional slots empty
  • Component renders correctly with all slots populated
  • Slotchange events are handled if layout depends on slot content presence
  • All custom events use the wc- prefix
  • All events have bubbles: true and composed: true
  • Event detail types are defined as TypeScript interfaces
  • Events are documented in JSDoc using @fires tags
  • Event detail contains enough information for Drupal behaviors to act without querying the component
  • All customizable properties use the --wc- prefix
  • Properties are documented in JSDoc using @cssprop tags
  • Every CSS custom property usage includes a hardcoded fallback value
  • Component renders correctly without any token stylesheet loaded
  • Component responds to dark mode via token changes (no JS required)
  • Passes axe-core automated audit with zero violations
  • All interactive elements are keyboard accessible (Tab, Enter, Space, Escape as appropriate)
  • Focus indicator is visible and meets 3:1 contrast ratio
  • prefers-reduced-motion: reduce disables all transitions/animations
  • forced-colors: active support for Windows High Contrast Mode
  • All form elements have visible, programmatic labels (WCAG 1.3.1)
  • Error messages use role="alert" or aria-live (WCAG 3.3.1)
  • Color is never the sole indicator of state (WCAG 1.4.1)
  • Touch targets meet 44x44px minimum (WCAG 2.5.8)
  • Component tolerates 200% browser zoom without content loss (WCAG 1.4.4)
  • Component tolerates text spacing overrides without content loss (WCAG 1.4.12)
  • Component renders meaningful content before JavaScript loads (:not(:defined) styles)
  • Slot content is visible when the component is not upgraded
  • Links and form elements work before the component JavaScript loads
  • JSDoc is 100% complete on the component class, all public properties, events, slots, parts, and CSS properties
  • Storybook story exists with all variants and states
  • Storybook MDX documentation includes Drupal integration section
  • TWIG template example is provided with detailed comments
  • Prop mapping table maps every attribute to its Drupal field source
  • Component registered in custom-elements.json via CEM analyzer
  • Component exported from the library’s barrel index.ts
  • Component does not import any Drupal, React, Angular, or Vue dependencies
  • Component does not fetch data — it receives data through attributes and slots
  • Component does not manage routing — navigation is handled by the browser or Drupal

These patterns create friction for the Drupal integration team. Avoid them.

Anti-Pattern 1: Requiring Complex JSON in Attributes

Section titled “Anti-Pattern 1: Requiring Complex JSON in Attributes”
<!-- BAD: Forces the TWIG template to serialize nested JSON -->
<wc-content-card
data='{"title":"My Article","author":{"name":"Dr. Chen","avatar":"/img/chen.jpg"},"tags":["health","wellness"]}'
></wc-content-card>

Why it is bad: TWIG is a templating language, not a serialization layer. Building JSON strings in TWIG is error-prone (escaping, quotes, nested objects). TWIG developers should be writing HTML, not hand-coding JSON.

Fix: Use flat attributes for scalar values, named slots for complex content.

<!-- GOOD: Flat attributes + slots -->
<wc-content-card heading="My Article" author-name="Dr. Chen">
<img slot="media" src="/img/chen.jpg" alt="Dr. Chen" />
<div slot="actions">
<a href="/tags/health">Health</a>
<a href="/tags/wellness">Wellness</a>
</div>
</wc-content-card>
// BAD: React-style prop patterns
@property({ type: Object })
onClick: (e: Event) => void = () => {};
@property({ type: Object })
renderItem: (item: unknown) => TemplateResult = () => html``;

Why it is bad: TWIG cannot pass JavaScript functions as attribute values. These patterns couple the component to a JavaScript framework.

Fix: Use custom events for callbacks, slots for custom rendering.

Anti-Pattern 3: Tight Coupling to Data Structures

Section titled “Anti-Pattern 3: Tight Coupling to Data Structures”
// BAD: Component knows about Drupal's internal data structure
@property({ type: Object })
node: DrupalNode = {};
render() {
return html`<h3>${this.node.field_title[0].value}</h3>`;
}

Why it is bad: The component now depends on Drupal’s internal field structure. If the Drupal field name changes, the component breaks. The component should have zero knowledge of Drupal.

Fix: Accept primitive values as attributes. Let the TWIG template extract values from Drupal’s data model.

// GOOD: Accepts primitive values
@property({ type: String }) heading = '';

Anti-Pattern 4: Missing Fallback Content for Slots

Section titled “Anti-Pattern 4: Missing Fallback Content for Slots”
// BAD: Empty slot with no fallback
render() {
return html`
<div class="card__media">
<slot name="media"></slot>
</div>
`;
}

Why it is bad: If the Drupal field is empty, the component renders a blank area with no visual indication of what belongs there (in development) and potentially broken layout (in production).

Fix: Always provide fallback content.

render() {
return html`
<div class="card__media">
<slot name="media">
<div class="card__media-placeholder" aria-hidden="true">
<wc-icon name="image" size="48"></wc-icon>
</div>
</slot>
</div>
`;
}

Anti-Pattern 5: Undocumented CSS Custom Properties

Section titled “Anti-Pattern 5: Undocumented CSS Custom Properties”
// BAD: Uses custom properties but does not document them
static styles = css`
:host {
background: var(--card-bg);
padding: var(--card-pad);
}
`;

Why it is bad: The Drupal theme developer has no way to discover which properties can be customized. They will either override with brute-force CSS (fighting Shadow DOM) or request code changes.

Fix: Document every CSS custom property in JSDoc with default values.

/**
* @cssprop [--wc-card-bg=var(--wc-color-surface)] - Card background color
* @cssprop [--wc-card-padding=var(--wc-spacing-lg)] - Card internal padding
*/

Anti-Pattern 6: Events Without Detail Payloads

Section titled “Anti-Pattern 6: Events Without Detail Payloads”
// BAD: Event with no useful detail
this.dispatchEvent(new Event('click'));

Why it is bad: The Drupal behavior has to query the component for its state after receiving the event. This is fragile and creates timing issues, especially with Drupal AJAX.

Fix: Always include a typed detail payload with enough context to act.

this.dispatchEvent(new CustomEvent('wc-card-click', {
bubbles: true,
composed: true,
detail: { href: this.href, heading: this.heading, activationMethod: 'click' },
}));

Anti-Pattern 7: Components That Break Without JavaScript

Section titled “Anti-Pattern 7: Components That Break Without JavaScript”
<!-- BAD: Nothing visible until JS loads and component upgrades -->
<wc-content-card heading="Article Title"></wc-content-card>

Why it is bad: Before JavaScript loads, the browser renders an empty unknown element. If JS fails, the user sees nothing. In healthcare, content must always be accessible.

Fix: Use slots to provide visible server-rendered content.

<!-- GOOD: Slot content is visible before JS loads -->
<wc-content-card heading="Article Title" href="/articles/my-article">
<img slot="media" src="/img/article.jpg" alt="Article illustration" />
<p>Article summary text is visible even without JavaScript.</p>
<a slot="actions" href="/articles/my-article">Read More</a>
</wc-content-card>

Anti-Pattern 8: Fetching Data Inside Components

Section titled “Anti-Pattern 8: Fetching Data Inside Components”
// BAD: Component fetches its own data
async connectedCallback() {
super.connectedCallback();
const response = await fetch(`/api/articles/${this.articleId}`);
this.data = await response.json();
}

Why it is bad: Drupal already has the data on the server. Fetching it again on the client creates unnecessary network requests, complicates caching, and creates a loading state that did not need to exist. Components should be presentation-only.

Fix: Drupal passes all data via attributes and slots. The component never fetches.


5.7.1 Initialization Relative to Drupal Behaviors

Section titled “5.7.1 Initialization Relative to Drupal Behaviors”

Understanding when your component initializes relative to Drupal’s lifecycle is critical.

Load order:

1. Drupal renders HTML (server-side)
2. Browser parses HTML, renders FOUC-safe content (:not(:defined) styles)
3. Drupal's JS aggregated file loads
4. wc-components.js loads (ES module, type="module")
5. Custom elements are registered (customElements.define)
6. Components upgrade: connectedCallback fires, Shadow DOM attaches
7. Drupal.behaviors.attach(document, drupalSettings) fires
8. Components are fully interactive

Key insight: Drupal behaviors fire after the DOM is loaded but the attach method receives a context parameter that can be any DOM subtree. When Drupal replaces content via AJAX, it calls attach on the new content only. Your component must handle being initialized (step 5-6) independently of Drupal behaviors (step 7).

5.7.2 Handling Drupal AJAX Content Replacement

Section titled “5.7.2 Handling Drupal AJAX Content Replacement”

Drupal’s AJAX system dynamically replaces regions of the page. When a region is replaced:

  1. Drupal calls Drupal.behaviors.detach(oldContent) on the old content
  2. Drupal replaces the DOM subtree with new HTML
  3. Drupal calls Drupal.behaviors.attach(newContent) on the new content

Impact on Web Components: When old DOM is removed, the custom element’s disconnectedCallback fires. When new DOM is inserted, the browser auto-upgrades any custom elements (because the definitions are already registered). The new components call connectedCallback automatically.

What you must handle: If your component creates external references (event listeners on window or document, IntersectionObservers, ResizeObservers, timers), clean them up in disconnectedCallback:

connectedCallback(): void {
super.connectedCallback();
this._resizeObserver = new ResizeObserver(this._onResize);
this._resizeObserver.observe(this);
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._resizeObserver?.disconnect();
this._resizeObserver = null;
}

5.7.3 Handling BigPipe Progressive Rendering

Section titled “5.7.3 Handling BigPipe Progressive Rendering”

Drupal’s BigPipe module streams page content in chunks. Placeholders are sent initially and then replaced with final content via inline <script> tags.

Impact on Web Components: A Web Component may be sent in the initial HTML with placeholder slot content, then BigPipe replaces a child element inside the component’s light DOM with the final content.

What you must handle: The component must react to light DOM changes. Use slotchange events to detect when slot content is updated:

firstUpdated(): void {
this.shadowRoot?.addEventListener('slotchange', this._onSlotChange);
}
private _onSlotChange = (e: Event): void => {
const slot = e.target as HTMLSlotElement;
const assignedNodes = slot.assignedNodes({ flatten: true });
if (slot.name === 'media') {
this._hasMedia = assignedNodes.length > 0;
this.requestUpdate();
}
};

5.7.4 MutationObserver for Dynamic Content Insertion

Section titled “5.7.4 MutationObserver for Dynamic Content Insertion”

For edge cases where content is inserted into the component’s light DOM without triggering slotchange (e.g., Drupal’s ajax_view command that updates children of an existing element), use a MutationObserver:

connectedCallback(): void {
super.connectedCallback();
this._mutationObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
this._handleChildrenChanged();
}
}
});
this._mutationObserver.observe(this, {
childList: true,
subtree: false, // Only direct children
});
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._mutationObserver?.disconnect();
}

5.7.5 The once() Pattern for Drupal Behaviors

Section titled “5.7.5 The once() Pattern for Drupal Behaviors”

When Drupal behaviors need to interact with your Web Components, they should use Drupal’s once() utility to prevent double-initialization:

Drupal.behaviors.wcCardAnalytics = {
attach(context) {
// once() ensures each element is processed exactly once,
// even if attach() is called multiple times on the same context.
const cards = once('wc-card-analytics', 'wc-content-card', context);
cards.forEach((card) => {
card.addEventListener('wc-card-click', handleCardClick);
});
},
detach(context, settings, trigger) {
if (trigger === 'unload') {
const cards = once.remove('wc-card-analytics', 'wc-content-card', context);
cards.forEach((card) => {
card.removeEventListener('wc-card-click', handleCardClick);
});
}
},
};

5.8 Theming Guidelines for Component Builders

Section titled “5.8 Theming Guidelines for Component Builders”

Every component must expose a documented set of CSS custom properties that allow theming without modifying the component source. Follow this hierarchy:

Tier 1 — Global tokens (defined in the token stylesheet, consumed by all components):

/* These come from the design token system. Components reference them. */
--wc-color-primary
--wc-color-surface
--wc-color-on-surface
--wc-color-border
--wc-spacing-sm, --wc-spacing-md, --wc-spacing-lg
--wc-radius-sm, --wc-radius-md, --wc-radius-lg
--wc-shadow-sm, --wc-shadow-md
--wc-font-size-base, --wc-font-size-lg

Tier 2 — Component-specific tokens (defined per component, fallback to global tokens):

/* Card-specific overrides */
--wc-card-bg: var(--wc-color-surface);
--wc-card-padding: var(--wc-spacing-lg);
--wc-card-radius: var(--wc-radius-md);
--wc-card-shadow: var(--wc-shadow-sm);
--wc-card-border-color: var(--wc-color-border);

The fallback chain — every CSS custom property reference in the component includes three levels:

:host {
/*
* Resolution order:
* 1. Component token: --wc-card-bg (most specific, set by consumer)
* 2. Global token: --wc-color-surface (theme-level)
* 3. Hardcoded fallback: #ffffff (last resort, no tokens loaded)
*/
background: var(--wc-card-bg, var(--wc-color-surface, #ffffff));
}

5.8.2 Documenting Theme Options in Storybook

Section titled “5.8.2 Documenting Theme Options in Storybook”

Every component must include a “Theming” section in its Storybook MDX documentation page:

## Theming
### CSS Custom Properties
| Property | Default | Description |
|---|---|---|
| `--wc-card-bg` | `var(--wc-color-surface)` | Card background color |
| `--wc-card-padding` | `var(--wc-spacing-lg)` | Card internal padding |
| `--wc-card-radius` | `var(--wc-radius-md)` | Card border radius |
| `--wc-card-shadow` | `var(--wc-shadow-sm)` | Card elevation shadow |
### Drupal Theme Override Example
```css
/* In your Drupal theme's CSS */
:root {
/* Override card appearance site-wide */
--wc-card-radius: 0; /* Sharp corners */
--wc-card-shadow: none; /* Flat design */
--wc-card-padding: 2rem; /* More padding */
}
/* Override for a specific context */
.sidebar wc-content-card {
--wc-card-padding: 1rem;
}

5.8.3 CSS Shadow Parts for Escape-Hatch Styling

Section titled “5.8.3 CSS Shadow Parts for Escape-Hatch Styling”

CSS Shadow Parts (::part()) provide targeted styling access to internal component elements. Use parts when CSS custom properties alone cannot achieve the needed customization.

When to use ::part():

Use CaseUse Part?Reason
Change background colorNoCSS custom property is sufficient
Change font sizeNoCSS custom property is sufficient
Add a pseudo-element (::before)YesCannot add pseudo-elements via custom properties
Change text-transformYesNo custom property covers this
Override display or positionYesStructural changes need part access

Exposing parts:

render() {
return html`
<div part="card" class="card card--${this.variant}">
<div part="header" class="card__header">
<slot name="media"></slot>
</div>
<div part="body" class="card__body">
<h3 part="heading" class="card__heading">${this.heading}</h3>
<slot></slot>
</div>
<div part="footer" class="card__footer">
<slot name="actions"></slot>
</div>
</div>
`;
}

Consumer usage:

/* Drupal theme CSS */
wc-content-card::part(heading) {
text-transform: uppercase;
letter-spacing: 0.05em;
}
wc-content-card[variant="featured"]::part(header) {
min-height: 200px;
}

Rules for parts:

  1. Only expose parts on stable internal elements that are unlikely to be refactored
  2. Document every part in JSDoc using @csspart tags
  3. Part names should be semantic (header, body, footer) not structural (div-1, wrapper)
  4. Never expose more than 5-7 parts per component — too many parts defeats encapsulation

5.8.4 Shadow DOM vs. Light DOM Decision Guide

Section titled “5.8.4 Shadow DOM vs. Light DOM Decision Guide”

Most components should use Shadow DOM (Lit’s default). Use Light DOM only when you need Drupal’s global CSS to style the component’s internal content.

Use Shadow DOM (default) when:

  • The component has its own internal structure (cards, buttons, inputs, navigation)
  • The component needs style encapsulation to prevent Drupal theme CSS bleed
  • The component has CSS custom properties for theming
  • The component is a design system primitive

Use Light DOM when:

  • The component wraps prose content from Drupal’s WYSIWYG editor (CKEditor)
  • The component is a layout wrapper that passes through child content
  • The component needs Drupal’s admin CSS to apply (e.g., contextual links, inline editing)

Light DOM component pattern:

@customElement('wc-prose')
export class WcProse extends LitElement {
/**
* Renders to light DOM so Drupal CKEditor content inherits
* the theme's typography styles.
*/
protected createRenderRoot(): HTMLElement {
return this;
}
static styles = css`
/* These styles apply in the light DOM */
:host {
display: block;
max-width: var(--wc-prose-max-width, 720px);
margin: 0 auto;
}
`;
render() {
return html`<slot></slot>`;
}
}

Light DOM tradeoff: The component’s internal styles are not encapsulated. Drupal theme CSS can affect the component’s children, which may be desired (for prose content) or undesired (for structural elements). Choose deliberately.

Every component must support dark mode through the token system. No component should have hardcoded colors.

Rules:

  1. Never use raw color values in component CSS. Always reference tokens.
  2. Test every component in light, dark, and high-contrast modes in Storybook.
  3. Dark mode works automatically when the token stylesheet is loaded and data-theme="dark" is set on an ancestor element.
  4. The component never needs to know which mode is active.

Storybook verification:

Every story should include a dark mode variant:

export const DarkMode: Story = {
args: { heading: 'Dark Mode Card', summary: 'This card in dark mode.' },
decorators: [
(story) => html`<div data-theme="dark" style="padding: 2rem; background: var(--wc-color-surface);">${story()}</div>`,
],
};

Quick Reference: Component API Design Checklist

Section titled “Quick Reference: Component API Design Checklist”

Use this as a one-page reference when designing a new component’s public API.

ATTRIBUTES
- kebab-case names heading, author-name, publish-date
- Map to Drupal field names Document in JSDoc: "Maps to field_summary"
- String/Number for simple data heading="Title", read-time="5"
- Boolean as presence/absence featured (not featured="true")
- Enum with default variant="default" | "featured" | "compact"
- JSON only for collections items='[...]' (menu items, options)
- Every attribute has a default value Renders without any attributes set
SLOTS
- Default slot for primary content <p>Body text</p>
- Named slots for specific areas <img slot="media" ...>
- Fallback content in every slot <slot name="media"><div class="placeholder"></div></slot>
- Drupal fields map to slots {{ content.field_media }} -> slot="media"
EVENTS
- wc- prefix wc-card-click, wc-form-submit
- bubbles: true, composed: true Required for Drupal behaviors
- Typed detail payload { href, heading, activationMethod }
- Enough data to act Do not force the listener to query the component
CSS CUSTOM PROPERTIES
- --wc-[component]-[property] --wc-card-bg, --wc-card-radius
- Three-level fallback chain var(--wc-card-bg, var(--wc-color-surface, #fff))
- Documented in JSDoc @cssprop With default value
- Works without token stylesheet Hardcoded fallback renders correctly
ACCESSIBILITY
- Semantic HTML inside Shadow DOM <h3>, <nav>, <button>, <a>
- ARIA attributes on interactive aria-expanded, aria-controls, aria-label
- Keyboard navigation Tab, Enter, Space, Escape, Arrow keys
- Focus visible (3:1 contrast) :focus-visible outline
- 44x44px touch targets min-height: 44px; min-width: 44px
- prefers-reduced-motion transition: none
- forced-colors: active System color keywords
- Error states announced role="alert"
PROGRESSIVE ENHANCEMENT
- :not(:defined) styles Visible content before JS loads
- Slot content as fallback Links work, images show, text visible
- No JavaScript-only rendering Server content always accessible