@adia-ai/web-components 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (316) hide show
  1. package/README.md +63 -24
  2. package/USAGE.md +604 -0
  3. package/components/accordion/accordion.d.ts +17 -0
  4. package/components/accordion/accordion.js +10 -117
  5. package/components/accordion/class.js +132 -0
  6. package/components/action-list/action-list.d.ts +15 -0
  7. package/components/action-list/action-list.js +9 -140
  8. package/components/action-list/class.js +156 -0
  9. package/components/agent-artifact/agent-artifact.d.ts +25 -0
  10. package/components/agent-artifact/agent-artifact.js +8 -181
  11. package/components/agent-artifact/class.js +200 -0
  12. package/components/agent-feedback-bar/agent-feedback-bar.d.ts +21 -0
  13. package/components/agent-feedback-bar/agent-feedback-bar.js +8 -143
  14. package/components/agent-feedback-bar/class.js +162 -0
  15. package/components/agent-questions/agent-questions.d.ts +23 -0
  16. package/components/agent-questions/agent-questions.js +8 -180
  17. package/components/agent-questions/class.js +199 -0
  18. package/components/agent-reasoning/agent-reasoning.d.ts +23 -0
  19. package/components/agent-reasoning/agent-reasoning.js +8 -494
  20. package/components/agent-reasoning/class.js +513 -0
  21. package/components/agent-suggestions/agent-suggestions.d.ts +21 -0
  22. package/components/agent-suggestions/agent-suggestions.js +8 -78
  23. package/components/agent-suggestions/class.js +97 -0
  24. package/components/agent-trace/agent-trace.d.ts +19 -0
  25. package/components/alert/alert.d.ts +29 -0
  26. package/components/alert/alert.js +8 -175
  27. package/components/alert/class.js +194 -0
  28. package/components/avatar/avatar.d.ts +27 -0
  29. package/components/avatar/avatar.js +9 -159
  30. package/components/avatar/class.js +173 -0
  31. package/components/badge/badge.d.ts +27 -0
  32. package/components/badge/badge.js +9 -75
  33. package/components/badge/class.js +93 -0
  34. package/components/block/block.d.ts +19 -0
  35. package/components/block/block.js +9 -15
  36. package/components/block/class.js +33 -0
  37. package/components/breadcrumb/breadcrumb.d.ts +23 -0
  38. package/components/breadcrumb/breadcrumb.js +8 -113
  39. package/components/breadcrumb/class.js +132 -0
  40. package/components/button/button.d.ts +34 -0
  41. package/components/button/button.js +15 -66
  42. package/components/button/class.js +80 -0
  43. package/components/calendar-picker/calendar-picker.a2ui.json +6 -1
  44. package/components/calendar-picker/calendar-picker.d.ts +27 -0
  45. package/components/calendar-picker/calendar-picker.js +8 -332
  46. package/components/calendar-picker/calendar-picker.yaml +51 -177
  47. package/components/calendar-picker/class.js +351 -0
  48. package/components/canvas/canvas.a2ui.json +6 -1
  49. package/components/canvas/canvas.d.ts +17 -0
  50. package/components/canvas/canvas.yaml +19 -36
  51. package/components/card/card.a2ui.json +3 -0
  52. package/components/card/card.d.ts +27 -0
  53. package/components/card/card.js +9 -50
  54. package/components/card/card.yaml +171 -433
  55. package/components/card/class.js +68 -0
  56. package/components/chart/chart.d.ts +41 -0
  57. package/components/chart/chart.js +8 -2131
  58. package/components/chart/class.js +2150 -0
  59. package/components/chart-legend/chart-legend.d.ts +27 -0
  60. package/components/chart-legend/chart-legend.js +8 -197
  61. package/components/chart-legend/class.js +215 -0
  62. package/components/chat-thread/chat-thread.d.ts +17 -0
  63. package/components/chat-thread/chat-thread.js +8 -157
  64. package/components/chat-thread/class.js +176 -0
  65. package/components/check/check.d.ts +30 -0
  66. package/components/check/check.js +11 -52
  67. package/components/check/class.js +68 -0
  68. package/components/code/class.js +501 -0
  69. package/components/code/code.d.ts +39 -0
  70. package/components/code/code.js +8 -482
  71. package/components/col/class.js +30 -0
  72. package/components/col/col.d.ts +23 -0
  73. package/components/col/col.js +10 -13
  74. package/components/color-picker/class.js +550 -0
  75. package/components/color-picker/color-picker.d.ts +37 -0
  76. package/components/color-picker/color-picker.js +8 -531
  77. package/components/command/class.js +364 -0
  78. package/components/command/command.a2ui.json +3 -0
  79. package/components/command/command.d.ts +19 -0
  80. package/components/command/command.js +8 -345
  81. package/components/command/command.yaml +105 -124
  82. package/components/demo-toggle/class.js +153 -0
  83. package/components/demo-toggle/demo-toggle.d.ts +23 -0
  84. package/components/demo-toggle/demo-toggle.js +8 -135
  85. package/components/description-list/class.js +86 -0
  86. package/components/description-list/description-list.d.ts +21 -0
  87. package/components/description-list/description-list.js +8 -67
  88. package/components/divider/class.js +57 -0
  89. package/components/divider/divider.d.ts +19 -0
  90. package/components/divider/divider.js +10 -40
  91. package/components/drawer/class.js +306 -0
  92. package/components/drawer/drawer.d.ts +25 -0
  93. package/components/drawer/drawer.js +8 -287
  94. package/components/embed/class.js +73 -0
  95. package/components/embed/embed.d.ts +23 -0
  96. package/components/embed/embed.js +9 -55
  97. package/components/empty-state/class.js +108 -0
  98. package/components/empty-state/empty-state.d.ts +21 -0
  99. package/components/empty-state/empty-state.js +9 -90
  100. package/components/feed/class.js +381 -0
  101. package/components/feed/feed.d.ts +19 -0
  102. package/components/feed/feed.js +9 -367
  103. package/components/field/class.js +266 -0
  104. package/components/field/field.d.ts +23 -0
  105. package/components/field/field.js +8 -247
  106. package/components/fields/class.js +106 -0
  107. package/components/fields/fields.d.ts +19 -0
  108. package/components/fields/fields.js +8 -87
  109. package/components/grid/class.js +31 -0
  110. package/components/grid/grid.d.ts +23 -0
  111. package/components/grid/grid.js +10 -14
  112. package/components/heatmap/class.js +305 -0
  113. package/components/heatmap/heatmap.d.ts +31 -0
  114. package/components/heatmap/heatmap.js +8 -286
  115. package/components/icon/class.js +54 -0
  116. package/components/icon/icon.d.ts +23 -0
  117. package/components/icon/icon.js +13 -40
  118. package/components/image/class.js +112 -0
  119. package/components/image/image.d.ts +33 -0
  120. package/components/image/image.js +9 -94
  121. package/components/index.js +1 -0
  122. package/components/input/class.js +773 -0
  123. package/components/input/input.a2ui.json +3 -0
  124. package/components/input/input.d.ts +61 -0
  125. package/components/input/input.js +8 -755
  126. package/components/input/input.yaml +171 -442
  127. package/components/inspector/class.js +142 -0
  128. package/components/inspector/inspector.a2ui.json +8 -1
  129. package/components/inspector/inspector.d.ts +17 -0
  130. package/components/inspector/inspector.js +8 -124
  131. package/components/inspector/inspector.yaml +15 -30
  132. package/components/kbd/class.js +34 -0
  133. package/components/kbd/kbd.a2ui.json +3 -0
  134. package/components/kbd/kbd.d.ts +17 -0
  135. package/components/kbd/kbd.js +10 -17
  136. package/components/kbd/kbd.yaml +54 -185
  137. package/components/link/class.js +187 -0
  138. package/components/link/link.d.ts +55 -0
  139. package/components/link/link.js +8 -168
  140. package/components/list/class.js +249 -0
  141. package/components/list/list.d.ts +23 -0
  142. package/components/list/list.js +9 -231
  143. package/components/menu/class.js +332 -0
  144. package/components/menu/menu.d.ts +21 -0
  145. package/components/menu/menu.js +11 -316
  146. package/components/modal/class.js +231 -0
  147. package/components/modal/modal.a2ui.json +5 -1
  148. package/components/modal/modal.d.ts +23 -0
  149. package/components/modal/modal.js +8 -212
  150. package/components/modal/modal.yaml +19 -39
  151. package/components/nav/class.js +150 -0
  152. package/components/nav/nav.d.ts +31 -0
  153. package/components/nav/nav.js +8 -131
  154. package/components/nav-group/class.js +152 -0
  155. package/components/nav-group/nav-group.d.ts +35 -0
  156. package/components/nav-group/nav-group.js +9 -134
  157. package/components/nav-item/class.js +86 -0
  158. package/components/nav-item/nav-item.d.ts +37 -0
  159. package/components/nav-item/nav-item.js +10 -69
  160. package/components/noodles/class.js +510 -0
  161. package/components/noodles/noodles.d.ts +33 -0
  162. package/components/noodles/noodles.js +9 -493
  163. package/components/option-card/class.js +167 -0
  164. package/components/option-card/option-card.d.ts +30 -0
  165. package/components/option-card/option-card.js +8 -149
  166. package/components/otp-input/class.js +180 -0
  167. package/components/otp-input/otp-input.a2ui.json +5 -1
  168. package/components/otp-input/otp-input.d.ts +25 -0
  169. package/components/otp-input/otp-input.js +9 -162
  170. package/components/otp-input/otp-input.yaml +45 -174
  171. package/components/page/class.js +97 -0
  172. package/components/page/page.d.ts +46 -0
  173. package/components/page/page.js +8 -79
  174. package/components/pagination/class.js +195 -0
  175. package/components/pagination/pagination.d.ts +23 -0
  176. package/components/pagination/pagination.js +9 -177
  177. package/components/pane/class.js +186 -0
  178. package/components/pane/pane.a2ui.json +12 -1
  179. package/components/pane/pane.css +10 -0
  180. package/components/pane/pane.d.ts +31 -0
  181. package/components/pane/pane.js +8 -143
  182. package/components/pane/pane.yaml +57 -157
  183. package/components/pipeline-status/class.js +189 -0
  184. package/components/pipeline-status/pipeline-status.a2ui.json +7 -1
  185. package/components/pipeline-status/pipeline-status.d.ts +21 -0
  186. package/components/pipeline-status/pipeline-status.js +9 -172
  187. package/components/pipeline-status/pipeline-status.yaml +34 -72
  188. package/components/popover/class.js +194 -0
  189. package/components/popover/popover.d.ts +23 -0
  190. package/components/popover/popover.js +9 -176
  191. package/components/progress/class.js +74 -0
  192. package/components/progress/progress.a2ui.json +3 -0
  193. package/components/progress/progress.d.ts +19 -0
  194. package/components/progress/progress.js +10 -57
  195. package/components/progress/progress.yaml +124 -287
  196. package/components/progress-row/class.js +110 -0
  197. package/components/progress-row/progress-row.d.ts +23 -0
  198. package/components/progress-row/progress-row.js +8 -92
  199. package/components/radio/class.js +83 -0
  200. package/components/radio/radio.d.ts +28 -0
  201. package/components/radio/radio.js +11 -67
  202. package/components/range/class.js +194 -0
  203. package/components/range/range.d.ts +31 -0
  204. package/components/range/range.js +9 -176
  205. package/components/rating/class.js +148 -0
  206. package/components/rating/rating.d.ts +33 -0
  207. package/components/rating/rating.js +9 -130
  208. package/components/richtext/class.js +87 -0
  209. package/components/richtext/richtext.a2ui.json +7 -1
  210. package/components/richtext/richtext.d.ts +19 -0
  211. package/components/richtext/richtext.js +8 -68
  212. package/components/richtext/richtext.yaml +30 -65
  213. package/components/row/class.js +50 -0
  214. package/components/row/row.d.ts +27 -0
  215. package/components/row/row.js +10 -33
  216. package/components/search/class.js +134 -0
  217. package/components/search/search.d.ts +35 -0
  218. package/components/search/search.js +10 -117
  219. package/components/segment/class.js +62 -0
  220. package/components/segment/segment.d.ts +25 -0
  221. package/components/segment/segment.js +10 -45
  222. package/components/segmented/class.js +165 -0
  223. package/components/segmented/segmented.a2ui.json +4 -0
  224. package/components/segmented/segmented.d.ts +24 -0
  225. package/components/segmented/segmented.js +10 -148
  226. package/components/segmented/segmented.yaml +41 -59
  227. package/components/select/class.js +408 -0
  228. package/components/select/select.d.ts +57 -0
  229. package/components/select/select.js +15 -396
  230. package/components/skeleton/class.js +52 -0
  231. package/components/skeleton/skeleton.d.ts +23 -0
  232. package/components/skeleton/skeleton.js +8 -34
  233. package/components/slider/class.js +184 -0
  234. package/components/slider/slider.d.ts +31 -0
  235. package/components/slider/slider.js +9 -166
  236. package/components/stack/class.js +28 -0
  237. package/components/stack/stack.d.ts +17 -0
  238. package/components/stack/stack.js +10 -11
  239. package/components/step-progress/class.js +98 -0
  240. package/components/step-progress/step-progress.d.ts +27 -0
  241. package/components/step-progress/step-progress.js +8 -79
  242. package/components/stepper/class.js +126 -0
  243. package/components/stepper/stepper.d.ts +19 -0
  244. package/components/stepper/stepper.js +9 -112
  245. package/components/stream/class.js +109 -0
  246. package/components/stream/stream.d.ts +19 -0
  247. package/components/stream/stream.js +8 -90
  248. package/components/swatch/class.js +131 -0
  249. package/components/swatch/swatch.d.ts +28 -0
  250. package/components/swatch/swatch.js +8 -112
  251. package/components/swiper/class.js +373 -0
  252. package/components/swiper/swiper.a2ui.json +4 -0
  253. package/components/swiper/swiper.d.ts +31 -0
  254. package/components/swiper/swiper.js +8 -354
  255. package/components/swiper/swiper.yaml +68 -212
  256. package/components/switch/class.js +63 -0
  257. package/components/switch/switch.a2ui.json +6 -1
  258. package/components/switch/switch.d.ts +30 -0
  259. package/components/switch/switch.js +11 -47
  260. package/components/switch/switch.yaml +70 -265
  261. package/components/table/class.js +1453 -0
  262. package/components/table/table.d.ts +37 -0
  263. package/components/table/table.js +8 -1435
  264. package/components/table-toolbar/class.js +680 -0
  265. package/components/table-toolbar/table-toolbar.d.ts +33 -0
  266. package/components/table-toolbar/table-toolbar.js +8 -689
  267. package/components/tabs/class.js +242 -0
  268. package/components/tabs/tabs.d.ts +21 -0
  269. package/components/tabs/tabs.js +8 -223
  270. package/components/tag/class.js +99 -0
  271. package/components/tag/tag.d.ts +27 -0
  272. package/components/tag/tag.js +8 -80
  273. package/components/text/class.js +46 -0
  274. package/components/text/text.d.ts +25 -0
  275. package/components/text/text.js +9 -28
  276. package/components/textarea/class.js +134 -0
  277. package/components/textarea/textarea.d.ts +31 -0
  278. package/components/textarea/textarea.js +11 -118
  279. package/components/timeline/class.js +176 -0
  280. package/components/timeline/timeline.d.ts +19 -0
  281. package/components/timeline/timeline.js +9 -162
  282. package/components/toast/class.js +92 -0
  283. package/components/toast/toast.d.ts +23 -0
  284. package/components/toast/toast.js +9 -76
  285. package/components/toggle-group/class.js +154 -0
  286. package/components/toggle-group/toggle-group.d.ts +19 -0
  287. package/components/toggle-group/toggle-group.js +11 -140
  288. package/components/toggle-scheme/class.js +286 -0
  289. package/components/toggle-scheme/toggle-scheme.a2ui.json +197 -0
  290. package/components/toggle-scheme/toggle-scheme.css +20 -0
  291. package/components/toggle-scheme/toggle-scheme.d.ts +41 -0
  292. package/components/toggle-scheme/toggle-scheme.js +17 -0
  293. package/components/toggle-scheme/toggle-scheme.yaml +173 -0
  294. package/components/toolbar/class.js +388 -0
  295. package/components/toolbar/toolbar.d.ts +23 -0
  296. package/components/toolbar/toolbar.js +10 -376
  297. package/components/tooltip/class.js +299 -0
  298. package/components/tooltip/tooltip.d.ts +27 -0
  299. package/components/tooltip/tooltip.js +8 -280
  300. package/components/tree/class.js +245 -0
  301. package/components/tree/tree.d.ts +15 -0
  302. package/components/tree/tree.js +9 -244
  303. package/components/upload/class.js +199 -0
  304. package/components/upload/upload.d.ts +27 -0
  305. package/components/upload/upload.js +11 -183
  306. package/core/element.d.ts +174 -0
  307. package/core/form.d.ts +108 -0
  308. package/core/index.d.ts +11 -0
  309. package/core/index.js +1 -0
  310. package/core/register.d.ts +25 -0
  311. package/core/register.js +58 -0
  312. package/core/signals.d.ts +94 -0
  313. package/core/template.d.ts +70 -0
  314. package/index.d.ts +315 -0
  315. package/package.json +25 -6
  316. package/traits/CATEGORIES.md +1 -1
@@ -0,0 +1,1453 @@
1
+ /**
2
+ * Non-side-effect class export for `<table-ui>`.
3
+ *
4
+ * Importing this file gives you the class(es) 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/table`
9
+ * (which imports this file + calls `defineIfFree()`).
10
+ *
11
+ * @see ../../USAGE.md#registration--auto-vs-explicit
12
+ */
13
+
14
+ /**
15
+ * <table-ui> — Data table with sorting, selection, pagination, search,
16
+ * column resize, keyboard navigation, cell types, and CSV export.
17
+ *
18
+ * Renders as CSS grid + subgrid rows with ARIA grid roles (no <table> element).
19
+ *
20
+ * Attributes:
21
+ * sortable — enable column sorting on header click
22
+ * selectable — enable row selection via checkboxes
23
+ * striped — alternate row background
24
+ * density — 'compact' | 'standard' | 'comfortable'
25
+ * paginate — rows per page (0 = no pagination)
26
+ * loading — show loading overlay
27
+ * search — global search filter string
28
+ *
29
+ * Declarative columns via <col-def> children:
30
+ * <col-def key="name" label="Name" type="text" width="200"
31
+ * min-width="100" max-width="400" flex="2" pinned="left"
32
+ * sortable hidden filter-type="text"></col-def>
33
+ *
34
+ * JS API:
35
+ * .columns = [{key, label, type, width, minWidth, maxWidth, flex,
36
+ * sortable, filterable, resizable, pinned, hidden,
37
+ * accessor, format, render, sortFn, sortDescFirst,
38
+ * filterType, filterFn, aggregate, meta}]
39
+ * .data = [{...}, ...]
40
+ * .selected → [...indices] (read-only)
41
+ * .sortState → [{key, dir}] (read-only)
42
+ * .exportCSV(filename?)
43
+ *
44
+ * Events:
45
+ * sort — { detail: { key, dir, sortState } }
46
+ * select — { detail: { selected: [...indices] } }
47
+ * page — { detail: { page } }
48
+ * resize — { detail: { key, width } }
49
+ * cell-click — { detail: { key, row, value, dataIndex } }
50
+ */
51
+
52
+ import { UIElement } from '../../core/element.js';
53
+ import { cellTypes, sortFns } from './cell-types.js';
54
+
55
+ // ── Helpers ──────────────────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * Resolve a dot-notation path on an object.
59
+ * getNestedValue({user: {name: 'Jo'}}, 'user.name') → 'Jo'
60
+ */
61
+ function getNestedValue(obj, path) {
62
+ if (!path || !obj) return undefined;
63
+ const parts = path.split('.');
64
+ let cur = obj;
65
+ for (const p of parts) {
66
+ if (cur == null) return undefined;
67
+ cur = cur[p];
68
+ }
69
+ return cur;
70
+ }
71
+
72
+ /**
73
+ * Get cell value for a column from a row, respecting accessor and dot notation.
74
+ */
75
+ function getCellValue(row, col) {
76
+ if (typeof col.accessor === 'function') return col.accessor(row);
77
+ return getNestedValue(row, col.key);
78
+ }
79
+
80
+ /**
81
+ * Escape a value for CSV output.
82
+ */
83
+ function csvEscape(val) {
84
+ const str = String(val ?? '');
85
+ if (str.includes(',') || str.includes('"') || str.includes('\n')) {
86
+ return '"' + str.replace(/"/g, '""') + '"';
87
+ }
88
+ return str;
89
+ }
90
+
91
+ // ── Component ────────────────────────────────────────────────────────────────
92
+
93
+ export class UITable extends UIElement {
94
+ static properties = {
95
+ sortable: { type: Boolean, default: false, reflect: true },
96
+ selectable: { type: Boolean, default: false, reflect: true },
97
+ expandable: { type: Boolean, default: false, reflect: true },
98
+ striped: { type: Boolean, default: false, reflect: true },
99
+ raw: { type: Boolean, default: false, reflect: true },
100
+ density: { type: String, default: 'standard', reflect: true },
101
+ paginate: { type: Number, default: 0, reflect: true },
102
+ loading: { type: Boolean, default: false, reflect: true },
103
+ search: { type: String, default: '', reflect: true },
104
+ };
105
+
106
+ static template = () => null;
107
+
108
+ // ── Private state ──
109
+
110
+ #columns = [];
111
+ #data = [];
112
+ #sortState = []; // [{key, dir}] for multi-sort
113
+ #filters = new Map(); // key → { op, value }
114
+ #expanded = new Set(); // row indices
115
+ #editingCell = null; // {rowIndex, colKey} or null
116
+ #page = 0;
117
+ #selected = new Set();
118
+ #columnWidths = new Map(); // key → current width in px
119
+ #focusedCell = null; // {row, col}
120
+ #bound = false;
121
+ #lastSelectedIndex = -1; // for shift-click range select
122
+ #openFilter = null; // column key of open filter dropdown
123
+ #filterFocusRaf = null;
124
+
125
+ // ── Public API: columns ──
126
+
127
+ set columns(arr) {
128
+ this.#columns = Array.isArray(arr) ? arr : [];
129
+ this.#requestRender();
130
+ }
131
+
132
+ get columns() { return this.#columns; }
133
+
134
+ // ── Public API: data ──
135
+
136
+ set data(arr) {
137
+ this.#data = Array.isArray(arr) ? arr : [];
138
+ this.#selected.clear();
139
+ this.#page = 0;
140
+ this.#requestRender();
141
+ }
142
+
143
+ get data() { return this.#data; }
144
+
145
+ // ── Public API: selection ──
146
+
147
+ /**
148
+ * Indices of currently-selected rows, ascending.
149
+ * @returns {number[]}
150
+ */
151
+ get selected() { return [...this.#selected].sort((a, b) => a - b); }
152
+
153
+ /**
154
+ * Replace the selection set programmatically. Out-of-range indices are
155
+ * silently dropped. Pair with `selectable` mode; on a non-selectable
156
+ * table the indices are stored but not rendered as checked rows.
157
+ * @param {Iterable<number>} indices
158
+ */
159
+ set selected(indices) {
160
+ this.#selected.clear();
161
+ if (indices) {
162
+ for (const i of indices) {
163
+ if (Number.isInteger(i) && i >= 0 && i < this.#data.length) {
164
+ this.#selected.add(i);
165
+ }
166
+ }
167
+ }
168
+ this.#requestRender();
169
+ this.dispatchEvent(new CustomEvent('select', {
170
+ detail: { selected: this.selected },
171
+ bubbles: true,
172
+ }));
173
+ }
174
+
175
+ /**
176
+ * Empty the selection set. Equivalent to `el.selected = []` but doesn't
177
+ * require constructing an empty array; the most common selection-write
178
+ * call by far (after a "delete" or "archive" bulk action), so worth
179
+ * having a one-token verb form.
180
+ */
181
+ clearSelection() {
182
+ if (this.#selected.size === 0) return;
183
+ this.#selected.clear();
184
+ this.#requestRender();
185
+ this.dispatchEvent(new CustomEvent('select', {
186
+ detail: { selected: [] },
187
+ bubbles: true,
188
+ }));
189
+ }
190
+
191
+ // ── Public API: read-only getters ──
192
+
193
+ get sortState() { return this.#sortState.map(s => ({ ...s })); }
194
+
195
+ // ── Public API: filters ──
196
+
197
+ setFilter(key, value, op = 'contains') {
198
+ if (value === null || value === undefined || value === '') {
199
+ this.#filters.delete(key);
200
+ } else {
201
+ this.#filters.set(key, { op, value });
202
+ }
203
+ this.#page = 0;
204
+ this.#requestRender();
205
+ this.dispatchEvent(new CustomEvent('filter-change', {
206
+ detail: { filters: Object.fromEntries(this.#filters) },
207
+ bubbles: true,
208
+ }));
209
+ }
210
+
211
+ clearFilters() {
212
+ this.#filters.clear();
213
+ this.#openFilter = null;
214
+ this.#page = 0;
215
+ this.#requestRender();
216
+ this.dispatchEvent(new CustomEvent('filter-change', {
217
+ detail: { filters: {} },
218
+ bubbles: true,
219
+ }));
220
+ }
221
+
222
+ get filters() { return Object.fromEntries(this.#filters); }
223
+
224
+ // ── Public API: expansion ──
225
+
226
+ toggleExpand(index) {
227
+ if (this.#expanded.has(index)) {
228
+ this.#expanded.delete(index);
229
+ this.dispatchEvent(new CustomEvent('row-collapse', { detail: { index, row: this.#data[index] }, bubbles: true }));
230
+ } else {
231
+ this.#expanded.add(index);
232
+ this.dispatchEvent(new CustomEvent('row-expand', { detail: { index, row: this.#data[index] }, bubbles: true }));
233
+ }
234
+ this.#requestRender();
235
+ }
236
+
237
+ get expanded() { return [...this.#expanded]; }
238
+
239
+ /** @type {((row: object, index: number) => HTMLElement)|null} */
240
+ expandRenderer = null;
241
+
242
+ // ── Public API: state persistence ──
243
+
244
+ getState() {
245
+ return {
246
+ sort: this.#sortState.map(s => ({ ...s })),
247
+ filters: Object.fromEntries(this.#filters),
248
+ columnWidths: Object.fromEntries(this.#columnWidths),
249
+ hiddenColumns: this.#columns.filter(c => c.hidden).map(c => c.key),
250
+ page: this.#page,
251
+ density: this.density,
252
+ };
253
+ }
254
+
255
+ setState(state) {
256
+ if (!state) return;
257
+ if (state.sort) this.#sortState = state.sort;
258
+ if (state.filters) {
259
+ this.#filters.clear();
260
+ for (const [key, val] of Object.entries(state.filters)) {
261
+ this.#filters.set(key, val);
262
+ }
263
+ }
264
+ if (state.columnWidths) {
265
+ this.#columnWidths.clear();
266
+ for (const [key, val] of Object.entries(state.columnWidths)) {
267
+ this.#columnWidths.set(key, val);
268
+ }
269
+ }
270
+ if (state.hiddenColumns) {
271
+ for (const col of this.#columns) {
272
+ col.hidden = state.hiddenColumns.includes(col.key);
273
+ }
274
+ }
275
+ if (state.page != null) this.#page = state.page;
276
+ if (state.density) this.density = state.density;
277
+ this.#requestRender();
278
+ }
279
+
280
+ #persistState() {
281
+ const key = this.getAttribute('state-key');
282
+ if (!key) return;
283
+ try {
284
+ localStorage.setItem(`table-state:${key}`, JSON.stringify(this.getState()));
285
+ } catch {}
286
+ }
287
+
288
+ #restoreState() {
289
+ const key = this.getAttribute('state-key');
290
+ if (!key) return;
291
+ try {
292
+ const saved = localStorage.getItem(`table-state:${key}`);
293
+ if (saved) this.setState(JSON.parse(saved));
294
+ } catch {}
295
+ }
296
+
297
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
298
+
299
+ connected() {
300
+ // Parse declarative <col-def> children
301
+ this.#parseColDefs();
302
+
303
+ this.setAttribute('role', 'grid');
304
+ this.setAttribute('tabindex', '0');
305
+
306
+ // Restore persisted state
307
+ this.#restoreState();
308
+
309
+ if (!this.#bound) {
310
+ this.#bound = true;
311
+ this.addEventListener('click', this.#onClick);
312
+ this.addEventListener('keydown', this.#onKeydown);
313
+ }
314
+ }
315
+
316
+ disconnected() {
317
+ this.removeEventListener('click', this.#onClick);
318
+ this.removeEventListener('keydown', this.#onKeydown);
319
+ this.#bound = false;
320
+ this.#cleanupResize();
321
+ if (this.#renderRaf) {
322
+ cancelAnimationFrame(this.#renderRaf);
323
+ this.#renderRaf = null;
324
+ }
325
+ if (this.#filterFocusRaf != null) {
326
+ cancelAnimationFrame(this.#filterFocusRaf);
327
+ this.#filterFocusRaf = null;
328
+ }
329
+ }
330
+
331
+ // ── <col-def> Parsing ──────────────────────────────────────────────────────
332
+
333
+ #parseColDefs() {
334
+ const defs = this.querySelectorAll(':scope > col-def');
335
+ if (!defs.length) return;
336
+
337
+ const cols = [];
338
+ for (const el of defs) {
339
+ const col = {
340
+ key: el.getAttribute('key') || '',
341
+ label: el.getAttribute('label') || el.getAttribute('key') || '',
342
+ type: el.getAttribute('type') || 'text',
343
+ pinned: el.getAttribute('pinned') || null, // 'left' | 'right' | null
344
+ hidden: el.hasAttribute('hidden'),
345
+ sortable: el.hasAttribute('sortable'),
346
+ resizable: el.hasAttribute('resizable'),
347
+ filterable: el.hasAttribute('filterable'),
348
+ filterType: el.getAttribute('filter-type') || null,
349
+ };
350
+
351
+ const w = el.getAttribute('width');
352
+ if (w) col.width = Number(w);
353
+
354
+ const min = el.getAttribute('min-width');
355
+ if (min) col.minWidth = Number(min);
356
+
357
+ const max = el.getAttribute('max-width');
358
+ if (max) col.maxWidth = Number(max);
359
+
360
+ const flex = el.getAttribute('flex');
361
+ if (flex) col.flex = Number(flex);
362
+
363
+ cols.push(col);
364
+ el.remove();
365
+ }
366
+
367
+ // Only set if we actually found col-defs and columns aren't already set via JS
368
+ if (cols.length && !this.#columns.length) {
369
+ this.#columns = cols;
370
+ }
371
+ }
372
+
373
+ // ── Render Batching ────────────────────────────────────────────────────────
374
+
375
+ #renderRaf = null;
376
+
377
+ #requestRender() {
378
+ if (!this.isConnected || this.#renderRaf) return;
379
+ this.#renderRaf = requestAnimationFrame(() => {
380
+ this.#renderRaf = null;
381
+ this.render();
382
+ this.#persistState();
383
+ });
384
+ }
385
+
386
+ // ── Visible Columns Helper ─────────────────────────────────────────────────
387
+
388
+ get #visibleColumns() {
389
+ return this.#columns.filter(c => !c.hidden);
390
+ }
391
+
392
+ // ── Grid Template Columns ──────────────────────────────────────────────────
393
+
394
+ #buildGridTemplate() {
395
+ const parts = [];
396
+
397
+ if (this.expandable) {
398
+ parts.push('2.5rem');
399
+ }
400
+
401
+ if (this.selectable) {
402
+ parts.push('2.5rem');
403
+ }
404
+
405
+ for (const col of this.#visibleColumns) {
406
+ // User-resized width takes priority
407
+ if (this.#columnWidths.has(col.key)) {
408
+ parts.push(`${this.#columnWidths.get(col.key)}px`);
409
+ } else if (col.width) {
410
+ parts.push(`${col.width}px`);
411
+ } else if (col.flex) {
412
+ parts.push(`minmax(6rem, ${col.flex}fr)`);
413
+ } else {
414
+ parts.push('minmax(6rem, 1fr)');
415
+ }
416
+ }
417
+
418
+ return parts.join(' ');
419
+ }
420
+
421
+ // ── Data Processing Pipeline ───────────────────────────────────────────────
422
+
423
+ /**
424
+ * Apply search → column filters → sort → return array of original data indices.
425
+ */
426
+ #getProcessedIndices() {
427
+ let indices = this.#data.map((_, i) => i);
428
+
429
+ // 1. Global search filter
430
+ if (this.search) {
431
+ const q = this.search.toLowerCase();
432
+ indices = indices.filter(i => {
433
+ const row = this.#data[i];
434
+ return this.#visibleColumns.some(col => {
435
+ const val = getCellValue(row, col);
436
+ return val != null && String(val).toLowerCase().includes(q);
437
+ });
438
+ });
439
+ }
440
+
441
+ // 2. Column filters
442
+ if (this.#filters.size) {
443
+ indices = indices.filter(i => {
444
+ const row = this.#data[i];
445
+ for (const [key, { op, value }] of this.#filters) {
446
+ const col = this.#columns.find(c => c.key === key);
447
+ const cellVal = getCellValue(row, col || { key });
448
+ const str = String(cellVal ?? '').toLowerCase();
449
+ const target = String(value).toLowerCase();
450
+ switch (op) {
451
+ case 'contains': if (!str.includes(target)) return false; break;
452
+ case 'equals': if (str !== target) return false; break;
453
+ case 'startsWith': if (!str.startsWith(target)) return false; break;
454
+ case 'gt': if (Number(cellVal) <= Number(value)) return false; break;
455
+ case 'lt': if (Number(cellVal) >= Number(value)) return false; break;
456
+ case 'gte': if (Number(cellVal) < Number(value)) return false; break;
457
+ case 'lte': if (Number(cellVal) > Number(value)) return false; break;
458
+ case 'between': {
459
+ const [lo, hi] = String(value).split(',').map(Number);
460
+ const n = Number(cellVal);
461
+ if (n < lo || n > hi) return false;
462
+ break;
463
+ }
464
+ case 'select': {
465
+ const selected = target.split(',').map(s => s.trim());
466
+ if (!selected.includes(str)) return false;
467
+ break;
468
+ }
469
+ default: if (!str.includes(target)) return false;
470
+ }
471
+ }
472
+ return true;
473
+ });
474
+ }
475
+
476
+ // 3. Multi-sort
477
+ if (this.#sortState.length) {
478
+ indices.sort((a, b) => {
479
+ for (const { key, dir } of this.#sortState) {
480
+ const col = this.#columns.find(c => c.key === key);
481
+ const va = getCellValue(this.#data[a], col || { key });
482
+ const vb = getCellValue(this.#data[b], col || { key });
483
+
484
+ // Determine sort function
485
+ let fn;
486
+ if (typeof col?.sortFn === 'function') {
487
+ fn = col.sortFn;
488
+ } else {
489
+ const typeDef = cellTypes[col?.type || 'text'];
490
+ const fnName = col?.sortFn || typeDef?.sortFn || 'alphanumeric';
491
+ fn = sortFns[fnName] || sortFns.alphanumeric;
492
+ }
493
+
494
+ // Nulls always sort last
495
+ if (va == null && vb == null) continue;
496
+ if (va == null) return 1;
497
+ if (vb == null) return -1;
498
+
499
+ const cmp = fn(va, vb);
500
+ if (cmp !== 0) return dir === 'asc' ? cmp : -cmp;
501
+ }
502
+ return 0;
503
+ });
504
+ }
505
+
506
+ return indices;
507
+ }
508
+
509
+ /**
510
+ * Paginate a set of indices.
511
+ */
512
+ #getPageSlice(indices) {
513
+ if (!this.paginate || this.paginate <= 0) return indices;
514
+ const start = this.#page * this.paginate;
515
+ return indices.slice(start, start + this.paginate);
516
+ }
517
+
518
+ get #pageCount() {
519
+ if (!this.paginate || this.paginate <= 0) return 1;
520
+ // pageCount should reflect filtered data, not raw data
521
+ const filteredCount = this.#getProcessedIndices().length;
522
+ return Math.max(1, Math.ceil(filteredCount / this.paginate));
523
+ }
524
+
525
+ // ── Render ─────────────────────────────────────────────────────────────────
526
+
527
+ render() {
528
+ const visCols = this.#visibleColumns;
529
+
530
+ // Set grid template
531
+ this.style.gridTemplateColumns = this.#buildGridTemplate();
532
+
533
+ // ── Header row ──
534
+
535
+ let header = this.querySelector(':scope > [data-header]');
536
+ if (!header) {
537
+ header = document.createElement('div');
538
+ header.setAttribute('role', 'row');
539
+ header.setAttribute('data-header', '');
540
+ this.prepend(header);
541
+ }
542
+
543
+ this.#renderHeader(header, visCols);
544
+
545
+ // ── Filter chips bar ──
546
+
547
+ this.#renderFilterChips();
548
+
549
+ // ── Body rowgroup ──
550
+
551
+ let body = this.querySelector(':scope > [data-body]');
552
+ if (!body) {
553
+ body = document.createElement('div');
554
+ body.setAttribute('role', 'rowgroup');
555
+ body.setAttribute('data-body', '');
556
+ header.after(body);
557
+ }
558
+
559
+ // Process data: search → sort → paginate
560
+ const allProcessed = this.#getProcessedIndices();
561
+ const pageIndices = this.#getPageSlice(allProcessed);
562
+
563
+ // Reconcile body rows (with expansion support)
564
+ const bodyChildren = [];
565
+ for (const idx of pageIndices) {
566
+ const existing = body.querySelector(`:scope > [role="row"][data-index="${idx}"]`);
567
+ const row = existing || this.#createRow(idx, visCols);
568
+ if (existing) this.#updateRow(existing, idx, visCols);
569
+
570
+ // Expand toggle in first cell
571
+ if (this.expandable) {
572
+ const isExpanded = this.#expanded.has(idx);
573
+ if (isExpanded) row.setAttribute('data-expanded', '');
574
+ else row.removeAttribute('data-expanded');
575
+ }
576
+
577
+ bodyChildren.push(row);
578
+
579
+ // Detail row (expansion)
580
+ if (this.expandable && this.#expanded.has(idx)) {
581
+ let detail = body.querySelector(`:scope > [data-detail-row][data-for="${idx}"]`);
582
+ if (!detail) {
583
+ detail = document.createElement('div');
584
+ detail.setAttribute('data-detail-row', '');
585
+ detail.dataset.for = idx;
586
+ if (this.expandRenderer) {
587
+ const content = this.expandRenderer(this.#data[idx], idx);
588
+ if (content instanceof Node) detail.appendChild(content);
589
+ else detail.textContent = String(content);
590
+ }
591
+ }
592
+ bodyChildren.push(detail);
593
+ }
594
+ }
595
+
596
+ // Reconcile: replace body contents
597
+ const existingChildren = [...body.children];
598
+ for (const child of existingChildren) {
599
+ if (!bodyChildren.includes(child)) child.remove();
600
+ }
601
+ for (let i = 0; i < bodyChildren.length; i++) {
602
+ if (body.children[i] !== bodyChildren[i]) {
603
+ if (body.children[i]) body.insertBefore(bodyChildren[i], body.children[i]);
604
+ else body.appendChild(bodyChildren[i]);
605
+ }
606
+ }
607
+
608
+ // ── Overlays ──
609
+
610
+ this.#renderOverlays(body);
611
+
612
+ // ── Aggregation footer ──
613
+
614
+ this.#renderAggregation(body, allProcessed, visCols);
615
+
616
+ // ── Pagination footer ──
617
+
618
+ const showPagination = this.paginate > 0 && this.#data.length > 0;
619
+ let footer = this.querySelector(':scope > [data-footer]');
620
+
621
+ if (showPagination) {
622
+ if (!footer) {
623
+ footer = document.createElement('div');
624
+ footer.setAttribute('data-footer', '');
625
+ this.appendChild(footer);
626
+ }
627
+ this.#renderPagination(footer, allProcessed.length);
628
+ } else if (footer) {
629
+ footer.remove();
630
+ }
631
+ }
632
+
633
+ // ── Header Rendering ───────────────────────────────────────────────────────
634
+
635
+ #renderHeader(header, visCols) {
636
+ const allSelected = this.#data.length > 0 && this.#selected.size === this.#data.length;
637
+ const cells = [];
638
+
639
+ // Checkbox column header
640
+ if (this.selectable) {
641
+ const cell = document.createElement('div');
642
+ cell.setAttribute('role', 'columnheader');
643
+ cell.setAttribute('data-check-col', '');
644
+ const check = document.createElement('check-ui');
645
+ if (allSelected) check.setAttribute('checked', '');
646
+ check.setAttribute('aria-label', 'Select all rows');
647
+ cell.appendChild(check);
648
+ cells.push(cell);
649
+ }
650
+
651
+ // Data columns
652
+ for (const col of visCols) {
653
+ const cell = document.createElement('div');
654
+ cell.setAttribute('role', 'columnheader');
655
+
656
+ // Label
657
+ const label = document.createElement('span');
658
+ label.textContent = col.label || col.key;
659
+ cell.appendChild(label);
660
+
661
+ // Sortable
662
+ const colSortable = col.sortable !== false && this.sortable;
663
+ if (colSortable) {
664
+ cell.dataset.sortKey = col.key;
665
+ cell.setAttribute('aria-label', `Sort by ${col.label || col.key}`);
666
+
667
+ const icon = document.createElement('icon-ui');
668
+ icon.setAttribute('data-sort-icon', '');
669
+
670
+ const sortEntry = this.#sortState.find(s => s.key === col.key);
671
+ if (sortEntry) {
672
+ cell.setAttribute('aria-sort', sortEntry.dir === 'asc' ? 'ascending' : 'descending');
673
+ icon.setAttribute('name', sortEntry.dir === 'asc' ? 'arrow-up' : 'arrow-down');
674
+ } else {
675
+ icon.setAttribute('name', 'caret-up-down');
676
+ }
677
+
678
+ cell.appendChild(icon);
679
+ }
680
+
681
+ // Pinned
682
+ if (col.pinned) {
683
+ cell.setAttribute('data-pinned', col.pinned);
684
+ }
685
+
686
+ // Resize handle
687
+ if (col.resizable !== false) {
688
+ const handle = document.createElement('div');
689
+ handle.setAttribute('data-resize-handle', '');
690
+ handle.dataset.resizeKey = col.key;
691
+ cell.appendChild(handle);
692
+ }
693
+
694
+ // Filter button
695
+ if (col.filter) {
696
+ const filterBtn = document.createElement('button');
697
+ filterBtn.setAttribute('data-filter-btn', '');
698
+ filterBtn.dataset.filterKey = col.key;
699
+ filterBtn.setAttribute('aria-label', `Filter ${col.label || col.key}`);
700
+ const filterIcon = document.createElement('icon-ui');
701
+ filterIcon.setAttribute('name', this.#filters.has(col.key) ? 'funnel-simple-fill' : 'funnel-simple');
702
+ filterIcon.setAttribute('size', 'xs');
703
+ filterBtn.appendChild(filterIcon);
704
+ cell.appendChild(filterBtn);
705
+
706
+ // Open filter dropdown
707
+ if (this.#openFilter === col.key) {
708
+ const dropdown = this.#buildFilterDropdown(col);
709
+ cell.appendChild(dropdown);
710
+ cell.setAttribute('data-filter-open', '');
711
+ }
712
+ }
713
+
714
+ cells.push(cell);
715
+ }
716
+
717
+ // Reconcile header cells
718
+ while (header.children.length > cells.length) header.lastChild.remove();
719
+ for (let i = 0; i < cells.length; i++) {
720
+ if (header.children[i]) header.replaceChild(cells[i], header.children[i]);
721
+ else header.appendChild(cells[i]);
722
+ }
723
+ }
724
+
725
+ // ── Row Builders ───────────────────────────────────────────────────────────
726
+
727
+ #createRow(dataIndex, visCols) {
728
+ const row = document.createElement('div');
729
+ row.setAttribute('role', 'row');
730
+ this.#updateRow(row, dataIndex, visCols);
731
+ return row;
732
+ }
733
+
734
+ #updateRow(row, dataIndex, visCols) {
735
+ const data = this.#data[dataIndex];
736
+ const isSelected = this.#selected.has(dataIndex);
737
+ row.dataset.index = dataIndex;
738
+
739
+ if (isSelected) {
740
+ row.setAttribute('data-selected', '');
741
+ row.setAttribute('aria-selected', 'true');
742
+ } else {
743
+ row.removeAttribute('data-selected');
744
+ row.removeAttribute('aria-selected');
745
+ }
746
+
747
+ const cells = [];
748
+
749
+ // Expand toggle cell
750
+ if (this.expandable) {
751
+ const cell = document.createElement('div');
752
+ cell.setAttribute('role', 'gridcell');
753
+ cell.setAttribute('data-expand-col', '');
754
+ const btn = document.createElement('button');
755
+ btn.setAttribute('data-expand-toggle', '');
756
+ btn.setAttribute('aria-label', 'Expand row');
757
+ const icon = document.createElement('icon-ui');
758
+ icon.setAttribute('name', 'caret-right');
759
+ icon.setAttribute('size', 'xs');
760
+ btn.appendChild(icon);
761
+ cell.appendChild(btn);
762
+ cells.push(cell);
763
+ }
764
+
765
+ // Checkbox cell
766
+ if (this.selectable) {
767
+ const cell = document.createElement('div');
768
+ cell.setAttribute('role', 'gridcell');
769
+ cell.setAttribute('data-check-col', '');
770
+ const check = document.createElement('check-ui');
771
+ if (isSelected) check.setAttribute('checked', '');
772
+ check.setAttribute('aria-label', `Select row ${dataIndex + 1}`);
773
+ cell.appendChild(check);
774
+ cells.push(cell);
775
+ }
776
+
777
+ // Data cells
778
+ for (const col of visCols) {
779
+ const cell = document.createElement('div');
780
+ cell.setAttribute('role', 'gridcell');
781
+ cell.dataset.key = col.key;
782
+
783
+ const value = getCellValue(data, col);
784
+
785
+ // Render priority: column.render > column.format > cellType.render > text
786
+ if (typeof col.render === 'function') {
787
+ const result = col.render(value, data, cell, dataIndex);
788
+ if (result instanceof Node) {
789
+ cell.replaceChildren(result);
790
+ } else if (typeof result === 'string') {
791
+ cell.innerHTML = result;
792
+ }
793
+ } else if (typeof col.format === 'function') {
794
+ cell.textContent = col.format(value, data);
795
+ } else {
796
+ const typeDef = cellTypes[col.type || 'text'];
797
+ if (typeDef?.render) {
798
+ typeDef.render(value, data, cell, col.meta);
799
+ } else {
800
+ cell.textContent = value != null ? value : '';
801
+ }
802
+ }
803
+
804
+ // Alignment from cell type
805
+ const typeDef = cellTypes[col.type || 'text'];
806
+ if (typeDef?.align) {
807
+ cell.dataset.align = typeDef.align;
808
+ }
809
+
810
+ // Pinned
811
+ if (col.pinned) {
812
+ cell.setAttribute('data-pinned', col.pinned);
813
+ }
814
+
815
+ cells.push(cell);
816
+ }
817
+
818
+ // Reconcile cells in row
819
+ while (row.children.length > cells.length) row.lastChild.remove();
820
+ for (let i = 0; i < cells.length; i++) {
821
+ if (row.children[i]) row.replaceChild(cells[i], row.children[i]);
822
+ else row.appendChild(cells[i]);
823
+ }
824
+ }
825
+
826
+ // ── Overlays ───────────────────────────────────────────────────────────────
827
+
828
+ #renderOverlays(body) {
829
+ let emptyEl = this.querySelector(':scope > [data-empty]');
830
+ let loadingEl = this.querySelector(':scope > [data-loading]');
831
+
832
+ if (this.loading) {
833
+ // Show loading overlay
834
+ if (!loadingEl) {
835
+ loadingEl = document.createElement('div');
836
+ loadingEl.setAttribute('data-loading', '');
837
+ const prog = document.createElement('progress-ui');
838
+ prog.setAttribute('indeterminate', '');
839
+ loadingEl.appendChild(prog);
840
+ body.after(loadingEl);
841
+ }
842
+ if (emptyEl) emptyEl.remove();
843
+ } else if (this.#data.length === 0) {
844
+ // Show empty state
845
+ if (!emptyEl) {
846
+ emptyEl = document.createElement('div');
847
+ emptyEl.setAttribute('data-empty', '');
848
+ const icon = document.createElement('icon-ui');
849
+ icon.setAttribute('name', 'table');
850
+ const span = document.createElement('span');
851
+ span.textContent = 'No data';
852
+ emptyEl.appendChild(icon);
853
+ emptyEl.appendChild(span);
854
+ body.after(emptyEl);
855
+ }
856
+ if (loadingEl) loadingEl.remove();
857
+ } else {
858
+ // Remove both overlays
859
+ if (emptyEl) emptyEl.remove();
860
+ if (loadingEl) loadingEl.remove();
861
+ }
862
+ }
863
+
864
+ // ── Aggregation ────────────────────────────────────────────────────────────
865
+
866
+ #renderAggregation(body, processedIndices, visCols) {
867
+ const hasAgg = visCols.some(c => c.aggregate);
868
+ let aggRow = this.querySelector(':scope > [data-agg-row]');
869
+
870
+ if (!hasAgg || this.#data.length === 0) {
871
+ if (aggRow) aggRow.remove();
872
+ return;
873
+ }
874
+
875
+ if (!aggRow) {
876
+ aggRow = document.createElement('div');
877
+ aggRow.setAttribute('role', 'row');
878
+ aggRow.setAttribute('data-agg-row', '');
879
+ body.after(aggRow);
880
+ }
881
+
882
+ const cells = [];
883
+
884
+ // Expand column spacer
885
+ if (this.expandable) {
886
+ const spacer = document.createElement('div');
887
+ spacer.setAttribute('role', 'gridcell');
888
+ cells.push(spacer);
889
+ }
890
+
891
+ // Checkbox column spacer
892
+ if (this.selectable) {
893
+ const spacer = document.createElement('div');
894
+ spacer.setAttribute('role', 'gridcell');
895
+ cells.push(spacer);
896
+ }
897
+
898
+ for (const col of visCols) {
899
+ const cell = document.createElement('div');
900
+ cell.setAttribute('role', 'gridcell');
901
+
902
+ if (col.aggregate) {
903
+ const values = processedIndices.map(i => {
904
+ const val = getCellValue(this.#data[i], col);
905
+ return val != null ? Number(val) : NaN;
906
+ }).filter(n => !isNaN(n));
907
+
908
+ let result;
909
+ switch (col.aggregate) {
910
+ case 'sum': result = values.reduce((a, b) => a + b, 0); break;
911
+ case 'avg': result = values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0; break;
912
+ case 'min': result = values.length ? Math.min(...values) : 0; break;
913
+ case 'max': result = values.length ? Math.max(...values) : 0; break;
914
+ case 'count': result = processedIndices.length; break;
915
+ default: result = '';
916
+ }
917
+
918
+ // Format using cell type if available
919
+ const typeDef = cellTypes[col.type || 'text'];
920
+ if (typeof result === 'number' && typeDef?.render) {
921
+ typeDef.render(result, {}, cell, col.meta);
922
+ } else {
923
+ cell.textContent = typeof result === 'number' ? result.toLocaleString() : result;
924
+ }
925
+ }
926
+
927
+ cells.push(cell);
928
+ }
929
+
930
+ // Reconcile cells
931
+ while (aggRow.children.length > cells.length) aggRow.lastChild.remove();
932
+ for (let i = 0; i < cells.length; i++) {
933
+ if (aggRow.children[i]) aggRow.replaceChild(cells[i], aggRow.children[i]);
934
+ else aggRow.appendChild(cells[i]);
935
+ }
936
+ }
937
+
938
+ // ── Pagination ─────────────────────────────────────────────────────────────
939
+
940
+ #renderPagination(footer, filteredTotal) {
941
+ let pag = footer.querySelector('pagination-ui');
942
+ if (!pag) {
943
+ footer.innerHTML = '';
944
+ pag = document.createElement('pagination-ui');
945
+ pag.addEventListener('page-change', (e) => {
946
+ this.#page = e.detail.page - 1; // pagination-ui is 1-based
947
+ this.render();
948
+ this.dispatchEvent(new CustomEvent('page', {
949
+ bubbles: true,
950
+ detail: { page: this.#page },
951
+ }));
952
+ });
953
+ footer.appendChild(pag);
954
+ }
955
+ const pageCount = this.paginate > 0
956
+ ? Math.max(1, Math.ceil(filteredTotal / this.paginate))
957
+ : 1;
958
+ pag.setAttribute('page', String(this.#page + 1));
959
+ pag.setAttribute('total', String(pageCount));
960
+ }
961
+
962
+ // ── Filter UI ──────────────────────────────────────────────────────────────
963
+
964
+ #buildFilterDropdown(col) {
965
+ const dropdown = document.createElement('div');
966
+ dropdown.setAttribute('data-filter-dropdown', '');
967
+
968
+ const currentFilter = this.#filters.get(col.key);
969
+
970
+ if (col.filter === 'select') {
971
+ // Build checkbox list from unique values
972
+ const uniqueVals = [...new Set(this.#data.map(row => {
973
+ const val = getCellValue(row, col);
974
+ return val != null ? String(val) : '';
975
+ }))].filter(Boolean).sort();
976
+
977
+ const selectedSet = new Set(
978
+ currentFilter?.op === 'select' ? String(currentFilter.value).split(',') : []
979
+ );
980
+
981
+ for (const val of uniqueVals) {
982
+ const label = document.createElement('label');
983
+ label.setAttribute('data-filter-option', '');
984
+ const check = document.createElement('input');
985
+ check.type = 'checkbox';
986
+ check.value = val;
987
+ check.checked = selectedSet.has(val.toLowerCase());
988
+ check.addEventListener('change', () => {
989
+ const checked = [...dropdown.querySelectorAll('input[type=checkbox]:checked')].map(c => c.value);
990
+ if (checked.length) {
991
+ this.setFilter(col.key, checked.join(','), 'select');
992
+ } else {
993
+ this.setFilter(col.key, null);
994
+ }
995
+ });
996
+ label.appendChild(check);
997
+ label.appendChild(document.createTextNode(` ${val}`));
998
+ dropdown.appendChild(label);
999
+ }
1000
+ } else if (col.filter === 'number') {
1001
+ const input = document.createElement('input');
1002
+ input.type = 'number';
1003
+ input.placeholder = 'Filter...';
1004
+ input.value = currentFilter?.value ?? '';
1005
+ input.setAttribute('data-filter-input', '');
1006
+
1007
+ const opSelect = document.createElement('select');
1008
+ opSelect.setAttribute('data-filter-op', '');
1009
+ for (const [val, label] of [['gte', '≥'], ['lte', '≤'], ['gt', '>'], ['lt', '<'], ['equals', '=']]) {
1010
+ const opt = document.createElement('option');
1011
+ opt.value = val;
1012
+ opt.textContent = label;
1013
+ if (currentFilter?.op === val) opt.selected = true;
1014
+ opSelect.appendChild(opt);
1015
+ }
1016
+
1017
+ const apply = () => {
1018
+ if (input.value) this.setFilter(col.key, input.value, opSelect.value);
1019
+ else this.setFilter(col.key, null);
1020
+ };
1021
+ input.addEventListener('input', apply);
1022
+ opSelect.addEventListener('change', apply);
1023
+
1024
+ dropdown.appendChild(opSelect);
1025
+ dropdown.appendChild(input);
1026
+ } else {
1027
+ // Default: text filter
1028
+ const input = document.createElement('input');
1029
+ input.type = 'text';
1030
+ input.placeholder = `Filter ${col.label || col.key}...`;
1031
+ input.value = currentFilter?.value ?? '';
1032
+ input.setAttribute('data-filter-input', '');
1033
+ input.addEventListener('input', () => {
1034
+ if (input.value) this.setFilter(col.key, input.value, 'contains');
1035
+ else this.setFilter(col.key, null);
1036
+ });
1037
+ dropdown.appendChild(input);
1038
+ }
1039
+
1040
+ // Clear button
1041
+ if (currentFilter) {
1042
+ const clearBtn = document.createElement('button');
1043
+ clearBtn.textContent = 'Clear';
1044
+ clearBtn.setAttribute('data-filter-clear', '');
1045
+ clearBtn.addEventListener('click', () => {
1046
+ this.setFilter(col.key, null);
1047
+ this.#openFilter = null;
1048
+ this.#requestRender();
1049
+ });
1050
+ dropdown.appendChild(clearBtn);
1051
+ }
1052
+
1053
+ // Auto-focus input
1054
+ if (this.#filterFocusRaf != null) cancelAnimationFrame(this.#filterFocusRaf);
1055
+ this.#filterFocusRaf = requestAnimationFrame(() => {
1056
+ this.#filterFocusRaf = null;
1057
+ if (!this.isConnected) return;
1058
+ const input = dropdown.querySelector('input');
1059
+ if (input) input.focus();
1060
+ });
1061
+
1062
+ return dropdown;
1063
+ }
1064
+
1065
+ #renderFilterChips() {
1066
+ let bar = this.querySelector(':scope > [data-filter-bar]');
1067
+
1068
+ if (this.#filters.size === 0) {
1069
+ if (bar) bar.remove();
1070
+ return;
1071
+ }
1072
+
1073
+ if (!bar) {
1074
+ bar = document.createElement('div');
1075
+ bar.setAttribute('data-filter-bar', '');
1076
+ bar.setAttribute('role', 'status');
1077
+ // Insert after header
1078
+ const header = this.querySelector(':scope > [data-header]');
1079
+ if (header) header.after(bar);
1080
+ else this.prepend(bar);
1081
+ }
1082
+
1083
+ bar.innerHTML = '';
1084
+ for (const [key, { op, value }] of this.#filters) {
1085
+ const col = this.#columns.find(c => c.key === key);
1086
+ const label = col?.label || key;
1087
+ const chip = document.createElement('badge-ui');
1088
+ const displayVal = op === 'select' ? `${value.split(',').length} selected` : value;
1089
+ chip.setAttribute('text', `${label}: ${displayVal}`);
1090
+ chip.setAttribute('size', 'xs');
1091
+ chip.setAttribute('data-filter-chip', key);
1092
+ // Dismiss button
1093
+ const dismiss = document.createElement('button');
1094
+ dismiss.textContent = '×';
1095
+ dismiss.setAttribute('data-chip-dismiss', '');
1096
+ dismiss.addEventListener('click', (e) => {
1097
+ e.stopPropagation();
1098
+ this.setFilter(key, null);
1099
+ });
1100
+ chip.appendChild(dismiss);
1101
+ bar.appendChild(chip);
1102
+ }
1103
+
1104
+ // Clear all button
1105
+ const clearAll = document.createElement('button');
1106
+ clearAll.textContent = 'Clear all';
1107
+ clearAll.setAttribute('data-filter-clear-all', '');
1108
+ clearAll.addEventListener('click', () => this.clearFilters());
1109
+ bar.appendChild(clearAll);
1110
+ }
1111
+
1112
+ // ── Event Handling: Click ──────────────────────────────────────────────────
1113
+
1114
+ #onClick = (e) => {
1115
+ const target = e.target;
1116
+
1117
+ // ── Filter button click ──
1118
+ const filterBtn = target.closest('[data-filter-btn]');
1119
+ if (filterBtn && this.contains(filterBtn)) {
1120
+ const key = filterBtn.dataset.filterKey;
1121
+ this.#openFilter = this.#openFilter === key ? null : key;
1122
+ this.#requestRender();
1123
+ return;
1124
+ }
1125
+
1126
+ // ── Click outside filter dropdown closes it ──
1127
+ if (this.#openFilter && !target.closest('[data-filter-dropdown]') && !target.closest('[data-filter-btn]')) {
1128
+ this.#openFilter = null;
1129
+ this.#requestRender();
1130
+ }
1131
+
1132
+ // ── Expand toggle click ──
1133
+ const expandToggle = target.closest('[data-expand-toggle]');
1134
+ if (expandToggle && this.expandable && this.contains(expandToggle)) {
1135
+ const row = expandToggle.closest('[role="row"]');
1136
+ if (row?.dataset?.index != null) {
1137
+ this.toggleExpand(Number(row.dataset.index));
1138
+ }
1139
+ return;
1140
+ }
1141
+
1142
+ // ── Resize handle mousedown is handled separately ──
1143
+ const resizeHandle = target.closest('[data-resize-handle]');
1144
+ if (resizeHandle && this.contains(resizeHandle)) {
1145
+ this.#startResize(e, resizeHandle);
1146
+ return;
1147
+ }
1148
+
1149
+ // ── Header sort click ──
1150
+ const headerCell = target.closest('[data-sort-key]');
1151
+ if (headerCell && this.sortable && this.contains(headerCell)) {
1152
+ const key = headerCell.dataset.sortKey;
1153
+ const existing = this.#sortState.findIndex(s => s.key === key);
1154
+ const col = this.#columns.find(c => c.key === key);
1155
+ const defaultDir = col?.sortDescFirst ? 'desc' : 'asc';
1156
+ const altDir = defaultDir === 'asc' ? 'desc' : 'asc';
1157
+
1158
+ if (e.shiftKey) {
1159
+ // Multi-sort: add or toggle
1160
+ if (existing >= 0) {
1161
+ const cur = this.#sortState[existing];
1162
+ if (cur.dir === altDir) {
1163
+ // Remove from sort state (third click)
1164
+ this.#sortState.splice(existing, 1);
1165
+ } else {
1166
+ cur.dir = cur.dir === defaultDir ? altDir : defaultDir;
1167
+ }
1168
+ } else {
1169
+ this.#sortState.push({ key, dir: defaultDir });
1170
+ }
1171
+ } else {
1172
+ // Single sort: replace
1173
+ if (existing >= 0 && this.#sortState.length === 1) {
1174
+ const cur = this.#sortState[0];
1175
+ if (cur.dir === altDir) {
1176
+ this.#sortState = [];
1177
+ } else {
1178
+ cur.dir = cur.dir === defaultDir ? altDir : defaultDir;
1179
+ }
1180
+ } else {
1181
+ this.#sortState = [{ key, dir: defaultDir }];
1182
+ }
1183
+ }
1184
+
1185
+ this.render();
1186
+ this.dispatchEvent(new CustomEvent('sort', {
1187
+ bubbles: true,
1188
+ detail: {
1189
+ key,
1190
+ dir: this.#sortState.find(s => s.key === key)?.dir || null,
1191
+ sortState: this.sortState,
1192
+ },
1193
+ }));
1194
+ return;
1195
+ }
1196
+
1197
+ // ── Header checkbox (select all) ──
1198
+ const headerCheck = target.closest('[data-header] check-ui');
1199
+ if (headerCheck && this.selectable) {
1200
+ const checked = headerCheck.hasAttribute('checked');
1201
+ if (checked) {
1202
+ for (let i = 0; i < this.#data.length; i++) this.#selected.add(i);
1203
+ } else {
1204
+ this.#selected.clear();
1205
+ }
1206
+ this.render();
1207
+ this.dispatchEvent(new CustomEvent('select', {
1208
+ bubbles: true, detail: { selected: this.selected },
1209
+ }));
1210
+ return;
1211
+ }
1212
+
1213
+ // ── Row checkbox ──
1214
+ const rowCheck = target.closest('[role="row"]:not([data-header]) check-ui');
1215
+ if (rowCheck && this.selectable) {
1216
+ const row = rowCheck.closest('[role="row"]');
1217
+ const idx = row ? +row.dataset.index : -1;
1218
+ if (idx >= 0) {
1219
+ const checked = rowCheck.hasAttribute('checked');
1220
+
1221
+ if (e.shiftKey && this.#lastSelectedIndex >= 0) {
1222
+ // Range select
1223
+ const from = Math.min(this.#lastSelectedIndex, idx);
1224
+ const to = Math.max(this.#lastSelectedIndex, idx);
1225
+ for (let i = from; i <= to; i++) {
1226
+ if (checked) this.#selected.add(i);
1227
+ else this.#selected.delete(i);
1228
+ }
1229
+ } else {
1230
+ if (checked) this.#selected.add(idx);
1231
+ else this.#selected.delete(idx);
1232
+ }
1233
+
1234
+ this.#lastSelectedIndex = idx;
1235
+ this.render();
1236
+ this.dispatchEvent(new CustomEvent('select', {
1237
+ bubbles: true, detail: { selected: this.selected },
1238
+ }));
1239
+ }
1240
+ return;
1241
+ }
1242
+
1243
+ // ── Cell click ──
1244
+ const gridcell = target.closest('[role="gridcell"][data-key]');
1245
+ if (gridcell && this.contains(gridcell)) {
1246
+ const row = gridcell.closest('[role="row"]');
1247
+ const dataIndex = row ? +row.dataset.index : -1;
1248
+ if (dataIndex >= 0) {
1249
+ const key = gridcell.dataset.key;
1250
+ const rowData = this.#data[dataIndex];
1251
+ const col = this.#columns.find(c => c.key === key);
1252
+ const value = col ? getCellValue(rowData, col) : undefined;
1253
+ this.dispatchEvent(new CustomEvent('cell-click', {
1254
+ bubbles: true,
1255
+ detail: { key, row: rowData, value, dataIndex },
1256
+ }));
1257
+ }
1258
+ }
1259
+ };
1260
+
1261
+ // ── Column Resize ──────────────────────────────────────────────────────────
1262
+
1263
+ #resizeState = null;
1264
+
1265
+ #startResize(e, handle) {
1266
+ e.preventDefault();
1267
+ e.stopPropagation();
1268
+
1269
+ const key = handle.dataset.resizeKey;
1270
+ const col = this.#columns.find(c => c.key === key);
1271
+ if (!col) return;
1272
+
1273
+ // Get current column width
1274
+ const headerCell = handle.closest('[role="columnheader"]');
1275
+ const startWidth = headerCell ? headerCell.getBoundingClientRect().width : 100;
1276
+ const startX = e.clientX;
1277
+
1278
+ this.setAttribute('data-resizing', '');
1279
+
1280
+ this.#resizeState = { key, col, startX, startWidth };
1281
+
1282
+ document.addEventListener('mousemove', this.#onResizeMove);
1283
+ document.addEventListener('mouseup', this.#onResizeEnd);
1284
+ }
1285
+
1286
+ #onResizeMove = (e) => {
1287
+ if (!this.#resizeState) return;
1288
+ const { key, col, startX, startWidth } = this.#resizeState;
1289
+ const delta = e.clientX - startX;
1290
+ let newWidth = startWidth + delta;
1291
+
1292
+ // Clamp to min/max
1293
+ const min = col.minWidth || 48;
1294
+ const max = col.maxWidth || Infinity;
1295
+ newWidth = Math.max(min, Math.min(max, newWidth));
1296
+
1297
+ this.#columnWidths.set(key, Math.round(newWidth));
1298
+ this.style.gridTemplateColumns = this.#buildGridTemplate();
1299
+ };
1300
+
1301
+ #onResizeEnd = (e) => {
1302
+ if (!this.#resizeState) return;
1303
+ const { key } = this.#resizeState;
1304
+
1305
+ document.removeEventListener('mousemove', this.#onResizeMove);
1306
+ document.removeEventListener('mouseup', this.#onResizeEnd);
1307
+
1308
+ this.removeAttribute('data-resizing');
1309
+
1310
+ const width = this.#columnWidths.get(key);
1311
+ this.#resizeState = null;
1312
+
1313
+ this.dispatchEvent(new CustomEvent('resize', {
1314
+ bubbles: true,
1315
+ detail: { key, width },
1316
+ }));
1317
+
1318
+ this.render();
1319
+ };
1320
+
1321
+ #cleanupResize() {
1322
+ document.removeEventListener('mousemove', this.#onResizeMove);
1323
+ document.removeEventListener('mouseup', this.#onResizeEnd);
1324
+ this.#resizeState = null;
1325
+ }
1326
+
1327
+ // ── Keyboard Navigation ────────────────────────────────────────────────────
1328
+
1329
+ #onKeydown = (e) => {
1330
+ const visCols = this.#visibleColumns;
1331
+ const totalCols = visCols.length + (this.selectable ? 1 : 0);
1332
+ const body = this.querySelector(':scope > [data-body]');
1333
+ const totalRows = body ? body.children.length : 0;
1334
+
1335
+ // Include header as row -1 conceptually, body rows 0..n-1
1336
+ if (!this.#focusedCell) {
1337
+ this.#focusedCell = { row: 0, col: 0 };
1338
+ }
1339
+
1340
+ let { row, col } = this.#focusedCell;
1341
+ let handled = true;
1342
+
1343
+ switch (e.key) {
1344
+ case 'ArrowDown':
1345
+ row = Math.min(row + 1, totalRows - 1);
1346
+ break;
1347
+ case 'ArrowUp':
1348
+ row = Math.max(row - 1, -1); // -1 = header
1349
+ break;
1350
+ case 'ArrowRight':
1351
+ col = Math.min(col + 1, totalCols - 1);
1352
+ break;
1353
+ case 'ArrowLeft':
1354
+ col = Math.max(col - 1, 0);
1355
+ break;
1356
+ case 'Tab':
1357
+ if (e.shiftKey) {
1358
+ col--;
1359
+ if (col < 0) { col = totalCols - 1; row--; }
1360
+ } else {
1361
+ col++;
1362
+ if (col >= totalCols) { col = 0; row++; }
1363
+ }
1364
+ if (row < -1 || row >= totalRows) { handled = false; break; }
1365
+ break;
1366
+ case 'Enter':
1367
+ // If on header row, trigger sort
1368
+ if (row === -1) {
1369
+ const header = this.querySelector(':scope > [data-header]');
1370
+ const cell = header?.children[col];
1371
+ const sortKey = cell?.dataset.sortKey;
1372
+ if (sortKey) cell.click();
1373
+ }
1374
+ break;
1375
+ default:
1376
+ handled = false;
1377
+ }
1378
+
1379
+ if (handled) {
1380
+ e.preventDefault();
1381
+ this.#focusedCell = { row, col };
1382
+ this.#updateFocus();
1383
+ }
1384
+ };
1385
+
1386
+ #updateFocus() {
1387
+ // Remove old focus
1388
+ const oldFocused = this.querySelector('[data-focused]');
1389
+ if (oldFocused) oldFocused.removeAttribute('data-focused');
1390
+
1391
+ if (!this.#focusedCell) return;
1392
+ const { row, col } = this.#focusedCell;
1393
+
1394
+ let cell;
1395
+ if (row === -1) {
1396
+ // Header
1397
+ const header = this.querySelector(':scope > [data-header]');
1398
+ cell = header?.children[col];
1399
+ } else {
1400
+ // Body
1401
+ const body = this.querySelector(':scope > [data-body]');
1402
+ const rowEl = body?.children[row];
1403
+ cell = rowEl?.children[col];
1404
+ }
1405
+
1406
+ if (cell) {
1407
+ cell.setAttribute('data-focused', '');
1408
+ cell.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
1409
+ }
1410
+ }
1411
+
1412
+ // ── CSV Export ─────────────────────────────────────────────────────────────
1413
+
1414
+ exportCSV(filename = 'export.csv') {
1415
+ const visCols = this.#visibleColumns;
1416
+ const processed = this.#getProcessedIndices();
1417
+
1418
+ // Header row
1419
+ const headerRow = visCols.map(c => csvEscape(c.label || c.key));
1420
+
1421
+ // Data rows
1422
+ const dataRows = processed.map(idx => {
1423
+ const row = this.#data[idx];
1424
+ return visCols.map(col => {
1425
+ const value = getCellValue(row, col);
1426
+
1427
+ // Use column format, then cell type format, then String
1428
+ if (typeof col.format === 'function') {
1429
+ return csvEscape(col.format(value, row));
1430
+ }
1431
+ const typeDef = cellTypes[col.type || 'text'];
1432
+ if (typeDef?.format) {
1433
+ return csvEscape(typeDef.format(value, row, col.meta));
1434
+ }
1435
+ return csvEscape(value);
1436
+ });
1437
+ });
1438
+
1439
+ const csv = [headerRow.join(','), ...dataRows.map(r => r.join(','))].join('\n');
1440
+
1441
+ // Trigger download
1442
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
1443
+ const url = URL.createObjectURL(blob);
1444
+ const link = document.createElement('a');
1445
+ link.href = url;
1446
+ link.download = filename;
1447
+ link.style.display = 'none';
1448
+ document.body.appendChild(link);
1449
+ link.click();
1450
+ document.body.removeChild(link);
1451
+ URL.revokeObjectURL(url);
1452
+ }
1453
+ }