Drupal Integration Guide
apps/docs/src/content/docs/pre-planning/drupal-guide Click to copy apps/docs/src/content/docs/pre-planning/drupal-guide Audience: Drupal developers, site builders, and front-end themers Last Updated: 2026-02-13 Status: Complete Prerequisites: Drupal 10.3+ or Drupal 11, familiarity with TWIG templating, basic understanding of Web Components
Table of Contents
Section titled “Table of Contents”- Introduction
- Getting Started — Library Installation
- TWIG Integration Patterns
- Node Template Examples
- Field Template Integration
- Views Integration
- Form Integration
- JavaScript Behaviors
- Theming & Customization
- Performance Optimization
- Accessibility Checklist
- Troubleshooting & Debugging
- Upgrade & Maintenance
- Real-World Example Project
1. Introduction
Section titled “1. Introduction”What This Guide Covers
Section titled “What This Guide Covers”This guide is the definitive reference for Drupal teams consuming the @org/wc-library Web Component library. It covers every aspect of integration: installation, TWIG templating, field and views integration, form participation, JavaScript behaviors, theming, performance, accessibility, and maintenance.
Architecture Boundary
Section titled “Architecture Boundary”The Web Component library has zero knowledge of Drupal. It is a standalone package of Lit-based Web Components distributed as ES modules with CSS custom property tokens. Drupal consumes it as a static asset — the same way it consumes any JavaScript library.
+------------------------------------------+ +----------------------------------+| @org/wc-library | | Drupal CMS || | | || - Lit Web Components (ES modules) | | - libraries.yml (asset loading) || - CSS custom properties (tokens) | | - TWIG templates (markup) || - Custom Elements Manifest (API docs) | | - Behaviors (event handling) || - Zero framework dependencies | | - Theme CSS (token overrides) || - Zero Drupal knowledge | | - SDC wrappers (optional) |+------------------------------------------+ +----------------------------------+ | | +--- npm install / CDN / dist copy -----------------+Component Prefix
Section titled “Component Prefix”All components use the wc- prefix (Web Components). Every HTML tag starts with wc-:
<wc-content-card><wc-button><wc-hero-banner><wc-text-input>Token Prefix
Section titled “Token Prefix”All CSS custom properties use the --hds- prefix (Healthcare Design System) for semantic and component tokens:
--hds-color-surface-primary--hds-color-interactive-primary--hds-card-border-radius--hds-button-primary-bg2. Getting Started — Library Installation
Section titled “2. Getting Started — Library Installation”Method 1: npm Package (Recommended)
Section titled “Method 1: npm Package (Recommended)”This is the recommended approach for production deployments. It provides version pinning, integrity checking, and integration with your existing build toolchain.
Step 1: Install the Package
Section titled “Step 1: Install the Package”# From your Drupal theme directorycd web/themes/custom/mytheme
# Install via npmnpm install @org/wc-library
# Or install a specific versionnpm install @org/wc-library@1.0.0Step 2: Configure libraries.yml
Section titled “Step 2: Configure libraries.yml”Declare the Web Component library as a Drupal asset library in your theme:
# Design tokens (CSS custom properties)hds-tokens: version: VERSION css: theme: node_modules/@org/wc-library/dist/styles/tokens.css: minified: true preprocess: false
# Web Components (ES modules)hds-components: version: VERSION js: node_modules/@org/wc-library/dist/index.js: type: module minified: true preprocess: false dependencies: - mytheme/hds-tokensCritical settings explained:
| Setting | Value | Why |
|---|---|---|
type: module | Required | Web Components are distributed as ES modules. Without this, Drupal will try to load them as classic scripts and fail. |
preprocess: false | Required | Prevents Drupal’s asset aggregation from bundling the ES module with other scripts, which would break import statements. |
minified: true | Optimization | Tells Drupal the file is already minified so it does not attempt minification. |
Step 3: Attach the Library
Section titled “Step 3: Attach the Library”Attach the library globally in your theme’s .info.yml:
name: My Healthcare Themetype: themebase theme: falsecore_version_requirement: ^10.3 || ^11libraries: - mytheme/hds-tokens - mytheme/hds-componentsOr attach it selectively in specific templates:
{# Only load components where they are used #}{{ attach_library('mytheme/hds-components') }}Step 4: Verify Installation
Section titled “Step 4: Verify Installation”Clear Drupal’s cache and inspect the page source to confirm both files are loaded:
drush crIn the browser, open DevTools and verify:
tokens.cssis loaded and:rootcontains--hds-*custom propertiesindex.jsis loaded withtype="module"- Custom elements are registered:
document.querySelector('wc-content-card')returns an element (if one exists on the page)
Alternative: Composer + Asset Packagist
Section titled “Alternative: Composer + Asset Packagist”If your project uses Composer exclusively for dependency management, use Asset Packagist to pull npm packages through Composer:
{ "repositories": [ { "type": "composer", "url": "https://asset-packagist.org" } ], "require": { "npm-asset/org--wc-library": "^1.0" }, "extra": { "installer-paths": { "web/libraries/{$name}": [ "type:npm-asset" ] } }}Then update your libraries.yml paths:
hds-components: version: VERSION js: /libraries/org--wc-library/dist/index.js: type: module minified: true preprocess: falseMethod 2: CDN Delivery (Rapid Prototyping)
Section titled “Method 2: CDN Delivery (Rapid Prototyping)”For prototyping, proof-of-concept work, or environments without a Node.js build step, use a CDN.
Self-Hosted CDN (Recommended for Production)
Section titled “Self-Hosted CDN (Recommended for Production)”For healthcare deployments, self-host the assets on your own CDN (e.g., CloudFront, Akamai) for security and availability guarantees:
hds-tokens-cdn: version: VERSION css: theme: https://cdn.yourhealthcare.org/wc-library/1.0.0/styles/tokens.css: type: external minified: true
hds-components-cdn: version: VERSION js: https://cdn.yourhealthcare.org/wc-library/1.0.0/index.js: type: external attributes: type: module crossorigin: anonymous minified: true preprocess: false dependencies: - mytheme/hds-tokens-cdnPublic CDN (Prototyping Only)
Section titled “Public CDN (Prototyping Only)”jsDelivr auto-syncs from npm. Pin to a specific version — never use @latest in production:
hds-components-jsdelivr: version: 1.0.0 css: theme: https://cdn.jsdelivr.net/npm/@org/wc-library@1.0.0/dist/styles/tokens.css: type: external minified: true js: https://cdn.jsdelivr.net/npm/@org/wc-library@1.0.0/dist/index.js: type: external attributes: type: module crossorigin: anonymous minified: true preprocess: falseCDN Fallback Pattern
Section titled “CDN Fallback Pattern”For resilience, implement a fallback that loads from a local copy if the CDN is unreachable:
{# In html.html.twig or a preprocess function #}<script type="module"> import('@org/wc-library').catch(() => { // CDN failed, load local fallback const script = document.createElement('script'); script.type = 'module'; script.src = '/themes/custom/mytheme/dist/wc-library/index.js'; document.head.appendChild(script); });</script>Method 3: Drupal Module Wrapper
Section titled “Method 3: Drupal Module Wrapper”For organizations that prefer Drupal-native package management, wrap the library in a custom module.
Module Structure
Section titled “Module Structure”modules/custom/wc_components/ wc_components.info.yml wc_components.libraries.yml wc_components.module dist/ index.js # Copied from @org/wc-library/dist styles/ tokens.css # Copied from @org/wc-library/dist/styles config/ install/ wc_components.settings.ymlModule Definition
Section titled “Module Definition”name: 'WC Web Components'type: moduledescription: 'Provides the WC Web Component library'package: 'WC'core_version_requirement: ^10.3 || ^11Module Libraries
Section titled “Module Libraries”wc-tokens: version: VERSION css: theme: dist/styles/tokens.css: minified: true preprocess: false
wc-components: version: VERSION js: dist/index.js: type: module minified: true preprocess: false dependencies: - wc_components/wc-tokensModule Hook Implementation
Section titled “Module Hook Implementation”<?php/** * Implements hook_page_attachments(). * * Attach the Web Component library globally. */function wc_components_page_attachments(array &$attachments): void { $config = \Drupal::config('wc_components.settings');
if ($config->get('load_globally')) { $attachments['#attached']['library'][] = 'wc_components/wc-components'; }}
/** * Implements hook_library_info_alter(). * * Allow CDN override via module settings. */function wc_components_library_info_alter(array &$libraries, string $extension): void { if ($extension !== 'wc_components') { return; }
$config = \Drupal::config('wc_components.settings'); $cdn_url = $config->get('cdn_url');
if ($cdn_url && isset($libraries['wc-components'])) { // Replace local paths with CDN URLs $version = $config->get('version') ?? '1.0.0'; $libraries['wc-components']['js'] = [ "{$cdn_url}/{$version}/index.js" => [ 'type' => 'external', 'attributes' => ['type' => 'module', 'crossorigin' => 'anonymous'], 'minified' => TRUE, 'preprocess' => FALSE, ], ]; }}3. TWIG Integration Patterns
Section titled “3. TWIG Integration Patterns”3.1 Basic Usage — Simple Component with Attributes
Section titled “3.1 Basic Usage — Simple Component with Attributes”Web Components are standard HTML elements. Use them in TWIG exactly as you would any HTML tag:
{# Basic button #}<wc-button variant="primary"> Schedule Appointment</wc-button>
{# Badge with dynamic content #}<wc-badge variant="success"> {{ 'Published'|t }}</wc-badge>
{# Icon with attribute #}<wc-icon name="calendar" size="24"></wc-icon>3.2 Attribute Binding with TWIG Variables
Section titled “3.2 Attribute Binding with TWIG Variables”Map Drupal variables to component attributes:
{# Map node data to component attributes #}<wc-content-card heading="{{ node.label }}" summary="{{ node.field_summary.value }}" category="{{ node.field_category.entity.label }}" href="{{ path('entity.node.canonical', {'node': node.id}) }}" publish-date="{{ node.getCreatedTime()|date('c') }}" read-time="{{ node.field_read_time.value }}" variant="{{ node.isPromoted() ? 'featured' : 'default' }}"> {{ content.body }}</wc-content-card>Important TWIG escaping note: TWIG auto-escapes variables by default, which is correct for HTML attribute values. Do not use |raw in attribute values — it introduces XSS vulnerabilities.
3.3 Slot Population — Passing Drupal Field Values into Slots
Section titled “3.3 Slot Population — Passing Drupal Field Values into Slots”Slots are the primary mechanism for injecting Drupal-rendered content into Web Components. Named slots use the slot attribute on child elements:
<wc-article-layout has-sidebar> {# Named slot: breadcrumb #} <nav slot="breadcrumb" aria-label="Breadcrumb"> {{ drupal_block('system_breadcrumb_block') }} </nav>
{# Named slot: hero image #} {% if content.field_hero_image|render|trim is not empty %} <div slot="hero"> {{ content.field_hero_image }} </div> {% endif %}
{# Named slot: author bio #} {% if content.field_author|render|trim is not empty %} <div slot="author"> {{ content.field_author }} </div> {% endif %}
{# Default slot: article body content (no slot attribute needed) #} <div class="article-body"> {{ content.body }} </div>
{# Named slot: sidebar #} {% if content.field_related_articles|render|trim is not empty %} <aside slot="sidebar"> {{ content.field_related_articles }} </aside> {% endif %}
{# Named slot: footer #} <div slot="footer"> {{ content.field_tags }} </div></wc-article-layout>3.4 Conditional Rendering
Section titled “3.4 Conditional Rendering”Show or hide components based on Drupal data:
{# Only render the hero banner if a hero image exists #}{% if node.field_hero_image.entity %} <wc-hero-banner heading="{{ node.label }}" subheading="{{ node.field_subtitle.value }}" image-src="{{ file_url(node.field_hero_image.entity.fileuri) }}" image-alt="{{ node.field_hero_image.alt }}" > {% if node.field_cta_text.value %} <wc-button slot="cta" variant="primary" href="{{ node.field_cta_url.0.url }}"> {{ node.field_cta_text.value }} </wc-button> {% endif %} </wc-hero-banner>{% endif %}
{# Conditionally apply variants #}<wc-alert variant="{{ node.field_urgency.value == 'high' ? 'danger' : 'info' }}" {% if node.field_dismissible.value %}dismissible{% endif %}> {{ content.field_alert_message }}</wc-alert>3.5 Loops — Rendering Multiple Instances from Entities
Section titled “3.5 Loops — Rendering Multiple Instances from Entities”Render collections of components from Drupal entity references or Views results:
{# Render a card grid from a multi-value entity reference field #}{% if node.field_related_articles|length > 0 %} <wc-card-grid columns="3" gap="lg"> {% for item in node.field_related_articles %} {% set related = item.entity %} <wc-content-card heading="{{ related.label }}" summary="{{ related.field_summary.value|length > 150 ? related.field_summary.value|slice(0, 150) ~ '...' : related.field_summary.value }}" category="{{ related.field_category.entity.label }}" href="{{ path('entity.node.canonical', {'node': related.id}) }}" publish-date="{{ related.getCreatedTime()|date('c') }}" variant="compact" ></wc-content-card> {% endfor %} </wc-card-grid>{% endif %}3.6 Variable Extraction — Preprocess Functions
Section titled “3.6 Variable Extraction — Preprocess Functions”For complex data mapping, prepare variables in a preprocess function rather than cluttering TWIG templates:
<?php/** * Implements hook_preprocess_node__article__teaser(). */function mytheme_preprocess_node__article__teaser(array &$variables): void { /** @var \Drupal\node\NodeInterface $node */ $node = $variables['node'];
// Extract and format data for the web component $variables['wc_card'] = [ 'heading' => $node->label(), 'summary' => $node->get('field_summary')->value ?? '', 'category' => $node->get('field_category')->entity?->label() ?? '', 'href' => $node->toUrl()->toString(), 'publish_date' => date('c', $node->getCreatedTime()), 'read_time' => (int) ($node->get('field_read_time')->value ?? 0), 'variant' => $node->isPromoted() ? 'featured' : 'default', 'has_image' => !$node->get('field_media')->isEmpty(), ];
// Build responsive image data if available if (!$node->get('field_media')->isEmpty()) { $media = $node->get('field_media')->entity; if ($media && $media->hasField('field_media_image')) { $image = $media->get('field_media_image')->entity; if ($image) { $variables['wc_card']['image_url'] = \Drupal::service('file_url_generator') ->generateAbsoluteString($image->getFileUri()); $variables['wc_card']['image_alt'] = $media->get('field_media_image')->alt ?? ''; } } }}Then the TWIG template becomes clean:
{# node--article--teaser.html.twig #}{{ attach_library('mytheme/hds-components') }}
<wc-content-card heading="{{ wc_card.heading }}" summary="{{ wc_card.summary }}" category="{{ wc_card.category }}" href="{{ wc_card.href }}" publish-date="{{ wc_card.publish_date }}" read-time="{{ wc_card.read_time }}" variant="{{ wc_card.variant }}"> {% if wc_card.has_image %} <img slot="media" src="{{ wc_card.image_url }}" alt="{{ wc_card.image_alt }}" loading="lazy" decoding="async" /> {% endif %}</wc-content-card>3.7 Render Arrays — Using #type = 'html_tag'
Section titled “3.7 Render Arrays — Using #type = 'html_tag'”For programmatic component rendering in PHP (e.g., custom blocks, form elements), use Drupal’s render array system:
<?php
// Build a content card as a render array$build['card'] = [ '#type' => 'html_tag', '#tag' => 'wc-content-card', '#attributes' => [ 'heading' => $node->label(), 'summary' => $node->get('field_summary')->value, 'category' => $category_label, 'href' => $node->toUrl()->toString(), 'publish-date' => date('c', $node->getCreatedTime()), 'read-time' => $node->get('field_read_time')->value, 'variant' => 'default', ], // Child elements go into #children or nested render arrays 'image' => [ '#type' => 'html_tag', '#tag' => 'img', '#attributes' => [ 'slot' => 'media', 'src' => $image_url, 'alt' => $image_alt, 'loading' => 'lazy', ], ], 'body' => [ '#markup' => $body_html, ],];4. Node Template Examples
Section titled “4. Node Template Examples”4.1 Article Teaser (node--article--teaser.html.twig)
Section titled “4.1 Article Teaser (node--article--teaser.html.twig)”This is the most common integration point. The article teaser maps Drupal’s article content type to the wc-content-card component.
{#/** * @file * Theme override for article nodes in teaser view mode. * * Maps Drupal article fields to the wc-content-card Web Component. * * Required fields: * - title (core) * - field_summary (Plain text, required) * - field_category (Term reference, required) * * Optional fields: * - field_media (Media reference -- hero image) * - field_read_time (Integer -- estimated reading time) * - field_tags (Term reference, multi-value) * * Component docs: [Storybook URL]/?path=/docs/organisms-content-card--docs */#}
{{ attach_library('mytheme/hds-components') }}
{# ---- Variable extraction ---- #}{%- 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.getCreatedTime()|date('c') -%}{%- set card_read_time = content.field_read_time|render|striptags|trim -%}{%- set card_variant = is_promoted ? 'featured' : 'default' -%}
{# ---- Component output ---- #}<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 #} {% if content.field_media|render|trim is not empty %} <div slot="media"> {{ content.field_media }} </div> {% endif %}
{# Actions slot: tags #} {% if content.field_tags|render|trim is not empty %} <div slot="actions"> {{ content.field_tags }} </div> {% endif %}</wc-content-card>Responsive image handling: If field_media is configured with a responsive image style in Drupal, the rendered output will include <picture> with <source> elements and proper srcset attributes. This works correctly inside the slot="media" — the browser handles responsive image selection regardless of Shadow DOM boundaries.
4.2 Article Full (node--article--full.html.twig)
Section titled “4.2 Article Full (node--article--full.html.twig)”The full article view uses multiple Web Components for the page layout:
{#/** * @file * Theme override for article nodes in full view mode. * * Uses wc-article-layout for page structure, with named slots * for hero, breadcrumb, author, sidebar, and footer content. */#}
{{ attach_library('mytheme/hds-components') }}
{# ---- Hero Section ---- #}{% if content.field_hero_image|render|trim is not empty %} <wc-hero-banner heading="{{ label[0]['#title']|default(node.label) }}" subheading="{{ content.field_subtitle|render|striptags|trim }}" > <div slot="media"> {{ content.field_hero_image }} </div> </wc-hero-banner>{% endif %}
{# ---- Article Layout ---- #}<wc-article-layout has-sidebar="{{ content.field_related_articles|render|trim is not empty ? 'true' : '' }}"> {# Breadcrumb slot #} <nav slot="breadcrumb" aria-label="{{ 'Breadcrumb'|t }}"> {{ drupal_block('system_breadcrumb_block') }} </nav>
{# Author slot #} {% if content.field_author|render|trim is not empty %} <div slot="author"> {% set author = node.field_author.entity %} <wc-media-object> {% if author.field_avatar.entity %} <wc-avatar slot="media" src="{{ file_url(author.field_avatar.entity.fileuri) }}" alt="{{ author.label }}" size="md" ></wc-avatar> {% endif %} <div> <strong>{{ author.label }}</strong> <time datetime="{{ node.getCreatedTime()|date('c') }}"> {{ node.getCreatedTime()|date('F j, Y') }} </time> {% if node.field_read_time.value %} <span>· {{ node.field_read_time.value }} {{ 'min read'|t }}</span> {% endif %} </div> </wc-media-object> </div> {% endif %}
{# Default slot: article body #} <div class="article-content"> {{ content.body }}
{# Inline components for rich content sections #} {% if content.field_faq|render|trim is not empty %} <wc-accordion> {% for item in node.field_faq %} <wc-accordion-item heading="{{ item.field_question.value }}"> {{ item.field_answer.value|raw }} </wc-accordion-item> {% endfor %} </wc-accordion> {% endif %} </div>
{# Sidebar slot: related content #} {% if content.field_related_articles|render|trim is not empty %} <div slot="sidebar"> <h3>{{ 'Related Articles'|t }}</h3> {% for item in node.field_related_articles %} {% set related = item.entity %} <wc-content-card heading="{{ related.label }}" href="{{ path('entity.node.canonical', {'node': related.id}) }}" variant="compact" ></wc-content-card> {% endfor %} </div> {% endif %}
{# Footer slot: tags and share #} <div slot="footer"> {% if content.field_tags|render|trim is not empty %} <div class="article-tags"> {{ content.field_tags }} </div> {% endif %} </div></wc-article-layout>4.3 Landing Page with Paragraphs (node--landing-page--full.html.twig)
Section titled “4.3 Landing Page with Paragraphs (node--landing-page--full.html.twig)”Landing pages use Drupal Paragraphs with dynamic component selection based on paragraph type:
{#/** * @file * Theme override for landing page nodes. * * Iterates over Paragraphs and maps each paragraph type * to the appropriate Web Component. */#}
{{ attach_library('mytheme/hds-components') }}
<wc-page-layout> {# Hero section from landing page fields #} {% if content.field_hero|render|trim is not empty %} <div slot="hero"> {{ content.field_hero }} </div> {% endif %}
{# Main content: iterate over paragraph items #} {{ content.field_sections }}</wc-page-layout>Then create paragraph-specific templates that map to Web Components:
{# paragraph--hero-banner.html.twig #}<wc-hero-banner heading="{{ content.field_heading|render|striptags|trim }}" subheading="{{ content.field_subheading|render|striptags|trim }}" alignment="{{ content.field_alignment|render|striptags|trim|default('center') }}"> {% if content.field_background_image|render|trim is not empty %} <div slot="media"> {{ content.field_background_image }} </div> {% endif %}
{% if content.field_cta|render|trim is not empty %} <div slot="cta"> {{ content.field_cta }} </div> {% endif %}</wc-hero-banner>{# paragraph--card-grid.html.twig #}{% set columns = content.field_columns|render|striptags|trim|default('3') %}
<wc-card-grid columns="{{ columns }}" gap="lg"> {% for item in paragraph.field_cards %} {% set card = item.entity %} <wc-content-card heading="{{ card.field_heading.value }}" summary="{{ card.field_summary.value }}" href="{{ card.field_link.0.url }}" variant="{{ card.field_featured.value ? 'featured' : 'default' }}" > {% if card.field_image.entity %} <img slot="media" src="{{ file_url(card.field_image.entity.fileuri) }}" alt="{{ card.field_image.alt }}" loading="lazy" /> {% endif %} </wc-content-card> {% endfor %}</wc-card-grid>{# paragraph--faq-accordion.html.twig #}<wc-accordion> {% for item in paragraph.field_faq_items %} {% set faq = item.entity %} <wc-accordion-item heading="{{ faq.field_question.value }}"> {{ faq.field_answer.value|raw }} </wc-accordion-item> {% endfor %}</wc-accordion>{# paragraph--tabbed-content.html.twig #}<wc-tabs> {% for item in paragraph.field_tabs %} {% set tab = item.entity %} <wc-tab-item label="{{ tab.field_tab_label.value }}"> {{ tab.field_tab_content.value|raw }} </wc-tab-item> {% endfor %}</wc-tabs>4.4 Layout Builder Integration
Section titled “4.4 Layout Builder Integration”If your site uses Drupal’s Layout Builder, Web Components can be used within layout sections and blocks:
{# layout--twocol-section.html.twig -- Layout Builder section override #}<wc-page-layout> <div slot="main"> {{ content.first }} </div> <div slot="sidebar"> {{ content.second }} </div></wc-page-layout>For custom Layout Builder blocks:
<?phpnamespace Drupal\mytheme_blocks\Plugin\Block;
use Drupal\Core\Block\BlockBase;
/** * @Block( * id = "wc_featured_content", * admin_label = @Translation("WC Featured Content"), * category = @Translation("WC Components") * ) */class FeaturedContentBlock extends BlockBase {
public function build(): array { return [ '#type' => 'html_tag', '#tag' => 'wc-card-grid', '#attributes' => [ 'columns' => '3', 'gap' => 'lg', ], '#attached' => [ 'library' => ['mytheme/hds-components'], ], 'cards' => $this->buildCards(), ]; }
private function buildCards(): array { // Query and build card render arrays // ... }
}5. Field Template Integration
Section titled “5. Field Template Integration”5.1 Field Template Override
Section titled “5.1 Field Template Override”Override field templates to wrap field values in Web Components:
{# field--node--field-category--article.html.twig #}{#/** * Renders the article category field as a WC badge. */#}{% for item in items %} <wc-badge variant="default" size="sm"> {{ item.content }} </wc-badge>{% endfor %}{# field--node--field-tags--article.html.twig #}{#/** * Renders the article tags field as a row of WC tags. */#}<div class="tag-list" style="display: flex; gap: 0.5rem; flex-wrap: wrap;"> {% for item in items %} <wc-tag> {{ item.content }} </wc-tag> {% endfor %}</div>5.2 Multiple Value Fields
Section titled “5.2 Multiple Value Fields”For multi-value fields, loop over items and render each as a component:
{# field--node--field-testimonials--landing-page.html.twig #}{#/** * Renders testimonials as a grid of WC cards. */#}{% if items|length > 0 %} <wc-card-grid columns="{{ items|length >= 3 ? '3' : items|length }}" gap="md"> {% for item in items %} {% set testimonial = item.content['#paragraph'] %} <wc-content-card variant="default"> <blockquote> {{ testimonial.field_quote.value }} </blockquote> <div slot="actions"> <cite>{{ testimonial.field_author_name.value }}</cite> </div> </wc-content-card> {% endfor %} </wc-card-grid>{% endif %}5.3 Custom Field Formatters (PHP)
Section titled “5.3 Custom Field Formatters (PHP)”For reusable, configurable mappings, create a field formatter plugin:
<?phpnamespace Drupal\mytheme_formatters\Plugin\Field\FieldFormatter;
use Drupal\Core\Field\FieldItemListInterface;use Drupal\Core\Field\FormatterBase;use Drupal\Core\Form\FormStateInterface;
/** * Plugin implementation for rendering entity references as WC content cards. * * @FieldFormatter( * id = "wc_content_card", * label = @Translation("WC Content Card"), * field_types = {"entity_reference"} * ) */class WcContentCardFormatter extends FormatterBase {
/** * {@inheritdoc} */ public static function defaultSettings(): array { return [ 'variant' => 'default', 'show_image' => TRUE, 'show_category' => TRUE, 'show_read_time' => TRUE, 'summary_length' => 150, ] + parent::defaultSettings(); }
/** * {@inheritdoc} */ public function settingsForm(array $form, FormStateInterface $form_state): array { $elements = parent::settingsForm($form, $form_state);
$elements['variant'] = [ '#type' => 'select', '#title' => $this->t('Card variant'), '#options' => [ 'default' => $this->t('Default'), 'featured' => $this->t('Featured'), 'compact' => $this->t('Compact'), ], '#default_value' => $this->getSetting('variant'), ];
$elements['show_image'] = [ '#type' => 'checkbox', '#title' => $this->t('Show hero image'), '#default_value' => $this->getSetting('show_image'), ];
$elements['summary_length'] = [ '#type' => 'number', '#title' => $this->t('Summary max length'), '#default_value' => $this->getSetting('summary_length'), '#min' => 50, '#max' => 500, ];
return $elements; }
/** * {@inheritdoc} */ public function viewElements(FieldItemListInterface $items, $langcode): array { $elements = [];
foreach ($items as $delta => $item) { $entity = $item->entity; if (!$entity) { continue; }
$summary = ''; if ($entity->hasField('field_summary')) { $summary = $entity->get('field_summary')->value ?? ''; $max = (int) $this->getSetting('summary_length'); if (mb_strlen($summary) > $max) { $summary = mb_substr($summary, 0, $max) . '...'; } }
$attributes = [ 'heading' => $entity->label(), 'summary' => $summary, 'href' => $entity->toUrl()->toString(), 'variant' => $this->getSetting('variant'), 'publish-date' => date('c', $entity->getCreatedTime()), ];
if ($this->getSetting('show_category') && $entity->hasField('field_category')) { $attributes['category'] = $entity->get('field_category')->entity?->label() ?? ''; }
if ($this->getSetting('show_read_time') && $entity->hasField('field_read_time')) { $attributes['read-time'] = (string) ($entity->get('field_read_time')->value ?? '0'); }
$elements[$delta] = [ '#type' => 'html_tag', '#tag' => 'wc-content-card', '#attributes' => $attributes, '#attached' => [ 'library' => ['mytheme/hds-components'], ], ];
// Add image to media slot if configured if ($this->getSetting('show_image') && $entity->hasField('field_media')) { $media = $entity->get('field_media')->entity; if ($media && $media->hasField('field_media_image')) { $image = $media->get('field_media_image')->entity; if ($image) { $elements[$delta]['image'] = [ '#type' => 'html_tag', '#tag' => 'img', '#attributes' => [ 'slot' => 'media', 'src' => \Drupal::service('file_url_generator') ->generateAbsoluteString($image->getFileUri()), 'alt' => $media->get('field_media_image')->alt ?? '', 'loading' => 'lazy', 'decoding' => 'async', ], ]; } } } }
return $elements; }
}When to use field formatters vs. template overrides:
| Approach | Use When |
|---|---|
| Template override | One-off field rendering, simple mapping, team prefers TWIG |
| Field formatter | Reusable across content types, needs admin configuration, complex data transformation |
6. Views Integration
Section titled “6. Views Integration”6.1 Custom Views Templates — Card Grid
Section titled “6.1 Custom Views Templates — Card Grid”Override the Views unformatted output to render results as a wc-card-grid:
{# views-view-unformatted--latest-articles.html.twig #}{#/** * Renders the "Latest Articles" view as a WC card grid. * Each row is rendered by its own row template (see below). */#}{{ attach_library('mytheme/hds-components') }}
{% if rows|length > 0 %} <wc-card-grid columns="3" gap="lg"> {% for row in rows %} {{ row.content }} {% endfor %} </wc-card-grid>{% else %} <wc-alert variant="info"> {{ 'No articles found.'|t }} </wc-alert>{% endif %}Then override the row template to render each result as a content card:
{# views-view-fields--latest-articles.html.twig #}{#/** * Renders a single view row as a wc-content-card. * * Available fields (configured in the Views UI): * - fields.title * - fields.field_summary * - fields.field_category * - fields.view_node (URL) * - fields.created * - fields.field_read_time * - fields.field_media */#}
<wc-content-card heading="{{ fields.title.content|striptags|trim }}" summary="{{ fields.field_summary.content|striptags|trim }}" category="{{ fields.field_category.content|striptags|trim }}" href="{{ fields.view_node.content|striptags|trim }}" publish-date="{{ fields.created.content|striptags|trim }}" read-time="{{ fields.field_read_time.content|striptags|trim }}"> {% if fields.field_media.content|trim is not empty %} <div slot="media"> {{ fields.field_media.content }} </div> {% endif %}</wc-content-card>6.2 Views Field Rewrite
Section titled “6.2 Views Field Rewrite”For simpler integrations, use Views’ “Rewrite results” feature to populate component attributes using replacement patterns.
In the Views UI:
- Add all needed fields (title, summary, category, URL, etc.)
- Exclude all fields from display except one
- On the last field, use “Rewrite results” with this custom text:
<wc-content-card heading="{{ title }}" summary="{{ field_summary }}" category="{{ field_category }}" href="{{ view_node }}" publish-date="{{ created }}"></wc-content-card>Limitation: Views field rewrite cannot handle conditional slot content (e.g., only showing an image if one exists). For conditional logic, use a custom Views row template instead.
6.3 Views Pager Integration
Section titled “6.3 Views Pager Integration”Override the Views pager to use the wc-pagination component:
{# views-mini-pager.html.twig #}{% if items.previous or items.next %} <wc-pagination current-page="{{ items.current }}" {% if items.previous %}previous-url="{{ items.previous.url }}"{% endif %} {% if items.next %}next-url="{{ items.next.url }}"{% endif %} ></wc-pagination>{% endif %}6.4 Custom Views Style Plugin
Section titled “6.4 Custom Views Style Plugin”For maximum control, create a Views style plugin that outputs Web Component markup:
<?phpnamespace Drupal\mytheme_views\Plugin\views\style;
use Drupal\views\Plugin\views\style\StylePluginBase;use Drupal\Core\Form\FormStateInterface;
/** * Views style plugin that renders results in a wc-card-grid. * * @ViewsStyle( * id = "wc_card_grid", * title = @Translation("WC Card Grid"), * help = @Translation("Renders view results as a WC card grid Web Component."), * theme = "views_view_wc_card_grid", * display_types = {"normal"} * ) */class WcCardGrid extends StylePluginBase {
protected $usesRowPlugin = TRUE; protected $usesGrouping = FALSE;
/** * {@inheritdoc} */ protected function defineOptions(): array { $options = parent::defineOptions(); $options['columns'] = ['default' => '3']; $options['gap'] = ['default' => 'lg']; $options['card_variant'] = ['default' => 'default']; return $options; }
/** * {@inheritdoc} */ public function buildOptionsForm(&$form, FormStateInterface $form_state): void { parent::buildOptionsForm($form, $form_state);
$form['columns'] = [ '#type' => 'select', '#title' => $this->t('Columns'), '#options' => ['1' => '1', '2' => '2', '3' => '3', '4' => '4'], '#default_value' => $this->options['columns'], ];
$form['gap'] = [ '#type' => 'select', '#title' => $this->t('Gap size'), '#options' => [ 'sm' => $this->t('Small'), 'md' => $this->t('Medium'), 'lg' => $this->t('Large'), ], '#default_value' => $this->options['gap'], ];
$form['card_variant'] = [ '#type' => 'select', '#title' => $this->t('Card variant'), '#options' => [ 'default' => $this->t('Default'), 'featured' => $this->t('Featured'), 'compact' => $this->t('Compact'), ], '#default_value' => $this->options['card_variant'], ]; }
}With the corresponding TWIG template:
{# views-view-wc-card-grid.html.twig #}{{ attach_library('mytheme/hds-components') }}
<wc-card-grid columns="{{ options.columns }}" gap="{{ options.gap }}"> {% for row in rows %} {{ row.content }} {% endfor %}</wc-card-grid>7. Form Integration
Section titled “7. Form Integration”7.1 Form API Render Arrays
Section titled “7.1 Form API Render Arrays”Use Drupal’s render array system to output Web Component form elements:
<?php
/** * Builds a contact form using WC Web Components. */function mytheme_contact_form(array &$form, FormStateInterface $form_state): array { $form['#attached']['library'][] = 'mytheme/hds-components';
$form['name'] = [ '#type' => 'html_tag', '#tag' => 'wc-text-input', '#attributes' => [ 'name' => 'name', 'label' => t('Full Name'), 'required' => 'true', 'help-text' => t('Enter your first and last name'), 'error-message' => t('Please enter your full name'), ], ];
$form['email'] = [ '#type' => 'html_tag', '#tag' => 'wc-text-input', '#attributes' => [ 'name' => 'email', 'label' => t('Email Address'), 'type' => 'email', 'required' => 'true', 'help-text' => t('We will use this to respond to your inquiry'), ], ];
$form['phone'] = [ '#type' => 'html_tag', '#tag' => 'wc-text-input', '#attributes' => [ 'name' => 'phone', 'label' => t('Phone Number'), 'type' => 'tel', 'help-text' => t('Optional -- for callback requests'), ], ];
$form['message'] = [ '#type' => 'html_tag', '#tag' => 'wc-textarea', '#attributes' => [ 'name' => 'message', 'label' => t('Your Message'), 'required' => 'true', 'rows' => '5', 'help-text' => t('Describe how we can help you'), ], ];
$form['submit'] = [ '#type' => 'html_tag', '#tag' => 'wc-button', '#attributes' => [ 'type' => 'submit', 'variant' => 'primary', ], '#value' => t('Send Message'), ];
return $form;}7.2 Form Alter Hooks
Section titled “7.2 Form Alter Hooks”Replace standard Drupal form elements with Web Components via hook_form_alter():
<?php
/** * Implements hook_form_alter(). * * Replace standard Drupal form elements with WC Web Components * on the user login form. */function mytheme_form_user_login_form_alter(array &$form, FormStateInterface $form_state): void { $form['#attached']['library'][] = 'mytheme/hds-components';
// Replace username field $form['name']['#theme_wrappers'] = []; $form['name']['#type'] = 'html_tag'; $form['name']['#tag'] = 'wc-text-input'; $form['name']['#attributes'] = [ 'name' => 'name', 'label' => t('Username'), 'required' => 'true', 'autocomplete' => 'username', ];
// Replace password field $form['pass']['#theme_wrappers'] = []; $form['pass']['#type'] = 'html_tag'; $form['pass']['#tag'] = 'wc-text-input'; $form['pass']['#attributes'] = [ 'name' => 'pass', 'label' => t('Password'), 'type' => 'password', 'required' => 'true', 'autocomplete' => 'current-password', ];
// Replace submit button $form['actions']['submit']['#type'] = 'html_tag'; $form['actions']['submit']['#tag'] = 'wc-button'; $form['actions']['submit']['#attributes'] = [ 'type' => 'submit', 'variant' => 'primary', ]; $form['actions']['submit']['#value'] = t('Log in');}7.3 Form-Associated Custom Elements
Section titled “7.3 Form-Associated Custom Elements”The wc-text-input, wc-textarea, wc-select, wc-checkbox, and wc-radio components are form-associated custom elements. They use the ElementInternals API to participate natively in HTML forms.
This means:
- They are included in
FormDataautomatically (no hidden input tricks) - They support native validation via the Constraint Validation API
- They report validity state to the parent
<form> - They respond to
form.reset()calls - They are accessible to assistive technology as form controls
How it works with Drupal forms:
<form method="post" action="/contact"> <!-- This Web Component participates in the form natively --> <wc-text-input name="full_name" label="Full Name" required ></wc-text-input>
<wc-text-input name="email" label="Email" type="email" required ></wc-text-input>
<!-- On form submit, FormData contains: full_name=..., email=... --> <wc-button type="submit" variant="primary"> Submit </wc-button></form>Browser support: ElementInternals is supported in all evergreen browsers (Chrome 77+, Firefox 93+, Safari 16.4+). For Drupal sites that must support older Safari versions, the element-internals-polyfill package provides a drop-in polyfill.
7.4 Server-Side Validation Integration
Section titled “7.4 Server-Side Validation Integration”Since Web Components submit values through standard FormData, Drupal’s server-side validation works without modification. The name attribute on the component maps directly to $form_state->getValue('name').
For displaying server-side validation errors in the Web Components:
<?php
/** * Implements hook_form_alter() for adding server-side error display. */function mytheme_form_alter(array &$form, FormStateInterface $form_state): void { if ($form_state->hasAnyErrors()) { $errors = $form_state->getErrors(); foreach ($errors as $field_name => $message) { // Set error attributes on the corresponding Web Component if (isset($form[$field_name]) && $form[$field_name]['#tag'] ?? '' === 'wc-text-input') { $form[$field_name]['#attributes']['error-message'] = (string) $message; } } }}8. JavaScript Behaviors
Section titled “8. JavaScript Behaviors”8.1 Basic Behavior Structure
Section titled “8.1 Basic Behavior Structure”Drupal behaviors are the standard mechanism for initializing JavaScript in Drupal. They work correctly with Web Components:
/** * @file * Drupal behaviors for WC Web Component event handling. */
(function (Drupal, once) { 'use strict';
/** * Track content card clicks for analytics. * * @type {Drupal~behavior} */ Drupal.behaviors.wcCardAnalytics = { attach(context) { const cards = once('wc-card-analytics', 'wc-content-card', context);
cards.forEach((card) => { card.addEventListener('wc-card-click', (event) => { const { href, heading, keyboard } = event.detail;
// Google Analytics 4 if (typeof gtag === 'function') { gtag('event', 'content_card_click', { content_title: heading, content_url: href, interaction_method: keyboard ? 'keyboard' : 'mouse', }); }
// Matomo / Piwik if (typeof _paq !== 'undefined') { _paq.push(['trackEvent', 'Content', 'Card Click', heading]); } }); }); }, };
})(Drupal, once);Register this behavior as a Drupal library:
wc-behaviors: version: VERSION js: js/wc-behaviors.js: {} dependencies: - core/drupal - core/once - mytheme/hds-components8.2 Event Delegation Patterns
Section titled “8.2 Event Delegation Patterns”For components that may be added dynamically (AJAX, BigPipe), use event delegation on a parent container:
(function (Drupal) { 'use strict';
/** * Handle accordion toggle events via delegation. * * Uses event delegation so dynamically added accordions * are handled without re-attachment. * * @type {Drupal~behavior} */ Drupal.behaviors.wcAccordionTracking = { attach(context) { // Attach once to the document body, not individual components const containers = once('wc-accordion-delegate', 'body', context);
containers.forEach((body) => { body.addEventListener('wc-accordion-toggle', (event) => { // event.composed is true, so it crosses shadow DOM boundaries const detail = event.detail; const accordion = event.target.closest('wc-accordion');
if (typeof gtag === 'function') { gtag('event', 'accordion_toggle', { section_title: detail.heading, is_open: detail.open, accordion_id: accordion?.id ?? 'unknown', }); } }); }); }, };
})(Drupal);8.3 Integrating with Drupal AJAX Framework
Section titled “8.3 Integrating with Drupal AJAX Framework”Handle Web Component events that trigger Drupal AJAX operations:
(function (Drupal, once) { 'use strict';
/** * Load more content when the pagination component fires a page change event. * * @type {Drupal~behavior} */ Drupal.behaviors.wcPaginationAjax = { attach(context) { const pagers = once('wc-pagination-ajax', 'wc-pagination', context);
pagers.forEach((pager) => { pager.addEventListener('wc-page-change', async (event) => { const { page } = event.detail; const targetSelector = pager.dataset.ajaxTarget; const viewName = pager.dataset.viewName; const viewDisplay = pager.dataset.viewDisplay;
if (!targetSelector || !viewName) return;
// Set loading state on the pagination component pager.setAttribute('loading', '');
try { // Use Drupal's AJAX framework to load the next page const response = await fetch( `/views/ajax?view_name=${viewName}&view_display_id=${viewDisplay}&page=${page}`, { headers: { 'X-Requested-With': 'XMLHttpRequest', }, } );
const data = await response.json();
// Find and update the target container const target = document.querySelector(targetSelector); if (target) { // Process AJAX commands from Drupal data.forEach((command) => { if (command.command === 'insert' && command.data) { target.innerHTML = command.data; // Re-attach Drupal behaviors to new content Drupal.attachBehaviors(target); } }); } } finally { pager.removeAttribute('loading'); } }); }); }, };
})(Drupal, once);8.4 Search Bar Integration
Section titled “8.4 Search Bar Integration”Connect the wc-search-bar component to Drupal’s Search API:
(function (Drupal, once) { 'use strict';
/** * Handle search bar submissions and autocomplete. * * @type {Drupal~behavior} */ Drupal.behaviors.wcSearch = { attach(context) { const searchBars = once('wc-search', 'wc-search-bar', context);
searchBars.forEach((searchBar) => { // Handle search submission searchBar.addEventListener('wc-search-submit', (event) => { const { query } = event.detail; if (query.trim()) { window.location.href = `/search?keys=${encodeURIComponent(query)}`; } });
// Handle autocomplete requests (if the component supports it) searchBar.addEventListener('wc-search-input', async (event) => { const { query } = event.detail; if (query.length < 3) return;
try { const response = await fetch( `/api/search-suggestions?q=${encodeURIComponent(query)}` ); const suggestions = await response.json();
// Pass suggestions back to the component searchBar.suggestions = suggestions; } catch (error) { console.error('Search autocomplete failed:', error); } }); }); }, };
})(Drupal, once);8.5 Dynamic Content Handling — BigPipe and AJAX
Section titled “8.5 Dynamic Content Handling — BigPipe and AJAX”When Drupal inserts content dynamically (via BigPipe, AJAX, or Turbo-like patterns), Web Components self-initialize as soon as they are inserted into the DOM. The Custom Elements API handles this natively — there is no need to manually “initialize” Web Components.
However, Drupal behaviors attached to Web Components need re-attachment:
(function (Drupal) { 'use strict';
/** * Ensure behaviors are attached after BigPipe content delivery. * * @type {Drupal~behavior} */ Drupal.behaviors.wcBigPipeHandler = { attach(context) { // This behavior automatically runs when BigPipe delivers content // because Drupal calls attachBehaviors() on each BigPipe placeholder // after replacement. // // Web Components in the new content will: // 1. Self-register (customElements.define is global) // 2. Self-render (connectedCallback fires on DOM insertion) // 3. Have behaviors attached (this function) // // No additional initialization needed. }, };
})(Drupal);8.6 Modal Dialog Integration
Section titled “8.6 Modal Dialog Integration”Connect Web Component modals with Drupal’s dialog system:
(function (Drupal, once) { 'use strict';
/** * Handle modal open/close events and manage focus. * * @type {Drupal~behavior} */ Drupal.behaviors.wcModals = { attach(context) { const triggers = once('wc-modal-trigger', '[data-wc-modal-target]', context);
triggers.forEach((trigger) => { trigger.addEventListener('click', (event) => { event.preventDefault(); const modalId = trigger.dataset.wcModalTarget; const modal = document.getElementById(modalId);
if (modal && modal.tagName === 'WC-MODAL') { modal.setAttribute('open', ''); } }); });
// Listen for modal close events const modals = once('wc-modal-events', 'wc-modal', context); modals.forEach((modal) => { modal.addEventListener('wc-modal-close', () => { // Return focus to the trigger element const triggerId = modal.dataset.triggeredBy; if (triggerId) { document.getElementById(triggerId)?.focus(); }
// Track modal close in analytics if (typeof gtag === 'function') { gtag('event', 'modal_close', { modal_id: modal.id, }); } }); }); }, };
})(Drupal, once);9. Theming & Customization
Section titled “9. Theming & Customization”9.1 CSS Custom Property Overrides
Section titled “9.1 CSS Custom Property Overrides”The primary customization mechanism is overriding CSS custom properties at the :root level in your Drupal theme. This requires zero changes to the component library.
Create a token overrides file in your theme:
:root { /* ============================================ * Brand Color Overrides * Override the semantic tokens to match your * healthcare organization's brand colors. * ============================================ */
/* Primary interactive color (links, primary buttons) */ --hds-color-interactive-primary: #0e7c61; /* Healthcare teal */ --hds-color-interactive-primary-hover: #0a5e49; --hds-color-interactive-primary-active: #084a3a;
/* Feedback colors (adjust for your brand) */ --hds-color-feedback-success: #16a34a; --hds-color-feedback-danger: #b91c1c; --hds-color-feedback-warning: #d97706;
/* ============================================ * Typography Overrides * ============================================ */ --hds-font-family-body: 'Source Sans 3', system-ui, sans-serif; --hds-font-family-heading: 'Merriweather', Georgia, serif;
/* ============================================ * Spacing Overrides * ============================================ */ --hds-space-inset-md: 1.25rem; /* Slightly more generous padding */
/* ============================================ * Component-Level Overrides * ============================================ */
/* Cards: sharper corners for this brand */ --hds-card-border-radius: 4px; --hds-card-padding: 2rem;
/* Buttons: rounder for this brand */ --hds-button-primary-border-radius: 999px; /* Pill shape */}Register this as a Drupal library that loads after the token stylesheet:
hds-token-overrides: version: VERSION css: theme: css/token-overrides.css: {} dependencies: - mytheme/hds-tokens9.2 Dark Mode Overrides
Section titled “9.2 Dark Mode Overrides”Override dark mode token values for your brand:
[data-theme="dark"] { /* Brand-specific dark mode adjustments */ --hds-color-interactive-primary: #34d399; /* Lighter teal for dark bg */ --hds-color-interactive-primary-hover: #6ee7b7; --hds-color-surface-primary: #0f172a; /* Deep navy instead of neutral */ --hds-color-surface-secondary: #1e293b;}Control the data-theme attribute in your theme’s html.html.twig:
{# html.html.twig #}<!DOCTYPE html><html{{ html_attributes }}> <head> {# Prevent flash of incorrect theme #} <script> (function() { var stored = localStorage.getItem('hds-theme-preference'); if (stored === 'dark' || stored === 'light') { document.documentElement.setAttribute('data-theme', stored); } })(); </script> <head-placeholder token="{{ placeholder_token }}"> <title>{{ head_title|safe_join(' | ') }}</title> <css-placeholder token="{{ placeholder_token }}"> <js-placeholder token="{{ placeholder_token }}"> </head> <body{{ attributes }}> {{ page_top }} {{ page }} {{ page_bottom }} <js-bottom-placeholder token="{{ placeholder_token }}"> </body></html>9.3 CSS Parts Styling
Section titled “9.3 CSS Parts Styling”For cases where CSS custom properties do not provide sufficient control, use the ::part() pseudo-element to style specific internal elements:
/* Style the card header area for featured cards */wc-content-card[variant="featured"]::part(header) { min-height: 200px; background: linear-gradient( 135deg, var(--hds-color-interactive-primary), var(--hds-color-feedback-success) );}
/* Style the card body in sidebar contexts */.sidebar wc-content-card::part(body) { padding: var(--hds-space-inset-sm);}
/* Override button label styling for a specific page */.hero-section wc-button::part(label) { text-transform: uppercase; letter-spacing: 0.05em;}When to use Parts vs. custom properties:
| Approach | Use When |
|---|---|
| CSS custom properties | Changing values (colors, spacing, sizes, fonts) |
::part() | Changing structural CSS (display, grid, flex, text-transform, pseudo-elements) |
Important: Only use ::part() on parts that are explicitly documented in the component’s API. The available parts are listed in the Custom Elements Manifest and Storybook documentation.
9.4 Per-Component Overrides with Selectors
Section titled “9.4 Per-Component Overrides with Selectors”Override tokens for specific component instances using CSS selectors:
/* Override card tokens only in the sidebar */.sidebar { --hds-card-padding: var(--hds-space-inset-sm); --hds-card-border-radius: var(--hds-radius-sm); --hds-card-shadow: none;}
/* Override button tokens in the hero section */.hero-section { --hds-button-primary-bg: #ffffff; --hds-button-primary-text: var(--hds-color-interactive-primary); --hds-button-font-size: var(--hds-font-size-lg);}
/* Override specific component instance by ID */#emergency-alert { --hds-color-feedback-danger: #ff0000;}This works because CSS custom properties inherit through the DOM tree. Setting a property on a parent element cascades it to all descendant Web Components within that subtree.
9.5 Theme Settings Integration
Section titled “9.5 Theme Settings Integration”Map Drupal theme settings to CSS custom properties dynamically:
<?php/** * Implements hook_preprocess_html(). * * Injects theme settings as CSS custom properties. */function mytheme_preprocess_html(array &$variables): void { $config = theme_get_setting('mytheme');
$overrides = [];
// Map theme settings to CSS custom properties if ($primary_color = $config['primary_color'] ?? NULL) { $overrides[] = "--hds-color-interactive-primary: {$primary_color}"; }
if ($heading_font = $config['heading_font'] ?? NULL) { $overrides[] = "--hds-font-family-heading: '{$heading_font}', serif"; }
if ($card_radius = $config['card_border_radius'] ?? NULL) { $overrides[] = "--hds-card-border-radius: {$card_radius}"; }
if (!empty($overrides)) { $css = ':root { ' . implode('; ', $overrides) . '; }'; $variables['#attached']['html_head'][] = [ [ '#type' => 'html_tag', '#tag' => 'style', '#value' => $css, ], 'mytheme_token_overrides', ]; }}9.6 Single Directory Component (SDC) Wrappers
Section titled “9.6 Single Directory Component (SDC) Wrappers”For teams using Drupal’s SDC system (Drupal 10.3+ / 11), wrap Web Components as SDCs for Drupal-native component discovery:
themes/custom/mytheme/components/ content-card/ content-card.component.yml content-card.twigname: Content Carddescription: 'Healthcare content card backed by the wc-content-card Web Component'status: stableprops: type: object required: - heading properties: heading: type: string title: Heading summary: type: string title: Summary text category: type: string title: Category label href: type: string title: Link URL format: uri publish_date: type: string title: Publish date (ISO 8601) read_time: type: integer title: Read time in minutes variant: type: string title: Visual variant enum: [default, featured, compact] default: default image_url: type: string title: Hero image URL image_alt: type: string title: Hero image alt textslots: default: title: Card body contentlibraryOverrides: dependencies: - mytheme/hds-components{# content-card.twig #}<wc-content-card heading="{{ heading }}" {% if summary %}summary="{{ summary }}"{% endif %} {% if category %}category="{{ category }}"{% endif %} {% if href %}href="{{ href }}"{% endif %} {% if publish_date %}publish-date="{{ publish_date }}"{% endif %} {% if read_time %}read-time="{{ read_time }}"{% endif %} variant="{{ variant|default('default') }}"> {% if image_url %} <img slot="media" src="{{ image_url }}" alt="{{ image_alt|default('') }}" loading="lazy" /> {% endif %}
{{ children }}</wc-content-card>Then use the SDC in other TWIG templates:
{# Using the SDC from another template #}{% include 'mytheme:content-card' with { heading: node.label, summary: node.field_summary.value, category: node.field_category.entity.label, href: path('entity.node.canonical', {'node': node.id}), variant: 'featured',} %}10. Performance Optimization
Section titled “10. Performance Optimization”10.1 Asset Loading Strategy
Section titled “10.1 Asset Loading Strategy”Preload Critical Components
Section titled “Preload Critical Components”For components that appear above the fold on every page, preload the module:
{# In html.html.twig <head> section #}<link rel="modulepreload" href="/themes/custom/mytheme/node_modules/@org/wc-library/dist/index.js"><link rel="preload" href="/themes/custom/mytheme/node_modules/@org/wc-library/dist/styles/tokens.css" as="style">Per-Component Imports (Tree Shaking)
Section titled “Per-Component Imports (Tree Shaking)”If your site only uses a subset of components, import individual components instead of the full bundle:
# Load only the card and button componentshds-cards: version: VERSION js: node_modules/@org/wc-library/dist/components/card/index.js: type: module minified: true preprocess: false node_modules/@org/wc-library/dist/components/button/index.js: type: module minified: true preprocess: false css: theme: node_modules/@org/wc-library/dist/styles/tokens.css: minified: true preprocess: false10.2 Drupal Asset Aggregation
Section titled “10.2 Drupal Asset Aggregation”Critical: Do not let Drupal aggregate ES module files with classic scripts. The preprocess: false setting in libraries.yml prevents this.
In your Drupal performance settings:
/admin/config/development/performance- Aggregate CSS files: Yes (token CSS can be aggregated safely)
- Aggregate JavaScript files: Yes, but the
preprocess: falseflag on WC library files excludes them from aggregation
10.3 Lazy Loading Below-the-Fold Components
Section titled “10.3 Lazy Loading Below-the-Fold Components”For components that appear below the fold, use dynamic imports triggered by Intersection Observer:
(function (Drupal, once) { 'use strict';
/** * Lazy-load heavy Web Components when they enter the viewport. * * @type {Drupal~behavior} */ Drupal.behaviors.wcLazyLoad = { attach(context) { const lazyComponents = once( 'wc-lazy-load', '[data-wc-lazy]', context );
if (!lazyComponents.length) return;
const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const el = entry.target; const componentModule = el.dataset.wcLazy;
// Dynamically import the component module import(componentModule).then(() => { // Component is now registered and will render observer.unobserve(el); }); } }); }, { rootMargin: '200px', // Start loading 200px before viewport } );
lazyComponents.forEach((el) => observer.observe(el)); }, };
})(Drupal, once);Usage in TWIG:
{# Lazy-load the media gallery component #}<wc-media-gallery data-wc-lazy="/themes/custom/mytheme/node_modules/@org/wc-library/dist/components/media-gallery/index.js"> {# Fallback content shown before component loads #} <div class="gallery-fallback"> {% for image in images %} <img src="{{ image.url }}" alt="{{ image.alt }}" loading="lazy" /> {% endfor %} </div></wc-media-gallery>10.4 Caching Strategies
Section titled “10.4 Caching Strategies”Browser Cache Headers
Section titled “Browser Cache Headers”Configure your web server to set appropriate cache headers for WC library assets:
# Apache .htaccess<FilesMatch "\.(js|css)$"> <IfModule mod_headers.c> # Cache for 1 year (assets are versioned via package version) Header set Cache-Control "public, max-age=31536000, immutable" </IfModule></FilesMatch># Nginxlocation ~* \.(js|css)$ { expires 1y; add_header Cache-Control "public, immutable";}Drupal Cache Tags and Contexts
Section titled “Drupal Cache Tags and Contexts”Web Components render client-side, so they do not affect Drupal’s render cache for the component markup itself. However, the data passed as attributes is subject to Drupal’s caching:
<?php
// When building component attributes from entity data,// add appropriate cache metadata:$build['card'] = [ '#type' => 'html_tag', '#tag' => 'wc-content-card', '#attributes' => [ 'heading' => $node->label(), // ... other attributes ], '#cache' => [ 'tags' => $node->getCacheTags(), 'contexts' => ['url.path', 'user.roles'], 'max-age' => 3600, ],];Service Worker Considerations
Section titled “Service Worker Considerations”If your site uses a service worker (e.g., via the Drupal PWA module), precache the Web Component library files:
const WC_ASSETS = [ '/themes/custom/mytheme/node_modules/@org/wc-library/dist/index.js', '/themes/custom/mytheme/node_modules/@org/wc-library/dist/styles/tokens.css',];
self.addEventListener('install', (event) => { event.waitUntil( caches.open('wc-library-v1').then((cache) => cache.addAll(WC_ASSETS)) );});10.5 Performance Checklist
Section titled “10.5 Performance Checklist”- Token CSS is loaded in
<head>(render-blocking is acceptable for tokens) - Component JS uses
type: moduleandpreprocess: false - Above-the-fold components are preloaded with
<link rel="modulepreload"> - Below-the-fold components use lazy loading via Intersection Observer
- Images in component slots use
loading="lazy"anddecoding="async" - Browser cache headers set to 1 year with immutable for versioned assets
- Drupal’s CSS aggregation is enabled (token CSS can be aggregated)
- No duplicate component registrations (check console for warnings)
- Font preloading configured for custom web fonts
- Service worker precaches WC library assets (if using PWA)
11. Accessibility Checklist
Section titled “11. Accessibility Checklist”This checklist covers Drupal-specific accessibility considerations when integrating the Web Component library. The components themselves are built to WCAG 2.1 AA (targeting AAA for color contrast), but correct integration is the Drupal team’s responsibility.
Screen Reader Testing
Section titled “Screen Reader Testing”- Test all pages with NVDA (Windows) and VoiceOver (macOS/iOS)
- Verify component headings are announced with correct level (h1-h6)
- Verify form labels are announced when inputs receive focus
- Verify error messages are announced via
role="alert"(live region) - Verify card click events announce the card heading
- Verify modal dialogs announce their title when opened
- Verify accordion state changes are announced (expanded/collapsed)
Keyboard Navigation
Section titled “Keyboard Navigation”- Tab order follows logical reading order through all components
- All interactive components are reachable via Tab key
- Cards with
hrefattribute are focusable and activatable with Enter - Accordion items toggle with Enter and Space keys
- Tab panels switch with Arrow keys
- Modals trap focus within the dialog when open
- Escape key closes modals and returns focus to trigger
- Focus indicator is visible (3:1 contrast against adjacent colors)
Drupal-Specific Concerns
Section titled “Drupal-Specific Concerns”-
{{ attributes }}is passed through to Web Components where appropriate - Drupal’s system status messages are accessible alongside Web Components
- Drupal’s admin toolbar does not interfere with component focus management
- Layout Builder preview mode renders components correctly
- Contextual links (pencil icon) remain accessible on component wrappers
Form Accessibility
Section titled “Form Accessibility”- Every
wc-text-inputhas a visible, non-emptylabelattribute - Required fields have
requiredattribute (not just visual indicator) - Error messages use
error-messageattribute (rendersrole="alert") - Help text uses
help-textattribute (connected viaaria-describedby) - Form submission errors focus the first invalid field
- Server-side validation errors display in the Web Component UI
ARIA and Semantics
Section titled “ARIA and Semantics”- ARIA attributes from Drupal templates are not duplicated by components
- Landmark regions (
nav,main,aside) are not duplicated - Heading hierarchy is maintained (h1 > h2 > h3, no skipping)
- Decorative images use
alt=""in slots (not omitted entirely) - Tables have proper
<caption>oraria-label
Focus Management After Dynamic Updates
Section titled “Focus Management After Dynamic Updates”- After AJAX content replacement, focus moves to the new content region
- BigPipe progressive rendering does not steal focus from user interaction
- After Views AJAX pager loads, focus moves to the first new result
- After form AJAX validation, focus moves to the error summary or first error
Visual and Display Modes
Section titled “Visual and Display Modes”- Components render correctly at 200% browser zoom
- Components tolerate text spacing overrides (WCAG 1.4.12 bookmarklet test)
- High contrast mode (
data-theme="high-contrast-light"/high-contrast-dark") works - Windows High Contrast Mode / Forced Colors renders all controls visibly
-
prefers-reduced-motiondisables all transitions and animations
12. Troubleshooting & Debugging
Section titled “12. Troubleshooting & Debugging”12.1 Components Not Rendering
Section titled “12.1 Components Not Rendering”Symptom: The page shows the raw HTML tags (e.g., <wc-content-card>) instead of rendered components.
Possible causes and solutions:
| Cause | Diagnosis | Solution |
|---|---|---|
| Library not loaded | Check Network tab for 404 on index.js | Verify path in libraries.yml, run drush cr |
Missing type: module | Console error: “Unexpected token ‘export‘“ | Add type: module to the js entry in libraries.yml |
Wrong preprocess setting | ES module bundled with other scripts, syntax error | Set preprocess: false on the js entry |
| Library not attached | No index.js request in Network tab | Add attach_library() in TWIG or global attachment in .info.yml |
| CORS issue (CDN) | Console error: “CORS policy” | Add CORS headers to CDN, or use self-hosted assets |
| Module not supported | Older browser (IE11) | Web Components require modern browsers; add polyfills if needed |
Quick diagnostic script:
// Paste in DevTools console to check component registrationconst tags = ['wc-content-card', 'wc-button', 'wc-hero-banner'];tags.forEach(tag => { const registered = customElements.get(tag); console.log(`${tag}: ${registered ? 'registered' : 'NOT REGISTERED'}`);});12.2 Attributes Not Updating
Section titled “12.2 Attributes Not Updating”Symptom: Component attributes show stale data or do not reflect Drupal field values.
| Cause | Diagnosis | Solution |
|---|---|---|
| Drupal render cache | Change not reflected after field edit | Clear cache: drush cr or configure cache tags correctly |
| TWIG auto-escaping | HTML entities in attribute values | This is correct behavior; do not use ` |
| Attribute vs. property | Setting a JS property but checking the HTML attribute | Use reflect: true on the component property (library concern), or set via attribute in TWIG |
| Whitespace in value | Leading/trailing spaces from ` | render |
| Boolean attributes | attribute="false" still truthy in HTML | For boolean attributes, conditionally include/exclude: {% if value %}attribute{% endif %} |
Boolean attribute pattern:
{# WRONG: <wc-button disabled="false"> -- "false" is truthy in HTML #}<wc-button {{ is_disabled ? 'disabled' : '' }}>
{# RIGHT: attribute present = true, absent = false #}<wc-button {% if is_disabled %}disabled{% endif %}>12.3 Events Not Firing
Section titled “12.3 Events Not Firing”Symptom: Event listeners attached in Drupal behaviors never trigger.
| Cause | Diagnosis | Solution |
|---|---|---|
| Listener attached before component defined | Race condition on page load | Use customElements.whenDefined() or listen on a parent |
| Behavior context not applied | once() filtered out the element | Check that the CSS selector matches, use DevTools to verify |
| Shadow DOM event not composed | Event does not bubble past shadow boundary | Library bug: event should use composed: true. Report to library team. |
| Event name mismatch | Typo in event name | Check CEM or Storybook docs for exact event name |
| AJAX content not re-attached | New elements after AJAX lack behaviors | Ensure Drupal.attachBehaviors() is called after content insertion |
Event debugging:
// Temporarily log all custom events from Web Componentsdocument.addEventListener('wc-card-click', (e) => console.log('card-click', e.detail));document.addEventListener('wc-form-submit', (e) => console.log('form-submit', e.detail));
// Or listen for ALL events on a specific componentconst card = document.querySelector('wc-content-card');const events = ['wc-card-click'];events.forEach(name => { card.addEventListener(name, (e) => console.log(name, e.detail));});12.4 Styling Not Applied
Section titled “12.4 Styling Not Applied”Symptom: Components appear unstyled or CSS overrides have no effect.
| Cause | Diagnosis | Solution |
|---|---|---|
| Token CSS not loaded | No --hds-* properties on :root | Verify hds-tokens library is attached, check Network tab |
| CSS load order wrong | Overrides load before tokens | Ensure override library depends on token library in libraries.yml |
| CSS custom properties not inherited | Property defined on wrong element | Set on :root or on a parent element of the component |
::part() on non-existent part | No effect, no error | Check CEM/Storybook for available part names |
| Drupal CSS aggregation conflict | Aggregated CSS changes load order | Set preprocess: false on override stylesheets if order matters |
| Specificity issue | Token override not taking effect | Check that the selector specificity of your override is sufficient; :root should be sufficient for top-level overrides |
Token inspection tool:
// Check all HDS tokens currently resolved on an elementfunction inspectTokens(element) { const computed = getComputedStyle(element); const tokens = {}; for (const prop of computed) { if (prop.startsWith('--hds-')) { tokens[prop] = computed.getPropertyValue(prop).trim(); } } console.table(tokens);}
// UsageinspectTokens(document.documentElement); // Check :root tokensinspectTokens(document.querySelector('wc-content-card')); // Check component tokens12.5 Flash of Unstyled Content (FOUC)
Section titled “12.5 Flash of Unstyled Content (FOUC)”Symptom: Components briefly appear unstyled or with raw content before rendering.
Solutions:
-
Load token CSS in
<head>(synchronous, render-blocking):hds-tokens:css:theme:tokens.css: {} # Loaded in <head> by default -
Use
:not(:defined)CSS to hide unregistered elements:mytheme.css wc-content-card:not(:defined),wc-hero-banner:not(:defined),wc-button:not(:defined) {/* Hide component until registered and rendered */opacity: 0;visibility: hidden;} -
Or use
:not(:defined)with a placeholder skeleton:wc-content-card:not(:defined) {display: block;min-height: 200px;background: var(--hds-color-surface-secondary);border-radius: var(--hds-card-border-radius, 8px);animation: pulse 1.5s ease-in-out infinite;}@keyframes pulse {0%, 100% { opacity: 0.6; }50% { opacity: 1; }}@media (prefers-reduced-motion: reduce) {wc-content-card:not(:defined) {animation: none;opacity: 0.8;}}
13. Upgrade & Maintenance
Section titled “13. Upgrade & Maintenance”13.1 Semantic Versioning Strategy
Section titled “13.1 Semantic Versioning Strategy”The @org/wc-library package follows strict semantic versioning:
| Version Type | Change Examples | Action Required |
|---|---|---|
| Patch (1.0.x) | Bug fixes, typo corrections in docs, internal refactoring | Update directly. No template changes needed. |
| Minor (1.x.0) | New components added, new optional attributes on existing components, new token values | Update directly. Review changelog for new features you may want to adopt. |
| Major (x.0.0) | Removed components, renamed attributes, changed event names, removed tokens, changed default behavior | Read migration guide. Plan template updates. Test in staging. |
13.2 Upgrade Workflow
Section titled “13.2 Upgrade Workflow”Step 1: Read the Changelog
Section titled “Step 1: Read the Changelog”Before any upgrade, read the changelog:
# View changelogcat node_modules/@org/wc-library/CHANGELOG.md
# Or check the Storybook for the new versionStep 2: Update the Package
Section titled “Step 2: Update the Package”# Update to latest within semver rangenpm update @org/wc-library
# Or update to a specific versionnpm install @org/wc-library@1.2.0
# For major version upgradesnpm install @org/wc-library@2.0.0Step 3: Clear Caches
Section titled “Step 3: Clear Caches”drush crStep 4: Test in Staging
Section titled “Step 4: Test in Staging”Run your full test suite against the staging environment:
# Visual regression test (if you have one)npx backstop test
# Accessibility auditnpx pa11y-ci --config .pa11yci.json
# Manual smoke test: check all major page templatesStep 5: Update Templates (Major Versions Only)
Section titled “Step 5: Update Templates (Major Versions Only)”For major version upgrades, the migration guide will list all breaking changes. Common patterns:
{# Before (v1) #}<wc-content-card title="{{ node.label }}">
{# After (v2) -- attribute renamed #}<wc-content-card heading="{{ node.label }}">13.3 Version Pinning Strategy
Section titled “13.3 Version Pinning Strategy”Recommended: Pin to a specific minor version range in package.json:
{ "dependencies": { "@org/wc-library": "~1.2.0" }}This allows patch updates (1.2.1, 1.2.2) but requires explicit action for minor updates (1.3.0). For critical healthcare environments, exact pinning may be preferred:
{ "dependencies": { "@org/wc-library": "1.2.0" }}13.4 Rollback Strategy
Section titled “13.4 Rollback Strategy”If an upgrade causes issues in production:
-
Revert the package version:
Terminal window npm install @org/wc-library@1.1.0 # Previous version -
Clear all caches:
Terminal window drush cr -
Purge CDN cache (if using CDN delivery):
Terminal window # Invalidate CDN cache for the WC library pathaws cloudfront create-invalidation --distribution-id XXXXX \--paths "/wc-library/*" -
Report the issue to the library team with:
- Browser and version
- Drupal version
- Component name and attributes used
- Console errors (if any)
- Screenshot or screen recording
13.5 Monitoring Component Health
Section titled “13.5 Monitoring Component Health”Set up monitoring for Web Component-related issues in production:
(function () { 'use strict';
// Monitor for Web Component registration failures window.addEventListener('error', (event) => { if (event.message && event.message.includes('customElements')) { // Report to your error tracking service if (typeof Sentry !== 'undefined') { Sentry.captureException(new Error('Web Component registration failure'), { extra: { message: event.message, filename: event.filename, }, }); } } });
// Monitor for unresolved custom elements after page load window.addEventListener('load', () => { setTimeout(() => { const unresolved = document.querySelectorAll(':not(:defined)'); if (unresolved.length > 0) { const tags = [...new Set([...unresolved].map((el) => el.tagName.toLowerCase()))]; console.warn('Unresolved custom elements:', tags);
// Report to monitoring if (typeof gtag === 'function') { gtag('event', 'wc_unresolved', { component_tags: tags.join(','), page_path: window.location.pathname, }); } } }, 5000); // Check after 5 seconds });})();14. Real-World Example Project
Section titled “14. Real-World Example Project””Regional Health Partners” — A Complete Integration
Section titled “”Regional Health Partners” — A Complete Integration”This section provides a complete, working example of a healthcare blog site integrating the @org/wc-library Web Component library with Drupal.
14.1 Site Overview
Section titled “14.1 Site Overview”- Content types: Article, Author, Category (taxonomy)
- Views: Latest Articles (listing page), Related Articles (sidebar block)
- Theme: Custom theme
rhp_theme(Regional Health Partners) - Components used:
wc-content-card,wc-card-grid,wc-hero-banner,wc-article-layout,wc-breadcrumb,wc-pagination,wc-button,wc-badge,wc-avatar,wc-accordion,wc-search-bar
14.2 Theme File Structure
Section titled “14.2 Theme File Structure”themes/custom/rhp_theme/ rhp_theme.info.yml rhp_theme.libraries.yml rhp_theme.theme # Preprocess functions package.json node_modules/ @org/wc-library/ # Installed via npm css/ base.css # Base theme styles token-overrides.css # Brand token overrides dark-mode.css # Dark mode token overrides component-overrides.css # Per-component overrides utilities.css # Utility classes for layout js/ wc-behaviors.js # Event handling behaviors wc-theme-toggle.js # Theme toggle behavior templates/ html.html.twig # HTML wrapper with theme flash prevention page.html.twig # Page layout node--article--teaser.html.twig # Article teaser -> wc-content-card node--article--full.html.twig # Article full -> wc-article-layout field--node--field-category.html.twig # Category field -> wc-badge field--node--field-tags.html.twig # Tags field -> wc-tag views-view-unformatted--latest-articles.html.twig views-view-fields--latest-articles.html.twig block--system-branding-block.html.twig components/ # SDC wrappers (optional) content-card/ content-card.component.yml content-card.twig14.3 Core Configuration Files
Section titled “14.3 Core Configuration Files”rhp_theme.info.yml
Section titled “rhp_theme.info.yml”name: 'Regional Health Partners'type: themedescription: 'Healthcare content hub theme using WC Web Components'core_version_requirement: ^10.3 || ^11base theme: false
libraries: - rhp_theme/global - rhp_theme/hds-tokens - rhp_theme/hds-components - rhp_theme/token-overrides - rhp_theme/behaviors
regions: header: Header content: Content sidebar: Sidebar footer: Footerrhp_theme.libraries.yml
Section titled “rhp_theme.libraries.yml”global: version: VERSION css: base: css/base.css: {} theme: css/utilities.css: {}
hds-tokens: version: VERSION css: theme: node_modules/@org/wc-library/dist/styles/tokens.css: minified: true preprocess: false
hds-components: version: VERSION js: node_modules/@org/wc-library/dist/index.js: type: module minified: true preprocess: false dependencies: - rhp_theme/hds-tokens
token-overrides: version: VERSION css: theme: css/token-overrides.css: {} css/dark-mode.css: {} css/component-overrides.css: {} dependencies: - rhp_theme/hds-tokens
behaviors: version: VERSION js: js/wc-behaviors.js: {} js/wc-theme-toggle.js: {} dependencies: - core/drupal - core/once - rhp_theme/hds-components14.4 Token Override File
Section titled “14.4 Token Override File”:root { /* Regional Health Partners brand colors */ --hds-color-interactive-primary: #0e7c61; /* Healthcare teal */ --hds-color-interactive-primary-hover: #0a6650; --hds-color-interactive-primary-active: #085340; --hds-color-feedback-success: #16a34a; --hds-color-feedback-danger: #dc2626;
/* Typography: Merriweather for headings, Source Sans for body */ --hds-font-family-heading: 'Merriweather', Georgia, serif; --hds-font-family-body: 'Source Sans 3', system-ui, sans-serif;
/* Slightly more rounded cards */ --hds-card-border-radius: 12px;
/* Pill-shaped buttons */ --hds-button-primary-border-radius: 999px;}14.5 Preprocess Functions
Section titled “14.5 Preprocess Functions”<?php/** * Implements hook_preprocess_node__article__teaser(). */function rhp_theme_preprocess_node__article__teaser(array &$variables): void { /** @var \Drupal\node\NodeInterface $node */ $node = $variables['node'];
$variables['wc'] = [ 'heading' => $node->label(), 'summary' => $node->get('field_summary')->value ?? '', 'category' => $node->get('field_category')->entity?->label() ?? '', 'href' => $node->toUrl()->toString(), 'publish_date' => date('c', $node->getCreatedTime()), 'read_time' => (int) ($node->get('field_read_time')->value ?? 0), 'variant' => $node->isPromoted() ? 'featured' : 'default', ];}
/** * Implements hook_preprocess_node__article__full(). */function rhp_theme_preprocess_node__article__full(array &$variables): void { /** @var \Drupal\node\NodeInterface $node */ $node = $variables['node'];
$variables['wc'] = [ 'heading' => $node->label(), 'subtitle' => $node->get('field_subtitle')->value ?? '', 'date' => date('F j, Y', $node->getCreatedTime()), 'date_iso' => date('c', $node->getCreatedTime()), 'read_time' => (int) ($node->get('field_read_time')->value ?? 0), 'has_sidebar' => !$node->get('field_related_articles')->isEmpty(), ];
// Author data $author = $node->get('field_author')->entity; if ($author) { $variables['wc']['author'] = [ 'name' => $author->label(), 'bio' => $author->get('field_bio')->value ?? '', ];
if (!$author->get('field_avatar')->isEmpty()) { $avatar = $author->get('field_avatar')->entity; if ($avatar) { $variables['wc']['author']['avatar_url'] = \Drupal::service('file_url_generator') ->generateAbsoluteString($avatar->getFileUri()); } } }}14.6 Key Templates
Section titled “14.6 Key Templates”node--article--teaser.html.twig
Section titled “node--article--teaser.html.twig”{{ attach_library('rhp_theme/hds-components') }}
<wc-content-card heading="{{ wc.heading }}" summary="{{ wc.summary }}" category="{{ wc.category }}" href="{{ wc.href }}" publish-date="{{ wc.publish_date }}" read-time="{{ wc.read_time }}" variant="{{ wc.variant }}"> {% if content.field_media|render|trim is not empty %} <div slot="media"> {{ content.field_media }} </div> {% endif %}</wc-content-card>node--article--full.html.twig
Section titled “node--article--full.html.twig”{{ attach_library('rhp_theme/hds-components') }}
{# Hero #}{% if content.field_hero_image|render|trim is not empty %} <wc-hero-banner heading="{{ wc.heading }}" subheading="{{ wc.subtitle }}" > <div slot="media">{{ content.field_hero_image }}</div> </wc-hero-banner>{% endif %}
{# Article layout #}<wc-article-layout {% if wc.has_sidebar %}has-sidebar{% endif %}> {# Breadcrumb #} <nav slot="breadcrumb" aria-label="{{ 'Breadcrumb'|t }}"> {{ drupal_block('system_breadcrumb_block') }} </nav>
{# Author #} {% if wc.author is defined %} <div slot="author"> <wc-media-object> {% if wc.author.avatar_url is defined %} <wc-avatar slot="media" src="{{ wc.author.avatar_url }}" alt="{{ wc.author.name }}" size="md" ></wc-avatar> {% endif %} <div> <strong>{{ wc.author.name }}</strong> <time datetime="{{ wc.date_iso }}">{{ wc.date }}</time> {% if wc.read_time %} <span>· {{ wc.read_time }} {{ 'min read'|t }}</span> {% endif %} </div> </wc-media-object> </div> {% endif %}
{# Body #} {{ content.body }}
{# Sidebar #} {% if wc.has_sidebar %} <div slot="sidebar"> <h3>{{ 'Related Articles'|t }}</h3> {{ content.field_related_articles }} </div> {% endif %}
{# Footer: tags #} <div slot="footer"> {{ content.field_tags }} </div></wc-article-layout>views-view-unformatted--latest-articles.html.twig
Section titled “views-view-unformatted--latest-articles.html.twig”{{ attach_library('rhp_theme/hds-components') }}
{% if rows|length > 0 %} <wc-card-grid columns="3" gap="lg"> {% for row in rows %} {{ row.content }} {% endfor %} </wc-card-grid>{% endif %}14.7 Behavior File
Section titled “14.7 Behavior File”/** * @file * Regional Health Partners -- WC component behaviors. */
(function (Drupal, once) { 'use strict';
/** * Track content card clicks in Google Analytics 4. */ Drupal.behaviors.rhpCardAnalytics = { attach(context) { const cards = once('rhp-card-analytics', 'wc-content-card', context);
cards.forEach((card) => { card.addEventListener('wc-card-click', (event) => { const { href, heading, keyboard } = event.detail;
if (typeof gtag === 'function') { gtag('event', 'content_card_click', { content_title: heading, content_url: href, interaction_method: keyboard ? 'keyboard' : 'mouse', page_section: card.closest('[data-section]')?.dataset.section ?? 'unknown', }); } }); }); }, };
/** * Connect search bar to Drupal Search API. */ Drupal.behaviors.rhpSearch = { attach(context) { const searchBars = once('rhp-search', 'wc-search-bar', context);
searchBars.forEach((searchBar) => { searchBar.addEventListener('wc-search-submit', (event) => { const { query } = event.detail; if (query.trim()) { window.location.href = `/search?keys=${encodeURIComponent(query)}`; } }); }); }, };
/** * Handle accordion tracking. */ Drupal.behaviors.rhpAccordionTracking = { attach(context) { const accordions = once('rhp-accordion', 'wc-accordion', context);
accordions.forEach((accordion) => { accordion.addEventListener('wc-accordion-toggle', (event) => { if (typeof gtag === 'function') { gtag('event', 'faq_toggle', { question: event.detail.heading, action: event.detail.open ? 'expand' : 'collapse', }); } }); }); }, };
})(Drupal, once);14.8 Theme Toggle Behavior
Section titled “14.8 Theme Toggle Behavior”/** * @file * Theme toggle (light/dark mode) behavior. */
(function (Drupal, once) { 'use strict';
Drupal.behaviors.rhpThemeToggle = { attach(context) { const toggles = once('rhp-theme-toggle', '[data-theme-toggle]', context);
toggles.forEach((toggle) => { toggle.addEventListener('click', () => { const current = document.documentElement.getAttribute('data-theme'); const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next); localStorage.setItem('hds-theme-preference', next);
// Update toggle button label for accessibility toggle.setAttribute( 'aria-label', next === 'dark' ? Drupal.t('Switch to light mode') : Drupal.t('Switch to dark mode') ); }); }); }, };
})(Drupal, once);14.9 Integration Verification Checklist
Section titled “14.9 Integration Verification Checklist”Use this checklist to verify your integration is complete and correct:
-
npm install @org/wc-librarycompleted successfully -
libraries.ymldeclares bothhds-tokens(CSS) andhds-components(JS) - Component JS entry uses
type: moduleandpreprocess: false - Libraries attached globally in
.info.ymlor per-template viaattach_library() -
drush crrun after configuration changes - Token CSS loads in
<head>(verify with DevTools Elements tab) -
:roothas--hds-*custom properties (verify with DevTools Computed tab) - Component JS loads with
type="module"attribute (verify in Network tab) - Custom elements are registered (verify with
customElements.get('wc-content-card')in console) - Components render correctly on article teaser pages
- Components render correctly on article full-view pages
- Token overrides (brand colors, typography) are applied
- Dark mode toggle works and persists preference
- Events fire correctly (check Drupal behavior handlers)
- Analytics tracking records component interactions
- Keyboard navigation works through all components
- Screen reader announces component content correctly
- Performance: no FOUC, lazy loading works for below-fold components
- Cache: cleared after every configuration change, appropriate cache tags on render arrays
Appendix A: Complete Attribute-to-Field Mapping Table
Section titled “Appendix A: Complete Attribute-to-Field Mapping Table”This table maps every wc-content-card attribute to its Drupal source and TWIG expression. Equivalent tables for each component are available in the Storybook documentation.
| WC Attribute | Type | Drupal Source | TWIG Expression | Required |
|---|---|---|---|---|
heading | String | Node title | {{ node.label }} | Yes |
summary | String | field_summary (Plain text) | {{ content.field_summary|render|striptags|trim }} | No |
category | String | field_category (Term reference) | {{ node.field_category.entity.label }} | No |
href | String | Node canonical URL | {{ path('entity.node.canonical', {'node': node.id}) }} | No |
publish-date | String (ISO 8601) | Node created timestamp | {{ node.getCreatedTime()|date('c') }} | No |
read-time | Number | field_read_time (Integer) | {{ node.field_read_time.value }} | No |
variant | String (default, featured, compact) | Derived from promotion status | {{ node.isPromoted() ? 'featured' : 'default' }} | No |
Appendix B: Event Reference
Section titled “Appendix B: Event Reference”All custom events emitted by Web Components. These events use bubbles: true and composed: true, meaning they cross Shadow DOM boundaries and can be caught at any ancestor level.
| Event Name | Component | Detail Properties | Use Case |
|---|---|---|---|
wc-card-click | wc-content-card | { href, heading, keyboard } | Analytics, navigation |
wc-form-submit | wc-form | { formData, valid } | Form processing |
wc-input | wc-text-input | { value, name } | Real-time validation |
wc-change | wc-text-input | { value, name } | Value change tracking |
wc-search-submit | wc-search-bar | { query } | Search routing |
wc-search-input | wc-search-bar | { query } | Autocomplete |
wc-accordion-toggle | wc-accordion | { heading, open } | Analytics, state tracking |
wc-tab-change | wc-tabs | { index, label } | Analytics, deep linking |
wc-modal-open | wc-modal | { id } | Focus management |
wc-modal-close | wc-modal | { id } | Focus restoration |
wc-nav-toggle | wc-nav-mobile | { open } | Mobile nav state |
wc-page-change | wc-pagination | { page } | AJAX paging |
Appendix C: CSS Custom Property Quick Reference
Section titled “Appendix C: CSS Custom Property Quick Reference”The most commonly overridden tokens for brand customization:
:root { /* --- Brand Colors --- */ --hds-color-interactive-primary: #YOUR_PRIMARY; --hds-color-interactive-primary-hover: #YOUR_PRIMARY_HOVER; --hds-color-feedback-success: #YOUR_SUCCESS; --hds-color-feedback-danger: #YOUR_DANGER;
/* --- Typography --- */ --hds-font-family-heading: 'Your Heading Font', serif; --hds-font-family-body: 'Your Body Font', sans-serif;
/* --- Card Component --- */ --hds-card-border-radius: 8px; --hds-card-padding: 1.5rem; --hds-card-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
/* --- Button Component --- */ --hds-button-primary-border-radius: 8px; --hds-button-primary-bg: var(--hds-color-interactive-primary); --hds-button-font-size: 0.875rem;
/* --- Focus Ring (critical for accessibility) --- */ --hds-color-border-focus: #3b82f6;}References
Section titled “References”Drupal Documentation
Section titled “Drupal Documentation”- Adding Assets (CSS, JS) to a Drupal Module via libraries.yml
- Drupal Single Directory Components (SDC)
- TWIG Template Reference
- Drupal JavaScript API and Behaviors
- Drupal AJAX Framework
Web Components & Lit
Section titled “Web Components & Lit”- Lit Official Documentation
- Custom Elements Manifest
- ElementInternals (MDN)
- Form-Associated Custom Elements
Integration Resources
Section titled “Integration Resources”- Web Components Drupal Module
- Custom Elements Drupal Module
- Declarative Shadow DOM and the Future of Drupal Theming
- Server Rendering Lit Web Components with Drupal
- Drupal Meets Design Systems (Enterprise UI Consistency)