stave.dev

Common Patterns

Recurring solutions to everyday problems: conditional rendering, dynamic content, forms, lazy init, and Zero Framework integration.

Conditional Rendering

Shadow DOM doesn't have built-in conditionals. Use JavaScript in connectedCallback or attributeChangedCallback to show and hide elements:

class TogglePanel extends ShadowComponent {
    static observables = ["open"];

    connectedCallback() {
        super.connectedCallback();
        this._content = this.shadowRoot.querySelector(".content");
        this._updateVisibility();
    }

    attributeChangedCallback(name, oldValue, newValue) {
        if (name === "open") {
            this._updateVisibility();
        }
    }

    _updateVisibility() {
        if (!this._content) return;
        const isOpen = this.hasAttribute("open");
        this._content.hidden = !isOpen;
    }
}
Use the hidden Attribute

The hidden attribute is the cleanest way to toggle visibility. It applies display: none without needing a CSS class. For transitions, toggle a class and use CSS animations instead.

Dynamic Content Updates

For components that display data which changes at runtime (tables, lists, dashboards), the pattern is: set a data property, then re-render.

class DataList extends ShadowComponent {
    static observables = [];

    connectedCallback() {
        super.connectedCallback();
        this._container = this.shadowRoot.querySelector(".items");
        this._data = [];
    }

    set data(items) {
        this._data = items;
        this._render();
    }

    get data() {
        return this._data;
    }

    _render() {
        if (!this._container) return;
        this._container.innerHTML = "";
        for (const item of this._data) {
            const el = document.createElement("div");
            el.className = "item";
            el.textContent = item.label;
            el.dataset.id = item.id;
            this._container.appendChild(el);
        }
    }
}
customElements.define("data-list", DataList);

// Usage:
const list = document.querySelector("data-list");
list.data = [
    { id: 1, label: "First item" },
    { id: 2, label: "Second item" }
];

This pattern separates "data in" from "rendering" and avoids the overhead of attribute serialization for complex objects.

Form Handling

Components that wrap form inputs need to expose their value and participate in form submission. The pattern uses a hidden input or direct value property:

<template id="star-rating">
    <style>
        :host { display: inline-flex; gap: 0.25rem; cursor: pointer; }
        .star { font-size: 1.5rem; color: var(--border-color); transition: color 0.15s; }
        .star.active { color: var(--accent-primary, #e85d26); }
    </style>
    <span class="star" data-value="1">&#9733;</span>
    <span class="star" data-value="2">&#9733;</span>
    <span class="star" data-value="3">&#9733;</span>
    <span class="star" data-value="4">&#9733;</span>
    <span class="star" data-value="5">&#9733;</span>
</template>

<script>
class StarRating extends ShadowComponent {
    static observables = ["value", "name"];

    connectedCallback() {
        super.connectedCallback();
        this._stars = this.shadowRoot.querySelectorAll(".star");

        this.shadowRoot.addEventListener("click", (e) => {
            const star = e.target.closest("[data-value]");
            if (!star) return;
            this.value = star.dataset.value;
            this._highlight(this.value);
            this.dispatchEvent(new CustomEvent("change", {
                bubbles: true,
                composed: true,
                detail: { value: this.value }
            }));
        });

        // Set initial highlight
        if (this.value) this._highlight(this.value);
    }

    _highlight(upTo) {
        this._stars.forEach(star => {
            star.classList.toggle("active",
                parseInt(star.dataset.value) <= parseInt(upTo)
            );
        });
    }
}
customElements.define("star-rating", StarRating);
</script>

Lazy Initialization

Heavy components (canvas renderers, data visualizations) should defer initialization until they're actually visible:

class HeavyChart extends ShadowComponent {
    static observables = [];

    connectedCallback() {
        super.connectedCallback();
        this._initialized = false;

        // Use IntersectionObserver for lazy init
        this._observer = new IntersectionObserver((entries) => {
            if (entries[0].isIntersecting && !this._initialized) {
                this._init();
                this._initialized = true;
                this._observer.disconnect();
            }
        });
        this._observer.observe(this);
    }

    disconnectedCallback() {
        if (this._observer) {
            this._observer.disconnect();
        }
    }

    _init() {
        // Heavy setup: create canvas, load data, start rendering
        this._canvas = this.shadowRoot.querySelector("canvas");
        this._ctx = this._canvas.getContext("2d");
        this._fetchAndRender();
    }
}
When to Use Lazy Init

Use this pattern for components that are below the fold or inside collapsed sections. For components that are immediately visible, the overhead of IntersectionObserver is not worth it — just initialize in connectedCallback.

Responsive Components

Components can adapt to their own size using ResizeObserver instead of media queries (which only see the viewport):

connectedCallback() {
    super.connectedCallback();

    this._resizeObserver = new ResizeObserver((entries) => {
        const width = entries[0].contentRect.width;
        if (width < 400) {
            this.setAttribute("layout", "compact");
        } else if (width < 700) {
            this.setAttribute("layout", "medium");
        } else {
            this.setAttribute("layout", "full");
        }
    });
    this._resizeObserver.observe(this);
}

disconnectedCallback() {
    this._resizeObserver.disconnect();
}

Then use :host([layout]) selectors in your CSS to adapt the layout:

:host([layout="compact"]) .grid {
    grid-template-columns: 1fr;
}
:host([layout="medium"]) .grid {
    grid-template-columns: 1fr 1fr;
}
:host([layout="full"]) .grid {
    grid-template-columns: 1fr 1fr 1fr;
}

Integration with Zero Framework

In a Zero Framework application, ShadowComponents are used in PHP views. Server-rendered data flows into components through slots:

<!-- Server data flows through slots -->
<user-card>
    <span slot="name"><?php echo htmlspecialchars($this->data['name']); ?></span>
    <span slot="email"><?php echo htmlspecialchars($this->data['email']); ?></span>
</user-card>

<!-- Loop to create multiple components -->
<?php foreach ($this->data['items'] as $item): ?>
<item-card>
    <span slot="title"><?php echo htmlspecialchars($item['title']); ?></span>
    <span slot="description"><?php echo htmlspecialchars($item['description']); ?></span>
</item-card>
<?php endforeach; ?>
Server + Client Roles

Let PHP handle initial data rendering through slots. Let JavaScript handle interactivity. This gives you fast first paint (no JS required for initial content) with full interactivity once the component hydrates.

Component Loading

Zero Framework auto-loads component files by naming convention. Place your .html component files in the module's assets/components/ directory and they are loaded automatically when the module renders.

modules/
  MyModule/
    MyModule.php
    assets/
      css/my-module.css        ← auto-loaded
      js/my-module.js          ← auto-loaded
      components/
        my-widget.html         ← auto-loaded
    view/
      index.php                ← uses <my-widget>

Global components (used across modules) live in the shared components directory and are loaded site-wide.