snice 7.8.0 → 7.9.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 (302) hide show
  1. package/README.md +1 -1
  2. package/adapters/react/SniceProvider.js +1 -1
  3. package/adapters/react/SniceRouter.js +1 -1
  4. package/adapters/react/matchRoute.js +1 -1
  5. package/adapters/react/table.d.ts +1 -0
  6. package/adapters/react/table.d.ts.map +1 -1
  7. package/adapters/react/table.js +1 -1
  8. package/adapters/react/table.js.map +1 -1
  9. package/adapters/react/table.tsx +2 -1
  10. package/adapters/react/useRequestHandler.js +1 -1
  11. package/bin/analyzer-contracts.js +17 -1
  12. package/custom-elements.json +57 -3
  13. package/dist/cdn/accordion/snice-accordion.js +1 -1
  14. package/dist/cdn/action-bar/snice-action-bar.js +1 -1
  15. package/dist/cdn/activity-feed/snice-activity-feed.js +1 -1
  16. package/dist/cdn/alert/snice-alert.js +1 -1
  17. package/dist/cdn/app-tiles/snice-app-tiles.js +1 -1
  18. package/dist/cdn/approval-flow/snice-approval-flow.js +1 -1
  19. package/dist/cdn/audio-recorder/snice-audio-recorder.js +1 -1
  20. package/dist/cdn/availability/snice-availability.js +1 -1
  21. package/dist/cdn/avatar/snice-avatar.js +1 -1
  22. package/dist/cdn/avatar-group/snice-avatar-group.js +1 -1
  23. package/dist/cdn/badge/snice-badge.js +1 -1
  24. package/dist/cdn/banner/snice-banner.js +1 -1
  25. package/dist/cdn/binpack/snice-binpack.js +1 -1
  26. package/dist/cdn/book/snice-book.js +1 -1
  27. package/dist/cdn/booking/snice-booking.js +1 -1
  28. package/dist/cdn/breadcrumbs/snice-breadcrumbs.js +1 -1
  29. package/dist/cdn/button/snice-button.js +1 -1
  30. package/dist/cdn/calendar/README.md +2 -2
  31. package/dist/cdn/calendar/snice-calendar.js +537 -44
  32. package/dist/cdn/calendar/snice-calendar.js.map +1 -1
  33. package/dist/cdn/calendar/snice-calendar.min.js +2 -2
  34. package/dist/cdn/calendar/snice-calendar.min.js.map +1 -1
  35. package/dist/cdn/camera/snice-camera.js +1 -1
  36. package/dist/cdn/camera-annotate/snice-camera-annotate.js +1 -1
  37. package/dist/cdn/candlestick/snice-candlestick.js +1 -1
  38. package/dist/cdn/card/snice-card.js +1 -1
  39. package/dist/cdn/carousel/snice-carousel.js +1 -1
  40. package/dist/cdn/cart/snice-cart.js +1 -1
  41. package/dist/cdn/chart/snice-chart.js +1 -1
  42. package/dist/cdn/chat/snice-chat.js +1 -1
  43. package/dist/cdn/checkbox/snice-checkbox.js +1 -1
  44. package/dist/cdn/chip/snice-chip.js +1 -1
  45. package/dist/cdn/code-block/README.md +2 -2
  46. package/dist/cdn/code-block/snice-code-block.js +177 -167
  47. package/dist/cdn/code-block/snice-code-block.js.map +1 -1
  48. package/dist/cdn/code-block/snice-code-block.min.js +2 -2
  49. package/dist/cdn/code-block/snice-code-block.min.js.map +1 -1
  50. package/dist/cdn/color-display/snice-color-display.js +1 -1
  51. package/dist/cdn/color-picker/snice-color-picker.js +1 -1
  52. package/dist/cdn/command-palette/README.md +1 -1
  53. package/dist/cdn/command-palette/snice-command-palette.js +2 -2
  54. package/dist/cdn/command-palette/snice-command-palette.js.map +1 -1
  55. package/dist/cdn/command-palette/snice-command-palette.min.js +1 -1
  56. package/dist/cdn/command-palette/snice-command-palette.min.js.map +1 -1
  57. package/dist/cdn/comments/snice-comments.js +1 -1
  58. package/dist/cdn/countdown/snice-countdown.js +1 -1
  59. package/dist/cdn/cropper/snice-cropper.js +1 -1
  60. package/dist/cdn/data-card/snice-data-card.js +1 -1
  61. package/dist/cdn/date-picker/snice-date-picker.js +1 -1
  62. package/dist/cdn/date-range-picker/snice-date-range-picker.js +1 -1
  63. package/dist/cdn/date-time-picker/snice-date-time-picker.js +1 -1
  64. package/dist/cdn/diff/snice-diff.js +1 -1
  65. package/dist/cdn/divider/snice-divider.js +1 -1
  66. package/dist/cdn/doc/snice-doc.js +1 -1
  67. package/dist/cdn/draw/snice-draw.js +1 -1
  68. package/dist/cdn/drawer/snice-drawer.js +1 -1
  69. package/dist/cdn/empty-state/snice-empty-state.js +1 -1
  70. package/dist/cdn/estimate/snice-estimate.js +1 -1
  71. package/dist/cdn/file-gallery/snice-file-gallery.js +1 -1
  72. package/dist/cdn/file-upload/snice-file-upload.js +1 -1
  73. package/dist/cdn/flip-card/snice-flip-card.js +1 -1
  74. package/dist/cdn/flow/snice-flow.js +1 -1
  75. package/dist/cdn/form-layout/snice-form-layout.js +1 -1
  76. package/dist/cdn/funnel/snice-funnel.js +1 -1
  77. package/dist/cdn/gantt/snice-gantt.js +1 -1
  78. package/dist/cdn/gauge/snice-gauge.js +1 -1
  79. package/dist/cdn/grid/snice-grid.js +1 -1
  80. package/dist/cdn/heatmap/snice-heatmap.js +1 -1
  81. package/dist/cdn/image/snice-image.js +1 -1
  82. package/dist/cdn/input/snice-input.js +1 -1
  83. package/dist/cdn/invoice/snice-invoice.js +1 -1
  84. package/dist/cdn/kanban/snice-kanban.js +1 -1
  85. package/dist/cdn/key-value/snice-key-value.js +1 -1
  86. package/dist/cdn/kpi/snice-kpi.js +1 -1
  87. package/dist/cdn/layout/snice-layout.js +1 -1
  88. package/dist/cdn/leaderboard/snice-leaderboard.js +1 -1
  89. package/dist/cdn/link/snice-link.js +1 -1
  90. package/dist/cdn/link-preview/snice-link-preview.js +1 -1
  91. package/dist/cdn/list/snice-list.js +1 -1
  92. package/dist/cdn/location/snice-location.js +1 -1
  93. package/dist/cdn/login/snice-login.js +1 -1
  94. package/dist/cdn/map/snice-map.js +1 -1
  95. package/dist/cdn/markdown/snice-markdown.js +1 -1
  96. package/dist/cdn/masonry/snice-masonry.js +1 -1
  97. package/dist/cdn/menu/snice-menu.js +1 -1
  98. package/dist/cdn/message-strip/snice-message-strip.js +1 -1
  99. package/dist/cdn/modal/snice-modal.js +1 -1
  100. package/dist/cdn/music-player/snice-music-player.js +1 -1
  101. package/dist/cdn/nav/snice-nav.js +1 -1
  102. package/dist/cdn/network-graph/snice-network-graph.js +1 -1
  103. package/dist/cdn/notification-center/snice-notification-center.js +1 -1
  104. package/dist/cdn/order-tracker/snice-order-tracker.js +1 -1
  105. package/dist/cdn/org-chart/snice-org-chart.js +1 -1
  106. package/dist/cdn/pagination/snice-pagination.js +1 -1
  107. package/dist/cdn/paint/snice-paint.js +1 -1
  108. package/dist/cdn/pdf-viewer/snice-pdf-viewer.js +1 -1
  109. package/dist/cdn/permission-matrix/snice-permission-matrix.js +1 -1
  110. package/dist/cdn/podcast-player/snice-podcast-player.js +1 -1
  111. package/dist/cdn/popover/snice-popover.js +1 -1
  112. package/dist/cdn/pricing-table/snice-pricing-table.js +1 -1
  113. package/dist/cdn/product-card/snice-product-card.js +1 -1
  114. package/dist/cdn/progress/snice-progress.js +2 -2
  115. package/dist/cdn/progress/snice-progress.js.map +1 -1
  116. package/dist/cdn/progress/snice-progress.min.js +1 -1
  117. package/dist/cdn/progress/snice-progress.min.js.map +1 -1
  118. package/dist/cdn/progress-ring/snice-progress-ring.js +1 -1
  119. package/dist/cdn/qr-code/README.md +2 -2
  120. package/dist/cdn/qr-code/snice-qr-code.js +19 -3
  121. package/dist/cdn/qr-code/snice-qr-code.js.map +1 -1
  122. package/dist/cdn/qr-code/snice-qr-code.min.js +2 -2
  123. package/dist/cdn/qr-code/snice-qr-code.min.js.map +1 -1
  124. package/dist/cdn/qr-reader/snice-qr-reader.js +1 -1
  125. package/dist/cdn/radio/snice-radio.js +1 -1
  126. package/dist/cdn/range-slider/snice-range-slider.js +1 -1
  127. package/dist/cdn/rating/snice-rating.js +1 -1
  128. package/dist/cdn/receipt/snice-receipt.js +5 -2
  129. package/dist/cdn/receipt/snice-receipt.js.map +1 -1
  130. package/dist/cdn/receipt/snice-receipt.min.js +1 -1
  131. package/dist/cdn/receipt/snice-receipt.min.js.map +1 -1
  132. package/dist/cdn/recipe/snice-recipe.js +1 -1
  133. package/dist/cdn/runtime/snice-runtime.esm.js +4 -4
  134. package/dist/cdn/runtime/snice-runtime.esm.js.map +1 -1
  135. package/dist/cdn/runtime/snice-runtime.esm.min.js.map +1 -1
  136. package/dist/cdn/runtime/snice-runtime.js +4 -4
  137. package/dist/cdn/runtime/snice-runtime.js.map +1 -1
  138. package/dist/cdn/runtime/snice-runtime.min.js.map +1 -1
  139. package/dist/cdn/sankey/snice-sankey.js +1 -1
  140. package/dist/cdn/segmented-control/snice-segmented-control.js +1 -1
  141. package/dist/cdn/select/README.md +2 -2
  142. package/dist/cdn/select/snice-select.js +6 -9
  143. package/dist/cdn/select/snice-select.js.map +1 -1
  144. package/dist/cdn/select/snice-select.min.js +2 -2
  145. package/dist/cdn/select/snice-select.min.js.map +1 -1
  146. package/dist/cdn/skeleton/snice-skeleton.js +1 -1
  147. package/dist/cdn/slider/snice-slider.js +1 -1
  148. package/dist/cdn/sortable/snice-sortable.js +1 -1
  149. package/dist/cdn/sparkline/snice-sparkline.js +1 -1
  150. package/dist/cdn/spinner/snice-spinner.js +1 -1
  151. package/dist/cdn/split-button/snice-split-button.js +1 -1
  152. package/dist/cdn/split-pane/snice-split-pane.js +1 -1
  153. package/dist/cdn/spotlight/snice-spotlight.js +1 -1
  154. package/dist/cdn/stat-group/snice-stat-group.js +1 -1
  155. package/dist/cdn/step-input/snice-step-input.js +1 -1
  156. package/dist/cdn/stepper/snice-stepper.js +1 -1
  157. package/dist/cdn/switch/snice-switch.js +1 -1
  158. package/dist/cdn/table/README.md +2 -2
  159. package/dist/cdn/table/snice-table.esm.js +1940 -407
  160. package/dist/cdn/table/snice-table.esm.js.map +1 -1
  161. package/dist/cdn/table/snice-table.esm.min.js +242 -53
  162. package/dist/cdn/table/snice-table.esm.min.js.map +1 -1
  163. package/dist/cdn/table/snice-table.js +1940 -407
  164. package/dist/cdn/table/snice-table.js.map +1 -1
  165. package/dist/cdn/table/snice-table.min.js +234 -45
  166. package/dist/cdn/table/snice-table.min.js.map +1 -1
  167. package/dist/cdn/tabs/snice-tabs.js +1 -1
  168. package/dist/cdn/tag/snice-tag.js +1 -1
  169. package/dist/cdn/tag-input/snice-tag-input.js +1 -1
  170. package/dist/cdn/terminal/snice-terminal.js +1 -1
  171. package/dist/cdn/testimonial/snice-testimonial.js +1 -1
  172. package/dist/cdn/textarea/snice-textarea.js +1 -1
  173. package/dist/cdn/time-picker/snice-time-picker.js +1 -1
  174. package/dist/cdn/time-range-picker/snice-time-range-picker.js +1 -1
  175. package/dist/cdn/timeline/snice-timeline.js +1 -1
  176. package/dist/cdn/timer/snice-timer.js +1 -1
  177. package/dist/cdn/toast/snice-toast.js +1 -1
  178. package/dist/cdn/tooltip/snice-tooltip.js +1 -1
  179. package/dist/cdn/tree/snice-tree.js +1 -1
  180. package/dist/cdn/treemap/snice-treemap.js +1 -1
  181. package/dist/cdn/user-card/snice-user-card.js +1 -1
  182. package/dist/cdn/video-player/snice-video-player.js +1 -1
  183. package/dist/cdn/virtual-scroller/snice-virtual-scroller.js +1 -1
  184. package/dist/cdn/waterfall/snice-waterfall.js +1 -1
  185. package/dist/cdn/weather/snice-weather.js +1 -1
  186. package/dist/cdn/work-order/snice-work-order.js +1 -1
  187. package/dist/components/calendar/snice-calendar.d.ts +187 -5
  188. package/dist/components/calendar/snice-calendar.js +537 -44
  189. package/dist/components/calendar/snice-calendar.js.map +1 -1
  190. package/dist/components/calendar/snice-calendar.types.d.ts +2 -1
  191. package/dist/components/code-block/formatter.d.ts +6 -0
  192. package/dist/components/code-block/formatter.js +174 -164
  193. package/dist/components/code-block/formatter.js.map +1 -1
  194. package/dist/components/code-block/grammars/snice.json +1 -1
  195. package/dist/components/code-block/grammars/typescript.json +1 -1
  196. package/dist/components/command-palette/snice-command-palette.js +1 -1
  197. package/dist/components/command-palette/snice-command-palette.js.map +1 -1
  198. package/dist/components/progress/snice-progress.js +1 -1
  199. package/dist/components/progress/snice-progress.js.map +1 -1
  200. package/dist/components/qr-code/qrcode.d.ts +1 -1
  201. package/dist/components/qr-code/qrcode.js +18 -2
  202. package/dist/components/qr-code/qrcode.js.map +1 -1
  203. package/dist/components/receipt/snice-receipt.js +4 -1
  204. package/dist/components/receipt/snice-receipt.js.map +1 -1
  205. package/dist/components/select/snice-select.d.ts +1 -1
  206. package/dist/components/select/snice-select.js +5 -8
  207. package/dist/components/select/snice-select.js.map +1 -1
  208. package/dist/components/table/snice-cell-actions.js +1 -1
  209. package/dist/components/table/snice-cell-actions.js.map +1 -1
  210. package/dist/components/table/snice-cell-color.js +1 -1
  211. package/dist/components/table/snice-cell-color.js.map +1 -1
  212. package/dist/components/table/snice-cell-date.js +20 -8
  213. package/dist/components/table/snice-cell-date.js.map +1 -1
  214. package/dist/components/table/snice-cell-email.js +1 -1
  215. package/dist/components/table/snice-cell-email.js.map +1 -1
  216. package/dist/components/table/snice-cell-image.js +1 -1
  217. package/dist/components/table/snice-cell-image.js.map +1 -1
  218. package/dist/components/table/snice-cell-json.js +1 -1
  219. package/dist/components/table/snice-cell-json.js.map +1 -1
  220. package/dist/components/table/snice-cell-link.js +1 -1
  221. package/dist/components/table/snice-cell-link.js.map +1 -1
  222. package/dist/components/table/snice-cell-location.js +1 -1
  223. package/dist/components/table/snice-cell-location.js.map +1 -1
  224. package/dist/components/table/snice-cell-phone.js +1 -1
  225. package/dist/components/table/snice-cell-phone.js.map +1 -1
  226. package/dist/components/table/snice-cell-status.js +1 -1
  227. package/dist/components/table/snice-cell-status.js.map +1 -1
  228. package/dist/components/table/snice-cell.d.ts +10 -0
  229. package/dist/components/table/snice-cell.js +59 -13
  230. package/dist/components/table/snice-cell.js.map +1 -1
  231. package/dist/components/table/snice-row.d.ts +0 -1
  232. package/dist/components/table/snice-row.js +4 -10
  233. package/dist/components/table/snice-row.js.map +1 -1
  234. package/dist/components/table/snice-table.d.ts +170 -2
  235. package/dist/components/table/snice-table.js +868 -189
  236. package/dist/components/table/snice-table.js.map +1 -1
  237. package/dist/components/table/snice-table.types.d.ts +17 -0
  238. package/dist/components/table/table-cell-presentation.d.ts +17 -4
  239. package/dist/components/table/table-cell-presentation.js +37 -11
  240. package/dist/components/table/table-cell-presentation.js.map +1 -1
  241. package/dist/components/table/table-column-manager.d.ts +153 -2
  242. package/dist/components/table/table-column-manager.js +364 -43
  243. package/dist/components/table/table-column-manager.js.map +1 -1
  244. package/dist/components/table/table-date.d.ts +29 -0
  245. package/dist/components/table/table-date.js +78 -0
  246. package/dist/components/table/table-date.js.map +1 -0
  247. package/dist/components/table/table-export.js +6 -2
  248. package/dist/components/table/table-export.js.map +1 -1
  249. package/dist/components/table/table-filter-engine.d.ts +9 -0
  250. package/dist/components/table/table-filter-engine.js +58 -14
  251. package/dist/components/table/table-filter-engine.js.map +1 -1
  252. package/dist/components/table/table-keyboard.d.ts +8 -0
  253. package/dist/components/table/table-keyboard.js +14 -3
  254. package/dist/components/table/table-keyboard.js.map +1 -1
  255. package/dist/components/table/table-toolbar.js +1 -0
  256. package/dist/components/table/table-toolbar.js.map +1 -1
  257. package/dist/components/table/table-tree-data.d.ts +2 -0
  258. package/dist/components/table/table-tree-data.js +35 -9
  259. package/dist/components/table/table-tree-data.js.map +1 -1
  260. package/dist/components/table/table-virtualizer.d.ts +44 -7
  261. package/dist/components/table/table-virtualizer.js +101 -54
  262. package/dist/components/table/table-virtualizer.js.map +1 -1
  263. package/dist/components/theme/theme.css +9 -0
  264. package/dist/index.cjs +1 -1
  265. package/dist/index.esm.js +1 -1
  266. package/dist/index.iife.js +1 -1
  267. package/dist/react/SniceProvider.js +1 -1
  268. package/dist/react/SniceRouter.js +1 -1
  269. package/dist/react/index.js +1 -1
  270. package/dist/react/matchRoute.js +1 -1
  271. package/dist/react/useRequestHandler.js +1 -1
  272. package/dist/symbols.cjs +1 -1
  273. package/dist/symbols.esm.js +1 -1
  274. package/dist/testing-dom.cjs +1 -1
  275. package/dist/testing-dom.esm.js +1 -1
  276. package/dist/transitions.cjs +1 -1
  277. package/dist/transitions.esm.js +1 -1
  278. package/docs/ai/DEVELOPMENT.md +93 -2
  279. package/docs/ai/components/calendar.md +113 -10
  280. package/docs/ai/components/code-block.md +4 -0
  281. package/docs/ai/components/doc.md +1 -1
  282. package/docs/ai/components/progress.md +1 -0
  283. package/docs/ai/components/receipt.md +1 -1
  284. package/docs/ai/components/table.md +29 -30
  285. package/docs/ai/components/theme.md +1 -0
  286. package/docs/ai/events.md +30 -0
  287. package/docs/ai/theme.md +1 -0
  288. package/docs/components/calendar.md +171 -11
  289. package/docs/components/code-block.md +2 -0
  290. package/docs/components/doc.md +1 -1
  291. package/docs/components/progress.md +13 -0
  292. package/docs/components/receipt.md +1 -0
  293. package/docs/components/table.md +274 -15
  294. package/docs/components/theme.md +5 -0
  295. package/docs/events.md +49 -0
  296. package/docs/plans/2026-03-06-date-range-picker-plan.md +4 -4
  297. package/docs/plans/2026-03-07-table-mui-parity-design.md +1 -1
  298. package/docs/plans/2026-03-09-action-bar-plan.md +11 -11
  299. package/docs/superpowers/specs/2026-07-09-table-incredible-design.md +1 -1
  300. package/docs/superpowers/specs/2026-07-09-table-phase0-implementation.md +2 -2
  301. package/package.json +3 -1
  302. package/vscode.html-custom-data.json +13 -1
@@ -6,6 +6,7 @@ import '../button/snice-button.js';
6
6
  import '../checkbox/snice-checkbox.js';
7
7
  import '../modal/snice-modal.js';
8
8
  import '../empty-state/snice-empty-state.js';
9
+ import '../progress/snice-progress.js';
9
10
  import './snice-cell.js';
10
11
  import './snice-cell-text.js';
11
12
  import './snice-cell-number.js';
@@ -31,7 +32,7 @@ import './snice-cell-image.js';
31
32
  import './snice-column.js';
32
33
  import './snice-row.js';
33
34
  import { TableVirtualizer } from './table-virtualizer.js';
34
- import { TableColumnManager } from './table-column-manager.js';
35
+ import { horizontalCellChrome, TableColumnManager } from './table-column-manager.js';
35
36
  import { TableFilterEngine } from './table-filter-engine.js';
36
37
  import { TableEditor } from './table-editor.js';
37
38
  import { TableKeyboard } from './table-keyboard.js';
@@ -42,16 +43,22 @@ import { TableTreeData } from './table-tree-data.js';
42
43
  import { TableGrouping } from './table-grouping.js';
43
44
  import { TableColumnMenu } from './table-column-menu.js';
44
45
  import { TableRowDnD, TableColumnDnD } from './table-row-dnd.js';
46
+ import { defaultCellAlign, emptyCellValue } from './table-cell-presentation.js';
45
47
  import '../utils.js';
46
48
  import '../icons/index.js';
47
49
  import '../form-control-validity.js';
48
50
  import '../form-label-association.js';
49
51
  import '../select/snice-option.js';
50
52
  import '../snice-cell-l6czK2XW.js';
51
- import './table-cell-presentation.js';
53
+ import './table-date.js';
52
54
  import '../rating/snice-rating.js';
53
55
  import './snice-progress.js';
54
56
 
57
+ /**
58
+ * The one string both loading affordances use — the zero-row message row shows
59
+ * it, the refetch overlay announces it. Kept together so they can never drift.
60
+ */
61
+ const LOADING_LABEL = 'Loading…';
55
62
  let SniceTable = (() => {
56
63
  let _classDecorators = [element('snice-table')];
57
64
  let _classDescriptor;
@@ -146,6 +153,9 @@ let SniceTable = (() => {
146
153
  let _density_decorators;
147
154
  let _density_initializers = [];
148
155
  let _density_extraInitializers = [];
156
+ let _columnFit_decorators;
157
+ let _columnFit_initializers = [];
158
+ let _columnFit_extraInitializers = [];
149
159
  let _headerFilters_decorators;
150
160
  let _headerFilters_initializers = [];
151
161
  let _headerFilters_extraInitializers = [];
@@ -221,6 +231,7 @@ let SniceTable = (() => {
221
231
  let _handleColumnReorderChange_decorators;
222
232
  let _handleLazyLoadChange_decorators;
223
233
  let _handleColumnMenuChange_decorators;
234
+ let _handleColumnFitChange_decorators;
224
235
  let _dispatchPageChange_decorators;
225
236
  let _dispatchRowSelectionChanged_decorators;
226
237
  let _dispatchRowClicked_decorators;
@@ -258,8 +269,8 @@ let SniceTable = (() => {
258
269
  _totalItems_decorators = [property({ type: Number, attribute: 'total-items' })];
259
270
  _pageSizes_decorators = [property({ type: Array, attribute: false })];
260
271
  _searchDebounce_decorators = [property({ type: Number, attribute: 'search-debounce' })];
261
- _columns_decorators = [property({ attribute: false })];
262
- _data_decorators = [property({ attribute: false })];
272
+ _columns_decorators = [property({ attribute: false, hasChanged: () => true })];
273
+ _data_decorators = [property({ attribute: false, hasChanged: () => true })];
263
274
  _mode_decorators = [property()];
264
275
  _currentSort_decorators = [property({ type: Array, attribute: false })];
265
276
  _selector_decorators = [property({ type: String, attribute: 'selector' })];
@@ -272,6 +283,7 @@ let SniceTable = (() => {
272
283
  _editable_decorators = [property({ type: Boolean, attribute: 'editable' })];
273
284
  _editMode_decorators = [property({ attribute: 'edit-mode' })];
274
285
  _density_decorators = [property({ attribute: 'density' })];
286
+ _columnFit_decorators = [property({ attribute: 'column-fit' })];
275
287
  _headerFilters_decorators = [property({ type: Boolean, attribute: 'header-filters' })];
276
288
  _quickFilter_decorators = [property({ type: Boolean, attribute: 'quick-filter' })];
277
289
  _rowReorder_decorators = [property({ type: Boolean, attribute: 'row-reorder' })];
@@ -319,6 +331,7 @@ let SniceTable = (() => {
319
331
  _handleColumnReorderChange_decorators = [watch('columnReorder', { immediate: false })];
320
332
  _handleLazyLoadChange_decorators = [watch('lazyLoad', 'lazyLoadThreshold', { immediate: false })];
321
333
  _handleColumnMenuChange_decorators = [watch('columnMenu', { immediate: false })];
334
+ _handleColumnFitChange_decorators = [watch('columnFit', { immediate: false })];
322
335
  _dispatchPageChange_decorators = [dispatch('page-change', { bubbles: true, composed: true })];
323
336
  _dispatchRowSelectionChanged_decorators = [dispatch('table-row-selection-changed', { bubbles: true, composed: true })];
324
337
  _dispatchRowClicked_decorators = [dispatch('row-clicked', { bubbles: true, composed: true })];
@@ -368,6 +381,7 @@ let SniceTable = (() => {
368
381
  __esDecorate(this, null, _handleColumnReorderChange_decorators, { kind: "method", name: "handleColumnReorderChange", static: false, private: false, access: { has: obj => "handleColumnReorderChange" in obj, get: obj => obj.handleColumnReorderChange }, metadata: _metadata }, null, _instanceExtraInitializers);
369
382
  __esDecorate(this, null, _handleLazyLoadChange_decorators, { kind: "method", name: "handleLazyLoadChange", static: false, private: false, access: { has: obj => "handleLazyLoadChange" in obj, get: obj => obj.handleLazyLoadChange }, metadata: _metadata }, null, _instanceExtraInitializers);
370
383
  __esDecorate(this, null, _handleColumnMenuChange_decorators, { kind: "method", name: "handleColumnMenuChange", static: false, private: false, access: { has: obj => "handleColumnMenuChange" in obj, get: obj => obj.handleColumnMenuChange }, metadata: _metadata }, null, _instanceExtraInitializers);
384
+ __esDecorate(this, null, _handleColumnFitChange_decorators, { kind: "method", name: "handleColumnFitChange", static: false, private: false, access: { has: obj => "handleColumnFitChange" in obj, get: obj => obj.handleColumnFitChange }, metadata: _metadata }, null, _instanceExtraInitializers);
371
385
  __esDecorate(this, null, _dispatchPageChange_decorators, { kind: "method", name: "dispatchPageChange", static: false, private: false, access: { has: obj => "dispatchPageChange" in obj, get: obj => obj.dispatchPageChange }, metadata: _metadata }, null, _instanceExtraInitializers);
372
386
  __esDecorate(this, null, _dispatchRowSelectionChanged_decorators, { kind: "method", name: "dispatchRowSelectionChanged", static: false, private: false, access: { has: obj => "dispatchRowSelectionChanged" in obj, get: obj => obj.dispatchRowSelectionChanged }, metadata: _metadata }, null, _instanceExtraInitializers);
373
387
  __esDecorate(this, null, _dispatchRowClicked_decorators, { kind: "method", name: "dispatchRowClicked", static: false, private: false, access: { has: obj => "dispatchRowClicked" in obj, get: obj => obj.dispatchRowClicked }, metadata: _metadata }, null, _instanceExtraInitializers);
@@ -412,6 +426,7 @@ let SniceTable = (() => {
412
426
  __esDecorate(null, null, _editable_decorators, { kind: "field", name: "editable", static: false, private: false, access: { has: obj => "editable" in obj, get: obj => obj.editable, set: (obj, value) => { obj.editable = value; } }, metadata: _metadata }, _editable_initializers, _editable_extraInitializers);
413
427
  __esDecorate(null, null, _editMode_decorators, { kind: "field", name: "editMode", static: false, private: false, access: { has: obj => "editMode" in obj, get: obj => obj.editMode, set: (obj, value) => { obj.editMode = value; } }, metadata: _metadata }, _editMode_initializers, _editMode_extraInitializers);
414
428
  __esDecorate(null, null, _density_decorators, { kind: "field", name: "density", static: false, private: false, access: { has: obj => "density" in obj, get: obj => obj.density, set: (obj, value) => { obj.density = value; } }, metadata: _metadata }, _density_initializers, _density_extraInitializers);
429
+ __esDecorate(null, null, _columnFit_decorators, { kind: "field", name: "columnFit", static: false, private: false, access: { has: obj => "columnFit" in obj, get: obj => obj.columnFit, set: (obj, value) => { obj.columnFit = value; } }, metadata: _metadata }, _columnFit_initializers, _columnFit_extraInitializers);
415
430
  __esDecorate(null, null, _headerFilters_decorators, { kind: "field", name: "headerFilters", static: false, private: false, access: { has: obj => "headerFilters" in obj, get: obj => obj.headerFilters, set: (obj, value) => { obj.headerFilters = value; } }, metadata: _metadata }, _headerFilters_initializers, _headerFilters_extraInitializers);
416
431
  __esDecorate(null, null, _quickFilter_decorators, { kind: "field", name: "quickFilter", static: false, private: false, access: { has: obj => "quickFilter" in obj, get: obj => obj.quickFilter, set: (obj, value) => { obj.quickFilter = value; } }, metadata: _metadata }, _quickFilter_initializers, _quickFilter_extraInitializers);
417
432
  __esDecorate(null, null, _rowReorder_decorators, { kind: "field", name: "rowReorder", static: false, private: false, access: { has: obj => "rowReorder" in obj, get: obj => obj.rowReorder, set: (obj, value) => { obj.rowReorder = value; } }, metadata: _metadata }, _rowReorder_initializers, _rowReorder_extraInitializers);
@@ -455,6 +470,11 @@ let SniceTable = (() => {
455
470
  // Wait for next frame to ensure DOM is updated
456
471
  await new Promise(resolve => requestAnimationFrame(resolve));
457
472
  this.renderHeader();
473
+ // The body render the columns assignment queued above can have been
474
+ // consumed against a not-yet-committed <tbody>. Repainting the header
475
+ // without the rows is what leaves a remote table showing headings over an
476
+ // empty frame, so this pass owns both.
477
+ this.renderBody();
458
478
  this.renderControls();
459
479
  return config;
460
480
  }
@@ -484,8 +504,7 @@ let SniceTable = (() => {
484
504
  if (response.totalItems !== undefined) {
485
505
  this.totalItems = response.totalItems;
486
506
  }
487
- this.loadError = null;
488
- this.classList.remove('table--error');
507
+ this.clearLoadError();
489
508
  this.loading = false;
490
509
  // Wait for next frame to ensure DOM is updated
491
510
  await new Promise(resolve => requestAnimationFrame(resolve));
@@ -504,14 +523,49 @@ let SniceTable = (() => {
504
523
  this.loading = false;
505
524
  // Wait for next frame to ensure DOM is updated
506
525
  await new Promise(resolve => requestAnimationFrame(resolve));
526
+ // Same guard the success path applies above: a newer request can settle
527
+ // (and paint) inside this frame, and this failure's repaint would then be
528
+ // a second full body rebuild owed to nobody.
529
+ if (seq !== this.dataRequestSeq)
530
+ return;
507
531
  this.renderBody();
508
532
  }
509
533
  }
534
+ /**
535
+ * Requeue a body render whose target had not been committed yet. A frame is
536
+ * the right granularity — the shadow template commits between frames — and
537
+ * the paint is dropped again (silently, as before) once the retries run out.
538
+ *
539
+ * The retry CHAINS: a template that needs more than one frame to commit is
540
+ * the whole reason this exists, so a retry that still finds no <tbody> spends
541
+ * one more of the budget rather than giving the frame back and quitting.
542
+ */
543
+ rearmBodyRender() {
544
+ if (this.bodyRenderRetryQueued || this.bodyRenderRetries >= 3)
545
+ return;
546
+ this.bodyRenderRetryQueued = true;
547
+ this.bodyRenderRetries++;
548
+ requestAnimationFrame(() => {
549
+ this.bodyRenderRetryQueued = false;
550
+ if (this.tbody)
551
+ this.renderBody();
552
+ else
553
+ this.rearmBodyRender();
554
+ });
555
+ }
510
556
  scheduleRender(what) {
511
557
  if (what === 'header' || what === 'both')
512
558
  this.pendingHeaderRender = true;
513
- if (what === 'body' || what === 'both')
559
+ if (what === 'body' || what === 'both') {
514
560
  this.pendingBodyRender = true;
561
+ // A NEWLY requested paint gets a fresh budget. The bound exists to stop
562
+ // one render from spinning against a template that structurally has no
563
+ // <tbody> (the slotted-rows branch), not to disqualify the table forever:
564
+ // without this reset, three misses during the slotted phase silently
565
+ // cancel every body render that follows a later flip to the native table.
566
+ if (!this.bodyRenderRetryQueued)
567
+ this.bodyRenderRetries = 0;
568
+ }
515
569
  if (this.renderQueued)
516
570
  return;
517
571
  this.renderQueued = true;
@@ -526,14 +580,28 @@ let SniceTable = (() => {
526
580
  const doBody = this.pendingBodyRender;
527
581
  this.pendingHeaderRender = false;
528
582
  this.pendingBodyRender = false;
529
- if (doHeader)
530
- this.renderHeader();
583
+ try {
584
+ if (doHeader)
585
+ this.renderHeader();
586
+ }
587
+ catch (error) {
588
+ // Both flags are already consumed. Letting a header failure escape
589
+ // would cancel the body paint queued alongside it and leave the rows
590
+ // permanently stale — the exact "header fine, rows blank" symptom.
591
+ console.error('Error rendering table header:', error);
592
+ }
531
593
  if (doBody)
532
594
  this.renderBody();
533
595
  });
534
596
  });
535
597
  }
536
598
  debouncedDataRequest() {
599
+ // The supersede decision belongs to the moment the NEW request is decided
600
+ // on, not to the moment its timeout fires. Bumping here closes the 150ms
601
+ // window in which an already-obsolete in-flight response still satisfied
602
+ // `seq === this.dataRequestSeq` and painted rows for the previous sort /
603
+ // filter / page under a header that already advertises the new one.
604
+ this.dataRequestSeq++;
537
605
  // Set loading immediately for instant feedback
538
606
  if (!this.loading) {
539
607
  this.loading = true;
@@ -621,6 +689,72 @@ let SniceTable = (() => {
621
689
  /* no border or radius — handled by .table-frame */
622
690
  }
623
691
 
692
+ /* column-fit="squish": the frame's width is the constraint.
693
+ *
694
+ * The arithmetic (syncColumnWidthsToFrame → fitVisibleColumns) already
695
+ * lands the columns on the frame's content width, and fixed layout hands
696
+ * any rounding slack back to the columns, so the rightmost edge sits on
697
+ * the frame's inner edge. These rules are the guarantee behind that:
698
+ * hiding the horizontal overflow means a stray pixel — a sub-pixel
699
+ * border, a browser rounding a percentage the other way — can never turn
700
+ * into a scrollbar the mode promised would not appear. Vertical scrolling
701
+ * is untouched. */
702
+ :host([column-fit="squish"]) .table-frame {
703
+ overflow-x: hidden;
704
+ }
705
+
706
+ :host([column-fit="squish"]) table {
707
+ max-width: 100%;
708
+ }
709
+
710
+ /* Squished columns are narrower than their content by design, so the
711
+ * content has to end in an ellipsis rather than wrap the row taller (or
712
+ * push a word past the clip edge). */
713
+ :host([column-fit="squish"]) th,
714
+ :host([column-fit="squish"]) td {
715
+ white-space: nowrap;
716
+ }
717
+
718
+ :host([column-fit="squish"]) td > snice-cell-text,
719
+ :host([column-fit="squish"]) td > .custom-cell,
720
+ :host([column-fit="squish"]) td > .tree-label {
721
+ display: block;
722
+ overflow: hidden;
723
+ text-overflow: ellipsis;
724
+ }
725
+
726
+ /* The typed cells (email, status, link, phone, location, json, colour,
727
+ * image, actions) each declare a 100px floor on their host AND on their
728
+ * inner .cell-content. That floor is a sensible default at natural width
729
+ * and fatal here: a squished column is routinely narrower than 100px, the
730
+ * cell refuses to shrink to it, and the td's own overflow: hidden then
731
+ * chops the label mid-glyph — no ellipsis, because the element that owns
732
+ * the ellipsis never got small enough to trigger it.
733
+ *
734
+ * .cell-content lives in the cell's shadow root, so no descendant
735
+ * selector from here can reach it. A custom property can: it inherits
736
+ * across the boundary, the cells read it with the same 100px as the
737
+ * fallback, and nothing outside a squished td sees any change. */
738
+ :host([column-fit="squish"]) td {
739
+ --snice-table-cell-min-width: 0;
740
+ }
741
+
742
+ /* The header label is the table's own markup and needs the same promise:
743
+ * "Department" clipped to "Departme" reads as a different word. A
744
+ * sortable header puts the label in a flex row beside the sort
745
+ * indicator, and a flex item only shrinks below its content once it is
746
+ * allowed to — hence min-width: 0 alongside the ellipsis. A plain header
747
+ * is bare text in the th, which ellipsises on the th itself. */
748
+ :host([column-fit="squish"]) th {
749
+ text-overflow: ellipsis;
750
+ }
751
+
752
+ :host([column-fit="squish"]) th .sort-header > span {
753
+ overflow: hidden;
754
+ text-overflow: ellipsis;
755
+ min-width: 0;
756
+ }
757
+
624
758
  /* Super-header (slotted area above column headers) */
625
759
  .table-superheader {
626
760
  background: var(--snice-color-surface, rgb(255 255 255));
@@ -743,9 +877,13 @@ let SniceTable = (() => {
743
877
  background-color: var(--snice-color-surface-container-low, rgb(245 245 245));
744
878
  }
745
879
 
746
- /* Row styling */
880
+ /* Row styling. The zebra tint is an ALPHA overlay, not a surface step:
881
+ surface-container-low sits ~2 luminance points from the body surface
882
+ on dark themes, so striped rendered as no striping at all there.
883
+ An overlay also composites over pinned/tinted cells instead of
884
+ replacing their color. */
747
885
  :host([striped]) tbody tr:nth-child(even) {
748
- background-color: var(--snice-color-surface-container-low, rgb(245 245 245));
886
+ background-color: var(--snice-table-stripe-bg, var(--snice-color-overlay-stripe, hsl(0 0% 0% / 0.02)));
749
887
  }
750
888
 
751
889
  :host([hoverable]) tbody tr:hover {
@@ -1009,12 +1147,14 @@ let SniceTable = (() => {
1009
1147
  justify-content: center;
1010
1148
  }
1011
1149
 
1012
- /* Loading fade */
1013
- tbody {
1150
+ /* Loading fade. Scoped to ROWS, never the whole tbody: with no rows the
1151
+ only thing in there is the loading indicator, and dimming that halves
1152
+ the contrast of the one element whose entire job is to be seen. */
1153
+ tbody > tr {
1014
1154
  transition: opacity var(--snice-transition-normal, 250ms);
1015
1155
  }
1016
1156
 
1017
- :host([loading]) tbody {
1157
+ :host([loading]) tbody > tr:not(.table-message-row) {
1018
1158
  opacity: 0.5;
1019
1159
  }
1020
1160
 
@@ -1029,6 +1169,33 @@ let SniceTable = (() => {
1029
1169
  z-index: 2;
1030
1170
  }
1031
1171
 
1172
+ /* Zero-row loading: spinner + the word, centred in the empty body. */
1173
+ .table-loading-state {
1174
+ display: flex;
1175
+ flex-direction: column;
1176
+ align-items: center;
1177
+ justify-content: center;
1178
+ gap: var(--snice-spacing-xs, 0.5rem);
1179
+ color: var(--snice-color-text-secondary, rgb(82 82 82));
1180
+ }
1181
+
1182
+ .table-loading-label {
1183
+ font-size: var(--snice-font-size-sm, 0.875rem);
1184
+ line-height: 1;
1185
+ }
1186
+
1187
+ /* snice-progress draws its circular track at 20% alpha, which disappears
1188
+ entirely on a dark surface — the ring the field report saw was a
1189
+ near-black hairline on near-black. Both table spinners opt out and use
1190
+ the border token at full strength, so the track reads in either
1191
+ theme and the primary arc always has something to travel around. */
1192
+ .table-loading-state snice-progress,
1193
+ .table-loading-overlay snice-progress {
1194
+ --progress-track-opacity: 1;
1195
+ --progress-bg: var(--snice-color-border, rgb(226 226 226));
1196
+ --progress-color: var(--snice-color-primary, rgb(37 99 235));
1197
+ }
1198
+
1032
1199
  .no-data {
1033
1200
  text-align: center;
1034
1201
  padding: var(--snice-spacing-lg, 1.5rem);
@@ -1170,10 +1337,58 @@ let SniceTable = (() => {
1170
1337
  background: var(--snice-color-primary, rgb(37 99 235));
1171
1338
  }
1172
1339
 
1173
- /* Pinned column separator */
1340
+ /* Pinned columns.
1341
+
1342
+ A frozen column must be opaque — scrolled cells pass underneath it —
1343
+ and a header keeps the header's own surface rather than borrowing the
1344
+ body's. The frozen EDGE (the inner-most pinned column on each side) is
1345
+ what the reader actually needs to see: a heavier divider plus a soft
1346
+ shadow cast INTO the scrolling region, mirrored per side. Between two
1347
+ columns pinned to the same side there is no boundary to mark, so they
1348
+ keep the ordinary cell border.
1349
+
1350
+ The shadow is a border-token tint, not black: a black drop shadow is
1351
+ invisible against a dark surface, which is exactly how pinning came to
1352
+ have no affordance at all in the dark theme. */
1174
1353
  .pinned-cell {
1175
1354
  background: var(--snice-color-surface, rgb(255 255 255));
1176
- box-shadow: 2px 0 4px -1px rgb(0 0 0 / 0.1);
1355
+ }
1356
+
1357
+ th.pinned-cell {
1358
+ background: var(--snice-color-surface-container-low, rgb(245 245 245));
1359
+ }
1360
+
1361
+ .pinned-cell--left.pinned-cell--edge {
1362
+ border-right: 2px solid var(--snice-color-border, rgb(226 226 226));
1363
+ box-shadow: 6px 0 8px -6px var(--snice-color-border, rgb(226 226 226));
1364
+ }
1365
+
1366
+ .pinned-cell--right.pinned-cell--edge {
1367
+ border-left: 2px solid var(--snice-color-border, rgb(226 226 226));
1368
+ box-shadow: -6px 0 8px -6px var(--snice-color-border, rgb(226 226 226));
1369
+ }
1370
+
1371
+ /* A right-pinned header is often the last cell in the row, where the
1372
+ shared rule above strips the right border. Its divider is on the left,
1373
+ so nothing to restore — but the edge border must survive that rule. */
1374
+ th:last-child.pinned-cell--right.pinned-cell--edge,
1375
+ td:last-child.pinned-cell--right.pinned-cell--edge {
1376
+ border-right: none;
1377
+ }
1378
+
1379
+ /* Pin marker: the only cue when the table is wide enough not to scroll. */
1380
+ .pin-indicator {
1381
+ display: inline-flex;
1382
+ align-items: center;
1383
+ margin-left: var(--snice-spacing-2xs, 0.25rem);
1384
+ color: var(--snice-color-text-secondary, rgb(82 82 82));
1385
+ opacity: 0.65;
1386
+ vertical-align: middle;
1387
+ pointer-events: none;
1388
+ }
1389
+
1390
+ .pin-indicator--right {
1391
+ transform: scaleX(-1);
1177
1392
  }
1178
1393
 
1179
1394
  /* Density: compact */
@@ -1596,7 +1811,7 @@ let SniceTable = (() => {
1596
1811
  }
1597
1812
 
1598
1813
  :host([striped]) .snice-table--slotted .table-body::slotted(snice-row:nth-child(even)) {
1599
- background-color: var(--snice-color-surface-container-low, rgb(245 245 245));
1814
+ background-color: var(--snice-table-stripe-bg, var(--snice-color-overlay-stripe, hsl(0 0% 0% / 0.02)));
1600
1815
  }
1601
1816
 
1602
1817
  :host([hoverable]) .snice-table--slotted .table-body::slotted(snice-row:hover) {
@@ -1702,9 +1917,18 @@ let SniceTable = (() => {
1702
1917
  }
1703
1918
  container.appendChild(controlsDiv);
1704
1919
  }
1705
- // Re-derive the fill row whenever the frame's box changes (host resized,
1706
- // fullscreen toggled) — the leftover space below the rows changes with it.
1920
+ // Re-derive the frame-dependent layout whenever the frame's box changes
1921
+ // (host resized, fullscreen toggled): the columns share its width, and the
1922
+ // leftover space below the rows changes with its height.
1707
1923
  handleFrameResize() {
1924
+ // Only when the width actually moved. Fitting rewrites header widths, which
1925
+ // can change row heights, which can flip the frame's vertical scrollbar and
1926
+ // call us straight back — the guard stops that becoming a cycle.
1927
+ const width = this.shadowRoot?.querySelector('.table-frame')?.clientWidth ?? 0;
1928
+ if (width > 0 && width !== this.lastFittedFrameWidth) {
1929
+ this.lastFittedFrameWidth = width;
1930
+ this.syncColumnWidthsToFrame();
1931
+ }
1708
1932
  this.updateFillRow();
1709
1933
  }
1710
1934
  async initialize() {
@@ -1807,7 +2031,18 @@ let SniceTable = (() => {
1807
2031
  }
1808
2032
  }
1809
2033
  this.data.splice(toIndex, 0, item);
1810
- this.data = [...this.data];
2034
+ // Reordering the SAME rows is not a delivery: a failed reload keeps its
2035
+ // error until data actually arrives, so a drag must not dismiss it.
2036
+ // Everything else the data watcher does (row index, detail prep, render)
2037
+ // is still wanted here — hence a narrow flag rather than
2038
+ // `settingSortedData`, which skips all of it.
2039
+ this.reorderingRows = true;
2040
+ try {
2041
+ this.data = [...this.data];
2042
+ }
2043
+ finally {
2044
+ this.reorderingRows = false;
2045
+ }
1811
2046
  this.selectedRows = selectedRows
1812
2047
  .map((row) => this.indexOfRow(row))
1813
2048
  .filter((index) => index >= 0);
@@ -1921,7 +2156,17 @@ let SniceTable = (() => {
1921
2156
  this.renderHeader(); // Re-render header to show/hide sort indicators
1922
2157
  }
1923
2158
  handleSelectableChange() {
1924
- this.render(); // Re-render both header and body for checkbox columns
2159
+ this.render(); // host template (the structural mode can change)
2160
+ // The checkbox tool column is structural: like the sibling selection-mode
2161
+ // watcher, a post-mount toggle has to rebuild the header (select-all th)
2162
+ // AND every row (row checkbox) — the host template render leaves the
2163
+ // imperatively built thead/tbody untouched, so without this the two
2164
+ // disagree and every data cell sits one column off its header. Rows are
2165
+ // never recycled stale across the toggle: computeStructuralSig folds in
2166
+ // `sel=`. Skipped before the first render — the initial paint reads
2167
+ // `selectable` itself.
2168
+ if (this.thead)
2169
+ this.scheduleRender('both');
1925
2170
  queueMicrotask(() => this.syncSlottedSelectionState());
1926
2171
  }
1927
2172
  // C1: reactive columns. A new column set can change cell types/formatting
@@ -1930,8 +2175,12 @@ let SniceTable = (() => {
1930
2175
  // construction is deferred to the coalescing queue (setter-stack landmine).
1931
2176
  handleColumnsAssignment() {
1932
2177
  this.renderedRows = new Map();
2178
+ // A new array is a NEW CONFIGURATION: its order / pinned / width / declared
2179
+ // set replace whatever the column model is holding, and states for columns
2180
+ // it no longer declares are dropped. initialize() is the render-path resync
2181
+ // that must NOT do this (it would undo a drag-resize on every paint).
1933
2182
  if (this.columns.length > 0)
1934
- this.columnManager.initialize(this.columns, this);
2183
+ this.columnManager.applyConfiguration(this.columns, this);
1935
2184
  // F: a column set change can add/remove an `aggregate` — resync the grouping
1936
2185
  // model's aggregator list and drop its stale flattened snapshot.
1937
2186
  this.grouping.setColumns(this.columns);
@@ -2004,9 +2253,21 @@ let SniceTable = (() => {
2004
2253
  // and the row-index map, then renders the body. The internal sort path
2005
2254
  // (sortLocalData) reassigns this.data with a re-sorted view of the SAME rows
2006
2255
  // and owns its own snapshot/render — settingSortedData tells us to skip.
2007
- handleDataAssignment() {
2256
+ handleDataAssignment(oldVal) {
2008
2257
  if (this.settingSortedData)
2009
2258
  return;
2259
+ // A load error describes the LAST delivery ATTEMPT, so a dataset arriving
2260
+ // from any source supersedes it — including the app falling back to local
2261
+ // rows (or to none) after the remote load failed. Clearing it only on the
2262
+ // remote success path left `loadError` and the `table--error` host class
2263
+ // alive forever, so the next zero-row render showed the stale ⚠️ where the
2264
+ // empty state belonged.
2265
+ //
2266
+ // A REORDER is not an arrival: the row-reorder handler republishes the same
2267
+ // rows in a new array, and dragging a row must not dismiss the ⚠️ the
2268
+ // contract says survives until data actually arrives.
2269
+ if (!this.reorderingRows)
2270
+ this.clearLoadError();
2010
2271
  this.unsortedData = [...this.data];
2011
2272
  // C3: rows delivered while a client-side sort is active must land in that
2012
2273
  // sorted order — the currentSort watcher only fires when the sort itself
@@ -2015,11 +2276,25 @@ let SniceTable = (() => {
2015
2276
  if (this.mode !== 'remote' && this.currentSort.length > 0)
2016
2277
  this.applyLocalSortOrder();
2017
2278
  this.rebuildRowIndex();
2279
+ // Re-delivering the SAME rows in a different order must not move the
2280
+ // selection to whatever row inherited the index (no-op for a new dataset).
2281
+ if (oldVal)
2282
+ this.remapSelectionAfterReorder(oldVal);
2018
2283
  this.masterDetail.prepare(this.data, row => this.rowValueSignature(row));
2019
2284
  if (this.settingDataImperative)
2020
2285
  return; // imperative setData(): caller renders
2021
2286
  this.scheduleRender('body');
2022
2287
  }
2288
+ /**
2289
+ * Drop the failed-load state: the ⚠️ message row's text and the `table--error`
2290
+ * host class always move together, so they are cleared together and from one
2291
+ * place. Idempotent, and deliberately does NOT render — every caller is either
2292
+ * mid-delivery (the data watcher renders) or already renders itself.
2293
+ */
2294
+ clearLoadError() {
2295
+ this.loadError = null;
2296
+ this.classList.remove('table--error');
2297
+ }
2023
2298
  handleDataChange() {
2024
2299
  this.renderBody();
2025
2300
  this.updateLoadingOverlay();
@@ -2039,11 +2314,12 @@ let SniceTable = (() => {
2039
2314
  overlay = document.createElement('div');
2040
2315
  overlay.className = 'table-loading-overlay';
2041
2316
  overlay.setAttribute('part', 'loading-overlay');
2042
- const spinner = document.createElement('snice-progress');
2043
- spinner.setAttribute('variant', 'circular');
2044
- spinner.setAttribute('indeterminate', '');
2045
- spinner.setAttribute('size', 'small');
2046
- overlay.appendChild(spinner);
2317
+ // The overlay sits ON TOP of readable rows, so it carries its label as
2318
+ // an aria-label rather than visible text — but it must still announce.
2319
+ overlay.setAttribute('role', 'status');
2320
+ overlay.setAttribute('aria-live', 'polite');
2321
+ overlay.setAttribute('aria-label', LOADING_LABEL);
2322
+ overlay.appendChild(this.createLoadingSpinner('medium'));
2047
2323
  frame.appendChild(overlay);
2048
2324
  }
2049
2325
  else if (!show && overlay) {
@@ -2106,11 +2382,26 @@ let SniceTable = (() => {
2106
2382
  this.sortLocalData();
2107
2383
  }
2108
2384
  handleCurrentPageChange() {
2385
+ // A clamp written by the display model itself is not a navigation — see
2386
+ // clampCurrentPage().
2387
+ if (this.clampingPage)
2388
+ return;
2109
2389
  if (this.paginationMode === 'server' && this.mode === 'remote')
2110
2390
  this.debouncedDataRequest();
2111
2391
  else
2112
2392
  this.scheduleRender('body');
2113
2393
  }
2394
+ clampCurrentPage(page) {
2395
+ if (page === this.currentPage)
2396
+ return;
2397
+ this.clampingPage = true;
2398
+ try {
2399
+ this.currentPage = page;
2400
+ }
2401
+ finally {
2402
+ this.clampingPage = false;
2403
+ }
2404
+ }
2114
2405
  handlePageSizeChange() {
2115
2406
  this.currentPage = 1;
2116
2407
  if (this.paginationMode === 'server' && this.mode === 'remote')
@@ -2126,7 +2417,9 @@ let SniceTable = (() => {
2126
2417
  }
2127
2418
  handleListChange() {
2128
2419
  this.renderedRows = new Map();
2129
- this.scheduleRender('body');
2420
+ // The header is part of the mode: entering list mode with a renderer drops
2421
+ // the columnar header, and leaving it must bring the header back.
2422
+ this.scheduleRender('both');
2130
2423
  }
2131
2424
  handleEditableChange() {
2132
2425
  if (this.editable)
@@ -2184,6 +2477,17 @@ let SniceTable = (() => {
2184
2477
  this.initColumnMenu();
2185
2478
  this.scheduleRender('header');
2186
2479
  }
2480
+ /**
2481
+ * Switching fit policy re-decides every unsized column's width, so the frame
2482
+ * has to be re-fitted from scratch: the cached width would otherwise make the
2483
+ * resize observer's "did the frame move?" guard swallow the very next pass.
2484
+ */
2485
+ handleColumnFitChange() {
2486
+ this.columnManager.setFitMode(this.columnFit);
2487
+ this.lastFittedFrameWidth = 0;
2488
+ this.syncColumnWidthsToFrame();
2489
+ this.scheduleRender('header');
2490
+ }
2187
2491
  /** Column definitions in their actual painted order. Column state owns
2188
2492
  * visibility/order; pinned columns are partitioned to their physical edges
2189
2493
  * so right-pinned cells do not overlap later unpinned columns. */
@@ -2200,9 +2504,124 @@ let SniceTable = (() => {
2200
2504
  .map((state) => byKey.get(state.key))
2201
2505
  .filter((column) => !!column);
2202
2506
  }
2507
+ /**
2508
+ * Cells one full-width row spans: every visible data column plus the tool
2509
+ * columns that precede them. Group, aggregate, detail, empty-state, and
2510
+ * virtual-spacer rows all measure themselves against this — a row that spans
2511
+ * MORE than the table has invents columns, and `table-layout: fixed` then
2512
+ * spends the frame's leftover width on the phantoms.
2513
+ */
2514
+ getTotalColumnSpan() {
2515
+ const managed = this.columnManager.getAllStates();
2516
+ const dataColumns = managed.length > 0
2517
+ ? this.columnManager.getVisibleColumns().length
2518
+ : this.columns.length;
2519
+ return dataColumns
2520
+ + (this.hasSelectionColumn() ? 1 : 0)
2521
+ + (this.masterDetail.isEnabled() ? 1 : 0)
2522
+ + (this.rowReorder && this.rowDnD.isEnabled() ? 1 : 0);
2523
+ }
2524
+ /**
2525
+ * Re-fit the columns nobody sized to the frame they are painted in.
2526
+ *
2527
+ * A column width is a CONTENT width, so the frame's budget has to be reduced
2528
+ * by each column's own padding + border and by the fixed tool columns before
2529
+ * it can be shared out. Both are read off the live header — the previous
2530
+ * render's, on the first pass — rather than guessed, so the fitted table
2531
+ * lands on the frame's width instead of a few pixels past it.
2532
+ *
2533
+ * Widths are written straight onto the header cells: under fixed layout the
2534
+ * first row owns the column grid, so no body repaint is needed and this can
2535
+ * run inside a resize observer without a render cascade.
2536
+ */
2537
+ syncColumnWidthsToFrame() {
2538
+ // The policy is the table's, the arithmetic is the manager's: hand it over
2539
+ // before every fit so a `columnFit` change is honoured on the next pass
2540
+ // whether it arrived as a property, an attribute, or an upgrade.
2541
+ this.columnManager.setFitMode(this.columnFit);
2542
+ const frame = this.shadowRoot?.querySelector('.table-frame');
2543
+ const headerRow = this.shadowRoot?.querySelector('thead tr.column-header-row');
2544
+ if (!frame || !headerRow)
2545
+ return false;
2546
+ const available = frame.clientWidth;
2547
+ // Layoutless DOM (or a display:none host): every measurement is 0 and
2548
+ // fitting to it would slam the columns to their minimums for nothing.
2549
+ if (!(available > 0))
2550
+ return false;
2551
+ const dataCells = Array.from(headerRow.querySelectorAll('th[data-key]'));
2552
+ // An active list renderer drops the columnar header entirely; there is no
2553
+ // column geometry to fit and no cell to measure the chrome from.
2554
+ if (dataCells.length === 0)
2555
+ return false;
2556
+ let reserved = 0;
2557
+ for (const cell of Array.from(headerRow.children)) {
2558
+ if (!cell.hasAttribute('data-key'))
2559
+ reserved += cell.getBoundingClientRect().width;
2560
+ }
2561
+ const chrome = horizontalCellChrome(dataCells[0]);
2562
+ if (!this.columnManager.fitVisibleColumns(available - reserved, chrome))
2563
+ return false;
2564
+ for (const cell of dataCells) {
2565
+ const state = this.columnManager.getState(cell.getAttribute('data-key') || '');
2566
+ if (state)
2567
+ cell.style.width = `${state.width}px`;
2568
+ }
2569
+ return true;
2570
+ }
2571
+ /**
2572
+ * Sticky geometry plus the visible affordance for a pinned cell.
2573
+ *
2574
+ * Freezing a column is only communicated if the reader can see where the
2575
+ * frozen region ends, so the inner-most pinned column on each side carries
2576
+ * the divider (`--edge`); the header additionally carries a pin marker, which
2577
+ * is the only cue when the table happens to be wide enough not to scroll.
2578
+ */
2579
+ applyPinnedCell(cell, key, state, zIndex) {
2580
+ if (state.pinned !== 'left' && state.pinned !== 'right')
2581
+ return;
2582
+ const side = state.pinned;
2583
+ const columns = side === 'left'
2584
+ ? this.columnManager.getPinnedLeft()
2585
+ : this.columnManager.getPinnedRight();
2586
+ const offsets = side === 'left'
2587
+ ? this.columnManager.getPinnedLeftOffsets()
2588
+ : this.columnManager.getPinnedRightOffsets();
2589
+ // The edge of the frozen region: the last left-pinned column, the first
2590
+ // right-pinned one. Inner boundaries between two pinned columns are just
2591
+ // ordinary cell borders.
2592
+ const edgeKey = side === 'left' ? columns[columns.length - 1]?.key : columns[0]?.key;
2593
+ cell.classList.add('pinned-cell', `pinned-cell--${side}`);
2594
+ if (key === edgeKey)
2595
+ cell.classList.add('pinned-cell--edge');
2596
+ cell.style.position = 'sticky';
2597
+ cell.style[side] = `${offsets.get(key) ?? 0}px`;
2598
+ cell.style.zIndex = zIndex;
2599
+ }
2600
+ /** The pin marker a frozen header carries, so pinning reads at any width. */
2601
+ static pinIndicator(side) {
2602
+ return `<span class="pin-indicator pin-indicator--${side}" aria-hidden="true">`
2603
+ + `<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor">`
2604
+ + `<path d="M9.5 1a1 1 0 0 0-.7 1.7l.3.3-3 3-2.4-.5a1 1 0 0 0-.9 1.7l3 3-3.6 3.6a.6.6 0 0 0 .8.8L6.6 12l3 3a1 1 0 0 0 1.7-.9l-.5-2.4 3-3 .3.3a1 1 0 0 0 1.4-1.4l-5.3-5.3A1 1 0 0 0 9.5 1Z"/>`
2605
+ + `</svg></span>`;
2606
+ }
2607
+ /**
2608
+ * List mode is only "list mode" once a renderer is installed: `list` alone is
2609
+ * a border treatment and still paints normal columnar cells. With a renderer
2610
+ * every row collapses to one full-width cell, so there is no column geometry
2611
+ * left for a header to label.
2612
+ */
2613
+ usesListRowRenderer() {
2614
+ return !!(this.list && this.listRenderer);
2615
+ }
2203
2616
  renderHeader() {
2204
2617
  if (!this.thead)
2205
2618
  return;
2619
+ // A card/directory list has no columns, so a columnar header would promise
2620
+ // fields that align with nothing. Suppress the data-column headers (and the
2621
+ // equally columnar group and header-filter rows) while a list renderer is
2622
+ // active; the tool columns below still have real body cells to head, so
2623
+ // select-all is kept rather than lost as collateral.
2624
+ const listRows = this.usesListRowRenderer();
2206
2625
  const headerRow = document.createElement('tr');
2207
2626
  headerRow.className = 'column-header-row';
2208
2627
  // Tool column headers — must match createRow order
@@ -2240,7 +2659,7 @@ let SniceTable = (() => {
2240
2659
  }
2241
2660
  headerRow.appendChild(selectCell);
2242
2661
  }
2243
- const visibleColumns = this.getVisibleColumnDefinitions();
2662
+ const visibleColumns = listRows ? [] : this.getVisibleColumnDefinitions();
2244
2663
  visibleColumns.forEach(column => {
2245
2664
  const th = document.createElement('th');
2246
2665
  th.setAttribute('data-key', column.key);
@@ -2257,23 +2676,10 @@ let SniceTable = (() => {
2257
2676
  }
2258
2677
  // Apply column width
2259
2678
  const state = this.columnManager.getState(column.key);
2679
+ const pinnedSide = state?.pinned === 'left' || state?.pinned === 'right' ? state.pinned : null;
2260
2680
  if (state) {
2261
2681
  th.style.width = `${state.width}px`;
2262
- // Pinned column sticky positioning
2263
- if (state.pinned === 'left') {
2264
- const offsets = this.columnManager.getPinnedLeftOffsets();
2265
- th.classList.add('pinned-cell');
2266
- th.style.position = 'sticky';
2267
- th.style.left = `${offsets.get(column.key) ?? 0}px`;
2268
- th.style.zIndex = '2';
2269
- }
2270
- else if (state.pinned === 'right') {
2271
- const offsets = this.columnManager.getPinnedRightOffsets();
2272
- th.classList.add('pinned-cell');
2273
- th.style.position = 'sticky';
2274
- th.style.right = `${offsets.get(column.key) ?? 0}px`;
2275
- th.style.zIndex = '2';
2276
- }
2682
+ this.applyPinnedCell(th, column.key, state, '2');
2277
2683
  }
2278
2684
  if (this.sortable && column.sortable !== false) {
2279
2685
  th.classList.add('sortable');
@@ -2281,11 +2687,22 @@ let SniceTable = (() => {
2281
2687
  // A role=button must be focusable and named, or the keyboard cannot
2282
2688
  // sort at all. Matches the declarative header in snice-header.ts.
2283
2689
  th.setAttribute('tabindex', '0');
2284
- th.setAttribute('aria-label', `Sort by ${column.label}`);
2690
+ // Frozen columns say so: the divider is a visual cue only.
2691
+ th.setAttribute('aria-label', pinnedSide
2692
+ ? `Sort by ${column.label}, pinned ${pinnedSide}`
2693
+ : `Sort by ${column.label}`);
2285
2694
  th.innerHTML = this.renderSortableHeader(column);
2286
2695
  }
2287
2696
  else {
2288
2697
  th.textContent = column.label;
2698
+ if (pinnedSide)
2699
+ th.setAttribute('aria-label', `${column.label}, pinned ${pinnedSide}`);
2700
+ }
2701
+ if (pinnedSide) {
2702
+ // Inside the label, not after it: `.sort-header` is a full-width flex
2703
+ // row, so a sibling would be pushed onto a second line.
2704
+ const label = th.querySelector('.sort-header > span') ?? th;
2705
+ label.insertAdjacentHTML('beforeend', SniceTable.pinIndicator(pinnedSide));
2289
2706
  }
2290
2707
  // Filter indicator
2291
2708
  if (this.filterEngine.hasColumnFilter(column.key)) {
@@ -2338,7 +2755,7 @@ let SniceTable = (() => {
2338
2755
  });
2339
2756
  this.thead.innerHTML = '';
2340
2757
  // Column groups header row (if any)
2341
- const groups = this.columnManager.getColumnGroups();
2758
+ const groups = listRows ? [] : this.columnManager.getColumnGroups();
2342
2759
  if (groups.length > 0) {
2343
2760
  const groupRow = document.createElement('tr');
2344
2761
  groupRow.className = 'column-group-row';
@@ -2360,9 +2777,12 @@ let SniceTable = (() => {
2360
2777
  groupRow.appendChild(groupedHeaders.content);
2361
2778
  this.thead.appendChild(groupRow);
2362
2779
  }
2363
- this.thead.appendChild(headerRow);
2780
+ // A list-mode header row with no tool columns has nothing to show; leaving
2781
+ // it out keeps the thead empty so it paints no strip above the cards.
2782
+ if (!listRows || headerRow.children.length > 0)
2783
+ this.thead.appendChild(headerRow);
2364
2784
  // Header filter row (if enabled)
2365
- if (this.headerFilters) {
2785
+ if (this.headerFilters && !listRows) {
2366
2786
  const filterRow = document.createElement('tr');
2367
2787
  filterRow.className = 'header-filter-row';
2368
2788
  if (this.rowReorder && this.rowDnD.isEnabled()) {
@@ -2399,6 +2819,11 @@ let SniceTable = (() => {
2399
2819
  });
2400
2820
  this.thead.appendChild(filterRow);
2401
2821
  }
2822
+ // The header is in the DOM, so its real chrome and tool-column widths are
2823
+ // measurable: fit the unsized columns to the frame before the browser
2824
+ // paints. Widths land on the header cells that were just created, so this
2825
+ // never re-enters renderHeader.
2826
+ this.syncColumnWidthsToFrame();
2402
2827
  }
2403
2828
  renderSortableHeader(column) {
2404
2829
  const sortItem = this.currentSort.find(s => s.column === column.key);
@@ -2430,9 +2855,90 @@ let SniceTable = (() => {
2430
2855
  </div>
2431
2856
  `;
2432
2857
  }
2858
+ /**
2859
+ * The single message row a zero-row body shows: loading spinner, load error,
2860
+ * or the empty state (the `empty-state` slot cloned fresh, else the default
2861
+ * placeholder). Returns null when there is data to render. Shared by the
2862
+ * ordinary and virtualized paths — the virtualizer wipes the tbody itself, so
2863
+ * it asks for this row rather than leaving a virtualized empty table blank.
2864
+ */
2865
+ /**
2866
+ * The spinner both loading affordances draw. `snice-progress` paints its
2867
+ * track at 20% alpha, which vanishes against a dark surface — the table pins
2868
+ * `--progress-track-opacity` in its own stylesheet so the ring reads on any
2869
+ * theme; the indicator arc keeps the primary color.
2870
+ */
2871
+ createLoadingSpinner(size) {
2872
+ const spinner = document.createElement('snice-progress');
2873
+ spinner.setAttribute('variant', 'circular');
2874
+ spinner.setAttribute('indeterminate', '');
2875
+ spinner.setAttribute('size', size);
2876
+ return spinner;
2877
+ }
2878
+ createEmptyBodyRow() {
2879
+ if (this.data.length > 0 || this.columns.length === 0)
2880
+ return null;
2881
+ const toolCols = (this.hasSelectionColumn() ? 1 : 0)
2882
+ + (this.masterDetail.isEnabled() ? 1 : 0)
2883
+ + (this.rowReorder && this.rowDnD.isEnabled() ? 1 : 0);
2884
+ const tr = document.createElement('tr');
2885
+ tr.className = 'table-message-row';
2886
+ const td = document.createElement('td');
2887
+ td.colSpan = this.columns.length + toolCols;
2888
+ td.className = 'no-data';
2889
+ tr.appendChild(td);
2890
+ if (this.loading) {
2891
+ // A bare ring in an otherwise empty rectangle reads as a rendering
2892
+ // artefact, not as "we are fetching your rows": at `small` it is 24px of
2893
+ // low-contrast stroke with nothing naming it. The zero-row body has the
2894
+ // space for a full-size spinner AND the word, and the pair is what makes
2895
+ // the state legible — visually and to a screen reader.
2896
+ const status = document.createElement('div');
2897
+ status.className = 'table-loading-state';
2898
+ status.setAttribute('role', 'status');
2899
+ status.setAttribute('aria-live', 'polite');
2900
+ status.appendChild(this.createLoadingSpinner('medium'));
2901
+ const label = document.createElement('span');
2902
+ label.className = 'table-loading-label';
2903
+ label.textContent = LOADING_LABEL;
2904
+ status.appendChild(label);
2905
+ td.appendChild(status);
2906
+ }
2907
+ else if (this.loadError) {
2908
+ // Surfaces a failed remote load instead of silently falling through to
2909
+ // the generic "No data" empty state.
2910
+ const errorDiv = document.createElement('div');
2911
+ errorDiv.className = 'table-error-message';
2912
+ errorDiv.textContent = `⚠️ ${this.loadError}`;
2913
+ td.appendChild(errorDiv);
2914
+ }
2915
+ else {
2916
+ const slotted = this.querySelector('[slot="empty-state"]');
2917
+ if (slotted) {
2918
+ td.appendChild(slotted.cloneNode(true));
2919
+ }
2920
+ else {
2921
+ const empty = document.createElement('snice-empty-state');
2922
+ empty.setAttribute('size', 'small');
2923
+ empty.setAttribute('icon', '📭');
2924
+ empty.setAttribute('title', 'No data');
2925
+ empty.setAttribute('description', 'No records to display.');
2926
+ td.appendChild(empty);
2927
+ }
2928
+ }
2929
+ return tr;
2930
+ }
2433
2931
  renderBody() {
2434
- if (!this.tbody)
2932
+ // The queue flush consumed `pendingBodyRender` before calling us, so a body
2933
+ // render that arrives while the shadow template has not committed its
2934
+ // <tbody> (a `table/config` response landing before @ready, a structural
2935
+ // branch flip mid-flight) would simply vanish — the header repaints and the
2936
+ // rows stay stale forever. Re-arm it for the next frame instead.
2937
+ if (!this.tbody) {
2938
+ this.rearmBodyRender();
2435
2939
  return;
2940
+ }
2941
+ this.bodyRenderRetries = 0;
2436
2942
  // Update column manager when columns change
2437
2943
  if (this.columns.length > 0) {
2438
2944
  this.columnManager.initialize(this.columns, this);
@@ -2464,8 +2970,7 @@ let SniceTable = (() => {
2464
2970
  // NOT the raw filtered data — otherwise the virtualizer windows the wrong
2465
2971
  // dataset and expand/collapse silently no-ops.
2466
2972
  if (this.virtualize && this.virtualizer.isEnabled()) {
2467
- this.virtualRowsSnapshot = this.getVirtualRows();
2468
- this.virtualizer.setTotalRows(this.virtualRowsSnapshot.length);
2973
+ this.syncVirtualRows();
2469
2974
  // The virtualizer's afterRender hook re-establishes grid ARIA/focus on
2470
2975
  // the freshly inserted window.
2471
2976
  // Still render pagination
@@ -2474,64 +2979,15 @@ let SniceTable = (() => {
2474
2979
  this.updateSelectAllState();
2475
2980
  return;
2476
2981
  }
2477
- if (this.data.length === 0 && this.columns.length > 0) {
2982
+ const emptyBodyRow = this.createEmptyBodyRow();
2983
+ if (emptyBodyRow) {
2478
2984
  // Empty / loading / error states replace the body with a single message
2479
2985
  // row. Reset the recycler map so a later data render starts from a clean
2480
2986
  // slate (no stale reuse candidates pointing at removed rows).
2481
2987
  this.tbody.innerHTML = '';
2482
2988
  this.renderedRows = new Map();
2483
- const toolCols = (this.hasSelectionColumn() ? 1 : 0)
2484
- + (this.masterDetail.isEnabled() ? 1 : 0)
2485
- + (this.rowReorder && this.rowDnD.isEnabled() ? 1 : 0);
2486
- const colSpan = this.columns.length + toolCols;
2487
- if (this.loading) {
2488
- // Show loading spinner
2489
- const tr = document.createElement('tr');
2490
- const td = document.createElement('td');
2491
- td.colSpan = colSpan;
2492
- td.className = 'no-data';
2493
- td.innerHTML = '<snice-progress variant="circular" indeterminate size="small"></snice-progress>';
2494
- tr.appendChild(td);
2495
- this.tbody.appendChild(tr);
2496
- return;
2497
- }
2498
- else if (this.loadError) {
2499
- // Show error state — surfaces a failed remote load instead of
2500
- // silently falling through to the generic "No data" empty state.
2501
- const tr = document.createElement('tr');
2502
- const td = document.createElement('td');
2503
- td.colSpan = colSpan;
2504
- td.className = 'no-data';
2505
- const errorDiv = document.createElement('div');
2506
- errorDiv.className = 'table-error-message';
2507
- errorDiv.textContent = `⚠️ ${this.loadError}`;
2508
- td.appendChild(errorDiv);
2509
- tr.appendChild(td);
2510
- this.tbody.appendChild(tr);
2511
- return;
2512
- }
2513
- else {
2514
- // Show empty state
2515
- const tr = document.createElement('tr');
2516
- const td = document.createElement('td');
2517
- td.colSpan = colSpan;
2518
- td.className = 'no-data';
2519
- const slotted = this.querySelector('[slot="empty-state"]');
2520
- if (slotted) {
2521
- td.appendChild(slotted.cloneNode(true));
2522
- }
2523
- else {
2524
- const empty = document.createElement('snice-empty-state');
2525
- empty.setAttribute('size', 'small');
2526
- empty.setAttribute('icon', '📭');
2527
- empty.setAttribute('title', 'No data');
2528
- empty.setAttribute('description', 'No records to display.');
2529
- td.appendChild(empty);
2530
- }
2531
- tr.appendChild(td);
2532
- this.tbody.appendChild(tr);
2533
- return;
2534
- }
2989
+ this.tbody.appendChild(emptyBodyRow);
2990
+ return;
2535
2991
  }
2536
2992
  // Apply client-side filters (cached snapshot — see getFilteredData).
2537
2993
  const filteredData = this.getFilteredData();
@@ -2543,15 +2999,23 @@ let SniceTable = (() => {
2543
2999
  if (this.pagination && this.paginationMode === 'client'
2544
3000
  && !this.usesGroupingDisplayModel() && !this.treeData.isEnabled()) {
2545
3001
  this.totalItems = filteredData.length;
2546
- const startIndex = (this.currentPage - 1) * this.pageSize;
3002
+ // Re-clamp before slicing. A shorter re-delivery (or a filter) can leave
3003
+ // `currentPage` past the new last page; slicing at the stale offset lands
3004
+ // off the end and renders an EMPTY body while the pager still advertises
3005
+ // the tail rows — blank until the user clicks a page button. Same
3006
+ // [1, totalPages] clamp goToPage() applies, and the same one the grouping
3007
+ // and tree display models already do before their own slice.
3008
+ // An empty set is left alone: a render before the rows arrive must not
3009
+ // rewrite the host's declared starting page.
3010
+ const totalPages = filteredData.length > 0
3011
+ ? Math.ceil(filteredData.length / this.pageSize)
3012
+ : this.currentPage;
3013
+ const page = Math.max(1, Math.min(this.currentPage, totalPages));
3014
+ this.clampCurrentPage(page);
3015
+ const startIndex = (page - 1) * this.pageSize;
2547
3016
  displayData = filteredData.slice(startIndex, startIndex + this.pageSize);
2548
3017
  }
2549
- const extraCols = (this.hasSelectionColumn() ? 1 : 0) + (this.masterDetail.isEnabled() ? 1 : 0) + (this.rowReorder && this.rowDnD.isEnabled() ? 1 : 0);
2550
- const managedColumns = this.columnManager.getAllStates();
2551
- const visibleColumnCount = managedColumns.length > 0
2552
- ? this.columnManager.getVisibleColumns().length
2553
- : this.columns.length;
2554
- const totalColSpan = visibleColumnCount + extraCols;
3018
+ const totalColSpan = this.getTotalColumnSpan();
2555
3019
  const structuralSig = this.computeStructuralSig();
2556
3020
  // Task B: describe the desired body as an ordered list of keyed entries,
2557
3021
  // then reconcile the tbody to match. Rows whose object + signature are
@@ -2647,6 +3111,19 @@ let SniceTable = (() => {
2647
3111
  restampIndex: index,
2648
3112
  create: () => this.createRow(rowData, index, treeRow),
2649
3113
  });
3114
+ // Tree data must compose with master-detail exactly like the ordinary
3115
+ // and grouping paths: createRow renders the toggle for every tree row,
3116
+ // so omitting this entry left that control inert. Generated gap nodes
3117
+ // (index -1) carry no data row and therefore no panel.
3118
+ if (index >= 0 && this.masterDetail.isEnabled() && this.masterDetail.isExpanded(index)) {
3119
+ entries.push({
3120
+ key: `__detail_${index}`,
3121
+ sig: 'detail',
3122
+ alwaysRebuild: true,
3123
+ create: () => this.masterDetail.createDetailRow(rowData, index, totalColSpan) ??
3124
+ document.createElement('tr'),
3125
+ });
3126
+ }
2650
3127
  });
2651
3128
  }
2652
3129
  else {
@@ -2704,6 +3181,20 @@ let SniceTable = (() => {
2704
3181
  * data row, so column widths, pinning, and alignment track the table
2705
3182
  * engine natively. Removed entirely when content fills (or overflows) the
2706
3183
  * frame, restoring the plain last-row styling.
3184
+ *
3185
+ * INVARIANT — the filler may only consume space the frame ALREADY has; space
3186
+ * that exists only BECAUSE of the filler must be given back. The filler is
3187
+ * table content, and `@observe('resize', '.table-frame')` re-derives it from
3188
+ * the frame's box, so on a host whose height is content-driven (the
3189
+ * documented `:host { height: 100% }` resolving against an auto or
3190
+ * max-content container — a stretched grid/flex item, a plain block parent)
3191
+ * the filler is an input to the very measurement that produced it: the frame
3192
+ * grows, the observer fires, the leftover computes positive again, and the
3193
+ * table inflates by one slack per animation frame without bound. No static
3194
+ * check distinguishes a content-driven frame from a genuinely sized one, so
3195
+ * this measures instead: apply the filler, then confirm the frame kept its
3196
+ * height. Any growth is self-inflicted and is subtracted from the filler;
3197
+ * once nothing honest is left, the frame had no free space to fill at all.
2707
3198
  */
2708
3199
  updateFillRow() {
2709
3200
  const frame = this.shadowRoot?.querySelector('.table-frame');
@@ -2718,22 +3209,45 @@ let SniceTable = (() => {
2718
3209
  const lastRow = rows[rows.length - 1];
2719
3210
  if (!lastRow)
2720
3211
  return;
2721
- const leftover = Math.floor(frame.clientHeight - tableEl.offsetHeight);
3212
+ // Baseline: the frame's height with no filler of ours in the box. Every
3213
+ // later reading is compared against it, so the correction below can never
3214
+ // hand back more than the filler itself created.
3215
+ const baseline = frame.clientHeight;
3216
+ let leftover = Math.floor(baseline - tableEl.offsetHeight);
2722
3217
  if (leftover <= 1)
2723
3218
  return;
2724
3219
  const fill = lastRow.cloneNode(true);
2725
3220
  fill.className = 'table-fill-row';
2726
- for (const attr of ['data-index', 'data-selected', 'role', 'aria-rowindex', 'aria-selected', 'tabindex', 'id']) {
3221
+ // `part` goes with the rest of the row identity: the filler is not "each
3222
+ // native body row", so a consumer's ::part(row)/::part(cell) rule must not
3223
+ // paint a phantom row across it.
3224
+ for (const attr of ['data-index', 'data-selected', 'role', 'aria-rowindex', 'aria-selected', 'tabindex', 'id', 'part']) {
2727
3225
  fill.removeAttribute(attr);
2728
3226
  }
2729
3227
  fill.setAttribute('aria-hidden', 'true');
2730
3228
  fill.querySelectorAll('td').forEach(td => {
2731
3229
  td.replaceChildren();
2732
- for (const attr of ['role', 'aria-colindex', 'tabindex', 'aria-selected'])
3230
+ for (const attr of ['role', 'aria-colindex', 'tabindex', 'aria-selected', 'part'])
2733
3231
  td.removeAttribute(attr);
2734
3232
  });
2735
3233
  fill.style.height = `${leftover}px`;
2736
3234
  tbody.appendChild(fill);
3235
+ // Feedback check. Reading `clientHeight` flushes layout, so each pass sees
3236
+ // the frame as it stands WITH the filler in it. A frame that kept its
3237
+ // baseline is genuinely sized and the filler stays; growth means the frame
3238
+ // is (partly) sized by its own content, so give exactly that back. Two
3239
+ // corrections settle every real case — a fully self-made leftover reaches
3240
+ // zero on the first — and the bounded loop guarantees termination.
3241
+ for (let attempt = 0; attempt < 3; attempt++) {
3242
+ const selfMade = frame.clientHeight - baseline;
3243
+ if (selfMade <= 0)
3244
+ return;
3245
+ leftover -= selfMade;
3246
+ if (leftover <= 1)
3247
+ break;
3248
+ fill.style.height = `${leftover}px`;
3249
+ }
3250
+ fill.remove();
2737
3251
  }
2738
3252
  // ── Task B: render-path reconciler ──────────────────────────────────────
2739
3253
  //
@@ -2891,7 +3405,7 @@ let SniceTable = (() => {
2891
3405
  restampRowIndex(el, index) {
2892
3406
  el.setAttribute('data-index', String(index));
2893
3407
  const isSelected = this.selectedRows.includes(index);
2894
- el.setAttribute('data-selected', String(isSelected));
3408
+ this.stampRowSelection(el, isSelected);
2895
3409
  const checkbox = el.querySelector('snice-checkbox.row-select');
2896
3410
  if (checkbox) {
2897
3411
  checkbox.setAttribute('data-row-index', String(index));
@@ -3078,24 +3592,30 @@ let SniceTable = (() => {
3078
3592
  span.textContent = rendered == null ? '' : String(rendered);
3079
3593
  return span;
3080
3594
  }
3081
- const tagName = this.getCellTagName(column.type);
3595
+ // A declared display formatter resolves the whole pipeline here — the
3596
+ // documented precedence is `formatter` first, `valueFormatter` as the
3597
+ // fallback — and its output is TEXT, so the column renders through the text
3598
+ // cell: a rating/progress/link/boolean cell cannot carry a display string
3599
+ // (its value channel is typed) and renders structure rather than text. This
3600
+ // is the rule the declarative <snice-row> path already uses.
3601
+ const displayText = this.cellDisplayText(column, value, row);
3602
+ const tagName = displayText === null ? this.getCellTagName(column.type) : 'snice-cell-text';
3082
3603
  const el = document.createElement(tagName);
3083
3604
  // Base attributes. Only force `align` if the column definition specifies
3084
3605
  // one; otherwise let the cell pick its type-appropriate default (numbers
3085
3606
  // right, rating/boolean center, text left). Hard-coding 'left' here fought
3086
- // the cell's own type-based text-align and made number values drift.
3607
+ // the cell's own type-based text-align and made number values drift — and a
3608
+ // formatted column keeps its TYPE's default, not the text cell's.
3087
3609
  el.setAttribute('type', column.type || 'text');
3088
3610
  if (column.align)
3089
3611
  el.setAttribute('align', column.align);
3612
+ else if (displayText !== null)
3613
+ el.setAttribute('align', defaultCellAlign(column.type));
3090
3614
  el.setAttribute('in-table', 'true');
3091
- // A declared display formatter makes the cell's value its DISPLAY text:
3092
- // the pipeline (valueGetter -> formatter, valueFormatter as the fallback)
3093
- // is resolved here, once, so the `value` channel a caller reads is what the
3094
- // cell actually shows. The cell is marked `formatted` and handed a column
3095
- // without the formatters, which keeps it from formatting again and tells
3096
- // the shared presentation contract to paint the text over any type-specific
3097
- // rendering (a rating/progress/link cell cannot render display text).
3098
- const displayText = this.cellDisplayText(column, value, row);
3615
+ // The cell's value IS its display value: with a formatter declared the cell
3616
+ // receives the formatted text and a column with the formatters removed, so
3617
+ // nothing formats twice and the `value` channel a caller reads is what the
3618
+ // cell shows.
3099
3619
  const cellColumn = displayText === null
3100
3620
  ? column
3101
3621
  : { ...column, formatter: undefined, valueFormatter: undefined };
@@ -3103,7 +3623,6 @@ let SniceTable = (() => {
3103
3623
  // JSON, action, and tag cells without a lossy attribute conversion. Keep a
3104
3624
  // primitive attribute for declarative inspection and CSS selectors.
3105
3625
  if (displayText !== null) {
3106
- el.setAttribute('formatted', '');
3107
3626
  el.value = displayText;
3108
3627
  el.setAttribute('value', displayText);
3109
3628
  }
@@ -3113,9 +3632,12 @@ let SniceTable = (() => {
3113
3632
  el.data = value;
3114
3633
  }
3115
3634
  else {
3116
- el.value = value ?? '';
3117
- if (value === null || typeof value !== 'object') {
3118
- el.setAttribute('value', String(value ?? ''));
3635
+ // An empty row value (null, or a field the row never carried) falls back
3636
+ // to the typed cell's own documented empty value, never to a blanket ''.
3637
+ const resolved = value ?? emptyCellValue(column.type);
3638
+ el.value = resolved;
3639
+ if (resolved !== null && typeof resolved !== 'object') {
3640
+ el.setAttribute('value', String(resolved));
3119
3641
  }
3120
3642
  }
3121
3643
  // Column definition as property (for formatter access etc.)
@@ -3332,16 +3854,36 @@ let SniceTable = (() => {
3332
3854
  updateRowSelectionState() {
3333
3855
  if (!this.tbody)
3334
3856
  return;
3335
- const rows = this.tbody.querySelectorAll('tr');
3336
- rows.forEach((row, index) => {
3857
+ // Key off each row's own `data-index`, never its DOM position: group
3858
+ // headers, aggregate footers, virtual spacers, and the inert fill row share
3859
+ // the tbody, so a positional walk hands a raw-data index to a non-data row
3860
+ // (and drifts every real row after it — on page 2 of a client-paged table
3861
+ // the offsets never lined up at all).
3862
+ const rows = this.tbody.querySelectorAll('tr[data-index]');
3863
+ rows.forEach(row => {
3864
+ const index = Number(row.getAttribute('data-index'));
3337
3865
  const isSelected = this.selectedRows.includes(index);
3338
- row.setAttribute('data-selected', String(isSelected));
3866
+ this.stampRowSelection(row, isSelected);
3339
3867
  const checkbox = row.querySelector('snice-checkbox.row-select');
3340
3868
  if (checkbox) {
3341
3869
  checkbox.checked = isSelected;
3342
3870
  }
3343
3871
  });
3344
3872
  }
3873
+ /**
3874
+ * The two selection reflections a row carries, written together: `data-selected`
3875
+ * drives the CSS, `aria-selected` is the documented a11y contract ("selected
3876
+ * data rows expose aria-selected"). The keyboard module stamps ARIA only on a
3877
+ * full body render, so the delta paths below have to carry it themselves —
3878
+ * otherwise a screen reader never hears an interaction-driven selection.
3879
+ */
3880
+ stampRowSelection(row, isSelected) {
3881
+ row.setAttribute('data-selected', String(isSelected));
3882
+ if (isSelected)
3883
+ row.setAttribute('aria-selected', 'true');
3884
+ else
3885
+ row.removeAttribute('aria-selected');
3886
+ }
3345
3887
  // A2: reflect one row's selection into the DOM (located by data-index). The
3346
3888
  // reconciler (Task B) re-stamps data-index on moved rows, so this stays
3347
3889
  // correct after reorder. No-op if the row isn't in the current window.
@@ -3351,7 +3893,7 @@ let SniceTable = (() => {
3351
3893
  const row = this.tbody.querySelector(`tr[data-index="${index}"]`);
3352
3894
  if (!row)
3353
3895
  return;
3354
- row.setAttribute('data-selected', String(isSelected));
3896
+ this.stampRowSelection(row, isSelected);
3355
3897
  const checkbox = row.querySelector('snice-checkbox.row-select');
3356
3898
  if (checkbox)
3357
3899
  checkbox.checked = isSelected;
@@ -3452,14 +3994,54 @@ let SniceTable = (() => {
3452
3994
  this.settingSortedData = false;
3453
3995
  }
3454
3996
  sortLocalData() {
3997
+ const previous = this.data;
3455
3998
  this.applyLocalSortOrder();
3456
3999
  // Sort replaced this.data (new order + reference) — resync the row-index
3457
4000
  // map and drop the stale filtered snapshot before re-rendering. Render is
3458
4001
  // queued (not synchronous): sortLocalData is reachable from the currentSort
3459
4002
  // setter stack via handleControlledSort, and cells must never construct there.
3460
4003
  this.rebuildRowIndex();
4004
+ this.remapSelectionAfterReorder(previous);
3461
4005
  this.scheduleRender('body');
3462
4006
  }
4007
+ /**
4008
+ * Selection is anchored to the ROW, not to its position. `selectedRows` holds
4009
+ * indices into `data`, so any reorder of that array — a local sort, or a
4010
+ * re-delivery of the same row objects in another order — has to re-resolve
4011
+ * them; otherwise the indices keep pointing at whatever row now occupies the
4012
+ * slot and the user's checkmark silently jumps to a different row (the same
4013
+ * remap the row-reorder/DnD path performs).
4014
+ *
4015
+ * Only a permutation is remapped: same length, same identities, no duplicates.
4016
+ * A genuinely new dataset keeps the documented raw-index semantics untouched,
4017
+ * and rows carrying no identity (duplicate objects) have nothing to anchor to.
4018
+ * Call AFTER rebuildRowIndex() — the remap reads the fresh row-index map.
4019
+ */
4020
+ remapSelectionAfterReorder(previous) {
4021
+ if (this.selectedRows.length === 0)
4022
+ return;
4023
+ if (previous === this.data || previous.length !== this.data.length)
4024
+ return;
4025
+ const identities = new Set(this.data);
4026
+ if (identities.size !== this.data.length)
4027
+ return;
4028
+ // BOTH sides have to be duplicate-free for "same length + subset" to mean
4029
+ // permutation: previous=[A,A] against data=[A,B] satisfies the subset test
4030
+ // while actually being a NEW dataset, and remapping it would move the
4031
+ // selection off the row the raw-index contract points at.
4032
+ if (new Set(previous).size !== previous.length)
4033
+ return;
4034
+ for (const row of previous)
4035
+ if (!identities.has(row))
4036
+ return;
4037
+ const remapped = this.selectedRows
4038
+ .map((index) => this.indexOfRow(previous[index]))
4039
+ .filter((index) => index >= 0);
4040
+ if (remapped.length === this.selectedRows.length
4041
+ && remapped.every((index, i) => index === this.selectedRows[i]))
4042
+ return;
4043
+ this.selectedRows = remapped;
4044
+ }
3463
4045
  dispatchRowSelectionChanged(rowIndex, selected) {
3464
4046
  return {
3465
4047
  selectedRows: this.selectedRows,
@@ -3579,20 +4161,37 @@ let SniceTable = (() => {
3579
4161
  const scrollContainer = this.getScrollContainer();
3580
4162
  if (!scrollContainer)
3581
4163
  return;
3582
- this.virtualRowsSnapshot = this.getVirtualRows();
4164
+ this.syncVirtualRows();
3583
4165
  this.virtualizer.attach({
3584
4166
  rowHeight: (displayIndex) => this.getVirtualRowHeight(displayIndex),
3585
4167
  bufferPx: this.virtualBuffer,
3586
4168
  totalRows: this.virtualRowsSnapshot.length,
3587
4169
  scrollContainer,
4170
+ // The host template swaps its structural branch (slotted rows ⇄ native
4171
+ // table) without re-attaching. Re-resolving keeps the window painting into
4172
+ // the LIVE tbody instead of the detached branch's orphaned one.
4173
+ resolveScrollContainer: () => this.getScrollContainer(),
3588
4174
  renderRange: (start, end) => this.renderRowRange(start, end),
4175
+ // Spacers span the real grid. A spacer wider than the table invents
4176
+ // columns for fixed layout to spend the frame's spare width on.
4177
+ resolveColumnSpan: () => this.getTotalColumnSpan(),
3589
4178
  // Pinned rows live outside the windowed range so they are always present,
3590
4179
  // exactly like the non-virtual path (renderBody).
3591
4180
  renderPinnedTop: () => this.renderPinnedRows(this.pinnedTopRows, 'top'),
3592
4181
  renderPinnedBottom: () => this.renderPinnedRows(this.pinnedBottomRows, 'bottom'),
4182
+ // A zero-row window still owes the user the empty/loading/error row the
4183
+ // ordinary path renders.
4184
+ renderEmptyBody: () => this.createEmptyBodyRow(),
3593
4185
  // Scroll-driven windows bypass renderBody; restore grid roles, logical
3594
4186
  // row indices, and roving focus after every inserted range.
3595
- afterRender: () => this.keyboard.refresh(),
4187
+ afterRender: () => {
4188
+ this.keyboard.refresh();
4189
+ // Every window rebuilds the tbody from scratch, taking any filler with
4190
+ // it. Virtualization is not exempt from the fill-row contract, and
4191
+ // renderBody()'s own tail call never runs on the virtual path, so the
4192
+ // filler is re-evaluated here instead.
4193
+ this.updateFillRow();
4194
+ },
3596
4195
  });
3597
4196
  }
3598
4197
  getVirtualRowHeight(displayIndex) {
@@ -3611,6 +4210,30 @@ let SniceTable = (() => {
3611
4210
  * virtualized); otherwise it is the filtered data. Each descriptor carries
3612
4211
  * the logical index used for `data-index`, selection, and detail lookups.
3613
4212
  */
4213
+ /**
4214
+ * Re-derive the windowed row model and hand it to the virtualizer.
4215
+ *
4216
+ * `virtualRowsSnapshot` is the ONLY state the paint pipeline caches, and
4217
+ * `renderRowRange` paints from it. Any caller that resolves a display index
4218
+ * against a FRESH `getVirtualRows()` and then asks the virtualizer to paint
4219
+ * (scrollToRow, the keyboard ensure-visible hook) must republish that model
4220
+ * first, or it scrolls to an offset computed in one list and paints rows from
4221
+ * another — the requested row never appears. Returns the fresh model so the
4222
+ * caller indexes exactly what will be painted.
4223
+ *
4224
+ * `paint` false leaves the repaint to the caller (scrollToIndex refreshes the
4225
+ * window itself). Those callers must not paint from here: the virtualizer's
4226
+ * afterRender hook re-runs keyboard.refresh(), whose ensure-visible callback
4227
+ * lands back in ensureKeyboardRowRendered — an unbounded recursion.
4228
+ */
4229
+ syncVirtualRows(paint = true) {
4230
+ this.virtualRowsSnapshot = this.getVirtualRows();
4231
+ // Not yet attached during setupVirtualization; attach() takes the count.
4232
+ if (this.virtualizer.isEnabled()) {
4233
+ this.virtualizer.setTotalRows(this.virtualRowsSnapshot.length, paint);
4234
+ }
4235
+ return this.virtualRowsSnapshot;
4236
+ }
3614
4237
  getVirtualRows() {
3615
4238
  const filtered = this.getFilteredData();
3616
4239
  // F: the flattened group+row+aggregate list is the virtual model when
@@ -3633,14 +4256,7 @@ let SniceTable = (() => {
3633
4256
  const rows = this.virtualRowsSnapshot.length > 0
3634
4257
  ? this.virtualRowsSnapshot
3635
4258
  : this.getVirtualRows();
3636
- const extraCols = (this.hasSelectionColumn() ? 1 : 0) +
3637
- (this.masterDetail.isEnabled() ? 1 : 0) +
3638
- (this.rowReorder && this.rowDnD.isEnabled() ? 1 : 0);
3639
- const managedColumns = this.columnManager.getAllStates();
3640
- const visibleColumnCount = managedColumns.length > 0
3641
- ? this.columnManager.getVisibleColumns().length
3642
- : this.columns.length;
3643
- const totalColSpan = visibleColumnCount + extraCols;
4259
+ const totalColSpan = this.getTotalColumnSpan();
3644
4260
  const structuralSig = this.computeStructuralSig();
3645
4261
  // Task B: recycle rows that stay in the window across a scroll shift. A row
3646
4262
  // still visible after scrolling keeps its data-object key and signature, so
@@ -3686,9 +4302,10 @@ let SniceTable = (() => {
3686
4302
  // Master-detail: render the expanded detail row in-window. The detail
3687
4303
  // A fixed detailHeight participates in virtual spacer math through
3688
4304
  // getVirtualRowHeight. Auto-height content is still rendered in-window
3689
- // and contributes through normal table layout. Tree rows never carry
3690
- // detail panels, so skip them.
3691
- if (!treeRow && this.masterDetail.isEnabled() && this.masterDetail.isExpanded(dataIndex)) {
4305
+ // and contributes through normal table layout. Tree rows compose with
4306
+ // master-detail like any other row; only generated gap nodes (index -1)
4307
+ // have no data row to describe.
4308
+ if (dataIndex >= 0 && this.masterDetail.isEnabled() && this.masterDetail.isExpanded(dataIndex)) {
3692
4309
  const detailRow = this.masterDetail.createDetailRow(data, dataIndex, totalColSpan);
3693
4310
  if (detailRow)
3694
4311
  fragment.appendChild(detailRow);
@@ -3714,7 +4331,8 @@ let SniceTable = (() => {
3714
4331
  // data row rather than an unrelated header/subtotal.
3715
4332
  if (this.usesGroupingDisplayModel() || this.treeData.isEnabled()) {
3716
4333
  const row = this.data[index];
3717
- const displayIndex = this.getVirtualRows().findIndex((entry) => entry.data === row);
4334
+ // Resolve against the model the virtualizer is about to paint from.
4335
+ const displayIndex = this.syncVirtualRows(false).findIndex((entry) => entry.data === row);
3718
4336
  if (displayIndex >= 0)
3719
4337
  this.virtualizer.scrollToIndex(displayIndex);
3720
4338
  return;
@@ -3723,6 +4341,14 @@ let SniceTable = (() => {
3723
4341
  }
3724
4342
  /** Rows currently reachable by grid keyboard navigation, in display order. */
3725
4343
  getKeyboardColumnCount() {
4344
+ // List mode paints tool cells plus ONE full-width cell per row, and its
4345
+ // header no longer mirrors the body — count the body shape directly.
4346
+ if (this.usesListRowRenderer()) {
4347
+ return 1
4348
+ + (this.rowReorder && this.rowDnD.isEnabled() ? 1 : 0)
4349
+ + (this.masterDetail.isEnabled() ? 1 : 0)
4350
+ + (this.hasSelectionColumn() ? 1 : 0);
4351
+ }
3726
4352
  const rendered = this.shadowRoot?.querySelectorAll('thead tr.column-header-row > th').length ?? 0;
3727
4353
  if (rendered > 0)
3728
4354
  return rendered;
@@ -3778,7 +4404,9 @@ let SniceTable = (() => {
3778
4404
  const row = this.getKeyboardRows()[rowIndex];
3779
4405
  if (row === undefined)
3780
4406
  return;
3781
- const displayIndex = this.getVirtualRows().findIndex((entry) => entry.data === row);
4407
+ // The window is painted from `virtualRowsSnapshot`; republish it so the
4408
+ // offset we scroll to and the rows that land there come from one model.
4409
+ const displayIndex = this.syncVirtualRows(false).findIndex((entry) => entry.data === row);
3782
4410
  if (displayIndex >= 0)
3783
4411
  this.virtualizer.scrollToIndex(displayIndex);
3784
4412
  }
@@ -3906,8 +4534,7 @@ let SniceTable = (() => {
3906
4534
  return items;
3907
4535
  const totalPages = Math.max(1, Math.ceil(items.length / this.pageSize));
3908
4536
  const page = Math.max(1, Math.min(this.currentPage, totalPages));
3909
- if (page !== this.currentPage)
3910
- this.currentPage = page;
4537
+ this.clampCurrentPage(page);
3911
4538
  const start = (page - 1) * this.pageSize;
3912
4539
  return items.slice(start, start + this.pageSize);
3913
4540
  }
@@ -3927,8 +4554,7 @@ let SniceTable = (() => {
3927
4554
  return rows;
3928
4555
  const totalPages = Math.max(1, Math.ceil(rows.length / this.pageSize));
3929
4556
  const page = Math.max(1, Math.min(this.currentPage, totalPages));
3930
- if (page !== this.currentPage)
3931
- this.currentPage = page;
4557
+ this.clampCurrentPage(page);
3932
4558
  const start = (page - 1) * this.pageSize;
3933
4559
  return rows.slice(start, start + this.pageSize);
3934
4560
  }
@@ -3990,16 +4616,21 @@ let SniceTable = (() => {
3990
4616
  this.renderBody();
3991
4617
  this.dispatchColumnPinChange(key, false);
3992
4618
  }
4619
+ // A measured width is the column's width, not the header's: the body has to
4620
+ // be repainted with it too, or the th and its tds carry different numbers
4621
+ // and the next body-only re-render paints the stale one.
3993
4622
  autoSizeColumn(key) {
3994
4623
  if (this.tbody) {
3995
4624
  this.columnManager.autoSizeColumn(key, this.tbody);
3996
4625
  this.renderHeader();
4626
+ this.renderBody();
3997
4627
  }
3998
4628
  }
3999
4629
  autoSizeAllColumns() {
4000
4630
  if (this.tbody) {
4001
4631
  this.columnManager.autoSizeAll(this.tbody);
4002
4632
  this.renderHeader();
4633
+ this.renderBody();
4003
4634
  }
4004
4635
  }
4005
4636
  moveColumn(key, toIndex) {
@@ -4259,8 +4890,7 @@ let SniceTable = (() => {
4259
4890
  this.masterDetail.onHeightChange = () => {
4260
4891
  if (!this.virtualize || !this.virtualizer.isEnabled())
4261
4892
  return;
4262
- this.virtualRowsSnapshot = this.getVirtualRows();
4263
- this.virtualizer.setTotalRows(this.virtualRowsSnapshot.length);
4893
+ this.syncVirtualRows();
4264
4894
  };
4265
4895
  this.masterDetail.prepare(this.data);
4266
4896
  this.renderHeader();
@@ -4487,6 +5117,9 @@ let SniceTable = (() => {
4487
5117
  // The row recycler cannot infer that a callback identity changed from
4488
5118
  // row data alone. Force fresh rows so list markup replaces table cells.
4489
5119
  this.renderedRows = new Map();
5120
+ // Installing a renderer removes the column geometry the header describes;
5121
+ // clearing it back to null restores columnar rows AND their header.
5122
+ this.renderHeader();
4490
5123
  this.renderBody();
4491
5124
  }
4492
5125
  setListViewRenderer(fn) {
@@ -4505,7 +5138,7 @@ let SniceTable = (() => {
4505
5138
  tr.style.height = `${this.rowHeight}px`;
4506
5139
  }
4507
5140
  const isSelected = this.selectedRows.includes(index);
4508
- tr.setAttribute('data-selected', String(isSelected));
5141
+ this.stampRowSelection(tr, isSelected);
4509
5142
  // Row DnD handle
4510
5143
  if (this.rowReorder && this.rowDnD.isEnabled()) {
4511
5144
  tr.appendChild(this.rowDnD.createDragHandle());
@@ -4576,7 +5209,18 @@ let SniceTable = (() => {
4576
5209
  const toggle = this.treeData.createToggle(treeRow);
4577
5210
  td.appendChild(toggle);
4578
5211
  const textSpan = document.createElement('span');
4579
- textSpan.textContent = value == null ? '' : String(value);
5212
+ textSpan.className = 'tree-label';
5213
+ // The group cell keeps its plain label next to the indent/chevron, but
5214
+ // it is still a cell: renderCell owns it outright, and otherwise the
5215
+ // documented display pipeline (formatter, then valueFormatter) decides
5216
+ // the text. Only a column with neither shows the raw working value.
5217
+ if (typeof column.renderCell === 'function') {
5218
+ textSpan.appendChild(this.createCellElement(column, value, rowData));
5219
+ }
5220
+ else {
5221
+ textSpan.textContent = this.cellDisplayText(column, value, rowData)
5222
+ ?? (value == null ? '' : String(value));
5223
+ }
4580
5224
  // Clicking the text label also toggles expand/collapse
4581
5225
  if (treeRow.hasChildren) {
4582
5226
  textSpan.style.cursor = 'pointer';
@@ -4595,21 +5239,7 @@ let SniceTable = (() => {
4595
5239
  const state = this.columnManager.getState(column.key);
4596
5240
  if (state) {
4597
5241
  td.style.width = `${state.width}px`;
4598
- // Pinned column sticky positioning
4599
- if (state.pinned === 'left') {
4600
- const offsets = this.columnManager.getPinnedLeftOffsets();
4601
- td.classList.add('pinned-cell');
4602
- td.style.position = 'sticky';
4603
- td.style.left = `${offsets.get(column.key) ?? 0}px`;
4604
- td.style.zIndex = '1';
4605
- }
4606
- else if (state.pinned === 'right') {
4607
- const offsets = this.columnManager.getPinnedRightOffsets();
4608
- td.classList.add('pinned-cell');
4609
- td.style.position = 'sticky';
4610
- td.style.right = `${offsets.get(column.key) ?? 0}px`;
4611
- td.style.zIndex = '1';
4612
- }
5242
+ this.applyPinnedCell(td, column.key, state, '1');
4613
5243
  }
4614
5244
  tr.appendChild(td);
4615
5245
  });
@@ -4773,20 +5403,7 @@ let SniceTable = (() => {
4773
5403
  const state = this.columnManager.getState(column.key);
4774
5404
  if (state) {
4775
5405
  td.style.width = `${state.width}px`;
4776
- if (state.pinned === 'left') {
4777
- const offsets = this.columnManager.getPinnedLeftOffsets();
4778
- td.classList.add('pinned-cell');
4779
- td.style.position = 'sticky';
4780
- td.style.left = `${offsets.get(column.key) ?? 0}px`;
4781
- td.style.zIndex = '1';
4782
- }
4783
- else if (state.pinned === 'right') {
4784
- const offsets = this.columnManager.getPinnedRightOffsets();
4785
- td.classList.add('pinned-cell');
4786
- td.style.position = 'sticky';
4787
- td.style.right = `${offsets.get(column.key) ?? 0}px`;
4788
- td.style.zIndex = '1';
4789
- }
5406
+ this.applyPinnedCell(td, column.key, state, '1');
4790
5407
  }
4791
5408
  tr.appendChild(td);
4792
5409
  });
@@ -4967,6 +5584,26 @@ let SniceTable = (() => {
4967
5584
  // C1: reactive, no attribute reflection. A post-mount assignment (or the
4968
5585
  // setData/setColumns aliases) re-renders through the microtask-coalesced
4969
5586
  // queue — see handleColumnsAssignment / handleDataAssignment.
5587
+ //
5588
+ // `hasChanged: () => true` — ASSIGNMENT IS THE SIGNAL, NOT IDENTITY.
5589
+ // The core dirty-check (notEqual in packages/core/src/utils.ts, applied by the
5590
+ // @property setter in element.ts) is identity-based and stays that way for
5591
+ // every other property: that is the right default for reactivity in general.
5592
+ // But `data` and `columns` are bulk payloads that callers routinely mutate in
5593
+ // place and then publish by re-assigning the SAME array reference
5594
+ // (`rows.push(row); table.data = rows`). The documented contract is that
5595
+ // assigning `data`/`columns` rerenders, so these two properties opt OUT of the
5596
+ // identity gate locally rather than weakening it globally.
5597
+ //
5598
+ // Tradeoff — double-render cost: a genuinely redundant assignment
5599
+ // (`table.data = table.data` with nothing changed) now costs a render that the
5600
+ // identity gate used to swallow, and a caller who assigns both `columns` and
5601
+ // `data` pays two schedule calls instead of one. It is bounded: both watchers
5602
+ // go through scheduleRender(), which coalesces a burst within a tick into a
5603
+ // single microtask flush, so the worst case is one extra body paint per tick,
5604
+ // never per assignment. Deep-equality diffing was rejected as the alternative
5605
+ // — it is O(rows x columns) on every assignment, i.e. a cost paid by the
5606
+ // common (genuinely-changed) path to save the rare redundant one.
4970
5607
  this.columns = (__runInitializers(this, _searchDebounce_extraInitializers), __runInitializers(this, _columns_initializers, []));
4971
5608
  this.data = (__runInitializers(this, _columns_extraInitializers), __runInitializers(this, _data_initializers, []));
4972
5609
  /**
@@ -4987,7 +5624,8 @@ let SniceTable = (() => {
4987
5624
  this.selector = __runInitializers(this, _selector_initializers, '');
4988
5625
  this.selectorOptions = (__runInitializers(this, _selector_extraInitializers), __runInitializers(this, _selectorOptions_initializers, []));
4989
5626
  this.loading = (__runInitializers(this, _selectorOptions_extraInitializers), __runInitializers(this, _loading_initializers, false));
4990
- // Set when a remote-mode load fails; cleared on the next successful load.
5627
+ // Set when a remote-mode load fails; cleared by clearLoadError() as soon as
5628
+ // ANY fresh dataset arrives (see there).
4991
5629
  // Not a @property — renderBody() reads it directly, no attribute needed.
4992
5630
  this.loadError = (__runInitializers(this, _loading_extraInitializers), null);
4993
5631
  this.virtualize = __runInitializers(this, _virtualize_initializers, false);
@@ -4997,7 +5635,16 @@ let SniceTable = (() => {
4997
5635
  this.editable = (__runInitializers(this, _columnResize_extraInitializers), __runInitializers(this, _editable_initializers, false));
4998
5636
  this.editMode = (__runInitializers(this, _editable_extraInitializers), __runInitializers(this, _editMode_initializers, 'cell'));
4999
5637
  this.density = (__runInitializers(this, _editMode_extraInitializers), __runInitializers(this, _density_initializers, 'standard'));
5000
- this.headerFilters = (__runInitializers(this, _density_extraInitializers), __runInitializers(this, _headerFilters_initializers, false));
5638
+ /**
5639
+ * How the columns relate to the frame's width.
5640
+ *
5641
+ * `scroll` keeps every column at or above its `minWidth` and lets the frame
5642
+ * scroll horizontally when they no longer fit. `squish` makes the frame the
5643
+ * hard constraint instead: minimums relax, content ellipsises, and the table
5644
+ * never scrolls sideways. See the `ColumnFit` docs in snice-table.types.ts.
5645
+ */
5646
+ this.columnFit = (__runInitializers(this, _density_extraInitializers), __runInitializers(this, _columnFit_initializers, 'scroll'));
5647
+ this.headerFilters = (__runInitializers(this, _columnFit_extraInitializers), __runInitializers(this, _headerFilters_initializers, false));
5001
5648
  this.quickFilter = (__runInitializers(this, _headerFilters_extraInitializers), __runInitializers(this, _quickFilter_initializers, false));
5002
5649
  this.rowReorder = (__runInitializers(this, _quickFilter_extraInitializers), __runInitializers(this, _rowReorder_initializers, false));
5003
5650
  this.columnReorder = (__runInitializers(this, _rowReorder_extraInitializers), __runInitializers(this, _columnReorder_initializers, false));
@@ -5049,7 +5696,9 @@ let SniceTable = (() => {
5049
5696
  this.table = __runInitializers(this, _table_initializers, void 0);
5050
5697
  this.thead = (__runInitializers(this, _table_extraInitializers), __runInitializers(this, _thead_initializers, void 0));
5051
5698
  this.tbody = (__runInitializers(this, _thead_extraInitializers), __runInitializers(this, _tbody_initializers, void 0));
5052
- this.unsortedData = (__runInitializers(this, _tbody_extraInitializers), []);
5699
+ /** Frame width the columns were last fitted to — see handleFrameResize. */
5700
+ this.lastFittedFrameWidth = (__runInitializers(this, _tbody_extraInitializers), 0);
5701
+ this.unsortedData = [];
5053
5702
  this.dataRequestTimeout = null;
5054
5703
  // A1: row-object → its index in `this.data`. Replaces the O(n) `data.indexOf`
5055
5704
  // that ran per row on every selection toggle (was O(n²) for select-all).
@@ -5081,6 +5730,10 @@ let SniceTable = (() => {
5081
5730
  // C1: set while the imperative setData() runs so the data @watch resyncs the
5082
5731
  // model but skips the auto-render (the caller drives the body fill).
5083
5732
  this.settingDataImperative = false;
5733
+ // Set while the row-reorder handler re-publishes `this.data` as a new array
5734
+ // of the SAME rows. Tells the data @watch that no fresh dataset arrived, so a
5735
+ // pending load error must survive the drag.
5736
+ this.reorderingRows = false;
5084
5737
  // C1/C2: microtask-coalesced render queue. Reactive assignments schedule a
5085
5738
  // render here instead of calling renderHeader/renderBody synchronously —
5086
5739
  // MANDATORY, because happy-dom crashes constructing cell elements inside a
@@ -5088,6 +5741,32 @@ let SniceTable = (() => {
5088
5741
  this.renderQueued = false;
5089
5742
  this.pendingHeaderRender = false;
5090
5743
  this.pendingBodyRender = false;
5744
+ // Re-arm bookkeeping for a body render that found no <tbody> to paint into.
5745
+ // Bounded: the slotted-rows template legitimately has no <tbody>, and an
5746
+ // unbounded retry there would spin every frame.
5747
+ this.bodyRenderRetries = 0;
5748
+ this.bodyRenderRetryQueued = false;
5749
+ /**
5750
+ * Write a page number the display model just derived (a re-clamp after the
5751
+ * row set shortened), WITHOUT re-entering the render scheduler.
5752
+ *
5753
+ * The three clamp sites all sit inside a display-model computation whose
5754
+ * caller paints immediately afterwards with the clamped page — the ordinary
5755
+ * body path slices with it and then renders the pager, the grouping and tree
5756
+ * models return the clamped slice. Letting the @watch fire here queued a
5757
+ * second, byte-identical body pass per delivery — the measured cost, and what
5758
+ * the regression test pins. Suppressing the write's watcher makes the clamp
5759
+ * cost zero extra passes and makes an oscillating clamp structurally unable
5760
+ * to loop.
5761
+ *
5762
+ * All three sites are reachable only under `paginationMode === 'client'`, so
5763
+ * the watcher's server branch (debouncedDataRequest, whose `loading = true`
5764
+ * re-enters renderBody SYNCHRONOUSLY through the loading watcher — from the
5765
+ * middle of a render) is not reachable from a clamp today. It is the reason
5766
+ * to keep this guard rather than to drop the watcher suppression if a fourth
5767
+ * clamp site ever lands outside that guard.
5768
+ */
5769
+ this.clampingPage = false;
5091
5770
  /**
5092
5771
  * A structural fingerprint of everything `createRow` reads that is NOT
5093
5772
  * per-row data: visible columns + their widths/pinning/offsets, tool columns,