@adia-ai/web-components 0.0.18 → 0.0.19

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.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://adiaui.dev/a2ui/v0_9/components/TableToolbar.json",
4
4
  "title": "TableToolbar",
5
- "description": "Header / companion bar for a sibling table-ui. Renders title + count badge, filter / sort / columns popovers, and a search input — all wired to the target table via an [for] id-ref. Modeled on chart-legend-ui's [for] binding pattern. Drop next to (or above) any table-ui to add the standard data-grid toolbar without re-implementing search, filter, sort, or column visibility.",
5
+ "description": "Header / companion bar for a sibling table-ui. Renders title + count badge, filter / sort / columns popovers, and a search input — all wired to the target table via an [for] id-ref. Modeled on chart-legend-ui's [for] binding pattern. Drop next to (or above) any table-ui to add the standard data-grid toolbar without re-implementing search, filter, sort, or column visibility. Filter rows auto-pick a primitive per column: ≤ 50 distinct values → multi- select (searchable above 12 options), id-like keys → free-text contains. The column descriptor's `filter` field overrides the auto-detect: `'select'` forces multi-select even on high-cardinality columns; `'text'` forces a contains input even on small enums.",
6
6
  "type": "object",
7
7
  "allOf": [
8
8
  {
@@ -150,12 +150,16 @@
150
150
  gap: var(--a-space-1);
151
151
  }
152
152
 
153
+ /* The popover head + empty hint are <text-ui variant="kicker"|caption">,
154
+ so their typography (uppercase, tracking, muted color) comes from
155
+ text-ui's own variant rules — no rule needed here.
156
+ Sort rows are <menu-item-ui> — hover/focus/danger styling comes from
157
+ menu-item-ui's @scope block.
158
+ Filter and columns rows are <field-ui inline> — label/control geometry
159
+ comes from field-ui's @scope block. */
160
+
153
161
  [data-toolbar-popover] [data-popover-head] {
154
- font-size: var(--a-ui-tiny);
155
- text-transform: uppercase;
156
- letter-spacing: 0.06em;
157
- color: var(--a-fg-muted);
158
- padding: var(--a-space-1) var(--a-ui-px, var(--a-space-2));
162
+ padding: var(--a-space-1) var(--a-space-2);
159
163
  }
160
164
 
161
165
  [data-toolbar-popover] [data-popover-list] {
@@ -178,92 +182,21 @@
178
182
  padding-top: var(--a-space-1);
179
183
  }
180
184
 
181
- /* ── Filter rows ── */
182
-
183
- [data-toolbar-popover] [data-filter-row] {
184
- display: grid;
185
- grid-template-columns: 1fr minmax(8rem, 1.2fr);
186
- align-items: center;
187
- gap: var(--a-space-2);
185
+ [data-toolbar-popover] [data-filter-row],
186
+ [data-toolbar-popover] [data-columns-row] {
188
187
  padding: var(--a-space-1) var(--a-space-2);
189
- border-radius: var(--a-radius-sm);
190
- color: var(--a-fg-subtle);
191
- transition:
192
- background var(--a-duration-fast) var(--a-easing),
193
- color var(--a-duration-fast) var(--a-easing);
194
- }
195
-
196
- [data-toolbar-popover] [data-filter-row]:hover {
197
- background: var(--a-bg-hover);
198
- color: var(--a-fg-hover);
199
- }
200
-
201
- [data-toolbar-popover] [data-filter-label] {
202
- font-size: var(--a-ui-sm);
203
- color: inherit;
204
- white-space: nowrap;
205
- overflow: hidden;
206
- text-overflow: ellipsis;
207
188
  }
208
189
 
209
190
  [data-toolbar-popover] [data-filter-input] {
210
191
  min-width: 0;
211
192
  }
212
193
 
213
- /* ── Sort rows ── */
214
-
215
- [data-toolbar-popover] [data-sort-row] {
216
- all: unset;
217
- cursor: pointer;
218
- display: flex;
219
- align-items: center;
220
- justify-content: space-between;
221
- gap: var(--a-space-2);
222
- padding: var(--a-space-1) var(--a-space-2);
223
- font-size: var(--a-ui-sm);
224
- color: var(--a-fg-subtle);
225
- border-radius: var(--a-radius-sm);
226
- transition:
227
- background var(--a-duration-fast) var(--a-easing),
228
- color var(--a-duration-fast) var(--a-easing);
229
- }
230
-
231
- [data-toolbar-popover] [data-sort-row]:hover {
232
- background: var(--a-bg-hover);
233
- color: var(--a-fg-hover);
234
- }
235
-
194
+ /* Active sort row: lift the menu-item-ui's bg/fg via CSS-var override —
195
+ menu-item-ui exposes --menu-item-bg / --menu-item-fg / --menu-item-icon-fg
196
+ for exactly this. Stays inside the menu-item-ui token contract. */
236
197
  [data-toolbar-popover] [data-sort-row][data-active] {
237
- color: var(--a-fg);
238
- background: var(--a-bg-selected);
198
+ --menu-item-bg: var(--a-bg-selected);
199
+ --menu-item-fg: var(--a-fg);
200
+ --menu-item-icon-fg: var(--a-fg);
239
201
  font-weight: var(--a-ui-weight, var(--a-weight-medium));
240
202
  }
241
-
242
- [data-toolbar-popover] [data-sort-indicator] {
243
- color: var(--a-fg-muted);
244
- }
245
-
246
- [data-toolbar-popover] [data-sort-row][data-active] [data-sort-indicator] {
247
- color: var(--a-fg);
248
- }
249
-
250
- /* ── Columns rows ── */
251
-
252
- [data-toolbar-popover] [data-columns-row] {
253
- display: flex;
254
- align-items: center;
255
- gap: var(--a-space-2);
256
- padding: var(--a-space-1) var(--a-space-2);
257
- font-size: var(--a-ui-sm);
258
- color: var(--a-fg-subtle);
259
- border-radius: var(--a-radius-sm);
260
- cursor: pointer;
261
- transition:
262
- background var(--a-duration-fast) var(--a-easing),
263
- color var(--a-duration-fast) var(--a-easing);
264
- }
265
-
266
- [data-toolbar-popover] [data-columns-row]:hover {
267
- background: var(--a-bg-hover);
268
- color: var(--a-fg-hover);
269
- }
@@ -43,7 +43,6 @@ class AdiaTableToolbar extends AdiaElement {
43
43
 
44
44
  #target = null;
45
45
  #targetListeners = [];
46
- #searchTimer = null;
47
46
  #activePopover = null; // { btn, panel, cleanup }
48
47
  #docListenersBound = false;
49
48
 
@@ -57,8 +56,6 @@ class AdiaTableToolbar extends AdiaElement {
57
56
  }
58
57
 
59
58
  disconnected() {
60
- clearTimeout(this.#searchTimer);
61
- this.#searchTimer = null;
62
59
  this.#closePopover();
63
60
  this.#detachTarget();
64
61
  }
@@ -139,13 +136,16 @@ class AdiaTableToolbar extends AdiaElement {
139
136
  controls.appendChild(this.#mkButton('sort', 'Sort', 'arrows-down-up'));
140
137
  controls.appendChild(this.#mkButton('columns', 'Columns', 'columns'));
141
138
 
142
- // Search
143
- const search = document.createElement('input-ui');
139
+ // Search — compose <search-ui>, which already stamps input-ui with
140
+ // the magnifying-glass prefix + clear suffix and debounces a `search`
141
+ // event. Rolling our own from input-ui would re-derive that wiring
142
+ // and (as discovered) hit a first-paint timing race where the icon
143
+ // name renders as literal text before the icon registry resolves.
144
+ const search = document.createElement('search-ui');
144
145
  search.setAttribute('data-search', '');
145
- search.setAttribute('type', 'search');
146
- search.setAttribute('prefix', 'magnifying-glass');
147
146
  search.setAttribute('placeholder', this.placeholder);
148
- search.addEventListener('input', this.#onSearchInput);
147
+ search.setAttribute('debounce', String(SEARCH_DEBOUNCE));
148
+ search.addEventListener('search', this.#onSearch);
149
149
 
150
150
  // Actions slot passthrough — we move any pre-existing [slot="actions"] children here
151
151
  const actionsSlot = document.createElement('div');
@@ -246,16 +246,13 @@ class AdiaTableToolbar extends AdiaElement {
246
246
 
247
247
  // ── Search ───────────────────────────────────────────────────────────────
248
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);
249
+ #onSearch = (e) => {
250
+ const value = e.detail?.value ?? '';
251
+ if (this.#target) this.#target.search = value;
252
+ this.dispatchEvent(new CustomEvent('search', {
253
+ bubbles: true,
254
+ detail: { value },
255
+ }));
259
256
  };
260
257
 
261
258
  // ── Sync from target (initial paint) ─────────────────────────────────────
@@ -344,40 +341,78 @@ class AdiaTableToolbar extends AdiaElement {
344
341
  }
345
342
  const filters = target.filters || {};
346
343
 
347
- const head = document.createElement('div');
348
- head.setAttribute('data-popover-head', '');
349
- head.textContent = 'Filter rows';
350
- panel.appendChild(head);
344
+ panel.appendChild(popoverHead('Filter rows'));
351
345
 
352
346
  const list = document.createElement('div');
353
347
  list.setAttribute('data-popover-list', '');
354
348
 
349
+ const data = target.data || [];
355
350
  for (const col of target.columns) {
356
351
  if (col.hidden) continue;
357
- const row = document.createElement('label');
352
+ // <field-ui inline label="…"><…control…></…></field-ui> — canonical
353
+ // label-binds-to-control pair, mints id + [for]. The CONTROL is
354
+ // chosen per-column from the column's filter shape:
355
+ // • 'select' → <select-ui multiple searchable> populated from data
356
+ // • 'text' → <input-ui type="text"> (contains match)
357
+ // Shape comes from col.filter when set, otherwise auto-detected
358
+ // from the data (id-like keys → text; ≤ 50 distinct values → select).
359
+ const row = document.createElement('field-ui');
358
360
  row.setAttribute('data-filter-row', '');
361
+ row.setAttribute('inline', '');
362
+ row.setAttribute('label', col.label || col.key);
363
+
364
+ const shape = detectFilterShape(col, data);
365
+ const current = filters[col.key];
366
+
367
+ if (shape === 'select') {
368
+ const sel = document.createElement('select-ui');
369
+ sel.setAttribute('data-filter-input', '');
370
+ sel.setAttribute('multiple', '');
371
+ sel.setAttribute('placeholder', '—');
372
+
373
+ const uniqueValues = collectUniqueValues(col, data);
374
+ // Searchable kicks in at 12+ options — below that the listbox is
375
+ // fully scannable and a search field is overhead.
376
+ if (uniqueValues.length >= 12) sel.setAttribute('searchable', '');
377
+
378
+ for (const val of uniqueValues) {
379
+ const opt = document.createElement('option');
380
+ opt.setAttribute('value', val);
381
+ opt.textContent = val;
382
+ sel.appendChild(opt);
383
+ }
384
+ if (current?.op === 'select' && current.value) {
385
+ sel.value = current.value;
386
+ }
387
+ sel.addEventListener('change', () => {
388
+ const v = sel.value || '';
389
+ if (v) target.setFilter(col.key, v, 'select');
390
+ else target.setFilter(col.key, null);
391
+ this.dispatchEvent(new CustomEvent('filter-change', {
392
+ bubbles: true,
393
+ detail: { filters: target.filters },
394
+ }));
395
+ });
396
+ row.appendChild(sel);
397
+ } else {
398
+ const input = document.createElement('input-ui');
399
+ input.setAttribute('type', 'text');
400
+ input.setAttribute('size', 'sm');
401
+ input.setAttribute('data-filter-input', '');
402
+ input.setAttribute('placeholder', '—');
403
+ if (current?.op === 'contains') input.value = current.value ?? '';
404
+ input.addEventListener('input', () => {
405
+ const v = input.value;
406
+ if (v) target.setFilter(col.key, v, 'contains');
407
+ else target.setFilter(col.key, null);
408
+ this.dispatchEvent(new CustomEvent('filter-change', {
409
+ bubbles: true,
410
+ detail: { filters: target.filters },
411
+ }));
412
+ });
413
+ row.appendChild(input);
414
+ }
359
415
 
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
416
  list.appendChild(row);
382
417
  }
383
418
 
@@ -412,10 +447,7 @@ class AdiaTableToolbar extends AdiaElement {
412
447
  return;
413
448
  }
414
449
 
415
- const head = document.createElement('div');
416
- head.setAttribute('data-popover-head', '');
417
- head.textContent = 'Sort by';
418
- panel.appendChild(head);
450
+ panel.appendChild(popoverHead('Sort by'));
419
451
 
420
452
  const sortState = target.sortState || [];
421
453
  const dirByKey = new Map(sortState.map((s) => [s.key, s.dir]));
@@ -427,29 +459,30 @@ class AdiaTableToolbar extends AdiaElement {
427
459
  if (col.hidden) continue;
428
460
  if (col.sortable === false) continue;
429
461
 
430
- const row = document.createElement('button');
431
- row.type = 'button';
462
+ // <menu-item-ui> is the canonical action-row primitive (role="menuitem",
463
+ // built-in icon + text slots, hover/focus tokens, danger variant). Append
464
+ // a trailing <icon-ui> after the auto-stamped [slot="text"] — its flex:1
465
+ // pushes any later child to the trailing edge.
466
+ const dir = dirByKey.get(col.key);
467
+ const row = document.createElement('menu-item-ui');
432
468
  row.setAttribute('data-sort-row', '');
469
+ row.setAttribute('text', col.label || col.key);
433
470
  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
471
  if (dir) row.dataset.active = dir;
446
- row.appendChild(indicator);
472
+
473
+ // Trailing direction indicator — append AFTER menu-item-ui's connected()
474
+ // has run its #stamp() (defers via rAF so the [slot="text"] span exists).
475
+ requestAnimationFrame(() => {
476
+ const indicator = document.createElement('icon-ui');
477
+ indicator.setAttribute('data-sort-indicator', '');
478
+ indicator.setAttribute('size', 'xs');
479
+ indicator.setAttribute('name', dir === 'asc' ? 'arrow-up' : dir === 'desc' ? 'arrow-down' : 'caret-up-down');
480
+ row.appendChild(indicator);
481
+ });
447
482
 
448
483
  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.
484
+ // Forward to the bound table by simulating a header click — the table
485
+ // already owns the asc / desc / clear cycle, so we don't duplicate state.
453
486
  const headerCell = target.querySelector(`:scope > [data-header] [data-sort-key="${col.key}"]`);
454
487
  if (!headerCell) return;
455
488
  const evt = new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: e.shiftKey });
@@ -512,17 +545,19 @@ class AdiaTableToolbar extends AdiaElement {
512
545
  return;
513
546
  }
514
547
 
515
- const head = document.createElement('div');
516
- head.setAttribute('data-popover-head', '');
517
- head.textContent = 'Visible columns';
518
- panel.appendChild(head);
548
+ panel.appendChild(popoverHead('Visible columns'));
519
549
 
520
550
  const list = document.createElement('div');
521
551
  list.setAttribute('data-popover-list', '');
522
552
 
523
553
  for (const col of target.columns) {
524
- const row = document.createElement('label');
554
+ // <field-ui inline label="…"><check-ui></check-ui></field-ui>
555
+ // canonical label-binds-to-control pair, mints id + [for] so clicking
556
+ // the label toggles the check. Same primitive used for filter rows.
557
+ const row = document.createElement('field-ui');
525
558
  row.setAttribute('data-columns-row', '');
559
+ row.setAttribute('inline', '');
560
+ row.setAttribute('label', col.label || col.key);
526
561
 
527
562
  const check = document.createElement('check-ui');
528
563
  if (!col.hidden) check.setAttribute('checked', '');
@@ -539,11 +574,6 @@ class AdiaTableToolbar extends AdiaElement {
539
574
  });
540
575
  row.appendChild(check);
541
576
 
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
577
  list.appendChild(row);
548
578
  }
549
579
 
@@ -560,6 +590,96 @@ function emptyHint(text) {
560
590
  return el;
561
591
  }
562
592
 
593
+ function popoverHead(text) {
594
+ // <text-ui variant="kicker"> renders the uppercase + tracked + muted-color
595
+ // section-marker typography role — exactly what a popover head needs. No
596
+ // bespoke styling required, no [data-popover-head] CSS rule.
597
+ const el = document.createElement('text-ui');
598
+ el.setAttribute('data-popover-head', '');
599
+ el.setAttribute('variant', 'kicker');
600
+ el.setAttribute('color', 'subtle');
601
+ el.textContent = text;
602
+ return el;
603
+ }
604
+
605
+ // ── Filter shape detection ─────────────────────────────────────────────────
606
+
607
+ const SELECT_THRESHOLD = 50; // ≤ N distinct values → multi-select
608
+ const SAMPLE_LIMIT = 500; // cap data scan for cardinality detection
609
+
610
+ /**
611
+ * Resolve a cell value for a column. Mirrors table.js getCellValue —
612
+ * supports col.accessor (function) and dot-notation col.key paths.
613
+ */
614
+ function getCellValue(row, col) {
615
+ if (typeof col?.accessor === 'function') return col.accessor(row);
616
+ const path = col?.key;
617
+ if (!path || row == null) return undefined;
618
+ const parts = String(path).split('.');
619
+ let cur = row;
620
+ for (const p of parts) {
621
+ if (cur == null) return undefined;
622
+ cur = cur[p];
623
+ }
624
+ return cur;
625
+ }
626
+
627
+ /**
628
+ * Heuristic: id-like keys (`id`, `userId`, `worker_id`, `uuid`, `guid`)
629
+ * are searched as free text — picking from a 10k-entry listbox is worse
630
+ * UX than typing a known value.
631
+ */
632
+ function looksLikeIdKey(key) {
633
+ if (!key) return false;
634
+ const k = String(key);
635
+ if (k === 'id' || k === 'ID' || k === 'uuid' || k === 'guid') return true;
636
+ if (/[a-z](Id|ID)$/.test(k)) return true; // camelCase: workerId, userId
637
+ if (/_id$/i.test(k)) return true; // snake_case: worker_id
638
+ return false;
639
+ }
640
+
641
+ /**
642
+ * Choose the right filter primitive for a column.
643
+ * col.filter = 'select' | 'text' — explicit override (table-ui contract)
644
+ * col.type = 'number' | 'currency' | 'percent' → 'text' (numeric range
645
+ * support is a future addition; falls back to contains for now)
646
+ * id-like key → 'text'
647
+ * ≤ SELECT_THRESHOLD distinct values in the sample → 'select'
648
+ * otherwise → 'text'
649
+ */
650
+ function detectFilterShape(col, data) {
651
+ if (col?.filter === 'select' || col?.filter === 'text') return col.filter;
652
+ if (looksLikeIdKey(col?.key)) return 'text';
653
+
654
+ const values = [];
655
+ const limit = Math.min(data?.length ?? 0, SAMPLE_LIMIT);
656
+ const unique = new Set();
657
+ for (let i = 0; i < limit; i++) {
658
+ const v = getCellValue(data[i], col);
659
+ if (v == null || v === '') continue;
660
+ values.push(v);
661
+ unique.add(String(v));
662
+ if (unique.size > SELECT_THRESHOLD) return 'text';
663
+ }
664
+ if (!values.length) return 'text';
665
+ return 'select';
666
+ }
667
+
668
+ /**
669
+ * Collect distinct stringified values for the column from data, sorted.
670
+ * Used to populate select-ui options when filter shape === 'select'.
671
+ */
672
+ function collectUniqueValues(col, data) {
673
+ const set = new Set();
674
+ const limit = Math.min(data?.length ?? 0, SAMPLE_LIMIT);
675
+ for (let i = 0; i < limit; i++) {
676
+ const v = getCellValue(data[i], col);
677
+ if (v == null || v === '') continue;
678
+ set.add(String(v));
679
+ }
680
+ return [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
681
+ }
682
+
563
683
  customElements.define('table-toolbar-ui', AdiaTableToolbar);
564
684
 
565
685
  export { AdiaTableToolbar };
@@ -10,6 +10,11 @@ description: >-
10
10
  target table via an [for] id-ref. Modeled on chart-legend-ui's [for] binding
11
11
  pattern. Drop next to (or above) any table-ui to add the standard data-grid
12
12
  toolbar without re-implementing search, filter, sort, or column visibility.
13
+ Filter rows auto-pick a primitive per column: ≤ 50 distinct values → multi-
14
+ select (searchable above 12 options), id-like keys → free-text contains. The
15
+ column descriptor's `filter` field overrides the auto-detect: `'select'`
16
+ forces multi-select even on high-cardinality columns; `'text'` forces a
17
+ contains input even on small enums.
13
18
  props:
14
19
  for:
15
20
  description: id-ref of the table-ui to control. Falls back to the first sibling table-ui within the same parent when omitted.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/web-components",
3
- "version": "0.0.18",
3
+ "version": "0.0.19",
4
4
  "description": "AdiaUI web components — vanilla custom elements. A2UI runtime (renderer, registry, streams, wiring) lives in @adia-ai/a2ui-utils.",
5
5
  "type": "module",
6
6
  "exports": {