picocrank 1.33.0 → 1.34.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 (35) hide show
  1. package/docs/filter-query-v1.md +5 -1
  2. package/package.json +1 -1
  3. package/router.js +33 -0
  4. package/styles/picocrank-extensions.css +23 -2
  5. package/vue/components/Accordion.spec.js +48 -0
  6. package/vue/components/Accordion.vue +46 -0
  7. package/vue/components/AccordionItem.vue +103 -0
  8. package/vue/components/FieldValue.spec.js +88 -0
  9. package/vue/components/FieldValue.vue +222 -0
  10. package/vue/components/FormField.spec.js +1 -0
  11. package/vue/components/FormField.vue +38 -10
  12. package/vue/components/Header.vue +24 -2
  13. package/vue/components/NotificationBlock.vue +25 -0
  14. package/vue/components/ReorderList.spec.js +318 -0
  15. package/vue/components/ReorderList.vue +614 -0
  16. package/vue/components/Table.spec.js +27 -0
  17. package/vue/components/Table.vue +458 -112
  18. package/vue/components/TableColumnFilterPopover.vue +63 -19
  19. package/vue/components/TableColumnOptionsPopover.vue +248 -273
  20. package/vue/components/TopBar.vue +6 -2
  21. package/vue/components/accordionContext.js +1 -0
  22. package/vue/composables/tableColumnPresets.js +33 -0
  23. package/vue/composables/tableGroupBy.js +173 -0
  24. package/vue/composables/tableGroupBy.spec.js +69 -0
  25. package/vue/composables/tableViewSettings.js +34 -0
  26. package/vue/composables/tableViewSettings.spec.js +21 -0
  27. package/vue/composables/useHeaderTopBarFit.js +91 -0
  28. package/vue/composables/useResponsiveNav.js +5 -3
  29. package/vue/data/navSectionHubs.js +16 -1
  30. package/vue/examples/AccordionExample.vue +33 -0
  31. package/vue/examples/FieldValueExample.vue +143 -0
  32. package/vue/examples/FormExample.vue +11 -6
  33. package/vue/examples/ReorderListExample.vue +236 -0
  34. package/vue/examples/TableExample.vue +5 -1
  35. package/vue/examples/TableRemoteExample.vue +28 -4
@@ -41,11 +41,38 @@
41
41
 
42
42
  <span v-if="filterable && isHeaderFiltered(header)" class="filter-indicator" title="Filtered" aria-hidden="true">⧩</span>
43
43
 
44
+ <span
45
+ v-if="isHeaderGrouped(header)"
46
+ class="group-indicator"
47
+ title="Grouped"
48
+ aria-hidden="true"
49
+ >
50
+ <HugeiconsIcon
51
+ :icon="Group01Icon"
52
+ width="0.95em"
53
+ height="0.95em"
54
+ :strokeWidth="2"
55
+ aria-hidden="true"
56
+ />
57
+ </span>
58
+
44
59
  <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>
60
+ <HugeiconsIcon
61
+ v-if="sortBy === header.key && sortDir === 'asc'"
62
+ :icon="SortByUp02Icon"
63
+ width="0.95em"
64
+ height="0.95em"
65
+ :strokeWidth="2"
66
+ aria-hidden="true"
67
+ />
68
+ <HugeiconsIcon
69
+ v-else-if="sortBy === header.key && sortDir === 'desc'"
70
+ :icon="SortByDown02Icon"
71
+ width="0.95em"
72
+ height="0.95em"
73
+ :strokeWidth="2"
74
+ aria-hidden="true"
75
+ />
49
76
  </span>
50
77
  </th>
51
78
  <th
@@ -74,77 +101,112 @@
74
101
  </tr>
75
102
  </thead>
76
103
  <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
104
+ <template v-for="(item, itemIndex) in displayBodyItems" :key="bodyItemKey(item, itemIndex)">
105
+ <tr
106
+ v-if="item.kind === 'group'"
107
+ class="table-group-row"
108
+ :class="{ 'table-group-row-collapsed': item.collapsed }"
97
109
  >
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)"
110
+ <td
111
+ v-if="selectable"
112
+ class="table-select-col table-group-cell"
113
+ data-row-click-ignore
114
+ />
115
+ <td
116
+ :colspan="bodyGroupColspan"
117
+ class="table-group-cell"
118
+ >
119
+ <button
120
+ type="button"
121
+ class="table-group-toggle neutral"
122
+ :aria-expanded="item.collapsed ? 'false' : 'true'"
123
+ :aria-label="item.collapsed ? 'Expand group' : 'Collapse group'"
124
+ data-row-click-ignore
125
+ @click.stop="toggleGroupCollapsed(item.groupId)"
105
126
  >
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"
127
+ <HugeiconsIcon
128
+ class="table-group-chevron"
129
+ :icon="ArrowDown01Icon"
130
+ width="1em"
131
+ height="1em"
132
+ aria-hidden="true"
133
+ />
134
+ </button>
135
+ <span class="table-group-label">{{ formatGroupValue(item.value) }}</span>
136
+ <span class="table-group-count subtle">({{ item.count }})</span>
137
+ </td>
138
+ </tr>
139
+ <tr
140
+ v-else
114
141
  :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,
142
+ {
143
+ 'row-clickable': rowClickable,
144
+ 'row-selected': selectable && isRowSelected(item.row, item.displayIndex),
145
+ 'row-context-menu-active': isRowContextMenuTarget(item.row, item.displayIndex),
146
+ },
147
+ resolveRowClass(item.row, item.displayIndex),
120
148
  ]"
121
- :style="resolveCellStyle(row, header, rowIndex)"
122
- :tabindex="isActiveCell(row, header, rowIndex) ? 0 : -1"
123
- @click="onCellClick(row, header, rowIndex, $event)"
149
+ :style="resolveRowStyle(item.row, item.displayIndex)"
150
+ @click="onRowClick(item.row, item.displayIndex, $event)"
151
+ @contextmenu="onRowContextMenu(item.row, item.displayIndex, $event)"
124
152
  >
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>
153
+ <td
154
+ v-if="selectable"
155
+ class="table-select-col"
156
+ data-row-click-ignore
157
+ @click.stop
158
+ >
159
+ <label class="table-select-label">
160
+ <input
161
+ type="checkbox"
162
+ :checked="isRowSelected(item.row, item.displayIndex)"
163
+ :aria-label="`Select row ${resolveRowKey(item.row, item.displayIndex)}`"
164
+ @click.stop="rememberSelectionCheckboxClick(item.row, item.displayIndex, $event)"
165
+ @change="onSelectionCheckboxChange(item.row, item.displayIndex)"
166
+ >
167
+ </label>
168
+ </td>
169
+ <td
170
+ v-for="(header, cellIndex) in visibleHeaders"
171
+ :key="header.key || cellIndex"
172
+ :ref="(element) => registerCellElement(element, item.row, header, item.displayIndex)"
173
+ v-bind="resolveCellAttrs(item.row, header, item.displayIndex)"
174
+ :colspan="bodyColumnColspan(cellIndex) || undefined"
175
+ :class="[
176
+ cellClasses(header),
177
+ header.class,
178
+ resolveCellClass(item.row, header, item.displayIndex),
179
+ { 'table-active-cell': isActiveCell(item.row, header, item.displayIndex) },
180
+ isLastColumnWithOptions(cellIndex) ? 'table-last-column-with-options' : null,
181
+ ]"
182
+ :style="resolveCellStyle(item.row, header, item.displayIndex)"
183
+ :tabindex="isActiveCell(item.row, header, item.displayIndex) ? 0 : -1"
184
+ @click="onCellClick(item.row, header, item.displayIndex, $event)"
185
+ >
186
+ <slot
187
+ v-if="slots[`cell-${header.key}`]"
188
+ :name="`cell-${header.key}`"
189
+ :row="item.row"
190
+ :value="item.row[header.key]"
191
+ :header="header"
192
+ :row-index="item.displayIndex"
193
+ :source-index="sourceRowIndex(item.row, item.displayIndex)"
194
+ />
195
+ <slot
196
+ v-else-if="slots.cell"
197
+ name="cell"
198
+ :row="item.row"
199
+ :value="item.row[header.key]"
200
+ :header="header"
201
+ :row-index="item.displayIndex"
202
+ :source-index="sourceRowIndex(item.row, item.displayIndex)"
203
+ />
204
+ <span v-else>
205
+ {{ item.row[header.key] }}
206
+ </span>
207
+ </td>
208
+ </tr>
209
+ </template>
148
210
  </tbody>
149
211
  </table>
150
212
 
@@ -174,10 +236,13 @@
174
236
  :rows="props.data"
175
237
  :select-options="activeSelectOptions"
176
238
  :can-hide="canHideActiveFilterColumn"
239
+ :can-group-by="canGroupByActiveFilterColumn"
240
+ :is-grouped-column="isActiveFilterHeaderGrouped"
177
241
  @apply="onFilterApply"
178
242
  @clear="onFilterClear"
179
243
  @cancel="onFilterCancel"
180
244
  @hide="onFilterHideColumn"
245
+ @group-by="onFilterGroupBy"
181
246
  />
182
247
 
183
248
  <TableColumnOptionsPopover
@@ -195,10 +260,13 @@
195
260
  :presets="savedPresets"
196
261
  :default-preset-id="defaultPresetId"
197
262
  :save-layout-preset="onSavePreset"
263
+ :overwrite-layout-preset="onOverwritePreset"
198
264
  :load-layout-preset="onLoadPreset"
199
265
  :delete-layout-preset="onDeletePreset"
200
266
  :set-default-layout-preset="onSetDefaultPreset"
201
267
  :load-developer-defaults="loadDeveloperDefaults"
268
+ :group-by="activeGroupBy"
269
+ :groupable="props.groupable"
202
270
  :anchor-el="columnOptionsButtonRef"
203
271
  @apply="onColumnOptionsApply"
204
272
  @cancel="onColumnOptionsCancel"
@@ -217,7 +285,13 @@
217
285
  <script setup>
218
286
  import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount, useSlots, useAttrs } from 'vue'
219
287
  import { HugeiconsIcon } from '@hugeicons/vue'
220
- import { LayoutGridIcon } from '@hugeicons/core-free-icons'
288
+ import {
289
+ ArrowDown01Icon,
290
+ Group01Icon,
291
+ LayoutGridIcon,
292
+ SortByDown02Icon,
293
+ SortByUp02Icon,
294
+ } from '@hugeicons/core-free-icons'
221
295
  import Pagination from './Pagination.vue'
222
296
  import TableColumnFilterPopover from './TableColumnFilterPopover.vue'
223
297
  import TableColumnOptionsPopover from './TableColumnOptionsPopover.vue'
@@ -242,15 +316,26 @@ import {
242
316
  getPresetById,
243
317
  listPresets,
244
318
  savePreset,
319
+ overwritePreset,
245
320
  deletePreset,
246
321
  setDefaultPreset,
247
322
  sanitizePresetState,
248
323
  layoutStatesEqual,
249
324
  } from '../composables/tableColumnPresets.js'
325
+ import {
326
+ getViewSettingsHandlers,
327
+ isViewSettingsControlEnabled,
328
+ VIEW_CONTROL_CALLBACK,
329
+ } from '../composables/tableViewSettings.js'
250
330
  import {
251
331
  defaultColumnPriorityForIndex,
252
332
  isValidColPriority,
253
333
  } from '../composables/tableColumnPriorities.js'
334
+ import {
335
+ buildDisplayBodyItems,
336
+ formatGroupValue,
337
+ isHeaderGroupable,
338
+ } from '../composables/tableGroupBy.js'
254
339
 
255
340
  const DEFAULT_PAGE_SIZE = 10
256
341
 
@@ -343,6 +428,21 @@ const props = defineProps({
343
428
  type: String,
344
429
  default: '',
345
430
  },
431
+ /**
432
+ * How saved table views (column order, visibility, filters in presets, etc.) are stored.
433
+ * - `localStorageControlled`: requires `tableId`; uses browser localStorage (default).
434
+ * - `callbackControlled`: requires `viewSettings` callbacks (server or app-owned persistence).
435
+ */
436
+ viewControlMode: {
437
+ type: String,
438
+ default: 'localStorageControlled',
439
+ validator: (value) => ['localStorageControlled', 'callbackControlled'].includes(value),
440
+ },
441
+ /** Callback handlers when `viewControlMode` is `callbackControlled`. */
442
+ viewSettings: {
443
+ type: Object,
444
+ default: null,
445
+ },
346
446
  loadSavedLayout: {
347
447
  type: Boolean,
348
448
  default: true,
@@ -355,6 +455,7 @@ const props = defineProps({
355
455
  type: [String, Function],
356
456
  default: 'id',
357
457
  },
458
+ /** @deprecated Use `viewSettings` with `viewControlMode="callbackControlled"`. */
358
459
  layoutPresets: {
359
460
  type: Object,
360
461
  default: null,
@@ -406,6 +507,14 @@ const props = defineProps({
406
507
  type: [Array, Function],
407
508
  default: null,
408
509
  },
510
+ groupable: {
511
+ type: Boolean,
512
+ default: true,
513
+ },
514
+ groupBy: {
515
+ type: String,
516
+ default: undefined,
517
+ },
409
518
  })
410
519
 
411
520
  const emit = defineEmits([
@@ -432,6 +541,8 @@ const emit = defineEmits([
432
541
  'column-priorities-change',
433
542
  'update:layoutLabel',
434
543
  'layout-label-change',
544
+ 'update:groupBy',
545
+ 'group-by-change',
435
546
  ])
436
547
 
437
548
  const attrs = useAttrs()
@@ -561,15 +672,24 @@ const internalColumnPriorities = ref({})
561
672
  const savedPresets = ref([])
562
673
  const defaultPresetId = ref(null)
563
674
  const activeLayoutPresetId = ref(null)
675
+ const internalGroupBy = ref(null)
676
+ const collapsedGroupIds = ref([])
564
677
 
565
678
  const isRemote = computed(() => typeof props.fetchRows === 'function')
566
679
 
567
- const layoutPresetsAvailable = computed(() => {
568
- if (isRemote.value) {
569
- return props.layoutPresets != null
570
- }
571
- return Boolean(props.tableId)
572
- })
680
+ const viewSettingsHandlers = computed(() =>
681
+ getViewSettingsHandlers(props.viewSettings, props.layoutPresets))
682
+
683
+ const isCallbackViewControl = computed(
684
+ () => props.viewControlMode === VIEW_CONTROL_CALLBACK,
685
+ )
686
+
687
+ const layoutPresetsAvailable = computed(() =>
688
+ isViewSettingsControlEnabled(
689
+ props.viewControlMode,
690
+ props.tableId,
691
+ viewSettingsHandlers.value,
692
+ ))
573
693
 
574
694
  const activeFilters = computed(() => {
575
695
  if (!props.filterable) {
@@ -629,6 +749,53 @@ function bodyColumnColspan(index) {
629
749
  return isLastColumnWithOptions(index) ? 2 : null
630
750
  }
631
751
 
752
+ const bodyGroupColspan = computed(() => {
753
+ let colspan = visibleHeaders.value.length
754
+ if (showColumnOptionsButton.value) {
755
+ colspan += 1
756
+ }
757
+ return colspan
758
+ })
759
+
760
+ function bodyItemKey(item, index) {
761
+ if (item.kind === 'group') {
762
+ return `group-${item.groupId}`
763
+ }
764
+ return resolveRowKey(item.row, item.sourceIndex ?? index)
765
+ }
766
+
767
+ function isHeaderGrouped(header) {
768
+ return Boolean(activeGroupBy.value && header?.key === activeGroupBy.value)
769
+ }
770
+
771
+ function toggleGroupCollapsed(groupId) {
772
+ const next = new Set(collapsedGroupIdSet.value)
773
+ if (next.has(groupId)) {
774
+ next.delete(groupId)
775
+ } else {
776
+ next.add(groupId)
777
+ }
778
+ collapsedGroupIds.value = [...next]
779
+ }
780
+
781
+ function setGroupBy(nextGroupBy) {
782
+ const header = props.headers.find((item) => item.key === nextGroupBy)
783
+ const normalized = isHeaderGroupable(header, props.groupable) ? nextGroupBy : null
784
+
785
+ if (props.groupBy === undefined) {
786
+ internalGroupBy.value = normalized
787
+ }
788
+
789
+ collapsedGroupIds.value = []
790
+ page.value = 1
791
+ emit('update:groupBy', normalized)
792
+ emit('group-by-change', normalized)
793
+ if (isRemote.value) {
794
+ scheduleFetchRows()
795
+ }
796
+ emitQueryChange()
797
+ }
798
+
632
799
  function isHeaderHidden(header) {
633
800
  if (header.hidden) {
634
801
  return true
@@ -750,16 +917,45 @@ const sortedItems = computed(() => {
750
917
  )
751
918
  })
752
919
 
753
- const displayRows = computed(() => {
754
- if (isRemote.value || !props.showPagination) {
755
- return sortedItems.value
920
+ const activeGroupBy = computed(() => {
921
+ if (!props.groupable) {
922
+ return null
923
+ }
924
+ if (props.groupBy !== undefined) {
925
+ return props.groupBy || null
756
926
  }
927
+ return internalGroupBy.value
928
+ })
757
929
 
758
- const start = (page.value - 1) * pageSize.value
759
- return sortedItems.value.slice(start, start + pageSize.value)
930
+ const collapsedGroupIdSet = computed(() => new Set(collapsedGroupIds.value))
931
+
932
+ const displayBodyItems = computed(() => {
933
+ const paginate = !isRemote.value && props.showPagination
934
+ const items = buildDisplayBodyItems(sortedItems.value, {
935
+ groupBy: activeGroupBy.value,
936
+ page: page.value,
937
+ pageSize: pageSize.value,
938
+ paginate,
939
+ collapsedGroupIds: collapsedGroupIdSet.value,
940
+ })
941
+ let displayIndex = 0
942
+ return items.map((item) => {
943
+ if (item.kind !== 'row') {
944
+ return item
945
+ }
946
+ const indexed = { ...item, displayIndex }
947
+ displayIndex += 1
948
+ return indexed
949
+ })
760
950
  })
761
951
 
762
- const hasRows = computed(() => displayRows.value.length > 0)
952
+ const displayRows = computed(() =>
953
+ displayBodyItems.value
954
+ .filter((item) => item.kind === 'row')
955
+ .map((item) => item.row),
956
+ )
957
+
958
+ const hasRows = computed(() => displayBodyItems.value.length > 0)
763
959
 
764
960
  const hasActiveFilters = computed(() =>
765
961
  isFilterQueryActive(activeFilterQuery.value, props.headers, props.data),
@@ -813,6 +1009,7 @@ const queryParams = computed(() => {
813
1009
  pageSize: pageSize.value,
814
1010
  sortBy: sortBy.value,
815
1011
  sortDir: sortDir.value,
1012
+ groupBy: activeGroupBy.value,
816
1013
  filterQuery: activeFilterQuery.value,
817
1014
  }
818
1015
 
@@ -852,6 +1049,7 @@ function headerClasses(header) {
852
1049
  sortable: header.sortable,
853
1050
  filterable: isHeaderFilterable(header),
854
1051
  filtered: isHeaderFiltered(header),
1052
+ grouped: isHeaderGrouped(header),
855
1053
  'menu-open': filterPopoverOpen.value && activeFilterHeader.value?.key === header.key,
856
1054
  [colPriorityClass(priority)]: isValidColPriority(priority),
857
1055
  }
@@ -1402,7 +1600,7 @@ function closeRowContextMenu() {
1402
1600
  rowContextMenuAnchor.value = null
1403
1601
  }
1404
1602
 
1405
- function onRowContextMenu(row, displayIndex, event) {
1603
+ function onRowContextMenu(row, sourceIndex, event) {
1406
1604
  if (!hasRowContextMenu.value) {
1407
1605
  return
1408
1606
  }
@@ -1413,8 +1611,8 @@ function onRowContextMenu(row, displayIndex, event) {
1413
1611
  event.preventDefault()
1414
1612
  event.stopPropagation()
1415
1613
 
1416
- const key = resolveRowKey(row, displayIndex)
1417
- const index = filteredRowIndex(displayIndex)
1614
+ const key = resolveRowKey(row, sourceIndex)
1615
+ const index = filteredRowIndex(sourceIndex)
1418
1616
  let targetKeys
1419
1617
 
1420
1618
  if (props.selectable && selectedKeySet.value.has(key) && activeSelectedKeys.value.length > 0) {
@@ -1549,6 +1747,17 @@ function onFilterCancel() {
1549
1747
  activeFilterValue.value = []
1550
1748
  }
1551
1749
 
1750
+ const canGroupByActiveFilterColumn = computed(() => {
1751
+ if (!props.groupable || !activeFilterHeader.value) {
1752
+ return false
1753
+ }
1754
+ return isHeaderGroupable(activeFilterHeader.value, props.groupable)
1755
+ })
1756
+
1757
+ const isActiveFilterHeaderGrouped = computed(() =>
1758
+ Boolean(activeFilterHeader.value?.key && activeFilterHeader.value.key === activeGroupBy.value),
1759
+ )
1760
+
1552
1761
  const canHideActiveFilterColumn = computed(() => {
1553
1762
  if (!showColumnOptionsButton.value || !activeFilterHeader.value) {
1554
1763
  return false
@@ -1563,6 +1772,12 @@ const canHideActiveFilterColumn = computed(() => {
1563
1772
  return configurableHeaders.value.some((item) => item.key === header.key)
1564
1773
  })
1565
1774
 
1775
+ function onFilterGroupBy(columnKey) {
1776
+ setGroupBy(columnKey || null)
1777
+ filterPopoverOpen.value = false
1778
+ onFilterCancel()
1779
+ }
1780
+
1566
1781
  function onFilterHideColumn() {
1567
1782
  const header = activeFilterHeader.value
1568
1783
  if (!header?.key || !canHideActiveFilterColumn.value) {
@@ -1648,10 +1863,13 @@ function openColumnOptions() {
1648
1863
  void refreshSavedPresets()
1649
1864
  }
1650
1865
 
1651
- function onColumnOptionsApply({ order, visibleKeys, priorities }) {
1866
+ function onColumnOptionsApply({ order, visibleKeys, priorities, groupBy }) {
1652
1867
  setColumnOrder(order)
1653
1868
  setColumnVisibility(visibleKeys)
1654
1869
  setColumnPriorities(priorities)
1870
+ if (groupBy !== undefined && groupBy !== activeGroupBy.value) {
1871
+ setGroupBy(groupBy)
1872
+ }
1655
1873
  updateActiveLayoutLabel()
1656
1874
  }
1657
1875
 
@@ -1660,15 +1878,16 @@ function onColumnOptionsCancel() {
1660
1878
  }
1661
1879
 
1662
1880
  async function refreshSavedPresets() {
1663
- if (isRemote.value) {
1664
- if (!props.layoutPresets || typeof props.layoutPresets.list !== 'function') {
1881
+ if (isCallbackViewControl.value) {
1882
+ const handlers = viewSettingsHandlers.value
1883
+ if (!handlers || typeof handlers.list !== 'function') {
1665
1884
  savedPresets.value = []
1666
1885
  defaultPresetId.value = null
1667
1886
  return
1668
1887
  }
1669
1888
 
1670
1889
  try {
1671
- const result = await props.layoutPresets.list()
1890
+ const result = await handlers.list()
1672
1891
  savedPresets.value = Array.isArray(result?.presets) ? result.presets : []
1673
1892
  defaultPresetId.value = result?.defaultPresetId ?? null
1674
1893
  } catch {
@@ -1706,6 +1925,7 @@ function getCurrentLayoutState() {
1706
1925
  filters: cloneFilters(activeFilters.value),
1707
1926
  sortBy: sortBy.value,
1708
1927
  sortDir: sortDir.value,
1928
+ groupBy: activeGroupBy.value,
1709
1929
  pageSize: pageSize.value,
1710
1930
  }
1711
1931
  }
@@ -1745,6 +1965,12 @@ function applyPresetState(state) {
1745
1965
  sortDir.value = 'asc'
1746
1966
  }
1747
1967
 
1968
+ if (sanitized.groupBy) {
1969
+ setGroupBy(sanitized.groupBy)
1970
+ } else {
1971
+ setGroupBy(null)
1972
+ }
1973
+
1748
1974
  if (sanitized.pageSize) {
1749
1975
  pageSize.value = sanitized.pageSize
1750
1976
  }
@@ -1760,13 +1986,14 @@ function applyPresetState(state) {
1760
1986
  async function onSavePreset({ name, setAsDefault }) {
1761
1987
  const state = getCurrentLayoutState()
1762
1988
 
1763
- if (isRemote.value) {
1764
- if (!props.layoutPresets || typeof props.layoutPresets.save !== 'function') {
1765
- return { ok: false, error: 'Remote layout save is unavailable.' }
1989
+ if (isCallbackViewControl.value) {
1990
+ const handlers = viewSettingsHandlers.value
1991
+ if (!handlers || typeof handlers.save !== 'function') {
1992
+ return { ok: false, error: 'View save callback is unavailable.' }
1766
1993
  }
1767
1994
 
1768
1995
  try {
1769
- const result = await props.layoutPresets.save({ name, state, setAsDefault })
1996
+ const result = await handlers.save({ name, state, setAsDefault })
1770
1997
  if (result?.ok) {
1771
1998
  await refreshSavedPresets()
1772
1999
  if (result.preset?.id) {
@@ -1799,14 +2026,50 @@ async function onSavePreset({ name, setAsDefault }) {
1799
2026
  return result
1800
2027
  }
1801
2028
 
2029
+ async function onOverwritePreset({ presetId }) {
2030
+ const state = getCurrentLayoutState()
2031
+
2032
+ if (isCallbackViewControl.value) {
2033
+ const handlers = viewSettingsHandlers.value
2034
+ if (!handlers || typeof handlers.overwrite !== 'function') {
2035
+ return { ok: false, error: 'View overwrite callback is unavailable.' }
2036
+ }
2037
+
2038
+ try {
2039
+ const result = await handlers.overwrite({ presetId, state })
2040
+ if (result?.ok) {
2041
+ await refreshSavedPresets()
2042
+ activeLayoutPresetId.value = presetId
2043
+ updateActiveLayoutLabel()
2044
+ }
2045
+ return result ?? { ok: false, error: 'Unable to overwrite layout.' }
2046
+ } catch {
2047
+ return { ok: false, error: 'Unable to overwrite layout.' }
2048
+ }
2049
+ }
2050
+
2051
+ if (!props.tableId) {
2052
+ return { ok: false, error: 'Table identity is required.' }
2053
+ }
2054
+
2055
+ const result = overwritePreset(props.tableId, presetId, { state })
2056
+ if (result.ok) {
2057
+ refreshSavedPresets()
2058
+ activeLayoutPresetId.value = presetId
2059
+ updateActiveLayoutLabel()
2060
+ }
2061
+ return result
2062
+ }
2063
+
1802
2064
  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.' }
2065
+ if (isCallbackViewControl.value) {
2066
+ const handlers = viewSettingsHandlers.value
2067
+ if (!handlers || typeof handlers.load !== 'function') {
2068
+ return { ok: false, error: 'View load callback is unavailable.' }
1806
2069
  }
1807
2070
 
1808
2071
  try {
1809
- const result = await props.layoutPresets.load(presetId)
2072
+ const result = await handlers.load(presetId)
1810
2073
  if (!result?.ok || !result.preset?.state) {
1811
2074
  return result ?? { ok: false, error: 'Preset not found.' }
1812
2075
  }
@@ -1836,13 +2099,14 @@ async function onLoadPreset(presetId) {
1836
2099
  }
1837
2100
 
1838
2101
  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.' }
2102
+ if (isCallbackViewControl.value) {
2103
+ const handlers = viewSettingsHandlers.value
2104
+ if (!handlers || typeof handlers.delete !== 'function') {
2105
+ return { ok: false, error: 'View delete callback is unavailable.' }
1842
2106
  }
1843
2107
 
1844
2108
  try {
1845
- const result = await props.layoutPresets.delete(presetId)
2109
+ const result = await handlers.delete(presetId)
1846
2110
  if (result?.ok) {
1847
2111
  await refreshSavedPresets()
1848
2112
  if (activeLayoutPresetId.value === presetId) {
@@ -1872,13 +2136,14 @@ async function onDeletePreset(presetId) {
1872
2136
  }
1873
2137
 
1874
2138
  async function onSetDefaultPreset(presetId) {
1875
- if (isRemote.value) {
1876
- if (!props.layoutPresets || typeof props.layoutPresets.setDefault !== 'function') {
2139
+ if (isCallbackViewControl.value) {
2140
+ const handlers = viewSettingsHandlers.value
2141
+ if (!handlers || typeof handlers.setDefault !== 'function') {
1877
2142
  return { ok: false, error: 'Unable to update default layout.' }
1878
2143
  }
1879
2144
 
1880
2145
  try {
1881
- const result = await props.layoutPresets.setDefault(presetId)
2146
+ const result = await handlers.setDefault(presetId)
1882
2147
  if (result?.ok) {
1883
2148
  await refreshSavedPresets()
1884
2149
  }
@@ -1921,6 +2186,7 @@ function getDeveloperDefaultLayoutState() {
1921
2186
  filters: {},
1922
2187
  sortBy: null,
1923
2188
  sortDir: 'asc',
2189
+ groupBy: null,
1924
2190
  pageSize: DEFAULT_PAGE_SIZE,
1925
2191
  }
1926
2192
  }
@@ -1980,6 +2246,7 @@ function loadDeveloperDefaults() {
1980
2246
  setFilters({})
1981
2247
  sortBy.value = null
1982
2248
  sortDir.value = 'asc'
2249
+ setGroupBy(null)
1983
2250
  setColumnOrder(getDeveloperDefaultColumnOrder())
1984
2251
  setColumnPriorities({})
1985
2252
 
@@ -2074,6 +2341,17 @@ watch(
2074
2341
  },
2075
2342
  )
2076
2343
 
2344
+ watch(
2345
+ activeGroupBy,
2346
+ () => {
2347
+ page.value = 1
2348
+ if (isRemote.value) {
2349
+ scheduleFetchRows()
2350
+ }
2351
+ emitQueryChange()
2352
+ },
2353
+ )
2354
+
2077
2355
  watch(
2078
2356
  () => [sortBy.value, sortDir.value],
2079
2357
  () => {
@@ -2184,6 +2462,24 @@ watch(
2184
2462
  { deep: true },
2185
2463
  )
2186
2464
 
2465
+ watch(
2466
+ () => props.groupBy,
2467
+ (nextGroupBy) => {
2468
+ if (nextGroupBy !== undefined) {
2469
+ collapsedGroupIds.value = []
2470
+ }
2471
+ },
2472
+ )
2473
+
2474
+ watch(
2475
+ () => props.groupable,
2476
+ (enabled) => {
2477
+ if (!enabled) {
2478
+ setGroupBy(null)
2479
+ }
2480
+ },
2481
+ )
2482
+
2187
2483
  watch(
2188
2484
  () => props.columnPriorities,
2189
2485
  (nextPriorities) => {
@@ -2204,7 +2500,7 @@ watch(
2204
2500
  )
2205
2501
 
2206
2502
  watch(
2207
- () => [props.tableId, props.layoutPresets],
2503
+ () => [props.viewControlMode, props.tableId, viewSettingsHandlers.value],
2208
2504
  () => {
2209
2505
  void refreshSavedPresets()
2210
2506
  updateActiveLayoutLabel()
@@ -2221,6 +2517,7 @@ watch(
2221
2517
  activeFilters,
2222
2518
  sortBy,
2223
2519
  sortDir,
2520
+ activeGroupBy,
2224
2521
  pageSize,
2225
2522
  () => props.headers,
2226
2523
  savedPresets,
@@ -2247,17 +2544,18 @@ watch(
2247
2544
 
2248
2545
  onMounted(async () => {
2249
2546
  if (props.loadSavedLayout) {
2250
- if (isRemote.value && props.layoutPresets && typeof props.layoutPresets.getDefault === 'function') {
2547
+ const handlers = viewSettingsHandlers.value
2548
+ if (isCallbackViewControl.value && handlers && typeof handlers.getDefault === 'function') {
2251
2549
  try {
2252
- const result = await props.layoutPresets.getDefault()
2550
+ const result = await handlers.getDefault()
2253
2551
  if (result?.preset?.state) {
2254
2552
  applyPresetState(result.preset.state)
2255
2553
  activeLayoutPresetId.value = result.preset.id
2256
2554
  }
2257
2555
  } catch {
2258
- // Ignore default layout load failures in remote mode.
2556
+ // Ignore default layout load failures in callback mode.
2259
2557
  }
2260
- } else if (!isRemote.value && props.tableId) {
2558
+ } else if (!isCallbackViewControl.value && props.tableId) {
2261
2559
  const preset = getDefaultPreset(props.tableId)
2262
2560
  if (preset?.state) {
2263
2561
  applyPresetState(preset.state)
@@ -2330,9 +2628,57 @@ table.loading tbody {
2330
2628
  }
2331
2629
 
2332
2630
  .sort-indicator {
2631
+ display: inline-flex;
2632
+ align-items: center;
2633
+ justify-content: center;
2333
2634
  width: 1.5em;
2334
- display: inline-block;
2335
- text-align: center;
2635
+ vertical-align: middle;
2636
+ }
2637
+
2638
+ .group-indicator {
2639
+ display: inline-flex;
2640
+ vertical-align: middle;
2641
+ margin-right: 0.25rem;
2642
+ opacity: 0.85;
2643
+ }
2644
+
2645
+ table thead th.grouped {
2646
+ color: var(--table-header-active-fg);
2647
+ }
2648
+
2649
+ tbody tr.table-group-row > td.table-group-cell {
2650
+ background-color: var(--table-header-active-bg);
2651
+ color: var(--table-header-fg);
2652
+ font-weight: 600;
2653
+ padding-top: 0.65rem;
2654
+ padding-bottom: 0.65rem;
2655
+ }
2656
+
2657
+ .table-group-toggle {
2658
+ display: inline-flex;
2659
+ align-items: center;
2660
+ justify-content: center;
2661
+ min-width: 1.5rem;
2662
+ margin-right: 0.35rem;
2663
+ padding: 0.1rem 0.35rem;
2664
+ vertical-align: middle;
2665
+ }
2666
+
2667
+ .table-group-chevron {
2668
+ flex-shrink: 0;
2669
+ transition: transform 0.15s ease;
2670
+ }
2671
+
2672
+ .table-group-row:not(.table-group-row-collapsed) .table-group-chevron {
2673
+ transform: rotate(180deg);
2674
+ }
2675
+
2676
+ .table-group-label {
2677
+ margin-right: 0.35rem;
2678
+ }
2679
+
2680
+ .table-group-count {
2681
+ font-weight: normal;
2336
2682
  }
2337
2683
 
2338
2684
  td:first-child,