Mercurial
diff design_system/src/components/collections.js @ 254:2b6e732087ff
[ui] Add complete native component catalog
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Tue, 04 Aug 2026 15:12:09 -0700 |
| parents | |
| children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/design_system/src/components/collections.js Tue Aug 04 15:12:09 2026 -0700 @@ -0,0 +1,579 @@ +const HTMLElementBase = globalThis.HTMLElement || class {}; + +function rememberAttribute(state, element, name) { + let attributes = state.get(element); + if (!attributes) { + attributes = new Map(); + state.set(element, attributes); + } + if (!attributes.has(name)) { + attributes.set(name, element.getAttribute(name)); + } +} + +function setManagedAttribute(state, element, name, value) { + rememberAttribute(state, element, name); + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); +} + +function originalAttribute(state, element, name) { + const attributes = state.get(element); + return attributes?.has(name) + ? attributes.get(name) + : element.getAttribute(name); +} + +function restoreElement(state, element) { + const attributes = state.get(element); + if (!attributes) return; + for (const [name, value] of attributes) { + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + } + state.delete(element); +} + +function restoreAttributes(state) { + for (const element of [...state.keys()]) restoreElement(state, element); +} + +function pruneAttributes(state, retained) { + for (const element of [...state.keys()]) { + if (!retained.has(element)) restoreElement(state, element); + } +} + +function setDefaultAttribute(state, element, name, value) { + if (!element.hasAttribute(name) || state.get(element)?.has(name)) { + setManagedAttribute(state, element, name, value); + } +} + +function eventElement(event) { + const target = event.target; + if (target?.nodeType === 1) return target; + return target?.parentElement || null; +} + +function ownedElement(host, selector) { + return [...host.querySelectorAll(selector)].find( + element => element.closest(host.localName) === host, + ) || null; +} + +function emit(element, type, detail) { + element.dispatchEvent(new CustomEvent(type, { + bubbles: true, + detail, + })); +} + +function isDisabled(control) { + return Boolean(control?.disabled) || + control?.getAttribute("aria-disabled") === "true"; +} + +/** + * Enhances a native scroll-snap viewport with navigation controls. + * + * @extends {HTMLElement} + */ +export class ZenCarousel extends HTMLElementBase { + connectedCallback() { + this._managed ||= new Map(); + this._onClick ||= event => { + const control = eventElement(event)?.closest( + "[data-zen-prev], [data-zen-next]", + ); + if (control === this._prev && !isDisabled(control)) this.move(-1); + else if (control === this._next && !isDisabled(control)) this.move(1); + }; + this._onKeydown ||= event => { + if (event.altKey || event.ctrlKey || event.metaKey || + !["ArrowLeft", "ArrowRight"].includes(event.key)) { + return; + } + const target = eventElement(event); + if (target?.closest("input, textarea, select, [contenteditable]")) return; + event.preventDefault(); + this.move(event.key === "ArrowLeft" ? -1 : 1); + }; + this._onScroll ||= () => { + globalThis.clearTimeout(this._scrollTimer); + this._scrollTimer = globalThis.setTimeout(() => { + this._scrollTimer = null; + this.setIndex(this.currentIndex(), true); + }, 100); + }; + this.addEventListener("click", this._onClick); + this.addEventListener("keydown", this._onKeydown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + this.removeEventListener("keydown", this._onKeydown); + this._viewport?.removeEventListener("scroll", this._onScroll); + globalThis.clearTimeout(this._scrollTimer); + restoreAttributes(this._managed); + this._viewport = null; + this._slides = []; + this._prev = null; + this._next = null; + this._index = null; + this._scrollTimer = null; + } + + sync() { + const viewport = ownedElement(this, "[data-zen-viewport]"); + if (viewport !== this._viewport) { + this._viewport?.removeEventListener("scroll", this._onScroll); + this._viewport = viewport; + viewport?.addEventListener("scroll", this._onScroll, { passive: true }); + } + this._slides = viewport ? [...viewport.children] : []; + this._prev = ownedElement(this, "[data-zen-prev]"); + this._next = ownedElement(this, "[data-zen-next]"); + + const retained = new Set([ + this, + viewport, + this._prev, + this._next, + ...this._slides, + ].filter(Boolean)); + pruneAttributes(this._managed, retained); + setDefaultAttribute(this._managed, this, "role", "region"); + setDefaultAttribute( + this._managed, + this, + "aria-roledescription", + "carousel", + ); + setDefaultAttribute(this._managed, this, "aria-label", "Carousel"); + if (viewport) { + setDefaultAttribute(this._managed, viewport, "tabindex", "0"); + } + for (const [index, slide] of this._slides.entries()) { + setDefaultAttribute(this._managed, slide, "role", "group"); + setDefaultAttribute( + this._managed, + slide, + "aria-roledescription", + "slide", + ); + setDefaultAttribute( + this._managed, + slide, + "aria-label", + `Slide ${index + 1} of ${this._slides.length}`, + ); + } + + const index = this.currentIndex(); + this._index = index; + this.render(); + } + + currentIndex() { + if (!this._viewport || !this._slides.length) return 0; + const viewportLeft = this._viewport.getBoundingClientRect().left; + let closest = 0; + let distance = Infinity; + for (const [index, slide] of this._slides.entries()) { + const current = Math.abs(slide.getBoundingClientRect().left - viewportLeft); + if (current < distance) { + closest = index; + distance = current; + } + } + return closest; + } + + move(offset) { + if (!this._slides.length) return; + const current = Number.isInteger(this._index) + ? this._index + : this.currentIndex(); + this.scrollTo(Math.max(0, Math.min(this._slides.length - 1, current + offset))); + } + + scrollTo(index) { + const slide = this._slides[index]; + if (!this._viewport || !slide || index === this._index) return; + const viewportRect = this._viewport.getBoundingClientRect(); + const slideRect = slide.getBoundingClientRect(); + const left = this._viewport.scrollLeft + slideRect.left - viewportRect.left; + if (typeof this._viewport.scrollTo === "function") { + this._viewport.scrollTo({ left, behavior: "smooth" }); + } else if (typeof slide.scrollIntoView === "function") { + slide.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "start", + }); + } + this.setIndex(index, true); + } + + setIndex(index, notify) { + const next = Math.max( + 0, + Math.min(this._slides.length ? this._slides.length - 1 : 0, index), + ); + if (next === this._index) { + this.render(); + return; + } + this._index = next; + this.render(); + if (notify) emit(this, "zen-change", { index: next }); + } + + render() { + const last = Math.max(0, this._slides.length - 1); + this.syncDisabled(this._prev, !this._slides.length || this._index <= 0); + this.syncDisabled( + this._next, + !this._slides.length || this._index >= last, + ); + } + + syncDisabled(control, disabled) { + if (!control) return; + const authored = originalAttribute(this._managed, control, "disabled"); + setManagedAttribute( + this._managed, + control, + "disabled", + disabled || authored !== null ? "" : null, + ); + } +} + +/** + * Keeps a message viewport pinned while the reader remains near its end. + * + * @extends {HTMLElement} + */ +export class ZenMessageScroller extends HTMLElementBase { + connectedCallback() { + this._managed ||= new Map(); + this._onScroll ||= () => { + this._nearEnd = this.isNearEnd(); + this.render(); + }; + this._onClick ||= event => { + const button = eventElement(event)?.closest("[data-zen-scroll-end]"); + if (button === this._button && !isDisabled(button)) this.scrollEnd(true); + }; + this._viewport = ownedElement(this, "[data-zen-viewport]") || this; + this._button = ownedElement(this, "[data-zen-scroll-end]"); + this._nearEnd = this.isNearEnd(); + this.addEventListener("click", this._onClick); + this._viewport.addEventListener("scroll", this._onScroll, { passive: true }); + this._observer ||= new MutationObserver(records => { + if (!records.some(record => record.addedNodes.length)) return; + const wasNearEnd = this._nearEnd; + if (wasNearEnd) this.scrollEnd(false); + else this.render(); + }); + this._observer.observe(this._viewport, { childList: true, subtree: true }); + this.render(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + this._viewport?.removeEventListener("scroll", this._onScroll); + restoreAttributes(this._managed); + this._viewport = null; + this._button = null; + } + + isNearEnd() { + if (!this._viewport) return true; + return this._viewport.scrollHeight - this._viewport.scrollTop - + this._viewport.clientHeight <= 24; + } + + scrollEnd(smooth) { + if (!this._viewport) return; + if (typeof this._viewport.scrollTo === "function") { + this._viewport.scrollTo({ + top: this._viewport.scrollHeight, + behavior: smooth ? "smooth" : "auto", + }); + } else { + this._viewport.scrollTop = this._viewport.scrollHeight; + } + this._nearEnd = true; + this.render(); + } + + render() { + if (!this._button) return; + setManagedAttribute( + this._managed, + this._button, + "hidden", + this._nearEnd ? "" : null, + ); + } +} + +/** + * Adds pointer and keyboard resizing to two native content panels. + * + * @extends {HTMLElement} + */ +export class ZenResizable extends HTMLElementBase { + static get observedAttributes() { + return ["min", "max"]; + } + + connectedCallback() { + this._managed ||= new Map(); + this._onPointerDown ||= event => { + const handle = eventElement(event)?.closest("[data-zen-handle]"); + if (handle !== this._handle || (event.button !== 0 && event.button !== -1)) { + return; + } + event.preventDefault(); + this._pointerId = event.pointerId; + this._handle.setPointerCapture?.(event.pointerId); + }; + this._onPointerMove ||= event => { + if (event.pointerId !== this._pointerId) return; + const rect = this.getBoundingClientRect(); + if (!rect.width) return; + this.setPercent((event.clientX - rect.left) / rect.width * 100, true); + }; + this._onPointerEnd ||= event => { + if (event.pointerId !== this._pointerId) return; + if (this._handle?.hasPointerCapture?.(event.pointerId)) { + this._handle.releasePointerCapture(event.pointerId); + } + this._pointerId = null; + }; + this._onKeydown ||= event => { + const handle = eventElement(event)?.closest("[data-zen-handle]"); + if (handle !== this._handle || + !["ArrowLeft", "ArrowRight"].includes(event.key)) { + return; + } + event.preventDefault(); + const step = Number(this.getAttribute("step")); + const amount = Number.isFinite(step) && step > 0 ? step : 1; + this.setPercent( + this._percent + (event.key === "ArrowLeft" ? -amount : amount), + true, + ); + }; + this.addEventListener("pointerdown", this._onPointerDown); + this.addEventListener("pointermove", this._onPointerMove); + this.addEventListener("pointerup", this._onPointerEnd); + this.addEventListener("pointercancel", this._onPointerEnd); + this.addEventListener("keydown", this._onKeydown); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("pointerdown", this._onPointerDown); + this.removeEventListener("pointermove", this._onPointerMove); + this.removeEventListener("pointerup", this._onPointerEnd); + this.removeEventListener("pointercancel", this._onPointerEnd); + this.removeEventListener("keydown", this._onKeydown); + restoreAttributes(this._managed); + this._first = null; + this._second = null; + this._handle = null; + this._pointerId = null; + this._percent = null; + } + + attributeChangedCallback() { + if (this.isConnected && Number.isFinite(this._percent)) { + this.setPercent(this._percent, false); + } + } + + limits() { + const minimum = this.getAttribute("min"); + const maximum = this.getAttribute("max"); + const min = minimum !== null && Number.isFinite(Number(minimum)) + ? Math.max(0, Math.min(100, Number(minimum))) + : 0; + const max = maximum !== null && Number.isFinite(Number(maximum)) + ? Math.max(min, Math.min(100, Number(maximum))) + : 100; + return { min, max }; + } + + sync() { + const children = [...this.children]; + const handle = children.find(child => child.hasAttribute("data-zen-handle")); + const handleIndex = children.indexOf(handle); + const first = children.slice(0, handleIndex).reverse().find( + child => child.hasAttribute("data-zen-panel"), + ); + const second = children.slice(handleIndex + 1).find( + child => child.hasAttribute("data-zen-panel"), + ); + + if (!first || !second || !handle) { + if (this._first) restoreElement(this._managed, this._first); + if (this._handle) restoreElement(this._managed, this._handle); + this._first = null; + this._second = null; + this._handle = null; + this._percent = null; + return; + } + if (this._first && this._first !== first) restoreElement(this._managed, this._first); + if (this._handle && this._handle !== handle) { + restoreElement(this._managed, this._handle); + } + this._first = first; + this._second = second; + this._handle = handle; + + setManagedAttribute(this._managed, this._handle, "role", "separator"); + setManagedAttribute( + this._managed, + this._handle, + "aria-orientation", + "vertical", + ); + setDefaultAttribute(this._managed, this._handle, "tabindex", "0"); + + if (!Number.isFinite(this._percent)) { + const containerWidth = this.getBoundingClientRect().width; + const rendered = containerWidth + ? this._first.getBoundingClientRect().width / containerWidth * 100 + : Number.parseFloat(this._first.style.flexBasis); + this._percent = Number.isFinite(rendered) ? rendered : 50; + } + this.setPercent(this._percent, false); + } + + setPercent(percent, notify) { + if (!this._first || !this._handle) return; + const { min, max } = this.limits(); + const next = Math.round(Math.max(min, Math.min(max, percent)) * 100) / 100; + const changed = next !== this._percent; + this._percent = next; + rememberAttribute(this._managed, this._first, "style"); + this._first.style.flexBasis = `${next}%`; + setManagedAttribute(this._managed, this._handle, "aria-valuemin", String(min)); + setManagedAttribute(this._managed, this._handle, "aria-valuemax", String(max)); + setManagedAttribute(this._managed, this._handle, "aria-valuenow", String(next)); + if (notify && changed) emit(this, "zen-resize", { percent: next }); + } +} + +/** + * Adds stable client-side sorting to a native HTML table. + * + * @extends {HTMLElement} + */ +export class ZenDataTable extends HTMLElementBase { + connectedCallback() { + this._managed ||= new Map(); + this._onClick ||= event => { + const button = eventElement(event)?.closest("button[data-zen-sort]"); + if (!this._buttons.includes(button) || isDisabled(button)) return; + this.sort(button); + }; + this.addEventListener("click", this._onClick); + this._observer ||= new MutationObserver(() => this.sync()); + this._observer.observe(this, { childList: true, subtree: true }); + this.sync(); + } + + disconnectedCallback() { + this._observer?.disconnect(); + this.removeEventListener("click", this._onClick); + restoreAttributes(this._managed); + this._table = null; + this._buttons = []; + } + + sync() { + this._table = + this.querySelector(":scope > table") || this.querySelector("table"); + this._buttons = this._table + ? [...this._table.querySelectorAll("button[data-zen-sort]")].filter( + button => button.closest("table") === this._table && + button.closest("th"), + ) + : []; + pruneAttributes( + this._managed, + new Set(this._buttons.map(button => button.closest("th"))), + ); + } + + sort(button) { + const key = button.getAttribute("data-zen-sort"); + const header = button.closest("th"); + if (!this._table || !key || !header) return; + const direction = + header.getAttribute("aria-sort") === "ascending" + ? "descending" + : "ascending"; + + for (const candidate of this._buttons) { + const candidateHeader = candidate.closest("th"); + setManagedAttribute( + this._managed, + candidateHeader, + "aria-sort", + candidateHeader === header ? direction : null, + ); + } + + for (const body of this._table.tBodies) { + const entries = [...body.rows].map((row, index) => { + const cell = [...row.cells].find( + candidate => candidate.getAttribute("data-key") === key, + ); + return { index, row, value: cell?.textContent?.trim() ?? "" }; + }); + const numeric = entries.every( + entry => Number.isFinite(Number(entry.value)), + ); + const factor = direction === "ascending" ? 1 : -1; + entries.sort((left, right) => { + const compared = numeric + ? Number(left.value) - Number(right.value) + : left.value.localeCompare(right.value); + return compared ? compared * factor : left.index - right.index; + }); + body.append(...entries.map(entry => entry.row)); + } + emit(this, "zen-sort", { column: key, direction }); + } +} + +const definitions = { + "zen-carousel": ZenCarousel, + "zen-data-table": ZenDataTable, + "zen-message-scroller": ZenMessageScroller, + "zen-resizable": ZenResizable, +}; + +if (globalThis.customElements) { + for (const [name, definition] of Object.entries(definitions)) { + if (!globalThis.customElements.get(name)) { + globalThis.customElements.define(name, definition); + } + } +}