picocrank 1.31.1 → 1.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/table.md +99 -0
- package/package.json +1 -1
- package/router.js +11 -0
- package/styles/picocrank-extensions.css +65 -0
- package/vue/components/Calendar.vue +41 -63
- package/vue/components/LoadingArea.spec.js +72 -0
- package/vue/components/LoadingArea.vue +176 -0
- package/vue/components/Table.spec.js +715 -0
- package/vue/components/Table.vue +974 -16
- package/vue/components/TableColumnFilterPopover.vue +56 -8
- package/vue/components/TableRowContextMenu.vue +267 -0
- package/vue/composables/tableFilters.js +6 -1
- package/vue/data/navSectionHubs.js +5 -0
- package/vue/examples/LoadingAreaExample.vue +175 -0
- package/vue/examples/TableExample.vue +156 -4
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import { h, nextTick } from 'vue'
|
|
4
|
+
import Table from './Table.vue'
|
|
5
|
+
|
|
6
|
+
const globalStubs = {
|
|
7
|
+
HugeiconsIcon: true,
|
|
8
|
+
Pagination: true,
|
|
9
|
+
TableColumnFilterPopover: true,
|
|
10
|
+
TableColumnOptionsPopover: true,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const headers = [
|
|
14
|
+
{ key: 'name', label: 'Name', sortable: true },
|
|
15
|
+
{ key: 'city', label: 'City' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const rows = [
|
|
19
|
+
{ name: 'Ada', city: 'London' },
|
|
20
|
+
{ name: 'Grace', city: 'New York' },
|
|
21
|
+
{ name: 'Katherine', city: 'Newport News' },
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
function mountTable(overrides = {}) {
|
|
25
|
+
return mount(Table, {
|
|
26
|
+
attachTo: document.body,
|
|
27
|
+
props: {
|
|
28
|
+
headers,
|
|
29
|
+
data: rows,
|
|
30
|
+
rowKey: 'name',
|
|
31
|
+
showPagination: false,
|
|
32
|
+
filterable: false,
|
|
33
|
+
columnOptions: false,
|
|
34
|
+
selectable: true,
|
|
35
|
+
...overrides.props,
|
|
36
|
+
},
|
|
37
|
+
global: {
|
|
38
|
+
stubs: globalStubs,
|
|
39
|
+
},
|
|
40
|
+
slots: overrides.slots,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('Table multi-select', () => {
|
|
45
|
+
it('renders a select column with header and row checkboxes when selectable', () => {
|
|
46
|
+
const wrapper = mountTable()
|
|
47
|
+
|
|
48
|
+
expect(wrapper.findAll('th.table-select-col')).toHaveLength(1)
|
|
49
|
+
expect(wrapper.findAll('td.table-select-col input[type="checkbox"]')).toHaveLength(rows.length)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not render select checkboxes when selectable is false', () => {
|
|
53
|
+
const wrapper = mountTable({
|
|
54
|
+
props: {
|
|
55
|
+
selectable: false,
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
expect(wrapper.find('th.table-select-col').exists()).toBe(false)
|
|
60
|
+
expect(wrapper.find('td.table-select-col').exists()).toBe(false)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('toggles a row via its checkbox and emits selection updates', async () => {
|
|
64
|
+
const wrapper = mountTable()
|
|
65
|
+
|
|
66
|
+
const firstCheckbox = wrapper.findAll('td.table-select-col input[type="checkbox"]')[0]
|
|
67
|
+
await firstCheckbox.setValue(true)
|
|
68
|
+
await nextTick()
|
|
69
|
+
|
|
70
|
+
expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual(['Ada'])
|
|
71
|
+
expect(wrapper.emitted('selection-change')?.at(-1)?.[0]).toEqual(['Ada'])
|
|
72
|
+
expect(wrapper.find('tbody tr.row-selected').exists()).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('toggles selection when a row is clicked', async () => {
|
|
76
|
+
const wrapper = mountTable()
|
|
77
|
+
|
|
78
|
+
await wrapper.findAll('tbody tr')[1].trigger('click')
|
|
79
|
+
await nextTick()
|
|
80
|
+
|
|
81
|
+
expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual(['Grace'])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('selects and clears the current page via the header checkbox', async () => {
|
|
85
|
+
const wrapper = mountTable()
|
|
86
|
+
const headerCheckbox = wrapper.find('th.table-select-col input[type="checkbox"]')
|
|
87
|
+
|
|
88
|
+
await headerCheckbox.setValue(true)
|
|
89
|
+
await nextTick()
|
|
90
|
+
|
|
91
|
+
expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual([
|
|
92
|
+
'Ada',
|
|
93
|
+
'Grace',
|
|
94
|
+
'Katherine',
|
|
95
|
+
])
|
|
96
|
+
|
|
97
|
+
await headerCheckbox.setValue(false)
|
|
98
|
+
await nextTick()
|
|
99
|
+
|
|
100
|
+
expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual([])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('supports controlled selectedKeys via v-model', async () => {
|
|
104
|
+
const wrapper = mountTable({
|
|
105
|
+
props: {
|
|
106
|
+
selectedKeys: ['Grace'],
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
await nextTick()
|
|
111
|
+
|
|
112
|
+
const checkboxes = wrapper.findAll('td.table-select-col input[type="checkbox"]')
|
|
113
|
+
expect(checkboxes[1].element.checked).toBe(true)
|
|
114
|
+
expect(wrapper.findAll('tbody tr.row-selected')).toHaveLength(1)
|
|
115
|
+
|
|
116
|
+
await wrapper.setProps({ selectedKeys: ['Ada', 'Katherine'] })
|
|
117
|
+
await nextTick()
|
|
118
|
+
|
|
119
|
+
expect(wrapper.findAll('tbody tr.row-selected')).toHaveLength(2)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('does not emit row-click while selectable; row clicks select instead', async () => {
|
|
123
|
+
const wrapper = mountTable({
|
|
124
|
+
props: {
|
|
125
|
+
onRowClick: () => {},
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
await wrapper.findAll('tbody tr')[0].trigger('click')
|
|
130
|
+
await nextTick()
|
|
131
|
+
|
|
132
|
+
expect(wrapper.emitted('row-click')).toBeUndefined()
|
|
133
|
+
expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual(['Ada'])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('clears selection when selectable is turned off', async () => {
|
|
137
|
+
const wrapper = mountTable()
|
|
138
|
+
|
|
139
|
+
await wrapper.findAll('tbody tr')[0].trigger('click')
|
|
140
|
+
await nextTick()
|
|
141
|
+
expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual(['Ada'])
|
|
142
|
+
|
|
143
|
+
await wrapper.setProps({ selectable: false })
|
|
144
|
+
await nextTick()
|
|
145
|
+
|
|
146
|
+
expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual([])
|
|
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
|
+
})
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
describe('Table row context menu', () => {
|
|
612
|
+
it('opens a menu and runs the item action for the clicked row', async () => {
|
|
613
|
+
const action = vi.fn()
|
|
614
|
+
const wrapper = mountTable({
|
|
615
|
+
props: {
|
|
616
|
+
selectable: false,
|
|
617
|
+
rowContextMenu: [
|
|
618
|
+
{ id: 'export', label: 'Export', action },
|
|
619
|
+
],
|
|
620
|
+
},
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
await wrapper.findAll('tbody tr')[0].trigger('contextmenu', {
|
|
624
|
+
clientX: 40,
|
|
625
|
+
clientY: 60,
|
|
626
|
+
})
|
|
627
|
+
await nextTick()
|
|
628
|
+
|
|
629
|
+
const menu = document.body.querySelector('.table-row-context-menu')
|
|
630
|
+
expect(menu).toBeTruthy()
|
|
631
|
+
|
|
632
|
+
menu.querySelector('[role="menuitem"]').click()
|
|
633
|
+
await nextTick()
|
|
634
|
+
|
|
635
|
+
expect(action).toHaveBeenCalledTimes(1)
|
|
636
|
+
expect(action.mock.calls[0][0].keys).toEqual(['Ada'])
|
|
637
|
+
expect(action.mock.calls[0][0].rows.map((row) => row.name)).toEqual(['Ada'])
|
|
638
|
+
expect(wrapper.emitted('row-context-menu-action')?.at(-1)?.[0].keys).toEqual(['Ada'])
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it('runs the action against all selected rows when right-clicking inside the selection', async () => {
|
|
642
|
+
const action = vi.fn()
|
|
643
|
+
const wrapper = mountTable({
|
|
644
|
+
props: {
|
|
645
|
+
selectable: true,
|
|
646
|
+
selectedKeys: ['Ada', 'Grace'],
|
|
647
|
+
rowContextMenu: [
|
|
648
|
+
{ id: 'export', label: 'Export', action },
|
|
649
|
+
],
|
|
650
|
+
},
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
await wrapper.findAll('tbody tr')[1].trigger('contextmenu', {
|
|
654
|
+
clientX: 40,
|
|
655
|
+
clientY: 60,
|
|
656
|
+
})
|
|
657
|
+
await nextTick()
|
|
658
|
+
|
|
659
|
+
document.body.querySelector('.table-row-context-menu [role="menuitem"]').click()
|
|
660
|
+
await nextTick()
|
|
661
|
+
|
|
662
|
+
expect(action.mock.calls[0][0].keys).toEqual(['Ada', 'Grace'])
|
|
663
|
+
expect(action.mock.calls[0][0].rows.map((row) => row.name)).toEqual(['Ada', 'Grace'])
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
it('replaces selection with the clicked row when right-clicking outside the selection', async () => {
|
|
667
|
+
const action = vi.fn()
|
|
668
|
+
const wrapper = mountTable({
|
|
669
|
+
props: {
|
|
670
|
+
selectable: true,
|
|
671
|
+
selectedKeys: ['Ada', 'Grace'],
|
|
672
|
+
rowContextMenu: [
|
|
673
|
+
{ id: 'export', label: 'Export', action },
|
|
674
|
+
],
|
|
675
|
+
},
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
await wrapper.findAll('tbody tr')[2].trigger('contextmenu', {
|
|
679
|
+
clientX: 40,
|
|
680
|
+
clientY: 60,
|
|
681
|
+
})
|
|
682
|
+
await nextTick()
|
|
683
|
+
|
|
684
|
+
expect(wrapper.emitted('update:selectedKeys')?.at(-1)?.[0]).toEqual(['Katherine'])
|
|
685
|
+
|
|
686
|
+
document.body.querySelector('.table-row-context-menu [role="menuitem"]').click()
|
|
687
|
+
await nextTick()
|
|
688
|
+
|
|
689
|
+
expect(action.mock.calls[0][0].keys).toEqual(['Katherine'])
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
it('keeps a hover class on target rows while the menu is open', async () => {
|
|
693
|
+
const wrapper = mountTable({
|
|
694
|
+
props: {
|
|
695
|
+
selectable: false,
|
|
696
|
+
rowContextMenu: [
|
|
697
|
+
{ id: 'export', label: 'Export', action: () => {} },
|
|
698
|
+
],
|
|
699
|
+
},
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
await wrapper.findAll('tbody tr')[0].trigger('contextmenu', {
|
|
703
|
+
clientX: 40,
|
|
704
|
+
clientY: 60,
|
|
705
|
+
})
|
|
706
|
+
await nextTick()
|
|
707
|
+
|
|
708
|
+
expect(wrapper.findAll('tbody tr')[0].classes()).toContain('row-context-menu-active')
|
|
709
|
+
|
|
710
|
+
document.body.querySelector('.table-row-context-menu [role="menuitem"]').click()
|
|
711
|
+
await nextTick()
|
|
712
|
+
|
|
713
|
+
expect(wrapper.findAll('tbody tr')[0].classes()).not.toContain('row-context-menu-active')
|
|
714
|
+
})
|
|
715
|
+
})
|