picocrank 1.31.1 → 1.33.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.
@@ -7,6 +7,21 @@
7
7
  <table class="row-hover" :class="{ loading: isLoading }">
8
8
  <thead>
9
9
  <tr>
10
+ <th
11
+ v-if="selectable"
12
+ class="table-select-col"
13
+ @click.stop
14
+ >
15
+ <label class="table-select-label">
16
+ <input
17
+ type="checkbox"
18
+ :checked="allDisplayRowsSelected"
19
+ :indeterminate="someDisplayRowsSelected"
20
+ :aria-label="allDisplayRowsSelected ? 'Deselect all rows on this page' : 'Select all rows on this page'"
21
+ @change="toggleSelectAllDisplayRows"
22
+ >
23
+ </label>
24
+ </th>
10
25
  <th
11
26
  v-for="(header, index) in visibleHeaders"
12
27
  :key="header.key || index"
@@ -62,30 +77,68 @@
62
77
  <tr
63
78
  v-for="(row, rowIndex) in displayRows"
64
79
  :key="resolveRowKey(row, rowIndex)"
65
- :class="{ 'row-clickable': rowClickable }"
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)"
66
89
  @click="onRowClick(row, rowIndex, $event)"
90
+ @contextmenu="onRowContextMenu(row, rowIndex, $event)"
67
91
  >
92
+ <td
93
+ v-if="selectable"
94
+ class="table-select-col"
95
+ data-row-click-ignore
96
+ @click.stop
97
+ >
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)"
105
+ >
106
+ </label>
107
+ </td>
68
108
  <td
69
109
  v-for="(header, cellIndex) in visibleHeaders"
70
110
  :key="header.key || cellIndex"
111
+ :ref="(element) => registerCellElement(element, row, header, rowIndex)"
112
+ v-bind="resolveCellAttrs(row, header, rowIndex)"
71
113
  :colspan="bodyColumnColspan(cellIndex) || undefined"
72
114
  :class="[
73
115
  cellClasses(header),
74
116
  header.class,
117
+ resolveCellClass(row, header, rowIndex),
118
+ { 'table-active-cell': isActiveCell(row, header, rowIndex) },
75
119
  isLastColumnWithOptions(cellIndex) ? 'table-last-column-with-options' : null,
76
120
  ]"
121
+ :style="resolveCellStyle(row, header, rowIndex)"
122
+ :tabindex="isActiveCell(row, header, rowIndex) ? 0 : -1"
123
+ @click="onCellClick(row, header, rowIndex, $event)"
77
124
  >
78
125
  <slot
79
126
  v-if="slots[`cell-${header.key}`]"
80
127
  :name="`cell-${header.key}`"
81
128
  :row="row"
82
129
  :value="row[header.key]"
130
+ :header="header"
131
+ :row-index="rowIndex"
132
+ :source-index="sourceRowIndex(row, rowIndex)"
83
133
  />
84
134
  <slot
85
135
  v-else-if="slots.cell"
86
136
  name="cell"
87
137
  :row="row"
88
138
  :value="row[header.key]"
139
+ :header="header"
140
+ :row-index="rowIndex"
141
+ :source-index="sourceRowIndex(row, rowIndex)"
89
142
  />
90
143
  <span v-else>
91
144
  {{ row[header.key] }}
@@ -120,9 +173,11 @@
120
173
  :value="activeFilterValue"
121
174
  :rows="props.data"
122
175
  :select-options="activeSelectOptions"
176
+ :can-hide="canHideActiveFilterColumn"
123
177
  @apply="onFilterApply"
124
178
  @clear="onFilterClear"
125
179
  @cancel="onFilterCancel"
180
+ @hide="onFilterHideColumn"
126
181
  />
127
182
 
128
183
  <TableColumnOptionsPopover
@@ -148,15 +203,25 @@
148
203
  @apply="onColumnOptionsApply"
149
204
  @cancel="onColumnOptionsCancel"
150
205
  />
206
+
207
+ <TableRowContextMenu
208
+ v-model:open="rowContextMenuOpen"
209
+ :items="rowContextMenuItems"
210
+ :client-x="rowContextMenuX"
211
+ :client-y="rowContextMenuY"
212
+ :target-count="rowContextMenuTargetKeys.length"
213
+ @select="onRowContextMenuSelect"
214
+ />
151
215
  </template>
152
216
 
153
217
  <script setup>
154
- import { ref, computed, watch, onMounted, onBeforeUnmount, useSlots, useAttrs } from 'vue'
218
+ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount, useSlots, useAttrs } from 'vue'
155
219
  import { HugeiconsIcon } from '@hugeicons/vue'
156
220
  import { LayoutGridIcon } from '@hugeicons/core-free-icons'
157
221
  import Pagination from './Pagination.vue'
158
222
  import TableColumnFilterPopover from './TableColumnFilterPopover.vue'
159
223
  import TableColumnOptionsPopover from './TableColumnOptionsPopover.vue'
224
+ import TableRowContextMenu from './TableRowContextMenu.vue'
160
225
  import { useLongPress } from '../composables/useLongPress.js'
161
226
  import {
162
227
  buildSelectOptions,
@@ -189,11 +254,6 @@ import {
189
254
 
190
255
  const DEFAULT_PAGE_SIZE = 10
191
256
 
192
- const sortBy = ref(null)
193
- const sortDir = ref('asc')
194
- const page = ref(1)
195
- const pageSize = ref(DEFAULT_PAGE_SIZE)
196
-
197
257
  const props = defineProps({
198
258
  headers: {
199
259
  type: Array,
@@ -203,6 +263,29 @@ const props = defineProps({
203
263
  type: Array,
204
264
  default: () => [],
205
265
  },
266
+ sortBy: {
267
+ type: [String, Number],
268
+ default: undefined,
269
+ },
270
+ sortDir: {
271
+ type: String,
272
+ default: undefined,
273
+ validator: (value) => value === undefined || value === 'asc' || value === 'desc',
274
+ },
275
+ page: {
276
+ type: Number,
277
+ default: undefined,
278
+ validator: (value) => value === undefined || (Number.isFinite(value) && value >= 1),
279
+ },
280
+ pageSize: {
281
+ type: Number,
282
+ default: undefined,
283
+ validator: (value) => value === undefined || (Number.isFinite(value) && value >= 1),
284
+ },
285
+ resetPageOnSort: {
286
+ type: Boolean,
287
+ default: true,
288
+ },
206
289
  showPagination: {
207
290
  type: Boolean,
208
291
  default: true,
@@ -276,6 +359,53 @@ const props = defineProps({
276
359
  type: Object,
277
360
  default: null,
278
361
  },
362
+ selectable: {
363
+ type: Boolean,
364
+ default: false,
365
+ },
366
+ selectedKeys: {
367
+ type: Array,
368
+ default: undefined,
369
+ },
370
+ selectionClickMode: {
371
+ type: String,
372
+ default: 'row',
373
+ validator: (value) => ['row', 'checkbox', 'modifier', 'extended'].includes(value),
374
+ },
375
+ activeCell: {
376
+ type: Object,
377
+ default: undefined,
378
+ },
379
+ rowClass: {
380
+ type: Function,
381
+ default: null,
382
+ },
383
+ rowStyle: {
384
+ type: Function,
385
+ default: null,
386
+ },
387
+ cellClass: {
388
+ type: Function,
389
+ default: null,
390
+ },
391
+ cellStyle: {
392
+ type: Function,
393
+ default: null,
394
+ },
395
+ cellAttrs: {
396
+ type: Function,
397
+ default: null,
398
+ },
399
+ /**
400
+ * Row right-click menu. Array of items, or a function that returns items
401
+ * for the click context. Each item may include `action(ctx)` where
402
+ * `ctx.keys` / `ctx.rows` are the target selection (clicked row, or all
403
+ * selected rows when the click is inside the current multi-selection).
404
+ */
405
+ rowContextMenu: {
406
+ type: [Array, Function],
407
+ default: null,
408
+ },
279
409
  })
280
410
 
281
411
  const emit = defineEmits([
@@ -284,6 +414,16 @@ const emit = defineEmits([
284
414
  'query-change',
285
415
  'fetch-error',
286
416
  'row-click',
417
+ 'cell-click',
418
+ 'update:sortBy',
419
+ 'update:sortDir',
420
+ 'update:page',
421
+ 'update:pageSize',
422
+ 'update:selectedKeys',
423
+ 'selection-change',
424
+ 'update:activeCell',
425
+ 'row-context-menu',
426
+ 'row-context-menu-action',
287
427
  'update:columnVisibility',
288
428
  'column-visibility-change',
289
429
  'update:columnOrder',
@@ -297,8 +437,111 @@ const emit = defineEmits([
297
437
  const attrs = useAttrs()
298
438
  const slots = useSlots()
299
439
 
300
- const rowClickable = computed(() => Boolean(attrs.onRowClick))
440
+ function normalizePage(value) {
441
+ const numeric = Number(value)
442
+ return Number.isFinite(numeric) && numeric >= 1 ? Math.floor(numeric) : 1
443
+ }
444
+
445
+ function normalizePageSize(value) {
446
+ const numeric = Number(value)
447
+ return Number.isFinite(numeric) && numeric >= 1 ? Math.floor(numeric) : DEFAULT_PAGE_SIZE
448
+ }
449
+
450
+ function normalizeSortDir(value) {
451
+ return value === 'desc' ? 'desc' : 'asc'
452
+ }
453
+
454
+ function normalizeActiveCell(value) {
455
+ if (
456
+ !value
457
+ || typeof value !== 'object'
458
+ || value.rowKey === null
459
+ || value.rowKey === undefined
460
+ || value.columnKey === null
461
+ || value.columnKey === undefined
462
+ ) {
463
+ return null
464
+ }
465
+ return {
466
+ rowKey: value.rowKey,
467
+ columnKey: value.columnKey,
468
+ }
469
+ }
470
+
471
+ const internalSortBy = ref(props.sortBy !== undefined ? props.sortBy : null)
472
+ const internalSortDir = ref(normalizeSortDir(props.sortDir))
473
+ const internalPage = ref(normalizePage(props.page))
474
+ const internalPageSize = ref(normalizePageSize(props.pageSize))
475
+ const internalActiveCell = ref(normalizeActiveCell(props.activeCell))
476
+
477
+ const sortBy = computed({
478
+ get: () => props.sortBy !== undefined ? props.sortBy : internalSortBy.value,
479
+ set: (value) => {
480
+ const nextValue = value ?? null
481
+ if (nextValue === sortBy.value) {
482
+ return
483
+ }
484
+ if (props.sortBy === undefined) {
485
+ internalSortBy.value = nextValue
486
+ }
487
+ emit('update:sortBy', nextValue)
488
+ },
489
+ })
490
+
491
+ const sortDir = computed({
492
+ get: () => props.sortDir !== undefined ? normalizeSortDir(props.sortDir) : internalSortDir.value,
493
+ set: (value) => {
494
+ const nextValue = normalizeSortDir(value)
495
+ if (nextValue === sortDir.value) {
496
+ return
497
+ }
498
+ if (props.sortDir === undefined) {
499
+ internalSortDir.value = nextValue
500
+ }
501
+ emit('update:sortDir', nextValue)
502
+ },
503
+ })
504
+
505
+ const page = computed({
506
+ get: () => props.page !== undefined ? normalizePage(props.page) : internalPage.value,
507
+ set: (value) => {
508
+ const nextValue = normalizePage(value)
509
+ if (nextValue === page.value) {
510
+ return
511
+ }
512
+ if (props.page === undefined) {
513
+ internalPage.value = nextValue
514
+ }
515
+ emit('update:page', nextValue)
516
+ },
517
+ })
518
+
519
+ const pageSize = computed({
520
+ get: () => props.pageSize !== undefined ? normalizePageSize(props.pageSize) : internalPageSize.value,
521
+ set: (value) => {
522
+ const nextValue = normalizePageSize(value)
523
+ if (nextValue === pageSize.value) {
524
+ return
525
+ }
526
+ if (props.pageSize === undefined) {
527
+ internalPageSize.value = nextValue
528
+ }
529
+ emit('update:pageSize', nextValue)
530
+ },
531
+ })
532
+
533
+ const activeCell = computed(() => (
534
+ props.activeCell !== undefined ? normalizeActiveCell(props.activeCell) : internalActiveCell.value
535
+ ))
536
+
537
+ const rowClickable = computed(() =>
538
+ Boolean(attrs.onRowClick)
539
+ || (props.selectable && props.selectionClickMode !== 'checkbox'),
540
+ )
301
541
  const internalFilters = ref({})
542
+ const internalSelectedKeys = ref([])
543
+ const selectionAnchorKey = ref(null)
544
+ const pendingSelectionCheckboxClick = ref(null)
302
545
  const remoteRows = ref([])
303
546
  const remoteTotal = ref(0)
304
547
  const internalLoading = ref(false)
@@ -491,11 +734,20 @@ const filteredItems = computed(() => {
491
734
  return applyFilterQuery(props.data, props.headers, activeFilterQuery.value)
492
735
  })
493
736
 
737
+ const activeSortHeader = computed(() =>
738
+ props.headers.find((header) => header?.key === sortBy.value),
739
+ )
740
+
494
741
  const sortedItems = computed(() => {
495
742
  if (isRemote.value) {
496
743
  return filteredItems.value
497
744
  }
498
- return sortRows(filteredItems.value, sortBy.value, sortDir.value)
745
+ return sortRows(
746
+ filteredItems.value,
747
+ sortBy.value,
748
+ sortDir.value,
749
+ activeSortHeader.value?.comparator,
750
+ )
499
751
  })
500
752
 
501
753
  const displayRows = computed(() => {
@@ -613,6 +865,60 @@ function cellClasses(header) {
613
865
  }
614
866
  }
615
867
 
868
+ function sourceRowIndex(row, displayIndex) {
869
+ const sourceRows = isRemote.value ? remoteRows.value : props.data
870
+ const index = sourceRows.indexOf(row)
871
+ return index >= 0 ? index : filteredRowIndex(displayIndex)
872
+ }
873
+
874
+ function rowContext(row, rowIndex) {
875
+ return {
876
+ row,
877
+ rowIndex,
878
+ sourceIndex: sourceRowIndex(row, rowIndex),
879
+ }
880
+ }
881
+
882
+ function cellContext(row, header, rowIndex) {
883
+ return {
884
+ ...rowContext(row, rowIndex),
885
+ value: row?.[header.key],
886
+ header,
887
+ }
888
+ }
889
+
890
+ function resolveRowClass(row, rowIndex) {
891
+ return props.rowClass?.(rowContext(row, rowIndex))
892
+ }
893
+
894
+ function resolveRowStyle(row, rowIndex) {
895
+ return props.rowStyle?.(rowContext(row, rowIndex))
896
+ }
897
+
898
+ function resolveCellClass(row, header, rowIndex) {
899
+ return props.cellClass?.(cellContext(row, header, rowIndex))
900
+ }
901
+
902
+ function resolveCellStyle(row, header, rowIndex) {
903
+ return props.cellStyle?.(cellContext(row, header, rowIndex))
904
+ }
905
+
906
+ function resolveCellAttrs(row, header, rowIndex) {
907
+ const value = props.cellAttrs?.(cellContext(row, header, rowIndex))
908
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
909
+ return {}
910
+ }
911
+
912
+ const {
913
+ class: _class,
914
+ style: _style,
915
+ key: _key,
916
+ ref: _ref,
917
+ ...safeAttrs
918
+ } = value
919
+ return safeAttrs
920
+ }
921
+
616
922
  function resolveRowKey(row, index) {
617
923
  if (typeof props.rowKey === 'function') {
618
924
  const key = props.rowKey(row, index)
@@ -632,6 +938,312 @@ function resolveRowKey(row, index) {
632
938
  return index
633
939
  }
634
940
 
941
+ function activeCellsEqual(left, right) {
942
+ return left?.rowKey === right?.rowKey && left?.columnKey === right?.columnKey
943
+ }
944
+
945
+ function setActiveCell(nextCell) {
946
+ const normalized = normalizeActiveCell(nextCell)
947
+ if (activeCellsEqual(normalized, activeCell.value)) {
948
+ return
949
+ }
950
+ if (props.activeCell === undefined) {
951
+ internalActiveCell.value = normalized
952
+ }
953
+ emit('update:activeCell', normalized)
954
+ }
955
+
956
+ function clearActiveCell() {
957
+ setActiveCell(null)
958
+ }
959
+
960
+ function isActiveCell(row, header, rowIndex) {
961
+ const current = activeCell.value
962
+ return Boolean(
963
+ current
964
+ && current.rowKey === resolveRowKey(row, rowIndex)
965
+ && current.columnKey === header.key,
966
+ )
967
+ }
968
+
969
+ const activeCellElements = new Map()
970
+
971
+ function registerCellElement(element, row, header, rowIndex) {
972
+ const rowKey = resolveRowKey(row, rowIndex)
973
+ let rowCells = activeCellElements.get(rowKey)
974
+
975
+ if (!element) {
976
+ rowCells?.delete(header.key)
977
+ if (rowCells?.size === 0) {
978
+ activeCellElements.delete(rowKey)
979
+ }
980
+ return
981
+ }
982
+
983
+ if (!rowCells) {
984
+ rowCells = new Map()
985
+ activeCellElements.set(rowKey, rowCells)
986
+ }
987
+ rowCells.set(header.key, element)
988
+ }
989
+
990
+ function focusCell(cell, options) {
991
+ if (!cell) {
992
+ return false
993
+ }
994
+ const element = activeCellElements.get(cell.rowKey)?.get(cell.columnKey)
995
+ if (!element) {
996
+ return false
997
+ }
998
+ element.focus(options)
999
+ return true
1000
+ }
1001
+
1002
+ function focusActiveCell(options) {
1003
+ return focusCell(activeCell.value, options)
1004
+ }
1005
+
1006
+ function cellContextAt(rowIndex, columnIndex) {
1007
+ const row = displayRows.value[rowIndex]
1008
+ const header = visibleHeaders.value[columnIndex]
1009
+ if (!row || !header) {
1010
+ return null
1011
+ }
1012
+
1013
+ return {
1014
+ ...cellContext(row, header, rowIndex),
1015
+ rowKey: resolveRowKey(row, rowIndex),
1016
+ columnKey: header.key,
1017
+ columnIndex,
1018
+ }
1019
+ }
1020
+
1021
+ function getActiveCellContext() {
1022
+ const current = activeCell.value
1023
+ if (!current) {
1024
+ return null
1025
+ }
1026
+
1027
+ const rowIndex = displayRows.value.findIndex(
1028
+ (row, index) => resolveRowKey(row, index) === current.rowKey,
1029
+ )
1030
+ const columnIndex = visibleHeaders.value.findIndex(
1031
+ (header) => header.key === current.columnKey,
1032
+ )
1033
+ if (rowIndex < 0 || columnIndex < 0) {
1034
+ return null
1035
+ }
1036
+
1037
+ return cellContextAt(rowIndex, columnIndex)
1038
+ }
1039
+
1040
+ function clampGridIndex(value, maximum) {
1041
+ const numeric = Number(value)
1042
+ const normalized = Number.isFinite(numeric) ? Math.trunc(numeric) : 0
1043
+ return Math.min(Math.max(normalized, 0), maximum)
1044
+ }
1045
+
1046
+ function scheduleCellFocus(cell, options) {
1047
+ if (!options?.focus) {
1048
+ return
1049
+ }
1050
+ void nextTick(() => focusCell(cell, options.focusOptions))
1051
+ }
1052
+
1053
+ function activateCellAt(rowIndex, columnIndex, options = {}) {
1054
+ if (displayRows.value.length === 0 || visibleHeaders.value.length === 0) {
1055
+ return null
1056
+ }
1057
+
1058
+ const nextRowIndex = clampGridIndex(rowIndex, displayRows.value.length - 1)
1059
+ const nextColumnIndex = clampGridIndex(columnIndex, visibleHeaders.value.length - 1)
1060
+ const context = cellContextAt(nextRowIndex, nextColumnIndex)
1061
+ const nextActiveCell = {
1062
+ rowKey: context.rowKey,
1063
+ columnKey: context.columnKey,
1064
+ }
1065
+
1066
+ setActiveCell(nextActiveCell)
1067
+ scheduleCellFocus(nextActiveCell, options)
1068
+ return context
1069
+ }
1070
+
1071
+ function moveActiveCell(rowDelta, columnDelta, options = {}) {
1072
+ if (displayRows.value.length === 0 || visibleHeaders.value.length === 0) {
1073
+ return null
1074
+ }
1075
+
1076
+ const current = getActiveCellContext()
1077
+ if (!current) {
1078
+ return activateCellAt(0, 0, options)
1079
+ }
1080
+
1081
+ const normalizedRowDelta = Number.isFinite(Number(rowDelta)) ? Math.trunc(Number(rowDelta)) : 0
1082
+ const normalizedColumnDelta = Number.isFinite(Number(columnDelta))
1083
+ ? Math.trunc(Number(columnDelta))
1084
+ : 0
1085
+ const rowIndex = clampGridIndex(
1086
+ current.rowIndex + normalizedRowDelta,
1087
+ displayRows.value.length - 1,
1088
+ )
1089
+ let columnIndex
1090
+
1091
+ if (options.wrapColumns) {
1092
+ const linearIndex = (rowIndex * visibleHeaders.value.length)
1093
+ + current.columnIndex
1094
+ + normalizedColumnDelta
1095
+ const clampedLinearIndex = clampGridIndex(
1096
+ linearIndex,
1097
+ (displayRows.value.length * visibleHeaders.value.length) - 1,
1098
+ )
1099
+ return activateCellAt(
1100
+ Math.floor(clampedLinearIndex / visibleHeaders.value.length),
1101
+ clampedLinearIndex % visibleHeaders.value.length,
1102
+ options,
1103
+ )
1104
+ }
1105
+
1106
+ columnIndex = clampGridIndex(
1107
+ current.columnIndex + normalizedColumnDelta,
1108
+ visibleHeaders.value.length - 1,
1109
+ )
1110
+ return activateCellAt(rowIndex, columnIndex, options)
1111
+ }
1112
+
1113
+ function onCellClick(row, header, rowIndex, event) {
1114
+ const context = cellContext(row, header, rowIndex)
1115
+ const nextActiveCell = {
1116
+ rowKey: resolveRowKey(row, rowIndex),
1117
+ columnKey: header.key,
1118
+ }
1119
+ setActiveCell(nextActiveCell)
1120
+ emit('cell-click', {
1121
+ ...context,
1122
+ ...nextActiveCell,
1123
+ event,
1124
+ })
1125
+ }
1126
+
1127
+ const activeSelectedKeys = computed(() => (
1128
+ props.selectedKeys !== undefined ? props.selectedKeys : internalSelectedKeys.value
1129
+ ))
1130
+
1131
+ const selectedKeySet = computed(() => new Set(activeSelectedKeys.value))
1132
+
1133
+ function isRowSelected(row, index) {
1134
+ return selectedKeySet.value.has(resolveRowKey(row, index))
1135
+ }
1136
+
1137
+ function setSelectedKeys(nextKeys) {
1138
+ const unique = [...new Set(nextKeys)]
1139
+ if (props.selectedKeys === undefined) {
1140
+ internalSelectedKeys.value = unique
1141
+ }
1142
+ emit('update:selectedKeys', unique)
1143
+ emit('selection-change', unique)
1144
+ }
1145
+
1146
+ function toggleRowSelection(row, index) {
1147
+ const key = resolveRowKey(row, index)
1148
+ const current = activeSelectedKeys.value
1149
+ selectionAnchorKey.value = key
1150
+ if (selectedKeySet.value.has(key)) {
1151
+ setSelectedKeys(current.filter((item) => item !== key))
1152
+ return
1153
+ }
1154
+ setSelectedKeys([...current, key])
1155
+ }
1156
+
1157
+ function replaceRowSelection(row, index) {
1158
+ const key = resolveRowKey(row, index)
1159
+ selectionAnchorKey.value = key
1160
+ setSelectedKeys([key])
1161
+ }
1162
+
1163
+ const displayRowKeys = computed(() =>
1164
+ displayRows.value.map((row, index) => resolveRowKey(row, index)),
1165
+ )
1166
+
1167
+ function selectRowRange(row, index, { updateAnchor = true } = {}) {
1168
+ const key = resolveRowKey(row, index)
1169
+ const anchorIndex = displayRowKeys.value.findIndex((item) => item === selectionAnchorKey.value)
1170
+ const targetIndex = displayRowKeys.value.findIndex((item) => item === key)
1171
+
1172
+ if (anchorIndex < 0 || targetIndex < 0) {
1173
+ setSelectedKeys([...activeSelectedKeys.value, key])
1174
+ selectionAnchorKey.value = key
1175
+ return
1176
+ }
1177
+
1178
+ const from = Math.min(anchorIndex, targetIndex)
1179
+ const to = Math.max(anchorIndex, targetIndex)
1180
+ setSelectedKeys([
1181
+ ...activeSelectedKeys.value,
1182
+ ...displayRowKeys.value.slice(from, to + 1),
1183
+ ])
1184
+ if (updateAnchor) {
1185
+ selectionAnchorKey.value = key
1186
+ }
1187
+ }
1188
+
1189
+ function rememberSelectionCheckboxClick(row, index, event) {
1190
+ pendingSelectionCheckboxClick.value = {
1191
+ key: resolveRowKey(row, index),
1192
+ shiftKey: event.shiftKey,
1193
+ }
1194
+ }
1195
+
1196
+ function onSelectionCheckboxChange(row, index) {
1197
+ const key = resolveRowKey(row, index)
1198
+ const shiftKey = (
1199
+ pendingSelectionCheckboxClick.value?.key === key
1200
+ && pendingSelectionCheckboxClick.value.shiftKey
1201
+ )
1202
+ pendingSelectionCheckboxClick.value = null
1203
+
1204
+ if (shiftKey) {
1205
+ selectRowRange(row, index)
1206
+ return
1207
+ }
1208
+ toggleRowSelection(row, index)
1209
+ }
1210
+
1211
+ const allDisplayRowsSelected = computed(() => {
1212
+ const keys = displayRowKeys.value
1213
+ return keys.length > 0 && keys.every((key) => selectedKeySet.value.has(key))
1214
+ })
1215
+
1216
+ const someDisplayRowsSelected = computed(() => {
1217
+ if (allDisplayRowsSelected.value) {
1218
+ return false
1219
+ }
1220
+ return displayRowKeys.value.some((key) => selectedKeySet.value.has(key))
1221
+ })
1222
+
1223
+ function toggleSelectAllDisplayRows() {
1224
+ const pageKeys = displayRowKeys.value
1225
+ if (pageKeys.length === 0) {
1226
+ return
1227
+ }
1228
+
1229
+ if (allDisplayRowsSelected.value) {
1230
+ const pageKeySet = new Set(pageKeys)
1231
+ setSelectedKeys(activeSelectedKeys.value.filter((key) => !pageKeySet.has(key)))
1232
+ selectionAnchorKey.value = null
1233
+ return
1234
+ }
1235
+
1236
+ setSelectedKeys([...activeSelectedKeys.value, ...pageKeys])
1237
+ selectionAnchorKey.value = pageKeys.at(-1) ?? null
1238
+ }
1239
+
1240
+ watch(() => props.selectable, (enabled) => {
1241
+ if (!enabled) {
1242
+ setSelectedKeys([])
1243
+ selectionAnchorKey.value = null
1244
+ }
1245
+ })
1246
+
635
1247
  function setFilters(nextFilters) {
636
1248
  const cloned = cloneFilters(nextFilters)
637
1249
  if (props.filters === undefined) {
@@ -686,7 +1298,188 @@ function onRowClick(row, displayIndex, event) {
686
1298
  if (event.target.closest('a, button, input, select, textarea, [data-row-click-ignore]')) {
687
1299
  return
688
1300
  }
689
- emit('row-click', { row, index: filteredRowIndex(displayIndex) })
1301
+
1302
+ if (props.selectable) {
1303
+ if (props.selectionClickMode === 'extended') {
1304
+ if (event.ctrlKey || event.metaKey) {
1305
+ toggleRowSelection(row, displayIndex)
1306
+ return
1307
+ }
1308
+
1309
+ const hasRangeAnchor = displayRowKeys.value.includes(selectionAnchorKey.value)
1310
+ if (event.shiftKey && hasRangeAnchor) {
1311
+ selectRowRange(row, displayIndex, { updateAnchor: false })
1312
+ return
1313
+ }
1314
+
1315
+ replaceRowSelection(row, displayIndex)
1316
+ return
1317
+ }
1318
+
1319
+ if (
1320
+ props.selectionClickMode !== 'checkbox'
1321
+ && event.shiftKey
1322
+ ) {
1323
+ selectRowRange(row, displayIndex)
1324
+ return
1325
+ }
1326
+
1327
+ if (
1328
+ props.selectionClickMode === 'row'
1329
+ || (
1330
+ props.selectionClickMode === 'modifier'
1331
+ && (event.ctrlKey || event.metaKey)
1332
+ )
1333
+ ) {
1334
+ toggleRowSelection(row, displayIndex)
1335
+ return
1336
+ }
1337
+ }
1338
+
1339
+ emit('row-click', {
1340
+ row,
1341
+ index: filteredRowIndex(displayIndex),
1342
+ event,
1343
+ })
1344
+ }
1345
+
1346
+ const rowContextMenuOpen = ref(false)
1347
+ const rowContextMenuX = ref(0)
1348
+ const rowContextMenuY = ref(0)
1349
+ const rowContextMenuItems = ref([])
1350
+ const rowContextMenuTargetKeys = ref([])
1351
+ const rowContextMenuAnchor = ref(null)
1352
+
1353
+ const rowContextMenuTargetKeySet = computed(() => new Set(rowContextMenuTargetKeys.value))
1354
+
1355
+ function isRowContextMenuTarget(row, index) {
1356
+ return rowContextMenuOpen.value && rowContextMenuTargetKeySet.value.has(resolveRowKey(row, index))
1357
+ }
1358
+
1359
+ const hasRowContextMenu = computed(() => {
1360
+ if (typeof props.rowContextMenu === 'function') {
1361
+ return true
1362
+ }
1363
+ return Array.isArray(props.rowContextMenu) && props.rowContextMenu.length > 0
1364
+ })
1365
+
1366
+ function buildRowLookup() {
1367
+ const map = new Map()
1368
+ const addRows = (rows) => {
1369
+ rows.forEach((row, index) => {
1370
+ const key = resolveRowKey(row, index)
1371
+ if (!map.has(key)) {
1372
+ map.set(key, row)
1373
+ }
1374
+ })
1375
+ }
1376
+
1377
+ if (isRemote.value) {
1378
+ addRows(remoteRows.value)
1379
+ } else {
1380
+ addRows(props.data)
1381
+ }
1382
+ addRows(displayRows.value)
1383
+ return map
1384
+ }
1385
+
1386
+ function resolveTargetRows(keys) {
1387
+ const lookup = buildRowLookup()
1388
+ return keys.map((key) => lookup.get(key)).filter((row) => row != null)
1389
+ }
1390
+
1391
+ function normalizeContextMenuItems(rawItems) {
1392
+ if (!Array.isArray(rawItems)) {
1393
+ return []
1394
+ }
1395
+ return rawItems.filter((item) => item && (item.divider || item.label || item.id))
1396
+ }
1397
+
1398
+ function closeRowContextMenu() {
1399
+ rowContextMenuOpen.value = false
1400
+ rowContextMenuItems.value = []
1401
+ rowContextMenuTargetKeys.value = []
1402
+ rowContextMenuAnchor.value = null
1403
+ }
1404
+
1405
+ function onRowContextMenu(row, displayIndex, event) {
1406
+ if (!hasRowContextMenu.value) {
1407
+ return
1408
+ }
1409
+ if (event.target.closest('a, button, input, select, textarea, [data-row-click-ignore]')) {
1410
+ return
1411
+ }
1412
+
1413
+ event.preventDefault()
1414
+ event.stopPropagation()
1415
+
1416
+ const key = resolveRowKey(row, displayIndex)
1417
+ const index = filteredRowIndex(displayIndex)
1418
+ let targetKeys
1419
+
1420
+ if (props.selectable && selectedKeySet.value.has(key) && activeSelectedKeys.value.length > 0) {
1421
+ targetKeys = [...activeSelectedKeys.value]
1422
+ } else {
1423
+ targetKeys = [key]
1424
+ if (props.selectable) {
1425
+ setSelectedKeys([key])
1426
+ }
1427
+ }
1428
+
1429
+ const rows = resolveTargetRows(targetKeys)
1430
+ const ctx = {
1431
+ row,
1432
+ key,
1433
+ index,
1434
+ rows,
1435
+ keys: targetKeys,
1436
+ selectedKeys: [...activeSelectedKeys.value],
1437
+ }
1438
+
1439
+ const rawItems = typeof props.rowContextMenu === 'function'
1440
+ ? props.rowContextMenu(ctx)
1441
+ : props.rowContextMenu
1442
+ const items = normalizeContextMenuItems(rawItems)
1443
+ if (items.length === 0) {
1444
+ closeRowContextMenu()
1445
+ return
1446
+ }
1447
+
1448
+ rowContextMenuAnchor.value = ctx
1449
+ rowContextMenuTargetKeys.value = targetKeys
1450
+ rowContextMenuItems.value = items
1451
+ rowContextMenuX.value = event.clientX
1452
+ rowContextMenuY.value = event.clientY
1453
+ rowContextMenuOpen.value = true
1454
+
1455
+ emit('row-context-menu', {
1456
+ ...ctx,
1457
+ event,
1458
+ })
1459
+ }
1460
+
1461
+ function onRowContextMenuSelect(item) {
1462
+ const anchor = rowContextMenuAnchor.value
1463
+ const keys = [...rowContextMenuTargetKeys.value]
1464
+ const lookup = buildRowLookup()
1465
+ const rows = keys.map((key) => lookup.get(key)).filter((row) => row != null)
1466
+ const selectedKeys = [...activeSelectedKeys.value]
1467
+ const payload = {
1468
+ item,
1469
+ row: anchor?.row,
1470
+ key: anchor?.key,
1471
+ index: anchor?.index,
1472
+ rows,
1473
+ keys,
1474
+ selectedKeys,
1475
+ }
1476
+
1477
+ if (typeof item.action === 'function') {
1478
+ item.action(payload)
1479
+ }
1480
+
1481
+ emit('row-context-menu-action', payload)
1482
+ closeRowContextMenu()
690
1483
  }
691
1484
 
692
1485
  function openFilter(header, event) {
@@ -756,6 +1549,39 @@ function onFilterCancel() {
756
1549
  activeFilterValue.value = []
757
1550
  }
758
1551
 
1552
+ const canHideActiveFilterColumn = computed(() => {
1553
+ if (!showColumnOptionsButton.value || !activeFilterHeader.value) {
1554
+ return false
1555
+ }
1556
+ const header = activeFilterHeader.value
1557
+ if (!header.key || header.hideable === false || header.hidden) {
1558
+ return false
1559
+ }
1560
+ if (!isHeaderHidden(header) && visibleColumnKeys.value.length <= 1) {
1561
+ return false
1562
+ }
1563
+ return configurableHeaders.value.some((item) => item.key === header.key)
1564
+ })
1565
+
1566
+ function onFilterHideColumn() {
1567
+ const header = activeFilterHeader.value
1568
+ if (!header?.key || !canHideActiveFilterColumn.value) {
1569
+ onFilterCancel()
1570
+ return
1571
+ }
1572
+
1573
+ const nextVisibleKeys = visibleColumnKeys.value.filter((key) => key !== header.key)
1574
+ if (nextVisibleKeys.length === 0) {
1575
+ onFilterCancel()
1576
+ return
1577
+ }
1578
+
1579
+ setColumnVisibility(nextVisibleKeys)
1580
+ updateActiveLayoutLabel()
1581
+ filterPopoverOpen.value = false
1582
+ onFilterCancel()
1583
+ }
1584
+
759
1585
  function buildColumnVisibility(visibleKeys) {
760
1586
  const visibleKeySet = new Set(visibleKeys)
761
1587
  return Object.fromEntries(
@@ -1238,7 +2064,7 @@ function scheduleFetchRows() {
1238
2064
  }
1239
2065
 
1240
2066
  watch(
1241
- () => [pageSize.value, sortBy.value, sortDir.value],
2067
+ pageSize,
1242
2068
  () => {
1243
2069
  page.value = 1
1244
2070
  if (isRemote.value) {
@@ -1248,6 +2074,19 @@ watch(
1248
2074
  },
1249
2075
  )
1250
2076
 
2077
+ watch(
2078
+ () => [sortBy.value, sortDir.value],
2079
+ () => {
2080
+ if (props.resetPageOnSort) {
2081
+ page.value = 1
2082
+ }
2083
+ if (isRemote.value) {
2084
+ scheduleFetchRows()
2085
+ }
2086
+ emitQueryChange()
2087
+ },
2088
+ )
2089
+
1251
2090
  watch(page, () => {
1252
2091
  if (isRemote.value) {
1253
2092
  scheduleFetchRows()
@@ -1255,6 +2094,40 @@ watch(page, () => {
1255
2094
  emitQueryChange()
1256
2095
  })
1257
2096
 
2097
+ watch(() => props.sortBy, (value) => {
2098
+ if (value !== undefined) {
2099
+ internalSortBy.value = value ?? null
2100
+ }
2101
+ })
2102
+
2103
+ watch(() => props.sortDir, (value) => {
2104
+ if (value !== undefined) {
2105
+ internalSortDir.value = normalizeSortDir(value)
2106
+ }
2107
+ })
2108
+
2109
+ watch(() => props.page, (value) => {
2110
+ if (value !== undefined) {
2111
+ internalPage.value = normalizePage(value)
2112
+ }
2113
+ })
2114
+
2115
+ watch(() => props.pageSize, (value) => {
2116
+ if (value !== undefined) {
2117
+ internalPageSize.value = normalizePageSize(value)
2118
+ }
2119
+ })
2120
+
2121
+ watch(
2122
+ () => props.activeCell,
2123
+ (value) => {
2124
+ if (value !== undefined) {
2125
+ internalActiveCell.value = normalizeActiveCell(value)
2126
+ }
2127
+ },
2128
+ { deep: true },
2129
+ )
2130
+
1258
2131
  watch(
1259
2132
  activeFilters,
1260
2133
  () => {
@@ -1362,11 +2235,15 @@ watch(activeLayoutLabel, (label) => {
1362
2235
  emitLayoutLabelChange(label)
1363
2236
  }, { immediate: true })
1364
2237
 
1365
- watch(sortedItems, () => {
1366
- if (!isRemote.value) {
1367
- page.value = 1
1368
- }
1369
- })
2238
+ watch(
2239
+ () => props.data,
2240
+ () => {
2241
+ if (!isRemote.value) {
2242
+ page.value = 1
2243
+ }
2244
+ },
2245
+ { deep: true },
2246
+ )
1370
2247
 
1371
2248
  onMounted(async () => {
1372
2249
  if (props.loadSavedLayout) {
@@ -1409,6 +2286,12 @@ onBeforeUnmount(() => {
1409
2286
  defineExpose({
1410
2287
  layoutLabel: activeLayoutLabel,
1411
2288
  loadDeveloperDefaults,
2289
+ clearFilters: clearAllFilters,
2290
+ focusActiveCell,
2291
+ clearActiveCell,
2292
+ activateCellAt,
2293
+ moveActiveCell,
2294
+ getActiveCellContext,
1412
2295
  })
1413
2296
  </script>
1414
2297
 
@@ -1457,10 +2340,85 @@ th:first-child {
1457
2340
  padding-left: 1rem;
1458
2341
  }
1459
2342
 
2343
+ th.table-select-col,
2344
+ td.table-select-col {
2345
+ width: 2.5rem;
2346
+ min-width: 2.5rem;
2347
+ max-width: 2.5rem;
2348
+ text-align: center;
2349
+ vertical-align: middle;
2350
+ padding-left: 1rem;
2351
+ padding-right: 0.25rem;
2352
+ }
2353
+
2354
+ .table-select-label {
2355
+ display: inline-flex;
2356
+ align-items: center;
2357
+ justify-content: center;
2358
+ margin: 0;
2359
+ cursor: pointer;
2360
+ }
2361
+
1460
2362
  tbody tr.row-clickable {
1461
2363
  cursor: pointer;
1462
2364
  }
1463
2365
 
2366
+ tbody td.table-active-cell {
2367
+ outline: 2px solid currentColor;
2368
+ outline-offset: -2px;
2369
+ }
2370
+
2371
+ tbody td.table-active-cell:focus,
2372
+ tbody td.table-active-cell:focus-visible {
2373
+ outline-width: 3px;
2374
+ }
2375
+
2376
+ tbody tr.row-selected > td {
2377
+ background-color: var(--control-checked-bg);
2378
+ color: var(--control-checked-fg);
2379
+ }
2380
+
2381
+ table.row-hover tbody tr.row-selected:hover > td,
2382
+ table.row-hover tbody tr.row-selected:focus-within > td,
2383
+ table.row-hover tbody tr.row-selected.row-context-menu-active > td {
2384
+ background-color: var(--control-checked-hover-bg);
2385
+ color: var(--control-checked-fg);
2386
+ }
2387
+
2388
+ /* Keep hover styling while the row context menu is open (pointer has left the row). */
2389
+ table.row-hover tbody tr.row-context-menu-active > td {
2390
+ background-color: var(--table-row-hover-bg);
2391
+ color: var(--table-row-hover-fg);
2392
+ }
2393
+
2394
+ /* Beat Femtocrank sticky cell backgrounds so selection remains visible while scrolling. */
2395
+ .table-scroll.sticky-cols-1 table tbody tr.row-selected > td,
2396
+ .table-scroll.sticky-cols-2 table tbody tr.row-selected > td,
2397
+ .table-scroll.sticky-cols-3 table tbody tr.row-selected > td {
2398
+ background-color: var(--control-checked-bg);
2399
+ color: var(--control-checked-fg);
2400
+ }
2401
+
2402
+ .table-scroll.sticky-cols-1 table.row-hover tbody tr.row-selected:hover > td,
2403
+ .table-scroll.sticky-cols-1 table.row-hover tbody tr.row-selected:focus-within > td,
2404
+ .table-scroll.sticky-cols-1 table.row-hover tbody tr.row-selected.row-context-menu-active > td,
2405
+ .table-scroll.sticky-cols-2 table.row-hover tbody tr.row-selected:hover > td,
2406
+ .table-scroll.sticky-cols-2 table.row-hover tbody tr.row-selected:focus-within > td,
2407
+ .table-scroll.sticky-cols-2 table.row-hover tbody tr.row-selected.row-context-menu-active > td,
2408
+ .table-scroll.sticky-cols-3 table.row-hover tbody tr.row-selected:hover > td,
2409
+ .table-scroll.sticky-cols-3 table.row-hover tbody tr.row-selected:focus-within > td,
2410
+ .table-scroll.sticky-cols-3 table.row-hover tbody tr.row-selected.row-context-menu-active > td {
2411
+ background-color: var(--control-checked-hover-bg);
2412
+ color: var(--control-checked-fg);
2413
+ }
2414
+
2415
+ .table-scroll.sticky-cols-1 table.row-hover tbody tr.row-context-menu-active > td,
2416
+ .table-scroll.sticky-cols-2 table.row-hover tbody tr.row-context-menu-active > td,
2417
+ .table-scroll.sticky-cols-3 table.row-hover tbody tr.row-context-menu-active > td {
2418
+ background-color: var(--table-row-hover-bg);
2419
+ color: var(--table-row-hover-fg);
2420
+ }
2421
+
1464
2422
  .table-empty-state {
1465
2423
  display: flex;
1466
2424
  align-items: center;