version 0.2.0 replaces fromer version!
This commit is contained in:
277
src/elements/layout.js
Normal file
277
src/elements/layout.js
Normal file
@@ -0,0 +1,277 @@
|
||||
/* =============================================================
|
||||
* @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;
|
||||
const items = slot.assignedElements({ flatten: true });
|
||||
// Build the rendered tree: each item + a separator after it.
|
||||
const wrapper = this.shadowRoot?.querySelector('.wn-breadcrumb__list');
|
||||
if (!wrapper) return;
|
||||
wrapper.querySelectorAll('.wn-breadcrumb__sep').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 = "/";
|
||||
// Use light-DOM-relative insertion: items are still in light DOM,
|
||||
// so DOM-order separators between them belong in light DOM too.
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user