Mercurial
view design_system/src/components/collections.js @ 269:de291f396881
install initial production config
Copy the ignored repository config into /etc/mrjunejune on first deployment while preserving existing production configuration on later deploys.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 13:08:10 -0700 |
| parents | 2b6e732087ff |
| children |
line wrap: on
line source
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); } } }