picocrank 1.32.0 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/docs/filter-query-v1.md +5 -1
  2. package/docs/table.md +99 -0
  3. package/package.json +1 -1
  4. package/router.js +33 -0
  5. package/styles/picocrank-extensions.css +23 -2
  6. package/vue/components/Accordion.spec.js +48 -0
  7. package/vue/components/Accordion.vue +46 -0
  8. package/vue/components/AccordionItem.vue +103 -0
  9. package/vue/components/FieldValue.spec.js +88 -0
  10. package/vue/components/FieldValue.vue +222 -0
  11. package/vue/components/FormField.spec.js +1 -0
  12. package/vue/components/FormField.vue +38 -10
  13. package/vue/components/Header.vue +24 -2
  14. package/vue/components/NotificationBlock.vue +25 -0
  15. package/vue/components/ReorderList.spec.js +318 -0
  16. package/vue/components/ReorderList.vue +614 -0
  17. package/vue/components/Table.spec.js +490 -1
  18. package/vue/components/Table.vue +1038 -111
  19. package/vue/components/TableColumnFilterPopover.vue +63 -19
  20. package/vue/components/TableColumnOptionsPopover.vue +248 -273
  21. package/vue/components/TopBar.vue +6 -2
  22. package/vue/components/accordionContext.js +1 -0
  23. package/vue/composables/tableColumnPresets.js +33 -0
  24. package/vue/composables/tableFilters.js +6 -1
  25. package/vue/composables/tableGroupBy.js +173 -0
  26. package/vue/composables/tableGroupBy.spec.js +69 -0
  27. package/vue/composables/tableViewSettings.js +34 -0
  28. package/vue/composables/tableViewSettings.spec.js +21 -0
  29. package/vue/composables/useHeaderTopBarFit.js +91 -0
  30. package/vue/composables/useResponsiveNav.js +5 -3
  31. package/vue/data/navSectionHubs.js +16 -1
  32. package/vue/examples/AccordionExample.vue +33 -0
  33. package/vue/examples/FieldValueExample.vue +143 -0
  34. package/vue/examples/FormExample.vue +11 -6
  35. package/vue/examples/ReorderListExample.vue +236 -0
  36. package/vue/examples/TableExample.vue +5 -1
  37. package/vue/examples/TableRemoteExample.vue +28 -4
@@ -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', () => {
@@ -227,6 +689,33 @@ describe('Table row context menu', () => {
227
689
  expect(action.mock.calls[0][0].keys).toEqual(['Katherine'])
228
690
  })
229
691
 
692
+ it('renders group header rows when groupBy is set', async () => {
693
+ const wrapper = mountTable({
694
+ props: {
695
+ selectable: false,
696
+ groupBy: 'city',
697
+ },
698
+ })
699
+
700
+ expect(wrapper.findAll('tbody tr.table-group-row')).toHaveLength(3)
701
+ expect(wrapper.find('.table-group-label').text()).toBe('London')
702
+ expect(wrapper.text()).toContain('(1)')
703
+ })
704
+
705
+ it('includes groupBy in query-change payloads', async () => {
706
+ const wrapper = mountTable({
707
+ props: {
708
+ selectable: false,
709
+ groupBy: 'city',
710
+ },
711
+ })
712
+
713
+ await nextTick()
714
+
715
+ const latestQuery = wrapper.emitted('query-change')?.at(-1)?.[0]
716
+ expect(latestQuery?.groupBy).toBe('city')
717
+ })
718
+
230
719
  it('keeps a hover class on target rows while the menu is open', async () => {
231
720
  const wrapper = mountTable({
232
721
  props: {