picocrank 1.32.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 (37) hide show
  1. package/docs/filter-query-v1.md +5 -1
  2. package/docs/table.md +99 -0
  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 +490 -1
  18. package/vue/components/Table.vue +1038 -111
  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/tableFilters.js +6 -1
  25. package/vue/composables/tableGroupBy.js +173 -0
  26. package/vue/composables/tableGroupBy.spec.js +69 -0
  27. package/vue/composables/tableViewSettings.js +34 -0
  28. package/vue/composables/tableViewSettings.spec.js +21 -0
  29. package/vue/composables/useHeaderTopBarFit.js +91 -0
  30. package/vue/composables/useResponsiveNav.js +5 -3
  31. package/vue/data/navSectionHubs.js +16 -1
  32. package/vue/examples/AccordionExample.vue +33 -0
  33. package/vue/examples/FieldValueExample.vue +143 -0
  34. package/vue/examples/FormExample.vue +11 -6
  35. package/vue/examples/ReorderListExample.vue +236 -0
  36. package/vue/examples/TableExample.vue +5 -1
  37. 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,59 +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
- 'row-clickable': rowClickable,
82
- 'row-selected': selectable && isRowSelected(row, rowIndex),
83
- 'row-context-menu-active': isRowContextMenuTarget(row, rowIndex),
84
- }"
85
- @click="onRowClick(row, rowIndex, $event)"
86
- @contextmenu="onRowContextMenu(row, rowIndex, $event)"
87
- >
88
- <td
89
- v-if="selectable"
90
- class="table-select-col"
91
- data-row-click-ignore
92
- @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 }"
93
109
  >
94
- <label class="table-select-label">
95
- <input
96
- type="checkbox"
97
- :checked="isRowSelected(row, rowIndex)"
98
- :aria-label="`Select row ${resolveRowKey(row, rowIndex)}`"
99
- @change="toggleRowSelection(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)"
100
126
  >
101
- </label>
102
- </td>
103
- <td
104
- v-for="(header, cellIndex) in visibleHeaders"
105
- :key="header.key || cellIndex"
106
- :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
107
141
  :class="[
108
- cellClasses(header),
109
- header.class,
110
- 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),
111
148
  ]"
149
+ :style="resolveRowStyle(item.row, item.displayIndex)"
150
+ @click="onRowClick(item.row, item.displayIndex, $event)"
151
+ @contextmenu="onRowContextMenu(item.row, item.displayIndex, $event)"
112
152
  >
113
- <slot
114
- v-if="slots[`cell-${header.key}`]"
115
- :name="`cell-${header.key}`"
116
- :row="row"
117
- :value="row[header.key]"
118
- />
119
- <slot
120
- v-else-if="slots.cell"
121
- name="cell"
122
- :row="row"
123
- :value="row[header.key]"
124
- />
125
- <span v-else>
126
- {{ row[header.key] }}
127
- </span>
128
- </td>
129
- </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>
130
210
  </tbody>
131
211
  </table>
132
212
 
@@ -156,10 +236,13 @@
156
236
  :rows="props.data"
157
237
  :select-options="activeSelectOptions"
158
238
  :can-hide="canHideActiveFilterColumn"
239
+ :can-group-by="canGroupByActiveFilterColumn"
240
+ :is-grouped-column="isActiveFilterHeaderGrouped"
159
241
  @apply="onFilterApply"
160
242
  @clear="onFilterClear"
161
243
  @cancel="onFilterCancel"
162
244
  @hide="onFilterHideColumn"
245
+ @group-by="onFilterGroupBy"
163
246
  />
164
247
 
165
248
  <TableColumnOptionsPopover
@@ -177,10 +260,13 @@
177
260
  :presets="savedPresets"
178
261
  :default-preset-id="defaultPresetId"
179
262
  :save-layout-preset="onSavePreset"
263
+ :overwrite-layout-preset="onOverwritePreset"
180
264
  :load-layout-preset="onLoadPreset"
181
265
  :delete-layout-preset="onDeletePreset"
182
266
  :set-default-layout-preset="onSetDefaultPreset"
183
267
  :load-developer-defaults="loadDeveloperDefaults"
268
+ :group-by="activeGroupBy"
269
+ :groupable="props.groupable"
184
270
  :anchor-el="columnOptionsButtonRef"
185
271
  @apply="onColumnOptionsApply"
186
272
  @cancel="onColumnOptionsCancel"
@@ -197,9 +283,15 @@
197
283
  </template>
198
284
 
199
285
  <script setup>
200
- import { ref, computed, watch, onMounted, onBeforeUnmount, useSlots, useAttrs } from 'vue'
286
+ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount, useSlots, useAttrs } from 'vue'
201
287
  import { HugeiconsIcon } from '@hugeicons/vue'
202
- 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'
203
295
  import Pagination from './Pagination.vue'
204
296
  import TableColumnFilterPopover from './TableColumnFilterPopover.vue'
205
297
  import TableColumnOptionsPopover from './TableColumnOptionsPopover.vue'
@@ -224,23 +316,29 @@ import {
224
316
  getPresetById,
225
317
  listPresets,
226
318
  savePreset,
319
+ overwritePreset,
227
320
  deletePreset,
228
321
  setDefaultPreset,
229
322
  sanitizePresetState,
230
323
  layoutStatesEqual,
231
324
  } from '../composables/tableColumnPresets.js'
325
+ import {
326
+ getViewSettingsHandlers,
327
+ isViewSettingsControlEnabled,
328
+ VIEW_CONTROL_CALLBACK,
329
+ } from '../composables/tableViewSettings.js'
232
330
  import {
233
331
  defaultColumnPriorityForIndex,
234
332
  isValidColPriority,
235
333
  } from '../composables/tableColumnPriorities.js'
334
+ import {
335
+ buildDisplayBodyItems,
336
+ formatGroupValue,
337
+ isHeaderGroupable,
338
+ } from '../composables/tableGroupBy.js'
236
339
 
237
340
  const DEFAULT_PAGE_SIZE = 10
238
341
 
239
- const sortBy = ref(null)
240
- const sortDir = ref('asc')
241
- const page = ref(1)
242
- const pageSize = ref(DEFAULT_PAGE_SIZE)
243
-
244
342
  const props = defineProps({
245
343
  headers: {
246
344
  type: Array,
@@ -250,6 +348,29 @@ const props = defineProps({
250
348
  type: Array,
251
349
  default: () => [],
252
350
  },
351
+ sortBy: {
352
+ type: [String, Number],
353
+ default: undefined,
354
+ },
355
+ sortDir: {
356
+ type: String,
357
+ default: undefined,
358
+ validator: (value) => value === undefined || value === 'asc' || value === 'desc',
359
+ },
360
+ page: {
361
+ type: Number,
362
+ default: undefined,
363
+ validator: (value) => value === undefined || (Number.isFinite(value) && value >= 1),
364
+ },
365
+ pageSize: {
366
+ type: Number,
367
+ default: undefined,
368
+ validator: (value) => value === undefined || (Number.isFinite(value) && value >= 1),
369
+ },
370
+ resetPageOnSort: {
371
+ type: Boolean,
372
+ default: true,
373
+ },
253
374
  showPagination: {
254
375
  type: Boolean,
255
376
  default: true,
@@ -307,6 +428,21 @@ const props = defineProps({
307
428
  type: String,
308
429
  default: '',
309
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
+ },
310
446
  loadSavedLayout: {
311
447
  type: Boolean,
312
448
  default: true,
@@ -319,6 +455,7 @@ const props = defineProps({
319
455
  type: [String, Function],
320
456
  default: 'id',
321
457
  },
458
+ /** @deprecated Use `viewSettings` with `viewControlMode="callbackControlled"`. */
322
459
  layoutPresets: {
323
460
  type: Object,
324
461
  default: null,
@@ -331,6 +468,35 @@ const props = defineProps({
331
468
  type: Array,
332
469
  default: undefined,
333
470
  },
471
+ selectionClickMode: {
472
+ type: String,
473
+ default: 'row',
474
+ validator: (value) => ['row', 'checkbox', 'modifier', 'extended'].includes(value),
475
+ },
476
+ activeCell: {
477
+ type: Object,
478
+ default: undefined,
479
+ },
480
+ rowClass: {
481
+ type: Function,
482
+ default: null,
483
+ },
484
+ rowStyle: {
485
+ type: Function,
486
+ default: null,
487
+ },
488
+ cellClass: {
489
+ type: Function,
490
+ default: null,
491
+ },
492
+ cellStyle: {
493
+ type: Function,
494
+ default: null,
495
+ },
496
+ cellAttrs: {
497
+ type: Function,
498
+ default: null,
499
+ },
334
500
  /**
335
501
  * Row right-click menu. Array of items, or a function that returns items
336
502
  * for the click context. Each item may include `action(ctx)` where
@@ -341,6 +507,14 @@ const props = defineProps({
341
507
  type: [Array, Function],
342
508
  default: null,
343
509
  },
510
+ groupable: {
511
+ type: Boolean,
512
+ default: true,
513
+ },
514
+ groupBy: {
515
+ type: String,
516
+ default: undefined,
517
+ },
344
518
  })
345
519
 
346
520
  const emit = defineEmits([
@@ -349,8 +523,14 @@ const emit = defineEmits([
349
523
  'query-change',
350
524
  'fetch-error',
351
525
  'row-click',
526
+ 'cell-click',
527
+ 'update:sortBy',
528
+ 'update:sortDir',
529
+ 'update:page',
530
+ 'update:pageSize',
352
531
  'update:selectedKeys',
353
532
  'selection-change',
533
+ 'update:activeCell',
354
534
  'row-context-menu',
355
535
  'row-context-menu-action',
356
536
  'update:columnVisibility',
@@ -361,14 +541,118 @@ const emit = defineEmits([
361
541
  'column-priorities-change',
362
542
  'update:layoutLabel',
363
543
  'layout-label-change',
544
+ 'update:groupBy',
545
+ 'group-by-change',
364
546
  ])
365
547
 
366
548
  const attrs = useAttrs()
367
549
  const slots = useSlots()
368
550
 
369
- const rowClickable = computed(() => Boolean(attrs.onRowClick) || props.selectable)
551
+ function normalizePage(value) {
552
+ const numeric = Number(value)
553
+ return Number.isFinite(numeric) && numeric >= 1 ? Math.floor(numeric) : 1
554
+ }
555
+
556
+ function normalizePageSize(value) {
557
+ const numeric = Number(value)
558
+ return Number.isFinite(numeric) && numeric >= 1 ? Math.floor(numeric) : DEFAULT_PAGE_SIZE
559
+ }
560
+
561
+ function normalizeSortDir(value) {
562
+ return value === 'desc' ? 'desc' : 'asc'
563
+ }
564
+
565
+ function normalizeActiveCell(value) {
566
+ if (
567
+ !value
568
+ || typeof value !== 'object'
569
+ || value.rowKey === null
570
+ || value.rowKey === undefined
571
+ || value.columnKey === null
572
+ || value.columnKey === undefined
573
+ ) {
574
+ return null
575
+ }
576
+ return {
577
+ rowKey: value.rowKey,
578
+ columnKey: value.columnKey,
579
+ }
580
+ }
581
+
582
+ const internalSortBy = ref(props.sortBy !== undefined ? props.sortBy : null)
583
+ const internalSortDir = ref(normalizeSortDir(props.sortDir))
584
+ const internalPage = ref(normalizePage(props.page))
585
+ const internalPageSize = ref(normalizePageSize(props.pageSize))
586
+ const internalActiveCell = ref(normalizeActiveCell(props.activeCell))
587
+
588
+ const sortBy = computed({
589
+ get: () => props.sortBy !== undefined ? props.sortBy : internalSortBy.value,
590
+ set: (value) => {
591
+ const nextValue = value ?? null
592
+ if (nextValue === sortBy.value) {
593
+ return
594
+ }
595
+ if (props.sortBy === undefined) {
596
+ internalSortBy.value = nextValue
597
+ }
598
+ emit('update:sortBy', nextValue)
599
+ },
600
+ })
601
+
602
+ const sortDir = computed({
603
+ get: () => props.sortDir !== undefined ? normalizeSortDir(props.sortDir) : internalSortDir.value,
604
+ set: (value) => {
605
+ const nextValue = normalizeSortDir(value)
606
+ if (nextValue === sortDir.value) {
607
+ return
608
+ }
609
+ if (props.sortDir === undefined) {
610
+ internalSortDir.value = nextValue
611
+ }
612
+ emit('update:sortDir', nextValue)
613
+ },
614
+ })
615
+
616
+ const page = computed({
617
+ get: () => props.page !== undefined ? normalizePage(props.page) : internalPage.value,
618
+ set: (value) => {
619
+ const nextValue = normalizePage(value)
620
+ if (nextValue === page.value) {
621
+ return
622
+ }
623
+ if (props.page === undefined) {
624
+ internalPage.value = nextValue
625
+ }
626
+ emit('update:page', nextValue)
627
+ },
628
+ })
629
+
630
+ const pageSize = computed({
631
+ get: () => props.pageSize !== undefined ? normalizePageSize(props.pageSize) : internalPageSize.value,
632
+ set: (value) => {
633
+ const nextValue = normalizePageSize(value)
634
+ if (nextValue === pageSize.value) {
635
+ return
636
+ }
637
+ if (props.pageSize === undefined) {
638
+ internalPageSize.value = nextValue
639
+ }
640
+ emit('update:pageSize', nextValue)
641
+ },
642
+ })
643
+
644
+ const activeCell = computed(() => (
645
+ props.activeCell !== undefined ? normalizeActiveCell(props.activeCell) : internalActiveCell.value
646
+ ))
647
+
648
+ const rowClickable = computed(() =>
649
+ Boolean(attrs.onRowClick)
650
+ || (props.selectable && props.selectionClickMode !== 'checkbox'),
651
+ )
370
652
  const internalFilters = ref({})
371
653
  const internalSelectedKeys = ref([])
654
+ const selectionAnchorKey = ref(null)
655
+ const pendingSelectionCheckboxClick = ref(null)
372
656
  const remoteRows = ref([])
373
657
  const remoteTotal = ref(0)
374
658
  const internalLoading = ref(false)
@@ -388,15 +672,24 @@ const internalColumnPriorities = ref({})
388
672
  const savedPresets = ref([])
389
673
  const defaultPresetId = ref(null)
390
674
  const activeLayoutPresetId = ref(null)
675
+ const internalGroupBy = ref(null)
676
+ const collapsedGroupIds = ref([])
391
677
 
392
678
  const isRemote = computed(() => typeof props.fetchRows === 'function')
393
679
 
394
- const layoutPresetsAvailable = computed(() => {
395
- if (isRemote.value) {
396
- return props.layoutPresets != null
397
- }
398
- return Boolean(props.tableId)
399
- })
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
+ ))
400
693
 
401
694
  const activeFilters = computed(() => {
402
695
  if (!props.filterable) {
@@ -456,6 +749,53 @@ function bodyColumnColspan(index) {
456
749
  return isLastColumnWithOptions(index) ? 2 : null
457
750
  }
458
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
+
459
799
  function isHeaderHidden(header) {
460
800
  if (header.hidden) {
461
801
  return true
@@ -561,23 +901,61 @@ const filteredItems = computed(() => {
561
901
  return applyFilterQuery(props.data, props.headers, activeFilterQuery.value)
562
902
  })
563
903
 
904
+ const activeSortHeader = computed(() =>
905
+ props.headers.find((header) => header?.key === sortBy.value),
906
+ )
907
+
564
908
  const sortedItems = computed(() => {
565
909
  if (isRemote.value) {
566
910
  return filteredItems.value
567
911
  }
568
- return sortRows(filteredItems.value, sortBy.value, sortDir.value)
912
+ return sortRows(
913
+ filteredItems.value,
914
+ sortBy.value,
915
+ sortDir.value,
916
+ activeSortHeader.value?.comparator,
917
+ )
569
918
  })
570
919
 
571
- const displayRows = computed(() => {
572
- if (isRemote.value || !props.showPagination) {
573
- return sortedItems.value
920
+ const activeGroupBy = computed(() => {
921
+ if (!props.groupable) {
922
+ return null
574
923
  }
924
+ if (props.groupBy !== undefined) {
925
+ return props.groupBy || null
926
+ }
927
+ return internalGroupBy.value
928
+ })
575
929
 
576
- const start = (page.value - 1) * pageSize.value
577
- 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
+ })
578
950
  })
579
951
 
580
- 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)
581
959
 
582
960
  const hasActiveFilters = computed(() =>
583
961
  isFilterQueryActive(activeFilterQuery.value, props.headers, props.data),
@@ -631,6 +1009,7 @@ const queryParams = computed(() => {
631
1009
  pageSize: pageSize.value,
632
1010
  sortBy: sortBy.value,
633
1011
  sortDir: sortDir.value,
1012
+ groupBy: activeGroupBy.value,
634
1013
  filterQuery: activeFilterQuery.value,
635
1014
  }
636
1015
 
@@ -670,6 +1049,7 @@ function headerClasses(header) {
670
1049
  sortable: header.sortable,
671
1050
  filterable: isHeaderFilterable(header),
672
1051
  filtered: isHeaderFiltered(header),
1052
+ grouped: isHeaderGrouped(header),
673
1053
  'menu-open': filterPopoverOpen.value && activeFilterHeader.value?.key === header.key,
674
1054
  [colPriorityClass(priority)]: isValidColPriority(priority),
675
1055
  }
@@ -683,6 +1063,60 @@ function cellClasses(header) {
683
1063
  }
684
1064
  }
685
1065
 
1066
+ function sourceRowIndex(row, displayIndex) {
1067
+ const sourceRows = isRemote.value ? remoteRows.value : props.data
1068
+ const index = sourceRows.indexOf(row)
1069
+ return index >= 0 ? index : filteredRowIndex(displayIndex)
1070
+ }
1071
+
1072
+ function rowContext(row, rowIndex) {
1073
+ return {
1074
+ row,
1075
+ rowIndex,
1076
+ sourceIndex: sourceRowIndex(row, rowIndex),
1077
+ }
1078
+ }
1079
+
1080
+ function cellContext(row, header, rowIndex) {
1081
+ return {
1082
+ ...rowContext(row, rowIndex),
1083
+ value: row?.[header.key],
1084
+ header,
1085
+ }
1086
+ }
1087
+
1088
+ function resolveRowClass(row, rowIndex) {
1089
+ return props.rowClass?.(rowContext(row, rowIndex))
1090
+ }
1091
+
1092
+ function resolveRowStyle(row, rowIndex) {
1093
+ return props.rowStyle?.(rowContext(row, rowIndex))
1094
+ }
1095
+
1096
+ function resolveCellClass(row, header, rowIndex) {
1097
+ return props.cellClass?.(cellContext(row, header, rowIndex))
1098
+ }
1099
+
1100
+ function resolveCellStyle(row, header, rowIndex) {
1101
+ return props.cellStyle?.(cellContext(row, header, rowIndex))
1102
+ }
1103
+
1104
+ function resolveCellAttrs(row, header, rowIndex) {
1105
+ const value = props.cellAttrs?.(cellContext(row, header, rowIndex))
1106
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1107
+ return {}
1108
+ }
1109
+
1110
+ const {
1111
+ class: _class,
1112
+ style: _style,
1113
+ key: _key,
1114
+ ref: _ref,
1115
+ ...safeAttrs
1116
+ } = value
1117
+ return safeAttrs
1118
+ }
1119
+
686
1120
  function resolveRowKey(row, index) {
687
1121
  if (typeof props.rowKey === 'function') {
688
1122
  const key = props.rowKey(row, index)
@@ -702,6 +1136,192 @@ function resolveRowKey(row, index) {
702
1136
  return index
703
1137
  }
704
1138
 
1139
+ function activeCellsEqual(left, right) {
1140
+ return left?.rowKey === right?.rowKey && left?.columnKey === right?.columnKey
1141
+ }
1142
+
1143
+ function setActiveCell(nextCell) {
1144
+ const normalized = normalizeActiveCell(nextCell)
1145
+ if (activeCellsEqual(normalized, activeCell.value)) {
1146
+ return
1147
+ }
1148
+ if (props.activeCell === undefined) {
1149
+ internalActiveCell.value = normalized
1150
+ }
1151
+ emit('update:activeCell', normalized)
1152
+ }
1153
+
1154
+ function clearActiveCell() {
1155
+ setActiveCell(null)
1156
+ }
1157
+
1158
+ function isActiveCell(row, header, rowIndex) {
1159
+ const current = activeCell.value
1160
+ return Boolean(
1161
+ current
1162
+ && current.rowKey === resolveRowKey(row, rowIndex)
1163
+ && current.columnKey === header.key,
1164
+ )
1165
+ }
1166
+
1167
+ const activeCellElements = new Map()
1168
+
1169
+ function registerCellElement(element, row, header, rowIndex) {
1170
+ const rowKey = resolveRowKey(row, rowIndex)
1171
+ let rowCells = activeCellElements.get(rowKey)
1172
+
1173
+ if (!element) {
1174
+ rowCells?.delete(header.key)
1175
+ if (rowCells?.size === 0) {
1176
+ activeCellElements.delete(rowKey)
1177
+ }
1178
+ return
1179
+ }
1180
+
1181
+ if (!rowCells) {
1182
+ rowCells = new Map()
1183
+ activeCellElements.set(rowKey, rowCells)
1184
+ }
1185
+ rowCells.set(header.key, element)
1186
+ }
1187
+
1188
+ function focusCell(cell, options) {
1189
+ if (!cell) {
1190
+ return false
1191
+ }
1192
+ const element = activeCellElements.get(cell.rowKey)?.get(cell.columnKey)
1193
+ if (!element) {
1194
+ return false
1195
+ }
1196
+ element.focus(options)
1197
+ return true
1198
+ }
1199
+
1200
+ function focusActiveCell(options) {
1201
+ return focusCell(activeCell.value, options)
1202
+ }
1203
+
1204
+ function cellContextAt(rowIndex, columnIndex) {
1205
+ const row = displayRows.value[rowIndex]
1206
+ const header = visibleHeaders.value[columnIndex]
1207
+ if (!row || !header) {
1208
+ return null
1209
+ }
1210
+
1211
+ return {
1212
+ ...cellContext(row, header, rowIndex),
1213
+ rowKey: resolveRowKey(row, rowIndex),
1214
+ columnKey: header.key,
1215
+ columnIndex,
1216
+ }
1217
+ }
1218
+
1219
+ function getActiveCellContext() {
1220
+ const current = activeCell.value
1221
+ if (!current) {
1222
+ return null
1223
+ }
1224
+
1225
+ const rowIndex = displayRows.value.findIndex(
1226
+ (row, index) => resolveRowKey(row, index) === current.rowKey,
1227
+ )
1228
+ const columnIndex = visibleHeaders.value.findIndex(
1229
+ (header) => header.key === current.columnKey,
1230
+ )
1231
+ if (rowIndex < 0 || columnIndex < 0) {
1232
+ return null
1233
+ }
1234
+
1235
+ return cellContextAt(rowIndex, columnIndex)
1236
+ }
1237
+
1238
+ function clampGridIndex(value, maximum) {
1239
+ const numeric = Number(value)
1240
+ const normalized = Number.isFinite(numeric) ? Math.trunc(numeric) : 0
1241
+ return Math.min(Math.max(normalized, 0), maximum)
1242
+ }
1243
+
1244
+ function scheduleCellFocus(cell, options) {
1245
+ if (!options?.focus) {
1246
+ return
1247
+ }
1248
+ void nextTick(() => focusCell(cell, options.focusOptions))
1249
+ }
1250
+
1251
+ function activateCellAt(rowIndex, columnIndex, options = {}) {
1252
+ if (displayRows.value.length === 0 || visibleHeaders.value.length === 0) {
1253
+ return null
1254
+ }
1255
+
1256
+ const nextRowIndex = clampGridIndex(rowIndex, displayRows.value.length - 1)
1257
+ const nextColumnIndex = clampGridIndex(columnIndex, visibleHeaders.value.length - 1)
1258
+ const context = cellContextAt(nextRowIndex, nextColumnIndex)
1259
+ const nextActiveCell = {
1260
+ rowKey: context.rowKey,
1261
+ columnKey: context.columnKey,
1262
+ }
1263
+
1264
+ setActiveCell(nextActiveCell)
1265
+ scheduleCellFocus(nextActiveCell, options)
1266
+ return context
1267
+ }
1268
+
1269
+ function moveActiveCell(rowDelta, columnDelta, options = {}) {
1270
+ if (displayRows.value.length === 0 || visibleHeaders.value.length === 0) {
1271
+ return null
1272
+ }
1273
+
1274
+ const current = getActiveCellContext()
1275
+ if (!current) {
1276
+ return activateCellAt(0, 0, options)
1277
+ }
1278
+
1279
+ const normalizedRowDelta = Number.isFinite(Number(rowDelta)) ? Math.trunc(Number(rowDelta)) : 0
1280
+ const normalizedColumnDelta = Number.isFinite(Number(columnDelta))
1281
+ ? Math.trunc(Number(columnDelta))
1282
+ : 0
1283
+ const rowIndex = clampGridIndex(
1284
+ current.rowIndex + normalizedRowDelta,
1285
+ displayRows.value.length - 1,
1286
+ )
1287
+ let columnIndex
1288
+
1289
+ if (options.wrapColumns) {
1290
+ const linearIndex = (rowIndex * visibleHeaders.value.length)
1291
+ + current.columnIndex
1292
+ + normalizedColumnDelta
1293
+ const clampedLinearIndex = clampGridIndex(
1294
+ linearIndex,
1295
+ (displayRows.value.length * visibleHeaders.value.length) - 1,
1296
+ )
1297
+ return activateCellAt(
1298
+ Math.floor(clampedLinearIndex / visibleHeaders.value.length),
1299
+ clampedLinearIndex % visibleHeaders.value.length,
1300
+ options,
1301
+ )
1302
+ }
1303
+
1304
+ columnIndex = clampGridIndex(
1305
+ current.columnIndex + normalizedColumnDelta,
1306
+ visibleHeaders.value.length - 1,
1307
+ )
1308
+ return activateCellAt(rowIndex, columnIndex, options)
1309
+ }
1310
+
1311
+ function onCellClick(row, header, rowIndex, event) {
1312
+ const context = cellContext(row, header, rowIndex)
1313
+ const nextActiveCell = {
1314
+ rowKey: resolveRowKey(row, rowIndex),
1315
+ columnKey: header.key,
1316
+ }
1317
+ setActiveCell(nextActiveCell)
1318
+ emit('cell-click', {
1319
+ ...context,
1320
+ ...nextActiveCell,
1321
+ event,
1322
+ })
1323
+ }
1324
+
705
1325
  const activeSelectedKeys = computed(() => (
706
1326
  props.selectedKeys !== undefined ? props.selectedKeys : internalSelectedKeys.value
707
1327
  ))
@@ -724,6 +1344,7 @@ function setSelectedKeys(nextKeys) {
724
1344
  function toggleRowSelection(row, index) {
725
1345
  const key = resolveRowKey(row, index)
726
1346
  const current = activeSelectedKeys.value
1347
+ selectionAnchorKey.value = key
727
1348
  if (selectedKeySet.value.has(key)) {
728
1349
  setSelectedKeys(current.filter((item) => item !== key))
729
1350
  return
@@ -731,10 +1352,60 @@ function toggleRowSelection(row, index) {
731
1352
  setSelectedKeys([...current, key])
732
1353
  }
733
1354
 
1355
+ function replaceRowSelection(row, index) {
1356
+ const key = resolveRowKey(row, index)
1357
+ selectionAnchorKey.value = key
1358
+ setSelectedKeys([key])
1359
+ }
1360
+
734
1361
  const displayRowKeys = computed(() =>
735
1362
  displayRows.value.map((row, index) => resolveRowKey(row, index)),
736
1363
  )
737
1364
 
1365
+ function selectRowRange(row, index, { updateAnchor = true } = {}) {
1366
+ const key = resolveRowKey(row, index)
1367
+ const anchorIndex = displayRowKeys.value.findIndex((item) => item === selectionAnchorKey.value)
1368
+ const targetIndex = displayRowKeys.value.findIndex((item) => item === key)
1369
+
1370
+ if (anchorIndex < 0 || targetIndex < 0) {
1371
+ setSelectedKeys([...activeSelectedKeys.value, key])
1372
+ selectionAnchorKey.value = key
1373
+ return
1374
+ }
1375
+
1376
+ const from = Math.min(anchorIndex, targetIndex)
1377
+ const to = Math.max(anchorIndex, targetIndex)
1378
+ setSelectedKeys([
1379
+ ...activeSelectedKeys.value,
1380
+ ...displayRowKeys.value.slice(from, to + 1),
1381
+ ])
1382
+ if (updateAnchor) {
1383
+ selectionAnchorKey.value = key
1384
+ }
1385
+ }
1386
+
1387
+ function rememberSelectionCheckboxClick(row, index, event) {
1388
+ pendingSelectionCheckboxClick.value = {
1389
+ key: resolveRowKey(row, index),
1390
+ shiftKey: event.shiftKey,
1391
+ }
1392
+ }
1393
+
1394
+ function onSelectionCheckboxChange(row, index) {
1395
+ const key = resolveRowKey(row, index)
1396
+ const shiftKey = (
1397
+ pendingSelectionCheckboxClick.value?.key === key
1398
+ && pendingSelectionCheckboxClick.value.shiftKey
1399
+ )
1400
+ pendingSelectionCheckboxClick.value = null
1401
+
1402
+ if (shiftKey) {
1403
+ selectRowRange(row, index)
1404
+ return
1405
+ }
1406
+ toggleRowSelection(row, index)
1407
+ }
1408
+
738
1409
  const allDisplayRowsSelected = computed(() => {
739
1410
  const keys = displayRowKeys.value
740
1411
  return keys.length > 0 && keys.every((key) => selectedKeySet.value.has(key))
@@ -756,15 +1427,18 @@ function toggleSelectAllDisplayRows() {
756
1427
  if (allDisplayRowsSelected.value) {
757
1428
  const pageKeySet = new Set(pageKeys)
758
1429
  setSelectedKeys(activeSelectedKeys.value.filter((key) => !pageKeySet.has(key)))
1430
+ selectionAnchorKey.value = null
759
1431
  return
760
1432
  }
761
1433
 
762
1434
  setSelectedKeys([...activeSelectedKeys.value, ...pageKeys])
1435
+ selectionAnchorKey.value = pageKeys.at(-1) ?? null
763
1436
  }
764
1437
 
765
1438
  watch(() => props.selectable, (enabled) => {
766
1439
  if (!enabled) {
767
1440
  setSelectedKeys([])
1441
+ selectionAnchorKey.value = null
768
1442
  }
769
1443
  })
770
1444
 
@@ -822,11 +1496,49 @@ function onRowClick(row, displayIndex, event) {
822
1496
  if (event.target.closest('a, button, input, select, textarea, [data-row-click-ignore]')) {
823
1497
  return
824
1498
  }
1499
+
825
1500
  if (props.selectable) {
826
- toggleRowSelection(row, displayIndex)
827
- return
1501
+ if (props.selectionClickMode === 'extended') {
1502
+ if (event.ctrlKey || event.metaKey) {
1503
+ toggleRowSelection(row, displayIndex)
1504
+ return
1505
+ }
1506
+
1507
+ const hasRangeAnchor = displayRowKeys.value.includes(selectionAnchorKey.value)
1508
+ if (event.shiftKey && hasRangeAnchor) {
1509
+ selectRowRange(row, displayIndex, { updateAnchor: false })
1510
+ return
1511
+ }
1512
+
1513
+ replaceRowSelection(row, displayIndex)
1514
+ return
1515
+ }
1516
+
1517
+ if (
1518
+ props.selectionClickMode !== 'checkbox'
1519
+ && event.shiftKey
1520
+ ) {
1521
+ selectRowRange(row, displayIndex)
1522
+ return
1523
+ }
1524
+
1525
+ if (
1526
+ props.selectionClickMode === 'row'
1527
+ || (
1528
+ props.selectionClickMode === 'modifier'
1529
+ && (event.ctrlKey || event.metaKey)
1530
+ )
1531
+ ) {
1532
+ toggleRowSelection(row, displayIndex)
1533
+ return
1534
+ }
828
1535
  }
829
- emit('row-click', { row, index: filteredRowIndex(displayIndex) })
1536
+
1537
+ emit('row-click', {
1538
+ row,
1539
+ index: filteredRowIndex(displayIndex),
1540
+ event,
1541
+ })
830
1542
  }
831
1543
 
832
1544
  const rowContextMenuOpen = ref(false)
@@ -888,7 +1600,7 @@ function closeRowContextMenu() {
888
1600
  rowContextMenuAnchor.value = null
889
1601
  }
890
1602
 
891
- function onRowContextMenu(row, displayIndex, event) {
1603
+ function onRowContextMenu(row, sourceIndex, event) {
892
1604
  if (!hasRowContextMenu.value) {
893
1605
  return
894
1606
  }
@@ -899,8 +1611,8 @@ function onRowContextMenu(row, displayIndex, event) {
899
1611
  event.preventDefault()
900
1612
  event.stopPropagation()
901
1613
 
902
- const key = resolveRowKey(row, displayIndex)
903
- const index = filteredRowIndex(displayIndex)
1614
+ const key = resolveRowKey(row, sourceIndex)
1615
+ const index = filteredRowIndex(sourceIndex)
904
1616
  let targetKeys
905
1617
 
906
1618
  if (props.selectable && selectedKeySet.value.has(key) && activeSelectedKeys.value.length > 0) {
@@ -1035,6 +1747,17 @@ function onFilterCancel() {
1035
1747
  activeFilterValue.value = []
1036
1748
  }
1037
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
+
1038
1761
  const canHideActiveFilterColumn = computed(() => {
1039
1762
  if (!showColumnOptionsButton.value || !activeFilterHeader.value) {
1040
1763
  return false
@@ -1049,6 +1772,12 @@ const canHideActiveFilterColumn = computed(() => {
1049
1772
  return configurableHeaders.value.some((item) => item.key === header.key)
1050
1773
  })
1051
1774
 
1775
+ function onFilterGroupBy(columnKey) {
1776
+ setGroupBy(columnKey || null)
1777
+ filterPopoverOpen.value = false
1778
+ onFilterCancel()
1779
+ }
1780
+
1052
1781
  function onFilterHideColumn() {
1053
1782
  const header = activeFilterHeader.value
1054
1783
  if (!header?.key || !canHideActiveFilterColumn.value) {
@@ -1134,10 +1863,13 @@ function openColumnOptions() {
1134
1863
  void refreshSavedPresets()
1135
1864
  }
1136
1865
 
1137
- function onColumnOptionsApply({ order, visibleKeys, priorities }) {
1866
+ function onColumnOptionsApply({ order, visibleKeys, priorities, groupBy }) {
1138
1867
  setColumnOrder(order)
1139
1868
  setColumnVisibility(visibleKeys)
1140
1869
  setColumnPriorities(priorities)
1870
+ if (groupBy !== undefined && groupBy !== activeGroupBy.value) {
1871
+ setGroupBy(groupBy)
1872
+ }
1141
1873
  updateActiveLayoutLabel()
1142
1874
  }
1143
1875
 
@@ -1146,15 +1878,16 @@ function onColumnOptionsCancel() {
1146
1878
  }
1147
1879
 
1148
1880
  async function refreshSavedPresets() {
1149
- if (isRemote.value) {
1150
- if (!props.layoutPresets || typeof props.layoutPresets.list !== 'function') {
1881
+ if (isCallbackViewControl.value) {
1882
+ const handlers = viewSettingsHandlers.value
1883
+ if (!handlers || typeof handlers.list !== 'function') {
1151
1884
  savedPresets.value = []
1152
1885
  defaultPresetId.value = null
1153
1886
  return
1154
1887
  }
1155
1888
 
1156
1889
  try {
1157
- const result = await props.layoutPresets.list()
1890
+ const result = await handlers.list()
1158
1891
  savedPresets.value = Array.isArray(result?.presets) ? result.presets : []
1159
1892
  defaultPresetId.value = result?.defaultPresetId ?? null
1160
1893
  } catch {
@@ -1192,6 +1925,7 @@ function getCurrentLayoutState() {
1192
1925
  filters: cloneFilters(activeFilters.value),
1193
1926
  sortBy: sortBy.value,
1194
1927
  sortDir: sortDir.value,
1928
+ groupBy: activeGroupBy.value,
1195
1929
  pageSize: pageSize.value,
1196
1930
  }
1197
1931
  }
@@ -1231,6 +1965,12 @@ function applyPresetState(state) {
1231
1965
  sortDir.value = 'asc'
1232
1966
  }
1233
1967
 
1968
+ if (sanitized.groupBy) {
1969
+ setGroupBy(sanitized.groupBy)
1970
+ } else {
1971
+ setGroupBy(null)
1972
+ }
1973
+
1234
1974
  if (sanitized.pageSize) {
1235
1975
  pageSize.value = sanitized.pageSize
1236
1976
  }
@@ -1246,13 +1986,14 @@ function applyPresetState(state) {
1246
1986
  async function onSavePreset({ name, setAsDefault }) {
1247
1987
  const state = getCurrentLayoutState()
1248
1988
 
1249
- if (isRemote.value) {
1250
- if (!props.layoutPresets || typeof props.layoutPresets.save !== 'function') {
1251
- 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.' }
1252
1993
  }
1253
1994
 
1254
1995
  try {
1255
- const result = await props.layoutPresets.save({ name, state, setAsDefault })
1996
+ const result = await handlers.save({ name, state, setAsDefault })
1256
1997
  if (result?.ok) {
1257
1998
  await refreshSavedPresets()
1258
1999
  if (result.preset?.id) {
@@ -1285,14 +2026,50 @@ async function onSavePreset({ name, setAsDefault }) {
1285
2026
  return result
1286
2027
  }
1287
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
+
1288
2064
  async function onLoadPreset(presetId) {
1289
- if (isRemote.value) {
1290
- if (!props.layoutPresets || typeof props.layoutPresets.load !== 'function') {
1291
- 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.' }
1292
2069
  }
1293
2070
 
1294
2071
  try {
1295
- const result = await props.layoutPresets.load(presetId)
2072
+ const result = await handlers.load(presetId)
1296
2073
  if (!result?.ok || !result.preset?.state) {
1297
2074
  return result ?? { ok: false, error: 'Preset not found.' }
1298
2075
  }
@@ -1322,13 +2099,14 @@ async function onLoadPreset(presetId) {
1322
2099
  }
1323
2100
 
1324
2101
  async function onDeletePreset(presetId) {
1325
- if (isRemote.value) {
1326
- if (!props.layoutPresets || typeof props.layoutPresets.delete !== 'function') {
1327
- 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.' }
1328
2106
  }
1329
2107
 
1330
2108
  try {
1331
- const result = await props.layoutPresets.delete(presetId)
2109
+ const result = await handlers.delete(presetId)
1332
2110
  if (result?.ok) {
1333
2111
  await refreshSavedPresets()
1334
2112
  if (activeLayoutPresetId.value === presetId) {
@@ -1358,13 +2136,14 @@ async function onDeletePreset(presetId) {
1358
2136
  }
1359
2137
 
1360
2138
  async function onSetDefaultPreset(presetId) {
1361
- if (isRemote.value) {
1362
- if (!props.layoutPresets || typeof props.layoutPresets.setDefault !== 'function') {
2139
+ if (isCallbackViewControl.value) {
2140
+ const handlers = viewSettingsHandlers.value
2141
+ if (!handlers || typeof handlers.setDefault !== 'function') {
1363
2142
  return { ok: false, error: 'Unable to update default layout.' }
1364
2143
  }
1365
2144
 
1366
2145
  try {
1367
- const result = await props.layoutPresets.setDefault(presetId)
2146
+ const result = await handlers.setDefault(presetId)
1368
2147
  if (result?.ok) {
1369
2148
  await refreshSavedPresets()
1370
2149
  }
@@ -1407,6 +2186,7 @@ function getDeveloperDefaultLayoutState() {
1407
2186
  filters: {},
1408
2187
  sortBy: null,
1409
2188
  sortDir: 'asc',
2189
+ groupBy: null,
1410
2190
  pageSize: DEFAULT_PAGE_SIZE,
1411
2191
  }
1412
2192
  }
@@ -1466,6 +2246,7 @@ function loadDeveloperDefaults() {
1466
2246
  setFilters({})
1467
2247
  sortBy.value = null
1468
2248
  sortDir.value = 'asc'
2249
+ setGroupBy(null)
1469
2250
  setColumnOrder(getDeveloperDefaultColumnOrder())
1470
2251
  setColumnPriorities({})
1471
2252
 
@@ -1550,7 +2331,18 @@ function scheduleFetchRows() {
1550
2331
  }
1551
2332
 
1552
2333
  watch(
1553
- () => [pageSize.value, sortBy.value, sortDir.value],
2334
+ pageSize,
2335
+ () => {
2336
+ page.value = 1
2337
+ if (isRemote.value) {
2338
+ scheduleFetchRows()
2339
+ }
2340
+ emitQueryChange()
2341
+ },
2342
+ )
2343
+
2344
+ watch(
2345
+ activeGroupBy,
1554
2346
  () => {
1555
2347
  page.value = 1
1556
2348
  if (isRemote.value) {
@@ -1560,6 +2352,19 @@ watch(
1560
2352
  },
1561
2353
  )
1562
2354
 
2355
+ watch(
2356
+ () => [sortBy.value, sortDir.value],
2357
+ () => {
2358
+ if (props.resetPageOnSort) {
2359
+ page.value = 1
2360
+ }
2361
+ if (isRemote.value) {
2362
+ scheduleFetchRows()
2363
+ }
2364
+ emitQueryChange()
2365
+ },
2366
+ )
2367
+
1563
2368
  watch(page, () => {
1564
2369
  if (isRemote.value) {
1565
2370
  scheduleFetchRows()
@@ -1567,6 +2372,40 @@ watch(page, () => {
1567
2372
  emitQueryChange()
1568
2373
  })
1569
2374
 
2375
+ watch(() => props.sortBy, (value) => {
2376
+ if (value !== undefined) {
2377
+ internalSortBy.value = value ?? null
2378
+ }
2379
+ })
2380
+
2381
+ watch(() => props.sortDir, (value) => {
2382
+ if (value !== undefined) {
2383
+ internalSortDir.value = normalizeSortDir(value)
2384
+ }
2385
+ })
2386
+
2387
+ watch(() => props.page, (value) => {
2388
+ if (value !== undefined) {
2389
+ internalPage.value = normalizePage(value)
2390
+ }
2391
+ })
2392
+
2393
+ watch(() => props.pageSize, (value) => {
2394
+ if (value !== undefined) {
2395
+ internalPageSize.value = normalizePageSize(value)
2396
+ }
2397
+ })
2398
+
2399
+ watch(
2400
+ () => props.activeCell,
2401
+ (value) => {
2402
+ if (value !== undefined) {
2403
+ internalActiveCell.value = normalizeActiveCell(value)
2404
+ }
2405
+ },
2406
+ { deep: true },
2407
+ )
2408
+
1570
2409
  watch(
1571
2410
  activeFilters,
1572
2411
  () => {
@@ -1623,6 +2462,24 @@ watch(
1623
2462
  { deep: true },
1624
2463
  )
1625
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
+
1626
2483
  watch(
1627
2484
  () => props.columnPriorities,
1628
2485
  (nextPriorities) => {
@@ -1643,7 +2500,7 @@ watch(
1643
2500
  )
1644
2501
 
1645
2502
  watch(
1646
- () => [props.tableId, props.layoutPresets],
2503
+ () => [props.viewControlMode, props.tableId, viewSettingsHandlers.value],
1647
2504
  () => {
1648
2505
  void refreshSavedPresets()
1649
2506
  updateActiveLayoutLabel()
@@ -1660,6 +2517,7 @@ watch(
1660
2517
  activeFilters,
1661
2518
  sortBy,
1662
2519
  sortDir,
2520
+ activeGroupBy,
1663
2521
  pageSize,
1664
2522
  () => props.headers,
1665
2523
  savedPresets,
@@ -1674,25 +2532,30 @@ watch(activeLayoutLabel, (label) => {
1674
2532
  emitLayoutLabelChange(label)
1675
2533
  }, { immediate: true })
1676
2534
 
1677
- watch(sortedItems, () => {
1678
- if (!isRemote.value) {
1679
- page.value = 1
1680
- }
1681
- })
2535
+ watch(
2536
+ () => props.data,
2537
+ () => {
2538
+ if (!isRemote.value) {
2539
+ page.value = 1
2540
+ }
2541
+ },
2542
+ { deep: true },
2543
+ )
1682
2544
 
1683
2545
  onMounted(async () => {
1684
2546
  if (props.loadSavedLayout) {
1685
- if (isRemote.value && props.layoutPresets && typeof props.layoutPresets.getDefault === 'function') {
2547
+ const handlers = viewSettingsHandlers.value
2548
+ if (isCallbackViewControl.value && handlers && typeof handlers.getDefault === 'function') {
1686
2549
  try {
1687
- const result = await props.layoutPresets.getDefault()
2550
+ const result = await handlers.getDefault()
1688
2551
  if (result?.preset?.state) {
1689
2552
  applyPresetState(result.preset.state)
1690
2553
  activeLayoutPresetId.value = result.preset.id
1691
2554
  }
1692
2555
  } catch {
1693
- // Ignore default layout load failures in remote mode.
2556
+ // Ignore default layout load failures in callback mode.
1694
2557
  }
1695
- } else if (!isRemote.value && props.tableId) {
2558
+ } else if (!isCallbackViewControl.value && props.tableId) {
1696
2559
  const preset = getDefaultPreset(props.tableId)
1697
2560
  if (preset?.state) {
1698
2561
  applyPresetState(preset.state)
@@ -1721,6 +2584,12 @@ onBeforeUnmount(() => {
1721
2584
  defineExpose({
1722
2585
  layoutLabel: activeLayoutLabel,
1723
2586
  loadDeveloperDefaults,
2587
+ clearFilters: clearAllFilters,
2588
+ focusActiveCell,
2589
+ clearActiveCell,
2590
+ activateCellAt,
2591
+ moveActiveCell,
2592
+ getActiveCellContext,
1724
2593
  })
1725
2594
  </script>
1726
2595
 
@@ -1759,9 +2628,57 @@ table.loading tbody {
1759
2628
  }
1760
2629
 
1761
2630
  .sort-indicator {
2631
+ display: inline-flex;
2632
+ align-items: center;
2633
+ justify-content: center;
1762
2634
  width: 1.5em;
1763
- display: inline-block;
1764
- 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;
1765
2682
  }
1766
2683
 
1767
2684
  td:first-child,
@@ -1792,6 +2709,16 @@ tbody tr.row-clickable {
1792
2709
  cursor: pointer;
1793
2710
  }
1794
2711
 
2712
+ tbody td.table-active-cell {
2713
+ outline: 2px solid currentColor;
2714
+ outline-offset: -2px;
2715
+ }
2716
+
2717
+ tbody td.table-active-cell:focus,
2718
+ tbody td.table-active-cell:focus-visible {
2719
+ outline-width: 3px;
2720
+ }
2721
+
1795
2722
  tbody tr.row-selected > td {
1796
2723
  background-color: var(--control-checked-bg);
1797
2724
  color: var(--control-checked-fg);