picocrank 1.23.1 → 1.25.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 (38) hide show
  1. package/AGENTS.md +2 -0
  2. package/docs/filter-query-v1.md +180 -0
  3. package/logo.svg +7 -7
  4. package/package.json +7 -5
  5. package/public/themes/README.md +19 -2
  6. package/public/themes/index.json +1 -5
  7. package/styles/dark-theme.css +16 -1
  8. package/supplemental-themes/README.md +9 -0
  9. package/supplemental-themes/catppuccin-latte-frappe/theme.css +276 -0
  10. package/supplemental-themes/dracula-alucard/theme.css +258 -0
  11. package/supplemental-themes/gruvbox-dark-light/theme.css +264 -0
  12. package/supplemental-themes/index.json +17 -0
  13. package/{public/themes → supplemental-themes}/waffles/theme.css +99 -0
  14. package/vite.config.js +91 -0
  15. package/vue/components/FormField.vue +69 -1
  16. package/vue/components/Header.vue +3 -3
  17. package/vue/components/Login.vue +1 -3
  18. package/vue/components/Section.vue +45 -14
  19. package/vue/components/Sidebar.vue +2 -1
  20. package/vue/components/SidebarNavLink.vue +6 -0
  21. package/vue/components/Table.vue +1113 -73
  22. package/vue/components/TableColumnFilterPopover.vue +263 -44
  23. package/vue/components/TableColumnOptionsPopover.vue +1116 -0
  24. package/vue/components/ThemeSwitcher.vue +119 -0
  25. package/vue/components/TopBar.vue +2 -2
  26. package/vue/composables/tableColumnPresets.js +296 -0
  27. package/vue/composables/tableFilterQuery.js +202 -0
  28. package/vue/composables/tableFilters.js +167 -3
  29. package/vue/composables/useCustomTheme.js +167 -24
  30. package/vue/data/examplePeople.js +2 -6
  31. package/vue/examples/App.vue +1 -1
  32. package/vue/examples/FormExample.vue +40 -6
  33. package/vue/examples/LoginExample.vue +1 -3
  34. package/vue/examples/TableExample.vue +329 -11
  35. package/vue/examples/TableRemoteExample.vue +112 -4
  36. package/vue/examples/ThemePreview.vue +17 -31
  37. package/public/themes/catppuccin/theme.css +0 -195
  38. package/public/themes/dracula/theme.css +0 -181
@@ -0,0 +1,1116 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <div
4
+ v-if="open"
5
+ ref="popoverRef"
6
+ class="table-column-options-popover"
7
+ role="dialog"
8
+ :aria-labelledby="titleId"
9
+ :style="popoverStyle"
10
+ @click.stop
11
+ >
12
+ <div class="popover-body">
13
+ <h4 :id="titleId" ref="titleRef" class="popover-title">
14
+ Column Options
15
+ </h4>
16
+
17
+ <Tabs
18
+ class="column-options-tabs"
19
+ :tabs="optionTabs"
20
+ default-tab="custom"
21
+ @tab-change="onOptionTabChange"
22
+ >
23
+ <template #tab-custom>
24
+ <p class="subtle column-options-help">
25
+ Drag rows or use the arrows to reorder. Toggle checkboxes to show or hide columns. Set responsive priority (1 hides first) when using column priorities layout.
26
+ </p>
27
+
28
+ <div ref="listScrollRef" class="column-options-scroll" :style="listScrollStyle">
29
+ <ul class="column-options-list">
30
+ <li
31
+ v-for="key in draftOrder"
32
+ :key="key"
33
+ class="column-options-item"
34
+ :class="{
35
+ 'is-dragging': draggingKey === key,
36
+ 'is-drag-over': dragOverKey === key && draggingKey !== key,
37
+ 'is-fixed': !isOrderable(key),
38
+ }"
39
+ :draggable="isOrderable(key)"
40
+ @dragstart="onDragStart(key, $event)"
41
+ @dragover.prevent="onDragOver(key)"
42
+ @drop.prevent="onDrop(key)"
43
+ @dragend="onDragEnd"
44
+ >
45
+ <label class="column-options-label">
46
+ <input
47
+ type="checkbox"
48
+ :checked="isVisible(key)"
49
+ :disabled="!isHideable(key) || (isVisible(key) && draftVisibleKeys.length <= 1)"
50
+ @change="toggleVisible(key)"
51
+ />
52
+ <span>{{ columnDisplayLabel(key) }}</span>
53
+ </label>
54
+
55
+ <select
56
+ v-if="isPriorityConfigurable(key)"
57
+ class="column-options-priority"
58
+ :value="prioritySelectValue(key)"
59
+ :aria-label="`Priority for ${columnLabel(key)} column`"
60
+ @change="setPriority(key, $event.target.value)"
61
+ >
62
+ <option value="">Never hide</option>
63
+ <option
64
+ v-for="level in priorityLevels"
65
+ :key="level"
66
+ :value="level"
67
+ >
68
+ {{ priorityOptionLabel(level) }}
69
+ </option>
70
+ </select>
71
+ <span v-else class="column-options-priority-spacer" aria-hidden="true" />
72
+
73
+ <div class="column-options-reorder">
74
+ <div class="column-options-move" role="group" :aria-label="`Move ${columnLabel(key)} column`">
75
+ <button
76
+ type="button"
77
+ class="neutral column-options-move-button"
78
+ :disabled="!canMove(key, -1)"
79
+ :aria-label="`Move ${columnLabel(key)} up`"
80
+ @click="moveColumn(key, -1)"
81
+ >
82
+ <HugeiconsIcon
83
+ :icon="ArrowUp01Icon"
84
+ width="0.9em"
85
+ height="0.9em"
86
+ :strokeWidth="2"
87
+ aria-hidden="true"
88
+ />
89
+ </button>
90
+ <button
91
+ type="button"
92
+ class="neutral column-options-move-button"
93
+ :disabled="!canMove(key, 1)"
94
+ :aria-label="`Move ${columnLabel(key)} down`"
95
+ @click="moveColumn(key, 1)"
96
+ >
97
+ <HugeiconsIcon
98
+ :icon="ArrowDown01Icon"
99
+ width="0.9em"
100
+ height="0.9em"
101
+ :strokeWidth="2"
102
+ aria-hidden="true"
103
+ />
104
+ </button>
105
+ </div>
106
+
107
+ <span
108
+ class="column-options-drag-handle"
109
+ :class="{ disabled: !isOrderable(key) }"
110
+ aria-hidden="true"
111
+ >
112
+ <HugeiconsIcon
113
+ :icon="DragDropVerticalIcon"
114
+ width="0.95em"
115
+ height="0.95em"
116
+ :strokeWidth="2"
117
+ />
118
+ </span>
119
+ </div>
120
+ </li>
121
+ </ul>
122
+ </div>
123
+ </template>
124
+
125
+ <template #tab-load>
126
+ <div class="column-options-load-panel">
127
+ <template v-if="layoutPresetsEnabled">
128
+ <ul v-if="presets.length > 0" class="column-options-preset-list">
129
+ <li
130
+ v-for="preset in presets"
131
+ :key="preset.id"
132
+ class="column-options-preset-item"
133
+ >
134
+ <div class="column-options-preset-meta">
135
+ <strong>{{ preset.name }}</strong>
136
+ <span v-if="preset.id === defaultPresetId" class="tag">Default</span>
137
+ <p class="subtle column-options-preset-date">{{ formatPresetDate(preset.savedAt) }}</p>
138
+ </div>
139
+ <div class="column-options-preset-actions" role="group" :aria-label="`Actions for ${preset.name}`">
140
+ <button
141
+ type="button"
142
+ class="neutral"
143
+ @click="submitLoadPreset(preset.id)"
144
+ >
145
+ Load
146
+ </button>
147
+ <button
148
+ v-if="preset.id !== defaultPresetId"
149
+ type="button"
150
+ class="neutral"
151
+ @click="submitSetDefaultPreset(preset.id)"
152
+ >
153
+ Set default
154
+ </button>
155
+ <button
156
+ v-else
157
+ type="button"
158
+ class="neutral"
159
+ @click="submitSetDefaultPreset(null)"
160
+ >
161
+ Clear default
162
+ </button>
163
+ <button
164
+ type="button"
165
+ class="neutral"
166
+ @click="submitDeletePreset(preset.id, preset.name)"
167
+ >
168
+ Delete
169
+ </button>
170
+ </div>
171
+ </li>
172
+ </ul>
173
+ <p v-else class="subtle column-options-tab-placeholder">No saved layouts yet.</p>
174
+ </template>
175
+ <p v-else class="subtle column-options-tab-placeholder">
176
+ Provide a <code>table-id</code> or remote <code>layout-presets</code> callbacks to enable saved layouts.
177
+ </p>
178
+
179
+ <p v-if="loadError" class="column-options-tab-error">{{ loadError }}</p>
180
+
181
+ <div class="column-options-load-default">
182
+ <button type="button" class="neutral" @click="submitLoadDeveloperDefaults">
183
+ Load default
184
+ </button>
185
+ </div>
186
+ </div>
187
+ </template>
188
+
189
+ <template #tab-save>
190
+ <div v-if="!layoutPresetsEnabled" class="column-options-tab-placeholder">
191
+ <p class="subtle">Provide a <code>table-id</code> or remote <code>layout-presets</code> callbacks to enable saved layouts.</p>
192
+ </div>
193
+ <div v-else class="column-options-save-panel">
194
+ <p class="subtle column-options-save-help">
195
+ Saves the current table view (filters, page size, column order, visibility, and priorities).
196
+ </p>
197
+ <label class="column-options-save-field">
198
+ <span class="field-label">Layout name</span>
199
+ <input
200
+ v-model="savePresetName"
201
+ type="text"
202
+ placeholder="e.g. Managers view"
203
+ @keydown.enter.prevent="submitSavePreset"
204
+ />
205
+ </label>
206
+ <label class="column-options-save-default">
207
+ <input v-model="saveAsDefault" type="checkbox" />
208
+ <span>Set as default</span>
209
+ </label>
210
+ <p v-if="saveError" class="column-options-tab-error">{{ saveError }}</p>
211
+ <p v-if="saveSuccess" class="column-options-tab-success">{{ saveSuccess }}</p>
212
+ <button type="button" @click="submitSavePreset">Save layout</button>
213
+ </div>
214
+ </template>
215
+ </Tabs>
216
+ </div>
217
+
218
+ <div ref="actionsRef" role="toolbar" class="popover-actions">
219
+ <button type="button" class="neutral" @click="close">Close</button>
220
+ <button type="button" class="neutral" @click="showAll">Show all</button>
221
+ </div>
222
+ </div>
223
+ </Teleport>
224
+ </template>
225
+
226
+ <script setup>
227
+ import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
228
+ import { HugeiconsIcon } from '@hugeicons/vue'
229
+ import {
230
+ ArrowDown01Icon,
231
+ ArrowUp01Icon,
232
+ DragDropVerticalIcon,
233
+ } from '@hugeicons/core-free-icons'
234
+ import {
235
+ isFilterEntryActive,
236
+ normalizeColumnFilterEntries,
237
+ } from '../composables/tableFilters.js'
238
+ import Tabs from './Tabs.vue'
239
+
240
+ const optionTabs = [
241
+ { id: 'custom', label: 'Custom' },
242
+ { id: 'load', label: 'Load' },
243
+ { id: 'save', label: 'Save' },
244
+ ]
245
+
246
+ const props = defineProps({
247
+ open: {
248
+ type: Boolean,
249
+ default: false,
250
+ },
251
+ headers: {
252
+ type: Array,
253
+ default: () => [],
254
+ },
255
+ columnKeys: {
256
+ type: Array,
257
+ default: () => [],
258
+ },
259
+ visibleKeys: {
260
+ type: Array,
261
+ default: () => [],
262
+ },
263
+ columnPriorities: {
264
+ type: Object,
265
+ default: () => ({}),
266
+ },
267
+ anchorEl: {
268
+ type: Object,
269
+ default: null,
270
+ },
271
+ filters: {
272
+ type: Object,
273
+ default: () => ({}),
274
+ },
275
+ rows: {
276
+ type: Array,
277
+ default: () => [],
278
+ },
279
+ tableId: {
280
+ type: String,
281
+ default: '',
282
+ },
283
+ layoutPresetsEnabled: {
284
+ type: Boolean,
285
+ default: false,
286
+ },
287
+ presets: {
288
+ type: Array,
289
+ default: () => [],
290
+ },
291
+ defaultPresetId: {
292
+ type: String,
293
+ default: null,
294
+ },
295
+ saveLayoutPreset: {
296
+ type: Function,
297
+ default: null,
298
+ },
299
+ loadLayoutPreset: {
300
+ type: Function,
301
+ default: null,
302
+ },
303
+ deleteLayoutPreset: {
304
+ type: Function,
305
+ default: null,
306
+ },
307
+ setDefaultLayoutPreset: {
308
+ type: Function,
309
+ default: null,
310
+ },
311
+ loadDeveloperDefaults: {
312
+ type: Function,
313
+ default: null,
314
+ },
315
+ })
316
+
317
+ const emit = defineEmits(['update:open', 'apply', 'cancel'])
318
+
319
+ const MIN_VISIBLE_OPTIONS = 8
320
+ const MAX_VISIBLE_OPTIONS = 20
321
+ const VIEWPORT_MARGIN_PX = 8
322
+
323
+ const popoverRef = ref(null)
324
+ const titleRef = ref(null)
325
+ const actionsRef = ref(null)
326
+ const listScrollRef = ref(null)
327
+ const popoverStyle = ref({ top: '0px', left: '0px' })
328
+ const listScrollStyle = ref({})
329
+ const titleId = useId()
330
+ const priorityLevels = [1, 2, 3, 4, 5]
331
+ const draftOrder = ref([])
332
+ const draftVisibleKeys = ref([])
333
+ const draftPriorities = ref({})
334
+ const draggingKey = ref(null)
335
+ const dragOverKey = ref(null)
336
+ const savePresetName = ref('')
337
+ const saveAsDefault = ref(false)
338
+ const saveError = ref('')
339
+ const saveSuccess = ref('')
340
+ const loadError = ref('')
341
+
342
+ const headerByKey = computed(() => new Map(
343
+ props.headers
344
+ .filter((header) => header?.key)
345
+ .map((header) => [header.key, header]),
346
+ ))
347
+
348
+ function columnLabel(key) {
349
+ return headerByKey.value.get(key)?.label || key
350
+ }
351
+
352
+ function columnFilterCount(key) {
353
+ const header = headerByKey.value.get(key)
354
+ if (!header || header.filterable === false) {
355
+ return 0
356
+ }
357
+
358
+ return normalizeColumnFilterEntries(props.filters?.[key], header, props.rows)
359
+ .filter((entry) => isFilterEntryActive(entry))
360
+ .length
361
+ }
362
+
363
+ function columnDisplayLabel(key) {
364
+ const label = columnLabel(key)
365
+ const count = columnFilterCount(key)
366
+ if (count > 0) {
367
+ return `${label} (${count})`
368
+ }
369
+ return label
370
+ }
371
+
372
+ function isHideable(key) {
373
+ const header = headerByKey.value.get(key)
374
+ return header?.hideable !== false && !header?.hidden
375
+ }
376
+
377
+ function isOrderable(key) {
378
+ const header = headerByKey.value.get(key)
379
+ return header?.orderable !== false && !header?.hidden
380
+ }
381
+
382
+ function isPriorityConfigurable(key) {
383
+ const header = headerByKey.value.get(key)
384
+ return header?.colPriorityConfigurable !== false && !header?.hidden
385
+ }
386
+
387
+ function isValidColPriority(priority) {
388
+ return Number.isInteger(priority) && priority >= 1 && priority <= 5
389
+ }
390
+
391
+ function defaultColumnPriority(key) {
392
+ const header = headerByKey.value.get(key)
393
+ return isValidColPriority(header?.colPriority) ? header.colPriority : null
394
+ }
395
+
396
+ function normalizePriority(value) {
397
+ if (value === '' || value === null || value === undefined) {
398
+ return null
399
+ }
400
+ const numeric = Number(value)
401
+ return isValidColPriority(numeric) ? numeric : null
402
+ }
403
+
404
+ function prioritySelectValue(key) {
405
+ const value = draftPriorities.value[key]
406
+ return value == null ? '' : String(value)
407
+ }
408
+
409
+ function priorityOptionLabel(level) {
410
+ if (level === 1) {
411
+ return '1 - Hide first'
412
+ }
413
+ if (level === 5) {
414
+ return '5 - Hide last'
415
+ }
416
+ return String(level)
417
+ }
418
+
419
+ function setPriority(key, rawValue) {
420
+ draftPriorities.value = {
421
+ ...draftPriorities.value,
422
+ [key]: normalizePriority(rawValue),
423
+ }
424
+ emitLiveChange()
425
+ }
426
+
427
+ function isVisible(key) {
428
+ if (!isHideable(key)) {
429
+ return true
430
+ }
431
+ return draftVisibleKeys.value.includes(key)
432
+ }
433
+
434
+ function positionPopover() {
435
+ const anchor = props.anchorEl
436
+ if (!anchor || !popoverRef.value) {
437
+ return
438
+ }
439
+
440
+ const rect = anchor.getBoundingClientRect()
441
+ const popoverRect = popoverRef.value.getBoundingClientRect()
442
+ const margin = VIEWPORT_MARGIN_PX
443
+ let top = rect.bottom + margin
444
+ let left = rect.right - popoverRect.width
445
+
446
+ if (left + popoverRect.width > window.innerWidth - margin) {
447
+ left = window.innerWidth - popoverRect.width - margin
448
+ }
449
+ if (left < margin) {
450
+ left = margin
451
+ }
452
+ if (top + popoverRect.height > window.innerHeight - margin) {
453
+ top = rect.top - popoverRect.height - margin
454
+ }
455
+ if (top < margin) {
456
+ top = margin
457
+ }
458
+
459
+ popoverStyle.value = {
460
+ top: `${top}px`,
461
+ left: `${left}px`,
462
+ }
463
+ }
464
+
465
+ function updateListScrollLimit() {
466
+ listScrollStyle.value = {}
467
+
468
+ if (!popoverRef.value || !listScrollRef.value) {
469
+ return
470
+ }
471
+
472
+ const anchor = props.anchorEl
473
+ const anchorRect = anchor?.getBoundingClientRect()
474
+ const popoverEl = popoverRef.value
475
+ const titleHeight = titleRef.value?.offsetHeight ?? 0
476
+ const tabsHeaderHeight = popoverEl.querySelector('.column-options-tabs .tabs-header')?.offsetHeight ?? 0
477
+ const helpHeight = popoverEl.querySelector('.column-options-help')?.offsetHeight ?? 0
478
+ const actionsHeight = actionsRef.value?.offsetHeight ?? 0
479
+ const popoverStyles = window.getComputedStyle(popoverEl)
480
+ const paddingTop = Number.parseFloat(popoverStyles.paddingTop) || 0
481
+ const paddingBottom = Number.parseFloat(popoverStyles.paddingBottom) || 0
482
+ const bodyGap = 12
483
+
484
+ let availablePopoverHeight = window.innerHeight - (VIEWPORT_MARGIN_PX * 2)
485
+ if (anchorRect) {
486
+ const spaceBelow = window.innerHeight - VIEWPORT_MARGIN_PX - anchorRect.bottom
487
+ const spaceAbove = anchorRect.top - VIEWPORT_MARGIN_PX
488
+ availablePopoverHeight = Math.max(spaceBelow, spaceAbove)
489
+ }
490
+
491
+ const reservedHeight = paddingTop + paddingBottom + titleHeight + tabsHeaderHeight + helpHeight + actionsHeight + bodyGap
492
+ const optionsAreaBudget = Math.max(0, availablePopoverHeight - reservedHeight)
493
+
494
+ const firstItem = listScrollRef.value.querySelector('.column-options-item')
495
+ const rowHeight = firstItem?.offsetHeight || 40
496
+ const optionCount = draftOrder.value.length
497
+
498
+ const maxVisibleByViewport = Math.floor(optionsAreaBudget / rowHeight)
499
+ const visibleSlots = Math.min(
500
+ MAX_VISIBLE_OPTIONS,
501
+ Math.max(MIN_VISIBLE_OPTIONS, maxVisibleByViewport),
502
+ )
503
+
504
+ const rowsToShow = optionCount === 0
505
+ ? MIN_VISIBLE_OPTIONS
506
+ : Math.min(
507
+ Math.max(optionCount, MIN_VISIBLE_OPTIONS),
508
+ visibleSlots,
509
+ )
510
+
511
+ listScrollStyle.value = {
512
+ maxHeight: `${rowsToShow * rowHeight}px`,
513
+ ...(optionCount > 0 && optionCount < MIN_VISIBLE_OPTIONS
514
+ ? { minHeight: `${MIN_VISIBLE_OPTIONS * rowHeight}px` }
515
+ : {}),
516
+ }
517
+ }
518
+
519
+ function resetPresetForm() {
520
+ savePresetName.value = ''
521
+ saveAsDefault.value = false
522
+ saveError.value = ''
523
+ saveSuccess.value = ''
524
+ loadError.value = ''
525
+ }
526
+
527
+ function formatPresetDate(savedAt) {
528
+ if (!savedAt) {
529
+ return ''
530
+ }
531
+ const date = new Date(savedAt)
532
+ if (Number.isNaN(date.getTime())) {
533
+ return savedAt
534
+ }
535
+ return date.toLocaleString()
536
+ }
537
+
538
+ async function submitSavePreset() {
539
+ saveError.value = ''
540
+ saveSuccess.value = ''
541
+
542
+ if (!props.saveLayoutPreset) {
543
+ saveError.value = 'Save is unavailable.'
544
+ return
545
+ }
546
+
547
+ const result = await Promise.resolve(props.saveLayoutPreset({
548
+ name: savePresetName.value,
549
+ setAsDefault: saveAsDefault.value,
550
+ }))
551
+
552
+ if (!result?.ok) {
553
+ saveError.value = result?.error || 'Unable to save layout.'
554
+ return
555
+ }
556
+
557
+ saveSuccess.value = `Saved “${result.preset.name}”.`
558
+ savePresetName.value = ''
559
+ saveAsDefault.value = false
560
+ }
561
+
562
+ async function submitLoadPreset(presetId) {
563
+ loadError.value = ''
564
+ const result = await Promise.resolve(props.loadLayoutPreset?.(presetId))
565
+ if (!result?.ok) {
566
+ loadError.value = result?.error || 'Unable to load layout.'
567
+ return
568
+ }
569
+ resetDraft()
570
+ }
571
+
572
+ async function submitDeletePreset(presetId, presetName) {
573
+ loadError.value = ''
574
+ if (!globalThis.confirm?.(`Delete saved layout “${presetName}”?`)) {
575
+ return
576
+ }
577
+
578
+ const result = await Promise.resolve(props.deleteLayoutPreset?.(presetId))
579
+ if (!result?.ok) {
580
+ loadError.value = result?.error || 'Unable to delete layout.'
581
+ }
582
+ }
583
+
584
+ async function submitSetDefaultPreset(presetId) {
585
+ loadError.value = ''
586
+ const result = await Promise.resolve(props.setDefaultLayoutPreset?.(presetId))
587
+ if (!result?.ok) {
588
+ loadError.value = result?.error || 'Unable to update default layout.'
589
+ }
590
+ }
591
+
592
+ function submitLoadDeveloperDefaults() {
593
+ loadError.value = ''
594
+ const result = props.loadDeveloperDefaults?.()
595
+ if (!result?.ok) {
596
+ loadError.value = result?.error || 'Unable to load defaults.'
597
+ return
598
+ }
599
+ resetDraft()
600
+ }
601
+
602
+ function resetDraft() {
603
+ draftOrder.value = [...props.columnKeys]
604
+ draftVisibleKeys.value = [...props.visibleKeys]
605
+ draftPriorities.value = { ...props.columnPriorities }
606
+ }
607
+
608
+ function toggleVisible(key) {
609
+ if (!isHideable(key)) {
610
+ return
611
+ }
612
+
613
+ if (isVisible(key)) {
614
+ if (draftVisibleKeys.value.length <= 1) {
615
+ return
616
+ }
617
+ draftVisibleKeys.value = draftVisibleKeys.value.filter((item) => item !== key)
618
+ emitLiveChange()
619
+ return
620
+ }
621
+
622
+ draftVisibleKeys.value = [...draftVisibleKeys.value, key]
623
+ emitLiveChange()
624
+ }
625
+
626
+ function canMove(key, direction) {
627
+ if (!isOrderable(key)) {
628
+ return false
629
+ }
630
+
631
+ let index = draftOrder.value.indexOf(key)
632
+ while (index >= 0 && index < draftOrder.value.length) {
633
+ index += direction
634
+ if (index < 0 || index >= draftOrder.value.length) {
635
+ return false
636
+ }
637
+ if (isOrderable(draftOrder.value[index])) {
638
+ return true
639
+ }
640
+ }
641
+
642
+ return false
643
+ }
644
+
645
+ function moveColumn(key, direction) {
646
+ if (!canMove(key, direction)) {
647
+ return
648
+ }
649
+
650
+ const order = [...draftOrder.value]
651
+ let index = order.indexOf(key)
652
+
653
+ while (true) {
654
+ const targetIndex = index + direction
655
+ if (targetIndex < 0 || targetIndex >= order.length) {
656
+ return
657
+ }
658
+ if (isOrderable(order[targetIndex])) {
659
+ const swapKey = order[targetIndex]
660
+ order[index] = swapKey
661
+ order[targetIndex] = key
662
+ draftOrder.value = order
663
+ emitLiveChange()
664
+ return
665
+ }
666
+ index = targetIndex
667
+ }
668
+ }
669
+
670
+ function onDragStart(key, event) {
671
+ if (!isOrderable(key)) {
672
+ event.preventDefault()
673
+ return
674
+ }
675
+ draggingKey.value = key
676
+ dragOverKey.value = key
677
+ event.dataTransfer.effectAllowed = 'move'
678
+ event.dataTransfer.setData('text/plain', key)
679
+ }
680
+
681
+ function onDragOver(key) {
682
+ if (!draggingKey.value || draggingKey.value === key) {
683
+ return
684
+ }
685
+ dragOverKey.value = key
686
+ }
687
+
688
+ function onDrop(targetKey) {
689
+ const sourceKey = draggingKey.value
690
+ if (!sourceKey || sourceKey === targetKey) {
691
+ return
692
+ }
693
+
694
+ const order = draftOrder.value.filter((key) => key !== sourceKey)
695
+ const targetIndex = order.indexOf(targetKey)
696
+ if (targetIndex === -1) {
697
+ return
698
+ }
699
+
700
+ order.splice(targetIndex, 0, sourceKey)
701
+ draftOrder.value = order
702
+ emitLiveChange()
703
+ }
704
+
705
+ function onDragEnd() {
706
+ draggingKey.value = null
707
+ dragOverKey.value = null
708
+ }
709
+
710
+ function onDocumentPointerDown(event) {
711
+ if (!props.open || !popoverRef.value) {
712
+ return
713
+ }
714
+ if (popoverRef.value.contains(event.target)) {
715
+ return
716
+ }
717
+ if (props.anchorEl?.contains?.(event.target)) {
718
+ return
719
+ }
720
+ close()
721
+ }
722
+
723
+ function onDocumentKeyDown(event) {
724
+ if (!props.open) {
725
+ return
726
+ }
727
+ if (event.key === 'Escape') {
728
+ event.preventDefault()
729
+ close()
730
+ }
731
+ }
732
+
733
+ function buildVisibleKeys() {
734
+ const visibleKeySet = new Set(draftVisibleKeys.value)
735
+ return draftOrder.value.filter((key) => !isHideable(key) || visibleKeySet.has(key))
736
+ }
737
+
738
+ function buildPriorityOverrides() {
739
+ const overrides = {}
740
+
741
+ for (const key of draftOrder.value) {
742
+ if (!isPriorityConfigurable(key)) {
743
+ continue
744
+ }
745
+
746
+ const draftValue = normalizePriority(draftPriorities.value[key])
747
+ const defaultValue = defaultColumnPriority(key)
748
+ if (draftValue !== defaultValue) {
749
+ overrides[key] = draftValue
750
+ }
751
+ }
752
+
753
+ return overrides
754
+ }
755
+
756
+ function emitLiveChange() {
757
+ const visibleKeys = buildVisibleKeys()
758
+ if (visibleKeys.length === 0) {
759
+ return
760
+ }
761
+
762
+ emit('apply', {
763
+ order: [...draftOrder.value],
764
+ visibleKeys,
765
+ priorities: buildPriorityOverrides(),
766
+ })
767
+ }
768
+
769
+ function showAll() {
770
+ draftVisibleKeys.value = draftOrder.value.filter((key) => isHideable(key))
771
+ emitLiveChange()
772
+ }
773
+
774
+ async function onOptionTabChange() {
775
+ await nextTick()
776
+ updateListScrollLimit()
777
+ await nextTick()
778
+ positionPopover()
779
+ }
780
+
781
+ function close() {
782
+ emit('cancel')
783
+ emit('update:open', false)
784
+ }
785
+
786
+ async function initializePopover() {
787
+ resetDraft()
788
+ resetPresetForm()
789
+ await nextTick()
790
+ updateListScrollLimit()
791
+ await nextTick()
792
+ positionPopover()
793
+ popoverRef.value?.querySelector('input, button')?.focus()
794
+ }
795
+
796
+ watch(
797
+ () => props.open,
798
+ async (isOpen, wasOpen) => {
799
+ if (!isOpen) {
800
+ listScrollStyle.value = {}
801
+ draggingKey.value = null
802
+ dragOverKey.value = null
803
+ document.removeEventListener('pointerdown', onDocumentPointerDown)
804
+ document.removeEventListener('keydown', onDocumentKeyDown)
805
+ return
806
+ }
807
+
808
+ if (!wasOpen) {
809
+ await initializePopover()
810
+ document.addEventListener('pointerdown', onDocumentPointerDown)
811
+ document.addEventListener('keydown', onDocumentKeyDown)
812
+ }
813
+ },
814
+ { immediate: true },
815
+ )
816
+
817
+ watch(
818
+ () => [props.columnKeys, props.headers],
819
+ () => {
820
+ if (props.open) {
821
+ resetDraft()
822
+ }
823
+ },
824
+ )
825
+
826
+ onBeforeUnmount(() => {
827
+ document.removeEventListener('pointerdown', onDocumentPointerDown)
828
+ document.removeEventListener('keydown', onDocumentKeyDown)
829
+ })
830
+ </script>
831
+
832
+ <style scoped>
833
+ .table-column-options-popover {
834
+ position: fixed;
835
+ z-index: 20;
836
+ display: flex;
837
+ flex-direction: column;
838
+ width: min(30rem, calc(100vw - 1rem));
839
+ min-width: min(30rem, calc(100vw - 1rem));
840
+ max-height: calc(100vh - 1rem);
841
+ padding: 0.75rem;
842
+ border: 1px solid var(--border-color, #e1e5e9);
843
+ border-radius: 0.4rem;
844
+ background: var(--background-color, #fff);
845
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
846
+ overflow: hidden;
847
+ box-sizing: border-box;
848
+ }
849
+
850
+ .popover-body {
851
+ flex: 1 1 auto;
852
+ min-height: 0;
853
+ display: flex;
854
+ flex-direction: column;
855
+ }
856
+
857
+ .popover-title {
858
+ margin: 0 0 0.35rem;
859
+ font-size: 0.95rem;
860
+ flex-shrink: 0;
861
+ }
862
+
863
+ .column-options-help {
864
+ margin: 0 0 0.75rem;
865
+ font-size: 0.85rem;
866
+ flex-shrink: 0;
867
+ }
868
+
869
+ .column-options-tabs {
870
+ flex: 1 1 auto;
871
+ min-height: 0;
872
+ min-width: 0;
873
+ display: flex;
874
+ flex-direction: column;
875
+ width: 100%;
876
+ }
877
+
878
+ .column-options-tabs :deep(.tabs-content) {
879
+ flex: 1 1 auto;
880
+ min-height: 0;
881
+ }
882
+
883
+ .column-options-tabs :deep(.tab-panel) {
884
+ padding-block: 0.75rem;
885
+ padding-inline: 0;
886
+ min-height: 0;
887
+ display: flex;
888
+ flex-direction: column;
889
+ }
890
+
891
+ .column-options-tabs :deep(.tab-button) {
892
+ padding: 0.45rem 0.85rem;
893
+ font-size: 0.9rem;
894
+ }
895
+
896
+ .column-options-tab-placeholder {
897
+ margin: 0;
898
+ font-size: 0.85rem;
899
+ }
900
+
901
+ .column-options-save-panel {
902
+ display: flex;
903
+ flex-direction: column;
904
+ gap: 0.75rem;
905
+ }
906
+
907
+ .column-options-save-help {
908
+ margin: 0;
909
+ font-size: 0.85rem;
910
+ }
911
+
912
+ .column-options-save-field {
913
+ display: flex;
914
+ flex-direction: column;
915
+ gap: 0.25rem;
916
+ }
917
+
918
+ .column-options-save-default {
919
+ display: flex;
920
+ align-items: center;
921
+ gap: 0.45rem;
922
+ }
923
+
924
+ .field-label {
925
+ font-size: 0.85rem;
926
+ color: var(--text-muted, #666);
927
+ }
928
+
929
+ .column-options-tab-error {
930
+ margin: 0;
931
+ font-size: 0.85rem;
932
+ color: var(--bad-color, #c62828);
933
+ }
934
+
935
+ .column-options-tab-success {
936
+ margin: 0;
937
+ font-size: 0.85rem;
938
+ color: var(--good-color, #2e7d32);
939
+ }
940
+
941
+ .column-options-load-panel {
942
+ display: flex;
943
+ flex-direction: column;
944
+ gap: 0.75rem;
945
+ }
946
+
947
+ .column-options-load-default {
948
+ display: flex;
949
+ justify-content: center;
950
+ padding-top: 0.25rem;
951
+ border-top: 1px solid var(--border-color, #e1e5e9);
952
+ }
953
+
954
+ .column-options-preset-list {
955
+ list-style: none;
956
+ margin: 0;
957
+ padding: 0;
958
+ display: flex;
959
+ flex-direction: column;
960
+ gap: 0.65rem;
961
+ max-height: 14rem;
962
+ overflow-y: auto;
963
+ }
964
+
965
+ .column-options-preset-item {
966
+ display: grid;
967
+ grid-template-columns: minmax(0, 1fr) auto;
968
+ gap: 0.5rem;
969
+ align-items: start;
970
+ padding-bottom: 0.65rem;
971
+ border-bottom: 1px solid var(--border-color, #e1e5e9);
972
+ }
973
+
974
+ .column-options-preset-item:last-child {
975
+ padding-bottom: 0;
976
+ border-bottom: 0;
977
+ }
978
+
979
+ .column-options-preset-meta strong {
980
+ display: inline;
981
+ margin-right: 0.35rem;
982
+ }
983
+
984
+ .column-options-preset-date {
985
+ margin: 0.2rem 0 0;
986
+ font-size: 0.8rem;
987
+ }
988
+
989
+ .column-options-preset-actions {
990
+ display: flex;
991
+ flex-wrap: wrap;
992
+ justify-content: flex-end;
993
+ gap: 0.35rem;
994
+ }
995
+
996
+ .column-options-scroll {
997
+ overflow-y: auto;
998
+ min-height: 0;
999
+ overscroll-behavior: contain;
1000
+ }
1001
+
1002
+ .column-options-list {
1003
+ list-style: none;
1004
+ margin: 0;
1005
+ padding: 0;
1006
+ display: flex;
1007
+ flex-direction: column;
1008
+ gap: 0.35rem;
1009
+ }
1010
+
1011
+ .column-options-item {
1012
+ display: grid;
1013
+ grid-template-columns: minmax(0, 1fr) 5.5rem auto;
1014
+ align-items: center;
1015
+ gap: 0.5rem;
1016
+ padding: 0.25rem 0.45rem;
1017
+ border: 1px solid var(--border-color, #e1e5e9);
1018
+ border-radius: 0.35rem;
1019
+ background: var(--standout-bg-color, #f8f9fa);
1020
+ }
1021
+
1022
+ .column-options-item.is-dragging {
1023
+ opacity: 0.55;
1024
+ }
1025
+
1026
+ .column-options-item.is-drag-over {
1027
+ border-color: var(--text-color, #334155);
1028
+ }
1029
+
1030
+ .column-options-item.is-fixed {
1031
+ opacity: 0.85;
1032
+ }
1033
+
1034
+ .column-options-drag-handle {
1035
+ display: inline-flex;
1036
+ align-items: center;
1037
+ color: var(--text-color, inherit);
1038
+ opacity: 0.65;
1039
+ cursor: grab;
1040
+ }
1041
+
1042
+ .column-options-item.is-fixed .column-options-drag-handle.disabled {
1043
+ opacity: 0.35;
1044
+ cursor: default;
1045
+ }
1046
+
1047
+ .column-options-label {
1048
+ display: inline-flex;
1049
+ align-items: center;
1050
+ gap: 0.5rem;
1051
+ margin: 0;
1052
+ min-width: 0;
1053
+ }
1054
+
1055
+ .column-options-label span {
1056
+ overflow: hidden;
1057
+ text-overflow: ellipsis;
1058
+ white-space: nowrap;
1059
+ }
1060
+
1061
+ .column-options-priority {
1062
+ width: 100%;
1063
+ min-width: 0;
1064
+ padding-top: 0.15rem;
1065
+ padding-bottom: 0.15rem;
1066
+ font-size: 0.9em;
1067
+ }
1068
+
1069
+ .column-options-priority-spacer {
1070
+ display: block;
1071
+ }
1072
+
1073
+ .column-options-reorder {
1074
+ display: inline-flex;
1075
+ align-items: center;
1076
+ gap: 0.25rem;
1077
+ }
1078
+
1079
+ .column-options-move {
1080
+ display: inline-flex;
1081
+ gap: 0.2rem;
1082
+ }
1083
+
1084
+ .column-options-move-button {
1085
+ display: inline-flex;
1086
+ align-items: center;
1087
+ justify-content: center;
1088
+ min-width: 1.75rem;
1089
+ padding: 0.2rem 0.35rem;
1090
+ }
1091
+
1092
+ .popover-actions {
1093
+ display: flex;
1094
+ flex-shrink: 0;
1095
+ flex-wrap: wrap;
1096
+ justify-content: flex-end;
1097
+ gap: 0.5rem;
1098
+ margin-top: 0.75rem;
1099
+ padding-top: 0.75rem;
1100
+ border-top: 1px solid var(--border-color, #e1e5e9);
1101
+ }
1102
+
1103
+ html[data-theme="dark"] .table-column-options-popover {
1104
+ background: var(--background-color, #1e1e1e);
1105
+ border-color: var(--border-color, #444);
1106
+ }
1107
+
1108
+ html[data-theme="dark"] .column-options-item {
1109
+ background: var(--standout-bg-color, #1f1f1f);
1110
+ border-color: var(--border-color, #444);
1111
+ }
1112
+
1113
+ html[data-theme="dark"] .popover-actions {
1114
+ border-top-color: var(--border-color, #444);
1115
+ }
1116
+ </style>