picocrank 1.33.0 → 1.35.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 (36) hide show
  1. package/docs/filter-query-v1.md +5 -1
  2. package/docs/table.md +21 -2
  3. package/package.json +1 -1
  4. package/router.js +33 -0
  5. package/styles/picocrank-extensions.css +23 -2
  6. package/vue/components/Accordion.spec.js +48 -0
  7. package/vue/components/Accordion.vue +46 -0
  8. package/vue/components/AccordionItem.vue +103 -0
  9. package/vue/components/FieldValue.spec.js +88 -0
  10. package/vue/components/FieldValue.vue +222 -0
  11. package/vue/components/FormField.spec.js +1 -0
  12. package/vue/components/FormField.vue +38 -10
  13. package/vue/components/Header.vue +24 -2
  14. package/vue/components/NotificationBlock.vue +25 -0
  15. package/vue/components/ReorderList.spec.js +318 -0
  16. package/vue/components/ReorderList.vue +614 -0
  17. package/vue/components/Table.spec.js +193 -0
  18. package/vue/components/Table.vue +821 -115
  19. package/vue/components/TableColumnFilterPopover.vue +63 -19
  20. package/vue/components/TableColumnOptionsPopover.vue +248 -273
  21. package/vue/components/TopBar.vue +6 -2
  22. package/vue/components/accordionContext.js +1 -0
  23. package/vue/composables/tableColumnPresets.js +33 -0
  24. package/vue/composables/tableGroupBy.js +173 -0
  25. package/vue/composables/tableGroupBy.spec.js +69 -0
  26. package/vue/composables/tableViewSettings.js +34 -0
  27. package/vue/composables/tableViewSettings.spec.js +21 -0
  28. package/vue/composables/useHeaderTopBarFit.js +91 -0
  29. package/vue/composables/useResponsiveNav.js +5 -3
  30. package/vue/data/navSectionHubs.js +16 -1
  31. package/vue/examples/AccordionExample.vue +33 -0
  32. package/vue/examples/FieldValueExample.vue +143 -0
  33. package/vue/examples/FormExample.vue +11 -6
  34. package/vue/examples/ReorderListExample.vue +236 -0
  35. package/vue/examples/TableExample.vue +5 -1
  36. package/vue/examples/TableRemoteExample.vue +28 -4
@@ -4,11 +4,31 @@
4
4
  :class="needsTableWrapper ? tableWrapperClasses : undefined"
5
5
  :style="needsTableWrapper ? undefined : { display: 'contents' }"
6
6
  >
7
- <table class="row-hover" :class="{ loading: isLoading }">
7
+ <table
8
+ ref="tableElementRef"
9
+ class="row-hover"
10
+ :class="{
11
+ loading: isLoading,
12
+ 'has-column-widths': hasResizedColumns,
13
+ 'is-column-resizing': activeResizeKey != null,
14
+ }"
15
+ :aria-busy="isLoading ? 'true' : undefined"
16
+ :aria-colcount="ariaColCount"
17
+ >
18
+ <colgroup>
19
+ <col v-if="selectable" class="table-select-col-width" />
20
+ <col
21
+ v-for="(header, index) in visibleHeaders"
22
+ :key="header.key || index"
23
+ :style="columnWidthStyle(header)"
24
+ />
25
+ <col v-if="showColumnOptionsButton" class="table-column-options-col-width" />
26
+ </colgroup>
8
27
  <thead>
9
28
  <tr>
10
29
  <th
11
30
  v-if="selectable"
31
+ scope="col"
12
32
  class="table-select-col"
13
33
  @click.stop
14
34
  >
@@ -25,8 +45,13 @@
25
45
  <th
26
46
  v-for="(header, index) in visibleHeaders"
27
47
  :key="header.key || index"
28
- :class="[headerClasses(header), header.class]"
29
- :style="{ width: header.width || 'auto' }"
48
+ scope="col"
49
+ :data-column-key="header.key"
50
+ :class="[headerClasses(header), header.class, { 'is-resizing': activeResizeKey === header.key }]"
51
+ :style="[columnWidthStyle(header), columnOverflowStyle()]"
52
+ :aria-sort="headerAriaSort(header)"
53
+ :aria-haspopup="isHeaderFilterable(header) ? 'dialog' : undefined"
54
+ :aria-expanded="headerFilterExpanded(header)"
30
55
  @click="onHeaderClick(header, $event)"
31
56
  @contextmenu.prevent="openFilter(header, $event)"
32
57
  @pointerdown="onHeaderPointerDown(header, $event)"
@@ -41,15 +66,58 @@
41
66
 
42
67
  <span v-if="filterable && isHeaderFiltered(header)" class="filter-indicator" title="Filtered" aria-hidden="true">⧩</span>
43
68
 
44
- <span v-if="header.sortable" class="sort-indicator">
45
- <span v-if="sortBy === header.key">
46
- <span v-if="sortDir === 'asc'">▲</span>
47
- <span v-else-if="sortDir === 'desc'">▼</span>
48
- </span>
69
+ <span
70
+ v-if="isHeaderGrouped(header)"
71
+ class="group-indicator"
72
+ title="Grouped"
73
+ aria-hidden="true"
74
+ >
75
+ <HugeiconsIcon
76
+ :icon="Group01Icon"
77
+ width="0.95em"
78
+ height="0.95em"
79
+ :strokeWidth="2"
80
+ aria-hidden="true"
81
+ />
82
+ </span>
83
+
84
+ <span v-if="header.sortable" class="sort-indicator" aria-hidden="true">
85
+ <HugeiconsIcon
86
+ v-if="sortBy === header.key && sortDir === 'asc'"
87
+ :icon="SortByUp02Icon"
88
+ width="0.95em"
89
+ height="0.95em"
90
+ :strokeWidth="2"
91
+ aria-hidden="true"
92
+ />
93
+ <HugeiconsIcon
94
+ v-else-if="sortBy === header.key && sortDir === 'desc'"
95
+ :icon="SortByDown02Icon"
96
+ width="0.95em"
97
+ height="0.95em"
98
+ :strokeWidth="2"
99
+ aria-hidden="true"
100
+ />
49
101
  </span>
102
+
103
+ <span
104
+ v-if="isHeaderResizable(header)"
105
+ class="column-resize-handle"
106
+ role="separator"
107
+ aria-orientation="vertical"
108
+ :aria-label="`Resize ${header.label || header.key} column`"
109
+ :aria-valuemin="MIN_COLUMN_WIDTH"
110
+ :aria-valuenow="columnResizeValueNow(header)"
111
+ :class="{ 'is-active': activeResizeKey === header.key }"
112
+ @pointerdown.stop="onResizePointerDown(header, $event)"
113
+ @dblclick.stop="onResizeDoubleClick(header, $event)"
114
+ @click.stop
115
+ @contextmenu.stop.prevent
116
+ />
50
117
  </th>
51
118
  <th
52
119
  v-if="showColumnOptionsButton"
120
+ scope="col"
53
121
  class="actions table-column-options-header"
54
122
  >
55
123
  <span class="actions-menu-trigger">
@@ -74,77 +142,113 @@
74
142
  </tr>
75
143
  </thead>
76
144
  <tbody v-if="hasRows">
77
- <tr
78
- v-for="(row, rowIndex) in displayRows"
79
- :key="resolveRowKey(row, rowIndex)"
80
- :class="[
81
- {
82
- 'row-clickable': rowClickable,
83
- 'row-selected': selectable && isRowSelected(row, rowIndex),
84
- 'row-context-menu-active': isRowContextMenuTarget(row, rowIndex),
85
- },
86
- resolveRowClass(row, rowIndex),
87
- ]"
88
- :style="resolveRowStyle(row, rowIndex)"
89
- @click="onRowClick(row, rowIndex, $event)"
90
- @contextmenu="onRowContextMenu(row, rowIndex, $event)"
91
- >
92
- <td
93
- v-if="selectable"
94
- class="table-select-col"
95
- data-row-click-ignore
96
- @click.stop
145
+ <template v-for="(item, itemIndex) in displayBodyItems" :key="bodyItemKey(item, itemIndex)">
146
+ <tr
147
+ v-if="item.kind === 'group'"
148
+ class="table-group-row"
149
+ :class="{ 'table-group-row-collapsed': item.collapsed }"
97
150
  >
98
- <label class="table-select-label">
99
- <input
100
- type="checkbox"
101
- :checked="isRowSelected(row, rowIndex)"
102
- :aria-label="`Select row ${resolveRowKey(row, rowIndex)}`"
103
- @click.stop="rememberSelectionCheckboxClick(row, rowIndex, $event)"
104
- @change="onSelectionCheckboxChange(row, rowIndex)"
151
+ <td
152
+ v-if="selectable"
153
+ class="table-select-col table-group-cell"
154
+ data-row-click-ignore
155
+ />
156
+ <td
157
+ :colspan="bodyGroupColspan"
158
+ class="table-group-cell"
159
+ >
160
+ <button
161
+ type="button"
162
+ class="table-group-toggle neutral"
163
+ :aria-expanded="item.collapsed ? 'false' : 'true'"
164
+ :aria-label="item.collapsed ? 'Expand group' : 'Collapse group'"
165
+ data-row-click-ignore
166
+ @click.stop="toggleGroupCollapsed(item.groupId)"
105
167
  >
106
- </label>
107
- </td>
108
- <td
109
- v-for="(header, cellIndex) in visibleHeaders"
110
- :key="header.key || cellIndex"
111
- :ref="(element) => registerCellElement(element, row, header, rowIndex)"
112
- v-bind="resolveCellAttrs(row, header, rowIndex)"
113
- :colspan="bodyColumnColspan(cellIndex) || undefined"
168
+ <HugeiconsIcon
169
+ class="table-group-chevron"
170
+ :icon="ArrowDown01Icon"
171
+ width="1em"
172
+ height="1em"
173
+ aria-hidden="true"
174
+ />
175
+ </button>
176
+ <span class="table-group-label">{{ formatGroupValue(item.value) }}</span>
177
+ <span class="table-group-count subtle">({{ item.count }})</span>
178
+ </td>
179
+ </tr>
180
+ <tr
181
+ v-else
114
182
  :class="[
115
- cellClasses(header),
116
- header.class,
117
- resolveCellClass(row, header, rowIndex),
118
- { 'table-active-cell': isActiveCell(row, header, rowIndex) },
119
- isLastColumnWithOptions(cellIndex) ? 'table-last-column-with-options' : null,
183
+ {
184
+ 'row-clickable': rowClickable,
185
+ 'row-selected': selectable && isRowSelected(item.row, item.displayIndex),
186
+ 'row-context-menu-active': isRowContextMenuTarget(item.row, item.displayIndex),
187
+ },
188
+ resolveRowClass(item.row, item.displayIndex),
120
189
  ]"
121
- :style="resolveCellStyle(row, header, rowIndex)"
122
- :tabindex="isActiveCell(row, header, rowIndex) ? 0 : -1"
123
- @click="onCellClick(row, header, rowIndex, $event)"
190
+ :style="resolveRowStyle(item.row, item.displayIndex)"
191
+ @click="onRowClick(item.row, item.displayIndex, $event)"
192
+ @contextmenu="onRowContextMenu(item.row, item.displayIndex, $event)"
124
193
  >
125
- <slot
126
- v-if="slots[`cell-${header.key}`]"
127
- :name="`cell-${header.key}`"
128
- :row="row"
129
- :value="row[header.key]"
130
- :header="header"
131
- :row-index="rowIndex"
132
- :source-index="sourceRowIndex(row, rowIndex)"
133
- />
134
- <slot
135
- v-else-if="slots.cell"
136
- name="cell"
137
- :row="row"
138
- :value="row[header.key]"
139
- :header="header"
140
- :row-index="rowIndex"
141
- :source-index="sourceRowIndex(row, rowIndex)"
142
- />
143
- <span v-else>
144
- {{ row[header.key] }}
145
- </span>
146
- </td>
147
- </tr>
194
+ <td
195
+ v-if="selectable"
196
+ class="table-select-col"
197
+ data-row-click-ignore
198
+ @click.stop
199
+ >
200
+ <label class="table-select-label">
201
+ <input
202
+ type="checkbox"
203
+ :checked="isRowSelected(item.row, item.displayIndex)"
204
+ :aria-label="`Select row ${resolveRowKey(item.row, item.displayIndex)}`"
205
+ @click.stop="rememberSelectionCheckboxClick(item.row, item.displayIndex, $event)"
206
+ @change="onSelectionCheckboxChange(item.row, item.displayIndex)"
207
+ >
208
+ </label>
209
+ </td>
210
+ <td
211
+ v-for="(header, cellIndex) in visibleHeaders"
212
+ :key="header.key || cellIndex"
213
+ :ref="(element) => registerCellElement(element, item.row, header, item.displayIndex)"
214
+ v-bind="resolveCellAttrs(item.row, header, item.displayIndex)"
215
+ :colspan="bodyColumnColspan(cellIndex) || undefined"
216
+ :class="[
217
+ cellClasses(header),
218
+ header.class,
219
+ resolveCellClass(item.row, header, item.displayIndex),
220
+ { 'table-active-cell': isActiveCell(item.row, header, item.displayIndex) },
221
+ isLastColumnWithOptions(cellIndex) ? 'table-last-column-with-options' : null,
222
+ ]"
223
+ :style="[resolveCellStyle(item.row, header, item.displayIndex), columnOverflowStyle()]"
224
+ :tabindex="isActiveCell(item.row, header, item.displayIndex) ? 0 : -1"
225
+ @click="onCellClick(item.row, header, item.displayIndex, $event)"
226
+ @keydown="onCellKeydown(item.row, header, item.displayIndex, $event)"
227
+ >
228
+ <slot
229
+ v-if="slots[`cell-${header.key}`]"
230
+ :name="`cell-${header.key}`"
231
+ :row="item.row"
232
+ :value="item.row[header.key]"
233
+ :header="header"
234
+ :row-index="item.displayIndex"
235
+ :source-index="sourceRowIndex(item.row, item.displayIndex)"
236
+ />
237
+ <slot
238
+ v-else-if="slots.cell"
239
+ name="cell"
240
+ :row="item.row"
241
+ :value="item.row[header.key]"
242
+ :header="header"
243
+ :row-index="item.displayIndex"
244
+ :source-index="sourceRowIndex(item.row, item.displayIndex)"
245
+ />
246
+ <span v-else>
247
+ {{ item.row[header.key] }}
248
+ </span>
249
+ </td>
250
+ </tr>
251
+ </template>
148
252
  </tbody>
149
253
  </table>
150
254
 
@@ -174,10 +278,13 @@
174
278
  :rows="props.data"
175
279
  :select-options="activeSelectOptions"
176
280
  :can-hide="canHideActiveFilterColumn"
281
+ :can-group-by="canGroupByActiveFilterColumn"
282
+ :is-grouped-column="isActiveFilterHeaderGrouped"
177
283
  @apply="onFilterApply"
178
284
  @clear="onFilterClear"
179
285
  @cancel="onFilterCancel"
180
286
  @hide="onFilterHideColumn"
287
+ @group-by="onFilterGroupBy"
181
288
  />
182
289
 
183
290
  <TableColumnOptionsPopover
@@ -195,10 +302,13 @@
195
302
  :presets="savedPresets"
196
303
  :default-preset-id="defaultPresetId"
197
304
  :save-layout-preset="onSavePreset"
305
+ :overwrite-layout-preset="onOverwritePreset"
198
306
  :load-layout-preset="onLoadPreset"
199
307
  :delete-layout-preset="onDeletePreset"
200
308
  :set-default-layout-preset="onSetDefaultPreset"
201
309
  :load-developer-defaults="loadDeveloperDefaults"
310
+ :group-by="activeGroupBy"
311
+ :groupable="props.groupable"
202
312
  :anchor-el="columnOptionsButtonRef"
203
313
  @apply="onColumnOptionsApply"
204
314
  @cancel="onColumnOptionsCancel"
@@ -217,7 +327,13 @@
217
327
  <script setup>
218
328
  import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount, useSlots, useAttrs } from 'vue'
219
329
  import { HugeiconsIcon } from '@hugeicons/vue'
220
- import { LayoutGridIcon } from '@hugeicons/core-free-icons'
330
+ import {
331
+ ArrowDown01Icon,
332
+ Group01Icon,
333
+ LayoutGridIcon,
334
+ SortByDown02Icon,
335
+ SortByUp02Icon,
336
+ } from '@hugeicons/core-free-icons'
221
337
  import Pagination from './Pagination.vue'
222
338
  import TableColumnFilterPopover from './TableColumnFilterPopover.vue'
223
339
  import TableColumnOptionsPopover from './TableColumnOptionsPopover.vue'
@@ -242,17 +358,29 @@ import {
242
358
  getPresetById,
243
359
  listPresets,
244
360
  savePreset,
361
+ overwritePreset,
245
362
  deletePreset,
246
363
  setDefaultPreset,
247
364
  sanitizePresetState,
248
365
  layoutStatesEqual,
249
366
  } from '../composables/tableColumnPresets.js'
367
+ import {
368
+ getViewSettingsHandlers,
369
+ isViewSettingsControlEnabled,
370
+ VIEW_CONTROL_CALLBACK,
371
+ } from '../composables/tableViewSettings.js'
250
372
  import {
251
373
  defaultColumnPriorityForIndex,
252
374
  isValidColPriority,
253
375
  } from '../composables/tableColumnPriorities.js'
376
+ import {
377
+ buildDisplayBodyItems,
378
+ formatGroupValue,
379
+ isHeaderGroupable,
380
+ } from '../composables/tableGroupBy.js'
254
381
 
255
382
  const DEFAULT_PAGE_SIZE = 10
383
+ const MIN_COLUMN_WIDTH = 48
256
384
 
257
385
  const props = defineProps({
258
386
  headers: {
@@ -343,6 +471,21 @@ const props = defineProps({
343
471
  type: String,
344
472
  default: '',
345
473
  },
474
+ /**
475
+ * How saved table views (column order, visibility, filters in presets, etc.) are stored.
476
+ * - `localStorageControlled`: requires `tableId`; uses browser localStorage (default).
477
+ * - `callbackControlled`: requires `viewSettings` callbacks (server or app-owned persistence).
478
+ */
479
+ viewControlMode: {
480
+ type: String,
481
+ default: 'localStorageControlled',
482
+ validator: (value) => ['localStorageControlled', 'callbackControlled'].includes(value),
483
+ },
484
+ /** Callback handlers when `viewControlMode` is `callbackControlled`. */
485
+ viewSettings: {
486
+ type: Object,
487
+ default: null,
488
+ },
346
489
  loadSavedLayout: {
347
490
  type: Boolean,
348
491
  default: true,
@@ -355,6 +498,7 @@ const props = defineProps({
355
498
  type: [String, Function],
356
499
  default: 'id',
357
500
  },
501
+ /** @deprecated Use `viewSettings` with `viewControlMode="callbackControlled"`. */
358
502
  layoutPresets: {
359
503
  type: Object,
360
504
  default: null,
@@ -406,6 +550,14 @@ const props = defineProps({
406
550
  type: [Array, Function],
407
551
  default: null,
408
552
  },
553
+ groupable: {
554
+ type: Boolean,
555
+ default: true,
556
+ },
557
+ groupBy: {
558
+ type: String,
559
+ default: undefined,
560
+ },
409
561
  })
410
562
 
411
563
  const emit = defineEmits([
@@ -432,6 +584,8 @@ const emit = defineEmits([
432
584
  'column-priorities-change',
433
585
  'update:layoutLabel',
434
586
  'layout-label-change',
587
+ 'update:groupBy',
588
+ 'group-by-change',
435
589
  ])
436
590
 
437
591
  const attrs = useAttrs()
@@ -561,15 +715,192 @@ const internalColumnPriorities = ref({})
561
715
  const savedPresets = ref([])
562
716
  const defaultPresetId = ref(null)
563
717
  const activeLayoutPresetId = ref(null)
718
+ const internalGroupBy = ref(null)
719
+ const collapsedGroupIds = ref([])
720
+ /** @type {import('vue').Ref<Record<string, number | null>>} */
721
+ const columnWidths = ref({})
722
+ const activeResizeKey = ref(null)
723
+ const tableElementRef = ref(null)
724
+ let resizeSession = null
564
725
 
565
726
  const isRemote = computed(() => typeof props.fetchRows === 'function')
566
727
 
567
- const layoutPresetsAvailable = computed(() => {
568
- if (isRemote.value) {
569
- return props.layoutPresets != null
728
+ const hasResizedColumns = computed(() =>
729
+ Object.values(columnWidths.value).some((width) => width != null),
730
+ )
731
+
732
+ function isHeaderResizable(header) {
733
+ return Boolean(header?.key) && header.resizable !== false
734
+ }
735
+
736
+ function formatColumnWidth(width) {
737
+ if (width == null || width === '') {
738
+ return undefined
570
739
  }
571
- return Boolean(props.tableId)
572
- })
740
+ return typeof width === 'number' ? `${width}px` : width
741
+ }
742
+
743
+ function columnWidthStyle(header) {
744
+ if (!header?.key) {
745
+ return header?.width ? { width: formatColumnWidth(header.width) } : undefined
746
+ }
747
+
748
+ if (Object.prototype.hasOwnProperty.call(columnWidths.value, header.key)) {
749
+ const resized = columnWidths.value[header.key]
750
+ if (resized == null) {
751
+ return undefined
752
+ }
753
+ return { width: `${resized}px` }
754
+ }
755
+
756
+ if (header.width) {
757
+ return { width: formatColumnWidth(header.width) }
758
+ }
759
+
760
+ return undefined
761
+ }
762
+
763
+ function columnOverflowStyle() {
764
+ if (!hasResizedColumns.value) {
765
+ return undefined
766
+ }
767
+ return {
768
+ overflow: 'hidden',
769
+ textOverflow: 'ellipsis',
770
+ whiteSpace: 'nowrap',
771
+ }
772
+ }
773
+
774
+ function columnResizeValueNow(header) {
775
+ const width = columnWidths.value[header.key]
776
+ return typeof width === 'number' ? width : undefined
777
+ }
778
+
779
+ function setColumnWidths(nextWidths) {
780
+ columnWidths.value = nextWidths
781
+ }
782
+
783
+ function setColumnWidth(columnKey, width) {
784
+ columnWidths.value = {
785
+ ...columnWidths.value,
786
+ [columnKey]: width,
787
+ }
788
+ }
789
+
790
+ function freezeSiblingColumnWidths(tableEl, activeKey, activeWidth) {
791
+ const next = { ...columnWidths.value }
792
+ tableEl?.querySelectorAll('thead th[data-column-key]').forEach((th) => {
793
+ const key = th.dataset.columnKey
794
+ if (!key || key === activeKey) {
795
+ return
796
+ }
797
+ if (typeof next[key] === 'number') {
798
+ return
799
+ }
800
+ next[key] = Math.max(MIN_COLUMN_WIDTH, Math.round(th.getBoundingClientRect().width))
801
+ })
802
+ next[activeKey] = activeWidth
803
+ setColumnWidths(next)
804
+ }
805
+
806
+ function endColumnResize() {
807
+ if (!resizeSession) {
808
+ return
809
+ }
810
+
811
+ const { moveListener, upListener, previousUserSelect, previousCursor } = resizeSession
812
+ window.removeEventListener('pointermove', moveListener)
813
+ window.removeEventListener('pointerup', upListener)
814
+ window.removeEventListener('pointercancel', upListener)
815
+ document.body.style.userSelect = previousUserSelect
816
+ document.body.style.cursor = previousCursor
817
+ activeResizeKey.value = null
818
+ resizeSession = null
819
+ }
820
+
821
+ function onResizePointerDown(header, event) {
822
+ if (!isHeaderResizable(header) || event.button !== 0) {
823
+ return
824
+ }
825
+
826
+ const handle = event.currentTarget
827
+ const headerCell = handle?.closest('th')
828
+ if (!headerCell) {
829
+ return
830
+ }
831
+
832
+ event.preventDefault()
833
+ endColumnResize()
834
+
835
+ const startX = event.clientX
836
+ const startWidth = Math.max(MIN_COLUMN_WIDTH, Math.round(headerCell.getBoundingClientRect().width))
837
+ activeResizeKey.value = header.key
838
+ freezeSiblingColumnWidths(
839
+ tableElementRef.value ?? headerCell.closest('table'),
840
+ header.key,
841
+ startWidth,
842
+ )
843
+
844
+ const previousUserSelect = document.body.style.userSelect
845
+ const previousCursor = document.body.style.cursor
846
+ document.body.style.userSelect = 'none'
847
+ document.body.style.cursor = 'col-resize'
848
+
849
+ const moveListener = (moveEvent) => {
850
+ const nextWidth = Math.max(
851
+ MIN_COLUMN_WIDTH,
852
+ Math.round(startWidth + (moveEvent.clientX - startX)),
853
+ )
854
+ setColumnWidth(header.key, nextWidth)
855
+ }
856
+
857
+ const upListener = () => {
858
+ endColumnResize()
859
+ }
860
+
861
+ resizeSession = {
862
+ moveListener,
863
+ upListener,
864
+ previousUserSelect,
865
+ previousCursor,
866
+ }
867
+
868
+ window.addEventListener('pointermove', moveListener)
869
+ window.addEventListener('pointerup', upListener)
870
+ window.addEventListener('pointercancel', upListener)
871
+
872
+ if (typeof handle.setPointerCapture === 'function') {
873
+ try {
874
+ handle.setPointerCapture(event.pointerId)
875
+ } catch {
876
+ // Ignore capture failures (e.g. unsupported pointer type in tests).
877
+ }
878
+ }
879
+ }
880
+
881
+ function onResizeDoubleClick(header, event) {
882
+ if (!isHeaderResizable(header)) {
883
+ return
884
+ }
885
+
886
+ event.preventDefault()
887
+ endColumnResize()
888
+ setColumnWidth(header.key, null)
889
+ }
890
+
891
+ const viewSettingsHandlers = computed(() =>
892
+ getViewSettingsHandlers(props.viewSettings, props.layoutPresets))
893
+
894
+ const isCallbackViewControl = computed(
895
+ () => props.viewControlMode === VIEW_CONTROL_CALLBACK,
896
+ )
897
+
898
+ const layoutPresetsAvailable = computed(() =>
899
+ isViewSettingsControlEnabled(
900
+ props.viewControlMode,
901
+ props.tableId,
902
+ viewSettingsHandlers.value,
903
+ ))
573
904
 
574
905
  const activeFilters = computed(() => {
575
906
  if (!props.filterable) {
@@ -629,6 +960,53 @@ function bodyColumnColspan(index) {
629
960
  return isLastColumnWithOptions(index) ? 2 : null
630
961
  }
631
962
 
963
+ const bodyGroupColspan = computed(() => {
964
+ let colspan = visibleHeaders.value.length
965
+ if (showColumnOptionsButton.value) {
966
+ colspan += 1
967
+ }
968
+ return colspan
969
+ })
970
+
971
+ function bodyItemKey(item, index) {
972
+ if (item.kind === 'group') {
973
+ return `group-${item.groupId}`
974
+ }
975
+ return resolveRowKey(item.row, item.sourceIndex ?? index)
976
+ }
977
+
978
+ function isHeaderGrouped(header) {
979
+ return Boolean(activeGroupBy.value && header?.key === activeGroupBy.value)
980
+ }
981
+
982
+ function toggleGroupCollapsed(groupId) {
983
+ const next = new Set(collapsedGroupIdSet.value)
984
+ if (next.has(groupId)) {
985
+ next.delete(groupId)
986
+ } else {
987
+ next.add(groupId)
988
+ }
989
+ collapsedGroupIds.value = [...next]
990
+ }
991
+
992
+ function setGroupBy(nextGroupBy) {
993
+ const header = props.headers.find((item) => item.key === nextGroupBy)
994
+ const normalized = isHeaderGroupable(header, props.groupable) ? nextGroupBy : null
995
+
996
+ if (props.groupBy === undefined) {
997
+ internalGroupBy.value = normalized
998
+ }
999
+
1000
+ collapsedGroupIds.value = []
1001
+ page.value = 1
1002
+ emit('update:groupBy', normalized)
1003
+ emit('group-by-change', normalized)
1004
+ if (isRemote.value) {
1005
+ scheduleFetchRows()
1006
+ }
1007
+ emitQueryChange()
1008
+ }
1009
+
632
1010
  function isHeaderHidden(header) {
633
1011
  if (header.hidden) {
634
1012
  return true
@@ -641,6 +1019,17 @@ function isHeaderHidden(header) {
641
1019
 
642
1020
  const visibleHeaders = computed(() => orderedHeaders.value.filter((header) => !isHeaderHidden(header)))
643
1021
 
1022
+ const ariaColCount = computed(() => {
1023
+ let count = visibleHeaders.value.length
1024
+ if (props.selectable) {
1025
+ count += 1
1026
+ }
1027
+ if (showColumnOptionsButton.value) {
1028
+ count += 1
1029
+ }
1030
+ return count > 0 ? count : undefined
1031
+ })
1032
+
644
1033
  const visibleColumnKeys = computed(() =>
645
1034
  orderedHeaders.value
646
1035
  .filter((header) => !isHeaderHidden(header))
@@ -750,16 +1139,45 @@ const sortedItems = computed(() => {
750
1139
  )
751
1140
  })
752
1141
 
753
- const displayRows = computed(() => {
754
- if (isRemote.value || !props.showPagination) {
755
- return sortedItems.value
1142
+ const activeGroupBy = computed(() => {
1143
+ if (!props.groupable) {
1144
+ return null
756
1145
  }
1146
+ if (props.groupBy !== undefined) {
1147
+ return props.groupBy || null
1148
+ }
1149
+ return internalGroupBy.value
1150
+ })
1151
+
1152
+ const collapsedGroupIdSet = computed(() => new Set(collapsedGroupIds.value))
757
1153
 
758
- const start = (page.value - 1) * pageSize.value
759
- return sortedItems.value.slice(start, start + pageSize.value)
1154
+ const displayBodyItems = computed(() => {
1155
+ const paginate = !isRemote.value && props.showPagination
1156
+ const items = buildDisplayBodyItems(sortedItems.value, {
1157
+ groupBy: activeGroupBy.value,
1158
+ page: page.value,
1159
+ pageSize: pageSize.value,
1160
+ paginate,
1161
+ collapsedGroupIds: collapsedGroupIdSet.value,
1162
+ })
1163
+ let displayIndex = 0
1164
+ return items.map((item) => {
1165
+ if (item.kind !== 'row') {
1166
+ return item
1167
+ }
1168
+ const indexed = { ...item, displayIndex }
1169
+ displayIndex += 1
1170
+ return indexed
1171
+ })
760
1172
  })
761
1173
 
762
- const hasRows = computed(() => displayRows.value.length > 0)
1174
+ const displayRows = computed(() =>
1175
+ displayBodyItems.value
1176
+ .filter((item) => item.kind === 'row')
1177
+ .map((item) => item.row),
1178
+ )
1179
+
1180
+ const hasRows = computed(() => displayBodyItems.value.length > 0)
763
1181
 
764
1182
  const hasActiveFilters = computed(() =>
765
1183
  isFilterQueryActive(activeFilterQuery.value, props.headers, props.data),
@@ -813,6 +1231,7 @@ const queryParams = computed(() => {
813
1231
  pageSize: pageSize.value,
814
1232
  sortBy: sortBy.value,
815
1233
  sortDir: sortDir.value,
1234
+ groupBy: activeGroupBy.value,
816
1235
  filterQuery: activeFilterQuery.value,
817
1236
  }
818
1237
 
@@ -846,12 +1265,32 @@ function isHeaderFiltered(header) {
846
1265
  return isColumnFiltered(activeFilters.value?.[header.key], header, props.data)
847
1266
  }
848
1267
 
1268
+ function headerAriaSort(header) {
1269
+ if (!header?.sortable) {
1270
+ return undefined
1271
+ }
1272
+ if (sortBy.value !== header.key) {
1273
+ return 'none'
1274
+ }
1275
+ return sortDir.value === 'desc' ? 'descending' : 'ascending'
1276
+ }
1277
+
1278
+ function headerFilterExpanded(header) {
1279
+ if (!isHeaderFilterable(header)) {
1280
+ return undefined
1281
+ }
1282
+ return filterPopoverOpen.value && activeFilterHeader.value?.key === header.key
1283
+ ? 'true'
1284
+ : 'false'
1285
+ }
1286
+
849
1287
  function headerClasses(header) {
850
1288
  const priority = resolvedColumnPriority(header)
851
1289
  return {
852
1290
  sortable: header.sortable,
853
1291
  filterable: isHeaderFilterable(header),
854
1292
  filtered: isHeaderFiltered(header),
1293
+ grouped: isHeaderGrouped(header),
855
1294
  'menu-open': filterPopoverOpen.value && activeFilterHeader.value?.key === header.key,
856
1295
  [colPriorityClass(priority)]: isValidColPriority(priority),
857
1296
  }
@@ -1110,6 +1549,14 @@ function moveActiveCell(rowDelta, columnDelta, options = {}) {
1110
1549
  return activateCellAt(rowIndex, columnIndex, options)
1111
1550
  }
1112
1551
 
1552
+ function isInteractiveCellTarget(target) {
1553
+ return Boolean(
1554
+ target?.closest?.(
1555
+ 'a, button, input, select, textarea, [contenteditable="true"], [data-row-click-ignore]',
1556
+ ),
1557
+ )
1558
+ }
1559
+
1113
1560
  function onCellClick(row, header, rowIndex, event) {
1114
1561
  const context = cellContext(row, header, rowIndex)
1115
1562
  const nextActiveCell = {
@@ -1117,6 +1564,9 @@ function onCellClick(row, header, rowIndex, event) {
1117
1564
  columnKey: header.key,
1118
1565
  }
1119
1566
  setActiveCell(nextActiveCell)
1567
+ if (!isInteractiveCellTarget(event.target)) {
1568
+ scheduleCellFocus(nextActiveCell, { focus: true })
1569
+ }
1120
1570
  emit('cell-click', {
1121
1571
  ...context,
1122
1572
  ...nextActiveCell,
@@ -1124,6 +1574,39 @@ function onCellClick(row, header, rowIndex, event) {
1124
1574
  })
1125
1575
  }
1126
1576
 
1577
+ function onCellKeydown(row, header, rowIndex, event) {
1578
+ if (!activeCell.value || !isActiveCell(row, header, rowIndex)) {
1579
+ return
1580
+ }
1581
+
1582
+ if (isInteractiveCellTarget(event.target) && event.target !== event.currentTarget) {
1583
+ return
1584
+ }
1585
+
1586
+ let rowDelta = 0
1587
+ let columnDelta = 0
1588
+
1589
+ switch (event.key) {
1590
+ case 'ArrowUp':
1591
+ rowDelta = -1
1592
+ break
1593
+ case 'ArrowDown':
1594
+ rowDelta = 1
1595
+ break
1596
+ case 'ArrowLeft':
1597
+ columnDelta = -1
1598
+ break
1599
+ case 'ArrowRight':
1600
+ columnDelta = 1
1601
+ break
1602
+ default:
1603
+ return
1604
+ }
1605
+
1606
+ event.preventDefault()
1607
+ moveActiveCell(rowDelta, columnDelta, { focus: true })
1608
+ }
1609
+
1127
1610
  const activeSelectedKeys = computed(() => (
1128
1611
  props.selectedKeys !== undefined ? props.selectedKeys : internalSelectedKeys.value
1129
1612
  ))
@@ -1402,7 +1885,7 @@ function closeRowContextMenu() {
1402
1885
  rowContextMenuAnchor.value = null
1403
1886
  }
1404
1887
 
1405
- function onRowContextMenu(row, displayIndex, event) {
1888
+ function onRowContextMenu(row, sourceIndex, event) {
1406
1889
  if (!hasRowContextMenu.value) {
1407
1890
  return
1408
1891
  }
@@ -1413,8 +1896,8 @@ function onRowContextMenu(row, displayIndex, event) {
1413
1896
  event.preventDefault()
1414
1897
  event.stopPropagation()
1415
1898
 
1416
- const key = resolveRowKey(row, displayIndex)
1417
- const index = filteredRowIndex(displayIndex)
1899
+ const key = resolveRowKey(row, sourceIndex)
1900
+ const index = filteredRowIndex(sourceIndex)
1418
1901
  let targetKeys
1419
1902
 
1420
1903
  if (props.selectable && selectedKeySet.value.has(key) && activeSelectedKeys.value.length > 0) {
@@ -1549,6 +2032,17 @@ function onFilterCancel() {
1549
2032
  activeFilterValue.value = []
1550
2033
  }
1551
2034
 
2035
+ const canGroupByActiveFilterColumn = computed(() => {
2036
+ if (!props.groupable || !activeFilterHeader.value) {
2037
+ return false
2038
+ }
2039
+ return isHeaderGroupable(activeFilterHeader.value, props.groupable)
2040
+ })
2041
+
2042
+ const isActiveFilterHeaderGrouped = computed(() =>
2043
+ Boolean(activeFilterHeader.value?.key && activeFilterHeader.value.key === activeGroupBy.value),
2044
+ )
2045
+
1552
2046
  const canHideActiveFilterColumn = computed(() => {
1553
2047
  if (!showColumnOptionsButton.value || !activeFilterHeader.value) {
1554
2048
  return false
@@ -1563,6 +2057,12 @@ const canHideActiveFilterColumn = computed(() => {
1563
2057
  return configurableHeaders.value.some((item) => item.key === header.key)
1564
2058
  })
1565
2059
 
2060
+ function onFilterGroupBy(columnKey) {
2061
+ setGroupBy(columnKey || null)
2062
+ filterPopoverOpen.value = false
2063
+ onFilterCancel()
2064
+ }
2065
+
1566
2066
  function onFilterHideColumn() {
1567
2067
  const header = activeFilterHeader.value
1568
2068
  if (!header?.key || !canHideActiveFilterColumn.value) {
@@ -1648,10 +2148,13 @@ function openColumnOptions() {
1648
2148
  void refreshSavedPresets()
1649
2149
  }
1650
2150
 
1651
- function onColumnOptionsApply({ order, visibleKeys, priorities }) {
2151
+ function onColumnOptionsApply({ order, visibleKeys, priorities, groupBy }) {
1652
2152
  setColumnOrder(order)
1653
2153
  setColumnVisibility(visibleKeys)
1654
2154
  setColumnPriorities(priorities)
2155
+ if (groupBy !== undefined && groupBy !== activeGroupBy.value) {
2156
+ setGroupBy(groupBy)
2157
+ }
1655
2158
  updateActiveLayoutLabel()
1656
2159
  }
1657
2160
 
@@ -1660,15 +2163,16 @@ function onColumnOptionsCancel() {
1660
2163
  }
1661
2164
 
1662
2165
  async function refreshSavedPresets() {
1663
- if (isRemote.value) {
1664
- if (!props.layoutPresets || typeof props.layoutPresets.list !== 'function') {
2166
+ if (isCallbackViewControl.value) {
2167
+ const handlers = viewSettingsHandlers.value
2168
+ if (!handlers || typeof handlers.list !== 'function') {
1665
2169
  savedPresets.value = []
1666
2170
  defaultPresetId.value = null
1667
2171
  return
1668
2172
  }
1669
2173
 
1670
2174
  try {
1671
- const result = await props.layoutPresets.list()
2175
+ const result = await handlers.list()
1672
2176
  savedPresets.value = Array.isArray(result?.presets) ? result.presets : []
1673
2177
  defaultPresetId.value = result?.defaultPresetId ?? null
1674
2178
  } catch {
@@ -1706,6 +2210,7 @@ function getCurrentLayoutState() {
1706
2210
  filters: cloneFilters(activeFilters.value),
1707
2211
  sortBy: sortBy.value,
1708
2212
  sortDir: sortDir.value,
2213
+ groupBy: activeGroupBy.value,
1709
2214
  pageSize: pageSize.value,
1710
2215
  }
1711
2216
  }
@@ -1745,6 +2250,12 @@ function applyPresetState(state) {
1745
2250
  sortDir.value = 'asc'
1746
2251
  }
1747
2252
 
2253
+ if (sanitized.groupBy) {
2254
+ setGroupBy(sanitized.groupBy)
2255
+ } else {
2256
+ setGroupBy(null)
2257
+ }
2258
+
1748
2259
  if (sanitized.pageSize) {
1749
2260
  pageSize.value = sanitized.pageSize
1750
2261
  }
@@ -1760,13 +2271,14 @@ function applyPresetState(state) {
1760
2271
  async function onSavePreset({ name, setAsDefault }) {
1761
2272
  const state = getCurrentLayoutState()
1762
2273
 
1763
- if (isRemote.value) {
1764
- if (!props.layoutPresets || typeof props.layoutPresets.save !== 'function') {
1765
- return { ok: false, error: 'Remote layout save is unavailable.' }
2274
+ if (isCallbackViewControl.value) {
2275
+ const handlers = viewSettingsHandlers.value
2276
+ if (!handlers || typeof handlers.save !== 'function') {
2277
+ return { ok: false, error: 'View save callback is unavailable.' }
1766
2278
  }
1767
2279
 
1768
2280
  try {
1769
- const result = await props.layoutPresets.save({ name, state, setAsDefault })
2281
+ const result = await handlers.save({ name, state, setAsDefault })
1770
2282
  if (result?.ok) {
1771
2283
  await refreshSavedPresets()
1772
2284
  if (result.preset?.id) {
@@ -1799,14 +2311,50 @@ async function onSavePreset({ name, setAsDefault }) {
1799
2311
  return result
1800
2312
  }
1801
2313
 
2314
+ async function onOverwritePreset({ presetId }) {
2315
+ const state = getCurrentLayoutState()
2316
+
2317
+ if (isCallbackViewControl.value) {
2318
+ const handlers = viewSettingsHandlers.value
2319
+ if (!handlers || typeof handlers.overwrite !== 'function') {
2320
+ return { ok: false, error: 'View overwrite callback is unavailable.' }
2321
+ }
2322
+
2323
+ try {
2324
+ const result = await handlers.overwrite({ presetId, state })
2325
+ if (result?.ok) {
2326
+ await refreshSavedPresets()
2327
+ activeLayoutPresetId.value = presetId
2328
+ updateActiveLayoutLabel()
2329
+ }
2330
+ return result ?? { ok: false, error: 'Unable to overwrite layout.' }
2331
+ } catch {
2332
+ return { ok: false, error: 'Unable to overwrite layout.' }
2333
+ }
2334
+ }
2335
+
2336
+ if (!props.tableId) {
2337
+ return { ok: false, error: 'Table identity is required.' }
2338
+ }
2339
+
2340
+ const result = overwritePreset(props.tableId, presetId, { state })
2341
+ if (result.ok) {
2342
+ refreshSavedPresets()
2343
+ activeLayoutPresetId.value = presetId
2344
+ updateActiveLayoutLabel()
2345
+ }
2346
+ return result
2347
+ }
2348
+
1802
2349
  async function onLoadPreset(presetId) {
1803
- if (isRemote.value) {
1804
- if (!props.layoutPresets || typeof props.layoutPresets.load !== 'function') {
1805
- return { ok: false, error: 'Remote layout load is unavailable.' }
2350
+ if (isCallbackViewControl.value) {
2351
+ const handlers = viewSettingsHandlers.value
2352
+ if (!handlers || typeof handlers.load !== 'function') {
2353
+ return { ok: false, error: 'View load callback is unavailable.' }
1806
2354
  }
1807
2355
 
1808
2356
  try {
1809
- const result = await props.layoutPresets.load(presetId)
2357
+ const result = await handlers.load(presetId)
1810
2358
  if (!result?.ok || !result.preset?.state) {
1811
2359
  return result ?? { ok: false, error: 'Preset not found.' }
1812
2360
  }
@@ -1836,13 +2384,14 @@ async function onLoadPreset(presetId) {
1836
2384
  }
1837
2385
 
1838
2386
  async function onDeletePreset(presetId) {
1839
- if (isRemote.value) {
1840
- if (!props.layoutPresets || typeof props.layoutPresets.delete !== 'function') {
1841
- return { ok: false, error: 'Remote layout delete is unavailable.' }
2387
+ if (isCallbackViewControl.value) {
2388
+ const handlers = viewSettingsHandlers.value
2389
+ if (!handlers || typeof handlers.delete !== 'function') {
2390
+ return { ok: false, error: 'View delete callback is unavailable.' }
1842
2391
  }
1843
2392
 
1844
2393
  try {
1845
- const result = await props.layoutPresets.delete(presetId)
2394
+ const result = await handlers.delete(presetId)
1846
2395
  if (result?.ok) {
1847
2396
  await refreshSavedPresets()
1848
2397
  if (activeLayoutPresetId.value === presetId) {
@@ -1872,13 +2421,14 @@ async function onDeletePreset(presetId) {
1872
2421
  }
1873
2422
 
1874
2423
  async function onSetDefaultPreset(presetId) {
1875
- if (isRemote.value) {
1876
- if (!props.layoutPresets || typeof props.layoutPresets.setDefault !== 'function') {
2424
+ if (isCallbackViewControl.value) {
2425
+ const handlers = viewSettingsHandlers.value
2426
+ if (!handlers || typeof handlers.setDefault !== 'function') {
1877
2427
  return { ok: false, error: 'Unable to update default layout.' }
1878
2428
  }
1879
2429
 
1880
2430
  try {
1881
- const result = await props.layoutPresets.setDefault(presetId)
2431
+ const result = await handlers.setDefault(presetId)
1882
2432
  if (result?.ok) {
1883
2433
  await refreshSavedPresets()
1884
2434
  }
@@ -1921,6 +2471,7 @@ function getDeveloperDefaultLayoutState() {
1921
2471
  filters: {},
1922
2472
  sortBy: null,
1923
2473
  sortDir: 'asc',
2474
+ groupBy: null,
1924
2475
  pageSize: DEFAULT_PAGE_SIZE,
1925
2476
  }
1926
2477
  }
@@ -1980,6 +2531,7 @@ function loadDeveloperDefaults() {
1980
2531
  setFilters({})
1981
2532
  sortBy.value = null
1982
2533
  sortDir.value = 'asc'
2534
+ setGroupBy(null)
1983
2535
  setColumnOrder(getDeveloperDefaultColumnOrder())
1984
2536
  setColumnPriorities({})
1985
2537
 
@@ -2074,6 +2626,17 @@ watch(
2074
2626
  },
2075
2627
  )
2076
2628
 
2629
+ watch(
2630
+ activeGroupBy,
2631
+ () => {
2632
+ page.value = 1
2633
+ if (isRemote.value) {
2634
+ scheduleFetchRows()
2635
+ }
2636
+ emitQueryChange()
2637
+ },
2638
+ )
2639
+
2077
2640
  watch(
2078
2641
  () => [sortBy.value, sortDir.value],
2079
2642
  () => {
@@ -2184,6 +2747,24 @@ watch(
2184
2747
  { deep: true },
2185
2748
  )
2186
2749
 
2750
+ watch(
2751
+ () => props.groupBy,
2752
+ (nextGroupBy) => {
2753
+ if (nextGroupBy !== undefined) {
2754
+ collapsedGroupIds.value = []
2755
+ }
2756
+ },
2757
+ )
2758
+
2759
+ watch(
2760
+ () => props.groupable,
2761
+ (enabled) => {
2762
+ if (!enabled) {
2763
+ setGroupBy(null)
2764
+ }
2765
+ },
2766
+ )
2767
+
2187
2768
  watch(
2188
2769
  () => props.columnPriorities,
2189
2770
  (nextPriorities) => {
@@ -2204,7 +2785,7 @@ watch(
2204
2785
  )
2205
2786
 
2206
2787
  watch(
2207
- () => [props.tableId, props.layoutPresets],
2788
+ () => [props.viewControlMode, props.tableId, viewSettingsHandlers.value],
2208
2789
  () => {
2209
2790
  void refreshSavedPresets()
2210
2791
  updateActiveLayoutLabel()
@@ -2221,6 +2802,7 @@ watch(
2221
2802
  activeFilters,
2222
2803
  sortBy,
2223
2804
  sortDir,
2805
+ activeGroupBy,
2224
2806
  pageSize,
2225
2807
  () => props.headers,
2226
2808
  savedPresets,
@@ -2247,17 +2829,18 @@ watch(
2247
2829
 
2248
2830
  onMounted(async () => {
2249
2831
  if (props.loadSavedLayout) {
2250
- if (isRemote.value && props.layoutPresets && typeof props.layoutPresets.getDefault === 'function') {
2832
+ const handlers = viewSettingsHandlers.value
2833
+ if (isCallbackViewControl.value && handlers && typeof handlers.getDefault === 'function') {
2251
2834
  try {
2252
- const result = await props.layoutPresets.getDefault()
2835
+ const result = await handlers.getDefault()
2253
2836
  if (result?.preset?.state) {
2254
2837
  applyPresetState(result.preset.state)
2255
2838
  activeLayoutPresetId.value = result.preset.id
2256
2839
  }
2257
2840
  } catch {
2258
- // Ignore default layout load failures in remote mode.
2841
+ // Ignore default layout load failures in callback mode.
2259
2842
  }
2260
- } else if (!isRemote.value && props.tableId) {
2843
+ } else if (!isCallbackViewControl.value && props.tableId) {
2261
2844
  const preset = getDefaultPreset(props.tableId)
2262
2845
  if (preset?.state) {
2263
2846
  applyPresetState(preset.state)
@@ -2275,6 +2858,7 @@ onMounted(async () => {
2275
2858
  })
2276
2859
 
2277
2860
  onBeforeUnmount(() => {
2861
+ endColumnResize()
2278
2862
  if (fetchAbortController.value) {
2279
2863
  fetchAbortController.value.abort()
2280
2864
  }
@@ -2296,11 +2880,85 @@ defineExpose({
2296
2880
  </script>
2297
2881
 
2298
2882
  <style scoped>
2883
+ table.has-column-widths {
2884
+ table-layout: fixed;
2885
+ width: 100%;
2886
+ }
2887
+
2888
+ /* Force fixed-layout cells to respect column widths so overflow clipping/ellipsis work. */
2889
+ table.has-column-widths th,
2890
+ table.has-column-widths td {
2891
+ max-width: 0;
2892
+ overflow: hidden;
2893
+ text-overflow: ellipsis;
2894
+ white-space: nowrap;
2895
+ }
2896
+
2897
+ table.has-column-widths th.table-select-col,
2898
+ table.has-column-widths td.table-select-col,
2899
+ table.has-column-widths th.table-column-options-header {
2900
+ max-width: none;
2901
+ }
2902
+
2903
+ table.is-column-resizing {
2904
+ cursor: col-resize;
2905
+ }
2906
+
2907
+ table.is-column-resizing thead th.sortable:hover {
2908
+ cursor: col-resize;
2909
+ }
2910
+
2911
+ col.table-select-col-width,
2912
+ col.table-column-options-col-width {
2913
+ width: 2.5rem;
2914
+ }
2915
+
2916
+ table thead th {
2917
+ position: relative;
2918
+ }
2919
+
2299
2920
  table thead th.sortable:hover {
2300
2921
  cursor: pointer;
2301
2922
  color: var(--table-header-active-fg);
2302
2923
  }
2303
2924
 
2925
+ .column-resize-handle {
2926
+ position: absolute;
2927
+ top: 0;
2928
+ right: -0.2rem;
2929
+ z-index: 2;
2930
+ width: 0.55rem;
2931
+ height: 100%;
2932
+ padding: 0;
2933
+ border: 0;
2934
+ background: transparent;
2935
+ cursor: col-resize;
2936
+ touch-action: none;
2937
+ user-select: none;
2938
+ opacity: 0;
2939
+ }
2940
+
2941
+ .column-resize-handle::after {
2942
+ content: '';
2943
+ position: absolute;
2944
+ top: 20%;
2945
+ bottom: 20%;
2946
+ left: 50%;
2947
+ width: 2px;
2948
+ transform: translateX(-50%);
2949
+ border-radius: 1px;
2950
+ background-color: var(--border-color, currentColor);
2951
+ opacity: 0.85;
2952
+ }
2953
+
2954
+ table thead th:hover .column-resize-handle,
2955
+ table thead th.is-resizing .column-resize-handle,
2956
+ .column-resize-handle:hover,
2957
+ .column-resize-handle:focus-visible,
2958
+ .column-resize-handle.is-active {
2959
+ opacity: 1;
2960
+ }
2961
+
2304
2962
  table thead th.filterable {
2305
2963
  cursor: context-menu;
2306
2964
  }
@@ -2330,9 +2988,57 @@ table.loading tbody {
2330
2988
  }
2331
2989
 
2332
2990
  .sort-indicator {
2991
+ display: inline-flex;
2992
+ align-items: center;
2993
+ justify-content: center;
2333
2994
  width: 1.5em;
2334
- display: inline-block;
2335
- text-align: center;
2995
+ vertical-align: middle;
2996
+ }
2997
+
2998
+ .group-indicator {
2999
+ display: inline-flex;
3000
+ vertical-align: middle;
3001
+ margin-right: 0.25rem;
3002
+ opacity: 0.85;
3003
+ }
3004
+
3005
+ table thead th.grouped {
3006
+ color: var(--table-header-active-fg);
3007
+ }
3008
+
3009
+ tbody tr.table-group-row > td.table-group-cell {
3010
+ background-color: var(--table-header-active-bg);
3011
+ color: var(--table-header-fg);
3012
+ font-weight: 600;
3013
+ padding-top: 0.65rem;
3014
+ padding-bottom: 0.65rem;
3015
+ }
3016
+
3017
+ .table-group-toggle {
3018
+ display: inline-flex;
3019
+ align-items: center;
3020
+ justify-content: center;
3021
+ min-width: 1.5rem;
3022
+ margin-right: 0.35rem;
3023
+ padding: 0.1rem 0.35rem;
3024
+ vertical-align: middle;
3025
+ }
3026
+
3027
+ .table-group-chevron {
3028
+ flex-shrink: 0;
3029
+ transition: transform 0.15s ease;
3030
+ }
3031
+
3032
+ .table-group-row:not(.table-group-row-collapsed) .table-group-chevron {
3033
+ transform: rotate(180deg);
3034
+ }
3035
+
3036
+ .table-group-label {
3037
+ margin-right: 0.35rem;
3038
+ }
3039
+
3040
+ .table-group-count {
3041
+ font-weight: normal;
2336
3042
  }
2337
3043
 
2338
3044
  td:first-child,