@adia-ai/web-components 0.8.37 → 0.8.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,542 @@
1
+ /**
2
+ * Non-side-effect class export for `<drilldown-ui>`.
3
+ *
4
+ * Importing this file gives you the class without auto-registering the tag.
5
+ * Useful for test isolation, subclassing with tag-name override, or selective
6
+ * composition.
7
+ *
8
+ * The auto-register path stays at `@adia-ai/web-components/components/drilldown`
9
+ * (which imports this file + calls `defineIfFree()`).
10
+ *
11
+ * @see ../../USAGE.md#registration--auto-vs-explicit
12
+ */
13
+
14
+ import { UIElement, untracked } from '../../core/element.js';
15
+
16
+ /**
17
+ * <drilldown-ui> — Single-panel, multi-level drill-in menu.
18
+ *
19
+ * Data-driven, content-only primitive (gh#1285) — set `.items` to a tree of
20
+ * `{value, label, meta?, dot?, counts?, children?, leaf?}` nodes; `children`
21
+ * may be a sync array or a `(item) => array | Promise<array>` lazy loader.
22
+ *
23
+ * Events:
24
+ * select — leaf activation (or branch activation with [select-on-drill]).
25
+ * detail: { path, item }
26
+ * navigate — the shown level changed (drill / back / jump / `.path` set).
27
+ * detail: { path }
28
+ */
29
+ export class UIDrilldown extends UIElement {
30
+ static requiredIcons = ['caret-left', 'caret-right', 'magnifying-glass'];
31
+
32
+ static properties = {
33
+ rootLabel: { type: String, default: '', reflect: true, attribute: 'root-label' },
34
+ filterable: { type: Boolean, default: false, reflect: true },
35
+ placeholder: { type: String, default: 'Filter…', reflect: true },
36
+ selectOnDrill:{ type: Boolean, default: false, reflect: true, attribute: 'select-on-drill' },
37
+ static: { type: Boolean, default: false, reflect: true },
38
+ };
39
+
40
+ // `path` is deliberately NOT in `static properties` (mirrors `items`,
41
+ // below — `dynamic: true` in the yaml, `audit-static-properties-vs-yaml`
42
+ // exempted): array values need real JSON encode/decode, which the
43
+ // generic reflect() helper can't do (it falls back to `String(v)` —
44
+ // a lossy comma-join — for anything that isn't Boolean). `path` still
45
+ // genuinely reflects to the `path` attribute — hand-rolled below via
46
+ // `static get observedAttributes()` + `attributeChangedCallback()` —
47
+ // it just doesn't route through the declarative property system.
48
+ static get observedAttributes() {
49
+ return [...super.observedAttributes, 'path'];
50
+ }
51
+
52
+ static template = () => null;
53
+
54
+ // ── Data ──
55
+ #items = [];
56
+ #childrenCache = new WeakMap(); // resolved-children-array, keyed by item object
57
+ #pendingLoads = new WeakMap(); // in-flight Promise, keyed by item object
58
+ #drillFrom = []; // per-depth: the value that was drilled FROM (for back-focus restore)
59
+ #filterQuery = '';
60
+
61
+ // ── DOM refs (stamped once) ──
62
+ #stamped = false;
63
+ #backBtn = null;
64
+ #backLabelEl = null;
65
+ #titleEl = null;
66
+ #countEl = null;
67
+ #filterRowEl = null;
68
+ #filterInputEl = null;
69
+ #panelEl = null;
70
+ #liveEl = null;
71
+ #breadcrumbEl = null;
72
+ #rowByEl = new WeakMap();
73
+ #activeValue = null;
74
+ #focusOnNextRender = null; // 'first' | { restoreValue } | null
75
+ #path = []; // current drilled-in level (array of ancestor values)
76
+ #applyingPathAttr = false; // re-entrancy guard: our own setAttribute('path', …) below
77
+
78
+ // ── Public data prop (mirrors command-ui's `.options` — JS-only, not
79
+ // reflected: object-array values don't round-trip through attributes) ──
80
+
81
+ get items() { return this.#items; }
82
+ set items(list) {
83
+ this.#items = Array.isArray(list) ? list : [];
84
+ this.#childrenCache = new WeakMap();
85
+ this.#pendingLoads = new WeakMap();
86
+ if (this.#stamped) this.#renderLevel(null);
87
+ }
88
+
89
+ // ── Public `path` accessor — hand-rolled JSON attribute reflection
90
+ // (see the `static get observedAttributes()` note above `static
91
+ // properties`). Setting `.path` is fully synchronous: it updates
92
+ // internal state, reflects the attribute, and re-renders the level
93
+ // in the SAME call — no dependency on the signals-effect scheduler,
94
+ // so `#focusOnNextRender` (set by `#drillInto()`/`back()` immediately
95
+ // before assigning `.path`) is always consumed by the render its own
96
+ // assignment triggers. ──
97
+
98
+ get path() { return [...this.#path]; }
99
+ set path(arr) {
100
+ this.#setPath(Array.isArray(arr) ? arr.map(String) : [], false);
101
+ }
102
+
103
+ #setPath(next, fromAttribute) {
104
+ const prev = this.#path;
105
+ const pathChanged = prev.length !== next.length || next.some((v, i) => v !== prev[i]);
106
+ let direction = null;
107
+ if (pathChanged) {
108
+ if (next.length > prev.length && prev.every((v, i) => v === next[i])) direction = 'forward';
109
+ else if (prev.length > next.length && next.every((v, i) => v === prev[i])) direction = 'backward';
110
+ }
111
+ this.#path = next;
112
+
113
+ if (!fromAttribute) {
114
+ this.#applyingPathAttr = true;
115
+ if (next.length) this.setAttribute('path', JSON.stringify(next));
116
+ else this.removeAttribute('path');
117
+ this.#applyingPathAttr = false;
118
+ }
119
+
120
+ if (this.#stamped) {
121
+ this.#renderLevel(direction);
122
+ if (pathChanged) {
123
+ this.dispatchEvent(new CustomEvent('navigate', { bubbles: true, detail: { path: [...next] } }));
124
+ }
125
+ }
126
+ }
127
+
128
+ attributeChangedCallback(name, oldVal, newVal) {
129
+ if (name === 'path') {
130
+ if (this.#applyingPathAttr) return;
131
+ let parsed = [];
132
+ try {
133
+ const p = JSON.parse(newVal ?? '[]');
134
+ if (Array.isArray(p)) parsed = p.map(String);
135
+ } catch { /* malformed — treat as empty path */ }
136
+ this.#setPath(parsed, true);
137
+ return;
138
+ }
139
+ super.attributeChangedCallback(name, oldVal, newVal);
140
+ }
141
+
142
+ connected() {
143
+ this.setAttribute('role', 'group');
144
+ this.addEventListener('keydown', this.#onKeydown);
145
+ // gh#284-style upgrade replay: a `path` attribute present BEFORE
146
+ // upgrade isn't guaranteed to have replayed attributeChangedCallback
147
+ // in every DOM shim (see element.js's own identical rationale for
148
+ // its declared-property pre-sync loop, which does not cover `path`
149
+ // since it isn't in `static properties`). Sync explicitly here.
150
+ if (this.hasAttribute('path')) {
151
+ let parsed = [];
152
+ try {
153
+ const p = JSON.parse(this.getAttribute('path') ?? '[]');
154
+ if (Array.isArray(p)) parsed = p.map(String);
155
+ } catch { /* malformed — treat as empty path */ }
156
+ this.#path = parsed;
157
+ }
158
+ }
159
+
160
+ render() {
161
+ if (!this.#stamped) this.#stamp();
162
+ this.#renderLevel(null);
163
+ }
164
+
165
+ disconnected() {
166
+ this.removeEventListener('keydown', this.#onKeydown);
167
+ this.#filterInputEl?.removeEventListener('input', this.#onFilterInput);
168
+ this.#backBtn?.removeEventListener('click', this.#onBackClick);
169
+ this.#panelEl?.removeEventListener('click', this.#onPanelClick);
170
+ this.#panelEl = null;
171
+ this.#filterInputEl = null;
172
+ this.#backBtn = null;
173
+ this.#backLabelEl = null;
174
+ this.#titleEl = null;
175
+ this.#countEl = null;
176
+ this.#filterRowEl = null;
177
+ this.#liveEl = null;
178
+ this.#breadcrumbEl = null;
179
+ }
180
+
181
+ // ── Structural stamp (once) ──
182
+
183
+ #stamp() {
184
+ this.innerHTML = `
185
+ <div slot="header">
186
+ <button-ui slot="back" icon="caret-left" variant="ghost" size="sm" hidden>
187
+ <span data-back-label></span>
188
+ </button-ui>
189
+ <div slot="breadcrumb"></div>
190
+ <div slot="title">
191
+ <span data-level-title></span>
192
+ <span data-level-count></span>
193
+ </div>
194
+ </div>
195
+ <div slot="filter" hidden>
196
+ <icon-ui name="magnifying-glass"></icon-ui>
197
+ <input type="text" />
198
+ </div>
199
+ <div slot="viewport">
200
+ <div slot="panel" role="listbox"></div>
201
+ </div>
202
+ <div slot="live" aria-live="polite" data-visually-hidden></div>
203
+ `;
204
+
205
+ this.#backBtn = this.querySelector('[slot="back"]');
206
+ this.#backLabelEl = this.querySelector('[data-back-label]');
207
+ this.#titleEl = this.querySelector('[data-level-title]');
208
+ this.#countEl = this.querySelector('[data-level-count]');
209
+ this.#filterRowEl = this.querySelector('[slot="filter"]');
210
+ this.#filterInputEl = this.querySelector('[slot="filter"] input');
211
+ this.#panelEl = this.querySelector('[slot="panel"]');
212
+ this.#liveEl = this.querySelector('[slot="live"]');
213
+ this.#breadcrumbEl = this.querySelector('[slot="breadcrumb"]');
214
+
215
+ // Adopt a consumer-supplied [slot="breadcrumb"] child (static markup —
216
+ // not an interpolated collection, so a direct querySelector is safe;
217
+ // mirrors the `wrapper-trap-ok` carve-out documented in tree.class.js).
218
+ const declaredBreadcrumb = [...this.children].find(
219
+ (el) => el.getAttribute('slot') === 'breadcrumb' && el.parentElement === this,
220
+ );
221
+ if (declaredBreadcrumb && declaredBreadcrumb !== this.#breadcrumbEl) {
222
+ this.#breadcrumbEl.replaceWith(declaredBreadcrumb);
223
+ this.#breadcrumbEl = declaredBreadcrumb;
224
+ }
225
+
226
+ this.#backBtn.addEventListener('click', this.#onBackClick);
227
+ this.#filterInputEl.addEventListener('input', this.#onFilterInput);
228
+ this.#panelEl.addEventListener('click', this.#onPanelClick);
229
+ this.#stamped = true;
230
+ }
231
+
232
+ // ── Prop-driven chrome sync (filterable / placeholder) — cheap, run every render() ──
233
+
234
+ #syncChrome() {
235
+ this.#filterRowEl.hidden = !this.filterable;
236
+ this.#filterInputEl.placeholder = this.placeholder;
237
+ }
238
+
239
+ // ── Tree walking ──
240
+
241
+ #findChild(list, value) {
242
+ return (list || []).find((it) => String(it.value) === String(value)) || null;
243
+ }
244
+
245
+ /** Resolve `item.children` to an array, or `'loading'`/`'pending'` sentinel. */
246
+ #resolveChildren(item) {
247
+ if (!item || item.leaf) return [];
248
+ const raw = item.children;
249
+ if (raw == null) return [];
250
+ if (Array.isArray(raw)) return raw;
251
+ if (typeof raw === 'function') {
252
+ if (this.#childrenCache.has(item)) return this.#childrenCache.get(item);
253
+ if (this.#pendingLoads.has(item)) return 'loading';
254
+ let result;
255
+ try {
256
+ result = raw(item);
257
+ } catch {
258
+ result = [];
259
+ }
260
+ if (result && typeof result.then === 'function') {
261
+ this.#pendingLoads.set(item, result);
262
+ result.then((resolved) => {
263
+ this.#pendingLoads.delete(item);
264
+ this.#childrenCache.set(item, Array.isArray(resolved) ? resolved : []);
265
+ this.#renderLevel(null);
266
+ }).catch(() => {
267
+ this.#pendingLoads.delete(item);
268
+ this.#childrenCache.set(item, []);
269
+ this.#renderLevel(null);
270
+ });
271
+ return 'loading';
272
+ }
273
+ const resolved = Array.isArray(result) ? result : [];
274
+ this.#childrenCache.set(item, resolved);
275
+ return resolved;
276
+ }
277
+ return [];
278
+ }
279
+
280
+ #hasChildren(item) {
281
+ if (!item || item.leaf) return false;
282
+ if (Array.isArray(item.children)) return item.children.length > 0;
283
+ return typeof item.children === 'function';
284
+ }
285
+
286
+ /** Walk `#items` through `path`, returning { item, list } for the current level. `item` is null at root. */
287
+ #resolveLevel(path) {
288
+ let list = this.#items;
289
+ let item = null;
290
+ for (const value of path) {
291
+ item = this.#findChild(list, value);
292
+ if (!item) return { item: null, list: [] };
293
+ const children = this.#resolveChildren(item);
294
+ list = children === 'loading' ? 'loading' : children;
295
+ if (list === 'loading') return { item, list: 'loading' };
296
+ }
297
+ return { item, list };
298
+ }
299
+
300
+ // ── Render current level ──
301
+
302
+ #renderLevel(direction) {
303
+ if (!this.#stamped) return;
304
+ // untracked: this rebuilds the row DOM from scratch on every call,
305
+ // creating fresh icon-ui/skeleton-ui/empty-state-ui children each
306
+ // time. render() (the caller) runs inside the base class's tracked
307
+ // effect WITHOUT an untracked() wrap (only connected() gets one) —
308
+ // a child's own reactive-property reads/writes during its
309
+ // synchronous connectedCallback would otherwise be attributed to
310
+ // THIS effect, and recreating that same child on every subsequent
311
+ // render re-triggers it, oscillating (the signals drain-loop guard
312
+ // traps exactly this shape — see element.js's own identical
313
+ // rationale for wrapping connected()).
314
+ untracked(() => this.#renderLevelImpl(direction));
315
+ }
316
+
317
+ #renderLevelImpl(direction) {
318
+ this.#syncChrome();
319
+
320
+ const path = this.path;
321
+ const { item: currentItem, list } = this.#resolveLevel(path);
322
+
323
+ // Header
324
+ const title = currentItem ? (currentItem.label ?? '') : (this.rootLabel || '');
325
+ this.#titleEl.textContent = title;
326
+ this.#backBtn.hidden = path.length === 0;
327
+ if (path.length > 0) {
328
+ const { item: parentItem } = this.#resolveLevel(path.slice(0, -1));
329
+ this.#backLabelEl.textContent = parentItem ? (parentItem.label ?? '') : (this.rootLabel || '');
330
+ }
331
+
332
+ const loading = list === 'loading';
333
+ const items = loading ? [] : list;
334
+ const q = this.#filterQuery.trim().toLowerCase();
335
+ const visible = q
336
+ ? items.filter((it) =>
337
+ (it.label || '').toLowerCase().includes(q) ||
338
+ (it.meta || '').toLowerCase().includes(q))
339
+ : items;
340
+
341
+ this.#countEl.textContent = loading ? '' : String(items.length);
342
+
343
+ // Body
344
+ this.#panelEl.setAttribute('aria-busy', String(loading));
345
+ this.#panelEl.setAttribute('aria-label', title || 'Items');
346
+ this.#panelEl.innerHTML = '';
347
+ this.#panelEl.removeAttribute('data-anim');
348
+
349
+ if (loading) {
350
+ for (let i = 0; i < 4; i++) {
351
+ const sk = document.createElement('skeleton-ui');
352
+ sk.setAttribute('height', '2.25rem');
353
+ sk.setAttribute('data-drilldown-skeleton', '');
354
+ this.#panelEl.appendChild(sk);
355
+ }
356
+ } else if (!visible.length) {
357
+ const empty = document.createElement('empty-state-ui');
358
+ empty.setAttribute('minimal', '');
359
+ empty.setAttribute('heading', q ? 'No matches' : 'No items');
360
+ this.#panelEl.appendChild(empty);
361
+ } else {
362
+ for (const it of visible) this.#panelEl.appendChild(this.#createRow(it));
363
+ }
364
+
365
+ if (!this.static) {
366
+ if (direction === 'forward') this.#panelEl.setAttribute('data-anim', 'forward');
367
+ else if (direction === 'backward') this.#panelEl.setAttribute('data-anim', 'backward');
368
+ }
369
+
370
+ // Focus handling — the row list is rebuilt from scratch above, so even
371
+ // an unchanged `#activeValue` needs its tabindex reasserted on the
372
+ // FRESH node (a persisted value match is not the same DOM element).
373
+ const rows = [...this.#panelEl.querySelectorAll('[role="option"]')];
374
+ if (this.#focusOnNextRender === 'first' && rows[0]) {
375
+ this.#activate(rows[0]);
376
+ rows[0].focus();
377
+ } else if (this.#focusOnNextRender && this.#focusOnNextRender.restoreValue != null) {
378
+ const target = rows.find((r) => r.dataset.value === String(this.#focusOnNextRender.restoreValue));
379
+ if (target) {
380
+ this.#activate(target);
381
+ target.focus();
382
+ } else if (rows[0]) {
383
+ this.#activate(rows[0]);
384
+ }
385
+ } else {
386
+ const keep = rows.find((r) => r.dataset.value === this.#activeValue);
387
+ if (keep) this.#activate(keep);
388
+ else if (rows[0]) this.#activate(rows[0]);
389
+ }
390
+ this.#focusOnNextRender = null;
391
+
392
+ // aria-live announcement
393
+ const count = loading ? '' : ` — ${items.length} item${items.length === 1 ? '' : 's'}`;
394
+ this.#liveEl.textContent = `${title || 'Root'}${count}`;
395
+ }
396
+
397
+ #escAttr(s) {
398
+ return String(s ?? '').replace(/"/g, '&quot;');
399
+ }
400
+ #escText(s) {
401
+ return String(s ?? '')
402
+ .replace(/&/g, '&amp;')
403
+ .replace(/</g, '&lt;')
404
+ .replace(/>/g, '&gt;');
405
+ }
406
+
407
+ #createRow(item) {
408
+ const hasChildren = this.#hasChildren(item);
409
+ const row = document.createElement('div');
410
+ row.setAttribute('role', 'option');
411
+ row.setAttribute('tabindex', '-1');
412
+ row.setAttribute('aria-selected', 'false');
413
+ row.dataset.value = String(item.value);
414
+ if (!hasChildren) row.setAttribute('data-leaf', '');
415
+
416
+ let html = '';
417
+ if (item.dot) {
418
+ html += `<span slot="dot" data-tone="${this.#escAttr(item.dot)}"></span>`;
419
+ }
420
+ html += `<div slot="main"><span slot="label">${this.#escText(item.label)}</span>`;
421
+ if (item.meta) html += `<span slot="meta">${this.#escText(item.meta)}</span>`;
422
+ html += `</div>`;
423
+ const counts = Array.isArray(item.counts) ? item.counts : [];
424
+ if (counts.length) {
425
+ html += `<span slot="counts">${counts
426
+ .map((c) => `<span data-count-chip data-tone="${this.#escAttr(c.variant || 'neutral')}">${this.#escText(c.value)}</span>`)
427
+ .join('')}</span>`;
428
+ }
429
+ if (hasChildren) html += `<icon-ui name="caret-right" slot="chevron"></icon-ui>`;
430
+ row.innerHTML = html;
431
+
432
+ this.#rowByEl.set(row, item);
433
+ return row;
434
+ }
435
+
436
+ // ── Roving tabindex ──
437
+
438
+ #activate(row) {
439
+ const prev = this.#panelEl.querySelector('[role="option"][tabindex="0"]');
440
+ if (prev && prev !== row) prev.setAttribute('tabindex', '-1');
441
+ row.setAttribute('tabindex', '0');
442
+ this.#activeValue = row.dataset.value;
443
+ }
444
+
445
+ #moveActive(dir) {
446
+ const rows = [...this.#panelEl.querySelectorAll('[role="option"]')];
447
+ if (!rows.length) return;
448
+ const current = this.#panelEl.querySelector('[role="option"][tabindex="0"]');
449
+ let idx = current ? rows.indexOf(current) : -1;
450
+ idx = (idx + dir + rows.length) % rows.length;
451
+ this.#activate(rows[idx]);
452
+ rows[idx].focus();
453
+ }
454
+
455
+ // ── Navigation actions ──
456
+
457
+ #drillInto(item) {
458
+ const path = this.path;
459
+ this.#drillFrom[path.length] = item.value;
460
+ this.#focusOnNextRender = 'first';
461
+ this.path = [...path, item.value];
462
+ }
463
+
464
+ back() {
465
+ const path = this.path;
466
+ if (!path.length) return;
467
+ const restoreValue = this.#drillFrom[path.length - 1];
468
+ this.#focusOnNextRender = { restoreValue };
469
+ this.path = path.slice(0, -1);
470
+ }
471
+
472
+ #fireSelect(item) {
473
+ this.dispatchEvent(new CustomEvent('select', {
474
+ bubbles: true,
475
+ detail: { path: this.path, item },
476
+ }));
477
+ }
478
+
479
+ #activateItem(item) {
480
+ const hasChildren = this.#hasChildren(item);
481
+ if (!hasChildren) {
482
+ this.#fireSelect(item);
483
+ return;
484
+ }
485
+ if (this.selectOnDrill) this.#fireSelect(item);
486
+ this.#drillInto(item);
487
+ }
488
+
489
+ // ── Event handlers (stable bound arrows — symmetric add/remove) ──
490
+
491
+ #onBackClick = () => this.back();
492
+
493
+ #onFilterInput = () => {
494
+ this.#filterQuery = this.#filterInputEl.value;
495
+ this.#renderLevel(null);
496
+ };
497
+
498
+ #onPanelClick = (e) => {
499
+ const row = e.target instanceof Element ? e.target.closest('[role="option"]') : null;
500
+ if (!row) return;
501
+ const item = this.#rowByEl.get(row);
502
+ if (!item) return;
503
+ this.#activate(row);
504
+ this.#activateItem(item);
505
+ };
506
+
507
+ #onKeydown = (e) => {
508
+ const row = e.target instanceof Element ? e.target.closest('[role="option"]') : null;
509
+ switch (e.key) {
510
+ case 'ArrowDown':
511
+ e.preventDefault();
512
+ this.#moveActive(1);
513
+ break;
514
+ case 'ArrowUp':
515
+ e.preventDefault();
516
+ this.#moveActive(-1);
517
+ break;
518
+ case 'ArrowRight': {
519
+ if (!row) break;
520
+ e.preventDefault();
521
+ const item = this.#rowByEl.get(row);
522
+ if (item && this.#hasChildren(item)) this.#activateItem(item);
523
+ break;
524
+ }
525
+ case 'ArrowLeft':
526
+ case 'Backspace':
527
+ if (e.key === 'Backspace' && e.target === this.#filterInputEl) break;
528
+ e.preventDefault();
529
+ this.back();
530
+ break;
531
+ case 'Enter':
532
+ case ' ':
533
+ if (!row) break;
534
+ e.preventDefault();
535
+ {
536
+ const item = this.#rowByEl.get(row);
537
+ if (item) this.#activateItem(item);
538
+ }
539
+ break;
540
+ }
541
+ };
542
+ }