comparison 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
comparison
equal deleted inserted replaced
253:fdf3816959cb 254:2b6e732087ff
1 const HTMLElementBase = globalThis.HTMLElement || class {};
2
3 function rememberAttribute(state, element, name) {
4 let attributes = state.get(element);
5 if (!attributes) {
6 attributes = new Map();
7 state.set(element, attributes);
8 }
9 if (!attributes.has(name)) {
10 attributes.set(name, element.getAttribute(name));
11 }
12 }
13
14 function setManagedAttribute(state, element, name, value) {
15 rememberAttribute(state, element, name);
16 if (value === null) element.removeAttribute(name);
17 else element.setAttribute(name, value);
18 }
19
20 function originalAttribute(state, element, name) {
21 const attributes = state.get(element);
22 return attributes?.has(name)
23 ? attributes.get(name)
24 : element.getAttribute(name);
25 }
26
27 function restoreElement(state, element) {
28 const attributes = state.get(element);
29 if (!attributes) return;
30 for (const [name, value] of attributes) {
31 if (value === null) element.removeAttribute(name);
32 else element.setAttribute(name, value);
33 }
34 state.delete(element);
35 }
36
37 function restoreAttributes(state) {
38 for (const element of [...state.keys()]) restoreElement(state, element);
39 }
40
41 function pruneAttributes(state, retained) {
42 for (const element of [...state.keys()]) {
43 if (!retained.has(element)) restoreElement(state, element);
44 }
45 }
46
47 function setDefaultAttribute(state, element, name, value) {
48 if (!element.hasAttribute(name) || state.get(element)?.has(name)) {
49 setManagedAttribute(state, element, name, value);
50 }
51 }
52
53 function eventElement(event) {
54 const target = event.target;
55 if (target?.nodeType === 1) return target;
56 return target?.parentElement || null;
57 }
58
59 function ownedElement(host, selector) {
60 return [...host.querySelectorAll(selector)].find(
61 element => element.closest(host.localName) === host,
62 ) || null;
63 }
64
65 function emit(element, type, detail) {
66 element.dispatchEvent(new CustomEvent(type, {
67 bubbles: true,
68 detail,
69 }));
70 }
71
72 function isDisabled(control) {
73 return Boolean(control?.disabled) ||
74 control?.getAttribute("aria-disabled") === "true";
75 }
76
77 /**
78 * Enhances a native scroll-snap viewport with navigation controls.
79 *
80 * @extends {HTMLElement}
81 */
82 export class ZenCarousel extends HTMLElementBase {
83 connectedCallback() {
84 this._managed ||= new Map();
85 this._onClick ||= event => {
86 const control = eventElement(event)?.closest(
87 "[data-zen-prev], [data-zen-next]",
88 );
89 if (control === this._prev && !isDisabled(control)) this.move(-1);
90 else if (control === this._next && !isDisabled(control)) this.move(1);
91 };
92 this._onKeydown ||= event => {
93 if (event.altKey || event.ctrlKey || event.metaKey ||
94 !["ArrowLeft", "ArrowRight"].includes(event.key)) {
95 return;
96 }
97 const target = eventElement(event);
98 if (target?.closest("input, textarea, select, [contenteditable]")) return;
99 event.preventDefault();
100 this.move(event.key === "ArrowLeft" ? -1 : 1);
101 };
102 this._onScroll ||= () => {
103 globalThis.clearTimeout(this._scrollTimer);
104 this._scrollTimer = globalThis.setTimeout(() => {
105 this._scrollTimer = null;
106 this.setIndex(this.currentIndex(), true);
107 }, 100);
108 };
109 this.addEventListener("click", this._onClick);
110 this.addEventListener("keydown", this._onKeydown);
111 this._observer ||= new MutationObserver(() => this.sync());
112 this._observer.observe(this, { childList: true, subtree: true });
113 this.sync();
114 }
115
116 disconnectedCallback() {
117 this._observer?.disconnect();
118 this.removeEventListener("click", this._onClick);
119 this.removeEventListener("keydown", this._onKeydown);
120 this._viewport?.removeEventListener("scroll", this._onScroll);
121 globalThis.clearTimeout(this._scrollTimer);
122 restoreAttributes(this._managed);
123 this._viewport = null;
124 this._slides = [];
125 this._prev = null;
126 this._next = null;
127 this._index = null;
128 this._scrollTimer = null;
129 }
130
131 sync() {
132 const viewport = ownedElement(this, "[data-zen-viewport]");
133 if (viewport !== this._viewport) {
134 this._viewport?.removeEventListener("scroll", this._onScroll);
135 this._viewport = viewport;
136 viewport?.addEventListener("scroll", this._onScroll, { passive: true });
137 }
138 this._slides = viewport ? [...viewport.children] : [];
139 this._prev = ownedElement(this, "[data-zen-prev]");
140 this._next = ownedElement(this, "[data-zen-next]");
141
142 const retained = new Set([
143 this,
144 viewport,
145 this._prev,
146 this._next,
147 ...this._slides,
148 ].filter(Boolean));
149 pruneAttributes(this._managed, retained);
150 setDefaultAttribute(this._managed, this, "role", "region");
151 setDefaultAttribute(
152 this._managed,
153 this,
154 "aria-roledescription",
155 "carousel",
156 );
157 setDefaultAttribute(this._managed, this, "aria-label", "Carousel");
158 if (viewport) {
159 setDefaultAttribute(this._managed, viewport, "tabindex", "0");
160 }
161 for (const [index, slide] of this._slides.entries()) {
162 setDefaultAttribute(this._managed, slide, "role", "group");
163 setDefaultAttribute(
164 this._managed,
165 slide,
166 "aria-roledescription",
167 "slide",
168 );
169 setDefaultAttribute(
170 this._managed,
171 slide,
172 "aria-label",
173 `Slide ${index + 1} of ${this._slides.length}`,
174 );
175 }
176
177 const index = this.currentIndex();
178 this._index = index;
179 this.render();
180 }
181
182 currentIndex() {
183 if (!this._viewport || !this._slides.length) return 0;
184 const viewportLeft = this._viewport.getBoundingClientRect().left;
185 let closest = 0;
186 let distance = Infinity;
187 for (const [index, slide] of this._slides.entries()) {
188 const current = Math.abs(slide.getBoundingClientRect().left - viewportLeft);
189 if (current < distance) {
190 closest = index;
191 distance = current;
192 }
193 }
194 return closest;
195 }
196
197 move(offset) {
198 if (!this._slides.length) return;
199 const current = Number.isInteger(this._index)
200 ? this._index
201 : this.currentIndex();
202 this.scrollTo(Math.max(0, Math.min(this._slides.length - 1, current + offset)));
203 }
204
205 scrollTo(index) {
206 const slide = this._slides[index];
207 if (!this._viewport || !slide || index === this._index) return;
208 const viewportRect = this._viewport.getBoundingClientRect();
209 const slideRect = slide.getBoundingClientRect();
210 const left = this._viewport.scrollLeft + slideRect.left - viewportRect.left;
211 if (typeof this._viewport.scrollTo === "function") {
212 this._viewport.scrollTo({ left, behavior: "smooth" });
213 } else if (typeof slide.scrollIntoView === "function") {
214 slide.scrollIntoView({
215 behavior: "smooth",
216 block: "nearest",
217 inline: "start",
218 });
219 }
220 this.setIndex(index, true);
221 }
222
223 setIndex(index, notify) {
224 const next = Math.max(
225 0,
226 Math.min(this._slides.length ? this._slides.length - 1 : 0, index),
227 );
228 if (next === this._index) {
229 this.render();
230 return;
231 }
232 this._index = next;
233 this.render();
234 if (notify) emit(this, "zen-change", { index: next });
235 }
236
237 render() {
238 const last = Math.max(0, this._slides.length - 1);
239 this.syncDisabled(this._prev, !this._slides.length || this._index <= 0);
240 this.syncDisabled(
241 this._next,
242 !this._slides.length || this._index >= last,
243 );
244 }
245
246 syncDisabled(control, disabled) {
247 if (!control) return;
248 const authored = originalAttribute(this._managed, control, "disabled");
249 setManagedAttribute(
250 this._managed,
251 control,
252 "disabled",
253 disabled || authored !== null ? "" : null,
254 );
255 }
256 }
257
258 /**
259 * Keeps a message viewport pinned while the reader remains near its end.
260 *
261 * @extends {HTMLElement}
262 */
263 export class ZenMessageScroller extends HTMLElementBase {
264 connectedCallback() {
265 this._managed ||= new Map();
266 this._onScroll ||= () => {
267 this._nearEnd = this.isNearEnd();
268 this.render();
269 };
270 this._onClick ||= event => {
271 const button = eventElement(event)?.closest("[data-zen-scroll-end]");
272 if (button === this._button && !isDisabled(button)) this.scrollEnd(true);
273 };
274 this._viewport = ownedElement(this, "[data-zen-viewport]") || this;
275 this._button = ownedElement(this, "[data-zen-scroll-end]");
276 this._nearEnd = this.isNearEnd();
277 this.addEventListener("click", this._onClick);
278 this._viewport.addEventListener("scroll", this._onScroll, { passive: true });
279 this._observer ||= new MutationObserver(records => {
280 if (!records.some(record => record.addedNodes.length)) return;
281 const wasNearEnd = this._nearEnd;
282 if (wasNearEnd) this.scrollEnd(false);
283 else this.render();
284 });
285 this._observer.observe(this._viewport, { childList: true, subtree: true });
286 this.render();
287 }
288
289 disconnectedCallback() {
290 this._observer?.disconnect();
291 this.removeEventListener("click", this._onClick);
292 this._viewport?.removeEventListener("scroll", this._onScroll);
293 restoreAttributes(this._managed);
294 this._viewport = null;
295 this._button = null;
296 }
297
298 isNearEnd() {
299 if (!this._viewport) return true;
300 return this._viewport.scrollHeight - this._viewport.scrollTop -
301 this._viewport.clientHeight <= 24;
302 }
303
304 scrollEnd(smooth) {
305 if (!this._viewport) return;
306 if (typeof this._viewport.scrollTo === "function") {
307 this._viewport.scrollTo({
308 top: this._viewport.scrollHeight,
309 behavior: smooth ? "smooth" : "auto",
310 });
311 } else {
312 this._viewport.scrollTop = this._viewport.scrollHeight;
313 }
314 this._nearEnd = true;
315 this.render();
316 }
317
318 render() {
319 if (!this._button) return;
320 setManagedAttribute(
321 this._managed,
322 this._button,
323 "hidden",
324 this._nearEnd ? "" : null,
325 );
326 }
327 }
328
329 /**
330 * Adds pointer and keyboard resizing to two native content panels.
331 *
332 * @extends {HTMLElement}
333 */
334 export class ZenResizable extends HTMLElementBase {
335 static get observedAttributes() {
336 return ["min", "max"];
337 }
338
339 connectedCallback() {
340 this._managed ||= new Map();
341 this._onPointerDown ||= event => {
342 const handle = eventElement(event)?.closest("[data-zen-handle]");
343 if (handle !== this._handle || (event.button !== 0 && event.button !== -1)) {
344 return;
345 }
346 event.preventDefault();
347 this._pointerId = event.pointerId;
348 this._handle.setPointerCapture?.(event.pointerId);
349 };
350 this._onPointerMove ||= event => {
351 if (event.pointerId !== this._pointerId) return;
352 const rect = this.getBoundingClientRect();
353 if (!rect.width) return;
354 this.setPercent((event.clientX - rect.left) / rect.width * 100, true);
355 };
356 this._onPointerEnd ||= event => {
357 if (event.pointerId !== this._pointerId) return;
358 if (this._handle?.hasPointerCapture?.(event.pointerId)) {
359 this._handle.releasePointerCapture(event.pointerId);
360 }
361 this._pointerId = null;
362 };
363 this._onKeydown ||= event => {
364 const handle = eventElement(event)?.closest("[data-zen-handle]");
365 if (handle !== this._handle ||
366 !["ArrowLeft", "ArrowRight"].includes(event.key)) {
367 return;
368 }
369 event.preventDefault();
370 const step = Number(this.getAttribute("step"));
371 const amount = Number.isFinite(step) && step > 0 ? step : 1;
372 this.setPercent(
373 this._percent + (event.key === "ArrowLeft" ? -amount : amount),
374 true,
375 );
376 };
377 this.addEventListener("pointerdown", this._onPointerDown);
378 this.addEventListener("pointermove", this._onPointerMove);
379 this.addEventListener("pointerup", this._onPointerEnd);
380 this.addEventListener("pointercancel", this._onPointerEnd);
381 this.addEventListener("keydown", this._onKeydown);
382 this._observer ||= new MutationObserver(() => this.sync());
383 this._observer.observe(this, { childList: true });
384 this.sync();
385 }
386
387 disconnectedCallback() {
388 this._observer?.disconnect();
389 this.removeEventListener("pointerdown", this._onPointerDown);
390 this.removeEventListener("pointermove", this._onPointerMove);
391 this.removeEventListener("pointerup", this._onPointerEnd);
392 this.removeEventListener("pointercancel", this._onPointerEnd);
393 this.removeEventListener("keydown", this._onKeydown);
394 restoreAttributes(this._managed);
395 this._first = null;
396 this._second = null;
397 this._handle = null;
398 this._pointerId = null;
399 this._percent = null;
400 }
401
402 attributeChangedCallback() {
403 if (this.isConnected && Number.isFinite(this._percent)) {
404 this.setPercent(this._percent, false);
405 }
406 }
407
408 limits() {
409 const minimum = this.getAttribute("min");
410 const maximum = this.getAttribute("max");
411 const min = minimum !== null && Number.isFinite(Number(minimum))
412 ? Math.max(0, Math.min(100, Number(minimum)))
413 : 0;
414 const max = maximum !== null && Number.isFinite(Number(maximum))
415 ? Math.max(min, Math.min(100, Number(maximum)))
416 : 100;
417 return { min, max };
418 }
419
420 sync() {
421 const children = [...this.children];
422 const handle = children.find(child => child.hasAttribute("data-zen-handle"));
423 const handleIndex = children.indexOf(handle);
424 const first = children.slice(0, handleIndex).reverse().find(
425 child => child.hasAttribute("data-zen-panel"),
426 );
427 const second = children.slice(handleIndex + 1).find(
428 child => child.hasAttribute("data-zen-panel"),
429 );
430
431 if (!first || !second || !handle) {
432 if (this._first) restoreElement(this._managed, this._first);
433 if (this._handle) restoreElement(this._managed, this._handle);
434 this._first = null;
435 this._second = null;
436 this._handle = null;
437 this._percent = null;
438 return;
439 }
440 if (this._first && this._first !== first) restoreElement(this._managed, this._first);
441 if (this._handle && this._handle !== handle) {
442 restoreElement(this._managed, this._handle);
443 }
444 this._first = first;
445 this._second = second;
446 this._handle = handle;
447
448 setManagedAttribute(this._managed, this._handle, "role", "separator");
449 setManagedAttribute(
450 this._managed,
451 this._handle,
452 "aria-orientation",
453 "vertical",
454 );
455 setDefaultAttribute(this._managed, this._handle, "tabindex", "0");
456
457 if (!Number.isFinite(this._percent)) {
458 const containerWidth = this.getBoundingClientRect().width;
459 const rendered = containerWidth
460 ? this._first.getBoundingClientRect().width / containerWidth * 100
461 : Number.parseFloat(this._first.style.flexBasis);
462 this._percent = Number.isFinite(rendered) ? rendered : 50;
463 }
464 this.setPercent(this._percent, false);
465 }
466
467 setPercent(percent, notify) {
468 if (!this._first || !this._handle) return;
469 const { min, max } = this.limits();
470 const next = Math.round(Math.max(min, Math.min(max, percent)) * 100) / 100;
471 const changed = next !== this._percent;
472 this._percent = next;
473 rememberAttribute(this._managed, this._first, "style");
474 this._first.style.flexBasis = `${next}%`;
475 setManagedAttribute(this._managed, this._handle, "aria-valuemin", String(min));
476 setManagedAttribute(this._managed, this._handle, "aria-valuemax", String(max));
477 setManagedAttribute(this._managed, this._handle, "aria-valuenow", String(next));
478 if (notify && changed) emit(this, "zen-resize", { percent: next });
479 }
480 }
481
482 /**
483 * Adds stable client-side sorting to a native HTML table.
484 *
485 * @extends {HTMLElement}
486 */
487 export class ZenDataTable extends HTMLElementBase {
488 connectedCallback() {
489 this._managed ||= new Map();
490 this._onClick ||= event => {
491 const button = eventElement(event)?.closest("button[data-zen-sort]");
492 if (!this._buttons.includes(button) || isDisabled(button)) return;
493 this.sort(button);
494 };
495 this.addEventListener("click", this._onClick);
496 this._observer ||= new MutationObserver(() => this.sync());
497 this._observer.observe(this, { childList: true, subtree: true });
498 this.sync();
499 }
500
501 disconnectedCallback() {
502 this._observer?.disconnect();
503 this.removeEventListener("click", this._onClick);
504 restoreAttributes(this._managed);
505 this._table = null;
506 this._buttons = [];
507 }
508
509 sync() {
510 this._table =
511 this.querySelector(":scope > table") || this.querySelector("table");
512 this._buttons = this._table
513 ? [...this._table.querySelectorAll("button[data-zen-sort]")].filter(
514 button => button.closest("table") === this._table &&
515 button.closest("th"),
516 )
517 : [];
518 pruneAttributes(
519 this._managed,
520 new Set(this._buttons.map(button => button.closest("th"))),
521 );
522 }
523
524 sort(button) {
525 const key = button.getAttribute("data-zen-sort");
526 const header = button.closest("th");
527 if (!this._table || !key || !header) return;
528 const direction =
529 header.getAttribute("aria-sort") === "ascending"
530 ? "descending"
531 : "ascending";
532
533 for (const candidate of this._buttons) {
534 const candidateHeader = candidate.closest("th");
535 setManagedAttribute(
536 this._managed,
537 candidateHeader,
538 "aria-sort",
539 candidateHeader === header ? direction : null,
540 );
541 }
542
543 for (const body of this._table.tBodies) {
544 const entries = [...body.rows].map((row, index) => {
545 const cell = [...row.cells].find(
546 candidate => candidate.getAttribute("data-key") === key,
547 );
548 return { index, row, value: cell?.textContent?.trim() ?? "" };
549 });
550 const numeric = entries.every(
551 entry => Number.isFinite(Number(entry.value)),
552 );
553 const factor = direction === "ascending" ? 1 : -1;
554 entries.sort((left, right) => {
555 const compared = numeric
556 ? Number(left.value) - Number(right.value)
557 : left.value.localeCompare(right.value);
558 return compared ? compared * factor : left.index - right.index;
559 });
560 body.append(...entries.map(entry => entry.row));
561 }
562 emit(this, "zen-sort", { column: key, direction });
563 }
564 }
565
566 const definitions = {
567 "zen-carousel": ZenCarousel,
568 "zen-data-table": ZenDataTable,
569 "zen-message-scroller": ZenMessageScroller,
570 "zen-resizable": ZenResizable,
571 };
572
573 if (globalThis.customElements) {
574 for (const [name, definition] of Object.entries(definitions)) {
575 if (!globalThis.customElements.get(name)) {
576 globalThis.customElements.define(name, definition);
577 }
578 }
579 }