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
@@ -0,0 +1,614 @@
1
+ <template>
2
+ <p v-if="items.length === 0" class="reorder-list-empty">
3
+ <slot name="empty">Nothing to reorder.</slot>
4
+ </p>
5
+ <ul
6
+ v-else
7
+ ref="rootRef"
8
+ class="reorder-list"
9
+ data-reorder-list-root
10
+ :class="{ 'is-disabled': disabled }"
11
+ :aria-label="ariaLabel || undefined"
12
+ :aria-labelledby="ariaLabelledby || undefined"
13
+ :aria-disabled="disabled ? 'true' : undefined"
14
+ @dragover.prevent="onListDragOver"
15
+ @drop.prevent="onListDrop"
16
+ @dragleave="onListDragLeave"
17
+ >
18
+ <li
19
+ v-if="dropGap !== null"
20
+ class="reorder-list-separator"
21
+ :style="{ top: `${separatorTop}px` }"
22
+ :data-drop-gap="dropGap"
23
+ aria-hidden="true"
24
+ />
25
+ <li
26
+ v-for="(item, index) in items"
27
+ :key="keyOf(item, index)"
28
+ class="reorder-list-row"
29
+ :class="{
30
+ 'is-dragging': draggingKey === keyOf(item, index),
31
+ 'is-drag-whole-row': dragWholeRow && !disabled && !isItemReorderDisabled(item, index),
32
+ 'is-reorder-disabled': isItemReorderDisabled(item, index),
33
+ }"
34
+ :data-reorder-key="keyOf(item, index)"
35
+ :draggable="dragWholeRow && !disabled && !isItemReorderDisabled(item, index) ? 'true' : 'false'"
36
+ :tabindex="rowTabIndex"
37
+ :aria-label="rowDragAriaLabel(item, index)"
38
+ @dragstart="onRowDragStart(item, index, $event)"
39
+ @dragend="onDragEnd"
40
+ @keydown="onRowKeydown(index, $event)"
41
+ >
42
+ <div v-if="showMoveButtons || showHandleGrip" class="reorder-list-controls">
43
+ <div
44
+ v-if="showMoveButtons"
45
+ class="reorder-list-move"
46
+ role="group"
47
+ :aria-label="`Move ${labelOf(item, index)}`"
48
+ >
49
+ <button
50
+ type="button"
51
+ class="neutral reorder-list-move-button"
52
+ :disabled="!canMove(index, -1)"
53
+ :aria-label="`Move ${labelOf(item, index)} up`"
54
+ @click="moveBy(index, -1, true)"
55
+ >
56
+ <HugeiconsIcon
57
+ :icon="ArrowUp01Icon"
58
+ width="0.9em"
59
+ height="0.9em"
60
+ :strokeWidth="2"
61
+ aria-hidden="true"
62
+ />
63
+ </button>
64
+ <button
65
+ type="button"
66
+ class="neutral reorder-list-move-button"
67
+ :disabled="!canMove(index, 1)"
68
+ :aria-label="`Move ${labelOf(item, index)} down`"
69
+ @click="moveBy(index, 1, true)"
70
+ >
71
+ <HugeiconsIcon
72
+ :icon="ArrowDown01Icon"
73
+ width="0.9em"
74
+ height="0.9em"
75
+ :strokeWidth="2"
76
+ aria-hidden="true"
77
+ />
78
+ </button>
79
+ </div>
80
+
81
+ <span
82
+ v-if="showHandleGrip"
83
+ class="reorder-list-handle"
84
+ :class="{ 'is-disabled': disabled || isItemReorderDisabled(item, index) }"
85
+ :draggable="disabled || isItemReorderDisabled(item, index) ? 'false' : 'true'"
86
+ :aria-hidden="showMoveButtons ? 'true' : undefined"
87
+ :tabindex="handleTabIndex"
88
+ :aria-label="showMoveButtons ? undefined : `Reorder ${labelOf(item, index)}. Arrow up or arrow down to move.`"
89
+ @dragstart.stop="onHandleDragStart(item, index, $event)"
90
+ @dragend="onDragEnd"
91
+ @keydown="onHandleKeydown(index, $event)"
92
+ >
93
+ <HugeiconsIcon
94
+ :icon="DragDropVerticalIcon"
95
+ width="0.95em"
96
+ height="0.95em"
97
+ :strokeWidth="2"
98
+ />
99
+ </span>
100
+ </div>
101
+
102
+ <div class="reorder-list-body">
103
+ <slot name="item" :item="item" :index="index">
104
+ {{ labelOf(item, index) }}
105
+ </slot>
106
+ </div>
107
+ </li>
108
+ </ul>
109
+ </template>
110
+
111
+ <script setup>
112
+ /**
113
+ * Reordering uses native HTML5 drag-and-drop (`draggable`, dragstart/dragover/drop).
114
+ * Drop position is derived from pointer Y during dragover; handle and whole-row modes
115
+ * both use the same DnD pipeline (not a custom pointer-drag implementation).
116
+ */
117
+ import { computed, nextTick, ref, useId, watch } from 'vue'
118
+ import { HugeiconsIcon } from '@hugeicons/vue'
119
+ import {
120
+ ArrowDown01Icon,
121
+ ArrowUp01Icon,
122
+ DragDropVerticalIcon,
123
+ } from '@hugeicons/core-free-icons'
124
+
125
+ const props = defineProps({
126
+ /**
127
+ * Ordered rows. Objects or primitive values. Reorder emits a new array.
128
+ */
129
+ modelValue: {
130
+ type: Array,
131
+ default: () => [],
132
+ },
133
+ /**
134
+ * Property name used as the row identity, or a function (item, index) => key.
135
+ * Primitives use their own value when the property is missing.
136
+ */
137
+ itemKey: {
138
+ type: [String, Function],
139
+ default: 'id',
140
+ },
141
+ /**
142
+ * Accessible name for move buttons. Defaults to label, name, title, or "row N".
143
+ */
144
+ itemLabel: {
145
+ type: Function,
146
+ default: null,
147
+ },
148
+ /**
149
+ * Show up/down buttons. Off by default; drag handles are the primary control.
150
+ * When hidden, arrow keys on the handle reorder rows.
151
+ */
152
+ showMoveButtons: {
153
+ type: Boolean,
154
+ default: false,
155
+ },
156
+ /**
157
+ * Show the grip used to drag a row. Arrow keys on the handle reorder when move buttons are hidden.
158
+ */
159
+ showDragHandles: {
160
+ type: Boolean,
161
+ default: true,
162
+ },
163
+ /**
164
+ * HTML5 drag from anywhere on the row (except buttons and form controls in the slot).
165
+ * Hides the grip handle; move buttons still apply when enabled.
166
+ */
167
+ dragWholeRow: {
168
+ type: Boolean,
169
+ default: false,
170
+ },
171
+ disabled: {
172
+ type: Boolean,
173
+ default: false,
174
+ },
175
+ /**
176
+ * When true for a row, that row cannot be dragged and keeps its handle disabled.
177
+ */
178
+ isItemReorderDisabled: {
179
+ type: Function,
180
+ default: null,
181
+ },
182
+ ariaLabel: {
183
+ type: String,
184
+ default: '',
185
+ },
186
+ ariaLabelledby: {
187
+ type: String,
188
+ default: '',
189
+ },
190
+ })
191
+
192
+ const emit = defineEmits(['update:modelValue', 'reorder'])
193
+
194
+ const rootRef = ref(null)
195
+ const listInstanceId = useId()
196
+ const draggingKey = ref(null)
197
+ /** Visual gap index: 0 is before the first row, length is after the last. */
198
+ const dropGap = ref(null)
199
+ const separatorTop = ref(0)
200
+
201
+ const items = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []))
202
+
203
+ const showHandleGrip = computed(() => props.showDragHandles && !props.dragWholeRow)
204
+
205
+ const handleTabIndex = computed(() => {
206
+ if (!showHandleGrip.value || props.showMoveButtons || props.disabled) {
207
+ return undefined
208
+ }
209
+ return 0
210
+ })
211
+
212
+ const rowTabIndex = computed(() => {
213
+ if (!props.dragWholeRow || props.showMoveButtons || props.disabled) {
214
+ return undefined
215
+ }
216
+ return 0
217
+ })
218
+
219
+ function rowDragAriaLabel(item, index) {
220
+ if (!props.dragWholeRow || props.showMoveButtons) {
221
+ return undefined
222
+ }
223
+ return `Reorder ${labelOf(item, index)}. Arrow up or arrow down to move.`
224
+ }
225
+
226
+ function keyOf(item, index) {
227
+ if (typeof props.itemKey === 'function') {
228
+ return String(props.itemKey(item, index))
229
+ }
230
+ if (item != null && typeof item === 'object' && props.itemKey in item && item[props.itemKey] != null) {
231
+ return String(item[props.itemKey])
232
+ }
233
+ return String(item)
234
+ }
235
+
236
+ function labelOf(item, index) {
237
+ if (typeof props.itemLabel === 'function') {
238
+ const custom = props.itemLabel(item, index)
239
+ if (custom != null && custom !== '') {
240
+ return String(custom)
241
+ }
242
+ }
243
+ if (item != null && typeof item === 'object') {
244
+ const text = item.label ?? item.name ?? item.title
245
+ if (text != null && text !== '') {
246
+ return String(text)
247
+ }
248
+ }
249
+ if (item != null && typeof item !== 'object') {
250
+ return String(item)
251
+ }
252
+ return `row ${index + 1}`
253
+ }
254
+
255
+ function indexOfKey(key) {
256
+ return items.value.findIndex((item, index) => keyOf(item, index) === key)
257
+ }
258
+
259
+ function isItemReorderDisabled(item, index) {
260
+ return typeof props.isItemReorderDisabled === 'function'
261
+ && props.isItemReorderDisabled(item, index)
262
+ }
263
+
264
+ function canMove(index, direction) {
265
+ if (props.disabled || isItemReorderDisabled(items.value[index], index)) {
266
+ return false
267
+ }
268
+ const target = index + direction
269
+ return target >= 0 && target < items.value.length
270
+ }
271
+
272
+ function reorder(fromIndex, toIndex) {
273
+ if (props.disabled) {
274
+ return
275
+ }
276
+ if (fromIndex === toIndex) {
277
+ return
278
+ }
279
+ if (fromIndex < 0 || toIndex < 0 || fromIndex >= items.value.length || toIndex >= items.value.length) {
280
+ return
281
+ }
282
+
283
+ const next = items.value.slice()
284
+ const [moved] = next.splice(fromIndex, 1)
285
+ next.splice(toIndex, 0, moved)
286
+ emit('update:modelValue', next)
287
+ emit('reorder', {
288
+ items: next,
289
+ item: moved,
290
+ fromIndex,
291
+ toIndex,
292
+ })
293
+ }
294
+
295
+ async function moveBy(index, direction, restoreFocus = false) {
296
+ if (!canMove(index, direction)) {
297
+ return
298
+ }
299
+ const key = keyOf(items.value[index], index)
300
+ reorder(index, index + direction)
301
+ if (!restoreFocus) {
302
+ return
303
+ }
304
+ await nextTick()
305
+ const selectorKey = typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
306
+ ? CSS.escape(key)
307
+ : key.replace(/"/g, '\\"')
308
+ const row = rootRef.value?.querySelector(`[data-reorder-key="${selectorKey}"]`)
309
+ const preferredLabel = direction < 0 ? ' up' : ' down'
310
+ const alternateLabel = direction < 0 ? ' down' : ' up'
311
+ const buttons = [...(row?.querySelectorAll('button') ?? [])]
312
+ const preferred = buttons.find((button) => button.getAttribute('aria-label')?.endsWith(preferredLabel) && !button.disabled)
313
+ const alternate = buttons.find((button) => button.getAttribute('aria-label')?.endsWith(alternateLabel) && !button.disabled)
314
+ const handle = row?.querySelector('.reorder-list-handle')
315
+ ;(preferred ?? alternate ?? handle)?.focus()
316
+ }
317
+
318
+ function onHandleKeydown(index, event) {
319
+ if (!showHandleGrip.value || props.showMoveButtons || props.disabled) {
320
+ return
321
+ }
322
+ onReorderKeydown(index, event)
323
+ }
324
+
325
+ function onRowKeydown(index, event) {
326
+ if (!props.dragWholeRow || props.showMoveButtons || props.disabled) {
327
+ return
328
+ }
329
+ onReorderKeydown(index, event)
330
+ }
331
+
332
+ function onReorderKeydown(index, event) {
333
+ if (event.key === 'ArrowUp') {
334
+ event.preventDefault()
335
+ moveBy(index, -1, true)
336
+ } else if (event.key === 'ArrowDown') {
337
+ event.preventDefault()
338
+ moveBy(index, 1, true)
339
+ }
340
+ }
341
+
342
+ const DRAG_CANCEL_SELECTOR = 'button, a, input, select, textarea, label'
343
+
344
+ function dragStartBlocked(event) {
345
+ const target = event.target
346
+ if (!(target instanceof Element)) {
347
+ return false
348
+ }
349
+ return Boolean(target.closest(DRAG_CANCEL_SELECTOR))
350
+ }
351
+
352
+ function beginDrag(item, index, event) {
353
+ if (props.disabled || isItemReorderDisabled(item, index)) {
354
+ event.preventDefault()
355
+ return
356
+ }
357
+ draggingKey.value = keyOf(item, index)
358
+ dropGap.value = null
359
+ if (event.dataTransfer) {
360
+ event.dataTransfer.effectAllowed = 'move'
361
+ event.dataTransfer.setData('text/plain', draggingKey.value)
362
+ event.dataTransfer.setData('application/x-picocrank-reorder-list', listInstanceId)
363
+ }
364
+ event.stopPropagation()
365
+ }
366
+
367
+ function onRowDragStart(item, index, event) {
368
+ if (!props.dragWholeRow) {
369
+ return
370
+ }
371
+ if (dragStartBlocked(event)) {
372
+ event.preventDefault()
373
+ return
374
+ }
375
+ beginDrag(item, index, event)
376
+ }
377
+
378
+ function onHandleDragStart(item, index, event) {
379
+ if (!showHandleGrip.value) {
380
+ event.preventDefault()
381
+ return
382
+ }
383
+ beginDrag(item, index, event)
384
+ }
385
+
386
+ function clearDrag() {
387
+ draggingKey.value = null
388
+ dropGap.value = null
389
+ separatorTop.value = 0
390
+ }
391
+
392
+ function directRows(list) {
393
+ return [...list.querySelectorAll(':scope > li.reorder-list-row')]
394
+ }
395
+
396
+ function separatorTopForGap(gap, rows, list) {
397
+ const listRect = list.getBoundingClientRect()
398
+ let y
399
+ if (gap <= 0) {
400
+ y = rows[0].getBoundingClientRect().top
401
+ } else if (gap >= rows.length) {
402
+ y = rows[rows.length - 1].getBoundingClientRect().bottom
403
+ } else {
404
+ const previous = rows[gap - 1].getBoundingClientRect()
405
+ const next = rows[gap].getBoundingClientRect()
406
+ y = (previous.bottom + next.top) / 2
407
+ }
408
+ return y - listRect.top + list.scrollTop
409
+ }
410
+
411
+ /**
412
+ * Gap in the current list where a drop would insert the dragged row.
413
+ * The slots immediately before and after the dragged row are null: dropping
414
+ * there would not move it.
415
+ */
416
+ function dropGapFromDragEvent(event) {
417
+ const list = rootRef.value
418
+ if (!list || draggingKey.value == null) {
419
+ return null
420
+ }
421
+ const rows = directRows(list)
422
+ const fromIndex = indexOfKey(draggingKey.value)
423
+ if (fromIndex < 0 || rows.length === 0) {
424
+ return null
425
+ }
426
+
427
+ let gap = rows.length
428
+ for (let index = 0; index < rows.length; index += 1) {
429
+ const rect = rows[index].getBoundingClientRect()
430
+ const midpoint = rect.top + (rect.height / 2)
431
+ if (event.clientY < midpoint) {
432
+ gap = index
433
+ break
434
+ }
435
+ }
436
+ if (gap === fromIndex || gap === fromIndex + 1) {
437
+ return null
438
+ }
439
+ return {
440
+ gap,
441
+ fromIndex,
442
+ top: separatorTopForGap(gap, rows, list),
443
+ }
444
+ }
445
+
446
+ function onListDragOver(event) {
447
+ if (props.disabled || draggingKey.value == null) {
448
+ return
449
+ }
450
+ event.preventDefault()
451
+ event.stopPropagation()
452
+ const next = dropGapFromDragEvent(event)
453
+ dropGap.value = next ? next.gap : null
454
+ separatorTop.value = next ? next.top : 0
455
+ if (event.dataTransfer) {
456
+ event.dataTransfer.dropEffect = 'move'
457
+ }
458
+ }
459
+
460
+ function onListDragLeave(event) {
461
+ const list = rootRef.value
462
+ const next = event.relatedTarget
463
+ if (list && next instanceof Node && list.contains(next)) {
464
+ return
465
+ }
466
+ dropGap.value = null
467
+ }
468
+
469
+ function onListDrop(event) {
470
+ if (props.disabled || draggingKey.value == null) {
471
+ return
472
+ }
473
+ event.preventDefault()
474
+ event.stopPropagation()
475
+ const next = dropGapFromDragEvent(event)
476
+ const sourceKey = draggingKey.value
477
+ clearDrag()
478
+ if (!sourceKey || !next) {
479
+ return
480
+ }
481
+ const toIndex = next.gap > next.fromIndex ? next.gap - 1 : next.gap
482
+ reorder(next.fromIndex, toIndex)
483
+ }
484
+
485
+ function onDragEnd() {
486
+ clearDrag()
487
+ }
488
+
489
+ watch(() => [props.showDragHandles, props.dragWholeRow], () => {
490
+ if (!props.showDragHandles && !props.dragWholeRow) {
491
+ clearDrag()
492
+ }
493
+ })
494
+ </script>
495
+
496
+ <style scoped>
497
+ .reorder-list {
498
+ position: relative;
499
+ list-style: none;
500
+ margin: 0;
501
+ padding: 0;
502
+ display: flex;
503
+ flex-direction: column;
504
+ gap: 0.5rem;
505
+ }
506
+
507
+ .reorder-list-empty {
508
+ margin: 0;
509
+ color: var(--muted-text-color);
510
+ }
511
+
512
+ .reorder-list-row {
513
+ display: flex;
514
+ align-items: center;
515
+ gap: 0.75rem;
516
+ min-width: 0;
517
+ padding: 0.55rem 0.75rem;
518
+ border: 1px solid var(--border-color);
519
+ border-radius: 0.35rem;
520
+ background: var(--standout-bg-color);
521
+ }
522
+
523
+ .reorder-list-row.is-dragging {
524
+ opacity: 0.55;
525
+ }
526
+
527
+ .reorder-list-row.is-drag-whole-row {
528
+ cursor: grab;
529
+ }
530
+
531
+ .reorder-list-row.is-drag-whole-row:hover:not(.is-dragging) {
532
+ background-color: var(--control-hover-bg-color);
533
+ color: var(--hover-text-color);
534
+ }
535
+
536
+ .reorder-list-row.is-drag-whole-row:active {
537
+ cursor: grabbing;
538
+ }
539
+
540
+ .reorder-list-row.is-reorder-disabled {
541
+ opacity: 0.85;
542
+ }
543
+
544
+ .reorder-list-row.is-drag-whole-row:focus-visible {
545
+ outline: 2px solid var(--focus-outline-color, var(--text-color));
546
+ outline-offset: 2px;
547
+ }
548
+
549
+ .reorder-list-separator {
550
+ position: absolute;
551
+ z-index: 1;
552
+ left: 0;
553
+ right: 0;
554
+ height: 3px;
555
+ margin: 0;
556
+ padding: 0;
557
+ border: 0;
558
+ border-radius: 999px;
559
+ background: var(--text-color);
560
+ pointer-events: none;
561
+ transform: translateY(-50%);
562
+ }
563
+
564
+ .reorder-list-controls {
565
+ display: inline-flex;
566
+ align-items: center;
567
+ gap: 0.35rem;
568
+ flex: 0 0 auto;
569
+ }
570
+
571
+ .reorder-list-move {
572
+ display: inline-flex;
573
+ gap: 0.2rem;
574
+ }
575
+
576
+ .reorder-list-move-button {
577
+ display: inline-flex;
578
+ align-items: center;
579
+ justify-content: center;
580
+ min-width: 1.75rem;
581
+ padding: 0.2rem 0.35rem;
582
+ }
583
+
584
+ .reorder-list-handle {
585
+ display: inline-flex;
586
+ align-items: center;
587
+ justify-content: center;
588
+ padding: 0.15rem;
589
+ color: inherit;
590
+ opacity: 0.65;
591
+ cursor: grab;
592
+ border-radius: 0.25rem;
593
+ }
594
+
595
+ .reorder-list-handle:active {
596
+ cursor: grabbing;
597
+ }
598
+
599
+ .reorder-list-handle.is-disabled,
600
+ .reorder-list.is-disabled .reorder-list-handle {
601
+ opacity: 0.35;
602
+ cursor: default;
603
+ }
604
+
605
+ .reorder-list-handle:focus-visible {
606
+ outline: 2px solid var(--focus-outline-color, var(--text-color));
607
+ outline-offset: 2px;
608
+ }
609
+
610
+ .reorder-list-body {
611
+ flex: 1 1 auto;
612
+ min-width: 0;
613
+ }
614
+ </style>