Files
whynot-design/src/elements/layout.js
tegwick a89bb563a0
Some checks failed
ci / check (push) Has been cancelled
ci / release (push) Has been cancelled
fix(showcase): break wn-breadcrumb slotchange infinite loop (WHYNOT-WP-0002 T11)
WnBreadcrumb._onSlot inserted separator <span>s into its own light DOM on
slotchange but cleaned up in the shadow DOM, so they were never removed — each
insertion re-fired slotchange, looping the main thread and wedging the showcase
page. Made _onSlot idempotent: exclude own separators when reading items, and
mutate only when separators are not already correct.

- Un-fixme the showcase visual test; add a warm-up full-page capture so
  deviceScaleFactor-2 sub-pixel snapping settles before the assertion. All 5
  visual tests pass.
- Remove the dead Google-Fonts @import from colors_and_type.css (token stacks are
  system-ui; webfont unused + a CI-flake source; no visual change).
- Unblocks WHYNOT-WP-0003 T08 (showcase = per-version visual catalog); both T11
  and T08 marked done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:10:41 +02:00

288 lines
11 KiB
JavaScript

/* =============================================================
* @whynot/design — layout.js
* ------------------------------------------------------------
* <wn-card>, <wn-modal>, <wn-table> / <wn-table-row> /
* <wn-table-cell>, <wn-banner>, <wn-toast>, <wn-toast-region>,
* <wn-empty-state>, <wn-breadcrumb>
* ============================================================= */
import { LitElement, html, nothing } from "lit";
import { WnBase } from "./atoms.js";
/* ---------- <wn-card> ---------- */
export class WnCard extends WnBase {
static properties = {
variant: { type: String, reflect: true },
size: { type: String, reflect: true },
clickable: { type: Boolean, reflect: true },
hasHeader: { state: true },
hasFooter: { state: true },
};
constructor() {
super();
this.hasHeader = false;
this.hasFooter = false;
}
_onSlotChange() {
this.hasHeader = !!this.querySelector('[slot="header"]');
this.hasFooter = !!this.querySelector('[slot="footer"]');
}
render() {
const cls = ["wn-card",
this.variant && this.variant !== "default" ? `wn-card--${this.variant}` : "",
this.size === "sm" ? "wn-card--sm" : this.size === "lg" ? "wn-card--lg" : "",
this.clickable ? "wn-card--clickable" : "",
].filter(Boolean).join(" ");
return html`
<div class=${cls}
role=${this.clickable ? "button" : nothing}
tabindex=${this.clickable ? "0" : nothing}
part="card">
<header class="wn-card__head" ?hidden=${!this.hasHeader}>
<slot name="header" @slotchange=${this._onSlotChange}></slot>
</header>
<slot @slotchange=${this._onSlotChange}></slot>
<footer class="wn-card__foot" ?hidden=${!this.hasFooter}>
<slot name="footer" @slotchange=${this._onSlotChange}></slot>
</footer>
</div>
`;
}
}
/* ---------- <wn-modal> ---------- */
export class WnModal extends WnBase {
static properties = {
open: { type: Boolean, reflect: true },
title: { type: String },
dismissible: { type: Boolean, reflect: true },
hasFooter: { state: true },
};
constructor() {
super();
this.open = false;
this.dismissible = true;
this.hasFooter = false;
}
_onSlotChange() {
this.hasFooter = !!this.querySelector('[slot="footer"]');
}
_onBackdrop(e) {
if (e.target === e.currentTarget && this.dismissible) this._dismiss();
}
_dismiss() {
this.open = false;
this.dispatchEvent(new CustomEvent("wn-dismiss", { bubbles: true, composed: true }));
}
_bindKey = (e) => {
if (e.key === "Escape" && this.dismissible && this.open) this._dismiss();
};
connectedCallback() { super.connectedCallback(); document.addEventListener("keydown", this._bindKey); }
disconnectedCallback() { document.removeEventListener("keydown", this._bindKey); super.disconnectedCallback(); }
render() {
if (!this.open) return nothing;
return html`
<div class="wn-modal__backdrop" @click=${this._onBackdrop} role="presentation" part="backdrop">
<div class="wn-modal__panel" role="dialog" aria-modal="true" aria-label=${this.title ?? nothing} part="panel">
<header class="wn-modal__head">
<h2 class="wn-modal__title">${this.title}<slot name="title"></slot></h2>
${this.dismissible
? html`<button class="wn-modal__close" type="button" aria-label="Close" @click=${this._dismiss}>
<wn-icon name="x" size="md"></wn-icon>
</button>`
: nothing}
</header>
<div class="wn-modal__body"><slot @slotchange=${this._onSlotChange}></slot></div>
<footer class="wn-modal__foot" ?hidden=${!this.hasFooter}>
<slot name="footer" @slotchange=${this._onSlotChange}></slot>
</footer>
</div>
</div>
`;
}
}
/* ---------- <wn-table>, <wn-table-row>, <wn-table-cell> ----------
* Tables in shadow DOM can't render real <table>/<tr> with slotted rows —
* the table model requires the row to be a child of <table>. So these
* components use CSS grid + flexbox to imitate a table visually. For real
* <table> + Django QuerySet rendering, write raw <table class="wn-table">
* markup directly using utility classes.
*/
export class WnTable extends WnBase {
static properties = {
columns: { type: Array },
compact: { type: Boolean, reflect: true },
};
constructor() { super(); this.columns = []; }
render() {
const cols = this.columns || [];
const cls = "wn-table" + (this.compact ? " wn-table--compact" : "");
return html`
<div class=${cls} part="table" role="table">
${cols.length
? html`<div class="wn-table__thead" role="rowgroup">
<div class="wn-table__tr wn-table__tr--head" role="row"
style=${`grid-template-columns: ${cols.map(c => (typeof c === "object" && c.width) ? `${c.width}px` : "1fr").join(" ")}`}>
${cols.map(c => html`<div class="wn-table__th" role="columnheader">${typeof c === "string" ? c : c.label}</div>`)}
</div>
</div>`
: nothing}
<div class="wn-table__tbody" role="rowgroup"
style=${cols.length
? `--wn-cols: ${cols.map(c => (typeof c === "object" && c.width) ? `${c.width}px` : "1fr").join(" ")}`
: nothing}>
<slot></slot>
</div>
</div>
`;
}
}
export class WnTableRow extends WnBase {
render() {
return html`<div class="wn-table__tr" role="row" part="row"
style="grid-template-columns: var(--wn-cols, repeat(auto-fit, minmax(80px, 1fr)));">
<slot></slot>
</div>`;
}
}
export class WnTableCell extends WnBase {
static properties = { variant: { type: String, reflect: true } };
render() {
const cls = "wn-table__td" + (this.variant ? ` wn-table__cell--${this.variant}` : "");
return html`<div class=${cls} role="cell" part="cell"><slot></slot></div>`;
}
}
/* ---------- <wn-banner> ---------- */
export class WnBanner extends WnBase {
static properties = {
variant: { type: String, reflect: true },
title: { type: String },
icon: { type: String },
dismissible: { type: Boolean, reflect: true },
};
constructor() { super(); this.variant = "info"; }
_dismiss() {
this.dispatchEvent(new CustomEvent("wn-dismiss", { bubbles: true, composed: true }));
this.remove();
}
render() {
const iconName = this.icon || ({
info: "circle-info", success: "circle-check",
warn: "circle-alert", error: "circle-alert",
}[this.variant]);
const cls = `wn-banner wn-banner--${this.variant}`;
return html`
<div class=${cls} role=${this.variant === "error" || this.variant === "warn" ? "alert" : "status"} part="banner">
${iconName ? html`<span class="wn-banner__icon"><wn-icon name=${iconName} size="md"></wn-icon></span>` : nothing}
<div class="wn-banner__body">
${this.title ? html`<p class="wn-banner__title">${this.title}</p>` : nothing}
<slot></slot>
</div>
${this.dismissible
? html`<button class="wn-banner__dismiss" type="button" aria-label="Dismiss" @click=${this._dismiss}>
<wn-icon name="x" size="sm"></wn-icon>
</button>`
: nothing}
</div>
`;
}
}
/* ---------- <wn-toast> / <wn-toast-region> ---------- */
export class WnToast extends WnBanner {
constructor() { super(); this.dismissible = true; }
render() {
const base = super.render();
return html`<div class="wn-toast" part="toast">${base}</div>`;
}
}
export class WnToastRegion extends WnBase {
render() {
return html`<div class="wn-toast-region" role="region" aria-label="Notifications" part="root">
<slot></slot>
</div>`;
}
}
/* ---------- <wn-empty-state> ---------- */
export class WnEmptyState extends WnBase {
static properties = {
icon: { type: String },
title: { type: String },
hasCta: { state: true },
};
constructor() { super(); this.hasCta = false; }
_onSlot() { this.hasCta = !!this.querySelector('[slot="cta"]'); }
render() {
return html`
<div class="wn-empty" part="empty">
${this.icon ? html`<wn-icon class="wn-empty__icon" name=${this.icon} size="lg"></wn-icon>` : nothing}
${this.title ? html`<p class="wn-empty__title">${this.title}</p>` : nothing}
<p class="wn-empty__body"><slot @slotchange=${this._onSlot}></slot></p>
<div class="wn-empty__cta" ?hidden=${!this.hasCta}>
<slot name="cta" @slotchange=${this._onSlot}></slot>
</div>
</div>
`;
}
}
/* ---------- <wn-breadcrumb> ---------- */
export class WnBreadcrumb extends WnBase {
_onSlot(e) {
const slot = e.target;
// Separators are inserted into the LIGHT DOM (so they sit in document order
// between the slotted items), which re-fires this slotchange. We must
// therefore be idempotent: exclude our own separators when reading items,
// and skip all mutation once the separators are already correct — otherwise
// each insertion retriggers slotchange and the main thread loops forever.
const items = slot.assignedElements({ flatten: true })
.filter((el) => !el.classList.contains("wn-breadcrumb__sep"));
const existing = [...this.querySelectorAll(":scope > .wn-breadcrumb__sep")];
if (existing.length === Math.max(0, items.length - 1)) {
// Structure already correct — only refresh the "current" marker, do not
// touch the child list (no mutation ⇒ no slotchange re-fire ⇒ loop ends).
items.forEach((el, i) => el.classList.toggle("wn-breadcrumb__current", i === items.length - 1));
return;
}
existing.forEach((s) => s.remove());
items.forEach((el, i) => {
el.classList.toggle("wn-breadcrumb__current", i === items.length - 1);
if (i > 0) {
const sep = document.createElement("span");
sep.className = "wn-breadcrumb__sep";
sep.setAttribute("aria-hidden", "true");
sep.textContent = "/";
el.parentNode.insertBefore(sep, el);
}
});
}
render() {
return html`
<nav class="wn-breadcrumb" aria-label="Breadcrumb" part="root">
<span class="wn-breadcrumb__list"><slot @slotchange=${this._onSlot}></slot></span>
</nav>
`;
}
}
export function defineLayout() {
if (!customElements.get("wn-card")) customElements.define("wn-card", WnCard);
if (!customElements.get("wn-modal")) customElements.define("wn-modal", WnModal);
if (!customElements.get("wn-table")) customElements.define("wn-table", WnTable);
if (!customElements.get("wn-table-row")) customElements.define("wn-table-row", WnTableRow);
if (!customElements.get("wn-table-cell")) customElements.define("wn-table-cell", WnTableCell);
if (!customElements.get("wn-banner")) customElements.define("wn-banner", WnBanner);
if (!customElements.get("wn-toast")) customElements.define("wn-toast", WnToast);
if (!customElements.get("wn-toast-region")) customElements.define("wn-toast-region", WnToastRegion);
if (!customElements.get("wn-empty-state")) customElements.define("wn-empty-state", WnEmptyState);
if (!customElements.get("wn-breadcrumb")) customElements.define("wn-breadcrumb", WnBreadcrumb);
}