@cl-light-ds/ui 0.2.0 → 0.3.0

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 (283) hide show
  1. package/README.md +45 -45
  2. package/package.json +1 -1
  3. package/src/components/accordion/Accordion.astro +248 -248
  4. package/src/components/accordion/AccordionContent.astro +26 -26
  5. package/src/components/accordion/AccordionItem.astro +36 -36
  6. package/src/components/accordion/AccordionTrigger.astro +118 -118
  7. package/src/components/accordion/CHANGELOG.md +41 -41
  8. package/src/components/accordion/index.ts +4 -4
  9. package/src/components/alert/Alert.astro +167 -167
  10. package/src/components/alert/AlertDescription.astro +24 -24
  11. package/src/components/alert/AlertTitle.astro +24 -24
  12. package/src/components/alert/CHANGELOG.md +5 -5
  13. package/src/components/alert/index.ts +4 -4
  14. package/src/components/avatar/Avatar.astro +197 -197
  15. package/src/components/avatar/AvatarBadge.astro +44 -44
  16. package/src/components/avatar/AvatarFallback.astro +26 -26
  17. package/src/components/avatar/AvatarGroup.astro +26 -26
  18. package/src/components/avatar/AvatarGroupCount.astro +30 -30
  19. package/src/components/avatar/AvatarImage.astro +46 -46
  20. package/src/components/avatar/CHANGELOG.md +44 -44
  21. package/src/components/avatar/index.ts +6 -6
  22. package/src/components/badge/Badge.astro +167 -167
  23. package/src/components/badge/CHANGELOG.md +49 -49
  24. package/src/components/badge/index.ts +2 -2
  25. package/src/components/breadcrumb/Breadcrumb.astro +76 -76
  26. package/src/components/breadcrumb/BreadcrumbItem.astro +32 -32
  27. package/src/components/breadcrumb/BreadcrumbList.astro +26 -26
  28. package/src/components/breadcrumb/BreadcrumbSeparator.astro +26 -26
  29. package/src/components/breadcrumb/CHANGELOG.md +24 -24
  30. package/src/components/breadcrumb/index.ts +5 -5
  31. package/src/components/button/Button.astro +233 -233
  32. package/src/components/button/CHANGELOG.md +41 -41
  33. package/src/components/button/index.ts +2 -2
  34. package/src/components/button-group/ButtonGroup.astro +130 -130
  35. package/src/components/button-group/ButtonGroupSeparator.astro +30 -30
  36. package/src/components/button-group/ButtonGroupText.astro +21 -21
  37. package/src/components/button-group/CHANGELOG.md +34 -34
  38. package/src/components/button-group/index.ts +3 -3
  39. package/src/components/card/CHANGELOG.md +162 -119
  40. package/src/components/card/Card.astro +197 -182
  41. package/src/components/card/CardAction.astro +26 -26
  42. package/src/components/card/CardContent.astro +28 -28
  43. package/src/components/card/CardDescription.astro +28 -28
  44. package/src/components/card/CardFooter.astro +24 -24
  45. package/src/components/card/CardHeader.astro +30 -30
  46. package/src/components/card/CardMedia.astro +27 -27
  47. package/src/components/card/CardMediaActions.astro +21 -0
  48. package/src/components/card/CardMediaSelect.astro +21 -0
  49. package/src/components/card/CardOverlay.astro +32 -32
  50. package/src/components/card/CardTitle.astro +21 -21
  51. package/src/components/card/index.ts +12 -10
  52. package/src/components/carousel/CHANGELOG.md +5 -5
  53. package/src/components/carousel/Carousel.astro +106 -106
  54. package/src/components/carousel/CarouselSlide.astro +22 -22
  55. package/src/components/carousel/index.ts +3 -3
  56. package/src/components/checkbox/CHANGELOG.md +58 -58
  57. package/src/components/checkbox/Checkbox.astro +159 -159
  58. package/src/components/checkbox/index.ts +1 -1
  59. package/src/components/collapsible/CHANGELOG.md +5 -5
  60. package/src/components/collapsible/Collapsible.astro +202 -202
  61. package/src/components/collapsible/CollapsibleContent.astro +36 -36
  62. package/src/components/collapsible/CollapsibleTrigger.astro +34 -34
  63. package/src/components/collapsible/index.ts +4 -4
  64. package/src/components/combobox/CHANGELOG.md +27 -27
  65. package/src/components/combobox/Combobox.astro +365 -365
  66. package/src/components/combobox/ComboboxContent.astro +37 -37
  67. package/src/components/combobox/ComboboxEmpty.astro +18 -18
  68. package/src/components/combobox/ComboboxGroup.astro +19 -19
  69. package/src/components/combobox/ComboboxInput.astro +74 -74
  70. package/src/components/combobox/ComboboxItem.astro +39 -39
  71. package/src/components/combobox/ComboboxLabel.astro +18 -18
  72. package/src/components/combobox/ComboboxList.astro +21 -21
  73. package/src/components/combobox/ComboboxSeparator.astro +17 -17
  74. package/src/components/combobox/ComboboxTrigger.astro +30 -30
  75. package/src/components/combobox/ComboboxValue.astro +22 -22
  76. package/src/components/combobox/index.ts +11 -11
  77. package/src/components/date/CHANGELOG.md +18 -18
  78. package/src/components/date/Date.astro +137 -137
  79. package/src/components/date/index.ts +2 -2
  80. package/src/components/dialog/CHANGELOG.md +39 -39
  81. package/src/components/dialog/Dialog.astro +196 -196
  82. package/src/components/dialog/DialogClose.astro +23 -23
  83. package/src/components/dialog/DialogContent.astro +42 -42
  84. package/src/components/dialog/DialogDescription.astro +18 -18
  85. package/src/components/dialog/DialogFooter.astro +18 -18
  86. package/src/components/dialog/DialogHeader.astro +18 -18
  87. package/src/components/dialog/DialogOverlay.astro +19 -19
  88. package/src/components/dialog/DialogTitle.astro +18 -18
  89. package/src/components/dialog/DialogTrigger.astro +37 -37
  90. package/src/components/dialog/index.ts +9 -9
  91. package/src/components/dropdown-menu/CHANGELOG.md +194 -194
  92. package/src/components/dropdown-menu/DropdownMenu.astro +226 -226
  93. package/src/components/dropdown-menu/DropdownMenuCheckboxItem.astro +114 -114
  94. package/src/components/dropdown-menu/DropdownMenuContent.astro +42 -42
  95. package/src/components/dropdown-menu/DropdownMenuGroup.astro +19 -19
  96. package/src/components/dropdown-menu/DropdownMenuItem.astro +220 -220
  97. package/src/components/dropdown-menu/DropdownMenuItemContent.astro +79 -79
  98. package/src/components/dropdown-menu/DropdownMenuLabel.astro +20 -20
  99. package/src/components/dropdown-menu/DropdownMenuPortal.astro +13 -13
  100. package/src/components/dropdown-menu/DropdownMenuRadioGroup.astro +13 -13
  101. package/src/components/dropdown-menu/DropdownMenuRadioItem.astro +101 -101
  102. package/src/components/dropdown-menu/DropdownMenuSeparator.astro +18 -18
  103. package/src/components/dropdown-menu/DropdownMenuShortcut.astro +18 -18
  104. package/src/components/dropdown-menu/DropdownMenuTrigger.astro +49 -49
  105. package/src/components/dropdown-menu/dropdown-menu-item-variants.ts +24 -24
  106. package/src/components/dropdown-menu/index.ts +12 -12
  107. package/src/components/field/CHANGELOG.md +33 -33
  108. package/src/components/field/Field.astro +664 -664
  109. package/src/components/field/FieldContent.astro +20 -20
  110. package/src/components/field/FieldDescription.astro +22 -22
  111. package/src/components/field/FieldError.astro +51 -51
  112. package/src/components/field/FieldGroup.astro +20 -20
  113. package/src/components/field/FieldLabel.astro +22 -22
  114. package/src/components/field/FieldLegend.astro +23 -23
  115. package/src/components/field/FieldSeparator.astro +30 -30
  116. package/src/components/field/FieldSet.astro +20 -20
  117. package/src/components/field/FieldTitle.astro +20 -20
  118. package/src/components/field/index.ts +10 -10
  119. package/src/components/hover-card/CHANGELOG.md +20 -20
  120. package/src/components/hover-card/HoverCard.astro +235 -235
  121. package/src/components/hover-card/HoverCardContent.astro +54 -54
  122. package/src/components/hover-card/HoverCardTrigger.astro +38 -38
  123. package/src/components/hover-card/index.ts +3 -3
  124. package/src/components/input/CHANGELOG.md +29 -29
  125. package/src/components/input/Input.astro +130 -130
  126. package/src/components/input/index.ts +2 -2
  127. package/src/components/input-group/CHANGELOG.md +49 -49
  128. package/src/components/input-group/InputGroup.astro +135 -135
  129. package/src/components/input-group/InputGroupAddon.astro +49 -49
  130. package/src/components/input-group/InputGroupButton.astro +58 -58
  131. package/src/components/input-group/InputGroupInput.astro +18 -18
  132. package/src/components/input-group/InputGroupText.astro +21 -21
  133. package/src/components/input-group/InputGroupTextarea.astro +17 -17
  134. package/src/components/input-group/controlClasses.ts +2 -2
  135. package/src/components/input-group/index.ts +6 -6
  136. package/src/components/item/CHANGELOG.md +27 -27
  137. package/src/components/item/Item.astro +122 -122
  138. package/src/components/item/ItemActions.astro +21 -21
  139. package/src/components/item/ItemContent.astro +25 -25
  140. package/src/components/item/ItemDescription.astro +25 -25
  141. package/src/components/item/ItemFooter.astro +24 -24
  142. package/src/components/item/ItemGroup.astro +24 -24
  143. package/src/components/item/ItemHeader.astro +24 -24
  144. package/src/components/item/ItemMedia.astro +47 -47
  145. package/src/components/item/ItemSeparator.astro +21 -21
  146. package/src/components/item/ItemTitle.astro +25 -25
  147. package/src/components/item/index.ts +10 -10
  148. package/src/components/kbd/CHANGELOG.md +5 -5
  149. package/src/components/kbd/Kbd.astro +145 -145
  150. package/src/components/kbd/KbdGroup.astro +55 -55
  151. package/src/components/kbd/index.ts +2 -2
  152. package/src/components/label/CHANGELOG.md +28 -28
  153. package/src/components/label/Label.astro +97 -97
  154. package/src/components/label/index.ts +2 -2
  155. package/src/components/link-group/CHANGELOG.md +75 -75
  156. package/src/components/link-group/LinkGroup.astro +67 -67
  157. package/src/components/link-group/LinkGroupItem.astro +32 -32
  158. package/src/components/link-group/LinkGroupList.astro +25 -25
  159. package/src/components/link-group/LinkGroupTitle.astro +22 -22
  160. package/src/components/link-group/index.ts +5 -5
  161. package/src/components/link-group/link-group-list-variants.ts +15 -15
  162. package/src/components/marquee/CHANGELOG.md +5 -5
  163. package/src/components/marquee/Marquee.astro +119 -119
  164. package/src/components/marquee/index.ts +2 -2
  165. package/src/components/native-select/CHANGELOG.md +5 -5
  166. package/src/components/native-select/NativeSelect.astro +125 -125
  167. package/src/components/native-select/NativeSelectOptGroup.astro +14 -14
  168. package/src/components/native-select/NativeSelectOption.astro +9 -9
  169. package/src/components/native-select/index.ts +3 -3
  170. package/src/components/navigation-menu/CHANGELOG.md +63 -63
  171. package/src/components/navigation-menu/NavigationMenu.astro +414 -414
  172. package/src/components/navigation-menu/NavigationMenuContent.astro +37 -37
  173. package/src/components/navigation-menu/NavigationMenuIndicator.astro +24 -24
  174. package/src/components/navigation-menu/NavigationMenuItem.astro +22 -22
  175. package/src/components/navigation-menu/NavigationMenuLink.astro +18 -18
  176. package/src/components/navigation-menu/NavigationMenuList.astro +21 -21
  177. package/src/components/navigation-menu/NavigationMenuPositioner.astro +44 -44
  178. package/src/components/navigation-menu/NavigationMenuTrigger.astro +30 -30
  179. package/src/components/navigation-menu/NavigationMenuViewport.astro +19 -19
  180. package/src/components/navigation-menu/index.ts +10 -10
  181. package/src/components/organisms/lightning-sidebar-nav/CHANGELOG.md +50 -0
  182. package/src/components/organisms/lightning-sidebar-nav/LightningSidebarNav.astro +176 -0
  183. package/src/components/organisms/lightning-sidebar-nav/SidebarNav.astro +50 -0
  184. package/src/components/organisms/lightning-sidebar-nav/SidebarNavGroup.astro +74 -0
  185. package/src/components/organisms/lightning-sidebar-nav/SidebarNavItem.astro +94 -0
  186. package/src/components/organisms/lightning-sidebar-nav/SidebarNavProBadge.astro +26 -0
  187. package/src/components/organisms/lightning-sidebar-nav/SidebarNavSection.astro +71 -0
  188. package/src/components/organisms/lightning-sidebar-nav/SidebarNavSkeleton.astro +67 -0
  189. package/src/components/organisms/lightning-sidebar-nav/SidebarNavSubItem.astro +69 -0
  190. package/src/components/organisms/lightning-sidebar-nav/SidebarNavUsage.astro +78 -0
  191. package/src/components/organisms/lightning-sidebar-nav/SidebarNavWorkspaceAvatar.astro +46 -0
  192. package/src/components/organisms/lightning-sidebar-nav/SidebarNavWorkspaceSwitcher.astro +130 -0
  193. package/src/components/organisms/lightning-sidebar-nav/icons.ts +46 -0
  194. package/src/components/organisms/lightning-sidebar-nav/index.ts +18 -0
  195. package/src/components/organisms/lightning-sidebar-nav/types.ts +77 -0
  196. package/src/components/popover/CHANGELOG.md +20 -20
  197. package/src/components/popover/Popover.astro +211 -211
  198. package/src/components/popover/PopoverClose.astro +18 -18
  199. package/src/components/popover/PopoverContent.astro +50 -50
  200. package/src/components/popover/PopoverDescription.astro +18 -18
  201. package/src/components/popover/PopoverHeader.astro +18 -18
  202. package/src/components/popover/PopoverTitle.astro +18 -18
  203. package/src/components/popover/PopoverTrigger.astro +35 -35
  204. package/src/components/popover/index.ts +7 -7
  205. package/src/components/radio-group/CHANGELOG.md +31 -31
  206. package/src/components/radio-group/RadioGroup.astro +81 -81
  207. package/src/components/radio-group/RadioGroupItem.astro +51 -51
  208. package/src/components/radio-group/index.ts +3 -3
  209. package/src/components/select/CHANGELOG.md +39 -39
  210. package/src/components/select/Select.astro +373 -373
  211. package/src/components/select/SelectContent.astro +91 -91
  212. package/src/components/select/SelectGroup.astro +19 -19
  213. package/src/components/select/SelectItem.astro +48 -48
  214. package/src/components/select/SelectLabel.astro +18 -18
  215. package/src/components/select/SelectSeparator.astro +17 -17
  216. package/src/components/select/SelectTrigger.astro +34 -34
  217. package/src/components/select/SelectValue.astro +22 -22
  218. package/src/components/select/index.ts +8 -8
  219. package/src/components/separator/CHANGELOG.md +5 -5
  220. package/src/components/separator/Separator.astro +60 -60
  221. package/src/components/separator/index.ts +2 -2
  222. package/src/components/skeleton/CHANGELOG.md +5 -5
  223. package/src/components/skeleton/Skeleton.astro +121 -121
  224. package/src/components/skeleton/index.ts +1 -1
  225. package/src/components/slider/CHANGELOG.md +5 -5
  226. package/src/components/slider/Slider.astro +198 -198
  227. package/src/components/slider/index.ts +1 -1
  228. package/src/components/spinner/CHANGELOG.md +18 -18
  229. package/src/components/spinner/Spinner.astro +135 -135
  230. package/src/components/spinner/index.ts +1 -1
  231. package/src/components/sticky-surface/CHANGELOG.md +5 -5
  232. package/src/components/sticky-surface/StickySurface.astro +152 -152
  233. package/src/components/sticky-surface/index.ts +2 -2
  234. package/src/components/switch/CHANGELOG.md +29 -29
  235. package/src/components/switch/Switch.astro +106 -106
  236. package/src/components/switch/index.ts +1 -1
  237. package/src/components/table/CHANGELOG.md +24 -24
  238. package/src/components/table/Table.astro +358 -251
  239. package/src/components/table/TableBody.astro +17 -18
  240. package/src/components/table/TableBulkToolbar.astro +37 -0
  241. package/src/components/table/TableCaption.astro +17 -17
  242. package/src/components/table/TableCell.astro +45 -21
  243. package/src/components/table/TableCellText.astro +17 -0
  244. package/src/components/table/TableColumnMenu.astro +50 -0
  245. package/src/components/table/TableEditableCell.astro +287 -0
  246. package/src/components/table/TableEmpty.astro +17 -0
  247. package/src/components/table/TableFolderCell.astro +38 -0
  248. package/src/components/table/TableFooter.astro +21 -21
  249. package/src/components/table/TableHead.astro +90 -21
  250. package/src/components/table/TableHeader.astro +17 -17
  251. package/src/components/table/TablePagination.astro +57 -0
  252. package/src/components/table/TableRow.astro +52 -21
  253. package/src/components/table/TableSelectAllBanner.astro +34 -0
  254. package/src/components/table/TableSelectCell.astro +50 -0
  255. package/src/components/table/TableSkeletonRow.astro +21 -0
  256. package/src/components/table/index.ts +19 -9
  257. package/src/components/table/table-cell-variants.ts +29 -0
  258. package/src/components/tabs/CHANGELOG.md +5 -5
  259. package/src/components/tabs/Tabs.astro +306 -306
  260. package/src/components/tabs/TabsContent.astro +47 -47
  261. package/src/components/tabs/TabsList.astro +69 -69
  262. package/src/components/tabs/TabsTrigger.astro +40 -40
  263. package/src/components/tabs/index.ts +6 -6
  264. package/src/components/textarea/CHANGELOG.md +22 -22
  265. package/src/components/textarea/Textarea.astro +58 -58
  266. package/src/components/textarea/index.ts +2 -2
  267. package/src/components/toggle/CHANGELOG.md +23 -23
  268. package/src/components/toggle/Toggle.astro +150 -150
  269. package/src/components/toggle/index.ts +1 -1
  270. package/src/components/toggle-group/CHANGELOG.md +18 -18
  271. package/src/components/toggle-group/ToggleGroup.astro +236 -236
  272. package/src/components/toggle-group/ToggleGroupItem.astro +48 -48
  273. package/src/components/toggle-group/index.ts +2 -2
  274. package/src/components/tooltip/CHANGELOG.md +22 -22
  275. package/src/components/tooltip/Tooltip.astro +357 -357
  276. package/src/components/tooltip/TooltipContent.astro +85 -85
  277. package/src/components/tooltip/TooltipTrigger.astro +34 -34
  278. package/src/components/tooltip/index.ts +3 -3
  279. package/src/lib/table-controller.ts +1090 -0
  280. package/src/lib/toggle-group-controller.ts +113 -113
  281. package/src/lib/toggle-shared.ts +27 -27
  282. package/src/lib/utils.ts +6 -6
  283. package/src/styles/globals.css +197 -177
@@ -0,0 +1,1090 @@
1
+ /**
2
+ * Behaviour for `<Table interactive>`.
3
+ *
4
+ * The controller never sorts, pages or persists data. It manages UI state
5
+ * (sort indicators, selection, edit lifecycle, tree visibility, column layout,
6
+ * keyboard focus) and reports intent through bubbling `table:*` events on the
7
+ * `[data-slot="table-root"]` element. The app responds by re-rendering rows;
8
+ * a MutationObserver re-applies layout and selection state to the new rows.
9
+ *
10
+ * Outbound events (all bubble, all cancelable unless noted):
11
+ * - `table:sort` `{ column, direction: "asc" | "desc" | null }`
12
+ * - `table:edit` `{ rowId, column, value, previous, respond(promise) }`
13
+ * Call `respond` with a promise; reject it (Error or string)
14
+ * to show the error state. `preventDefault()` rejects the edit.
15
+ * - `table:selection` `{ rowIds, allResults }` (not cancelable)
16
+ * - `table:page` `{ page, pageSize }`
17
+ * - `table:toggle-row` `{ rowId, expanded }`
18
+ * - `table:columns` `{ widths, hidden }` (not cancelable)
19
+ *
20
+ * Inbound event:
21
+ * - `table:set` `{ loading?: boolean, totalCount?: number, page?: number }`
22
+ */
23
+
24
+ type SortState = "asc" | "desc" | "none";
25
+ type EditValue = string | string[];
26
+
27
+ export interface TableEditDetail {
28
+ rowId: string | null;
29
+ column: string;
30
+ value: EditValue;
31
+ previous: EditValue;
32
+ respond: (result: unknown) => void;
33
+ }
34
+
35
+ export interface TableSortDetail {
36
+ column: string;
37
+ direction: "asc" | "desc" | null;
38
+ }
39
+
40
+ export interface TableSetDetail {
41
+ loading?: boolean;
42
+ totalCount?: number;
43
+ page?: number;
44
+ }
45
+
46
+ const bound = new WeakSet<HTMLElement>();
47
+ const MIN_COLUMN_WIDTH = 64;
48
+ const ERROR_TIMEOUT = 4000;
49
+
50
+ export function initTables(scope: ParentNode = document) {
51
+ scope
52
+ .querySelectorAll<HTMLElement>('[data-slot="table-root"][data-interactive]')
53
+ .forEach((root) => {
54
+ if (bound.has(root)) return;
55
+ bound.add(root);
56
+ createTable(root);
57
+ });
58
+ }
59
+
60
+ function errorMessage(error: unknown, fallback: string): string {
61
+ if (typeof error === "string" && error) return error;
62
+ if (error instanceof Error && error.message) return error.message;
63
+ return fallback;
64
+ }
65
+
66
+ function parseValue(td: HTMLElement): EditValue {
67
+ const raw = td.dataset.value ?? "";
68
+ if (td.dataset.editor !== "multiselect") return raw;
69
+ try {
70
+ const parsed: unknown = JSON.parse(raw || "[]");
71
+ return Array.isArray(parsed) ? parsed.map(String) : [];
72
+ } catch {
73
+ return [];
74
+ }
75
+ }
76
+
77
+ function serializeValue(td: HTMLElement, value: EditValue): string {
78
+ return td.dataset.editor === "multiselect" ? JSON.stringify(value) : String(value);
79
+ }
80
+
81
+ function sameValue(a: EditValue, b: EditValue): boolean {
82
+ if (Array.isArray(a) && Array.isArray(b)) {
83
+ return a.length === b.length && a.every((v) => b.includes(v));
84
+ }
85
+ return a === b;
86
+ }
87
+
88
+ function isTextEntry(el: Element | null): boolean {
89
+ return !!el && (el.matches("input:not([type=checkbox]), textarea, select") || !!el.closest('[data-slot="table-cell-editor"]'));
90
+ }
91
+
92
+ function createTable(root: HTMLElement) {
93
+ const tableElement = root.querySelector<HTMLTableElement>('table[data-slot="table"]');
94
+ if (!tableElement) return;
95
+ const table: HTMLTableElement = tableElement;
96
+
97
+ const live = root.querySelector<HTMLElement>('[data-slot="table-live"]');
98
+ const pagination = root.querySelector<HTMLElement>('[data-slot="table-pagination"]');
99
+ const banner = root.querySelector<HTMLElement>('[data-slot="table-select-all-banner"]');
100
+ const columnMenu = root.querySelector<HTMLElement>("[data-table-column-menu]");
101
+ const pinCount = Number(root.dataset.pinnedColumns ?? 0) || 0;
102
+
103
+ let allResults = false;
104
+ let activeText: HTMLTableCellElement | null = null;
105
+ let current: HTMLTableCellElement | null = null;
106
+ const hiddenColumns = new Set<string>();
107
+ const visibleErrors = new Set<HTMLTableCellElement>();
108
+ const errorTimers = new WeakMap<HTMLElement, number>();
109
+ const boundWidgets = new WeakSet<Element>();
110
+
111
+ table.setAttribute("role", "grid");
112
+
113
+ // ---------------------------------------------------------------- helpers
114
+
115
+ const emit = <T,>(name: string, detail: T, cancelable = true) =>
116
+ root.dispatchEvent(new CustomEvent<T>(name, { bubbles: true, cancelable, detail }));
117
+
118
+ const announce = (message: string) => {
119
+ if (!live) return;
120
+ live.textContent = "";
121
+ window.setTimeout(() => {
122
+ live.textContent = message;
123
+ }, 50);
124
+ };
125
+
126
+ const headerRow = () => table.tHead?.rows[table.tHead.rows.length - 1] ?? null;
127
+
128
+ const bodyRows = () =>
129
+ Array.from(table.tBodies).flatMap((tbody) =>
130
+ Array.from(tbody.rows).filter((r) => r.dataset.slot === "table-row"),
131
+ );
132
+
133
+ const allRows = () => Array.from(table.rows);
134
+
135
+ const cellLabel = (td: HTMLElement) => td.dataset.label ?? td.dataset.column ?? "value";
136
+
137
+ const hasTree = () => !!table.querySelector("tbody tr[data-folder]");
138
+
139
+ const totalCount = () => {
140
+ const value = Number(root.dataset.totalCount ?? pagination?.dataset.totalCount);
141
+ return Number.isFinite(value) && value > 0 ? value : null;
142
+ };
143
+
144
+ // ---------------------------------------------------------------- sorting
145
+
146
+ const syncSortAvailability = () => {
147
+ const tree = hasTree();
148
+ table.querySelectorAll<HTMLButtonElement>('[data-slot="table-sort-button"]').forEach((button) => {
149
+ button.disabled = tree;
150
+ button.title = tree ? "Sorting is not available in tree tables" : "";
151
+ });
152
+ };
153
+
154
+ const setSortState = (th: HTMLElement, state: SortState) => {
155
+ th.dataset.sort = state;
156
+ th.setAttribute("aria-sort", state === "asc" ? "ascending" : state === "desc" ? "descending" : "none");
157
+ };
158
+
159
+ const cycleSort = (th: HTMLTableCellElement) => {
160
+ if (hasTree()) return;
161
+ const column = th.dataset.column;
162
+ if (!column) return;
163
+ const sortable = Array.from(table.querySelectorAll<HTMLTableCellElement>("th[data-sortable]"));
164
+ const previous = sortable.map((h) => [h, (h.dataset.sort as SortState) ?? "none"] as const);
165
+ const now = (th.dataset.sort as SortState) ?? "none";
166
+ const next: SortState = now === "asc" ? "desc" : now === "desc" ? "none" : "asc";
167
+
168
+ sortable.forEach((h) => setSortState(h, h === th ? next : "none"));
169
+
170
+ const accepted = emit<TableSortDetail>("table:sort", {
171
+ column,
172
+ direction: next === "none" ? null : next,
173
+ });
174
+ if (!accepted) {
175
+ previous.forEach(([h, state]) => setSortState(h, state));
176
+ return;
177
+ }
178
+ const name = th.textContent?.trim() || column;
179
+ announce(next === "none" ? "Sorting cleared" : `Sorted by ${name}, ${next === "asc" ? "ascending" : "descending"}`);
180
+ };
181
+
182
+ // -------------------------------------------------------------- selection
183
+
184
+ const rowCheckbox = (tr: HTMLTableRowElement) =>
185
+ tr.querySelector<HTMLInputElement>('input[data-table-select="row"]');
186
+
187
+ const headerCheckbox = () => table.querySelector<HTMLInputElement>('input[data-table-select="all"]');
188
+
189
+ const selectionRows = () => bodyRows().filter((tr) => rowCheckbox(tr));
190
+
191
+ const descendantsOf = (tr: HTMLTableRowElement) => {
192
+ const id = tr.dataset.rowId;
193
+ if (!id) return [];
194
+ const ids = new Set([id]);
195
+ const result: HTMLTableRowElement[] = [];
196
+ for (const row of bodyRows()) {
197
+ const parent = row.dataset.parentId;
198
+ if (parent && ids.has(parent)) {
199
+ result.push(row);
200
+ if (row.dataset.rowId) ids.add(row.dataset.rowId);
201
+ }
202
+ }
203
+ return result;
204
+ };
205
+
206
+ const setText = (selector: string, text: string, scope: ParentNode = root) =>
207
+ scope.querySelectorAll(selector).forEach((el) => {
208
+ el.textContent = text;
209
+ });
210
+
211
+ const syncSelection = () => {
212
+ const rows = selectionRows();
213
+ const boxes = rows.map((tr) => rowCheckbox(tr)!).filter((b) => !b.disabled);
214
+ let checkedCount = 0;
215
+
216
+ rows.forEach((tr) => {
217
+ const checked = rowCheckbox(tr)!.checked;
218
+ if (checked) {
219
+ checkedCount += 1;
220
+ tr.dataset.state = "selected";
221
+ tr.setAttribute("aria-selected", "true");
222
+ } else {
223
+ if (tr.dataset.state === "selected") delete tr.dataset.state;
224
+ tr.setAttribute("aria-selected", "false");
225
+ }
226
+ });
227
+
228
+ const everyChecked = boxes.length > 0 && boxes.every((b) => b.checked);
229
+ const header = headerCheckbox();
230
+ if (header) {
231
+ header.checked = everyChecked;
232
+ header.indeterminate = checkedCount > 0 && !everyChecked;
233
+ }
234
+ if (!everyChecked) allResults = false;
235
+
236
+ const total = totalCount();
237
+ const count = allResults && total ? total : checkedCount;
238
+ root.toggleAttribute("data-has-selection", checkedCount > 0);
239
+ setText('[data-slot="table-selection-count"]', String(count));
240
+
241
+ if (banner) {
242
+ const show = everyChecked && total !== null && total > boxes.length;
243
+ banner.hidden = !show;
244
+ setText('[data-slot="table-page-count"]', String(boxes.length), banner);
245
+ setText('[data-slot="table-total-count"]', String(total ?? 0), banner);
246
+ banner.querySelectorAll<HTMLElement>("[data-banner-page]").forEach((el) => (el.hidden = allResults));
247
+ banner.querySelectorAll<HTMLElement>("[data-banner-all]").forEach((el) => (el.hidden = !allResults));
248
+ }
249
+ return { rows, checkedCount, count };
250
+ };
251
+
252
+ const selectionChanged = () => {
253
+ const { rows, count } = syncSelection();
254
+ const rowIds = rows
255
+ .filter((tr) => rowCheckbox(tr)!.checked)
256
+ .map((tr) => tr.dataset.rowId)
257
+ .filter((id): id is string => !!id);
258
+ emit("table:selection", { rowIds, allResults }, false);
259
+ announce(count === 0 ? "No rows selected" : `${count} selected`);
260
+ };
261
+
262
+ const clearSelection = () => {
263
+ allResults = false;
264
+ selectionRows().forEach((tr) => (rowCheckbox(tr)!.checked = false));
265
+ selectionChanged();
266
+ };
267
+
268
+ // ------------------------------------------------------------------- tree
269
+
270
+ const applyTree = () => {
271
+ const hiddenParents = new Set<string>();
272
+ for (const tr of bodyRows()) {
273
+ const parent = tr.dataset.parentId;
274
+ const hiddenByParent = !!parent && hiddenParents.has(parent);
275
+ tr.hidden = hiddenByParent;
276
+ const id = tr.dataset.rowId;
277
+ const folder = tr.hasAttribute("data-folder");
278
+ const expanded = tr.hasAttribute("data-expanded");
279
+ if (id && (hiddenByParent || (folder && !expanded))) hiddenParents.add(id);
280
+ if (folder) {
281
+ tr.setAttribute("aria-expanded", String(expanded));
282
+ const toggle = tr.querySelector<HTMLElement>('[data-slot="table-row-toggle"]');
283
+ toggle?.setAttribute("aria-label", expanded ? "Collapse row" : "Expand row");
284
+ toggle?.setAttribute("aria-expanded", String(expanded));
285
+ }
286
+ }
287
+ };
288
+
289
+ const setExpanded = (tr: HTMLTableRowElement, expanded: boolean) => {
290
+ if (!tr.hasAttribute("data-folder") || tr.hasAttribute("data-expanded") === expanded) return;
291
+ tr.toggleAttribute("data-expanded", expanded);
292
+ applyTree();
293
+ emit("table:toggle-row", { rowId: tr.dataset.rowId ?? null, expanded });
294
+ const name = tr.querySelector("[data-folder-cell]")?.textContent?.trim() ?? "Row";
295
+ announce(`${name} ${expanded ? "expanded" : "collapsed"}`);
296
+ };
297
+
298
+ // ---------------------------------------------------------------- columns
299
+
300
+ const columnCells = (index: number, column: string | undefined, headerLength: number) =>
301
+ allRows().flatMap((tr) => {
302
+ if (tr.cells.length === headerLength) return tr.cells[index] ? [tr.cells[index]] : [];
303
+ if (!column) return [];
304
+ return Array.from(tr.cells).filter((c) => c.dataset.column === column);
305
+ });
306
+
307
+ const applyColumns = () => {
308
+ const header = headerRow();
309
+ if (!header) return;
310
+ Array.from(header.cells).forEach((th, index) => {
311
+ const column = th.dataset.column;
312
+ const hide = !!column && hiddenColumns.has(column);
313
+ columnCells(index, column, header.cells.length).forEach((cell) => (cell.hidden = hide));
314
+ });
315
+ };
316
+
317
+ const applyPinned = () => {
318
+ const header = headerRow();
319
+ if (!header || pinCount <= 0) return;
320
+ const ths = Array.from(header.cells);
321
+ const pinned = ths.map((_, i) => i).filter((i) => !ths[i]!.hidden).slice(0, pinCount);
322
+ const offsets = new Map<number, number>();
323
+ let left = 0;
324
+ pinned.forEach((i) => {
325
+ offsets.set(i, left);
326
+ left += ths[i]!.getBoundingClientRect().width;
327
+ });
328
+ const last = pinned[pinned.length - 1];
329
+ ths.forEach((th, index) => {
330
+ columnCells(index, th.dataset.column, ths.length).forEach((cell) => {
331
+ const offset = offsets.get(index);
332
+ if (offset === undefined) {
333
+ delete cell.dataset.pinned;
334
+ delete cell.dataset.pinnedLast;
335
+ cell.style.left = "";
336
+ return;
337
+ }
338
+ cell.dataset.pinned = "";
339
+ cell.toggleAttribute("data-pinned-last", index === last);
340
+ cell.style.left = `${offset}px`;
341
+ });
342
+ });
343
+ };
344
+
345
+ const emitColumns = () => {
346
+ const widths: Record<string, number> = {};
347
+ headerRow()
348
+ ?.querySelectorAll<HTMLElement>("th[data-column]")
349
+ .forEach((th) => {
350
+ if (th.style.width) widths[th.dataset.column!] = Math.round(parseFloat(th.style.width));
351
+ });
352
+ emit("table:columns", { widths, hidden: Array.from(hiddenColumns) }, false);
353
+ };
354
+
355
+ const setColumnWidth = (th: HTMLElement, width: number) => {
356
+ const px = `${Math.max(MIN_COLUMN_WIDTH, Math.round(width))}px`;
357
+ th.style.width = px;
358
+ th.style.minWidth = px;
359
+ applyPinned();
360
+ };
361
+
362
+ const bindResize = (handle: HTMLElement) => {
363
+ handle.addEventListener("pointerdown", (event) => {
364
+ const th = handle.closest("th");
365
+ if (!th) return;
366
+ event.preventDefault();
367
+ event.stopPropagation();
368
+ const startX = event.clientX;
369
+ const startWidth = th.getBoundingClientRect().width;
370
+ handle.setPointerCapture(event.pointerId);
371
+ root.dataset.resizing = "";
372
+ const move = (e: PointerEvent) => setColumnWidth(th, startWidth + e.clientX - startX);
373
+ const up = () => {
374
+ handle.removeEventListener("pointermove", move);
375
+ handle.removeEventListener("pointerup", up);
376
+ handle.removeEventListener("pointercancel", up);
377
+ delete root.dataset.resizing;
378
+ emitColumns();
379
+ };
380
+ handle.addEventListener("pointermove", move);
381
+ handle.addEventListener("pointerup", up);
382
+ handle.addEventListener("pointercancel", up);
383
+ });
384
+ // A click on the handle must not toggle the sort button underneath.
385
+ handle.addEventListener("click", (e) => e.stopPropagation());
386
+ };
387
+
388
+ if (columnMenu) {
389
+ let columns: string[] = [];
390
+ let visible: string[] = [];
391
+ try {
392
+ columns = JSON.parse(columnMenu.dataset.columns ?? "[]");
393
+ visible = JSON.parse(columnMenu.dataset.defaultValues ?? "[]");
394
+ } catch {
395
+ /* malformed attributes: treat every column as visible */
396
+ }
397
+ columns.filter((c) => !visible.includes(c)).forEach((c) => hiddenColumns.add(c));
398
+ columnMenu.addEventListener("dropdown-menu:values-change", (event) => {
399
+ const { values } = (event as CustomEvent<{ values: string[] }>).detail;
400
+ hiddenColumns.clear();
401
+ columns.filter((c) => !values.includes(c)).forEach((c) => hiddenColumns.add(c));
402
+ applyColumns();
403
+ applyPinned();
404
+ ensureTabStop();
405
+ emitColumns();
406
+ });
407
+ }
408
+
409
+ // ----------------------------------------------------------------- errors
410
+
411
+ // Bubbles are moved to <body> on first use: pinned (sticky, z-indexed) cells
412
+ // form stacking contexts that would paint later rows over the bubble.
413
+ const errorElements = new WeakMap<HTMLElement, HTMLElement>();
414
+ const errorElement = (td: HTMLElement) => {
415
+ let el = errorElements.get(td) ?? null;
416
+ if (!el) {
417
+ el = td.querySelector<HTMLElement>('[data-slot="table-cell-error"]');
418
+ if (!el) return null;
419
+ errorElements.set(td, el);
420
+ document.body.append(el);
421
+ }
422
+ return el;
423
+ };
424
+
425
+ const positionError = (td: HTMLTableCellElement) => {
426
+ const el = errorElement(td);
427
+ if (!el) return;
428
+ const rect = td.getBoundingClientRect();
429
+ el.style.left = `${rect.left + 8}px`;
430
+ el.style.top = `${rect.bottom + 4}px`;
431
+ };
432
+
433
+ const showError = (td: HTMLTableCellElement, message: string, autoHide = false) => {
434
+ const el = errorElement(td);
435
+ if (!el) return;
436
+ el.textContent = message;
437
+ el.id ||= `table-error-${Math.random().toString(36).slice(2)}`;
438
+ el.hidden = false;
439
+ td.querySelector("[data-table-input]")?.setAttribute("aria-describedby", el.id);
440
+ visibleErrors.add(td);
441
+ positionError(td);
442
+ window.clearTimeout(errorTimers.get(td));
443
+ if (autoHide) errorTimers.set(td, window.setTimeout(() => hideError(td), ERROR_TIMEOUT));
444
+ };
445
+
446
+ const hideError = (td: HTMLTableCellElement) => {
447
+ const el = errorElement(td);
448
+ if (el) el.hidden = true;
449
+ td.querySelector("[data-table-input]")?.removeAttribute("aria-describedby");
450
+ visibleErrors.delete(td);
451
+ };
452
+
453
+ const repositionErrors = () =>
454
+ visibleErrors.forEach((td) => (td.isConnected ? positionError(td) : hideError(td)));
455
+ root.querySelector('[data-slot="table-container"]')?.addEventListener("scroll", repositionErrors, { passive: true });
456
+ window.addEventListener("scroll", repositionErrors, { passive: true, capture: true });
457
+ window.addEventListener("resize", () => {
458
+ repositionErrors();
459
+ applyPinned();
460
+ });
461
+
462
+ // ---------------------------------------------------------------- editing
463
+
464
+ const requestEdit = (td: HTMLTableCellElement, value: EditValue, previous: EditValue): Promise<unknown> => {
465
+ let pending: Promise<unknown> | undefined;
466
+ const accepted = emit<TableEditDetail>("table:edit", {
467
+ rowId: td.closest("tr")?.dataset.rowId ?? null,
468
+ column: td.dataset.column ?? "",
469
+ value,
470
+ previous,
471
+ respond: (result) => {
472
+ pending = Promise.resolve(result);
473
+ },
474
+ });
475
+ if (!accepted) return Promise.reject(new Error("This change was not accepted."));
476
+ return pending ?? Promise.resolve(undefined);
477
+ };
478
+
479
+ const textInput = (td: HTMLElement) => td.querySelector<HTMLInputElement>("input[data-table-input]");
480
+ const confirmButton = (td: HTMLElement) => td.querySelector<HTMLButtonElement>('[data-table-action="confirm"]');
481
+
482
+ const validateText = (td: HTMLTableCellElement, showMessage: boolean) => {
483
+ const input = textInput(td);
484
+ if (!input) return true;
485
+ const valid = input.checkValidity();
486
+ const confirm = confirmButton(td);
487
+ if (confirm) confirm.disabled = !valid;
488
+ if (valid) {
489
+ input.removeAttribute("aria-invalid");
490
+ hideError(td);
491
+ } else if (showMessage) {
492
+ input.setAttribute("aria-invalid", "true");
493
+ showError(td, input.validationMessage);
494
+ }
495
+ return valid;
496
+ };
497
+
498
+ const renderTextValue = (td: HTMLElement, value: string) => {
499
+ const target = td.querySelector<HTMLElement>('[data-slot="table-cell-value"]');
500
+ if (!target) return;
501
+ target.textContent = value || td.dataset.placeholder || "";
502
+ target.classList.toggle("text-muted-foreground", !value);
503
+ target.classList.toggle("font-normal", !value);
504
+ };
505
+
506
+ const cancelText = (td: HTMLTableCellElement, refocus = true) => {
507
+ const input = textInput(td);
508
+ td.dataset.state = "idle";
509
+ input?.removeAttribute("aria-invalid");
510
+ hideError(td);
511
+ if (activeText === td) activeText = null;
512
+ if (refocus) focusCell(td);
513
+ };
514
+
515
+ const startText = (td: HTMLTableCellElement) => {
516
+ const input = textInput(td);
517
+ if (!input || td.dataset.state === "saving") return;
518
+ if (activeText && activeText !== td) {
519
+ const other = activeText;
520
+ if (textInput(other)?.value !== (other.dataset.value ?? "")) {
521
+ textInput(other)?.focus();
522
+ announce(`Save or cancel the ${cellLabel(other)} edit first`);
523
+ return;
524
+ }
525
+ cancelText(other, false);
526
+ }
527
+ activeText = td;
528
+ input.value = td.dataset.value ?? "";
529
+ td.dataset.state = "editing";
530
+ validateText(td, false);
531
+ input.focus();
532
+ input.select();
533
+ };
534
+
535
+ const commitText = async (td: HTMLTableCellElement) => {
536
+ const input = textInput(td);
537
+ if (!input || td.dataset.state === "saving") return;
538
+ if (!validateText(td, true)) {
539
+ input.focus();
540
+ return;
541
+ }
542
+ const previous = td.dataset.value ?? "";
543
+ const value = input.value;
544
+ if (value === previous) {
545
+ cancelText(td);
546
+ return;
547
+ }
548
+ td.dataset.state = "saving";
549
+ td.setAttribute("aria-busy", "true");
550
+ input.readOnly = true;
551
+ try {
552
+ const result = await requestEdit(td, value, previous);
553
+ const saved = typeof result === "string" ? result : value;
554
+ td.dataset.value = saved;
555
+ renderTextValue(td, saved);
556
+ td.dataset.state = "idle";
557
+ activeText = null;
558
+ hideError(td);
559
+ focusCell(td);
560
+ announce(`${cellLabel(td)} saved`);
561
+ } catch (error) {
562
+ td.dataset.state = "error";
563
+ input.setAttribute("aria-invalid", "true");
564
+ const message = errorMessage(error, "Could not save the change.");
565
+ showError(td, message);
566
+ input.focus();
567
+ announce(`${cellLabel(td)} not saved. ${message}`);
568
+ } finally {
569
+ input.readOnly = false;
570
+ td.removeAttribute("aria-busy");
571
+ }
572
+ };
573
+
574
+ // -------------------------------------------------------- choice editors
575
+
576
+ const renderChoice = (td: HTMLTableCellElement, value: EditValue) => {
577
+ const target = td.querySelector<HTMLElement>('[data-slot="table-cell-value"]');
578
+ const template = td.querySelector<HTMLTemplateElement>('template[data-slot="table-cell-templates"]');
579
+ if (!target || !template) return;
580
+ const values = Array.isArray(value) ? value : value ? [value] : [];
581
+ const option = (v: string) =>
582
+ template.content.querySelector<HTMLElement>(`[data-option="${CSS.escape(v)}"]`);
583
+
584
+ if (td.dataset.editor === "combobox") {
585
+ const text = values.map((v) => option(v)?.textContent?.trim() ?? v).join(", ");
586
+ renderTextValue(td, text);
587
+ return;
588
+ }
589
+
590
+ const max = Number(td.dataset.maxVisible ?? 2) || 2;
591
+ const nodes: Node[] = values.slice(0, max).map((v) => {
592
+ const node = option(v)?.cloneNode(true) as HTMLElement | undefined;
593
+ if (node) {
594
+ node.removeAttribute("data-option");
595
+ return node;
596
+ }
597
+ return document.createTextNode(v);
598
+ });
599
+ if (values.length > max) {
600
+ const more = template.content.querySelector("[data-option-overflow]")?.cloneNode(true) as HTMLElement | undefined;
601
+ if (more) {
602
+ more.removeAttribute("data-option-overflow");
603
+ more.textContent = `+${values.length - max}`;
604
+ nodes.push(more);
605
+ }
606
+ }
607
+ if (nodes.length === 0) {
608
+ const empty = document.createElement("span");
609
+ empty.className = "text-muted-foreground font-normal";
610
+ empty.textContent = td.dataset.placeholder ?? "";
611
+ nodes.push(empty);
612
+ }
613
+ target.replaceChildren(...nodes);
614
+ };
615
+
616
+ const commitChoice = async (
617
+ td: HTMLTableCellElement,
618
+ value: EditValue,
619
+ previous: EditValue,
620
+ revert: (value: EditValue) => void,
621
+ ) => {
622
+ if (sameValue(value, previous)) return;
623
+ td.dataset.value = serializeValue(td, value);
624
+ renderChoice(td, value);
625
+ td.dataset.state = "saving";
626
+ td.setAttribute("aria-busy", "true");
627
+ hideError(td);
628
+ try {
629
+ await requestEdit(td, value, previous);
630
+ announce(`${cellLabel(td)} saved`);
631
+ } catch (error) {
632
+ td.dataset.value = serializeValue(td, previous);
633
+ renderChoice(td, previous);
634
+ revert(previous);
635
+ const message = errorMessage(error, "Could not save the change.");
636
+ showError(td, message, true);
637
+ announce(`${cellLabel(td)} not saved. ${message}`);
638
+ } finally {
639
+ td.dataset.state = "idle";
640
+ td.removeAttribute("aria-busy");
641
+ }
642
+ };
643
+
644
+ const bindMenuCell = (menu: HTMLElement) => {
645
+ const td = menu.closest<HTMLTableCellElement>("td[data-editable]");
646
+ if (!td) return;
647
+ const restore = (value: EditValue) =>
648
+ menu.dispatchEvent(
649
+ new CustomEvent("dropdown-menu:set", {
650
+ detail: Array.isArray(value) ? { values: value, source: "restore" } : { value, source: "restore" },
651
+ }),
652
+ );
653
+
654
+ if (td.dataset.editor === "select") {
655
+ menu.addEventListener("dropdown-menu:value-change", (event) => {
656
+ const { value, previousValue, source } = (
657
+ event as CustomEvent<{ value: string | null; previousValue: string | null; source: string }>
658
+ ).detail;
659
+ if (source === "programmatic" || source === "restore") return;
660
+ void commitChoice(td, value ?? "", previousValue ?? "", restore);
661
+ });
662
+ return;
663
+ }
664
+
665
+ // Multiselect: preview while open, commit once when the menu closes.
666
+ let snapshot: string[] | null = null;
667
+ let working: string[] = [];
668
+ menu.addEventListener("dropdown-menu:open-change", (event) => {
669
+ const { open } = (event as CustomEvent<{ open: boolean }>).detail;
670
+ if (open) {
671
+ snapshot = parseValue(td) as string[];
672
+ working = [...snapshot];
673
+ return;
674
+ }
675
+ if (!snapshot) return;
676
+ const before = snapshot;
677
+ snapshot = null;
678
+ if (sameValue(working, before)) {
679
+ renderChoice(td, before);
680
+ return;
681
+ }
682
+ void commitChoice(td, working, before, restore);
683
+ });
684
+ menu.addEventListener("dropdown-menu:values-change", (event) => {
685
+ const { values, source } = (event as CustomEvent<{ values: string[]; source: string }>).detail;
686
+ if (source === "programmatic" || source === "restore" || !snapshot) return;
687
+ working = values;
688
+ renderChoice(td, values);
689
+ });
690
+ };
691
+
692
+ const comboboxOf = (td: HTMLElement) => td.querySelector<HTMLElement>("[data-table-combobox]");
693
+
694
+ const startCombobox = (td: HTMLTableCellElement) => {
695
+ const combobox = comboboxOf(td);
696
+ if (!combobox || td.dataset.state === "saving") return;
697
+ td.dataset.state = "editing";
698
+ const input = combobox.querySelector<HTMLInputElement>('[data-slot="combobox-input"]');
699
+ input?.focus();
700
+ combobox.dispatchEvent(new CustomEvent("combobox:set", { detail: { open: true } }));
701
+ };
702
+
703
+ const bindCombobox = (combobox: HTMLElement) => {
704
+ const td = combobox.closest<HTMLTableCellElement>("td[data-editable]");
705
+ if (!td) return;
706
+ const restore = (value: EditValue) =>
707
+ combobox.dispatchEvent(new CustomEvent("combobox:set", { detail: { value: String(value) || null } }));
708
+ const close = () => {
709
+ if (td.dataset.state !== "editing") return;
710
+ td.dataset.state = "idle";
711
+ restore(td.dataset.value ?? "");
712
+ focusCell(td);
713
+ };
714
+
715
+ combobox.addEventListener("combobox:change", (event) => {
716
+ if (td.dataset.state !== "editing") return;
717
+ const { value } = (event as CustomEvent<{ value: string | null }>).detail;
718
+ const previous = td.dataset.value ?? "";
719
+ td.dataset.state = "idle";
720
+ focusCell(td);
721
+ if (value !== null && value !== previous) void commitChoice(td, value, previous, restore);
722
+ });
723
+ combobox.addEventListener("combobox:open-change", (event) => {
724
+ if ((event as CustomEvent<{ open: boolean }>).detail.open) return;
725
+ // Let a pending `combobox:change` land first.
726
+ window.setTimeout(close, 0);
727
+ });
728
+ combobox.addEventListener("focusout", (event) => {
729
+ const next = (event as FocusEvent).relatedTarget as Node | null;
730
+ if (next && combobox.contains(next)) return;
731
+ window.setTimeout(() => {
732
+ if (!combobox.contains(document.activeElement)) close();
733
+ }, 0);
734
+ });
735
+ };
736
+
737
+ // --------------------------------------------------------------- keyboard
738
+
739
+ const visibleCells = (tr: HTMLTableRowElement) => Array.from(tr.cells).filter((c) => !c.hidden);
740
+ const gridRows = () => allRows().filter((tr) => !tr.hidden && visibleCells(tr).length > 0);
741
+
742
+ function focusCell(cell: HTMLTableCellElement) {
743
+ if (current && current !== cell) current.tabIndex = -1;
744
+ current = cell;
745
+ cell.tabIndex = 0;
746
+ cell.focus();
747
+ }
748
+
749
+ function ensureTabStop() {
750
+ const cells = Array.from(table.querySelectorAll<HTMLTableCellElement>("th, td"));
751
+ cells.forEach((cell) => {
752
+ if (cell !== current) cell.tabIndex = -1;
753
+ });
754
+ // Inner controls are reached with Enter/Space, not Tab (ARIA grid pattern).
755
+ table
756
+ .querySelectorAll<HTMLElement>("th button, th input, td button, td input[type=checkbox], td a[href]")
757
+ .forEach((el) => {
758
+ if (!el.closest('[data-slot="table-cell-editor"]')) el.tabIndex = -1;
759
+ });
760
+ if (!current || !table.contains(current) || current.hidden || current.closest("tr")?.hidden) {
761
+ current = gridRows()[0] ? visibleCells(gridRows()[0]!)[0] ?? null : null;
762
+ }
763
+ if (current) current.tabIndex = 0;
764
+ }
765
+
766
+ const moveFocus = (cell: HTMLTableCellElement, key: string, ctrl: boolean) => {
767
+ const rows = gridRows();
768
+ const tr = cell.closest("tr");
769
+ let rowIndex = tr ? rows.indexOf(tr) : -1;
770
+ if (rowIndex < 0) return;
771
+ let colIndex = visibleCells(tr!).indexOf(cell);
772
+ const lastRow = rows.length - 1;
773
+ switch (key) {
774
+ case "ArrowUp":
775
+ rowIndex = Math.max(0, rowIndex - 1);
776
+ break;
777
+ case "ArrowDown":
778
+ rowIndex = Math.min(lastRow, rowIndex + 1);
779
+ break;
780
+ case "ArrowLeft":
781
+ colIndex = Math.max(0, colIndex - 1);
782
+ break;
783
+ case "ArrowRight":
784
+ colIndex += 1;
785
+ break;
786
+ case "Home":
787
+ colIndex = 0;
788
+ if (ctrl) rowIndex = 0;
789
+ break;
790
+ case "End":
791
+ colIndex = Number.MAX_SAFE_INTEGER;
792
+ if (ctrl) rowIndex = lastRow;
793
+ break;
794
+ case "PageUp":
795
+ rowIndex = Math.max(0, rowIndex - 10);
796
+ break;
797
+ case "PageDown":
798
+ rowIndex = Math.min(lastRow, rowIndex + 10);
799
+ break;
800
+ }
801
+ const target = visibleCells(rows[rowIndex]!);
802
+ const next = target[Math.min(colIndex, target.length - 1)];
803
+ if (next) focusCell(next);
804
+ };
805
+
806
+ const activate = (cell: HTMLTableCellElement) => {
807
+ if (cell.matches('td[data-editable][data-editor="text"]')) return startText(cell);
808
+ if (cell.matches('td[data-editable][data-editor="combobox"]')) return startCombobox(cell);
809
+ const control = cell.querySelector<HTMLElement>(
810
+ '[data-slot="table-cell-display"], [data-slot="table-sort-button"], [data-slot="table-row-toggle"], input[type=checkbox], button, a[href]',
811
+ );
812
+ if (!control || control.closest('[data-slot="table-cell-editor"]')) return;
813
+ // Checkboxes toggle in place; menus and buttons take focus so it returns to them.
814
+ if (!control.matches("input[type=checkbox]")) control.focus();
815
+ control.click();
816
+ };
817
+
818
+ const NAV_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"]);
819
+
820
+ // Capture phase so arrow keys on a focused trigger navigate the grid
821
+ // instead of opening its menu.
822
+ table.addEventListener(
823
+ "keydown",
824
+ (event) => {
825
+ const target = event.target as HTMLElement;
826
+ if (isTextEntry(target)) return;
827
+ const cell = target.closest<HTMLTableCellElement>("th, td");
828
+ if (!cell || cell.closest("table") !== table || !NAV_KEYS.has(event.key)) return;
829
+
830
+ const tr = cell.closest("tr");
831
+ if (tr?.hasAttribute("data-folder") && cell.hasAttribute("data-folder-cell")) {
832
+ const expanded = tr.hasAttribute("data-expanded");
833
+ if (event.key === "ArrowRight" && !expanded) {
834
+ event.preventDefault();
835
+ event.stopPropagation();
836
+ return setExpanded(tr, true);
837
+ }
838
+ if (event.key === "ArrowLeft" && expanded) {
839
+ event.preventDefault();
840
+ event.stopPropagation();
841
+ return setExpanded(tr, false);
842
+ }
843
+ }
844
+ if (event.altKey && cell.tagName === "TH" && cell.querySelector('[data-slot="table-resize-handle"]')) {
845
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
846
+ event.preventDefault();
847
+ event.stopPropagation();
848
+ setColumnWidth(cell, cell.getBoundingClientRect().width + (event.key === "ArrowRight" ? 16 : -16));
849
+ emitColumns();
850
+ return;
851
+ }
852
+ }
853
+ event.preventDefault();
854
+ event.stopPropagation();
855
+ moveFocus(cell, event.key, event.ctrlKey || event.metaKey);
856
+ },
857
+ true,
858
+ );
859
+
860
+ table.addEventListener("keydown", (event) => {
861
+ const target = event.target as HTMLElement;
862
+ const cell = target.closest<HTMLTableCellElement>("th, td");
863
+ if (!cell || cell.closest("table") !== table) return;
864
+
865
+ // Text editor keys.
866
+ if (target.matches("input[data-table-input]")) {
867
+ if (event.key === "Enter") {
868
+ event.preventDefault();
869
+ void commitText(cell);
870
+ } else if (event.key === "Escape") {
871
+ event.preventDefault();
872
+ event.stopPropagation();
873
+ cancelText(cell);
874
+ }
875
+ return;
876
+ }
877
+ if (isTextEntry(target)) return;
878
+ if (target !== cell) return; // inner controls handle their own Enter/Space
879
+
880
+ if (event.key === "Enter" || event.key === "F2") {
881
+ event.preventDefault();
882
+ activate(cell);
883
+ } else if (event.key === " ") {
884
+ const checkbox = cell.querySelector<HTMLInputElement>("input[type=checkbox]");
885
+ if (checkbox) {
886
+ event.preventDefault();
887
+ checkbox.click();
888
+ }
889
+ }
890
+ });
891
+
892
+ table.addEventListener("focusin", (event) => {
893
+ const cell = (event.target as HTMLElement).closest<HTMLTableCellElement>("th, td");
894
+ if (!cell || cell.closest("table") !== table || cell === current) return;
895
+ if (current) current.tabIndex = -1;
896
+ current = cell;
897
+ cell.tabIndex = 0;
898
+ });
899
+
900
+ // ----------------------------------------------------------------- clicks
901
+
902
+ root.addEventListener("click", (event) => {
903
+ const target = event.target as HTMLElement;
904
+
905
+ const action = target.closest<HTMLElement>("[data-table-action]");
906
+ if (action && root.contains(action)) {
907
+ const td = action.closest<HTMLTableCellElement>("td");
908
+ switch (action.dataset.tableAction) {
909
+ case "confirm":
910
+ if (td) void commitText(td);
911
+ return;
912
+ case "cancel":
913
+ if (td) cancelText(td);
914
+ return;
915
+ case "clear-selection":
916
+ clearSelection();
917
+ return;
918
+ case "select-all-results":
919
+ allResults = true;
920
+ selectionChanged();
921
+ return;
922
+ }
923
+ }
924
+
925
+ const sort = target.closest<HTMLButtonElement>('[data-slot="table-sort-button"]');
926
+ if (sort && table.contains(sort)) {
927
+ const th = sort.closest("th");
928
+ if (th) cycleSort(th);
929
+ return;
930
+ }
931
+
932
+ const toggle = target.closest<HTMLElement>('[data-slot="table-row-toggle"]');
933
+ if (toggle && table.contains(toggle)) {
934
+ const tr = toggle.closest("tr");
935
+ if (tr) setExpanded(tr, !tr.hasAttribute("data-expanded"));
936
+ return;
937
+ }
938
+
939
+ const display = target.closest<HTMLElement>('[data-slot="table-cell-display"]');
940
+ const td = display?.closest<HTMLTableCellElement>("td[data-editable]");
941
+ if (display && td && table.contains(td)) {
942
+ if (td.dataset.editor === "text") startText(td);
943
+ else if (td.dataset.editor === "combobox") startCombobox(td);
944
+ // select/multiselect: the dropdown trigger opens itself.
945
+ }
946
+
947
+ const page = target.closest<HTMLButtonElement>("[data-table-page]");
948
+ if (page && pagination?.contains(page)) goToPage(page.dataset.tablePage ?? "");
949
+ });
950
+
951
+ table.addEventListener("input", (event) => {
952
+ const input = event.target as HTMLElement;
953
+ if (!input.matches("input[data-table-input]")) return;
954
+ const td = input.closest<HTMLTableCellElement>("td");
955
+ if (!td) return;
956
+ if (td.dataset.state === "error") td.dataset.state = "editing";
957
+ validateText(td, true);
958
+ });
959
+
960
+ table.addEventListener("change", (event) => {
961
+ const input = event.target as HTMLInputElement;
962
+ const kind = input.dataset?.tableSelect;
963
+ if (!kind) return;
964
+ if (kind === "all") {
965
+ allResults = false;
966
+ selectionRows().forEach((tr) => {
967
+ const box = rowCheckbox(tr)!;
968
+ if (!box.disabled) box.checked = input.checked;
969
+ });
970
+ } else {
971
+ const tr = input.closest("tr");
972
+ if (!input.checked) allResults = false;
973
+ if (tr?.hasAttribute("data-folder")) {
974
+ descendantsOf(tr).forEach((row) => {
975
+ const box = rowCheckbox(row);
976
+ if (box && !box.disabled) box.checked = input.checked;
977
+ });
978
+ }
979
+ }
980
+ selectionChanged();
981
+ });
982
+
983
+ // ------------------------------------------------------------- pagination
984
+
985
+ const paginationState = () => {
986
+ const page = Number(pagination?.dataset.page ?? 1) || 1;
987
+ const pageSize = Number(pagination?.dataset.pageSize ?? 10) || 10;
988
+ const total = Number(pagination?.dataset.totalCount ?? 0) || 0;
989
+ return { page, pageSize, total, pageCount: Math.max(1, Math.ceil(total / pageSize)) };
990
+ };
991
+
992
+ const renderPagination = () => {
993
+ if (!pagination) return;
994
+ const { page, pageSize, total, pageCount } = paginationState();
995
+ const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
996
+ const to = Math.min(page * pageSize, total);
997
+ setText('[data-slot="table-pagination-summary"]', `Showing ${from}–${to} of ${total}`, pagination);
998
+ setText('[data-slot="table-pagination-status"]', `Page ${page} of ${pageCount}`, pagination);
999
+ pagination.querySelectorAll<HTMLButtonElement>("[data-table-page]").forEach((button) => {
1000
+ const dir = button.dataset.tablePage;
1001
+ button.disabled = dir === "first" || dir === "prev" ? page <= 1 : page >= pageCount;
1002
+ });
1003
+ };
1004
+
1005
+ const setPage = (page: number, pageSize: number) => {
1006
+ if (!pagination) return;
1007
+ const accepted = emit("table:page", { page, pageSize });
1008
+ if (!accepted) return;
1009
+ pagination.dataset.page = String(page);
1010
+ pagination.dataset.pageSize = String(pageSize);
1011
+ allResults = false;
1012
+ renderPagination();
1013
+ announce(`Page ${page}`);
1014
+ };
1015
+
1016
+ function goToPage(direction: string) {
1017
+ const { page, pageSize, pageCount } = paginationState();
1018
+ const next =
1019
+ direction === "first" ? 1 : direction === "prev" ? page - 1 : direction === "next" ? page + 1 : pageCount;
1020
+ const clamped = Math.min(pageCount, Math.max(1, next));
1021
+ if (clamped !== page) setPage(clamped, pageSize);
1022
+ }
1023
+
1024
+ pagination?.addEventListener("change", (event) => {
1025
+ const select = event.target as HTMLSelectElement;
1026
+ if (!select.matches("[data-table-page-size]")) return;
1027
+ setPage(1, Number(select.value) || 10);
1028
+ });
1029
+
1030
+ // ---------------------------------------------------------------- inbound
1031
+
1032
+ root.addEventListener("table:set", (event) => {
1033
+ const detail = (event as CustomEvent<TableSetDetail>).detail ?? {};
1034
+ if (detail.loading !== undefined) {
1035
+ root.toggleAttribute("data-loading", detail.loading);
1036
+ table.setAttribute("aria-busy", String(detail.loading));
1037
+ }
1038
+ if (detail.totalCount !== undefined) {
1039
+ root.dataset.totalCount = String(detail.totalCount);
1040
+ if (pagination) pagination.dataset.totalCount = String(detail.totalCount);
1041
+ }
1042
+ if (detail.page !== undefined && pagination) pagination.dataset.page = String(detail.page);
1043
+ renderPagination();
1044
+ syncSelection();
1045
+ });
1046
+
1047
+ // ------------------------------------------------------------------- sync
1048
+
1049
+ const bindWidgets = () => {
1050
+ table.querySelectorAll<HTMLElement>("[data-table-menu]").forEach((menu) => {
1051
+ if (boundWidgets.has(menu)) return;
1052
+ boundWidgets.add(menu);
1053
+ bindMenuCell(menu);
1054
+ });
1055
+ table.querySelectorAll<HTMLElement>("[data-table-combobox]").forEach((combobox) => {
1056
+ if (boundWidgets.has(combobox)) return;
1057
+ boundWidgets.add(combobox);
1058
+ bindCombobox(combobox);
1059
+ });
1060
+ table.querySelectorAll<HTMLElement>('[data-slot="table-resize-handle"]').forEach((handle) => {
1061
+ if (boundWidgets.has(handle)) return;
1062
+ boundWidgets.add(handle);
1063
+ bindResize(handle);
1064
+ });
1065
+ };
1066
+
1067
+ const sync = () => {
1068
+ repositionErrors();
1069
+ bindWidgets();
1070
+ applyTree();
1071
+ applyColumns();
1072
+ applyPinned();
1073
+ syncSortAvailability();
1074
+ syncSelection();
1075
+ ensureTabStop();
1076
+ };
1077
+
1078
+ let scheduled = false;
1079
+ new MutationObserver(() => {
1080
+ if (scheduled) return;
1081
+ scheduled = true;
1082
+ requestAnimationFrame(() => {
1083
+ scheduled = false;
1084
+ sync();
1085
+ });
1086
+ }).observe(table, { childList: true, subtree: true });
1087
+
1088
+ sync();
1089
+ renderPagination();
1090
+ }