picocrank 1.24.0 → 1.25.1

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 (41) 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 +34 -2
  6. package/public/themes/index.json +1 -5
  7. package/styles/dark-theme.css +42 -186
  8. package/styles/picocrank-extensions.css +15 -0
  9. package/styles.css +1 -0
  10. package/supplemental-themes/README.md +9 -0
  11. package/supplemental-themes/catppuccin-latte-frappe/theme.css +211 -0
  12. package/supplemental-themes/dracula-alucard/theme.css +193 -0
  13. package/supplemental-themes/gruvbox-dark-light/theme.css +199 -0
  14. package/supplemental-themes/index.json +17 -0
  15. package/{public/themes → supplemental-themes}/waffles/theme.css +69 -46
  16. package/vite.config.js +91 -0
  17. package/vue/components/Header.vue +3 -3
  18. package/vue/components/Login.vue +1 -3
  19. package/vue/components/Section.vue +45 -14
  20. package/vue/components/Sidebar.vue +6 -11
  21. package/vue/components/SidebarNavLink.vue +6 -9
  22. package/vue/components/Table.vue +1115 -75
  23. package/vue/components/TableColumnFilterPopover.vue +263 -44
  24. package/vue/components/TableColumnOptionsPopover.vue +1116 -0
  25. package/vue/components/Tabs.vue +4 -25
  26. package/vue/components/ThemeSwitcher.vue +158 -0
  27. package/vue/components/TopBar.vue +2 -2
  28. package/vue/composables/tableColumnPresets.js +296 -0
  29. package/vue/composables/tableFilterQuery.js +202 -0
  30. package/vue/composables/tableFilters.js +167 -3
  31. package/vue/composables/useCustomTheme.js +167 -24
  32. package/vue/data/examplePeople.js +2 -6
  33. package/vue/examples/App.vue +14 -1
  34. package/vue/examples/ExampleHeaderThemeSwitcher.vue +78 -0
  35. package/vue/examples/LoginExample.vue +1 -3
  36. package/vue/examples/PageStructure.vue +17 -0
  37. package/vue/examples/TableExample.vue +329 -11
  38. package/vue/examples/TableRemoteExample.vue +112 -4
  39. package/vue/examples/ThemePreview.vue +45 -38
  40. package/public/themes/catppuccin/theme.css +0 -195
  41. package/public/themes/dracula/theme.css +0 -181
@@ -26,7 +26,7 @@
26
26
  </label>
27
27
  <label v-if="draft.operator !== 'empty'">
28
28
  <span class="field-label">Value</span>
29
- <input v-model="draft.value" type="text" @keydown.enter.prevent="apply" />
29
+ <input v-model="draft.value" type="text" @keydown.enter.prevent="addFilter" />
30
30
  </label>
31
31
  </div>
32
32
 
@@ -43,7 +43,7 @@
43
43
  </label>
44
44
  <label>
45
45
  <span class="field-label">Value</span>
46
- <input v-model="draft.value" type="number" @keydown.enter.prevent="apply" />
46
+ <input v-model="draft.value" type="number" @keydown.enter.prevent="addFilter" />
47
47
  </label>
48
48
  </div>
49
49
 
@@ -70,12 +70,76 @@
70
70
  </div>
71
71
  <p v-if="selectOptions.length === 0" class="subtle">No options available.</p>
72
72
  </div>
73
+
74
+ <label class="filter-name-field">
75
+ <span class="field-label">Name (optional)</span>
76
+ <input
77
+ v-model="draftName"
78
+ type="text"
79
+ placeholder="Label for this filter"
80
+ @keydown.enter.prevent="addFilter"
81
+ />
82
+ </label>
73
83
  </div>
74
84
 
75
85
  <div ref="actionsRef" role="toolbar" class="popover-actions">
76
86
  <button type="button" class="neutral" @click="cancel">Cancel</button>
77
87
  <button type="button" class="neutral" @click="clear">Clear</button>
78
- <button type="button" @click="apply">Apply</button>
88
+ <button type="button" :disabled="!canAddFilter" @click="addFilter">Add filter</button>
89
+ </div>
90
+
91
+ <div v-if="savedFilters.length > 0" class="saved-filters">
92
+ <p class="saved-filters-heading">Filters</p>
93
+ <ul class="saved-filters-list">
94
+ <li
95
+ v-for="(entry, index) in savedFilters"
96
+ :key="entry.id"
97
+ class="saved-filter-item"
98
+ >
99
+ <label class="saved-filter-label">
100
+ <input
101
+ type="checkbox"
102
+ :checked="entry.enabled"
103
+ @change="toggleFilterEnabled(entry.id, $event.target.checked)"
104
+ />
105
+ <span>{{ filterLabel(entry) }}</span>
106
+ </label>
107
+ <div class="saved-filter-actions" role="group" :aria-label="`Actions for ${filterLabel(entry)}`">
108
+ <button
109
+ type="button"
110
+ class="neutral saved-filter-action-button"
111
+ :disabled="index === 0"
112
+ aria-label="Move filter up"
113
+ @click="moveFilter(index, -1)"
114
+ >
115
+ ↑
116
+ </button>
117
+ <button
118
+ type="button"
119
+ class="neutral saved-filter-action-button"
120
+ :disabled="index === savedFilters.length - 1"
121
+ aria-label="Move filter down"
122
+ @click="moveFilter(index, 1)"
123
+ >
124
+ ↓
125
+ </button>
126
+ <button
127
+ type="button"
128
+ class="neutral saved-filter-action-button saved-filter-delete-button"
129
+ aria-label="Delete filter"
130
+ @click="deleteFilter(entry.id)"
131
+ >
132
+ <HugeiconsIcon
133
+ :icon="Delete02Icon"
134
+ width="0.9em"
135
+ height="0.9em"
136
+ :strokeWidth="2"
137
+ aria-hidden="true"
138
+ />
139
+ </button>
140
+ </div>
141
+ </li>
142
+ </ul>
79
143
  </div>
80
144
  </div>
81
145
  </Teleport>
@@ -83,11 +147,17 @@
83
147
 
84
148
  <script setup>
85
149
  import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue'
150
+ import { HugeiconsIcon } from '@hugeicons/vue'
151
+ import { Delete02Icon } from '@hugeicons/core-free-icons'
86
152
  import CheckGroup from './CheckGroup.vue'
87
153
  import RadioGroup from './RadioGroup.vue'
88
154
  import {
89
155
  createEmptyFilter,
156
+ createFilterEntry,
157
+ describeFilter,
90
158
  inferFilterType,
159
+ isFilterEntryActive,
160
+ normalizeColumnFilterEntries,
91
161
  normalizeFilter,
92
162
  } from '../composables/tableFilters.js'
93
163
 
@@ -105,8 +175,8 @@ const props = defineProps({
105
175
  default: null,
106
176
  },
107
177
  value: {
108
- type: Object,
109
- default: null,
178
+ type: Array,
179
+ default: () => [],
110
180
  },
111
181
  rows: {
112
182
  type: Array,
@@ -132,6 +202,8 @@ const popoverStyle = ref({ top: '0px', left: '0px' })
132
202
  const checkGroupScrollStyle = ref({})
133
203
  const titleId = useId()
134
204
  const draft = ref(createEmptyFilter({ key: '' }))
205
+ const draftName = ref('')
206
+ const savedFilters = ref([])
135
207
 
136
208
  const filterType = computed(() => {
137
209
  if (!props.header) {
@@ -142,18 +214,16 @@ const filterType = computed(() => {
142
214
 
143
215
  const columnLabel = computed(() => props.header?.label || props.header?.key || '')
144
216
 
145
- const selectedCount = computed(() => {
146
- if (filterType.value !== 'select' || !Array.isArray(draft.value.values)) {
147
- return 0
148
- }
149
- return draft.value.values.length
150
- })
217
+ const activeSavedCount = computed(() =>
218
+ savedFilters.value.filter((entry) => isFilterEntryActive(entry)).length,
219
+ )
151
220
 
152
221
  const popoverTitle = computed(() => {
153
- if (filterType.value === 'select') {
154
- return `Filter ${columnLabel.value} (${selectedCount.value} selected)`
222
+ const base = columnLabel.value
223
+ if (activeSavedCount.value > 0) {
224
+ return `Filter ${base} (${activeSavedCount.value})`
155
225
  }
156
- return `Filter ${columnLabel.value}`
226
+ return `Filter ${base}`
157
227
  })
158
228
 
159
229
  const booleanOptions = [
@@ -177,6 +247,15 @@ const booleanValue = computed({
177
247
  },
178
248
  })
179
249
 
250
+ const canAddFilter = computed(() =>
251
+ Boolean(normalizeFilter(props.header, draft.value, props.rows)),
252
+ )
253
+
254
+ function filterLabel(entry) {
255
+ const name = entry.name?.trim()
256
+ return name || describeFilter(props.header, entry)
257
+ }
258
+
180
259
  function positionPopover() {
181
260
  const anchor = props.anchorEl
182
261
  if (!anchor || !popoverRef.value) {
@@ -254,22 +333,28 @@ function updateCheckGroupScrollLimit() {
254
333
  }
255
334
  }
256
335
 
257
- function resetDraft() {
336
+ function resetDraftForm() {
258
337
  if (!props.header) {
259
338
  draft.value = createEmptyFilter({ key: '' })
339
+ draftName.value = ''
260
340
  return
261
341
  }
262
342
 
263
- const base = createEmptyFilter(props.header, props.rows)
264
- if (props.value) {
265
- draft.value = {
266
- ...base,
267
- ...JSON.parse(JSON.stringify(props.value)),
268
- }
343
+ draft.value = createEmptyFilter(props.header, props.rows)
344
+ draftName.value = ''
345
+ }
346
+
347
+ function resetSavedFilters() {
348
+ if (!props.header) {
349
+ savedFilters.value = []
269
350
  return
270
351
  }
271
352
 
272
- draft.value = base
353
+ savedFilters.value = normalizeColumnFilterEntries(props.value, props.header, props.rows)
354
+ }
355
+
356
+ function emitFilters() {
357
+ emit('apply', savedFilters.value.map((entry) => ({ ...entry })))
273
358
  }
274
359
 
275
360
  function onDocumentPointerDown(event) {
@@ -295,48 +380,110 @@ function onDocumentKeyDown(event) {
295
380
  }
296
381
  }
297
382
 
298
- function apply() {
299
- const normalized = normalizeFilter(props.header, draft.value, props.rows)
300
- emit('apply', normalized)
301
- emit('update:open', false)
383
+ function addFilter() {
384
+ if (!canAddFilter.value) {
385
+ return
386
+ }
387
+
388
+ const entry = createFilterEntry(
389
+ props.header,
390
+ draft.value,
391
+ props.rows,
392
+ { name: draftName.value },
393
+ )
394
+ if (!entry) {
395
+ return
396
+ }
397
+
398
+ savedFilters.value = [...savedFilters.value, entry]
399
+ resetDraftForm()
400
+ emitFilters()
401
+ nextTick(() => {
402
+ updateCheckGroupScrollLimit()
403
+ positionPopover()
404
+ })
405
+ }
406
+
407
+ function toggleFilterEnabled(id, enabled) {
408
+ savedFilters.value = savedFilters.value.map((entry) => (
409
+ entry.id === id ? { ...entry, enabled } : entry
410
+ ))
411
+ emitFilters()
412
+ }
413
+
414
+ function moveFilter(index, direction) {
415
+ const targetIndex = index + direction
416
+ if (targetIndex < 0 || targetIndex >= savedFilters.value.length) {
417
+ return
418
+ }
419
+
420
+ const next = [...savedFilters.value]
421
+ const [entry] = next.splice(index, 1)
422
+ next.splice(targetIndex, 0, entry)
423
+ savedFilters.value = next
424
+ emitFilters()
425
+ }
426
+
427
+ function deleteFilter(id) {
428
+ savedFilters.value = savedFilters.value.filter((entry) => entry.id !== id)
429
+ emitFilters()
302
430
  }
303
431
 
304
432
  function clear() {
433
+ savedFilters.value = []
434
+ resetDraftForm()
305
435
  emit('clear')
306
436
  emit('update:open', false)
307
437
  }
308
438
 
309
439
  function cancel() {
310
- emit('cancel')
311
440
  emit('update:open', false)
441
+ emit('cancel')
442
+ }
443
+
444
+ function detachDocumentListeners() {
445
+ document.removeEventListener('pointerdown', onDocumentPointerDown)
446
+ document.removeEventListener('keydown', onDocumentKeyDown)
447
+ }
448
+
449
+ function attachDocumentListeners() {
450
+ detachDocumentListeners()
451
+ document.addEventListener('pointerdown', onDocumentPointerDown)
452
+ document.addEventListener('keydown', onDocumentKeyDown)
453
+ }
454
+
455
+ async function initializePopover() {
456
+ resetSavedFilters()
457
+ resetDraftForm()
458
+ await nextTick()
459
+ updateCheckGroupScrollLimit()
460
+ await nextTick()
461
+ positionPopover()
462
+ popoverRef.value?.querySelector('input, select, button')?.focus()
463
+ attachDocumentListeners()
312
464
  }
313
465
 
314
466
  watch(
315
- () => [props.open, props.header, props.value, props.selectOptions.length],
316
- async ([isOpen]) => {
467
+ () => [props.open, props.header?.key],
468
+ async ([isOpen, headerKey], previous) => {
469
+ const wasOpen = previous?.[0] ?? false
470
+ const previousHeaderKey = previous?.[1]
471
+
317
472
  if (!isOpen) {
318
473
  checkGroupScrollStyle.value = {}
319
- document.removeEventListener('pointerdown', onDocumentPointerDown)
320
- document.removeEventListener('keydown', onDocumentKeyDown)
474
+ detachDocumentListeners()
321
475
  return
322
476
  }
323
477
 
324
- resetDraft()
325
- await nextTick()
326
- updateCheckGroupScrollLimit()
327
- await nextTick()
328
- positionPopover()
329
- popoverRef.value?.querySelector('input, select, button')?.focus()
330
-
331
- document.addEventListener('pointerdown', onDocumentPointerDown)
332
- document.addEventListener('keydown', onDocumentKeyDown)
478
+ if (!wasOpen || headerKey !== previousHeaderKey) {
479
+ await initializePopover()
480
+ }
333
481
  },
334
482
  { immediate: true },
335
483
  )
336
484
 
337
485
  onBeforeUnmount(() => {
338
- document.removeEventListener('pointerdown', onDocumentPointerDown)
339
- document.removeEventListener('keydown', onDocumentKeyDown)
486
+ detachDocumentListeners()
340
487
  })
341
488
  </script>
342
489
 
@@ -382,13 +529,22 @@ onBeforeUnmount(() => {
382
529
  min-height: 0;
383
530
  }
384
531
 
532
+ .filter-name-field {
533
+ display: flex;
534
+ flex-direction: column;
535
+ gap: 0.25rem;
536
+ margin-top: 0.75rem;
537
+ flex-shrink: 0;
538
+ }
539
+
385
540
  .checkgroup-scroll {
386
541
  overflow-y: auto;
387
542
  min-height: 0;
388
543
  overscroll-behavior: contain;
389
544
  }
390
545
 
391
- .filter-fields label {
546
+ .filter-fields label,
547
+ .filter-name-field {
392
548
  display: flex;
393
549
  flex-direction: column;
394
550
  gap: 0.25rem;
@@ -410,12 +566,75 @@ onBeforeUnmount(() => {
410
566
  border-top: 1px solid var(--border-color, #e1e5e9);
411
567
  }
412
568
 
569
+ .saved-filters {
570
+ flex-shrink: 0;
571
+ margin-top: 0.75rem;
572
+ padding-top: 0.75rem;
573
+ border-top: 1px solid var(--border-color, #e1e5e9);
574
+ min-height: 0;
575
+ }
576
+
577
+ .saved-filters-heading {
578
+ margin: 0 0 0.5rem;
579
+ font-size: 0.85rem;
580
+ color: var(--text-muted, #666);
581
+ }
582
+
583
+ .saved-filters-list {
584
+ list-style: none;
585
+ margin: 0;
586
+ padding: 0;
587
+ display: flex;
588
+ flex-direction: column;
589
+ gap: 0.35rem;
590
+ max-height: 12rem;
591
+ overflow-y: auto;
592
+ }
593
+
594
+ .saved-filter-item {
595
+ display: grid;
596
+ grid-template-columns: minmax(0, 1fr) auto;
597
+ align-items: center;
598
+ gap: 0.35rem;
599
+ }
600
+
601
+ .saved-filter-label {
602
+ display: flex;
603
+ align-items: flex-start;
604
+ gap: 0.45rem;
605
+ min-width: 0;
606
+ }
607
+
608
+ .saved-filter-label span {
609
+ overflow: hidden;
610
+ text-overflow: ellipsis;
611
+ }
612
+
613
+ .saved-filter-actions {
614
+ display: inline-flex;
615
+ gap: 0.15rem;
616
+ }
617
+
618
+ .saved-filter-action-button {
619
+ min-width: 1.6rem;
620
+ padding-inline: 0.25rem;
621
+ font-size: 0.85rem;
622
+ line-height: 1.2;
623
+ }
624
+
625
+ .saved-filter-delete-button {
626
+ display: inline-flex;
627
+ align-items: center;
628
+ justify-content: center;
629
+ }
630
+
413
631
  html[data-theme="dark"] .table-column-filter-popover {
414
632
  background: var(--background-color, #1e1e1e);
415
633
  border-color: var(--border-color, #444);
416
634
  }
417
635
 
418
- html[data-theme="dark"] .popover-actions {
636
+ html[data-theme="dark"] .popover-actions,
637
+ html[data-theme="dark"] .saved-filters {
419
638
  border-top-color: var(--border-color, #444);
420
639
  }
421
640
  </style>