@adia-ai/web-components 0.0.17 → 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.
@@ -38,6 +38,7 @@ export { AdiaCommand } from './command/command.js';
38
38
  export { AdiaColorPicker } from './color-picker/color-picker.js';
39
39
  export { AdiaNoodles } from './noodles/noodles.js';
40
40
  export { AdiaTable } from './table/table.js';
41
+ export { AdiaTableToolbar } from './table-toolbar/table-toolbar.js';
41
42
  export { AdiaTimeline, AdiaTimelineItem } from './timeline/timeline.js';
42
43
  export { AdiaStepper, AdiaStepperItem } from './stepper/stepper.js';
43
44
  export { AdiaSwiper } from './swiper/swiper.js';
@@ -241,12 +241,26 @@ class AdiaNoodles extends AdiaElement {
241
241
  }
242
242
 
243
243
  // ── Port position computation ──────────────────────────────
244
-
244
+ // Uses bounding client rects so nested port-bearing descendants
245
+ // (e.g. inside an absolutely-positioned card wrapper) project
246
+ // correctly into the noodles-ui coordinate system.
247
+ //
248
+ // When an ancestor applies a CSS transform (zoom/pan in a graph
249
+ // editor), bounding rects are in *screen* pixels — but SVG paths
250
+ // and port-indicator dots are positioned in *local* (untransformed)
251
+ // pixels. We detect any ancestor scale by comparing this element's
252
+ // visible width to its layout width and divide deltas accordingly.
253
+ // No transform → ratio is 1, no-op.
245
254
  #getPortPosition(el, side) {
246
- const left = el.offsetLeft;
247
- const top = el.offsetTop;
248
- const w = el.offsetWidth;
249
- const h = el.offsetHeight;
255
+ const elRect = el.getBoundingClientRect();
256
+ const myRect = this.getBoundingClientRect();
257
+ const localScale = (this.offsetWidth && myRect.width)
258
+ ? (myRect.width / this.offsetWidth)
259
+ : 1;
260
+ const left = (elRect.left - myRect.left) / localScale;
261
+ const top = (elRect.top - myRect.top) / localScale;
262
+ const w = elRect.width / localScale;
263
+ const h = elRect.height / localScale;
250
264
 
251
265
  switch (side) {
252
266
  case 'left': return { x: left, y: top + h / 2 };
@@ -0,0 +1,212 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://adiaui.dev/a2ui/v0_9/components/TableToolbar.json",
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.",
6
+ "type": "object",
7
+ "allOf": [
8
+ {
9
+ "$ref": "common_types.json#/$defs/ComponentCommon"
10
+ },
11
+ {
12
+ "$ref": "common_types.json#/$defs/CatalogComponentCommon"
13
+ }
14
+ ],
15
+ "properties": {
16
+ "columns": {
17
+ "description": "Show the Columns visibility popover button.",
18
+ "type": "boolean",
19
+ "default": true
20
+ },
21
+ "component": {
22
+ "const": "TableToolbar"
23
+ },
24
+ "count": {
25
+ "description": "Optional count badge value shown next to the title. When unset, falls back to the row count of the bound table.",
26
+ "type": "string",
27
+ "default": ""
28
+ },
29
+ "filterable": {
30
+ "description": "Show the Filter popover button.",
31
+ "type": "boolean",
32
+ "default": true
33
+ },
34
+ "for": {
35
+ "description": "id-ref of the table-ui to control. Falls back to the first sibling table-ui within the same parent when omitted.",
36
+ "type": "string",
37
+ "default": ""
38
+ },
39
+ "placeholder": {
40
+ "description": "Placeholder text for the search input.",
41
+ "type": "string",
42
+ "default": "Search..."
43
+ },
44
+ "searchable": {
45
+ "description": "Show the search input.",
46
+ "type": "boolean",
47
+ "default": true
48
+ },
49
+ "sortable": {
50
+ "description": "Show the Sort popover button.",
51
+ "type": "boolean",
52
+ "default": true
53
+ },
54
+ "text": {
55
+ "description": "Title text shown on the left. Alternative to slotted heading content.",
56
+ "type": "string",
57
+ "default": ""
58
+ },
59
+ "variant": {
60
+ "description": "Toolbar visual variant. `default` renders bare on parent surface; `card` adds the same chrome as a card-ui header.",
61
+ "type": "string",
62
+ "enum": [
63
+ "default",
64
+ "card"
65
+ ],
66
+ "default": "default"
67
+ }
68
+ },
69
+ "required": [
70
+ "component"
71
+ ],
72
+ "unevaluatedProperties": false,
73
+ "x-adiaui": {
74
+ "anti_patterns": [],
75
+ "category": "agent",
76
+ "events": {
77
+ "columns-change": {
78
+ "description": "Column visibility changed. Detail: { hiddenColumns }."
79
+ },
80
+ "filter-change": {
81
+ "description": "Filter set changed. Detail: { filters }."
82
+ },
83
+ "search": {
84
+ "description": "Debounced search query change. Detail: { value }."
85
+ },
86
+ "sort-change": {
87
+ "description": "Sort state changed. Detail: { sortState }."
88
+ }
89
+ },
90
+ "examples": [
91
+ {
92
+ "description": "Members table with filter/sort/columns/search wired to a sibling table-ui.",
93
+ "a2ui": "[\n {\"id\": \"root\", \"component\": \"Column\", \"gap\": \"3\", \"children\": [\"bar\", \"card\"]},\n {\"id\": \"bar\", \"component\": \"TableToolbar\", \"for\": \"members\", \"text\": \"All Employees\", \"count\": \"32\"},\n {\"id\": \"card\", \"component\": \"Card\", \"children\": [\"sec\"]},\n {\"id\": \"sec\", \"component\": \"Section\", \"bleed\": true, \"children\": [\"tbl\"]},\n {\"id\": \"tbl\", \"component\": \"Table\", \"id\": \"members\", \"sortable\": true, \"raw\": true}\n]",
94
+ "name": "members-toolbar"
95
+ }
96
+ ],
97
+ "keywords": [
98
+ "table-toolbar",
99
+ "data-grid",
100
+ "data-grid-toolbar",
101
+ "filter",
102
+ "sort",
103
+ "columns",
104
+ "search",
105
+ "directory",
106
+ "admin",
107
+ "backoffice",
108
+ "listing",
109
+ "records"
110
+ ],
111
+ "name": "AdiaTableToolbar",
112
+ "related": [
113
+ "table",
114
+ "search",
115
+ "button",
116
+ "badge",
117
+ "popover"
118
+ ],
119
+ "slots": {
120
+ "default": {
121
+ "description": "Optional title content. Used when [text] is empty."
122
+ },
123
+ "actions": {
124
+ "description": "Trailing action area — primary buttons (e.g. \"New row\") rendered after the search input."
125
+ }
126
+ },
127
+ "states": [
128
+ {
129
+ "description": "Default, ready for interaction.",
130
+ "name": "idle"
131
+ }
132
+ ],
133
+ "synonyms": {
134
+ "columns": [
135
+ "table-toolbar",
136
+ "table"
137
+ ],
138
+ "data-grid": [
139
+ "table-toolbar",
140
+ "table"
141
+ ],
142
+ "data-grid-toolbar": [
143
+ "table-toolbar",
144
+ "table"
145
+ ],
146
+ "filter": [
147
+ "table-toolbar",
148
+ "table"
149
+ ],
150
+ "sort": [
151
+ "table-toolbar",
152
+ "table"
153
+ ]
154
+ },
155
+ "tag": "table-toolbar-ui",
156
+ "tokens": {
157
+ "--table-toolbar-bg": {
158
+ "description": "Toolbar background (variant=card)"
159
+ },
160
+ "--table-toolbar-border": {
161
+ "description": "Toolbar border color (variant=card)"
162
+ },
163
+ "--table-toolbar-gap": {
164
+ "description": "Gap between toolbar clusters"
165
+ },
166
+ "--table-toolbar-popover-bg": {
167
+ "description": "Popover background"
168
+ },
169
+ "--table-toolbar-popover-border": {
170
+ "description": "Popover border"
171
+ },
172
+ "--table-toolbar-popover-fg": {
173
+ "description": "Popover text color"
174
+ },
175
+ "--table-toolbar-popover-gap": {
176
+ "description": "Popover content gap"
177
+ },
178
+ "--table-toolbar-popover-min": {
179
+ "description": "Popover minimum width"
180
+ },
181
+ "--table-toolbar-popover-pad": {
182
+ "description": "Popover padding"
183
+ },
184
+ "--table-toolbar-popover-radius": {
185
+ "description": "Popover radius"
186
+ },
187
+ "--table-toolbar-popover-shadow": {
188
+ "description": "Popover shadow"
189
+ },
190
+ "--table-toolbar-px": {
191
+ "description": "Horizontal padding"
192
+ },
193
+ "--table-toolbar-py": {
194
+ "description": "Vertical padding"
195
+ },
196
+ "--table-toolbar-radius": {
197
+ "description": "Toolbar corner radius (variant=card)"
198
+ },
199
+ "--table-toolbar-title-fg": {
200
+ "description": "Title text color"
201
+ },
202
+ "--table-toolbar-title-size": {
203
+ "description": "Title font size"
204
+ },
205
+ "--table-toolbar-title-weight": {
206
+ "description": "Title font weight"
207
+ }
208
+ },
209
+ "traits": [],
210
+ "version": 1
211
+ }
212
+ }
@@ -0,0 +1,269 @@
1
+ @scope (table-toolbar-ui) {
2
+ :where(:scope) {
3
+ /* ── Layout ── */
4
+ --table-toolbar-gap: var(--a-space-3);
5
+ --table-toolbar-py: var(--a-space-2);
6
+ --table-toolbar-px: 0;
7
+ --table-toolbar-cluster-gap: var(--a-space-2);
8
+
9
+ /* ── Surface ── */
10
+ --table-toolbar-bg: transparent;
11
+ --table-toolbar-border: transparent;
12
+ --table-toolbar-radius: var(--a-radius-lg);
13
+
14
+ /* ── Title ── */
15
+ --table-toolbar-title-fg: var(--a-fg-strong);
16
+ --table-toolbar-title-size: var(--a-ui-lg);
17
+ --table-toolbar-title-weight: var(--a-weight-medium);
18
+ --table-toolbar-title-gap: var(--a-space-2);
19
+
20
+ /* ── Search ── */
21
+ --table-toolbar-search-min: 14rem;
22
+ --table-toolbar-search-max: 22rem;
23
+
24
+ /* ── Popover ── */
25
+ --table-toolbar-popover-bg: var(--a-canvas);
26
+ --table-toolbar-popover-fg: var(--a-canvas-text);
27
+ --table-toolbar-popover-border: var(--a-border);
28
+ --table-toolbar-popover-radius: var(--a-radius-lg);
29
+ --table-toolbar-popover-shadow: var(--a-shadow-lg);
30
+ --table-toolbar-popover-pad: var(--a-space-2);
31
+ --table-toolbar-popover-gap: var(--a-space-1);
32
+ --table-toolbar-popover-min: 16rem;
33
+ --table-toolbar-popover-head-fg: var(--a-fg-muted);
34
+ --table-toolbar-popover-head-size: var(--a-ui-tiny);
35
+ --table-toolbar-popover-head-pad: var(--a-space-2);
36
+ --table-toolbar-popover-row-pad: var(--a-space-2);
37
+ --table-toolbar-popover-row-radius: var(--a-radius-sm);
38
+ --table-toolbar-popover-row-bg-hover: var(--a-bg-hover);
39
+ --table-toolbar-popover-input-bg: var(--a-bg-subtle);
40
+ --table-toolbar-popover-input-border: var(--a-border-subtle);
41
+ --table-toolbar-popover-input-radius: var(--a-radius-sm);
42
+ --table-toolbar-popover-input-py: var(--a-space-1);
43
+ --table-toolbar-popover-input-px: var(--a-space-2);
44
+ --table-toolbar-popover-input-size: var(--a-ui-sm);
45
+ --table-toolbar-popover-action-fg: var(--a-accent);
46
+ --table-toolbar-popover-action-size: var(--a-ui-sm);
47
+ }
48
+
49
+ /* ═══════ Base ═══════ */
50
+
51
+ :scope {
52
+ box-sizing: border-box;
53
+ display: block;
54
+ color: var(--a-fg);
55
+ }
56
+
57
+ [data-toolbar] {
58
+ display: flex;
59
+ flex-direction: row;
60
+ align-items: center;
61
+ gap: var(--table-toolbar-gap);
62
+ padding: var(--table-toolbar-py) var(--table-toolbar-px);
63
+ min-width: 0;
64
+ }
65
+
66
+ /* ═══════ Variant: card ═══════ */
67
+
68
+ :scope[variant="card"] [data-toolbar] {
69
+ background: var(--table-toolbar-bg);
70
+ border: 1px solid var(--table-toolbar-border);
71
+ border-radius: var(--table-toolbar-radius);
72
+ padding: var(--a-space-3) var(--a-space-4);
73
+ }
74
+
75
+ /* ═══════ Title cluster ═══════ */
76
+
77
+ [data-title] {
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: var(--table-toolbar-title-gap);
81
+ flex: 0 0 auto;
82
+ min-width: 0;
83
+ }
84
+
85
+ [data-heading] {
86
+ font-size: var(--table-toolbar-title-size);
87
+ font-weight: var(--table-toolbar-title-weight);
88
+ color: var(--table-toolbar-title-fg);
89
+ line-height: 1.2;
90
+ }
91
+
92
+ [data-count-badge][hidden] { display: none; }
93
+
94
+ /* ═══════ Controls cluster ═══════ */
95
+
96
+ [data-controls] {
97
+ display: inline-flex;
98
+ align-items: center;
99
+ gap: var(--table-toolbar-cluster-gap);
100
+ flex: 0 0 auto;
101
+ }
102
+
103
+ [data-toolbar-btn][hidden] { display: none; }
104
+
105
+ /* ═══════ Search ═══════ */
106
+
107
+ [data-search] {
108
+ flex: 1 1 var(--table-toolbar-search-min);
109
+ min-width: 0;
110
+ max-width: var(--table-toolbar-search-max);
111
+ margin-inline-start: auto;
112
+ }
113
+
114
+ [data-search][hidden] { display: none; }
115
+
116
+ /* ═══════ Actions slot ═══════ */
117
+
118
+ [data-actions] {
119
+ display: inline-flex;
120
+ align-items: center;
121
+ gap: var(--table-toolbar-cluster-gap);
122
+ flex: 0 0 auto;
123
+ }
124
+
125
+ [data-actions]:empty { display: none; }
126
+ }
127
+
128
+ /* ═══════ Popover (top layer — outside @scope) ═══════
129
+ Popovers escape to the top layer and cannot inherit --table-toolbar-* tokens
130
+ from the @scope block. Style with raw --a-* tokens — same pattern used by
131
+ tooltip-ui and toolbar-ui's spillover menu. */
132
+
133
+ [data-toolbar-popover]:not(:popover-open) {
134
+ display: none !important;
135
+ }
136
+
137
+ [data-toolbar-popover]:popover-open {
138
+ margin: 0;
139
+ padding: var(--a-space-1);
140
+ background: var(--a-canvas-bright);
141
+ color: var(--a-fg);
142
+ border: 1px solid var(--a-ui-border);
143
+ border-radius: var(--a-radius);
144
+ box-shadow: var(--a-shadow-lg);
145
+ min-width: 16rem;
146
+ font-family: inherit;
147
+ font-size: var(--a-ui-size);
148
+ display: flex;
149
+ flex-direction: column;
150
+ gap: var(--a-space-1);
151
+ }
152
+
153
+ [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));
159
+ }
160
+
161
+ [data-toolbar-popover] [data-popover-list] {
162
+ display: flex;
163
+ flex-direction: column;
164
+ gap: var(--a-space-1);
165
+ max-height: 22rem;
166
+ overflow-y: auto;
167
+ }
168
+
169
+ [data-toolbar-popover] [data-popover-empty] {
170
+ display: block;
171
+ padding: var(--a-space-3) var(--a-space-2);
172
+ text-align: center;
173
+ }
174
+
175
+ [data-toolbar-popover] [data-popover-action] {
176
+ margin-top: var(--a-space-1);
177
+ border-top: 1px solid var(--a-border-subtle);
178
+ padding-top: var(--a-space-1);
179
+ }
180
+
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);
188
+ 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
+ }
208
+
209
+ [data-toolbar-popover] [data-filter-input] {
210
+ min-width: 0;
211
+ }
212
+
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
+
236
+ [data-toolbar-popover] [data-sort-row][data-active] {
237
+ color: var(--a-fg);
238
+ background: var(--a-bg-selected);
239
+ font-weight: var(--a-ui-weight, var(--a-weight-medium));
240
+ }
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
+ }
@@ -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 };
@@ -0,0 +1,160 @@
1
+ $schema: ../../../../scripts/schemas/component.yaml.schema.json
2
+ name: AdiaTableToolbar
3
+ tag: table-toolbar-ui
4
+ component: TableToolbar
5
+ category: agent
6
+ version: 1
7
+ description: >-
8
+ Header / companion bar for a sibling table-ui. Renders title + count badge,
9
+ filter / sort / columns popovers, and a search input — all wired to the
10
+ target table via an [for] id-ref. Modeled on chart-legend-ui's [for] binding
11
+ pattern. Drop next to (or above) any table-ui to add the standard data-grid
12
+ toolbar without re-implementing search, filter, sort, or column visibility.
13
+ props:
14
+ for:
15
+ description: id-ref of the table-ui to control. Falls back to the first sibling table-ui within the same parent when omitted.
16
+ type: string
17
+ default: ""
18
+ reflect: true
19
+ text:
20
+ description: Title text shown on the left. Alternative to slotted heading content.
21
+ type: string
22
+ default: ""
23
+ count:
24
+ description: Optional count badge value shown next to the title. When unset, falls back to the row count of the bound table.
25
+ type: string
26
+ default: ""
27
+ filterable:
28
+ description: Show the Filter popover button.
29
+ type: boolean
30
+ default: true
31
+ reflect: true
32
+ sortable:
33
+ description: Show the Sort popover button.
34
+ type: boolean
35
+ default: true
36
+ reflect: true
37
+ columns:
38
+ description: Show the Columns visibility popover button.
39
+ type: boolean
40
+ default: true
41
+ reflect: true
42
+ searchable:
43
+ description: Show the search input.
44
+ type: boolean
45
+ default: true
46
+ reflect: true
47
+ placeholder:
48
+ description: Placeholder text for the search input.
49
+ type: string
50
+ default: Search...
51
+ variant:
52
+ description: Toolbar visual variant. `default` renders bare on parent surface; `card` adds the same chrome as a card-ui header.
53
+ type: string
54
+ default: default
55
+ enum:
56
+ - default
57
+ - card
58
+ reflect: true
59
+ events:
60
+ search:
61
+ description: "Debounced search query change. Detail: { value }."
62
+ filter-change:
63
+ description: "Filter set changed. Detail: { filters }."
64
+ sort-change:
65
+ description: "Sort state changed. Detail: { sortState }."
66
+ columns-change:
67
+ description: "Column visibility changed. Detail: { hiddenColumns }."
68
+ slots:
69
+ default:
70
+ description: Optional title content. Used when [text] is empty.
71
+ actions:
72
+ description: Trailing action area — primary buttons (e.g. "New row") rendered after the search input.
73
+ states:
74
+ - name: idle
75
+ description: Default, ready for interaction.
76
+ traits: []
77
+ tokens:
78
+ --table-toolbar-gap:
79
+ description: Gap between toolbar clusters
80
+ --table-toolbar-py:
81
+ description: Vertical padding
82
+ --table-toolbar-px:
83
+ description: Horizontal padding
84
+ --table-toolbar-bg:
85
+ description: Toolbar background (variant=card)
86
+ --table-toolbar-border:
87
+ description: Toolbar border color (variant=card)
88
+ --table-toolbar-radius:
89
+ description: Toolbar corner radius (variant=card)
90
+ --table-toolbar-title-fg:
91
+ description: Title text color
92
+ --table-toolbar-title-size:
93
+ description: Title font size
94
+ --table-toolbar-title-weight:
95
+ description: Title font weight
96
+ --table-toolbar-popover-bg:
97
+ description: Popover background
98
+ --table-toolbar-popover-fg:
99
+ description: Popover text color
100
+ --table-toolbar-popover-border:
101
+ description: Popover border
102
+ --table-toolbar-popover-radius:
103
+ description: Popover radius
104
+ --table-toolbar-popover-shadow:
105
+ description: Popover shadow
106
+ --table-toolbar-popover-pad:
107
+ description: Popover padding
108
+ --table-toolbar-popover-gap:
109
+ description: Popover content gap
110
+ --table-toolbar-popover-min:
111
+ description: Popover minimum width
112
+ a2ui:
113
+ rules: []
114
+ anti_patterns: []
115
+ examples:
116
+ - name: members-toolbar
117
+ description: Members table with filter/sort/columns/search wired to a sibling table-ui.
118
+ a2ui: >-
119
+ [
120
+ {"id": "root", "component": "Column", "gap": "3", "children": ["bar", "card"]},
121
+ {"id": "bar", "component": "TableToolbar", "for": "members", "text": "All Employees", "count": "32"},
122
+ {"id": "card", "component": "Card", "children": ["sec"]},
123
+ {"id": "sec", "component": "Section", "bleed": true, "children": ["tbl"]},
124
+ {"id": "tbl", "component": "Table", "id": "members", "sortable": true, "raw": true}
125
+ ]
126
+ keywords:
127
+ - table-toolbar
128
+ - data-grid
129
+ - data-grid-toolbar
130
+ - filter
131
+ - sort
132
+ - columns
133
+ - search
134
+ - directory
135
+ - admin
136
+ - backoffice
137
+ - listing
138
+ - records
139
+ synonyms:
140
+ data-grid:
141
+ - table-toolbar
142
+ - table
143
+ data-grid-toolbar:
144
+ - table-toolbar
145
+ - table
146
+ filter:
147
+ - table-toolbar
148
+ - table
149
+ sort:
150
+ - table-toolbar
151
+ - table
152
+ columns:
153
+ - table-toolbar
154
+ - table
155
+ related:
156
+ - table
157
+ - search
158
+ - button
159
+ - badge
160
+ - popover
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/web-components",
3
- "version": "0.0.17",
3
+ "version": "0.0.18",
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": {
@@ -39,6 +39,7 @@
39
39
  @import "../components/color-picker/color-picker.css";
40
40
  @import "../components/noodles/noodles.css";
41
41
  @import "../components/table/table.css";
42
+ @import "../components/table-toolbar/table-toolbar.css";
42
43
  @import "../components/timeline/timeline.css";
43
44
  @import "../components/upload/upload.css";
44
45
  @import "../components/card/card.css";