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 +99 -0
- package/package.json +1 -1
- package/vue/components/Table.spec.js +463 -1
- package/vue/components/Table.vue +604 -23
- package/vue/composables/tableFilters.js +6 -1
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
|
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', () => {
|
package/vue/components/Table.vue
CHANGED
|
@@ -77,11 +77,15 @@
|
|
|
77
77
|
<tr
|
|
78
78
|
v-for="(row, rowIndex) in displayRows"
|
|
79
79
|
:key="resolveRowKey(row, rowIndex)"
|
|
80
|
-
:class="
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
827
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1678
|
-
|
|
1679
|
-
|
|
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
|
}
|