@zeyos/zx 2.1.0 → 2.2.1

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 (311) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +15 -0
  3. package/dist/types/compat/base.d.ts +156 -0
  4. package/dist/types/compat/globals.d.ts +20 -0
  5. package/dist/types/compat/index.d.ts +128 -0
  6. package/dist/types/compat/map/basic.d.ts +175 -0
  7. package/dist/types/compat/map/data.d.ts +102 -0
  8. package/dist/types/compat/map/date-time.d.ts +80 -0
  9. package/dist/types/compat/map/factory.d.ts +9 -0
  10. package/dist/types/compat/map/form.d.ts +98 -0
  11. package/dist/types/compat/map/helpers.d.ts +12 -0
  12. package/dist/types/compat/map/http.d.ts +36 -0
  13. package/dist/types/compat/map/layout.d.ts +106 -0
  14. package/dist/types/compat/map/options.d.ts +80 -0
  15. package/dist/types/compat/map/select.d.ts +95 -0
  16. package/dist/types/compat/map/stubs.d.ts +32 -0
  17. package/dist/types/compat/map/utilities.d.ts +37 -0
  18. package/dist/types/compat-entry.d.ts +5 -0
  19. package/dist/types/components/badge/badge.d.ts +46 -0
  20. package/dist/types/components/breadcrumb/breadcrumb.d.ts +200 -0
  21. package/dist/types/components/button/button.d.ts +42 -0
  22. package/dist/types/components/check-button/check-button.d.ts +98 -0
  23. package/dist/types/components/checklist/checklist.d.ts +143 -0
  24. package/dist/types/components/checklist/field-adapter.d.ts +5 -0
  25. package/dist/types/components/context-menu/context-menu.d.ts +130 -0
  26. package/dist/types/components/copy/copy-button.d.ts +48 -0
  27. package/dist/types/components/copy/copy-input.d.ts +91 -0
  28. package/dist/types/components/data-filter/data-filter.d.ts +250 -0
  29. package/dist/types/components/data-filter/filter-core.d.ts +52 -0
  30. package/dist/types/components/date-picker/date-picker.d.ts +137 -0
  31. package/dist/types/components/date-picker/date-range-picker.d.ts +350 -0
  32. package/dist/types/components/date-picker/field-adapter.d.ts +6 -0
  33. package/dist/types/components/date-picker/month-picker.d.ts +75 -0
  34. package/dist/types/components/date-picker/range-field-adapter.d.ts +7 -0
  35. package/dist/types/components/date-picker/time-picker.d.ts +127 -0
  36. package/dist/types/components/datebox/date-range-box.d.ts +218 -0
  37. package/dist/types/components/datebox/datebox.d.ts +160 -0
  38. package/dist/types/components/datebox/field-adapter.d.ts +5 -0
  39. package/dist/types/components/dialog/dialog.d.ts +284 -0
  40. package/dist/types/components/dropdown/dropdown.d.ts +118 -0
  41. package/dist/types/components/empty-state/empty-state.d.ts +38 -0
  42. package/dist/types/components/field/field.d.ts +231 -0
  43. package/dist/types/components/field-upload/field-adapter.d.ts +5 -0
  44. package/dist/types/components/field-upload/field-upload.d.ts +213 -0
  45. package/dist/types/components/fieldset/fieldset.d.ts +125 -0
  46. package/dist/types/components/finder/finder.d.ts +252 -0
  47. package/dist/types/components/form/form.d.ts +194 -0
  48. package/dist/types/components/groupbox/groupbox.d.ts +89 -0
  49. package/dist/types/components/layout/layout.d.ts +132 -0
  50. package/dist/types/components/loading/inline-loading.d.ts +86 -0
  51. package/dist/types/components/loading/progress-bar.d.ts +168 -0
  52. package/dist/types/components/loading/spinner.d.ts +34 -0
  53. package/dist/types/components/master-panel/master-panel.d.ts +112 -0
  54. package/dist/types/components/menu-button/menu-button.d.ts +142 -0
  55. package/dist/types/components/message/message.d.ts +212 -0
  56. package/dist/types/components/modal/modal.d.ts +105 -0
  57. package/dist/types/components/multi-value-editor/field-adapter.d.ts +5 -0
  58. package/dist/types/components/multi-value-editor/multi-value-editor.d.ts +66 -0
  59. package/dist/types/components/navigation-bar/navigation-bar.d.ts +229 -0
  60. package/dist/types/components/number-field/field-adapter.d.ts +5 -0
  61. package/dist/types/components/number-field/number-field.d.ts +292 -0
  62. package/dist/types/components/pagination/pagination.d.ts +267 -0
  63. package/dist/types/components/panel/panel.d.ts +194 -0
  64. package/dist/types/components/permission/permission.d.ts +93 -0
  65. package/dist/types/components/rating/field-adapter.d.ts +5 -0
  66. package/dist/types/components/rating/rating.d.ts +213 -0
  67. package/dist/types/components/search/search.d.ts +98 -0
  68. package/dist/types/components/select/field-adapter.d.ts +5 -0
  69. package/dist/types/components/select/filter.d.ts +11 -0
  70. package/dist/types/components/select/select.d.ts +235 -0
  71. package/dist/types/components/skeleton/skeleton.d.ts +97 -0
  72. package/dist/types/components/slider/field-adapter.d.ts +5 -0
  73. package/dist/types/components/slider/slider.d.ts +216 -0
  74. package/dist/types/components/split-view/split-view.d.ts +335 -0
  75. package/dist/types/components/stepper/stepper.d.ts +314 -0
  76. package/dist/types/components/stepper/steps.d.ts +131 -0
  77. package/dist/types/components/tabbox/tabbox.d.ts +230 -0
  78. package/dist/types/components/table/sort.d.ts +49 -0
  79. package/dist/types/components/table/table.d.ts +872 -0
  80. package/dist/types/components/tag-picker/field-adapter.d.ts +5 -0
  81. package/dist/types/components/tag-picker/tag-picker.d.ts +375 -0
  82. package/dist/types/components/timebox/field-adapter.d.ts +5 -0
  83. package/dist/types/components/timebox/timebox.d.ts +121 -0
  84. package/dist/types/components/toggle/field-adapter.d.ts +5 -0
  85. package/dist/types/components/toggle/toggle.d.ts +94 -0
  86. package/dist/types/components/toolbar/toolbar.d.ts +230 -0
  87. package/dist/types/components/tooltip/tooltip.d.ts +183 -0
  88. package/dist/types/components/tree/hierarchy.d.ts +113 -0
  89. package/dist/types/components/tree/tree.d.ts +383 -0
  90. package/dist/types/components/truncate/truncate.d.ts +61 -0
  91. package/dist/types/components/value-list/field-adapter.d.ts +5 -0
  92. package/dist/types/components/value-list/value-list.d.ts +143 -0
  93. package/dist/types/core/breakpoint.d.ts +68 -0
  94. package/dist/types/core/component.d.ts +115 -0
  95. package/dist/types/core/date.d.ts +50 -0
  96. package/dist/types/core/dom.d.ts +86 -0
  97. package/dist/types/core/export.d.ts +70 -0
  98. package/dist/types/core/fontawesome.d.ts +107 -0
  99. package/dist/types/core/format.d.ts +107 -0
  100. package/dist/types/core/http.d.ts +122 -0
  101. package/dist/types/core/i18n.d.ts +35 -0
  102. package/dist/types/core/icons.d.ts +137 -0
  103. package/dist/types/core/keyboard.d.ts +72 -0
  104. package/dist/types/core/position.d.ts +45 -0
  105. package/dist/types/core/storage.d.ts +46 -0
  106. package/dist/types/core/util.d.ts +117 -0
  107. package/dist/types/elements/define.d.ts +73 -0
  108. package/dist/types/elements/reflect.d.ts +27 -0
  109. package/dist/types/index.d.ts +79 -0
  110. package/dist/types/zeyos/connect.d.ts +65 -0
  111. package/dist/types/zeyos/form.d.ts +140 -0
  112. package/dist/types/zeyos/icons.d.ts +104 -0
  113. package/dist/types/zeyos/index.d.ts +12 -0
  114. package/dist/types/zeyos/modules.d.ts +110 -0
  115. package/dist/types/zeyos/query.d.ts +157 -0
  116. package/dist/types/zeyos/schema.d.ts +79 -0
  117. package/dist/types/zeyos/select.d.ts +167 -0
  118. package/dist/types/zeyos/table.d.ts +205 -0
  119. package/dist/zx-compat.esm.js +375 -39
  120. package/dist/zx-compat.esm.min.js +1 -1
  121. package/dist/zx-compat.global.js +372 -73
  122. package/dist/zx-compat.global.min.js +3 -3
  123. package/dist/zx-zeyos.esm.js +355 -49
  124. package/dist/zx-zeyos.esm.min.js +1 -1
  125. package/dist/zx.css +68 -6
  126. package/dist/zx.esm.js +359 -73
  127. package/dist/zx.esm.min.js +3 -3
  128. package/dist/zx.global.js +359 -73
  129. package/dist/zx.global.min.js +3 -3
  130. package/dist/zx.min.css +1 -1
  131. package/docs/api.json +48 -2
  132. package/docs/llms.md +31 -0
  133. package/package.json +37 -9
  134. package/src/compat/base.js +291 -0
  135. package/src/compat/globals.js +165 -0
  136. package/src/compat/index.js +84 -0
  137. package/src/compat/map/basic.js +464 -0
  138. package/src/compat/map/data.js +232 -0
  139. package/src/compat/map/date-time.js +177 -0
  140. package/src/compat/map/factory.js +45 -0
  141. package/src/compat/map/form.js +330 -0
  142. package/src/compat/map/helpers.js +46 -0
  143. package/src/compat/map/http.js +117 -0
  144. package/src/compat/map/layout.js +256 -0
  145. package/src/compat/map/options.js +267 -0
  146. package/src/compat/map/select.js +198 -0
  147. package/src/compat/map/stubs.js +26 -0
  148. package/src/compat/map/utilities.js +113 -0
  149. package/src/compat-entry.js +11 -0
  150. package/src/components/badge/badge.css +147 -0
  151. package/src/components/badge/badge.js +92 -0
  152. package/src/components/breadcrumb/breadcrumb.css +105 -0
  153. package/src/components/breadcrumb/breadcrumb.js +331 -0
  154. package/src/components/button/button.css +104 -0
  155. package/src/components/button/button.js +76 -0
  156. package/src/components/check-button/check-button.css +66 -0
  157. package/src/components/check-button/check-button.js +167 -0
  158. package/src/components/checklist/checklist.css +65 -0
  159. package/src/components/checklist/checklist.js +282 -0
  160. package/src/components/checklist/field-adapter.js +46 -0
  161. package/src/components/context-menu/context-menu.css +54 -0
  162. package/src/components/context-menu/context-menu.js +334 -0
  163. package/src/components/copy/copy-button.css +27 -0
  164. package/src/components/copy/copy-button.js +78 -0
  165. package/src/components/copy/copy-input.css +59 -0
  166. package/src/components/copy/copy-input.js +123 -0
  167. package/src/components/data-filter/data-filter.css +53 -0
  168. package/src/components/data-filter/data-filter.js +400 -0
  169. package/src/components/data-filter/filter-core.js +128 -0
  170. package/src/components/date-picker/date-picker.css +181 -0
  171. package/src/components/date-picker/date-picker.js +500 -0
  172. package/src/components/date-picker/date-range-picker.css +264 -0
  173. package/src/components/date-picker/date-range-picker.js +990 -0
  174. package/src/components/date-picker/field-adapter.js +36 -0
  175. package/src/components/date-picker/month-picker.js +223 -0
  176. package/src/components/date-picker/range-field-adapter.js +31 -0
  177. package/src/components/date-picker/time-picker.css +191 -0
  178. package/src/components/date-picker/time-picker.js +406 -0
  179. package/src/components/datebox/date-range-box.css +128 -0
  180. package/src/components/datebox/date-range-box.js +492 -0
  181. package/src/components/datebox/datebox.css +108 -0
  182. package/src/components/datebox/datebox.js +358 -0
  183. package/src/components/datebox/field-adapter.js +34 -0
  184. package/src/components/dialog/dialog.css +74 -0
  185. package/src/components/dialog/dialog.js +450 -0
  186. package/src/components/dropdown/dropdown.css +24 -0
  187. package/src/components/dropdown/dropdown.js +288 -0
  188. package/src/components/empty-state/empty-state.css +80 -0
  189. package/src/components/empty-state/empty-state.js +92 -0
  190. package/src/components/field/field.css +127 -0
  191. package/src/components/field/field.js +605 -0
  192. package/src/components/field-upload/field-adapter.js +21 -0
  193. package/src/components/field-upload/field-upload.css +108 -0
  194. package/src/components/field-upload/field-upload.js +475 -0
  195. package/src/components/fieldset/fieldset.css +37 -0
  196. package/src/components/fieldset/fieldset.js +307 -0
  197. package/src/components/finder/finder.css +98 -0
  198. package/src/components/finder/finder.js +509 -0
  199. package/src/components/form/form.css +32 -0
  200. package/src/components/form/form.js +302 -0
  201. package/src/components/groupbox/groupbox.css +51 -0
  202. package/src/components/groupbox/groupbox.js +199 -0
  203. package/src/components/layout/layout.css +117 -0
  204. package/src/components/layout/layout.js +136 -0
  205. package/src/components/loading/inline-loading.css +34 -0
  206. package/src/components/loading/inline-loading.js +140 -0
  207. package/src/components/loading/progress-bar.css +115 -0
  208. package/src/components/loading/progress-bar.js +259 -0
  209. package/src/components/loading/spinner.css +58 -0
  210. package/src/components/loading/spinner.js +57 -0
  211. package/src/components/master-panel/master-panel.css +51 -0
  212. package/src/components/master-panel/master-panel.js +203 -0
  213. package/src/components/menu-button/menu-button.css +49 -0
  214. package/src/components/menu-button/menu-button.js +294 -0
  215. package/src/components/message/message.css +122 -0
  216. package/src/components/message/message.js +499 -0
  217. package/src/components/modal/modal.css +47 -0
  218. package/src/components/modal/modal.js +203 -0
  219. package/src/components/multi-value-editor/field-adapter.js +44 -0
  220. package/src/components/multi-value-editor/multi-value-editor.css +56 -0
  221. package/src/components/multi-value-editor/multi-value-editor.js +170 -0
  222. package/src/components/navigation-bar/navigation-bar.css +130 -0
  223. package/src/components/navigation-bar/navigation-bar.js +450 -0
  224. package/src/components/number-field/field-adapter.js +40 -0
  225. package/src/components/number-field/number-field.css +77 -0
  226. package/src/components/number-field/number-field.js +545 -0
  227. package/src/components/pagination/pagination.css +95 -0
  228. package/src/components/pagination/pagination.js +481 -0
  229. package/src/components/panel/panel.css +123 -0
  230. package/src/components/panel/panel.js +336 -0
  231. package/src/components/permission/permission.css +47 -0
  232. package/src/components/permission/permission.js +198 -0
  233. package/src/components/rating/field-adapter.js +34 -0
  234. package/src/components/rating/rating.css +97 -0
  235. package/src/components/rating/rating.js +395 -0
  236. package/src/components/search/search.css +70 -0
  237. package/src/components/search/search.js +193 -0
  238. package/src/components/select/field-adapter.js +24 -0
  239. package/src/components/select/filter.js +39 -0
  240. package/src/components/select/select.css +180 -0
  241. package/src/components/select/select.js +663 -0
  242. package/src/components/skeleton/skeleton.css +76 -0
  243. package/src/components/skeleton/skeleton.js +139 -0
  244. package/src/components/slider/field-adapter.js +36 -0
  245. package/src/components/slider/slider.css +181 -0
  246. package/src/components/slider/slider.js +399 -0
  247. package/src/components/split-view/split-view.css +139 -0
  248. package/src/components/split-view/split-view.js +806 -0
  249. package/src/components/stepper/stepper.css +234 -0
  250. package/src/components/stepper/stepper.js +459 -0
  251. package/src/components/stepper/steps.js +131 -0
  252. package/src/components/tabbox/tabbox.css +281 -0
  253. package/src/components/tabbox/tabbox.js +484 -0
  254. package/src/components/table/sort.js +85 -0
  255. package/src/components/table/table.css +364 -0
  256. package/src/components/table/table.js +1711 -0
  257. package/src/components/tag-picker/field-adapter.js +35 -0
  258. package/src/components/tag-picker/tag-picker.css +169 -0
  259. package/src/components/tag-picker/tag-picker.js +837 -0
  260. package/src/components/timebox/field-adapter.js +24 -0
  261. package/src/components/timebox/timebox.css +51 -0
  262. package/src/components/timebox/timebox.js +276 -0
  263. package/src/components/toggle/field-adapter.js +24 -0
  264. package/src/components/toggle/toggle.css +66 -0
  265. package/src/components/toggle/toggle.js +150 -0
  266. package/src/components/toolbar/toolbar.css +81 -0
  267. package/src/components/toolbar/toolbar.js +511 -0
  268. package/src/components/tooltip/tooltip.css +36 -0
  269. package/src/components/tooltip/tooltip.js +420 -0
  270. package/src/components/tree/hierarchy.js +151 -0
  271. package/src/components/tree/tree.css +99 -0
  272. package/src/components/tree/tree.js +784 -0
  273. package/src/components/truncate/truncate.css +20 -0
  274. package/src/components/truncate/truncate.js +92 -0
  275. package/src/components/value-list/field-adapter.js +24 -0
  276. package/src/components/value-list/value-list.css +108 -0
  277. package/src/components/value-list/value-list.js +336 -0
  278. package/src/core/breakpoint.js +150 -0
  279. package/src/core/component.js +292 -0
  280. package/src/core/date.js +216 -0
  281. package/src/core/dom.js +260 -0
  282. package/src/core/export.js +204 -0
  283. package/src/core/fontawesome.js +177 -0
  284. package/src/core/format.js +283 -0
  285. package/src/core/http.js +246 -0
  286. package/src/core/i18n.js +83 -0
  287. package/src/core/icons.js +283 -0
  288. package/src/core/keyboard.js +240 -0
  289. package/src/core/position.js +177 -0
  290. package/src/core/storage.js +175 -0
  291. package/src/core/util.js +336 -0
  292. package/src/elements/define.js +715 -0
  293. package/src/elements/reflect.js +79 -0
  294. package/src/index.js +131 -0
  295. package/src/zeyos/connect.js +142 -0
  296. package/src/zeyos/form.js +254 -0
  297. package/src/zeyos/icons.js +92 -0
  298. package/src/zeyos/index.js +21 -0
  299. package/src/zeyos/modules.js +269 -0
  300. package/src/zeyos/query.js +199 -0
  301. package/src/zeyos/schema.js +474 -0
  302. package/src/zeyos/select.js +175 -0
  303. package/src/zeyos/table.js +272 -0
  304. package/styles/base.css +124 -0
  305. package/styles/icons.css +34 -0
  306. package/styles/tokens/dark.css +84 -0
  307. package/styles/tokens/density.css +9 -0
  308. package/styles/tokens/global.css +80 -0
  309. package/styles/tokens/modules.css +109 -0
  310. package/styles/tokens/semantic.css +49 -0
  311. package/styles/zx.css +61 -0
@@ -0,0 +1,1711 @@
1
+ import { Component } from '../../core/component.js';
2
+ import { breakpoints, onBreakpoint } from '../../core/breakpoint.js';
3
+ import { h } from '../../core/dom.js';
4
+ import { printf } from '../../core/i18n.js';
5
+ import { uid } from '../../core/util.js';
6
+ import { Datebox } from '../datebox/datebox.js';
7
+ import { NumberField } from '../number-field/number-field.js';
8
+ import { Select } from '../select/select.js';
9
+ import { Toggle } from '../toggle/toggle.js';
10
+ import { sortRows } from './sort.js';
11
+
12
+ /** @typedef {Record<string, any>} TableRow */
13
+ /** @typedef {'asc'|'desc'} TableSortDirection */
14
+ /** @typedef {false|'single'|'multi'} TableSelectionMode */
15
+ /** @typedef {false|'cell'|'row'} TableEditMode */
16
+ /** @typedef {'text'|'number'|'select'|'date'|'checkbox'|'textarea'|'custom'} TableEditorType */
17
+ /** @typedef {{value: unknown, label: string}} TableEditorOption */
18
+
19
+ /**
20
+ * Editing handle handed to a custom `column.editor`.
21
+ * @typedef {Object} TableEditorApi
22
+ * @property {unknown} value Value the cell holds when the editor opens.
23
+ * @property {(value: unknown) => void} commit Commits the edit with the given value.
24
+ * @property {() => void} cancel Cancels the edit and restores the cell.
25
+ * @property {TableRow} row Row being edited.
26
+ * @property {TableColumn} column Column being edited.
27
+ */
28
+
29
+ /**
30
+ * @typedef {Object} TableColumn
31
+ * @property {string} id Row property and stable column identifier.
32
+ * @property {string} label Visible header label.
33
+ * @property {boolean} [sortable=false] Whether the header changes the active sort.
34
+ * @property {string} [width='auto'] CSS width, `auto`, or an `fr` proportion. `fr` values are
35
+ * converted into proportional percentages on `<col>` elements and enable `table-layout: fixed`;
36
+ * this preserves a single aligned header/body table without measuring layout in JavaScript.
37
+ * @property {'start'|'center'|'end'|'left'|'right'} [align='start'] Cell alignment.
38
+ * @property {(row: TableRow, rowIndex: number) => Node|string|number|null|undefined} [render]
39
+ * Cell renderer. Non-Node results are inserted as text.
40
+ * @property {(row: TableRow) => unknown} [sortValue] Sort-value accessor.
41
+ * @property {string} [headerTitle] Header tooltip.
42
+ * @property {boolean} [popin=true] While stacked, whether this column becomes a labelled line.
43
+ * `false` keeps it in the card's headline — the identifying columns usually want that.
44
+ * @property {boolean|TableEditorType|((row: TableRow) => boolean|TableEditorType)} [editable=false]
45
+ * Editor for this column while the table runs with `editMode`. `true` means `'text'`; a function is
46
+ * evaluated per row so individual rows stay read-only.
47
+ * @property {TableEditorOption[]|Record<string, string>|((row: TableRow) => TableEditorOption[]|Record<string, string>)} [options]
48
+ * Choices for an `'select'` editor: `[{value, label}]`, a `{value: label}` map, or a row callback.
49
+ * @property {Record<string, unknown>} [editorProps] Options forwarded to the underlying editor
50
+ * component (NumberField, Select, Datebox, Toggle) or to the generated `<input>`/`<textarea>`.
51
+ * @property {(row: TableRow, api: TableEditorApi) => Node|{toElement: () => Node, destroy?: () => void}} [editor]
52
+ * Fully custom editor. Wins over `editable`, except that a per-row `editable` function returning
53
+ * `false` still marks the cell read-only. A returned Zx component is destroyed with the edit.
54
+ * @property {(raw: unknown, row: TableRow) => unknown} [parse] Converts the editor's raw output into
55
+ * the value written back into the row.
56
+ * @property {(value: unknown, row: TableRow) => string} [format] Renders the value as the initial
57
+ * text of a `'text'` or `'textarea'` editor.
58
+ * @property {(value: unknown, row: TableRow) => true|string} [validate] Commit guard. A returned
59
+ * string marks the cell invalid, shows the message, and refuses the commit.
60
+ */
61
+
62
+ /**
63
+ * @typedef {Object} TableOptions
64
+ * @property {TableColumn[]} [columns=[]] Column definitions.
65
+ * @property {TableRow[]} [data=[]] Initial rows.
66
+ * @property {string|((row: TableRow) => unknown)} [rowId='ID'] Row id accessor.
67
+ * @property {{id: string, dir: TableSortDirection}|null} [sort=null] Initial sort.
68
+ * @property {'local'|'server'} [sortMode='local'] Whether sorting reorders local rows.
69
+ * @property {TableSelectionMode} [selectable=false] Row selection mode.
70
+ * @property {boolean} [stickyHeader=true] Whether column headers stick while scrolling.
71
+ * @property {false|number} [growing=false] Number of rows to render at first, with a control that
72
+ * appends the next batch on demand. A result set an ERP query returns happily — tens of thousands
73
+ * of rows — costs more to lay out than anyone will ever scroll through.
74
+ * @property {false|'sm'|'md'|'lg'|'xl'} [responsive=false] Width below which each row stacks into a
75
+ * card of label/value pairs instead of scrolling sideways. Measured on the table's own container,
76
+ * not the viewport, so a table inside a split pane stacks when *it* is narrow.
77
+ * @property {number|string|null} [height=null] Scroll-region height in pixels or CSS units.
78
+ * @property {string|Node|(() => Node|string)|null} [emptyText=null] What to show when there are no
79
+ * rows; null resolves `table.empty`. A Node — an `emptyState()` with an action in it, say — is
80
+ * placed as given, and a function is called on each render, which is what to use when the
81
+ * placeholder holds live controls.
82
+ * @property {((row: TableRow) => string)|null} [rowClass=null] Additional row class callback.
83
+ * @property {boolean} [zebra=true] Whether alternate rows use the zebra background.
84
+ * @property {TableEditMode} [editMode=false] Inline editing mode. `'cell'` edits one cell, `'row'`
85
+ * opens every editable cell of the row together and commits them as a unit. Editing is completely
86
+ * inert while this is `false`.
87
+ * @property {(event: CustomEvent<TableRowClickDetail>) => void} [onrowclick]
88
+ * @property {(event: CustomEvent<TableRowClickDetail>) => void} [onrowdblclick]
89
+ * @property {(event: CustomEvent<TableSortDetail>) => void} [onsort]
90
+ * @property {(event: CustomEvent<TableSelectionDetail>) => void} [onselectionchange]
91
+ * @property {(event: CustomEvent<TableDataDetail>) => void} [ondatachange]
92
+ * @property {(event: CustomEvent<{stacked: boolean}>) => void} [onstackedchange] Fired when the
93
+ * table crosses its `responsive` width in either direction.
94
+ * @property {(event: CustomEvent<{rendered: number, total: number}>) => void} [ongrow] Fired after
95
+ * more rows are rendered.
96
+ * @property {(event: CustomEvent<TableEditStartDetail>) => void} [oneditstart]
97
+ * @property {(event: CustomEvent<TableEditCommitDetail>) => void} [oneditcommit]
98
+ * @property {(event: CustomEvent<TableEditCancelDetail>) => void} [oneditcancel]
99
+ * @property {(event: CustomEvent<TableEditInvalidDetail>) => void} [oneditinvalid]
100
+ */
101
+
102
+ /**
103
+ * @typedef {Object} TableRowClickDetail
104
+ * @property {TableRow} row
105
+ * @property {unknown} id
106
+ * @property {number} index
107
+ * @property {Event} event
108
+ */
109
+
110
+ /** @typedef {{id: string, dir: TableSortDirection}} TableSortDetail */
111
+ /** @typedef {{rows: TableRow[], ids: unknown[]}} TableSelectionDetail */
112
+ /** @typedef {{rows: TableRow[]}} TableDataDetail */
113
+
114
+ /**
115
+ * @typedef {Object} TableEditStartDetail
116
+ * @property {TableRow} row Row being edited.
117
+ * @property {unknown} id Row id.
118
+ * @property {TableColumn|null} column Focused column.
119
+ * @property {string} columnId Focused column id.
120
+ * @property {unknown} value Value the editor opened with.
121
+ */
122
+
123
+ /**
124
+ * @typedef {Object} TableEditCommitDetail
125
+ * @property {TableRow} row Row as it is before the commit.
126
+ * @property {unknown} id Row id.
127
+ * @property {TableColumn|null} column Focused column.
128
+ * @property {string} columnId Focused column id.
129
+ * @property {unknown} value Committed value of the focused column.
130
+ * @property {unknown} previous Previous value of the focused column.
131
+ * @property {Record<string, unknown>} changes Every changed cell of the row, keyed by column id.
132
+ */
133
+
134
+ /**
135
+ * @typedef {Object} TableEditCancelDetail
136
+ * @property {TableRow} row Row that was being edited.
137
+ * @property {unknown} id Row id.
138
+ * @property {TableColumn|null} column Focused column.
139
+ * @property {string} columnId Focused column id.
140
+ */
141
+
142
+ /**
143
+ * @typedef {Object} TableEditInvalidDetail
144
+ * @property {TableRow} row Row being edited.
145
+ * @property {unknown} id Row id.
146
+ * @property {TableColumn|null} column Rejected column.
147
+ * @property {string} columnId Rejected column id.
148
+ * @property {unknown} value Rejected value.
149
+ * @property {string} message Validation message.
150
+ */
151
+
152
+ /**
153
+ * Internal per-cell editor bookkeeping.
154
+ * @typedef {Object} TableEditorEntry
155
+ * @property {TableColumn} column Edited column.
156
+ * @property {TableEditorType} type Resolved editor type.
157
+ * @property {HTMLTableCellElement} td Edited cell.
158
+ * @property {HTMLElement} host Element the editor lives in.
159
+ * @property {HTMLElement|null} control Focusable control inside the editor.
160
+ * @property {{destroy: () => void}|null} component Zx component instance, when the editor uses one.
161
+ * @property {() => unknown} read Reads the editor's current value.
162
+ * @property {() => void} destroy Destroys the editor and removes its DOM.
163
+ */
164
+
165
+ /** Editor types accepted by `column.editable`. */
166
+ const EDITOR_TYPES = new Set(['text', 'number', 'select', 'date', 'checkbox', 'textarea']);
167
+ /** Selector used to find the focusable control inside a custom editor. */
168
+ const FOCUSABLE = 'input, textarea, select, button, [tabindex]:not([tabindex="-1"])';
169
+
170
+ /**
171
+ * Semantic, sortable data table with optional single or checkbox-based multi-selection and
172
+ * opt-in inline cell or row editing.
173
+ * @fires Table#rowclick
174
+ * @fires Table#rowdblclick
175
+ * @fires Table#sort
176
+ * @fires Table#selectionchange
177
+ * @fires Table#datachange
178
+ * @fires Table#editstart
179
+ * @fires Table#editcommit
180
+ * @fires Table#editcancel
181
+ * @fires Table#editinvalid
182
+ * @extends {Component<TableOptions>}
183
+ */
184
+ export class Table extends Component {
185
+ static cssName = 'table';
186
+
187
+ /** @type {TableOptions} */
188
+ static defaults = {
189
+ columns: [],
190
+ data: [],
191
+ rowId: 'ID',
192
+ sort: null,
193
+ sortMode: 'local',
194
+ selectable: false,
195
+ stickyHeader: true,
196
+ responsive: false,
197
+ growing: false,
198
+ height: null,
199
+ emptyText: null,
200
+ rowClass: null,
201
+ zebra: true,
202
+ editMode: false
203
+ };
204
+
205
+ /**
206
+ * Creates a table around a target or creates a new root when target is null.
207
+ * @param {Element|string|null} target Existing root, selector, or null.
208
+ * @param {TableOptions} [options={}] Table options.
209
+ */
210
+ constructor(target, options = {}) {
211
+ super(target, options);
212
+ }
213
+
214
+ /** @returns {HTMLElement} Table root. */
215
+ render() {
216
+ const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
217
+ // Claimed here, not left to the base constructor: `_watchWidth()` below observes `this.el`,
218
+ // and until this assignment it is null for a component creating its own root.
219
+ this.el = root;
220
+ this._originalChildren = this.el ? Array.from(this.el.childNodes) : null;
221
+ this._columns = Array.isArray(this.options.columns) ? this.options.columns.map((column) => ({ ...column })) : [];
222
+ this._data = Array.isArray(this.options.data) ? [...this.options.data] : [];
223
+ this._selected = new Set();
224
+ this._selectionAnchorId = null;
225
+ this._sort = normalizeSort(this.options.sort, this._columns);
226
+ this._rowMeta = new WeakMap();
227
+ this._rowElements = new Map();
228
+ this._restored = false;
229
+ // Editing state. `_edit` is null unless a cell or row is currently open for editing; every
230
+ // other member below is only ever touched while `_editMode !== false`.
231
+ this._editMode = normalizeEditMode(this.options.editMode);
232
+ this._edit = null;
233
+ this._editMessage = null;
234
+ this._stacked = false;
235
+ this._width = null;
236
+ this._growStep = growStep(this.options.growing);
237
+ this._rendered = this._growStep ?? Infinity;
238
+ this._more = null;
239
+ this._rovingKey = null;
240
+ this._rovingCell = null;
241
+ this._rovingMatched = false;
242
+
243
+ if (this._sort && this.options.sortMode === 'local') this._sortData();
244
+
245
+ this._table = h('table', { class: 'zx-table__table' });
246
+ this._colgroup = h('colgroup', { class: 'zx-table__columns' });
247
+ this._thead = h('thead', { class: 'zx-table__head' });
248
+ this._tbody = h('tbody', { class: 'zx-table__body' });
249
+ this._table.append(this._colgroup, this._thead, this._tbody);
250
+
251
+ this._scroll = h('div', {
252
+ class: 'zx-table__scroll',
253
+ dataset: {
254
+ stickyHeader: String(Boolean(this.options.stickyHeader)),
255
+ zebra: String(Boolean(this.options.zebra))
256
+ }
257
+ }, this._table);
258
+ if (this.options.height != null) {
259
+ this._scroll.style.blockSize = typeof this.options.height === 'number' ?
260
+ `${this.options.height}px` : String(this.options.height);
261
+ }
262
+ if (this._editMode === false) {
263
+ root.replaceChildren(this._scroll);
264
+ } else {
265
+ root.dataset.editMode = this._editMode;
266
+ // Always present and empty rather than hidden: a live region that is revealed together with
267
+ // its text is frequently missed by screen readers.
268
+ this._editMessage = h('div', {
269
+ class: 'zx-table__message',
270
+ id: uid('zx-table-message'),
271
+ role: 'status',
272
+ ariaLive: 'polite'
273
+ });
274
+ root.replaceChildren(this._scroll, this._editMessage);
275
+ }
276
+
277
+ this._renderColumns();
278
+ this._renderHeader();
279
+ this._renderBody();
280
+ this._watchWidth();
281
+ this.listen(this._tbody, 'click', (event) => this._handleBodyClick(event));
282
+ this.listen(this._tbody, 'dblclick', (event) => this._handleBodyDoubleClick(event));
283
+ if (this._editMode !== false) {
284
+ this.listen(this._tbody, 'keydown', (event) => this._handleEditKeydown(/** @type {KeyboardEvent} */ (event)));
285
+ this.listen(this._tbody, 'focusin', (event) => this._handleEditFocusIn(event));
286
+ this.listen(document, 'pointerdown', (event) => this._handleDocumentPointerDown(event), { capture: true });
287
+ }
288
+ return root;
289
+ }
290
+
291
+ /**
292
+ * Toggles a loading state. While loading, the body is dimmed, an indeterminate progress bar is
293
+ * shown, and `aria-busy` is set — a lightweight take on the busy/skeleton pattern used by
294
+ * enterprise tables (SAP UI5, Salesforce SLDS). Call `setLoading(false)` (or `setData`) when done.
295
+ * @param {boolean} [loading=true] Whether the table is loading.
296
+ * @returns {this}
297
+ */
298
+ setLoading(loading = true) {
299
+ if (loading) this.el.dataset.loading = 'true';
300
+ else delete this.el.dataset.loading;
301
+ this.el.setAttribute('aria-busy', String(Boolean(loading)));
302
+ return this;
303
+ }
304
+
305
+ /**
306
+ * Replaces all rows.
307
+ * @param {TableRow[]} rows New rows.
308
+ * @returns {this}
309
+ * @fires Table#datachange
310
+ * @fires Table#editcancel
311
+ */
312
+ setData(rows) {
313
+ // New data means a new result set; whatever the reader had grown to belonged to the old one.
314
+ this._rendered = this._growStep ?? Infinity;
315
+ this.setLoading(false);
316
+ assertRows(rows);
317
+ this._data = [...rows];
318
+ if (this._sort && this.options.sortMode === 'local') this._sortData();
319
+ const selectionChanged = this._pruneSelection();
320
+ this._renderBody();
321
+ if (selectionChanged) this._emitSelectionChange();
322
+ this._emitDataChange();
323
+ return this;
324
+ }
325
+
326
+ /**
327
+ * Appends rows to the current data.
328
+ * @param {TableRow[]} rows Rows to append.
329
+ * @returns {this}
330
+ * @fires Table#datachange
331
+ * @fires Table#editcancel
332
+ */
333
+ addData(rows) {
334
+ assertRows(rows);
335
+ this._data.push(...rows);
336
+ if (this._sort && this.options.sortMode === 'local') this._sortData();
337
+ this._renderBody();
338
+ this._emitDataChange();
339
+ return this;
340
+ }
341
+
342
+ /**
343
+ * Replaces the row identified by its current id.
344
+ * @param {unknown} id Existing row id.
345
+ * @param {TableRow} row Replacement row.
346
+ * @returns {this}
347
+ * @fires Table#datachange
348
+ * @fires Table#editcancel
349
+ */
350
+ updateRow(id, row) {
351
+ const index = this._data.findIndex((candidate) => Object.is(this._idFor(candidate), id));
352
+ if (index < 0) return this;
353
+ const wasSelected = this._selected.delete(id);
354
+ this._data[index] = row;
355
+ if (wasSelected) this._selected.add(this._idFor(row));
356
+ if (this._sort && this.options.sortMode === 'local') this._sortData();
357
+ this._renderBody();
358
+ if (wasSelected && !Object.is(id, this._idFor(row))) this._emitSelectionChange();
359
+ this._emitDataChange();
360
+ return this;
361
+ }
362
+
363
+ /**
364
+ * Removes the row with the given id.
365
+ * @param {unknown} id Row id.
366
+ * @returns {this}
367
+ * @fires Table#selectionchange
368
+ * @fires Table#datachange
369
+ * @fires Table#editcancel
370
+ */
371
+ removeRow(id) {
372
+ const index = this._data.findIndex((candidate) => Object.is(this._idFor(candidate), id));
373
+ if (index < 0) return this;
374
+ this._data.splice(index, 1);
375
+ const selectionChanged = this._selected.delete(id);
376
+ if (Object.is(this._selectionAnchorId, id)) this._selectionAnchorId = null;
377
+ this._renderBody();
378
+ if (selectionChanged) this._emitSelectionChange();
379
+ this._emitDataChange();
380
+ return this;
381
+ }
382
+
383
+ /**
384
+ * Returns a row by id.
385
+ * @param {unknown} id Row id.
386
+ * @returns {TableRow|null} Matching row or null.
387
+ */
388
+ getRow(id) {
389
+ return this._data.find((row) => Object.is(this._idFor(row), id)) ?? null;
390
+ }
391
+
392
+ /** @returns {TableRow[]} Shallow copy of current rows in display order. */
393
+ getData() {
394
+ return [...this._data];
395
+ }
396
+
397
+ /**
398
+ * Removes all rows.
399
+ * @returns {this}
400
+ * @fires Table#selectionchange
401
+ * @fires Table#datachange
402
+ * @fires Table#editcancel
403
+ */
404
+ empty() {
405
+ const selectionChanged = this._selected.size > 0;
406
+ this._data = [];
407
+ this._selected.clear();
408
+ this._selectionAnchorId = null;
409
+ this._renderBody();
410
+ if (selectionChanged) this._emitSelectionChange();
411
+ this._emitDataChange();
412
+ return this;
413
+ }
414
+
415
+ /**
416
+ * Activates a column sort. Local mode stably reorders data; server mode only updates the
417
+ * header state and emits the request event.
418
+ * @param {string} id Column id.
419
+ * @param {TableSortDirection} [dir] Direction; omitted toggles the current column.
420
+ * @param {{silent?: boolean}} [options={}] Event options.
421
+ * @returns {this}
422
+ * @fires Table#sort
423
+ * @fires Table#editcancel
424
+ */
425
+ setSort(id, dir, options = {}) {
426
+ // Server mode never re-renders the body, so the open editor is dropped here rather than in
427
+ // `_renderBody()`.
428
+ this._abortEdit({ focus: false });
429
+ const column = this._columns.find((candidate) => candidate.id === id);
430
+ if (!column) throw new RangeError(`Unknown table column: ${id}`);
431
+ const direction = dir ?? (this._sort?.id === id && this._sort.dir === 'asc' ? 'desc' : 'asc');
432
+ if (direction !== 'asc' && direction !== 'desc') {
433
+ throw new TypeError('Table sort direction must be "asc" or "desc"');
434
+ }
435
+ this._sort = { id, dir: direction };
436
+ if (this.options.sortMode === 'local') {
437
+ this._sortData();
438
+ this._renderBody();
439
+ }
440
+ this._syncSortHeader();
441
+ if (!options.silent) this.emit('sort', { id, dir: direction });
442
+ return this;
443
+ }
444
+
445
+ /** @returns {TableRow[]} Selected rows in current display order. */
446
+ getSelection() {
447
+ return this._data.filter((row) => this._selected.has(this._idFor(row)));
448
+ }
449
+
450
+ /**
451
+ * Replaces selected ids, ignoring ids absent from the current data.
452
+ * @param {unknown[]} ids Row ids.
453
+ * @returns {this}
454
+ * @fires Table#selectionchange
455
+ */
456
+ setSelection(ids) {
457
+ if (!Array.isArray(ids)) throw new TypeError('Table selection must be an array of row ids');
458
+ const valid = new Set(this._data.map((row) => this._idFor(row)));
459
+ const next = new Set();
460
+ if (this.options.selectable !== false) {
461
+ for (const id of ids) {
462
+ if (valid.has(id)) next.add(id);
463
+ if (this.options.selectable === 'single' && next.size > 0) break;
464
+ }
465
+ }
466
+ if (sameSet(this._selected, next)) return this;
467
+ this._selected = next;
468
+ this._syncSelection();
469
+ this._emitSelectionChange();
470
+ return this;
471
+ }
472
+
473
+ /**
474
+ * Clears all selected rows.
475
+ * @returns {this}
476
+ * @fires Table#selectionchange
477
+ */
478
+ clearSelection() {
479
+ if (this._selected.size === 0) return this;
480
+ this._selected.clear();
481
+ this._selectionAnchorId = null;
482
+ this._syncSelection();
483
+ this._emitSelectionChange();
484
+ return this;
485
+ }
486
+
487
+ /**
488
+ * Opens the editor for a cell. No-op unless the table runs with `editMode` and the column
489
+ * resolves to an editable type for that row. In `'row'` mode every editable cell of the row is
490
+ * opened and `columnId` only decides which editor receives focus. Any other open edit is
491
+ * committed first.
492
+ * @param {unknown} rowId Row id.
493
+ * @param {string} columnId Column id.
494
+ * @returns {this}
495
+ * @fires Table#editstart
496
+ */
497
+ startEdit(rowId, columnId) {
498
+ if (this._editMode === false) return this;
499
+ const column = this._columns.find((candidate) => candidate.id === columnId);
500
+ if (!column) return this;
501
+ const current = this._data.find((candidate) => Object.is(this._idFor(candidate), rowId));
502
+ if (current === undefined || resolveEditable(column, current) === false) return this;
503
+
504
+ if (this._edit) {
505
+ if (Object.is(this._edit.id, rowId) && this._edit.cells.has(columnId)) {
506
+ this._edit.columnId = columnId;
507
+ this._focusEditor(columnId);
508
+ return this;
509
+ }
510
+ this.commitEdit();
511
+ // A rejected or prevented commit keeps the previous editor open and wins over the new one.
512
+ if (this._edit) return this;
513
+ }
514
+
515
+ // Resolve the row again: the commit above may have rewritten and re-sorted the data.
516
+ const index = this._data.findIndex((candidate) => Object.is(this._idFor(candidate), rowId));
517
+ if (index < 0) return this;
518
+ const row = this._data[index];
519
+ const tr = this._rowElements.get(rowId)?.[0];
520
+ if (!tr) return this;
521
+ const targets = this._editMode === 'row'
522
+ ? this._columns.filter((candidate) => resolveEditable(candidate, row) !== false)
523
+ : [column];
524
+ /** @type {Map<string, TableEditorEntry>} */
525
+ const cells = new Map();
526
+ for (const target of targets) {
527
+ const td = cellIn(tr, target.id);
528
+ if (!td) continue;
529
+ const entry = this._createEditor(td, target, row, index);
530
+ if (entry) cells.set(target.id, entry);
531
+ }
532
+ if (cells.size === 0) return this;
533
+
534
+ this._edit = { id: rowId, columnId, row, cells, tr };
535
+ tr.dataset.editing = 'true';
536
+ this.emit('editstart', { row, id: rowId, column, columnId, value: row?.[columnId] });
537
+ this._focusEditor(columnId);
538
+ return this;
539
+ }
540
+
541
+ /**
542
+ * Commits the open edit. Validation runs first: the first column whose `validate` rejects marks
543
+ * its cell invalid, emits `editinvalid`, and leaves the editor open. A listener may
544
+ * `preventDefault()` the `editcommit` event — e.g. while a server round-trip is pending — which
545
+ * also keeps the editor open. Otherwise every changed cell is written back through `updateRow()`,
546
+ * so `datachange` fires exactly as it does for a programmatic row update.
547
+ * @returns {this}
548
+ * @fires Table#editcommit
549
+ * @fires Table#editinvalid
550
+ * @fires Table#datachange
551
+ */
552
+ commitEdit() {
553
+ const edit = this._edit;
554
+ if (!edit) return this;
555
+ const row = this.getRow(edit.id) ?? edit.row;
556
+ /** @type {Record<string, unknown>} */
557
+ const values = {};
558
+ for (const [columnId, entry] of edit.cells) values[columnId] = entry.read();
559
+
560
+ const editedColumns = this._columns.filter((column) => edit.cells.has(column.id));
561
+ const failure = firstValidationError(values, editedColumns, row, this._msgOr('table.invalid', 'Invalid value'));
562
+ if (failure) {
563
+ this._markInvalid(failure.columnId, failure.message);
564
+ edit.columnId = failure.columnId;
565
+ this.emit('editinvalid', {
566
+ row,
567
+ id: edit.id,
568
+ column: this._columns.find((column) => column.id === failure.columnId) ?? null,
569
+ columnId: failure.columnId,
570
+ value: values[failure.columnId],
571
+ message: failure.message
572
+ });
573
+ this._focusEditor(failure.columnId);
574
+ return this;
575
+ }
576
+ this._clearInvalid();
577
+
578
+ const changes = diffChanges(row, values);
579
+ const columnId = edit.columnId;
580
+ const event = this.emit('editcommit', {
581
+ row,
582
+ id: edit.id,
583
+ column: this._columns.find((column) => column.id === columnId) ?? null,
584
+ columnId,
585
+ value: values[columnId],
586
+ previous: row?.[columnId],
587
+ changes
588
+ });
589
+ if (event.defaultPrevented) return this;
590
+
591
+ const restoreFocus = this._editHasFocus();
592
+ const updated = { ...row, ...changes };
593
+ this._teardownEdit();
594
+ if (Object.keys(changes).length > 0) this.updateRow(edit.id, updated);
595
+ // Read the id back off the updated row: editing the id column itself moves the row's identity.
596
+ if (restoreFocus) this._focusCell(this._idFor(updated), columnId);
597
+ return this;
598
+ }
599
+
600
+ /**
601
+ * Closes the open edit without writing anything back and returns focus to the cell.
602
+ * @returns {this}
603
+ * @fires Table#editcancel
604
+ */
605
+ cancelEdit() {
606
+ this._abortEdit({ focus: true });
607
+ return this;
608
+ }
609
+
610
+ /** @returns {boolean} Whether a cell or row is currently open for editing. */
611
+ isEditing() {
612
+ return this._edit !== null;
613
+ }
614
+
615
+ /** @returns {{id: unknown, columnId: string}|null} Row id and focused column id, or null. */
616
+ getEditing() {
617
+ return this._edit ? { id: this._edit.id, columnId: this._edit.columnId } : null;
618
+ }
619
+
620
+ /**
621
+ * The rows currently rendered — all of them unless `growing` is on.
622
+ * @returns {TableRow[]}
623
+ */
624
+ _visibleData() {
625
+ return this._rendered === Infinity ? this._data : this._data.slice(0, this._rendered);
626
+ }
627
+
628
+ /**
629
+ * Renders the next batch of rows.
630
+ * @param {number} [count] How many to add; defaults to the configured batch size.
631
+ * @returns {this}
632
+ * @fires Table#grow
633
+ */
634
+ growBy(count) {
635
+ if (!this._growStep) return this;
636
+ const step = Number(count) > 0 ? Math.trunc(Number(count)) : this._growStep;
637
+ const before = this._rendered;
638
+ this._rendered = Math.min(this._data.length, this._rendered + step);
639
+ if (this._rendered === before) return this;
640
+ this._renderBody();
641
+ this.emit('grow', { rendered: this._rendered, total: this._data.length });
642
+ return this;
643
+ }
644
+
645
+ /**
646
+ * Renders every remaining row.
647
+ * @returns {this}
648
+ * @fires Table#grow
649
+ */
650
+ showAll() {
651
+ return this.growBy(this._data.length);
652
+ }
653
+
654
+ /**
655
+ * How many rows are currently rendered.
656
+ * @returns {number}
657
+ */
658
+ getRenderedCount() {
659
+ return Math.min(this._rendered, this._data.length);
660
+ }
661
+
662
+ /**
663
+ * Keeps the "show more" control in step with what is rendered.
664
+ *
665
+ * The control sits outside the table rather than in a `<tfoot>` row spanning every column: it is
666
+ * a command, not data, and a button inside the grid would be reachable by the row navigation
667
+ * keys as though it were a cell.
668
+ * @returns {void}
669
+ */
670
+ _syncGrowing() {
671
+ if (!this._growStep) return;
672
+ const remaining = this._data.length - this.getRenderedCount();
673
+ if (remaining <= 0) {
674
+ this._more?.remove();
675
+ this._more = null;
676
+ return;
677
+ }
678
+ const label = this._message('table.more', 'Show %1 more', [remaining]);
679
+ if (!this._more) {
680
+ this._more = h('button', {
681
+ class: 'zx-table__more',
682
+ type: 'button',
683
+ onclick: () => this.growBy()
684
+ });
685
+ this.el.append(this._more);
686
+ }
687
+ this._more.textContent = label;
688
+ // Announced as a count rather than a bare "show more", so the size of what is left is audible.
689
+ this._more.setAttribute('aria-label', label);
690
+ }
691
+
692
+ /**
693
+ * Resolves a localized message, falling back to a built-in template.
694
+ * @param {string} key Message key.
695
+ * @param {string} fallback Template used when the host has no translation.
696
+ * @param {unknown[]} args Positional interpolation values.
697
+ * @returns {string}
698
+ */
699
+ _message(key, fallback, args) {
700
+ const message = this.msg(key, ...args);
701
+ return message === key ? printf(fallback, args) : message;
702
+ }
703
+
704
+ /**
705
+ * Watches the table's own width and stacks the rows below `responsive`.
706
+ *
707
+ * The width comes from `onBreakpoint` observing this element rather than the window, which is the
708
+ * whole point of the option: a table inside a split pane or a modal has no idea how wide the
709
+ * viewport is, and a media query would stack it on a phone while leaving it unreadable in a
710
+ * 320px pane on a desktop.
711
+ * @returns {void}
712
+ */
713
+ _watchWidth() {
714
+ this._width?.destroy();
715
+ this._width = null;
716
+ const threshold = this.options.responsive;
717
+ if (!threshold || !(threshold in breakpoints)) return;
718
+ this._width = onBreakpoint(
719
+ (_name, width) => this._setStacked(width > 0 && width < breakpoints[threshold]),
720
+ { target: this.el }
721
+ );
722
+ }
723
+
724
+ /**
725
+ * Enters or leaves stacked mode.
726
+ * @param {boolean} stacked Whether rows should stack.
727
+ * @returns {void}
728
+ * @fires Table#stackedchange
729
+ */
730
+ _setStacked(stacked) {
731
+ if (this._stacked === stacked) return;
732
+ this._stacked = stacked;
733
+ this.el.toggleAttribute('data-stacked', stacked);
734
+ this._applyStackedRoles(stacked);
735
+ this.emit('stackedchange', { stacked });
736
+ }
737
+
738
+ /**
739
+ * States the table roles explicitly while stacked, and removes them again on the way out.
740
+ *
741
+ * Stacking works by changing `display` on the table elements, and a table whose display is not
742
+ * `table` loses its implicit ARIA roles — leaving a screen reader with a pile of generic blocks
743
+ * where a table used to be. The roles are only asserted while the layout has taken them away.
744
+ * @param {boolean} stacked Whether the roles should be present.
745
+ * @returns {void}
746
+ */
747
+ _applyStackedRoles(stacked) {
748
+ /** @type {Array<[Element|null, string]>} */
749
+ const parts = [[this._table, 'table'], [this._thead, 'rowgroup'], [this._tbody, 'rowgroup']];
750
+ for (const [element, role] of parts) {
751
+ if (!element) continue;
752
+ if (stacked) element.setAttribute('role', role);
753
+ else element.removeAttribute('role');
754
+ }
755
+ for (const [selector, role] of [['tr', 'row'], ['td', 'cell'], ['th', 'columnheader']]) {
756
+ for (const element of this._table.querySelectorAll(selector)) {
757
+ if (stacked) element.setAttribute('role', role);
758
+ else element.removeAttribute('role');
759
+ }
760
+ }
761
+ }
762
+
763
+ /**
764
+ * Reports whether the rows are currently stacked.
765
+ * @returns {boolean}
766
+ */
767
+ isStacked() {
768
+ return Boolean(this._stacked);
769
+ }
770
+
771
+ /** Restores an enhanced target and releases all component listeners. @returns {void} */
772
+ destroy() {
773
+ this._teardownEdit();
774
+ this._width?.destroy();
775
+ this._width = null;
776
+ if (this.el) delete this.el.dataset.editMode;
777
+ if (!this._restored && this._originalChildren) {
778
+ this.el.replaceChildren(...this._originalChildren);
779
+ this._restored = true;
780
+ }
781
+ super.destroy();
782
+ }
783
+
784
+ /** @returns {void} */
785
+ _renderColumns() {
786
+ const fragment = document.createDocumentFragment();
787
+ if (this.options.selectable === 'multi') {
788
+ fragment.append(h('col', { class: 'zx-table__selection-column' }));
789
+ }
790
+ const fractions = this._columns.map((column) => parseFraction(column.width));
791
+ const total = fractions.reduce((sum, value) => sum + value, 0);
792
+ this._table.dataset.layout = total > 0 ? 'fixed' : 'auto';
793
+ this._columns.forEach((column, index) => {
794
+ const col = h('col');
795
+ if (fractions[index] > 0) col.style.width = `${fractions[index] / total * 100}%`;
796
+ else if (column.width && column.width !== 'auto') col.style.width = String(column.width);
797
+ fragment.append(col);
798
+ });
799
+ this._colgroup.replaceChildren(fragment);
800
+ }
801
+
802
+ /** @returns {void} */
803
+ _renderHeader() {
804
+ const row = h('tr', { class: 'zx-table__header-row' });
805
+ if (this._growStep) row.setAttribute('aria-rowindex', '1');
806
+ if (this.options.selectable === 'multi') {
807
+ this._selectAll = h('input', {
808
+ class: 'zx-table__checkbox zx-table__select-all',
809
+ type: 'checkbox',
810
+ ariaLabel: 'Select all rows'
811
+ });
812
+ const selectionHeader = h('th', {
813
+ class: 'zx-table__selection-header',
814
+ scope: 'col'
815
+ }, this._selectAll);
816
+ row.append(selectionHeader);
817
+ this.listen(this._selectAll, 'click', () => {
818
+ /*
819
+ * The rendered rows, not every row in the data. With `growing` on, selecting a hundred
820
+ * records because the reader ticked a box above the ten they can see is a trap — the next
821
+ * click is usually a bulk action. `showAll()` first if you mean all of them.
822
+ */
823
+ if (this._selectAll.checked) {
824
+ for (const dataRow of this._visibleData()) this._selected.add(this._idFor(dataRow));
825
+ } else {
826
+ for (const dataRow of this._visibleData()) this._selected.delete(this._idFor(dataRow));
827
+ }
828
+ this._selectionAnchorId = null;
829
+ this._syncSelection();
830
+ this._emitSelectionChange();
831
+ });
832
+ } else {
833
+ this._selectAll = null;
834
+ }
835
+
836
+ this._headers = new Map();
837
+ for (const column of this._columns) {
838
+ const th = h('th', { scope: 'col' });
839
+ if (column.align) th.dataset.align = column.align;
840
+ if (column.headerTitle) th.title = column.headerTitle;
841
+ if (column.sortable) {
842
+ const button = h('button', {
843
+ class: 'zx-table__sort-button',
844
+ type: 'button'
845
+ }, h('span', { class: 'zx-table__header-label' }, column.label));
846
+ this.listen(button, 'click', () => {
847
+ const direction = this._sort?.id === column.id && this._sort.dir === 'asc' ? 'desc' : 'asc';
848
+ this.setSort(column.id, direction);
849
+ });
850
+ th.append(button);
851
+ } else {
852
+ th.append(h('span', { class: 'zx-table__header-label' }, column.label));
853
+ }
854
+ this._headers.set(column.id, th);
855
+ row.append(th);
856
+ }
857
+ this._thead.replaceChildren(row);
858
+ this._syncSortHeader();
859
+ }
860
+
861
+ /** @returns {void} @fires Table#editcancel */
862
+ _renderBody() {
863
+ // Every data mutation funnels through here, so this is the single place that guarantees no
864
+ // editor component outlives the rows it was rendered into.
865
+ this._abortEdit({ focus: false });
866
+ const fragment = document.createDocumentFragment();
867
+ this._rowMeta = new WeakMap();
868
+ this._rowElements = new Map();
869
+ this._rovingCell = null;
870
+ this._rovingMatched = false;
871
+ if (this._data.length === 0) {
872
+ fragment.append(h('tr', { class: 'zx-table__empty-row' },
873
+ h('td', {
874
+ class: 'zx-table__empty',
875
+ colspan: this._columns.length + (this.options.selectable === 'multi' ? 1 : 0)
876
+ }, this._emptyContent())
877
+ ));
878
+ } else {
879
+ // Indices stay absolute: a row's index is its place in the data, not in what is on screen.
880
+ this._visibleData().forEach((row, index) => fragment.append(this._createRow(row, index)));
881
+ }
882
+ this._tbody.replaceChildren(fragment);
883
+ /*
884
+ * With `growing` on, the DOM holds a prefix of the data — so without this a screen reader
885
+ * announces "row 3 of 10" for a table of ten thousand. The header counts as a row.
886
+ */
887
+ if (this._growStep) this._table.setAttribute('aria-rowcount', String(this._data.length + 1));
888
+ this._syncSelectAll();
889
+ this._syncGrowing();
890
+ if (this._stacked) this._applyStackedRoles(true);
891
+ }
892
+
893
+ /**
894
+ * What to put in the empty row.
895
+ *
896
+ * An empty table is a screen in its own right — the place to say why there is nothing and what to
897
+ * do about it — so this takes a node as readily as a string. A function is re-called on every
898
+ * render, which matters when the placeholder contains a button: reusing one node would move the
899
+ * same element between renders and quietly share its listeners.
900
+ * @returns {Node|string}
901
+ */
902
+ _emptyContent() {
903
+ const empty = this.options.emptyText;
904
+ if (empty == null) return this.msg('table.empty');
905
+ return typeof empty === 'function' ? empty() : empty;
906
+ }
907
+
908
+ /** @param {TableRow} row @param {number} index @returns {HTMLTableRowElement} */
909
+ _createRow(row, index) {
910
+ const id = this._idFor(row);
911
+ const tr = /** @type {HTMLTableRowElement} */ (h('tr', { dataset: { row: '' } }));
912
+ // Only a prefix of the data is in the DOM while growing, so each row states where it really is.
913
+ if (this._growStep) tr.setAttribute('aria-rowindex', String(index + 2));
914
+ const rowClass = typeof this.options.rowClass === 'function' ? this.options.rowClass(row) : '';
915
+ if (rowClass) tr.classList.add(...String(rowClass).split(/\s+/).filter(Boolean));
916
+ if (this.options.selectable !== false) tr.setAttribute('aria-selected', String(this._selected.has(id)));
917
+ this._rowMeta.set(tr, { row, id, index });
918
+ const elements = this._rowElements.get(id) ?? [];
919
+ elements.push(tr);
920
+ this._rowElements.set(id, elements);
921
+
922
+ if (this.options.selectable === 'multi') {
923
+ const checkbox = h('input', {
924
+ class: 'zx-table__checkbox zx-table__row-checkbox',
925
+ type: 'checkbox',
926
+ checked: this._selected.has(id),
927
+ ariaLabel: `Select row ${index + 1}`
928
+ });
929
+ tr.append(h('td', { class: 'zx-table__selection-cell' }, checkbox));
930
+ }
931
+
932
+ this._columns.forEach((column) => {
933
+ const cell = /** @type {HTMLTableCellElement} */ (h('td'));
934
+ if (column.align) cell.dataset.align = column.align;
935
+ // Written unconditionally: `responsive` can be switched on after the rows exist, and a
936
+ // stacked cell has no column header above it to say what its value means.
937
+ cell.dataset.label = column.label;
938
+ if (column.popin === false) cell.dataset.popin = 'false';
939
+ if (this._editMode !== false) {
940
+ cell.dataset.column = column.id;
941
+ if (resolveEditable(column, row) !== false) {
942
+ cell.dataset.editable = 'true';
943
+ cell.tabIndex = -1;
944
+ this._offerRovingCell(cell, id, column.id);
945
+ }
946
+ }
947
+ this._renderCellContent(cell, column, row, index);
948
+ tr.append(cell);
949
+ });
950
+ return tr;
951
+ }
952
+
953
+ /**
954
+ * Fills a cell with the column's rendered value, replacing anything already in it.
955
+ * @param {HTMLTableCellElement} cell Target cell.
956
+ * @param {TableColumn} column Column definition.
957
+ * @param {TableRow} row Row data.
958
+ * @param {number} index Row index.
959
+ * @returns {void}
960
+ */
961
+ _renderCellContent(cell, column, row, index) {
962
+ const value = typeof column.render === 'function' ? column.render(row, index) : row?.[column.id];
963
+ if (value && typeof value === 'object' && typeof value.nodeType === 'number') cell.replaceChildren(value);
964
+ else if (value != null) cell.replaceChildren(document.createTextNode(String(value)));
965
+ else cell.replaceChildren();
966
+ }
967
+
968
+ /** @param {MouseEvent} event @returns {void} */
969
+ _handleBodyClick(event) {
970
+ const target = /** @type {Element|null} */ (event.target?.nodeType === 1 ? event.target : event.target?.parentElement);
971
+ // Interacting with an open editor is never a row activation.
972
+ if (this._edit && isInsideEditor(target)) return;
973
+ const rowElement = target?.closest('tr[data-row]');
974
+ if (!rowElement || !this._tbody.contains(rowElement)) return;
975
+ const meta = this._rowMeta.get(rowElement);
976
+ if (!meta) return;
977
+ // Read the row metadata first: committing re-renders the body and detaches `rowElement`.
978
+ if (this._edit) this.commitEdit();
979
+
980
+ const checkbox = target.closest('.zx-table__row-checkbox');
981
+ if (checkbox && this.options.selectable === 'multi') {
982
+ this._toggleMultiSelection(meta, /** @type {HTMLInputElement} */ (checkbox).checked, event.shiftKey);
983
+ return;
984
+ }
985
+
986
+ this.emit('rowclick', { ...meta, event });
987
+ if (this.options.selectable === 'single' && !this._selected.has(meta.id)) {
988
+ this._selected = new Set([meta.id]);
989
+ this._selectionAnchorId = meta.id;
990
+ this._syncSelection();
991
+ this._emitSelectionChange();
992
+ }
993
+ }
994
+
995
+ /** @param {MouseEvent} event @returns {void} @fires Table#editstart */
996
+ _handleBodyDoubleClick(event) {
997
+ const target = /** @type {Element|null} */ (event.target?.nodeType === 1 ? event.target : event.target?.parentElement);
998
+ if (target?.closest('.zx-table__row-checkbox')) return;
999
+ if (this._edit && isInsideEditor(target)) return;
1000
+ const rowElement = target?.closest('tr[data-row]');
1001
+ if (!rowElement || !this._tbody.contains(rowElement)) return;
1002
+ const meta = this._rowMeta.get(rowElement);
1003
+ if (!meta) return;
1004
+ if (this._editMode !== false) {
1005
+ const cell = target?.closest('td[data-editable="true"]');
1006
+ if (cell && this._tbody.contains(cell)) {
1007
+ // A double-click that opens an editor is an edit gesture, not a row activation.
1008
+ this.startEdit(meta.id, String(cell.dataset.column));
1009
+ return;
1010
+ }
1011
+ }
1012
+ this.emit('rowdblclick', { ...meta, event });
1013
+ }
1014
+
1015
+ /**
1016
+ * Builds the editor for one cell and mounts it over the cell's content.
1017
+ * @param {HTMLTableCellElement} td Cell to edit.
1018
+ * @param {TableColumn} column Column definition.
1019
+ * @param {TableRow} row Row being edited.
1020
+ * @param {number} index Row index.
1021
+ * @returns {TableEditorEntry|null} Editor entry, or null when the column is read-only.
1022
+ */
1023
+ _createEditor(td, column, row, index) {
1024
+ const type = resolveEditable(column, row);
1025
+ if (type === false) return null;
1026
+ const value = row?.[column.id];
1027
+ const props = column.editorProps && typeof column.editorProps === 'object' ? column.editorProps : {};
1028
+ // The rendered content stays in the cell but invisible, so the column keeps its intrinsic width
1029
+ // while the editor — which is taken out of flow — can never change the row height.
1030
+ const shadow = h('span', { class: 'zx-table__cell-shadow', ariaHidden: 'true' });
1031
+ shadow.append(...td.childNodes);
1032
+ const host = h('div', { class: 'zx-table__editor', dataset: { editor: type } });
1033
+ td.replaceChildren(shadow, host);
1034
+ td.dataset.editing = 'true';
1035
+
1036
+ /** @type {TableEditorEntry} */
1037
+ const entry = {
1038
+ column,
1039
+ type,
1040
+ td,
1041
+ host,
1042
+ control: null,
1043
+ component: null,
1044
+ read: () => value,
1045
+ destroy() {
1046
+ this.component?.destroy();
1047
+ this.host.remove();
1048
+ }
1049
+ };
1050
+
1051
+ if (type === 'custom') {
1052
+ entry.custom = value;
1053
+ entry.read = () => parseCellValue(column, entry.custom, row);
1054
+ /** @type {TableEditorApi} */
1055
+ const api = {
1056
+ value,
1057
+ row,
1058
+ column,
1059
+ commit: (next) => {
1060
+ entry.custom = next;
1061
+ this.commitEdit();
1062
+ },
1063
+ cancel: () => this.cancelEdit()
1064
+ };
1065
+ const node = column.editor(row, api);
1066
+ // A custom editor may return a Node or a Zx component; a component is adopted so that it is
1067
+ // destroyed with the edit instead of outliving the row it was rendered into.
1068
+ if (node && typeof node.toElement === 'function') {
1069
+ if (typeof node.destroy === 'function') entry.component = node;
1070
+ host.append(node.toElement());
1071
+ } else if (node) {
1072
+ host.append(node);
1073
+ }
1074
+ entry.control = /** @type {HTMLElement|null} */ (host.querySelector(FOCUSABLE)) ?? host;
1075
+ return entry;
1076
+ }
1077
+ if (type === 'number') {
1078
+ const field = new NumberField(null, { ...props, value: value ?? null });
1079
+ host.append(field.el);
1080
+ entry.component = field;
1081
+ entry.control = field.getInput();
1082
+ entry.read = () => {
1083
+ // The field only parses its text on blur or Enter; a commit triggered by Tab or by a click
1084
+ // outside must not discard what the user has typed.
1085
+ field.set(field.getInput().value, { silent: true });
1086
+ return parseCellValue(column, field.get(), row);
1087
+ };
1088
+ return entry;
1089
+ }
1090
+ if (type === 'select') {
1091
+ const items = normalizeEditorOptions(column.options, row);
1092
+ // Fall back to a loose match so `{value: label}` maps (whose keys are always strings) still
1093
+ // open on the right option for a numeric row value.
1094
+ const match = items.find((item) => Object.is(item.value, value))
1095
+ ?? items.find((item) => String(item.value) === String(value ?? ''));
1096
+ const select = new Select(null, {
1097
+ ...props, items, valueKey: 'value', labelKey: 'label', value: match ? match.value : null
1098
+ });
1099
+ host.append(select.el);
1100
+ entry.component = select;
1101
+ entry.control = /** @type {HTMLElement} */ (select.refs.input);
1102
+ entry.read = () => parseCellValue(column, select.value, row);
1103
+ return entry;
1104
+ }
1105
+ if (type === 'date') {
1106
+ const datebox = new Datebox(null, { ...props, value: value ?? null });
1107
+ host.append(datebox.el);
1108
+ entry.component = datebox;
1109
+ const dateInput = /** @type {HTMLInputElement} */ (datebox.refs.input);
1110
+ entry.control = dateInput;
1111
+ entry.read = () => {
1112
+ // Same as the number editor: push the typed text through the box's own parser, but keep
1113
+ // the previous date when it cannot be read rather than clearing the cell.
1114
+ const text = String(dateInput.value ?? '').trim();
1115
+ const previous = datebox.get();
1116
+ if (text === '') return parseCellValue(column, null, row);
1117
+ datebox.set(text, { silent: true });
1118
+ if (datebox.get() === null) datebox.set(previous, { silent: true });
1119
+ return parseCellValue(column, datebox.get(), row);
1120
+ };
1121
+ return entry;
1122
+ }
1123
+ if (type === 'checkbox') {
1124
+ const toggle = new Toggle(null, { ...props, checked: Boolean(value) });
1125
+ host.append(toggle.el);
1126
+ entry.component = toggle;
1127
+ entry.control = /** @type {HTMLElement} */ (toggle.el);
1128
+ entry.read = () => parseCellValue(column, toggle.get(), row);
1129
+ return entry;
1130
+ }
1131
+
1132
+ const textarea = type === 'textarea';
1133
+ const input = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (h(textarea ? 'textarea' : 'input', {
1134
+ ...props,
1135
+ class: textarea ? 'zx-table__editor-input zx-table__editor-textarea' : 'zx-table__editor-input',
1136
+ ...(textarea ? {} : { type: 'text' }),
1137
+ ariaLabel: props.ariaLabel ?? `${column.label} row ${index + 1}`,
1138
+ value: formatCellValue(column, value, row)
1139
+ }));
1140
+ host.append(input);
1141
+ entry.control = input;
1142
+ entry.read = () => parseCellValue(column, input.value, row);
1143
+ return entry;
1144
+ }
1145
+
1146
+ /**
1147
+ * Moves focus into one of the open editors.
1148
+ * @param {string} columnId Column id.
1149
+ * @returns {void}
1150
+ */
1151
+ _focusEditor(columnId) {
1152
+ const entry = this._edit?.cells.get(columnId);
1153
+ const control = entry?.control;
1154
+ if (!control || typeof control.focus !== 'function') return;
1155
+ control.focus();
1156
+ // Opening a text editor selects its content, so typing replaces the old value.
1157
+ if (entry.type === 'text' && typeof control.select === 'function') control.select();
1158
+ }
1159
+
1160
+ /**
1161
+ * Moves focus back onto a cell.
1162
+ * @param {unknown} id Row id.
1163
+ * @param {string} columnId Column id.
1164
+ * @returns {void}
1165
+ */
1166
+ _focusCell(id, columnId) {
1167
+ const tr = this._rowElements.get(id)?.[0];
1168
+ const td = tr ? cellIn(tr, columnId) : null;
1169
+ td?.focus();
1170
+ }
1171
+
1172
+ /** Destroys every open editor and restores the cells they covered. @returns {void} */
1173
+ _teardownEdit() {
1174
+ const edit = this._edit;
1175
+ if (!edit) return;
1176
+ this._edit = null;
1177
+ const index = this._data.findIndex((candidate) => Object.is(this._idFor(candidate), edit.id));
1178
+ const row = index < 0 ? edit.row : this._data[index];
1179
+ for (const entry of edit.cells.values()) {
1180
+ entry.destroy();
1181
+ delete entry.td.dataset.editing;
1182
+ delete entry.td.dataset.invalid;
1183
+ this._renderCellContent(entry.td, entry.column, row, index < 0 ? 0 : index);
1184
+ }
1185
+ edit.cells.clear();
1186
+ delete edit.tr.dataset.editing;
1187
+ this._clearInvalid();
1188
+ }
1189
+
1190
+ /**
1191
+ * Cancels the open edit, optionally returning focus to its cell.
1192
+ * @param {{focus?: boolean}} [options={}] Whether focus returns to the edited cell.
1193
+ * @returns {void}
1194
+ * @fires Table#editcancel
1195
+ */
1196
+ _abortEdit({ focus = true } = {}) {
1197
+ const edit = this._edit;
1198
+ if (!edit) return;
1199
+ const restoreFocus = focus && this._editHasFocus();
1200
+ const row = this.getRow(edit.id) ?? edit.row;
1201
+ const column = this._columns.find((candidate) => candidate.id === edit.columnId) ?? null;
1202
+ this._teardownEdit();
1203
+ if (restoreFocus) this._focusCell(edit.id, edit.columnId);
1204
+ this.emit('editcancel', { row, id: edit.id, column, columnId: edit.columnId });
1205
+ }
1206
+
1207
+ /** @returns {boolean} Whether focus currently sits inside this table. */
1208
+ _editHasFocus() {
1209
+ const active = document.activeElement;
1210
+ return Boolean(active && this.el?.contains(active));
1211
+ }
1212
+
1213
+ /**
1214
+ * Marks one edited cell invalid and announces the message.
1215
+ * @param {string} columnId Rejected column id.
1216
+ * @param {string} message Validation message.
1217
+ * @returns {void}
1218
+ */
1219
+ _markInvalid(columnId, message) {
1220
+ this._clearInvalid();
1221
+ const entry = this._edit?.cells.get(columnId);
1222
+ if (!entry) return;
1223
+ entry.td.dataset.invalid = 'true';
1224
+ entry.control?.setAttribute('aria-invalid', 'true');
1225
+ if (!this._editMessage) return;
1226
+ entry.control?.setAttribute('aria-describedby', this._editMessage.id);
1227
+ this._editMessage.textContent = message;
1228
+ }
1229
+
1230
+ /** Clears the invalid marks and the validation message. @returns {void} */
1231
+ _clearInvalid() {
1232
+ if (this._edit) {
1233
+ for (const entry of this._edit.cells.values()) {
1234
+ delete entry.td.dataset.invalid;
1235
+ entry.control?.removeAttribute('aria-invalid');
1236
+ entry.control?.removeAttribute('aria-describedby');
1237
+ }
1238
+ }
1239
+ if (this._editMessage) this._editMessage.textContent = '';
1240
+ }
1241
+
1242
+ /**
1243
+ * Handles Enter/F2 on a focused cell and Escape/Enter/Tab inside an open editor.
1244
+ * @param {KeyboardEvent} event Keydown event.
1245
+ * @returns {void}
1246
+ */
1247
+ _handleEditKeydown(event) {
1248
+ const target = /** @type {Element|null} */ (event.target?.nodeType === 1 ? event.target : event.target?.parentElement);
1249
+ if (!target) return;
1250
+
1251
+ if (this._edit && isInsideEditor(target)) {
1252
+ // An open combobox list or calendar owns these keys until it closes again.
1253
+ if (deferToEditor(target)) return;
1254
+ if (event.key === 'Escape') {
1255
+ if (event.defaultPrevented) return;
1256
+ event.preventDefault();
1257
+ this.cancelEdit();
1258
+ return;
1259
+ }
1260
+ if (event.key === 'Enter') {
1261
+ if (target.localName === 'textarea' && event.shiftKey) return;
1262
+ event.preventDefault();
1263
+ this.commitEdit();
1264
+ return;
1265
+ }
1266
+ if (event.key === 'Tab') this._moveEdit(event);
1267
+ return;
1268
+ }
1269
+
1270
+ if (event.key !== 'Enter' && event.key !== 'F2') return;
1271
+ const cell = target.closest('td[data-editable="true"]');
1272
+ if (!cell || !this._tbody.contains(cell)) return;
1273
+ const meta = this._rowMeta.get(cell.parentElement);
1274
+ if (!meta) return;
1275
+ event.preventDefault();
1276
+ this.startEdit(meta.id, String(cell.dataset.column));
1277
+ }
1278
+
1279
+ /**
1280
+ * Commits the current editor and moves the edit one editable cell forward or backward.
1281
+ * @param {KeyboardEvent} event Tab keydown.
1282
+ * @returns {void}
1283
+ */
1284
+ _moveEdit(event) {
1285
+ const edit = this._edit;
1286
+ if (!edit) return;
1287
+ const direction = event.shiftKey ? -1 : 1;
1288
+ const index = this._data.findIndex((candidate) => Object.is(this._idFor(candidate), edit.id));
1289
+ const next = this._nextEditableCell(index, edit.columnId, direction);
1290
+
1291
+ if (next && Object.is(next.id, edit.id) && edit.cells.has(next.columnId)) {
1292
+ // Row mode: the neighbouring editor is already open, so this is a plain focus move.
1293
+ event.preventDefault();
1294
+ edit.columnId = next.columnId;
1295
+ this._focusEditor(next.columnId);
1296
+ return;
1297
+ }
1298
+ if (!next) {
1299
+ // Past the last editable cell: commit, then let the browser move focus out of the table.
1300
+ this.commitEdit();
1301
+ if (this._edit) event.preventDefault();
1302
+ return;
1303
+ }
1304
+ event.preventDefault();
1305
+ this.commitEdit();
1306
+ if (!this._edit) this.startEdit(next.id, next.columnId);
1307
+ }
1308
+
1309
+ /**
1310
+ * Finds the next editable cell, wrapping from the end of a row into the next row.
1311
+ * @param {number} rowIndex Row index to start from.
1312
+ * @param {string} columnId Column id to start from.
1313
+ * @param {number} direction `1` forward, `-1` backward.
1314
+ * @returns {{id: unknown, columnId: string, index: number}|null} Next cell, or null at the end.
1315
+ */
1316
+ _nextEditableCell(rowIndex, columnId, direction) {
1317
+ const columnCount = this._columns.length;
1318
+ if (rowIndex < 0 || columnCount === 0) return null;
1319
+ let row = rowIndex;
1320
+ let column = this._columns.findIndex((candidate) => candidate.id === columnId);
1321
+ const limit = this._data.length * columnCount + columnCount;
1322
+ for (let step = 0; step < limit; step += 1) {
1323
+ column += direction;
1324
+ if (column >= columnCount) {
1325
+ row += 1;
1326
+ column = 0;
1327
+ } else if (column < 0) {
1328
+ row -= 1;
1329
+ column = columnCount - 1;
1330
+ }
1331
+ if (row < 0 || row >= this._data.length) return null;
1332
+ const candidateRow = this._data[row];
1333
+ const candidateColumn = this._columns[column];
1334
+ if (resolveEditable(candidateColumn, candidateRow) !== false) {
1335
+ return { id: this._idFor(candidateRow), columnId: candidateColumn.id, index: row };
1336
+ }
1337
+ }
1338
+ return null;
1339
+ }
1340
+
1341
+ /** @param {Event} event @returns {void} */
1342
+ _handleEditFocusIn(event) {
1343
+ const target = /** @type {Element|null} */ (event.target?.nodeType === 1 ? event.target : null);
1344
+ const cell = target?.closest('td[data-editable="true"]');
1345
+ if (!cell || !this._tbody.contains(cell)) return;
1346
+ const meta = this._rowMeta.get(cell.parentElement);
1347
+ if (!meta) return;
1348
+ this._setRovingCell(/** @type {HTMLTableCellElement} */ (cell), meta.id, String(cell.dataset.column));
1349
+ }
1350
+
1351
+ /** @param {Event} event @returns {void} @fires Table#editcommit */
1352
+ _handleDocumentPointerDown(event) {
1353
+ if (!this._edit) return;
1354
+ const target = /** @type {Element|null} */ (event.target?.nodeType === 1 ? event.target : event.target?.parentElement);
1355
+ if (target && isInsideEditor(target)) return;
1356
+ // Pointer events inside the body are resolved by the click handler, which commits there and
1357
+ // still reports `rowclick`.
1358
+ if (target && this._tbody.contains(target)) return;
1359
+ this.commitEdit();
1360
+ }
1361
+
1362
+ /**
1363
+ * Keeps exactly one editable cell in the tab order, preferring the last focused one.
1364
+ * @param {HTMLTableCellElement} cell Newly rendered editable cell.
1365
+ * @param {unknown} id Row id.
1366
+ * @param {string} columnId Column id.
1367
+ * @returns {void}
1368
+ */
1369
+ _offerRovingCell(cell, id, columnId) {
1370
+ if (this._rovingMatched) return;
1371
+ const preferred = this._rovingKey !== null
1372
+ && Object.is(this._rovingKey.id, id) && this._rovingKey.columnId === columnId;
1373
+ if (!preferred && this._rovingCell) return;
1374
+ if (preferred && this._rovingCell) this._rovingCell.tabIndex = -1;
1375
+ cell.tabIndex = 0;
1376
+ this._rovingCell = cell;
1377
+ this._rovingMatched = preferred;
1378
+ }
1379
+
1380
+ /** @param {HTMLTableCellElement} cell @param {unknown} id @param {string} columnId @returns {void} */
1381
+ _setRovingCell(cell, id, columnId) {
1382
+ if (this._rovingCell && this._rovingCell !== cell) this._rovingCell.tabIndex = -1;
1383
+ cell.tabIndex = 0;
1384
+ this._rovingCell = cell;
1385
+ this._rovingMatched = true;
1386
+ this._rovingKey = { id, columnId };
1387
+ }
1388
+
1389
+ /**
1390
+ * Resolves a message key with an English fallback for hosts without a translator.
1391
+ * @param {string} key Message key.
1392
+ * @param {string} fallback Text used when the key is untranslated.
1393
+ * @returns {string}
1394
+ */
1395
+ _msgOr(key, fallback) {
1396
+ const message = this.msg(key);
1397
+ return message === key ? fallback : message;
1398
+ }
1399
+
1400
+ /** @param {{row: TableRow, id: unknown, index: number}} meta @param {boolean} checked @param {boolean} shiftKey @returns {void} */
1401
+ _toggleMultiSelection(meta, checked, shiftKey) {
1402
+ const anchorIndex = this._data.findIndex((row) => Object.is(this._idFor(row), this._selectionAnchorId));
1403
+ if (shiftKey && anchorIndex >= 0) {
1404
+ const start = Math.min(anchorIndex, meta.index);
1405
+ const end = Math.max(anchorIndex, meta.index);
1406
+ for (let index = start; index <= end; index += 1) {
1407
+ const id = this._idFor(this._data[index]);
1408
+ if (checked) this._selected.add(id);
1409
+ else this._selected.delete(id);
1410
+ }
1411
+ } else if (checked) {
1412
+ this._selected.add(meta.id);
1413
+ } else {
1414
+ this._selected.delete(meta.id);
1415
+ }
1416
+ this._selectionAnchorId = meta.id;
1417
+ this._syncSelection();
1418
+ this._emitSelectionChange();
1419
+ }
1420
+
1421
+ /** @returns {void} */
1422
+ _syncSelection() {
1423
+ for (const [id, rows] of this._rowElements) {
1424
+ const selected = this._selected.has(id);
1425
+ for (const row of rows) {
1426
+ row.setAttribute('aria-selected', String(selected));
1427
+ const checkbox = row.querySelector('.zx-table__row-checkbox');
1428
+ if (checkbox) checkbox.checked = selected;
1429
+ }
1430
+ }
1431
+ this._syncSelectAll();
1432
+ }
1433
+
1434
+ /** @returns {void} */
1435
+ _syncSelectAll() {
1436
+ if (!this._selectAll) return;
1437
+ // Measured against what is rendered, so the box reflects the rows the reader can actually see.
1438
+ const visible = this._visibleData();
1439
+ const selectedRows = visible.reduce(
1440
+ (count, row) => count + Number(this._selected.has(this._idFor(row))),
1441
+ 0
1442
+ );
1443
+ this._selectAll.disabled = visible.length === 0;
1444
+ this._selectAll.checked = visible.length > 0 && selectedRows === visible.length;
1445
+ this._selectAll.indeterminate = selectedRows > 0 && selectedRows < visible.length;
1446
+ }
1447
+
1448
+ /** @returns {void} */
1449
+ _syncSortHeader() {
1450
+ if (!this._headers) return;
1451
+ for (const [id, header] of this._headers) {
1452
+ if (this._sort?.id === id) header.setAttribute('aria-sort', this._sort.dir === 'asc' ? 'ascending' : 'descending');
1453
+ else header.removeAttribute('aria-sort');
1454
+ }
1455
+ }
1456
+
1457
+ /** @returns {void} */
1458
+ _sortData() {
1459
+ if (!this._sort) return;
1460
+ const column = this._columns.find((candidate) => candidate.id === this._sort.id);
1461
+ if (!column) return;
1462
+ const getValue = typeof column.sortValue === 'function' ? column.sortValue : (row) => row?.[column.id];
1463
+ this._data = sortRows(this._data, getValue, this._sort.dir);
1464
+ }
1465
+
1466
+ /** @returns {boolean} Whether selection changed. */
1467
+ _pruneSelection() {
1468
+ const valid = new Set(this._data.map((row) => this._idFor(row)));
1469
+ const previousSize = this._selected.size;
1470
+ for (const id of this._selected) if (!valid.has(id)) this._selected.delete(id);
1471
+ if (!valid.has(this._selectionAnchorId)) this._selectionAnchorId = null;
1472
+ return previousSize !== this._selected.size;
1473
+ }
1474
+
1475
+ /** @param {TableRow} row @returns {unknown} */
1476
+ _idFor(row) {
1477
+ return typeof this.options.rowId === 'function' ? this.options.rowId(row) : row?.[this.options.rowId];
1478
+ }
1479
+
1480
+ /** @returns {void} @fires Table#selectionchange */
1481
+ _emitSelectionChange() {
1482
+ const rows = this.getSelection();
1483
+ this.emit('selectionchange', { rows, ids: rows.map((row) => this._idFor(row)) });
1484
+ }
1485
+
1486
+ /** @returns {void} @fires Table#datachange */
1487
+ _emitDataChange() {
1488
+ this.emit('datachange', { rows: this.getData() });
1489
+ }
1490
+ }
1491
+
1492
+ /**
1493
+ * Row activation event.
1494
+ * @event Table#rowclick
1495
+ * @type {CustomEvent<TableRowClickDetail>}
1496
+ */
1497
+
1498
+ /**
1499
+ * Row double-activation event.
1500
+ * @event Table#rowdblclick
1501
+ * @type {CustomEvent<TableRowClickDetail>}
1502
+ */
1503
+
1504
+ /** @event Table#sort @type {CustomEvent<TableSortDetail>} */
1505
+ /** @event Table#selectionchange @type {CustomEvent<TableSelectionDetail>} */
1506
+ /** @event Table#datachange @type {CustomEvent<TableDataDetail>} */
1507
+
1508
+ /**
1509
+ * Editor opened for a cell (or, in row mode, for the row).
1510
+ * @event Table#editstart
1511
+ * @type {CustomEvent<TableEditStartDetail>}
1512
+ */
1513
+
1514
+ /**
1515
+ * Values are about to be written back. Cancelable: `preventDefault()` keeps the editor open, which
1516
+ * lets a server round-trip reject the change.
1517
+ * @event Table#editcommit
1518
+ * @type {CustomEvent<TableEditCommitDetail>}
1519
+ */
1520
+
1521
+ /**
1522
+ * Editing ended without writing anything back — Escape, `cancelEdit()`, or a data refresh
1523
+ * (`setData`, `setSort`, …) that replaced the edited rows.
1524
+ * @event Table#editcancel
1525
+ * @type {CustomEvent<TableEditCancelDetail>}
1526
+ */
1527
+
1528
+ /**
1529
+ * A column's `validate` rejected the commit.
1530
+ * @event Table#editinvalid
1531
+ * @type {CustomEvent<TableEditInvalidDetail>}
1532
+ */
1533
+
1534
+ /**
1535
+ * Resolves the editor type of a column for one row.
1536
+ *
1537
+ * `column.editor` wins over `column.editable`, except that a per-row `editable` function returning
1538
+ * `false` still marks the cell read-only. `editable: true` means `'text'`; unknown type names are
1539
+ * treated as not editable.
1540
+ * @param {TableColumn} column Column definition.
1541
+ * @param {TableRow} row Row the cell belongs to.
1542
+ * @returns {TableEditorType|false} Editor type, or false when the cell is read-only.
1543
+ */
1544
+ export function resolveEditable(column, row) {
1545
+ if (!column || typeof column !== 'object') return false;
1546
+ if (typeof column.editor === 'function') {
1547
+ if (typeof column.editable === 'function' && column.editable(row) === false) return false;
1548
+ return 'custom';
1549
+ }
1550
+ const declared = typeof column.editable === 'function' ? column.editable(row) : column.editable;
1551
+ if (declared === true) return 'text';
1552
+ if (typeof declared === 'string' && EDITOR_TYPES.has(declared)) return /** @type {TableEditorType} */ (declared);
1553
+ return false;
1554
+ }
1555
+
1556
+ /**
1557
+ * Normalizes `column.options` into `[{value, label}]`. Object maps always yield string values,
1558
+ * because object keys are strings; use the array form to keep other value types.
1559
+ * @param {TableEditorOption[]|Record<string, string>|((row: TableRow) => unknown)|undefined} source Option source.
1560
+ * @param {TableRow} row Row the options are resolved for.
1561
+ * @returns {TableEditorOption[]} Normalized options.
1562
+ */
1563
+ export function normalizeEditorOptions(source, row) {
1564
+ const raw = typeof source === 'function' ? source(row) : source;
1565
+ if (Array.isArray(raw)) {
1566
+ return raw.map((item) => (item !== null && typeof item === 'object'
1567
+ ? { value: item.value, label: String(item.label ?? item.value ?? '') }
1568
+ : { value: item, label: String(item ?? '') }));
1569
+ }
1570
+ if (raw !== null && typeof raw === 'object') {
1571
+ return Object.entries(raw).map(([value, label]) => ({ value, label: String(label ?? '') }));
1572
+ }
1573
+ return [];
1574
+ }
1575
+
1576
+ /**
1577
+ * Renders a value as the initial text of a text or textarea editor.
1578
+ * @param {TableColumn} column Column definition.
1579
+ * @param {unknown} value Cell value.
1580
+ * @param {TableRow} row Row the cell belongs to.
1581
+ * @returns {string} Editor text.
1582
+ */
1583
+ export function formatCellValue(column, value, row) {
1584
+ if (typeof column?.format === 'function') {
1585
+ const formatted = column.format(value, row);
1586
+ return formatted == null ? '' : String(formatted);
1587
+ }
1588
+ return value == null ? '' : String(value);
1589
+ }
1590
+
1591
+ /**
1592
+ * Converts an editor's raw output into the value written back into the row.
1593
+ * @param {TableColumn} column Column definition.
1594
+ * @param {unknown} raw Editor output.
1595
+ * @param {TableRow} row Row the cell belongs to.
1596
+ * @returns {unknown} Value for the row.
1597
+ */
1598
+ export function parseCellValue(column, raw, row) {
1599
+ return typeof column?.parse === 'function' ? column.parse(raw, row) : raw;
1600
+ }
1601
+
1602
+ /**
1603
+ * Compares two cell values, treating equal-timestamp dates as unchanged.
1604
+ * @param {unknown} left First value.
1605
+ * @param {unknown} right Second value.
1606
+ * @returns {boolean} Whether the values are equal.
1607
+ */
1608
+ export function valuesEqual(left, right) {
1609
+ if (Object.is(left, right)) return true;
1610
+ if (left instanceof Date && right instanceof Date) return left.getTime() === right.getTime();
1611
+ return false;
1612
+ }
1613
+
1614
+ /**
1615
+ * Diffs edited values against a row and returns only what actually changed. This is the `changes`
1616
+ * map carried by `editcommit`; in row mode it holds every changed cell of the row.
1617
+ * @param {TableRow} row Row before the commit.
1618
+ * @param {Record<string, unknown>} values Edited values keyed by column id.
1619
+ * @returns {Record<string, unknown>} Changed values keyed by column id.
1620
+ */
1621
+ export function diffChanges(row, values) {
1622
+ /** @type {Record<string, unknown>} */
1623
+ const changes = {};
1624
+ for (const [columnId, value] of Object.entries(values ?? {})) {
1625
+ if (!valuesEqual(row?.[columnId], value)) changes[columnId] = value;
1626
+ }
1627
+ return changes;
1628
+ }
1629
+
1630
+ /**
1631
+ * Runs the columns' `validate` callbacks in column order and stops at the first rejection, so a
1632
+ * later validator never runs once one has failed.
1633
+ * @param {Record<string, unknown>} values Edited values keyed by column id.
1634
+ * @param {TableColumn[]} columns Columns to validate, in order.
1635
+ * @param {TableRow} row Row being edited.
1636
+ * @param {string} [fallbackMessage='Invalid value'] Message used when a validator returns `false`.
1637
+ * @returns {{columnId: string, message: string}|null} First rejection, or null when all pass.
1638
+ */
1639
+ export function firstValidationError(values, columns, row, fallbackMessage = 'Invalid value') {
1640
+ for (const column of columns ?? []) {
1641
+ if (typeof column?.validate !== 'function') continue;
1642
+ if (!Object.prototype.hasOwnProperty.call(values ?? {}, column.id)) continue;
1643
+ const result = column.validate(values[column.id], row);
1644
+ if (typeof result === 'string') return { columnId: column.id, message: result };
1645
+ if (result === false) return { columnId: column.id, message: fallbackMessage };
1646
+ }
1647
+ return null;
1648
+ }
1649
+
1650
+ /** @param {unknown} mode @returns {TableEditMode} */
1651
+ function normalizeEditMode(mode) {
1652
+ if (mode === 'cell' || mode === 'row') return mode;
1653
+ return mode === true ? 'cell' : false;
1654
+ }
1655
+
1656
+ /** @param {Element|null} target @returns {boolean} */
1657
+ function isInsideEditor(target) {
1658
+ return Boolean(target?.closest?.('.zx-table__editor'));
1659
+ }
1660
+
1661
+ /**
1662
+ * Whether a child component still owns Escape/Enter/Tab because one of its overlays is open.
1663
+ * @param {Element} target Key event target.
1664
+ * @returns {boolean}
1665
+ */
1666
+ function deferToEditor(target) {
1667
+ if (target.closest('[popover]')) return true;
1668
+ return target.getAttribute?.('aria-expanded') === 'true';
1669
+ }
1670
+
1671
+ /** @param {HTMLTableRowElement} tr @param {string} columnId @returns {HTMLTableCellElement|null} */
1672
+ function cellIn(tr, columnId) {
1673
+ for (const cell of tr.cells) {
1674
+ if (cell.dataset.column === columnId) return /** @type {HTMLTableCellElement} */ (cell);
1675
+ }
1676
+ return null;
1677
+ }
1678
+
1679
+ /** @param {unknown} sort @param {TableColumn[]} columns @returns {{id: string, dir: TableSortDirection}|null} */
1680
+ function normalizeSort(sort, columns) {
1681
+ if (!sort || typeof sort !== 'object') return null;
1682
+ if (!columns.some((column) => column.id === sort.id)) return null;
1683
+ return { id: sort.id, dir: sort.dir === 'desc' ? 'desc' : 'asc' };
1684
+ }
1685
+
1686
+ /** @param {unknown} width @returns {number} */
1687
+ function parseFraction(width) {
1688
+ if (typeof width !== 'string') return 0;
1689
+ const match = /^(\d*\.?\d+)fr$/.exec(width.trim());
1690
+ return match ? Math.max(0, Number(match[1])) : 0;
1691
+ }
1692
+
1693
+ /** @param {unknown} rows @returns {asserts rows is TableRow[]} */
1694
+ function assertRows(rows) {
1695
+ if (!Array.isArray(rows)) throw new TypeError('Table data must be an array');
1696
+ }
1697
+
1698
+ /** @param {Set<unknown>} left @param {Set<unknown>} right @returns {boolean} */
1699
+ function sameSet(left, right) {
1700
+ return left.size === right.size && [...left].every((value) => right.has(value));
1701
+ }
1702
+
1703
+ /**
1704
+ * Normalizes the `growing` option into a batch size.
1705
+ * @param {unknown} growing Option value.
1706
+ * @returns {number|null} Batch size, or null when the table renders everything at once.
1707
+ */
1708
+ function growStep(growing) {
1709
+ const step = Math.trunc(Number(growing));
1710
+ return Number.isFinite(step) && step > 0 ? step : null;
1711
+ }