@adia-ai/web-components 0.0.16 → 0.0.18

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,565 @@
1
+ /**
2
+ * <table-toolbar-ui for="emps" text="All Employees" count="32"></table-toolbar-ui>
3
+ *
4
+ * Companion / header bar for a sibling table-ui. Renders:
5
+ * • title + optional count badge
6
+ * • filter / sort / columns popover buttons
7
+ * • search input
8
+ * • optional [slot="actions"] trailing region
9
+ *
10
+ * [for] resolution mirrors chart-legend-ui — the toolbar mounts an element by
11
+ * id, then dispatches state changes against it. When [for] is absent, falls
12
+ * back to the first table-ui sibling under the same parent.
13
+ *
14
+ * Popovers use the platform Popover API + core/anchor.js, the same primitives
15
+ * that menu-ui / popover-ui / toolbar-ui already use in this package.
16
+ *
17
+ * State flow:
18
+ * search → table.search (string property)
19
+ * filters → table.setFilter() (per-key)
20
+ * sort → simulated click on table's [data-sort-key] header
21
+ * column hidden → table.columns = (clone with hidden flag flipped)
22
+ */
23
+
24
+ import { AdiaElement } from '../../core/element.js';
25
+ import { anchorPopover } from '../../core/anchor.js';
26
+
27
+ const SEARCH_DEBOUNCE = 200;
28
+
29
+ class AdiaTableToolbar extends AdiaElement {
30
+ static properties = {
31
+ for: { type: String, default: '', reflect: true },
32
+ text: { type: String, default: '', reflect: false },
33
+ count: { type: String, default: '', reflect: false },
34
+ filterable: { type: Boolean, default: true, reflect: true },
35
+ sortable: { type: Boolean, default: true, reflect: true },
36
+ columns: { type: Boolean, default: true, reflect: true },
37
+ searchable: { type: Boolean, default: true, reflect: true },
38
+ placeholder: { type: String, default: 'Search...', reflect: false },
39
+ variant: { type: String, default: 'default', reflect: true },
40
+ };
41
+
42
+ static template = () => null;
43
+
44
+ #target = null;
45
+ #targetListeners = [];
46
+ #searchTimer = null;
47
+ #activePopover = null; // { btn, panel, cleanup }
48
+ #docListenersBound = false;
49
+
50
+ // ── Lifecycle ────────────────────────────────────────────────────────────
51
+
52
+ connected() {
53
+ this.setAttribute('role', 'toolbar');
54
+ this.#stamp();
55
+ this.#resolveTarget();
56
+ this.#syncFromTarget();
57
+ }
58
+
59
+ disconnected() {
60
+ clearTimeout(this.#searchTimer);
61
+ this.#searchTimer = null;
62
+ this.#closePopover();
63
+ this.#detachTarget();
64
+ }
65
+
66
+ render() {
67
+ // [for] / count / text changes come through here.
68
+ this.#resolveTarget();
69
+ this.#syncFromTarget();
70
+ this.#updateTitle();
71
+ }
72
+
73
+ // ── Target resolution ────────────────────────────────────────────────────
74
+
75
+ #resolveTarget() {
76
+ const next = this.#findTarget();
77
+ if (next === this.#target) return;
78
+ this.#detachTarget();
79
+ if (!next) return;
80
+ this.#target = next;
81
+
82
+ const onSort = () => this.#refreshSortPanel();
83
+ const onFilter = () => this.#refreshFilterPanel();
84
+ next.addEventListener('sort', onSort);
85
+ next.addEventListener('filter-change', onFilter);
86
+ this.#targetListeners.push(['sort', onSort], ['filter-change', onFilter]);
87
+ }
88
+
89
+ #findTarget() {
90
+ if (this.for) {
91
+ const root = this.getRootNode?.();
92
+ const byId = root?.getElementById?.(this.for) || document.getElementById(this.for);
93
+ if (byId && byId.tagName?.toLowerCase() === 'table-ui') return byId;
94
+ return null;
95
+ }
96
+ // Fallback — first sibling table-ui in the same parent.
97
+ const parent = this.parentElement;
98
+ if (!parent) return null;
99
+ return parent.querySelector(':scope table-ui') || null;
100
+ }
101
+
102
+ #detachTarget() {
103
+ if (this.#target) {
104
+ for (const [evt, fn] of this.#targetListeners) {
105
+ this.#target.removeEventListener(evt, fn);
106
+ }
107
+ }
108
+ this.#targetListeners = [];
109
+ this.#target = null;
110
+ }
111
+
112
+ // ── DOM stamp ────────────────────────────────────────────────────────────
113
+
114
+ #stamp() {
115
+ if (this.querySelector(':scope > [data-toolbar]')) return;
116
+
117
+ const root = document.createElement('div');
118
+ root.setAttribute('data-toolbar', '');
119
+
120
+ // Title cluster
121
+ const title = document.createElement('div');
122
+ title.setAttribute('data-title', '');
123
+
124
+ const heading = document.createElement('span');
125
+ heading.setAttribute('data-heading', '');
126
+ title.appendChild(heading);
127
+
128
+ const badge = document.createElement('badge-ui');
129
+ badge.setAttribute('data-count-badge', '');
130
+ badge.setAttribute('size', 'sm');
131
+ badge.setAttribute('variant', 'muted');
132
+ badge.hidden = true;
133
+ title.appendChild(badge);
134
+
135
+ // Controls cluster
136
+ const controls = document.createElement('div');
137
+ controls.setAttribute('data-controls', '');
138
+ controls.appendChild(this.#mkButton('filter', 'Filter', 'funnel-simple'));
139
+ controls.appendChild(this.#mkButton('sort', 'Sort', 'arrows-down-up'));
140
+ controls.appendChild(this.#mkButton('columns', 'Columns', 'columns'));
141
+
142
+ // Search
143
+ const search = document.createElement('input-ui');
144
+ search.setAttribute('data-search', '');
145
+ search.setAttribute('type', 'search');
146
+ search.setAttribute('prefix', 'magnifying-glass');
147
+ search.setAttribute('placeholder', this.placeholder);
148
+ search.addEventListener('input', this.#onSearchInput);
149
+
150
+ // Actions slot passthrough — we move any pre-existing [slot="actions"] children here
151
+ const actionsSlot = document.createElement('div');
152
+ actionsSlot.setAttribute('data-actions', '');
153
+ for (const node of [...this.children]) {
154
+ if (node === root) continue;
155
+ if (node.getAttribute?.('slot') === 'actions') {
156
+ actionsSlot.appendChild(node);
157
+ }
158
+ }
159
+
160
+ root.appendChild(title);
161
+ root.appendChild(controls);
162
+ root.appendChild(search);
163
+ root.appendChild(actionsSlot);
164
+
165
+ this.appendChild(root);
166
+ this.#updateTitle();
167
+ this.#updateControlVisibility();
168
+ }
169
+
170
+ #mkPopoverAction(label, onClick) {
171
+ const btn = document.createElement('button-ui');
172
+ btn.setAttribute('data-popover-action', '');
173
+ btn.setAttribute('text', label);
174
+ btn.setAttribute('variant', 'ghost');
175
+ btn.setAttribute('size', 'sm');
176
+ btn.setAttribute('stretch', '');
177
+ btn.addEventListener('click', onClick);
178
+ return btn;
179
+ }
180
+
181
+ #mkButton(kind, label, icon) {
182
+ const btn = document.createElement('button-ui');
183
+ btn.setAttribute('data-toolbar-btn', kind);
184
+ btn.setAttribute('icon', icon);
185
+ btn.setAttribute('text', label);
186
+ btn.setAttribute('variant', 'outline');
187
+ btn.setAttribute('size', 'sm');
188
+ btn.setAttribute('aria-haspopup', 'menu');
189
+ btn.addEventListener('click', (e) => {
190
+ e.stopPropagation();
191
+ this.#togglePopover(kind, btn);
192
+ });
193
+ return btn;
194
+ }
195
+
196
+ #updateTitle() {
197
+ const heading = this.querySelector(':scope [data-heading]');
198
+ if (!heading) return;
199
+ if (this.text) {
200
+ heading.textContent = this.text;
201
+ heading.hidden = false;
202
+ } else if (heading.textContent.trim()) {
203
+ heading.hidden = false;
204
+ } else {
205
+ heading.hidden = true;
206
+ }
207
+
208
+ const badge = this.querySelector(':scope [data-count-badge]');
209
+ if (!badge) return;
210
+ const explicit = this.count?.toString().trim();
211
+ const fallback = this.#target?.data?.length;
212
+ const value = explicit || (Number.isFinite(fallback) ? String(fallback) : '');
213
+ if (value) {
214
+ badge.setAttribute('text', value);
215
+ badge.hidden = false;
216
+ } else {
217
+ badge.hidden = true;
218
+ }
219
+ }
220
+
221
+ #updateControlVisibility() {
222
+ const root = this.querySelector(':scope > [data-toolbar]');
223
+ if (!root) return;
224
+
225
+ const setHidden = (sel, hidden) => {
226
+ const el = root.querySelector(sel);
227
+ if (el) el.hidden = hidden;
228
+ };
229
+
230
+ setHidden('[data-toolbar-btn="filter"]', !this.filterable);
231
+ setHidden('[data-toolbar-btn="sort"]', !this.sortable);
232
+ setHidden('[data-toolbar-btn="columns"]', !this.columns);
233
+ setHidden('[data-search]', !this.searchable);
234
+ }
235
+
236
+ // Re-run on attribute changes for boolean flags.
237
+ attributeChanged(name) {
238
+ if (['filterable', 'sortable', 'columns', 'searchable'].includes(name)) {
239
+ this.#updateControlVisibility();
240
+ }
241
+ if (name === 'placeholder') {
242
+ const search = this.querySelector(':scope [data-search]');
243
+ search?.setAttribute('placeholder', this.placeholder);
244
+ }
245
+ }
246
+
247
+ // ── Search ───────────────────────────────────────────────────────────────
248
+
249
+ #onSearchInput = (e) => {
250
+ const value = e.target?.value ?? '';
251
+ clearTimeout(this.#searchTimer);
252
+ this.#searchTimer = setTimeout(() => {
253
+ if (this.#target) this.#target.search = value;
254
+ this.dispatchEvent(new CustomEvent('search', {
255
+ bubbles: true,
256
+ detail: { value },
257
+ }));
258
+ }, SEARCH_DEBOUNCE);
259
+ };
260
+
261
+ // ── Sync from target (initial paint) ─────────────────────────────────────
262
+
263
+ #syncFromTarget() {
264
+ if (!this.#target) return;
265
+ const search = this.querySelector(':scope [data-search]');
266
+ if (search && this.#target.search) {
267
+ search.value = this.#target.search;
268
+ }
269
+ this.#updateTitle();
270
+ }
271
+
272
+ // ── Popovers ─────────────────────────────────────────────────────────────
273
+
274
+ #togglePopover(kind, btn) {
275
+ if (this.#activePopover?.kind === kind) {
276
+ this.#closePopover();
277
+ return;
278
+ }
279
+ this.#closePopover();
280
+
281
+ const panel = document.createElement('div');
282
+ panel.setAttribute('data-toolbar-popover', kind);
283
+ panel.setAttribute('popover', 'manual');
284
+ panel.setAttribute('role', 'menu');
285
+
286
+ if (kind === 'filter') this.#fillFilterPanel(panel);
287
+ if (kind === 'sort') this.#fillSortPanel(panel);
288
+ if (kind === 'columns') this.#fillColumnsPanel(panel);
289
+
290
+ document.body.appendChild(panel);
291
+
292
+ try { panel.showPopover(); } catch { /* popover API unavailable */ }
293
+ const cleanup = anchorPopover(btn, panel, { placement: 'bottom-start', gap: 4 });
294
+
295
+ this.#activePopover = { kind, btn, panel, cleanup };
296
+
297
+ if (!this.#docListenersBound) {
298
+ this.#docListenersBound = true;
299
+ requestAnimationFrame(() => {
300
+ document.addEventListener('pointerdown', this.#onDocDown, true);
301
+ document.addEventListener('keydown', this.#onDocKey, true);
302
+ });
303
+ }
304
+ }
305
+
306
+ #closePopover() {
307
+ const ap = this.#activePopover;
308
+ if (!ap) return;
309
+ ap.cleanup?.();
310
+ if (ap.panel?.matches?.(':popover-open')) {
311
+ try { ap.panel.hidePopover(); } catch { /* noop */ }
312
+ }
313
+ ap.panel?.remove();
314
+ this.#activePopover = null;
315
+ if (this.#docListenersBound) {
316
+ this.#docListenersBound = false;
317
+ document.removeEventListener('pointerdown', this.#onDocDown, true);
318
+ document.removeEventListener('keydown', this.#onDocKey, true);
319
+ }
320
+ }
321
+
322
+ #onDocDown = (e) => {
323
+ const ap = this.#activePopover;
324
+ if (!ap) return;
325
+ if (ap.btn.contains(e.target)) return;
326
+ if (ap.panel.contains(e.target)) return;
327
+ this.#closePopover();
328
+ };
329
+
330
+ #onDocKey = (e) => {
331
+ if (e.key !== 'Escape') return;
332
+ e.stopPropagation();
333
+ this.#closePopover();
334
+ this.#activePopover?.btn?.focus?.({ preventScroll: true });
335
+ };
336
+
337
+ // ── Filter panel ─────────────────────────────────────────────────────────
338
+
339
+ #fillFilterPanel(panel) {
340
+ const target = this.#target;
341
+ if (!target?.columns?.length) {
342
+ panel.appendChild(emptyHint('No filterable columns'));
343
+ return;
344
+ }
345
+ const filters = target.filters || {};
346
+
347
+ const head = document.createElement('div');
348
+ head.setAttribute('data-popover-head', '');
349
+ head.textContent = 'Filter rows';
350
+ panel.appendChild(head);
351
+
352
+ const list = document.createElement('div');
353
+ list.setAttribute('data-popover-list', '');
354
+
355
+ for (const col of target.columns) {
356
+ if (col.hidden) continue;
357
+ const row = document.createElement('label');
358
+ row.setAttribute('data-filter-row', '');
359
+
360
+ const labelEl = document.createElement('span');
361
+ labelEl.setAttribute('data-filter-label', '');
362
+ labelEl.textContent = col.label || col.key;
363
+ row.appendChild(labelEl);
364
+
365
+ const input = document.createElement('input-ui');
366
+ input.setAttribute('type', 'text');
367
+ input.setAttribute('size', 'sm');
368
+ input.setAttribute('data-filter-input', '');
369
+ input.setAttribute('placeholder', '—');
370
+ input.value = filters[col.key]?.value ?? '';
371
+ input.addEventListener('input', () => {
372
+ const v = input.value;
373
+ if (v) target.setFilter(col.key, v, 'contains');
374
+ else target.setFilter(col.key, null);
375
+ this.dispatchEvent(new CustomEvent('filter-change', {
376
+ bubbles: true,
377
+ detail: { filters: target.filters },
378
+ }));
379
+ });
380
+ row.appendChild(input);
381
+ list.appendChild(row);
382
+ }
383
+
384
+ panel.appendChild(list);
385
+
386
+ if (Object.keys(filters).length) {
387
+ const clear = this.#mkPopoverAction('Clear all filters', () => {
388
+ target.clearFilters();
389
+ this.dispatchEvent(new CustomEvent('filter-change', {
390
+ bubbles: true,
391
+ detail: { filters: {} },
392
+ }));
393
+ this.#refreshFilterPanel();
394
+ });
395
+ panel.appendChild(clear);
396
+ }
397
+ }
398
+
399
+ #refreshFilterPanel() {
400
+ const ap = this.#activePopover;
401
+ if (!ap || ap.kind !== 'filter') return;
402
+ ap.panel.replaceChildren();
403
+ this.#fillFilterPanel(ap.panel);
404
+ }
405
+
406
+ // ── Sort panel ───────────────────────────────────────────────────────────
407
+
408
+ #fillSortPanel(panel) {
409
+ const target = this.#target;
410
+ if (!target?.columns?.length) {
411
+ panel.appendChild(emptyHint('No sortable columns'));
412
+ return;
413
+ }
414
+
415
+ const head = document.createElement('div');
416
+ head.setAttribute('data-popover-head', '');
417
+ head.textContent = 'Sort by';
418
+ panel.appendChild(head);
419
+
420
+ const sortState = target.sortState || [];
421
+ const dirByKey = new Map(sortState.map((s) => [s.key, s.dir]));
422
+
423
+ const list = document.createElement('div');
424
+ list.setAttribute('data-popover-list', '');
425
+
426
+ for (const col of target.columns) {
427
+ if (col.hidden) continue;
428
+ if (col.sortable === false) continue;
429
+
430
+ const row = document.createElement('button');
431
+ row.type = 'button';
432
+ row.setAttribute('data-sort-row', '');
433
+ row.dataset.key = col.key;
434
+
435
+ const labelEl = document.createElement('span');
436
+ labelEl.setAttribute('data-sort-label', '');
437
+ labelEl.textContent = col.label || col.key;
438
+ row.appendChild(labelEl);
439
+
440
+ const dir = dirByKey.get(col.key);
441
+ const indicator = document.createElement('icon-ui');
442
+ indicator.setAttribute('data-sort-indicator', '');
443
+ indicator.setAttribute('size', 'xs');
444
+ indicator.setAttribute('name', dir === 'asc' ? 'arrow-up' : dir === 'desc' ? 'arrow-down' : 'caret-up-down');
445
+ if (dir) row.dataset.active = dir;
446
+ row.appendChild(indicator);
447
+
448
+ row.addEventListener('click', (e) => {
449
+ // Multi-sort with shift: forward to the table by simulating a header click
450
+ // (the table already owns the asc / desc / clear cycle). Falls back to no-op
451
+ // when the table is not present — same defensive shape as chart-legend's
452
+ // [for] target check.
453
+ const headerCell = target.querySelector(`:scope > [data-header] [data-sort-key="${col.key}"]`);
454
+ if (!headerCell) return;
455
+ const evt = new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: e.shiftKey });
456
+ headerCell.dispatchEvent(evt);
457
+ this.dispatchEvent(new CustomEvent('sort-change', {
458
+ bubbles: true,
459
+ detail: { sortState: target.sortState },
460
+ }));
461
+ this.#refreshSortPanel();
462
+ });
463
+
464
+ list.appendChild(row);
465
+ }
466
+
467
+ panel.appendChild(list);
468
+
469
+ if (sortState.length) {
470
+ const clear = this.#mkPopoverAction('Clear sort', () => {
471
+ // Simulate cycle-through clicks until empty — but the table only stores
472
+ // one sort entry per key, and a non-shift click on an already-active key
473
+ // cycles to the opposite dir before clearing. Cleanest: clone columns and
474
+ // clear via a microtask using clicks on each active key until empty.
475
+ for (const s of [...sortState]) {
476
+ const headerCell = target.querySelector(`:scope > [data-header] [data-sort-key="${s.key}"]`);
477
+ if (!headerCell) continue;
478
+ // Two clicks toggle through asc → desc → clear when single-sort. With
479
+ // multi-sort entries we shift-click to remove individually.
480
+ const fire = (shift) => headerCell.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: shift }));
481
+ if (sortState.length > 1) {
482
+ // Shift-clicking through default→opposite→remove.
483
+ fire(true); fire(true);
484
+ } else {
485
+ // Single-sort: click twice non-shift to cycle through both dirs and clear.
486
+ fire(false); fire(false);
487
+ }
488
+ }
489
+ this.dispatchEvent(new CustomEvent('sort-change', {
490
+ bubbles: true,
491
+ detail: { sortState: target.sortState },
492
+ }));
493
+ this.#refreshSortPanel();
494
+ });
495
+ panel.appendChild(clear);
496
+ }
497
+ }
498
+
499
+ #refreshSortPanel() {
500
+ const ap = this.#activePopover;
501
+ if (!ap || ap.kind !== 'sort') return;
502
+ ap.panel.replaceChildren();
503
+ this.#fillSortPanel(ap.panel);
504
+ }
505
+
506
+ // ── Columns panel ────────────────────────────────────────────────────────
507
+
508
+ #fillColumnsPanel(panel) {
509
+ const target = this.#target;
510
+ if (!target?.columns?.length) {
511
+ panel.appendChild(emptyHint('No columns'));
512
+ return;
513
+ }
514
+
515
+ const head = document.createElement('div');
516
+ head.setAttribute('data-popover-head', '');
517
+ head.textContent = 'Visible columns';
518
+ panel.appendChild(head);
519
+
520
+ const list = document.createElement('div');
521
+ list.setAttribute('data-popover-list', '');
522
+
523
+ for (const col of target.columns) {
524
+ const row = document.createElement('label');
525
+ row.setAttribute('data-columns-row', '');
526
+
527
+ const check = document.createElement('check-ui');
528
+ if (!col.hidden) check.setAttribute('checked', '');
529
+ check.dataset.key = col.key;
530
+ check.addEventListener('change', () => {
531
+ const next = target.columns.map((c) => (
532
+ c.key === col.key ? { ...c, hidden: !check.hasAttribute('checked') } : { ...c }
533
+ ));
534
+ target.columns = next;
535
+ this.dispatchEvent(new CustomEvent('columns-change', {
536
+ bubbles: true,
537
+ detail: { hiddenColumns: next.filter((c) => c.hidden).map((c) => c.key) },
538
+ }));
539
+ });
540
+ row.appendChild(check);
541
+
542
+ const labelEl = document.createElement('span');
543
+ labelEl.setAttribute('data-columns-label', '');
544
+ labelEl.textContent = col.label || col.key;
545
+ row.appendChild(labelEl);
546
+
547
+ list.appendChild(row);
548
+ }
549
+
550
+ panel.appendChild(list);
551
+ }
552
+ }
553
+
554
+ function emptyHint(text) {
555
+ const el = document.createElement('text-ui');
556
+ el.setAttribute('data-popover-empty', '');
557
+ el.setAttribute('color', 'subtle');
558
+ el.setAttribute('variant', 'caption');
559
+ el.textContent = text;
560
+ return el;
561
+ }
562
+
563
+ customElements.define('table-toolbar-ui', AdiaTableToolbar);
564
+
565
+ export { AdiaTableToolbar };