picocrank 1.32.0 → 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.
package/docs/table.md ADDED
@@ -0,0 +1,99 @@
1
+ # Table
2
+
3
+ `Table` supports local data and remote `fetchRows` data. Existing usages remain
4
+ uncontrolled; pass any query-state prop to control that field independently.
5
+
6
+ ## Query state
7
+
8
+ ```vue
9
+ <Table
10
+ v-model:sort-by="sortBy"
11
+ v-model:sort-dir="sortDir"
12
+ v-model:page="page"
13
+ v-model:page-size="pageSize"
14
+ />
15
+ ```
16
+
17
+ `sortBy`, `sortDir`, `page`, and `pageSize` default to internal state when
18
+ omitted. Controlled values may be changed after mount. User actions emit the
19
+ corresponding `update:*` event. Sorting resets the page by default; set
20
+ `:reset-page-on-sort="false"` to preserve the current page. Page-size, data, and
21
+ filter changes continue to reset to page 1.
22
+
23
+ A sortable header can provide a value comparator:
24
+
25
+ ```js
26
+ {
27
+ key: 'version',
28
+ sortable: true,
29
+ comparator: (left, right, leftRow, rightRow) =>
30
+ left.localeCompare(right, undefined, { numeric: true }),
31
+ }
32
+ ```
33
+
34
+ The comparator defines ascending order and receives non-null cell values plus
35
+ their rows. `Table` reverses its result for descending order and keeps null
36
+ values last. `sortRows(rows, sortBy, sortDir, comparator)` accepts the same
37
+ optional comparator.
38
+
39
+ ## Cells and display hooks
40
+
41
+ Named `#cell-{key}` slots and the generic `#cell` slot receive:
42
+
43
+ ```ts
44
+ { row, value, header, rowIndex, sourceIndex }
45
+ ```
46
+
47
+ `rowIndex` is the index on the displayed page. For local data, `sourceIndex` is
48
+ the index in `data`; for remote data it is the index in the current response.
49
+
50
+ The `rowClass` and `rowStyle` callbacks receive
51
+ `{ row, rowIndex, sourceIndex }`. `cellClass`, `cellStyle`, and `cellAttrs`
52
+ receive that context plus `{ value, header }`. Return Vue-compatible class or
53
+ style values. `cellAttrs` is intended for attributes such as `title`, `aria-*`,
54
+ and `data-*`; use the dedicated class/style callbacks for those fields.
55
+ Header `class` continues to apply to both its header and body cells.
56
+
57
+ ## Selection and active cells
58
+
59
+ `selectionClickMode` controls selectable-row clicks:
60
+
61
+ - `row` (default): row clicks toggle selection; Ctrl/Cmd also toggles and Shift
62
+ selects a range on the displayed page.
63
+ - `checkbox`: only checkboxes change selection; row clicks emit `row-click`.
64
+ - `modifier`: Ctrl/Cmd toggles, Shift selects a displayed-page range, and an
65
+ unmodified click emits `row-click`.
66
+ - `extended`: unmodified clicks replace the selection, Ctrl/Cmd toggles, and
67
+ Shift adds a range on the displayed page. This matches desktop-style
68
+ selection while leaving the default behavior unchanged.
69
+
70
+ The additive `row-click` payload is `{ row, index, event }`, where `event` is
71
+ the native click event.
72
+
73
+ `activeCell` is an optional controlled `{ rowKey, columnKey }` value:
74
+
75
+ ```vue
76
+ <Table
77
+ v-model:active-cell="activeCell"
78
+ @cell-click="onCellClick"
79
+ />
80
+ ```
81
+
82
+ Clicking a data cell emits `cell-click` with the enriched cell context and adds
83
+ the visibly outlined `table-active-cell` class. The component ref exposes:
84
+
85
+ - `clearFilters()`
86
+ - `focusActiveCell(options?)` (returns whether a displayed active cell was focused)
87
+ - `clearActiveCell()`
88
+ - `activateCellAt(rowIndex, columnIndex, options?)`
89
+ - `moveActiveCell(rowDelta, columnDelta, options?)`
90
+ - `getActiveCellContext()`
91
+ - the existing `loadDeveloperDefaults()` and `layoutLabel`
92
+
93
+ Cell indices use `displayRows` and `visibleHeaders` and are clamped to the
94
+ displayed grid. Pass `{ wrapColumns: true }` to `moveActiveCell` to move from
95
+ the last column of one row to the first column of the next (or in reverse),
96
+ which is suitable for Tab/Shift+Tab. Pass `{ focus: true, focusOptions? }` to
97
+ activation or movement methods to focus the destination after Vue renders.
98
+ `getActiveCellContext()` returns the cell slot context plus `rowKey`,
99
+ `columnKey`, and `columnIndex`, or `null` when the active cell is not displayed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "picocrank",
3
- "version": "1.32.0",
3
+ "version": "1.33.0",
4
4
  "type": "module",
5
5
  "description": "A set of zero dependency Vue components built on the Femtocrank global CSS theme.",
6
6
  "main": "vite.config.js",
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
2
  import { mount } from '@vue/test-utils'
3
- import { nextTick } from 'vue'
3
+ import { h, nextTick } from 'vue'
4
4
  import Table from './Table.vue'
5
5
 
6
6
  const globalStubs = {
@@ -37,6 +37,7 @@ function mountTable(overrides = {}) {
37
37
  global: {
38
38
  stubs: globalStubs,
39
39
  },
40
+ slots: overrides.slots,
40
41
  })
41
42
  }
42
43
 
@@ -144,6 +145,467 @@ describe('Table multi-select', () => {
144
145
 
145
146
  expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual([])
146
147
  })
148
+
149
+ it('supports checkbox-only selection while preserving row clicks', async () => {
150
+ const onRowClick = vi.fn()
151
+ const wrapper = mountTable({
152
+ props: {
153
+ selectionClickMode: 'checkbox',
154
+ onRowClick,
155
+ },
156
+ })
157
+
158
+ await wrapper.findAll('tbody tr')[0].trigger('click', { ctrlKey: true })
159
+
160
+ expect(wrapper.emitted('update:selectedKeys')).toBeUndefined()
161
+ expect(wrapper.emitted('row-click')?.[0]?.[0].row.name).toBe('Ada')
162
+ expect(wrapper.emitted('row-click')?.[0]?.[0].index).toBe(0)
163
+ expect(wrapper.emitted('row-click')?.[0]?.[0].event).toBeInstanceOf(MouseEvent)
164
+ expect(wrapper.emitted('row-click')?.[0]?.[0].event.ctrlKey).toBe(true)
165
+ expect(onRowClick).toHaveBeenCalledTimes(1)
166
+
167
+ await wrapper.findAll('td.table-select-col input[type="checkbox"]')[0].setValue(true)
168
+
169
+ expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual(['Ada'])
170
+ })
171
+
172
+ it('supports modifier toggles and shift ranges on displayed rows', async () => {
173
+ const wrapper = mountTable({
174
+ props: {
175
+ selectionClickMode: 'modifier',
176
+ onRowClick: () => {},
177
+ },
178
+ })
179
+ const tableRows = wrapper.findAll('tbody tr')
180
+
181
+ await tableRows[1].trigger('click')
182
+ expect(wrapper.emitted('row-click')).toHaveLength(1)
183
+ expect(wrapper.emitted('update:selectedKeys')).toBeUndefined()
184
+
185
+ await tableRows[0].trigger('click', { ctrlKey: true })
186
+ expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual(['Ada'])
187
+
188
+ await tableRows[2].trigger('click', { shiftKey: true })
189
+ expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual([
190
+ 'Ada',
191
+ 'Grace',
192
+ 'Katherine',
193
+ ])
194
+
195
+ await tableRows[1].trigger('click', { metaKey: true })
196
+ expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual([
197
+ 'Ada',
198
+ 'Katherine',
199
+ ])
200
+ expect(wrapper.emitted('row-click')).toHaveLength(1)
201
+ })
202
+
203
+ it('supports extended replace, modifier-toggle, and range semantics', async () => {
204
+ const extendedRows = [
205
+ ...rows,
206
+ { name: 'Margaret', city: 'Arlington' },
207
+ ]
208
+ const wrapper = mountTable({
209
+ props: {
210
+ data: extendedRows,
211
+ showPagination: true,
212
+ pageSize: 3,
213
+ selectionClickMode: 'extended',
214
+ },
215
+ })
216
+ const tableRows = wrapper.findAll('tbody tr')
217
+
218
+ await tableRows[0].trigger('click')
219
+ expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual(['Ada'])
220
+
221
+ await tableRows[1].trigger('click')
222
+ expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual(['Grace'])
223
+
224
+ await tableRows[0].trigger('click', { ctrlKey: true })
225
+ expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual(['Grace', 'Ada'])
226
+
227
+ await tableRows[1].trigger('click', { metaKey: true })
228
+ expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual(['Ada'])
229
+
230
+ await tableRows[2].trigger('click', { shiftKey: true })
231
+ expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual([
232
+ 'Ada',
233
+ 'Grace',
234
+ 'Katherine',
235
+ ])
236
+ expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).not.toContain('Margaret')
237
+ })
238
+ })
239
+
240
+ describe('Table controlled query state', () => {
241
+ it('keeps sort internal by default and emits sort updates', async () => {
242
+ const wrapper = mountTable({
243
+ props: {
244
+ selectable: false,
245
+ },
246
+ })
247
+ const names = () => wrapper.findAll('tbody tr').map((row) => row.find('td').text())
248
+
249
+ await wrapper.find('thead th').trigger('click')
250
+ expect(names()).toEqual(['Ada', 'Grace', 'Katherine'])
251
+ expect(wrapper.emitted('update:sortBy')?.at(-1)?.[0]).toBe('name')
252
+
253
+ await wrapper.find('thead th').trigger('click')
254
+ expect(names()).toEqual(['Katherine', 'Grace', 'Ada'])
255
+ expect(wrapper.emitted('update:sortDir')?.at(-1)?.[0]).toBe('desc')
256
+ })
257
+
258
+ it('accepts controlled sort changes after mount', async () => {
259
+ const wrapper = mountTable({
260
+ props: {
261
+ selectable: false,
262
+ },
263
+ })
264
+ const names = () => wrapper.findAll('tbody tr').map((row) => row.find('td').text())
265
+
266
+ await wrapper.setProps({ sortBy: 'name', sortDir: 'desc' })
267
+ expect(names()).toEqual(['Katherine', 'Grace', 'Ada'])
268
+
269
+ await wrapper.find('thead th').trigger('click')
270
+ expect(wrapper.emitted('update:sortDir')?.at(-1)?.[0]).toBe('asc')
271
+ expect(names()).toEqual(['Katherine', 'Grace', 'Ada'])
272
+
273
+ await wrapper.setProps({ sortDir: 'asc' })
274
+ expect(names()).toEqual(['Ada', 'Grace', 'Katherine'])
275
+ })
276
+
277
+ it('supports controlled page and pageSize updates', async () => {
278
+ const wrapper = mountTable({
279
+ props: {
280
+ selectable: false,
281
+ showPagination: true,
282
+ page: 2,
283
+ pageSize: 1,
284
+ },
285
+ })
286
+ const visibleNames = () => wrapper.findAll('tbody tr').map((row) => row.find('td').text())
287
+ const pagination = wrapper.findComponent({ name: 'Pagination' })
288
+
289
+ expect(visibleNames()).toEqual(['Grace'])
290
+
291
+ pagination.vm.$emit('update:page', 3)
292
+ await nextTick()
293
+ expect(wrapper.emitted('update:page')?.at(-1)?.[0]).toBe(3)
294
+ expect(visibleNames()).toEqual(['Grace'])
295
+
296
+ await wrapper.setProps({ page: 3 })
297
+ expect(visibleNames()).toEqual(['Katherine'])
298
+
299
+ await wrapper.setProps({ page: 1 })
300
+ pagination.vm.$emit('update:pageSize', 2)
301
+ await nextTick()
302
+ expect(wrapper.emitted('update:pageSize')?.at(-1)?.[0]).toBe(2)
303
+ expect(visibleNames()).toEqual(['Ada'])
304
+
305
+ await wrapper.setProps({ pageSize: 2 })
306
+ expect(visibleNames()).toEqual(['Ada', 'Grace'])
307
+ })
308
+
309
+ it('preserves the page on sort when resetPageOnSort is false', async () => {
310
+ const wrapper = mountTable({
311
+ props: {
312
+ selectable: false,
313
+ showPagination: true,
314
+ pageSize: 1,
315
+ resetPageOnSort: false,
316
+ },
317
+ })
318
+ const pagination = wrapper.findComponent({ name: 'Pagination' })
319
+
320
+ pagination.vm.$emit('update:page', 2)
321
+ await nextTick()
322
+ expect(wrapper.find('tbody td').text()).toBe('Grace')
323
+ const pageUpdateCount = wrapper.emitted('update:page')?.length
324
+
325
+ await wrapper.find('thead th').trigger('click')
326
+
327
+ expect(wrapper.find('tbody td').text()).toBe('Grace')
328
+ expect(wrapper.emitted('update:page')).toHaveLength(pageUpdateCount)
329
+ })
330
+
331
+ it('keeps the default sort reset and still resets for size, data, and filters', async () => {
332
+ const wrapper = mountTable({
333
+ props: {
334
+ selectable: false,
335
+ showPagination: true,
336
+ pageSize: 1,
337
+ resetPageOnSort: false,
338
+ },
339
+ })
340
+ const pagination = wrapper.findComponent({ name: 'Pagination' })
341
+ const goToSecondPage = async () => {
342
+ pagination.vm.$emit('update:page', 2)
343
+ await nextTick()
344
+ expect(wrapper.find('tbody td').text()).toBe('Grace')
345
+ }
346
+
347
+ await goToSecondPage()
348
+ pagination.vm.$emit('update:pageSize', 2)
349
+ await wrapper.setProps({ pageSize: 2 })
350
+ expect(wrapper.emitted('update:page')?.at(-1)?.[0]).toBe(1)
351
+
352
+ pagination.vm.$emit('update:pageSize', 1)
353
+ await wrapper.setProps({ pageSize: 1 })
354
+ await goToSecondPage()
355
+ await wrapper.setProps({ data: [...rows] })
356
+ expect(wrapper.emitted('update:page')?.at(-1)?.[0]).toBe(1)
357
+
358
+ await goToSecondPage()
359
+ await wrapper.setProps({
360
+ filterable: true,
361
+ filters: {
362
+ city: [{ type: 'text', operator: 'contains', value: 'London' }],
363
+ },
364
+ })
365
+ expect(wrapper.emitted('update:page')?.at(-1)?.[0]).toBe(1)
366
+
367
+ const defaultWrapper = mountTable({
368
+ props: {
369
+ selectable: false,
370
+ showPagination: true,
371
+ pageSize: 1,
372
+ },
373
+ })
374
+ const defaultPagination = defaultWrapper.findComponent({ name: 'Pagination' })
375
+ defaultPagination.vm.$emit('update:page', 2)
376
+ await nextTick()
377
+
378
+ await defaultWrapper.find('thead th').trigger('click')
379
+
380
+ expect(defaultWrapper.emitted('update:page')?.at(-1)?.[0]).toBe(1)
381
+ expect(defaultWrapper.find('tbody td').text()).toBe('Ada')
382
+ })
383
+ })
384
+
385
+ describe('Table sorting and cell extension hooks', () => {
386
+ it('uses a header comparator for natural sorting', async () => {
387
+ const comparator = vi.fn((left, right) =>
388
+ left.localeCompare(right, undefined, { numeric: true }))
389
+ const versionRows = [
390
+ { version: 'item10' },
391
+ { version: 'item2' },
392
+ { version: 'item1' },
393
+ ]
394
+ const wrapper = mountTable({
395
+ props: {
396
+ headers: [
397
+ { key: 'version', label: 'Version', sortable: true, comparator },
398
+ ],
399
+ data: versionRows,
400
+ rowKey: 'version',
401
+ selectable: false,
402
+ },
403
+ })
404
+
405
+ await wrapper.find('thead th').trigger('click')
406
+
407
+ expect(wrapper.findAll('tbody td').map((cell) => cell.text())).toEqual([
408
+ 'item1',
409
+ 'item2',
410
+ 'item10',
411
+ ])
412
+ expect(comparator).toHaveBeenCalled()
413
+ expect(comparator.mock.calls[0][2]).toEqual(versionRows[1])
414
+ })
415
+
416
+ it('enriches cell slots and applies row and cell hooks', () => {
417
+ const rowClass = vi.fn(({ row }) => row.name === 'Katherine' && 'custom-row')
418
+ const rowStyle = vi.fn(({ rowIndex }) => ({ opacity: rowIndex === 0 ? '0.5' : '1' }))
419
+ const cellClass = vi.fn(({ header }) => header.key === 'city' && 'custom-cell')
420
+ const cellStyle = vi.fn(({ sourceIndex }) => ({ fontWeight: sourceIndex === 2 ? '700' : '400' }))
421
+ const cellAttrs = vi.fn(({ header, sourceIndex }) => (
422
+ header.key === 'city'
423
+ ? { title: 'City value', 'data-source-index': sourceIndex }
424
+ : null
425
+ ))
426
+ const wrapper = mountTable({
427
+ props: {
428
+ headers: [
429
+ { key: 'name', label: 'Name', class: 'legacy-header-class' },
430
+ { key: 'city', label: 'City' },
431
+ ],
432
+ selectable: false,
433
+ sortBy: 'name',
434
+ sortDir: 'desc',
435
+ rowClass,
436
+ rowStyle,
437
+ cellClass,
438
+ cellStyle,
439
+ cellAttrs,
440
+ },
441
+ slots: {
442
+ 'cell-name': ({ row, value, header, rowIndex, sourceIndex }) =>
443
+ h(
444
+ 'span',
445
+ { class: 'slot-payload' },
446
+ `${row.name}:${value}:${header.key}:${rowIndex}:${sourceIndex}`,
447
+ ),
448
+ },
449
+ })
450
+
451
+ const firstRow = wrapper.findAll('tbody tr')[0]
452
+ const cells = firstRow.findAll('td')
453
+
454
+ expect(firstRow.classes()).toContain('custom-row')
455
+ expect(firstRow.attributes('style')).toContain('opacity: 0.5')
456
+ expect(cells[0].classes()).toContain('legacy-header-class')
457
+ expect(cells[0].find('.slot-payload').text()).toBe('Katherine:Katherine:name:0:2')
458
+ expect(cells[1].classes()).toContain('custom-cell')
459
+ expect(cells[1].attributes('style')).toContain('font-weight: 700')
460
+ expect(cells[1].attributes('title')).toBe('City value')
461
+ expect(cells[1].attributes('data-source-index')).toBe('2')
462
+ expect(rowClass).toHaveBeenCalledWith({
463
+ row: rows[2],
464
+ rowIndex: 0,
465
+ sourceIndex: 2,
466
+ })
467
+ expect(cellClass.mock.calls[1][0].header.key).toBe('city')
468
+ })
469
+ })
470
+
471
+ describe('Table active cell and exposed APIs', () => {
472
+ it('tracks clicked cells, emits cell details, focuses, and clears', async () => {
473
+ const wrapper = mountTable({
474
+ props: {
475
+ selectable: false,
476
+ },
477
+ })
478
+ const firstCell = wrapper.find('tbody td')
479
+
480
+ await firstCell.trigger('click')
481
+
482
+ expect(firstCell.classes()).toContain('table-active-cell')
483
+ expect(wrapper.emitted('update:activeCell')?.at(-1)?.[0]).toEqual({
484
+ rowKey: 'Ada',
485
+ columnKey: 'name',
486
+ })
487
+ expect(wrapper.emitted('cell-click')?.at(-1)?.[0]).toMatchObject({
488
+ row: rows[0],
489
+ value: 'Ada',
490
+ header: headers[0],
491
+ rowIndex: 0,
492
+ sourceIndex: 0,
493
+ rowKey: 'Ada',
494
+ columnKey: 'name',
495
+ })
496
+
497
+ expect(wrapper.vm.focusActiveCell()).toBe(true)
498
+ expect(document.activeElement).toBe(firstCell.element)
499
+
500
+ wrapper.vm.clearActiveCell()
501
+ await nextTick()
502
+ expect(wrapper.emitted('update:activeCell')?.at(-1)?.[0]).toBeNull()
503
+ expect(wrapper.find('.table-active-cell').exists()).toBe(false)
504
+ })
505
+
506
+ it('activates and moves cells with clamping and column wrapping', async () => {
507
+ const wrapper = mountTable({
508
+ props: {
509
+ selectable: false,
510
+ },
511
+ })
512
+
513
+ let context = wrapper.vm.activateCellAt(-10, 10)
514
+ await nextTick()
515
+ expect(context).toMatchObject({
516
+ rowKey: 'Ada',
517
+ columnKey: 'city',
518
+ rowIndex: 0,
519
+ columnIndex: 1,
520
+ })
521
+
522
+ context = wrapper.vm.moveActiveCell(0, 1, { wrapColumns: true })
523
+ await nextTick()
524
+ expect(context).toMatchObject({
525
+ rowKey: 'Grace',
526
+ columnKey: 'name',
527
+ rowIndex: 1,
528
+ columnIndex: 0,
529
+ })
530
+
531
+ context = wrapper.vm.moveActiveCell(0, -1, { wrapColumns: true })
532
+ await nextTick()
533
+ expect(context).toMatchObject({
534
+ rowKey: 'Ada',
535
+ columnKey: 'city',
536
+ rowIndex: 0,
537
+ columnIndex: 1,
538
+ })
539
+
540
+ context = wrapper.vm.moveActiveCell(-10, -10)
541
+ await nextTick()
542
+ expect(context).toMatchObject({
543
+ rowKey: 'Ada',
544
+ columnKey: 'name',
545
+ rowIndex: 0,
546
+ columnIndex: 0,
547
+ })
548
+
549
+ wrapper.vm.activateCellAt(99, 99)
550
+ context = wrapper.vm.moveActiveCell(10, 1, { wrapColumns: true })
551
+ await nextTick()
552
+ expect(context).toMatchObject({
553
+ rowKey: 'Katherine',
554
+ columnKey: 'city',
555
+ rowIndex: 2,
556
+ columnIndex: 1,
557
+ })
558
+
559
+ context = wrapper.vm.activateCellAt(1, 1, { focus: true })
560
+ await nextTick()
561
+ expect(wrapper.vm.getActiveCellContext()).toMatchObject(context)
562
+ expect(document.activeElement).toBe(wrapper.find('.table-active-cell').element)
563
+ })
564
+
565
+ it('supports a controlled active cell set after mount', async () => {
566
+ const wrapper = mountTable({
567
+ props: {
568
+ selectable: false,
569
+ activeCell: null,
570
+ },
571
+ })
572
+
573
+ await wrapper.setProps({
574
+ activeCell: { rowKey: 'Grace', columnKey: 'city' },
575
+ })
576
+
577
+ const active = wrapper.find('.table-active-cell')
578
+ expect(active.text()).toBe('New York')
579
+ expect(wrapper.vm.focusActiveCell()).toBe(true)
580
+ expect(document.activeElement).toBe(active.element)
581
+
582
+ wrapper.vm.activateCellAt(0, 0)
583
+ await nextTick()
584
+ expect(wrapper.emitted('update:activeCell')?.at(-1)?.[0]).toEqual({
585
+ rowKey: 'Ada',
586
+ columnKey: 'name',
587
+ })
588
+ expect(wrapper.find('.table-active-cell').text()).toBe('New York')
589
+
590
+ wrapper.vm.clearActiveCell()
591
+ expect(wrapper.emitted('update:activeCell')?.at(-1)?.[0]).toBeNull()
592
+ expect(wrapper.find('.table-active-cell').text()).toBe('New York')
593
+ })
594
+
595
+ it('exposes clearFilters', () => {
596
+ const wrapper = mountTable({
597
+ props: {
598
+ filterable: true,
599
+ filters: {
600
+ name: [{ type: 'text', operator: 'contains', value: 'Ada' }],
601
+ },
602
+ },
603
+ })
604
+
605
+ wrapper.vm.clearFilters()
606
+
607
+ expect(wrapper.emitted('update:filters')?.at(-1)?.[0]).toEqual({})
608
+ })
147
609
  })
148
610
 
149
611
  describe('Table row context menu', () => {
@@ -77,11 +77,15 @@
77
77
  <tr
78
78
  v-for="(row, rowIndex) in displayRows"
79
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
- }"
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)"
85
89
  @click="onRowClick(row, rowIndex, $event)"
86
90
  @contextmenu="onRowContextMenu(row, rowIndex, $event)"
87
91
  >
@@ -96,31 +100,45 @@
96
100
  type="checkbox"
97
101
  :checked="isRowSelected(row, rowIndex)"
98
102
  :aria-label="`Select row ${resolveRowKey(row, rowIndex)}`"
99
- @change="toggleRowSelection(row, rowIndex)"
103
+ @click.stop="rememberSelectionCheckboxClick(row, rowIndex, $event)"
104
+ @change="onSelectionCheckboxChange(row, rowIndex)"
100
105
  >
101
106
  </label>
102
107
  </td>
103
108
  <td
104
109
  v-for="(header, cellIndex) in visibleHeaders"
105
110
  :key="header.key || cellIndex"
111
+ :ref="(element) => registerCellElement(element, row, header, rowIndex)"
112
+ v-bind="resolveCellAttrs(row, header, rowIndex)"
106
113
  :colspan="bodyColumnColspan(cellIndex) || undefined"
107
114
  :class="[
108
115
  cellClasses(header),
109
116
  header.class,
117
+ resolveCellClass(row, header, rowIndex),
118
+ { 'table-active-cell': isActiveCell(row, header, rowIndex) },
110
119
  isLastColumnWithOptions(cellIndex) ? 'table-last-column-with-options' : null,
111
120
  ]"
121
+ :style="resolveCellStyle(row, header, rowIndex)"
122
+ :tabindex="isActiveCell(row, header, rowIndex) ? 0 : -1"
123
+ @click="onCellClick(row, header, rowIndex, $event)"
112
124
  >
113
125
  <slot
114
126
  v-if="slots[`cell-${header.key}`]"
115
127
  :name="`cell-${header.key}`"
116
128
  :row="row"
117
129
  :value="row[header.key]"
130
+ :header="header"
131
+ :row-index="rowIndex"
132
+ :source-index="sourceRowIndex(row, rowIndex)"
118
133
  />
119
134
  <slot
120
135
  v-else-if="slots.cell"
121
136
  name="cell"
122
137
  :row="row"
123
138
  :value="row[header.key]"
139
+ :header="header"
140
+ :row-index="rowIndex"
141
+ :source-index="sourceRowIndex(row, rowIndex)"
124
142
  />
125
143
  <span v-else>
126
144
  {{ row[header.key] }}
@@ -197,7 +215,7 @@
197
215
  </template>
198
216
 
199
217
  <script setup>
200
- import { ref, computed, watch, onMounted, onBeforeUnmount, useSlots, useAttrs } from 'vue'
218
+ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount, useSlots, useAttrs } from 'vue'
201
219
  import { HugeiconsIcon } from '@hugeicons/vue'
202
220
  import { LayoutGridIcon } from '@hugeicons/core-free-icons'
203
221
  import Pagination from './Pagination.vue'
@@ -236,11 +254,6 @@ import {
236
254
 
237
255
  const DEFAULT_PAGE_SIZE = 10
238
256
 
239
- const sortBy = ref(null)
240
- const sortDir = ref('asc')
241
- const page = ref(1)
242
- const pageSize = ref(DEFAULT_PAGE_SIZE)
243
-
244
257
  const props = defineProps({
245
258
  headers: {
246
259
  type: Array,
@@ -250,6 +263,29 @@ const props = defineProps({
250
263
  type: Array,
251
264
  default: () => [],
252
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
+ },
253
289
  showPagination: {
254
290
  type: Boolean,
255
291
  default: true,
@@ -331,6 +367,35 @@ const props = defineProps({
331
367
  type: Array,
332
368
  default: undefined,
333
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
+ },
334
399
  /**
335
400
  * Row right-click menu. Array of items, or a function that returns items
336
401
  * for the click context. Each item may include `action(ctx)` where
@@ -349,8 +414,14 @@ const emit = defineEmits([
349
414
  'query-change',
350
415
  'fetch-error',
351
416
  'row-click',
417
+ 'cell-click',
418
+ 'update:sortBy',
419
+ 'update:sortDir',
420
+ 'update:page',
421
+ 'update:pageSize',
352
422
  'update:selectedKeys',
353
423
  'selection-change',
424
+ 'update:activeCell',
354
425
  'row-context-menu',
355
426
  'row-context-menu-action',
356
427
  'update:columnVisibility',
@@ -366,9 +437,111 @@ const emit = defineEmits([
366
437
  const attrs = useAttrs()
367
438
  const slots = useSlots()
368
439
 
369
- const rowClickable = computed(() => Boolean(attrs.onRowClick) || props.selectable)
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
+ )
370
541
  const internalFilters = ref({})
371
542
  const internalSelectedKeys = ref([])
543
+ const selectionAnchorKey = ref(null)
544
+ const pendingSelectionCheckboxClick = ref(null)
372
545
  const remoteRows = ref([])
373
546
  const remoteTotal = ref(0)
374
547
  const internalLoading = ref(false)
@@ -561,11 +734,20 @@ const filteredItems = computed(() => {
561
734
  return applyFilterQuery(props.data, props.headers, activeFilterQuery.value)
562
735
  })
563
736
 
737
+ const activeSortHeader = computed(() =>
738
+ props.headers.find((header) => header?.key === sortBy.value),
739
+ )
740
+
564
741
  const sortedItems = computed(() => {
565
742
  if (isRemote.value) {
566
743
  return filteredItems.value
567
744
  }
568
- 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
+ )
569
751
  })
570
752
 
571
753
  const displayRows = computed(() => {
@@ -683,6 +865,60 @@ function cellClasses(header) {
683
865
  }
684
866
  }
685
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
+
686
922
  function resolveRowKey(row, index) {
687
923
  if (typeof props.rowKey === 'function') {
688
924
  const key = props.rowKey(row, index)
@@ -702,6 +938,192 @@ function resolveRowKey(row, index) {
702
938
  return index
703
939
  }
704
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
+
705
1127
  const activeSelectedKeys = computed(() => (
706
1128
  props.selectedKeys !== undefined ? props.selectedKeys : internalSelectedKeys.value
707
1129
  ))
@@ -724,6 +1146,7 @@ function setSelectedKeys(nextKeys) {
724
1146
  function toggleRowSelection(row, index) {
725
1147
  const key = resolveRowKey(row, index)
726
1148
  const current = activeSelectedKeys.value
1149
+ selectionAnchorKey.value = key
727
1150
  if (selectedKeySet.value.has(key)) {
728
1151
  setSelectedKeys(current.filter((item) => item !== key))
729
1152
  return
@@ -731,10 +1154,60 @@ function toggleRowSelection(row, index) {
731
1154
  setSelectedKeys([...current, key])
732
1155
  }
733
1156
 
1157
+ function replaceRowSelection(row, index) {
1158
+ const key = resolveRowKey(row, index)
1159
+ selectionAnchorKey.value = key
1160
+ setSelectedKeys([key])
1161
+ }
1162
+
734
1163
  const displayRowKeys = computed(() =>
735
1164
  displayRows.value.map((row, index) => resolveRowKey(row, index)),
736
1165
  )
737
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
+
738
1211
  const allDisplayRowsSelected = computed(() => {
739
1212
  const keys = displayRowKeys.value
740
1213
  return keys.length > 0 && keys.every((key) => selectedKeySet.value.has(key))
@@ -756,15 +1229,18 @@ function toggleSelectAllDisplayRows() {
756
1229
  if (allDisplayRowsSelected.value) {
757
1230
  const pageKeySet = new Set(pageKeys)
758
1231
  setSelectedKeys(activeSelectedKeys.value.filter((key) => !pageKeySet.has(key)))
1232
+ selectionAnchorKey.value = null
759
1233
  return
760
1234
  }
761
1235
 
762
1236
  setSelectedKeys([...activeSelectedKeys.value, ...pageKeys])
1237
+ selectionAnchorKey.value = pageKeys.at(-1) ?? null
763
1238
  }
764
1239
 
765
1240
  watch(() => props.selectable, (enabled) => {
766
1241
  if (!enabled) {
767
1242
  setSelectedKeys([])
1243
+ selectionAnchorKey.value = null
768
1244
  }
769
1245
  })
770
1246
 
@@ -822,11 +1298,49 @@ function onRowClick(row, displayIndex, event) {
822
1298
  if (event.target.closest('a, button, input, select, textarea, [data-row-click-ignore]')) {
823
1299
  return
824
1300
  }
1301
+
825
1302
  if (props.selectable) {
826
- toggleRowSelection(row, displayIndex)
827
- return
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
+ }
828
1337
  }
829
- emit('row-click', { row, index: filteredRowIndex(displayIndex) })
1338
+
1339
+ emit('row-click', {
1340
+ row,
1341
+ index: filteredRowIndex(displayIndex),
1342
+ event,
1343
+ })
830
1344
  }
831
1345
 
832
1346
  const rowContextMenuOpen = ref(false)
@@ -1550,7 +2064,7 @@ function scheduleFetchRows() {
1550
2064
  }
1551
2065
 
1552
2066
  watch(
1553
- () => [pageSize.value, sortBy.value, sortDir.value],
2067
+ pageSize,
1554
2068
  () => {
1555
2069
  page.value = 1
1556
2070
  if (isRemote.value) {
@@ -1560,6 +2074,19 @@ watch(
1560
2074
  },
1561
2075
  )
1562
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
+
1563
2090
  watch(page, () => {
1564
2091
  if (isRemote.value) {
1565
2092
  scheduleFetchRows()
@@ -1567,6 +2094,40 @@ watch(page, () => {
1567
2094
  emitQueryChange()
1568
2095
  })
1569
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
+
1570
2131
  watch(
1571
2132
  activeFilters,
1572
2133
  () => {
@@ -1674,11 +2235,15 @@ watch(activeLayoutLabel, (label) => {
1674
2235
  emitLayoutLabelChange(label)
1675
2236
  }, { immediate: true })
1676
2237
 
1677
- watch(sortedItems, () => {
1678
- if (!isRemote.value) {
1679
- page.value = 1
1680
- }
1681
- })
2238
+ watch(
2239
+ () => props.data,
2240
+ () => {
2241
+ if (!isRemote.value) {
2242
+ page.value = 1
2243
+ }
2244
+ },
2245
+ { deep: true },
2246
+ )
1682
2247
 
1683
2248
  onMounted(async () => {
1684
2249
  if (props.loadSavedLayout) {
@@ -1721,6 +2286,12 @@ onBeforeUnmount(() => {
1721
2286
  defineExpose({
1722
2287
  layoutLabel: activeLayoutLabel,
1723
2288
  loadDeveloperDefaults,
2289
+ clearFilters: clearAllFilters,
2290
+ focusActiveCell,
2291
+ clearActiveCell,
2292
+ activateCellAt,
2293
+ moveActiveCell,
2294
+ getActiveCellContext,
1724
2295
  })
1725
2296
  </script>
1726
2297
 
@@ -1792,6 +2363,16 @@ tbody tr.row-clickable {
1792
2363
  cursor: pointer;
1793
2364
  }
1794
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
+
1795
2376
  tbody tr.row-selected > td {
1796
2377
  background-color: var(--control-checked-bg);
1797
2378
  color: var(--control-checked-fg);
@@ -387,7 +387,7 @@ export function cloneColumnFilterEntries(value, header = null, rows = []) {
387
387
  return normalizeColumnFilterEntries(value, header, rows).map((entry) => ({ ...entry }))
388
388
  }
389
389
 
390
- export function sortRows(rows, sortBy, sortDir) {
390
+ export function sortRows(rows, sortBy, sortDir, comparator = null) {
391
391
  if (!sortBy) {
392
392
  return [...rows]
393
393
  }
@@ -399,6 +399,11 @@ export function sortRows(rows, sortBy, sortDir) {
399
399
  if (av === bv) return 0
400
400
  if (av === null || av === undefined) return 1
401
401
  if (bv === null || bv === undefined) return -1
402
+ if (typeof comparator === 'function') {
403
+ const result = Number(comparator(av, bv, a, b))
404
+ if (!Number.isFinite(result) || result === 0) return 0
405
+ return sortDir === 'asc' ? result : -result
406
+ }
402
407
  if (typeof av === 'string' && typeof bv === 'string') {
403
408
  return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av)
404
409
  }