picocrank 1.25.2 → 1.26.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 (40) hide show
  1. package/eslint.config.js +68 -0
  2. package/package.json +13 -3
  3. package/router.js +74 -0
  4. package/styles/dark-theme.css +20 -11
  5. package/styles/picocrank-extensions.css +77 -30
  6. package/supplemental-themes/catppuccin-latte-frappe/theme.css +10 -24
  7. package/supplemental-themes/dracula-alucard/theme.css +10 -24
  8. package/supplemental-themes/gruvbox-dark-light/theme.css +10 -24
  9. package/supplemental-themes/waffles/theme.css +2 -16
  10. package/vue/components/Breadcrumbs.vue +192 -45
  11. package/vue/components/Calendar.vue +45 -25
  12. package/vue/components/DangerZone.vue +186 -0
  13. package/vue/components/Header.vue +22 -6
  14. package/vue/components/NavigationGrid.vue +26 -180
  15. package/vue/components/NotificationBlock.vue +120 -0
  16. package/vue/components/NotificationPopup.vue +44 -41
  17. package/vue/components/ReadOnlyTextArea.vue +1 -1
  18. package/vue/components/SidebarNavLink.vue +3 -3
  19. package/vue/components/Table.vue +107 -79
  20. package/vue/components/TableColumnOptionsPopover.vue +9 -0
  21. package/vue/components/Tabs.vue +363 -79
  22. package/vue/components/TopBar.vue +2 -2
  23. package/vue/composables/tableColumnPriorities.js +33 -0
  24. package/vue/composables/tableFilters.js +1 -1
  25. package/vue/data/navSectionHubs.js +196 -0
  26. package/vue/data/routeBreadcrumbs.js +90 -0
  27. package/vue/examples/Admin.vue +12 -12
  28. package/vue/examples/App.vue +27 -36
  29. package/vue/examples/CalendarExample.vue +32 -22
  30. package/vue/examples/ExampleHeaderThemeSwitcher.vue +1 -1
  31. package/vue/examples/Login.vue +0 -3
  32. package/vue/examples/LoginExample.vue +2 -14
  33. package/vue/examples/NavSectionHub.vue +36 -0
  34. package/vue/examples/PageStructure.vue +6 -3
  35. package/vue/examples/StatusExample.vue +248 -129
  36. package/vue/examples/TableExample.vue +68 -67
  37. package/vue/examples/TabsExample.vue +200 -255
  38. package/vue/examples/ThemePreview.vue +21 -23
  39. package/vue/examples/ViewItem.vue +32 -5
  40. package/vue/examples/Welcome.vue +3 -40
@@ -1,112 +1,290 @@
1
1
  <template>
2
- <div class="tabs-container">
3
- <div class="tabs-header" role="tablist">
4
- <button
5
- v-for="(tab, index) in tabs"
6
- :key="tab.id || index"
7
- :class="['tab-button', { active: activeTabId === (tab.id || index) }]"
8
- :aria-selected="activeTabId === (tab.id || index)"
9
- :aria-controls="`tab-panel-${tab.id || index}`"
10
- :id="`tab-${tab.id || index}`"
11
- role="tab"
12
- @click="selectTab(tab.id || index)"
2
+ <div
3
+ class="tabs-container"
4
+ :class="{
5
+ 'tabs-vertical': orientation === 'vertical',
6
+ 'tabs-flush': !padding,
7
+ }"
8
+ >
9
+ <div class="tabs-header">
10
+ <div
11
+ class="tabs-header-scroll"
12
+ role="tablist"
13
+ :aria-orientation="orientation"
14
+ :aria-labelledby="tablistLabelledBy || undefined"
15
+ @keydown="onTabListKeydown"
13
16
  >
14
- <HugeiconsIcon
15
- v-if="tab.icon"
16
- :icon="tab.icon"
17
- width="1em"
18
- height="1em"
19
- />
20
- <span>{{ tab.label }}</span>
21
- <slot :name="`tab-${tab.id || index}-badge`" />
22
- </button>
17
+ <button
18
+ v-for="(tab, index) in tabs"
19
+ :key="getTabKey(tab, index)"
20
+ :ref="(el) => setTabRef(getTabKey(tab, index), el)"
21
+ :class="['tab-button', { active: isTabSelected(tab, index) }]"
22
+ :aria-selected="isTabSelected(tab, index) ? 'true' : 'false'"
23
+ :aria-controls="`tab-panel-${getTabKey(tab, index)}`"
24
+ :id="`tab-${getTabKey(tab, index)}`"
25
+ :tabindex="isTabFocused(tab, index) ? 0 : -1"
26
+ role="tab"
27
+ type="button"
28
+ @click="onTabClick(getTabKey(tab, index))"
29
+ >
30
+ <HugeiconsIcon
31
+ v-if="tab.icon"
32
+ :icon="tab.icon"
33
+ width="1em"
34
+ height="1em"
35
+ aria-hidden="true"
36
+ />
37
+ <span>{{ tab.label }}</span>
38
+ <slot :name="`tab-${getTabKey(tab, index)}-badge`" />
39
+ </button>
40
+ </div>
23
41
  </div>
24
42
  <div class="tabs-content">
25
43
  <div
26
44
  v-for="(tab, index) in tabs"
27
- :key="tab.id || index"
28
- :id="`tab-panel-${tab.id || index}`"
29
- :class="['tab-panel', { active: activeTabId === (tab.id || index) }]"
45
+ :key="`panel-${getTabKey(tab, index)}`"
46
+ :id="`tab-panel-${getTabKey(tab, index)}`"
47
+ :class="['tab-panel', { active: isTabSelected(tab, index) }]"
30
48
  role="tabpanel"
31
- :aria-labelledby="`tab-${tab.id || index}`"
32
- v-show="activeTabId === (tab.id || index)"
49
+ :aria-labelledby="`tab-${getTabKey(tab, index)}`"
50
+ :hidden="!isTabSelected(tab, index)"
51
+ :tabindex="isTabSelected(tab, index) ? 0 : -1"
33
52
  >
34
- <slot :name="`tab-${tab.id || index}`" :tab="tab" />
53
+ <slot :name="`tab-${getTabKey(tab, index)}`" :tab="tab" />
35
54
  </div>
36
55
  </div>
37
56
  </div>
38
57
  </template>
39
58
 
40
59
  <script setup>
41
- import { ref, onMounted, watch } from 'vue';
42
- import { HugeiconsIcon } from '@hugeicons/vue';
60
+ import { nextTick, onMounted, ref, watch } from 'vue'
61
+ import { HugeiconsIcon } from '@hugeicons/vue'
43
62
 
44
63
  const props = defineProps({
45
64
  tabs: {
46
65
  type: Array,
47
66
  required: true,
48
67
  validator: (tabs) => {
49
- return tabs.every(tab =>
50
- typeof tab === 'object' &&
51
- (tab.id !== undefined || true) &&
52
- typeof tab.label === 'string'
53
- );
54
- }
68
+ return tabs.every(tab =>
69
+ typeof tab === 'object'
70
+ && (tab.id !== undefined || true)
71
+ && typeof tab.label === 'string',
72
+ )
73
+ },
55
74
  },
56
75
  defaultTab: {
57
76
  type: [String, Number],
58
- default: null
59
- }
60
- });
77
+ default: null,
78
+ },
79
+ /** APG activation: `auto` selects on focus; `manual` requires Space/Enter. */
80
+ activation: {
81
+ type: String,
82
+ default: 'auto',
83
+ validator: (value) => ['auto', 'manual'].includes(value),
84
+ },
85
+ /** Optional id of an element that labels the tablist. */
86
+ tablistLabelledBy: {
87
+ type: String,
88
+ default: '',
89
+ },
90
+ /** Tab strip layout: `horizontal` (default) or `vertical`. */
91
+ orientation: {
92
+ type: String,
93
+ default: 'horizontal',
94
+ validator: (value) => ['horizontal', 'vertical'].includes(value),
95
+ },
96
+ /** When true, panels use full inset padding; default false keeps dividers flush. */
97
+ padding: {
98
+ type: Boolean,
99
+ default: false,
100
+ },
101
+ })
61
102
 
62
- const emit = defineEmits(['tab-change']);
103
+ const emit = defineEmits(['tab-change'])
104
+
105
+ const tabRefs = new Map()
63
106
 
64
- // Initialize activeTabId with first tab by default
65
107
  function getInitialTabId() {
66
108
  if (props.defaultTab !== null) {
67
- return props.defaultTab;
109
+ return props.defaultTab
68
110
  }
69
111
  if (props.tabs.length > 0) {
70
- return props.tabs[0].id ?? 0;
112
+ return getTabKey(props.tabs[0], 0)
71
113
  }
72
- return null;
114
+ return null
73
115
  }
74
116
 
75
- const activeTabId = ref(getInitialTabId());
117
+ const activeTabId = ref(getInitialTabId())
118
+ const focusedTabId = ref(getInitialTabId())
119
+
120
+ function getTabKey(tab, index) {
121
+ return tab.id ?? index
122
+ }
76
123
 
77
- function selectTab(tabId) {
78
- if (activeTabId.value !== tabId) {
79
- activeTabId.value = tabId;
80
- const activeTab = props.tabs.find(tab => (tab.id ?? props.tabs.indexOf(tab)) === tabId);
81
- emit('tab-change', activeTab, tabId);
124
+ function setTabRef(tabKey, el) {
125
+ if (el) {
126
+ tabRefs.set(tabKey, el)
127
+ return
82
128
  }
129
+ tabRefs.delete(tabKey)
83
130
  }
84
131
 
85
- function setActiveTab(tabId) {
86
- const tab = props.tabs.find(t => (t.id ?? props.tabs.indexOf(t)) === tabId);
87
- if (tab) {
88
- selectTab(tabId);
132
+ function tabIndexForKey(tabKey) {
133
+ return props.tabs.findIndex((tab, index) => getTabKey(tab, index) === tabKey)
134
+ }
135
+
136
+ function isTabSelected(tab, index) {
137
+ return activeTabId.value === getTabKey(tab, index)
138
+ }
139
+
140
+ function isTabFocused(tab, index) {
141
+ return focusedTabId.value === getTabKey(tab, index)
142
+ }
143
+
144
+ function focusTab(tabKey) {
145
+ tabRefs.get(tabKey)?.focus()
146
+ }
147
+
148
+ function selectTab(tabKey, { emitChange = true } = {}) {
149
+ if (activeTabId.value !== tabKey) {
150
+ activeTabId.value = tabKey
151
+ if (emitChange) {
152
+ const index = tabIndexForKey(tabKey)
153
+ const activeTab = index >= 0 ? props.tabs[index] : null
154
+ emit('tab-change', activeTab, tabKey)
155
+ }
89
156
  }
157
+ focusedTabId.value = tabKey
90
158
  }
91
159
 
92
- // Initialize active tab on mount if not already set
93
- onMounted(() => {
94
- if (activeTabId.value === null && props.tabs.length > 0) {
95
- activeTabId.value = props.tabs[0].id ?? 0;
160
+ function onTabClick(tabKey) {
161
+ selectTab(tabKey)
162
+ }
163
+
164
+ function moveFocusToIndex(index, { activate = props.activation === 'auto' } = {}) {
165
+ if (props.tabs.length === 0) {
166
+ return
167
+ }
168
+
169
+ const normalized = (index + props.tabs.length) % props.tabs.length
170
+ const tabKey = getTabKey(props.tabs[normalized], normalized)
171
+ focusedTabId.value = tabKey
172
+
173
+ if (activate) {
174
+ selectTab(tabKey)
175
+ }
176
+
177
+ nextTick(() => focusTab(tabKey))
178
+ }
179
+
180
+ function tabKeyFromElement(tabEl) {
181
+ const suffix = tabEl.id.slice('tab-'.length)
182
+ const index = props.tabs.findIndex((tab, i) => String(getTabKey(tab, i)) === suffix)
183
+ if (index >= 0) {
184
+ return getTabKey(props.tabs[index], index)
185
+ }
186
+ return suffix
187
+ }
188
+
189
+ function onTabListKeydown(event) {
190
+ const tabEl = event.target?.closest?.('[role="tab"]')
191
+ if (!tabEl || !tabEl.id?.startsWith('tab-')) {
192
+ return
193
+ }
194
+
195
+ const tabKey = tabKeyFromElement(tabEl)
196
+ const index = tabIndexForKey(tabKey)
197
+ if (index < 0) {
198
+ return
199
+ }
200
+
201
+ const isVertical = props.orientation === 'vertical'
202
+
203
+ switch (event.key) {
204
+ case 'ArrowRight':
205
+ if (!isVertical) {
206
+ event.preventDefault()
207
+ moveFocusToIndex(index + 1)
208
+ }
209
+ break
210
+ case 'ArrowLeft':
211
+ if (!isVertical) {
212
+ event.preventDefault()
213
+ moveFocusToIndex(index - 1)
214
+ }
215
+ break
216
+ case 'ArrowDown':
217
+ if (isVertical) {
218
+ event.preventDefault()
219
+ moveFocusToIndex(index + 1)
220
+ }
221
+ break
222
+ case 'ArrowUp':
223
+ if (isVertical) {
224
+ event.preventDefault()
225
+ moveFocusToIndex(index - 1)
226
+ }
227
+ break
228
+ case 'Home':
229
+ event.preventDefault()
230
+ moveFocusToIndex(0)
231
+ break
232
+ case 'End':
233
+ event.preventDefault()
234
+ moveFocusToIndex(props.tabs.length - 1)
235
+ break
236
+ case ' ':
237
+ case 'Enter':
238
+ if (props.activation === 'manual') {
239
+ event.preventDefault()
240
+ selectTab(tabKey)
241
+ }
242
+ break
243
+ default:
244
+ break
245
+ }
246
+ }
247
+
248
+ function setActiveTab(tabKey) {
249
+ const index = tabIndexForKey(tabKey)
250
+ if (index >= 0) {
251
+ selectTab(tabKey)
252
+ nextTick(() => focusTab(tabKey))
253
+ }
254
+ }
255
+
256
+ function syncTabIdsAfterListChange() {
257
+ if (props.tabs.length === 0) {
258
+ activeTabId.value = null
259
+ focusedTabId.value = null
260
+ return
96
261
  }
97
- });
98
262
 
99
- // Watch for tabs changes
100
- watch(() => props.tabs, (newTabs) => {
101
- if (newTabs.length > 0 && activeTabId.value === null) {
102
- activeTabId.value = newTabs[0].id ?? 0;
263
+ const activeStillExists = tabIndexForKey(activeTabId.value) >= 0
264
+ if (!activeStillExists) {
265
+ const firstKey = getTabKey(props.tabs[0], 0)
266
+ activeTabId.value = firstKey
267
+ focusedTabId.value = firstKey
268
+ return
103
269
  }
104
- }, { immediate: true });
270
+
271
+ if (tabIndexForKey(focusedTabId.value) < 0) {
272
+ focusedTabId.value = activeTabId.value
273
+ }
274
+ }
275
+
276
+ onMounted(() => {
277
+ syncTabIdsAfterListChange()
278
+ })
279
+
280
+ watch(() => props.tabs, () => {
281
+ syncTabIdsAfterListChange()
282
+ }, { deep: true })
105
283
 
106
284
  defineExpose({
107
285
  setActiveTab,
108
- activeTabId
109
- });
286
+ activeTabId,
287
+ })
110
288
  </script>
111
289
 
112
290
  <style scoped>
@@ -114,27 +292,55 @@ defineExpose({
114
292
  width: 100%;
115
293
  }
116
294
 
295
+ .tabs-container.tabs-vertical {
296
+ display: flex;
297
+ align-items: stretch;
298
+ gap: 0;
299
+ }
300
+
117
301
  .tabs-header {
302
+ border-bottom: 1px solid var(--tab-border);
303
+ }
304
+
305
+ .tabs-vertical .tabs-header {
306
+ flex-shrink: 0;
307
+ border-bottom: none;
308
+ border-right: 1px solid var(--tab-border);
309
+ }
310
+
311
+ .tabs-header-scroll {
118
312
  display: flex;
119
- border-bottom: 2px solid var(--tab-border);
120
313
  gap: 0.25rem;
121
314
  overflow-x: auto;
122
315
  scrollbar-width: thin;
123
316
  }
124
317
 
125
- .tabs-header::-webkit-scrollbar {
318
+ .tabs-header-scroll::-webkit-scrollbar {
126
319
  height: 4px;
127
320
  }
128
321
 
129
- .tabs-header::-webkit-scrollbar-track {
322
+ .tabs-header-scroll::-webkit-scrollbar-track {
130
323
  background: transparent;
131
324
  }
132
325
 
133
- .tabs-header::-webkit-scrollbar-thumb {
326
+ .tabs-header-scroll::-webkit-scrollbar-thumb {
134
327
  background: var(--tab-border);
135
328
  border-radius: 2px;
136
329
  }
137
330
 
331
+ .tabs-vertical .tabs-header-scroll {
332
+ flex-direction: column;
333
+ overflow-x: hidden;
334
+ overflow-y: auto;
335
+ min-width: 10rem;
336
+ max-height: 24rem;
337
+ }
338
+
339
+ .tabs-vertical .tabs-header-scroll::-webkit-scrollbar {
340
+ width: 4px;
341
+ height: auto;
342
+ }
343
+
138
344
  .tab-button {
139
345
  display: flex;
140
346
  align-items: center;
@@ -142,33 +348,89 @@ defineExpose({
142
348
  padding: 0.75rem 1.25rem;
143
349
  background: transparent;
144
350
  border: none;
145
- border-bottom: 2px solid transparent;
146
- border-radius: 0;
351
+ border-bottom: 1px solid transparent;
352
+ border-radius: 0.4em 0.4em 0 0;
147
353
  cursor: pointer;
148
354
  font-size: 1em;
149
355
  color: var(--tab-fg);
150
- transition: all 0.2s ease;
151
356
  white-space: nowrap;
152
357
  position: relative;
153
358
  }
154
359
 
155
- .tab-button:hover {
360
+ .tab-button:hover:not(.active) {
156
361
  color: var(--tab-hover-fg);
157
362
  background-color: var(--tab-hover-bg);
158
363
  }
159
364
 
160
365
  .tab-button.active {
366
+ background-color: var(--tab-active-bg);
161
367
  color: var(--tab-active-fg);
162
- border-bottom-color: var(--tab-active-fg);
163
- font-weight: bold;
368
+ border-bottom-color: transparent;
164
369
  }
165
370
 
166
- .tab-button:focus {
167
- outline: 2px solid var(--tab-focus-outline-color);
168
- outline-offset: -2px;
371
+ .tabs-vertical .tab-button {
372
+ width: 100%;
373
+ justify-content: flex-start;
374
+ border-bottom: none;
375
+ border-right: 1px solid transparent;
376
+ border-radius: 0.4em 0 0 0.4em;
377
+ }
378
+
379
+ .tabs-vertical .tab-button.active {
380
+ border-right-color: transparent;
381
+ }
382
+
383
+ .tabs-vertical .tabs-content {
384
+ flex: 1;
385
+ min-width: 0;
386
+ }
387
+
388
+ .tabs-flush .tab-panel {
389
+ padding: 0;
390
+ padding-top: 1.5rem;
391
+ }
392
+
393
+ .tabs-vertical.tabs-flush .tab-panel {
394
+ padding-top: 0;
395
+ padding-left: 0.75rem;
396
+ }
397
+
398
+ .tabs-vertical.tabs-flush .tabs-header {
399
+ display: flex;
400
+ flex-direction: column;
401
+ align-self: stretch;
402
+ }
403
+
404
+ .tabs-vertical.tabs-flush .tabs-header-scroll {
405
+ flex: 1;
406
+ max-height: none;
407
+ }
408
+
409
+ .tabs-vertical.tabs-flush .tab-button {
169
410
  border-radius: 0;
170
411
  }
171
412
 
413
+ .tab-button.active:hover,
414
+ .tab-button.active:focus-visible {
415
+ background-color: var(--tab-active-hover-bg);
416
+ color: var(--tab-active-fg);
417
+ }
418
+
419
+ .tab-button:focus {
420
+ outline: none;
421
+ }
422
+
423
+ .tab-button:focus-visible {
424
+ outline: none;
425
+ box-shadow: inset 0 0 0 2px var(--focus-outline-color);
426
+ z-index: 1;
427
+ }
428
+
429
+ .tab-button:hover:not(.active):focus-visible {
430
+ color: var(--tab-hover-fg);
431
+ background-color: var(--tab-hover-bg);
432
+ }
433
+
172
434
  .tabs-content {
173
435
  width: 100%;
174
436
  min-height: 200px;
@@ -177,11 +439,23 @@ defineExpose({
177
439
  .tab-panel {
178
440
  padding-block: 1.5rem;
179
441
  padding-inline: 0.75rem;
442
+ }
443
+
444
+ .tab-panel:focus {
445
+ outline: none;
446
+ }
447
+
448
+ .tab-panel:focus-visible {
449
+ outline: 2px solid var(--focus-outline-color);
450
+ outline-offset: 2px;
451
+ border-radius: 0.4em;
452
+ }
453
+
454
+ .tab-panel[hidden] {
180
455
  display: none;
181
456
  }
182
457
 
183
458
  .tab-panel.active {
184
- display: block;
185
459
  animation: fadeIn 0.2s ease-in;
186
460
  }
187
461
 
@@ -198,7 +472,7 @@ defineExpose({
198
472
 
199
473
  /* Responsive */
200
474
  @media (max-width: 768px) {
201
- .tabs-header {
475
+ .tabs-header-scroll {
202
476
  gap: 0;
203
477
  }
204
478
 
@@ -211,5 +485,15 @@ defineExpose({
211
485
  padding-block: 1rem;
212
486
  padding-inline: 0.5rem;
213
487
  }
488
+
489
+ .tabs-flush .tab-panel {
490
+ padding: 0;
491
+ padding-top: 1rem;
492
+ }
493
+
494
+ .tabs-vertical.tabs-flush .tab-panel {
495
+ padding-top: 0;
496
+ padding-left: 0.5rem;
497
+ }
214
498
  }
215
499
  </style>
@@ -97,7 +97,7 @@ function handleLinkClick(callback = null) {
97
97
  padding: 0.5em 0.75em;
98
98
  border-radius: 0.25em;
99
99
  text-decoration: none;
100
- color: var(--header-fg-color, var(--header-text-color, inherit));
100
+ color: var(--header-fg-color);
101
101
  transition: background-color 0.15s ease;
102
102
  white-space: nowrap;
103
103
  border: 0;
@@ -124,7 +124,7 @@ function handleLinkClick(callback = null) {
124
124
 
125
125
  html[data-theme="dark"] {
126
126
  .topbar-link {
127
- color: var(--header-fg-color, var(--header-text-color, #f8f9fa));
127
+ color: var(--header-fg-color);
128
128
  }
129
129
 
130
130
  .separator {
@@ -0,0 +1,33 @@
1
+ export function isValidColPriority(priority) {
2
+ return Number.isInteger(priority) && priority >= 1 && priority <= 5
3
+ }
4
+
5
+ /** Default responsive priority by visible column index (0-based). First column has no auto priority. */
6
+ export function autoColumnPriorityForIndex(index) {
7
+ if (index <= 0) {
8
+ return null
9
+ }
10
+ if (index === 1) {
11
+ return 5
12
+ }
13
+ if (index === 2) {
14
+ return 4
15
+ }
16
+ if (index === 3) {
17
+ return 3
18
+ }
19
+ if (index === 4) {
20
+ return 2
21
+ }
22
+ return 1
23
+ }
24
+
25
+ export function defaultColumnPriorityForIndex(header, index, responsiveColumns = true) {
26
+ if (isValidColPriority(header?.colPriority)) {
27
+ return header.colPriority
28
+ }
29
+ if (!responsiveColumns) {
30
+ return null
31
+ }
32
+ return autoColumnPriorityForIndex(index)
33
+ }
@@ -120,7 +120,7 @@ export function filterEntrySpec(entry) {
120
120
  return null
121
121
  }
122
122
 
123
- const { id, name, enabled, ...filter } = entry
123
+ const { id: _id, name: _name, enabled: _enabled, ...filter } = entry
124
124
  return filter
125
125
  }
126
126