@adia-ai/web-components 0.8.37 → 0.8.39

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/MIGRATION.md +9 -7
  3. package/README.md +3 -3
  4. package/bin/doc.mjs +27 -5
  5. package/components/calendar-picker/calendar-picker.css +4 -1
  6. package/components/card/card.css +1 -1
  7. package/components/combobox/combobox.css +6 -0
  8. package/components/command/command.a2ui.json +3 -0
  9. package/components/command/command.class.js +28 -6
  10. package/components/command/command.css +14 -3
  11. package/components/command/command.yaml +5 -0
  12. package/components/date-range-picker/date-range-picker.css +6 -0
  13. package/components/datetime-picker/datetime-picker.css +4 -0
  14. package/components/drilldown/drilldown.a2ui.json +244 -0
  15. package/components/drilldown/drilldown.class.js +550 -0
  16. package/components/drilldown/drilldown.css +304 -0
  17. package/components/drilldown/drilldown.d.ts +68 -0
  18. package/components/drilldown/drilldown.examples.md +20 -0
  19. package/components/drilldown/drilldown.js +17 -0
  20. package/components/drilldown/drilldown.yaml +273 -0
  21. package/components/index.js +1 -0
  22. package/components/modal/modal.class.js +68 -4
  23. package/components/nav/nav.a2ui.json +5 -0
  24. package/components/nav/nav.class.js +35 -12
  25. package/components/nav/nav.d.ts +2 -0
  26. package/components/nav/nav.yaml +18 -0
  27. package/components/nav-item/nav-item.class.js +9 -6
  28. package/components/page/page.a2ui.json +13 -1
  29. package/components/page/page.css +113 -0
  30. package/components/page/page.yaml +30 -3
  31. package/components/select/select.class.js +30 -12
  32. package/components/select/select.css +11 -2
  33. package/components/swatch/swatch.css +6 -4
  34. package/components/toggle-group/toggle-group.class.js +21 -11
  35. package/components/toggle-group/toggle-group.css +16 -8
  36. package/components/toggle-group/toggle-group.d.ts +6 -0
  37. package/components/toggle-group/toggle-group.yaml +10 -0
  38. package/components/toggle-group/toggle-option.a2ui.json +5 -0
  39. package/components/toggle-group/toggle-option.yaml +18 -2
  40. package/custom-elements.json +277 -100
  41. package/dist/theme-provider.min.js +3 -3
  42. package/dist/web-components.min.css +1 -1
  43. package/dist/web-components.min.js +110 -88
  44. package/dist/web-components.sheet.js +1 -1
  45. package/package.json +1 -1
  46. package/patterns/bulk-action-toolbar/bulk-action-toolbar.examples.html +1 -1
  47. package/styles/components.css +1 -0
  48. package/traits/view-transition/view-transition.js +7 -0
@@ -0,0 +1,550 @@
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
+ // No native <input> wrap (per ADR-0055 / ADR-0025): the filter field
185
+ // composes <input-ui> — the search-ui precedent — instead of stamping
186
+ // a raw <input>. `raw` strips input-ui's own chrome so it blends into
187
+ // this row's own icon + border-bottom (drilldown.css owns the layout).
188
+ this.innerHTML = `
189
+ <div slot="header">
190
+ <button-ui slot="back" icon="caret-left" variant="ghost" size="sm" hidden>
191
+ <span data-back-label></span>
192
+ </button-ui>
193
+ <div slot="breadcrumb"></div>
194
+ <div slot="title">
195
+ <span data-level-title></span>
196
+ <span data-level-count></span>
197
+ </div>
198
+ </div>
199
+ <div slot="filter" hidden>
200
+ <icon-ui name="magnifying-glass"></icon-ui>
201
+ <input-ui raw></input-ui>
202
+ </div>
203
+ <div slot="viewport">
204
+ <div slot="panel" role="listbox"></div>
205
+ </div>
206
+ <div slot="live" aria-live="polite" data-visually-hidden></div>
207
+ `;
208
+
209
+ this.#backBtn = this.querySelector('[slot="back"]');
210
+ this.#backLabelEl = this.querySelector('[data-back-label]');
211
+ this.#titleEl = this.querySelector('[data-level-title]');
212
+ this.#countEl = this.querySelector('[data-level-count]');
213
+ this.#filterRowEl = this.querySelector('[slot="filter"]');
214
+ this.#filterInputEl = this.querySelector('[slot="filter"] input-ui');
215
+ this.#panelEl = this.querySelector('[slot="panel"]');
216
+ this.#liveEl = this.querySelector('[slot="live"]');
217
+ this.#breadcrumbEl = this.querySelector('[slot="breadcrumb"]');
218
+
219
+ // Adopt a consumer-supplied [slot="breadcrumb"] child (static markup —
220
+ // not an interpolated collection, so a direct querySelector is safe;
221
+ // mirrors the `wrapper-trap-ok` carve-out documented in tree.class.js).
222
+ const declaredBreadcrumb = [...this.children].find(
223
+ (el) => el.getAttribute('slot') === 'breadcrumb' && el.parentElement === this,
224
+ );
225
+ if (declaredBreadcrumb && declaredBreadcrumb !== this.#breadcrumbEl) {
226
+ this.#breadcrumbEl.replaceWith(declaredBreadcrumb);
227
+ this.#breadcrumbEl = declaredBreadcrumb;
228
+ }
229
+
230
+ this.#backBtn.addEventListener('click', this.#onBackClick);
231
+ this.#filterInputEl.addEventListener('input', this.#onFilterInput);
232
+ this.#panelEl.addEventListener('click', this.#onPanelClick);
233
+ this.#stamped = true;
234
+ }
235
+
236
+ // ── Prop-driven chrome sync (filterable / placeholder) — cheap, run every render() ──
237
+
238
+ #syncChrome() {
239
+ this.#filterRowEl.hidden = !this.filterable;
240
+ this.#filterInputEl.setAttribute('placeholder', this.placeholder);
241
+ }
242
+
243
+ // ── Tree walking ──
244
+
245
+ #findChild(list, value) {
246
+ return (list || []).find((it) => String(it.value) === String(value)) || null;
247
+ }
248
+
249
+ /** Resolve `item.children` to an array, or `'loading'`/`'pending'` sentinel. */
250
+ #resolveChildren(item) {
251
+ if (!item || item.leaf) return [];
252
+ const raw = item.children;
253
+ if (raw == null) return [];
254
+ if (Array.isArray(raw)) return raw;
255
+ if (typeof raw === 'function') {
256
+ if (this.#childrenCache.has(item)) return this.#childrenCache.get(item);
257
+ if (this.#pendingLoads.has(item)) return 'loading';
258
+ let result;
259
+ try {
260
+ result = raw(item);
261
+ } catch {
262
+ result = [];
263
+ }
264
+ if (result && typeof result.then === 'function') {
265
+ this.#pendingLoads.set(item, result);
266
+ result.then((resolved) => {
267
+ this.#pendingLoads.delete(item);
268
+ this.#childrenCache.set(item, Array.isArray(resolved) ? resolved : []);
269
+ this.#renderLevel(null);
270
+ }).catch(() => {
271
+ this.#pendingLoads.delete(item);
272
+ this.#childrenCache.set(item, []);
273
+ this.#renderLevel(null);
274
+ });
275
+ return 'loading';
276
+ }
277
+ const resolved = Array.isArray(result) ? result : [];
278
+ this.#childrenCache.set(item, resolved);
279
+ return resolved;
280
+ }
281
+ return [];
282
+ }
283
+
284
+ #hasChildren(item) {
285
+ if (!item || item.leaf) return false;
286
+ if (Array.isArray(item.children)) return item.children.length > 0;
287
+ return typeof item.children === 'function';
288
+ }
289
+
290
+ /** Walk `#items` through `path`, returning { item, list } for the current level. `item` is null at root. */
291
+ #resolveLevel(path) {
292
+ let list = this.#items;
293
+ let item = null;
294
+ for (const value of path) {
295
+ item = this.#findChild(list, value);
296
+ if (!item) return { item: null, list: [] };
297
+ const children = this.#resolveChildren(item);
298
+ list = children === 'loading' ? 'loading' : children;
299
+ if (list === 'loading') return { item, list: 'loading' };
300
+ }
301
+ return { item, list };
302
+ }
303
+
304
+ // ── Render current level ──
305
+
306
+ #renderLevel(direction) {
307
+ if (!this.#stamped) return;
308
+ // untracked: this rebuilds the row DOM from scratch on every call,
309
+ // creating fresh icon-ui/skeleton-ui/empty-state-ui children each
310
+ // time. render() (the caller) runs inside the base class's tracked
311
+ // effect WITHOUT an untracked() wrap (only connected() gets one) —
312
+ // a child's own reactive-property reads/writes during its
313
+ // synchronous connectedCallback would otherwise be attributed to
314
+ // THIS effect, and recreating that same child on every subsequent
315
+ // render re-triggers it, oscillating (the signals drain-loop guard
316
+ // traps exactly this shape — see element.js's own identical
317
+ // rationale for wrapping connected()).
318
+ untracked(() => this.#renderLevelImpl(direction));
319
+ }
320
+
321
+ #renderLevelImpl(direction) {
322
+ this.#syncChrome();
323
+
324
+ const path = this.path;
325
+ const { item: currentItem, list } = this.#resolveLevel(path);
326
+
327
+ // Header
328
+ const title = currentItem ? (currentItem.label ?? '') : (this.rootLabel || '');
329
+ this.#titleEl.textContent = title;
330
+ this.#backBtn.hidden = path.length === 0;
331
+ if (path.length > 0) {
332
+ const { item: parentItem } = this.#resolveLevel(path.slice(0, -1));
333
+ this.#backLabelEl.textContent = parentItem ? (parentItem.label ?? '') : (this.rootLabel || '');
334
+ }
335
+
336
+ const loading = list === 'loading';
337
+ const items = loading ? [] : list;
338
+ const q = this.#filterQuery.trim().toLowerCase();
339
+ const visible = q
340
+ ? items.filter((it) =>
341
+ (it.label || '').toLowerCase().includes(q) ||
342
+ (it.meta || '').toLowerCase().includes(q))
343
+ : items;
344
+
345
+ this.#countEl.textContent = loading ? '' : String(items.length);
346
+
347
+ // Body
348
+ this.#panelEl.setAttribute('aria-busy', String(loading));
349
+ this.#panelEl.setAttribute('aria-label', title || 'Items');
350
+ this.#panelEl.innerHTML = '';
351
+ this.#panelEl.removeAttribute('data-anim');
352
+
353
+ if (loading) {
354
+ for (let i = 0; i < 4; i++) {
355
+ const sk = document.createElement('skeleton-ui');
356
+ sk.setAttribute('height', '2.25rem');
357
+ sk.setAttribute('data-drilldown-skeleton', '');
358
+ this.#panelEl.appendChild(sk);
359
+ }
360
+ } else if (!visible.length) {
361
+ const empty = document.createElement('empty-state-ui');
362
+ empty.setAttribute('minimal', '');
363
+ empty.setAttribute('heading', q ? 'No matches' : 'No items');
364
+ this.#panelEl.appendChild(empty);
365
+ } else {
366
+ for (const it of visible) this.#panelEl.appendChild(this.#createRow(it));
367
+ }
368
+
369
+ if (!this.static) {
370
+ if (direction === 'forward') this.#panelEl.setAttribute('data-anim', 'forward');
371
+ else if (direction === 'backward') this.#panelEl.setAttribute('data-anim', 'backward');
372
+ }
373
+
374
+ // Focus handling — the row list is rebuilt from scratch above, so even
375
+ // an unchanged `#activeValue` needs its tabindex reasserted on the
376
+ // FRESH node (a persisted value match is not the same DOM element).
377
+ const rows = [...this.#panelEl.querySelectorAll('[role="option"]')];
378
+ if (this.#focusOnNextRender === 'first' && rows[0]) {
379
+ this.#activate(rows[0]);
380
+ rows[0].focus();
381
+ } else if (this.#focusOnNextRender && this.#focusOnNextRender.restoreValue != null) {
382
+ const target = rows.find((r) => r.dataset.value === String(this.#focusOnNextRender.restoreValue));
383
+ if (target) {
384
+ this.#activate(target);
385
+ target.focus();
386
+ } else if (rows[0]) {
387
+ this.#activate(rows[0]);
388
+ }
389
+ } else {
390
+ const keep = rows.find((r) => r.dataset.value === this.#activeValue);
391
+ if (keep) this.#activate(keep);
392
+ else if (rows[0]) this.#activate(rows[0]);
393
+ }
394
+ this.#focusOnNextRender = null;
395
+
396
+ // aria-live announcement
397
+ const count = loading ? '' : ` — ${items.length} item${items.length === 1 ? '' : 's'}`;
398
+ this.#liveEl.textContent = `${title || 'Root'}${count}`;
399
+ }
400
+
401
+ #escAttr(s) {
402
+ return String(s ?? '').replace(/"/g, '&quot;');
403
+ }
404
+ #escText(s) {
405
+ return String(s ?? '')
406
+ .replace(/&/g, '&amp;')
407
+ .replace(/</g, '&lt;')
408
+ .replace(/>/g, '&gt;');
409
+ }
410
+
411
+ #createRow(item) {
412
+ const hasChildren = this.#hasChildren(item);
413
+ const row = document.createElement('div');
414
+ row.setAttribute('role', 'option');
415
+ row.setAttribute('tabindex', '-1');
416
+ row.setAttribute('aria-selected', 'false');
417
+ row.dataset.value = String(item.value);
418
+ if (!hasChildren) row.setAttribute('data-leaf', '');
419
+
420
+ let html = '';
421
+ if (item.dot) {
422
+ html += `<span slot="dot" data-tone="${this.#escAttr(item.dot)}"></span>`;
423
+ }
424
+ html += `<div slot="main"><span slot="label">${this.#escText(item.label)}</span>`;
425
+ if (item.meta) html += `<span slot="meta">${this.#escText(item.meta)}</span>`;
426
+ html += `</div>`;
427
+ const counts = Array.isArray(item.counts) ? item.counts : [];
428
+ if (counts.length) {
429
+ html += `<span slot="counts">${counts
430
+ .map((c) => `<span data-count-chip data-tone="${this.#escAttr(c.variant || 'neutral')}">${this.#escText(c.value)}</span>`)
431
+ .join('')}</span>`;
432
+ }
433
+ if (hasChildren) html += `<icon-ui name="caret-right" slot="chevron"></icon-ui>`;
434
+ row.innerHTML = html;
435
+
436
+ this.#rowByEl.set(row, item);
437
+ return row;
438
+ }
439
+
440
+ // ── Roving tabindex ──
441
+
442
+ #activate(row) {
443
+ const prev = this.#panelEl.querySelector('[role="option"][tabindex="0"]');
444
+ if (prev && prev !== row) prev.setAttribute('tabindex', '-1');
445
+ row.setAttribute('tabindex', '0');
446
+ this.#activeValue = row.dataset.value;
447
+ }
448
+
449
+ #moveActive(dir) {
450
+ const rows = [...this.#panelEl.querySelectorAll('[role="option"]')];
451
+ if (!rows.length) return;
452
+ const current = this.#panelEl.querySelector('[role="option"][tabindex="0"]');
453
+ let idx = current ? rows.indexOf(current) : -1;
454
+ idx = (idx + dir + rows.length) % rows.length;
455
+ this.#activate(rows[idx]);
456
+ rows[idx].focus();
457
+ }
458
+
459
+ // ── Navigation actions ──
460
+
461
+ #drillInto(item) {
462
+ const path = this.path;
463
+ this.#drillFrom[path.length] = item.value;
464
+ this.#focusOnNextRender = 'first';
465
+ this.path = [...path, item.value];
466
+ }
467
+
468
+ back() {
469
+ const path = this.path;
470
+ if (!path.length) return;
471
+ const restoreValue = this.#drillFrom[path.length - 1];
472
+ this.#focusOnNextRender = { restoreValue };
473
+ this.path = path.slice(0, -1);
474
+ }
475
+
476
+ #fireSelect(item) {
477
+ this.dispatchEvent(new CustomEvent('select', {
478
+ bubbles: true,
479
+ detail: { path: this.path, item },
480
+ }));
481
+ }
482
+
483
+ #activateItem(item) {
484
+ const hasChildren = this.#hasChildren(item);
485
+ if (!hasChildren) {
486
+ this.#fireSelect(item);
487
+ return;
488
+ }
489
+ if (this.selectOnDrill) this.#fireSelect(item);
490
+ this.#drillInto(item);
491
+ }
492
+
493
+ // ── Event handlers (stable bound arrows — symmetric add/remove) ──
494
+
495
+ #onBackClick = () => this.back();
496
+
497
+ #onFilterInput = () => {
498
+ this.#filterQuery = this.#filterInputEl.value;
499
+ this.#renderLevel(null);
500
+ };
501
+
502
+ #onPanelClick = (e) => {
503
+ const row = e.target instanceof Element ? e.target.closest('[role="option"]') : null;
504
+ if (!row) return;
505
+ const item = this.#rowByEl.get(row);
506
+ if (!item) return;
507
+ this.#activate(row);
508
+ this.#activateItem(item);
509
+ };
510
+
511
+ #onKeydown = (e) => {
512
+ const row = e.target instanceof Element ? e.target.closest('[role="option"]') : null;
513
+ switch (e.key) {
514
+ case 'ArrowDown':
515
+ e.preventDefault();
516
+ this.#moveActive(1);
517
+ break;
518
+ case 'ArrowUp':
519
+ e.preventDefault();
520
+ this.#moveActive(-1);
521
+ break;
522
+ case 'ArrowRight': {
523
+ if (!row) break;
524
+ e.preventDefault();
525
+ const item = this.#rowByEl.get(row);
526
+ if (item && this.#hasChildren(item)) this.#activateItem(item);
527
+ break;
528
+ }
529
+ case 'ArrowLeft':
530
+ case 'Backspace':
531
+ // input-ui's editable surface is a light-DOM contenteditable span
532
+ // INSIDE the composed <input-ui> — e.target on a keydown fired while
533
+ // typing is that inner span, not the <input-ui> host itself (unlike
534
+ // the former native <input>, where target === the field element).
535
+ if (e.key === 'Backspace' && this.#filterInputEl?.contains(e.target)) break;
536
+ e.preventDefault();
537
+ this.back();
538
+ break;
539
+ case 'Enter':
540
+ case ' ':
541
+ if (!row) break;
542
+ e.preventDefault();
543
+ {
544
+ const item = this.#rowByEl.get(row);
545
+ if (item) this.#activateItem(item);
546
+ }
547
+ break;
548
+ }
549
+ };
550
+ }