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);
+    }
+  }
+}