@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.
- package/CHANGELOG.md +36 -0
- package/MIGRATION.md +9 -7
- package/README.md +3 -3
- package/bin/doc.mjs +27 -5
- package/components/calendar-picker/calendar-picker.css +4 -1
- package/components/card/card.css +1 -1
- package/components/combobox/combobox.css +6 -0
- package/components/command/command.a2ui.json +3 -0
- package/components/command/command.class.js +28 -6
- package/components/command/command.css +14 -3
- package/components/command/command.yaml +5 -0
- package/components/date-range-picker/date-range-picker.css +6 -0
- package/components/datetime-picker/datetime-picker.css +4 -0
- package/components/drilldown/drilldown.a2ui.json +244 -0
- package/components/drilldown/drilldown.class.js +550 -0
- package/components/drilldown/drilldown.css +304 -0
- package/components/drilldown/drilldown.d.ts +68 -0
- package/components/drilldown/drilldown.examples.md +20 -0
- package/components/drilldown/drilldown.js +17 -0
- package/components/drilldown/drilldown.yaml +273 -0
- package/components/index.js +1 -0
- package/components/modal/modal.class.js +68 -4
- package/components/nav/nav.a2ui.json +5 -0
- package/components/nav/nav.class.js +35 -12
- package/components/nav/nav.d.ts +2 -0
- package/components/nav/nav.yaml +18 -0
- package/components/nav-item/nav-item.class.js +9 -6
- package/components/page/page.a2ui.json +13 -1
- package/components/page/page.css +113 -0
- package/components/page/page.yaml +30 -3
- package/components/select/select.class.js +30 -12
- package/components/select/select.css +11 -2
- package/components/swatch/swatch.css +6 -4
- package/components/toggle-group/toggle-group.class.js +21 -11
- package/components/toggle-group/toggle-group.css +16 -8
- package/components/toggle-group/toggle-group.d.ts +6 -0
- package/components/toggle-group/toggle-group.yaml +10 -0
- package/components/toggle-group/toggle-option.a2ui.json +5 -0
- package/components/toggle-group/toggle-option.yaml +18 -2
- package/custom-elements.json +277 -100
- package/dist/theme-provider.min.js +3 -3
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +110 -88
- package/dist/web-components.sheet.js +1 -1
- package/package.json +1 -1
- package/patterns/bulk-action-toolbar/bulk-action-toolbar.examples.html +1 -1
- package/styles/components.css +1 -0
- 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, '"');
|
|
403
|
+
}
|
|
404
|
+
#escText(s) {
|
|
405
|
+
return String(s ?? '')
|
|
406
|
+
.replace(/&/g, '&')
|
|
407
|
+
.replace(/</g, '<')
|
|
408
|
+
.replace(/>/g, '>');
|
|
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
|
+
}
|