/* ============================================================= * @whynot/design — layout.js * ------------------------------------------------------------ * , , / / * , , , , * , * ============================================================= */ import { LitElement, html, nothing } from "lit"; import { WnBase } from "./atoms.js"; /* ---------- ---------- */ 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`
`; } } /* ---------- ---------- */ 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` `; } } /* ---------- , , ---------- * Tables in shadow DOM can't render real / with slotted rows — * the table model requires the row to be a child of
. So these * components use CSS grid + flexbox to imitate a table visually. For real *
+ Django QuerySet rendering, write raw
* 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`
${cols.length ? html`
(typeof c === "object" && c.width) ? `${c.width}px` : "1fr").join(" ")}`}> ${cols.map(c => html`
${typeof c === "string" ? c : c.label}
`)}
` : nothing}
(typeof c === "object" && c.width) ? `${c.width}px` : "1fr").join(" ")}` : nothing}>
`; } } export class WnTableRow extends WnBase { render() { return html`
`; } } 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`
`; } } /* ---------- ---------- */ 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`
${iconName ? html`` : nothing}
${this.title ? html`

${this.title}

` : nothing}
${this.dismissible ? html`` : nothing}
`; } } /* ---------- / ---------- */ export class WnToast extends WnBanner { constructor() { super(); this.dismissible = true; } render() { const base = super.render(); return html`
${base}
`; } } export class WnToastRegion extends WnBase { render() { return html`
`; } } /* ---------- ---------- */ 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`
${this.icon ? html`` : nothing} ${this.title ? html`

${this.title}

` : nothing}

`; } } /* ---------- ---------- */ 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` `; } } 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); }