picocrank 1.21.5 → 1.22.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.
@@ -1,100 +1,115 @@
1
1
  <template>
2
- <aside ref="sidebarRef" :class="{ 'shown': isOpen, 'stuck': isStuck }" class="sidebar">
3
- <div class = "flex-row">
4
- <h2>Navigation</h2>
5
- <div class = "fg1" />
6
- <button class="stick-toggle" :aria-pressed="isStuck" :title="isStuck ? 'Unstick sidebar' : 'Stick sidebar'" @click="toggleStick">
7
- <span v-if="isStuck">
8
- <HugeiconsIcon :icon="Pin02Icon" width = "1em" height = "1em" :strokeWidth = 3 />
9
- </span>
10
- <span v-else>
11
- <HugeiconsIcon :icon="PinIcon" width = "1em" height = "1em" :strokeWidth = 3 />
12
- </span>
13
- </button>
14
- </div>
15
-
16
- <nav class="mainnav">
17
- <ul class="navigation-links">
18
- <template v-for="item in visibleNavItems" :key="item.key">
19
- <li v-if="item.kind === 'section-header'" class="nav-section-header-item">
20
- <button
21
- type="button"
22
- class="nav-section-header"
23
- :aria-expanded="!item.collapsed"
24
- :aria-controls="`nav-section-${item.section.id}`"
25
- @click="toggleSection(item.section.id)"
26
- >
27
- <span>{{ item.section.title }}</span>
28
- <HugeiconsIcon
29
- :icon="item.collapsed ? ArrowRight01Icon : ArrowDown01Icon"
30
- width="0.85em"
31
- height="0.85em"
32
- class="nav-section-chevron"
33
- />
34
- </button>
35
- </li>
36
-
37
- <li
38
- v-else
39
- :title="item.link.title"
40
- :class="{ disabled: item.link.disabled }"
41
- >
42
- <div v-if="item.link.type === 'separator'" class="separator"></div>
43
- <div v-else-if="item.link.type === 'html'" v-html="item.link.html"></div>
44
- <a
45
- v-else-if="item.link.type === 'callback' || item.link.disabled"
46
- href="#"
47
- :class="{ active: isLinkActive(item.link), disabled: item.link.disabled }"
48
- :aria-disabled="item.link.disabled || undefined"
49
- @click.prevent="handleNavItemClick(item.link)"
50
- >
51
- <HugeiconsIcon :icon="item.link.icon" />
52
- <span class="nav-link-title">{{ item.link.title }}</span>
53
- <span
54
- v-if="showCount(item.link)"
55
- class="nav-link-count"
56
- :aria-label="`${item.link.count} notifications`"
57
- >{{ formatCount(item.link.count) }}</span>
58
- <span
59
- v-else-if="showIndicator(item.link)"
60
- class="nav-link-indicator"
61
- aria-label="Requires attention"
62
- />
63
- </a>
64
- <router-link
65
- v-else
66
- v-bind="item.link.props || {}"
67
- :to="item.link.to || item.link.path"
68
- :class="{ active: isLinkActive(item.link) }"
69
- @click="handleLinkClick()"
70
- >
71
- <HugeiconsIcon :icon="item.link.icon" />
72
- <span class="nav-link-title">{{ item.link.title }}</span>
73
- <span
74
- v-if="showCount(item.link)"
75
- class="nav-link-count"
76
- :aria-label="`${item.link.count} notifications`"
77
- >{{ formatCount(item.link.count) }}</span>
78
- <span
79
- v-else-if="showIndicator(item.link)"
80
- class="nav-link-indicator"
81
- aria-label="Requires attention"
82
- />
83
- </router-link>
84
- </li>
85
- </template>
86
- </ul>
87
- </nav>
2
+ <aside
3
+ :id="SIDEBAR_PANEL_ID"
4
+ ref="sidebarRef"
5
+ class="sidebar"
6
+ :class="{ 'shown': isOpen, 'stuck': isStuck }"
7
+ aria-label="Sidebar"
8
+ :aria-hidden="isVisible ? undefined : 'true'"
9
+ :inert="isVisible ? undefined : true"
10
+ >
11
+ <div class="flex-row">
12
+ <h2 :id="headingId">Navigation</h2>
13
+ <div class="fg1" />
14
+ <button
15
+ type="button"
16
+ class="stick-toggle"
17
+ :aria-pressed="isStuck"
18
+ :aria-label="isStuck ? 'Unstick sidebar' : 'Stick sidebar'"
19
+ :title="isStuck ? 'Unstick sidebar' : 'Stick sidebar'"
20
+ @click="toggleStick"
21
+ >
22
+ <span aria-hidden="true">
23
+ <HugeiconsIcon
24
+ v-if="isStuck"
25
+ :icon="Pin02Icon"
26
+ width="1em"
27
+ height="1em"
28
+ :strokeWidth="3"
29
+ />
30
+ <HugeiconsIcon
31
+ v-else
32
+ :icon="PinIcon"
33
+ width="1em"
34
+ height="1em"
35
+ :strokeWidth="3"
36
+ />
37
+ </span>
38
+ </button>
39
+ </div>
40
+
41
+ <nav class="mainnav" :aria-labelledby="headingId">
42
+ <menu class="navigation-links">
43
+ <SidebarNavLink
44
+ v-for="link in navigationGroups.ungrouped"
45
+ :key="link.name"
46
+ :link="link"
47
+ :active="isLinkActive(link)"
48
+ @select="handleNavItemClick"
49
+ />
50
+
51
+ <li
52
+ v-for="section in navigationGroups.sections"
53
+ :key="section.id"
54
+ class="nav-section"
55
+ >
56
+ <button
57
+ type="button"
58
+ class="nav-section-header"
59
+ :id="sectionHeaderId(section.id)"
60
+ :aria-expanded="!isSectionCollapsed(section.id)"
61
+ :aria-controls="sectionPanelId(section.id)"
62
+ @click="toggleSection(section.id)"
63
+ >
64
+ <span>{{ section.title }}</span>
65
+ <HugeiconsIcon
66
+ :icon="isSectionCollapsed(section.id) ? ArrowRight01Icon : ArrowDown01Icon"
67
+ width="0.85em"
68
+ height="0.85em"
69
+ class="nav-section-chevron"
70
+ aria-hidden="true"
71
+ />
72
+ </button>
73
+
74
+ <menu
75
+ :id="sectionPanelId(section.id)"
76
+ class="nav-section-links"
77
+ role="group"
78
+ :aria-labelledby="sectionHeaderId(section.id)"
79
+ :hidden="isSectionCollapsed(section.id)"
80
+ >
81
+ <SidebarNavLink
82
+ v-for="link in section.items"
83
+ :key="link.name"
84
+ :link="link"
85
+ :active="isLinkActive(link)"
86
+ @select="handleNavItemClick"
87
+ />
88
+ </menu>
89
+ </li>
90
+
91
+ <SidebarNavLink
92
+ v-for="link in navigationGroups.trailingUngrouped"
93
+ :key="link.name"
94
+ :link="link"
95
+ :active="isLinkActive(link)"
96
+ @select="handleNavItemClick"
97
+ />
98
+ </menu>
99
+ </nav>
88
100
  </aside>
89
101
  </template>
90
102
 
91
103
  <script setup>
92
- import { ref, inject, onUnmounted, watch, nextTick, computed } from 'vue'
104
+ import { ref, inject, onUnmounted, watch, nextTick, computed, useId } from 'vue'
93
105
  import { useRoute } from 'vue-router'
94
106
  import { HugeiconsIcon } from '@hugeicons/vue'
95
107
  import { Pin02Icon, PinIcon, ArrowDown01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons'
108
+ import SidebarNavLink from './SidebarNavLink.vue'
96
109
 
97
110
  const STORAGE_KEY = 'picocrank-nav-sections-collapsed'
111
+ const SIDEBAR_PANEL_ID = 'picocrank-sidebar'
112
+ const TOGGLER_ID = 'sidebar-toggler-button'
98
113
 
99
114
  const props = defineProps({
100
115
  navigation: {
@@ -107,13 +122,15 @@ const isOpen = ref(false)
107
122
  const isStuck = ref(false)
108
123
  const sidebarRef = ref(null)
109
124
  const route = useRoute()
125
+ const headingId = useId()
126
+
127
+ const isVisible = computed(() => isOpen.value || isStuck.value)
110
128
 
111
129
  const injectedNavigation = inject('navigation', null)
112
130
 
113
131
  const navigation = computed(() => {
114
132
  const nav = props.navigation || injectedNavigation
115
133
  if (!nav) return null
116
- // Component ref (or ref-like): use exposed API
117
134
  if (nav.value) {
118
135
  return nav.value
119
136
  }
@@ -158,6 +175,14 @@ function isSectionCollapsed(sectionId) {
158
175
  return collapsedSections.value.has(sectionId)
159
176
  }
160
177
 
178
+ function sectionHeaderId(sectionId) {
179
+ return `nav-section-header-${sectionId}`
180
+ }
181
+
182
+ function sectionPanelId(sectionId) {
183
+ return `nav-section-${sectionId}`
184
+ }
185
+
161
186
  function toggleSection(sectionId) {
162
187
  const next = new Set(collapsedSections.value)
163
188
  if (next.has(sectionId)) {
@@ -202,40 +227,6 @@ const navigationGroups = computed(() => {
202
227
  return { ungrouped, trailingUngrouped, sections }
203
228
  })
204
229
 
205
- const visibleNavItems = computed(() => {
206
- const items = []
207
-
208
- for (const link of navigationGroups.value.ungrouped) {
209
- items.push({ key: link.name, kind: 'link', link })
210
- }
211
-
212
- for (const section of navigationGroups.value.sections) {
213
- const collapsed = isSectionCollapsed(section.id)
214
- items.push({
215
- key: section.id,
216
- kind: 'section-header',
217
- section,
218
- collapsed,
219
- })
220
-
221
- if (!collapsed) {
222
- for (const link of section.items) {
223
- items.push({
224
- key: link.name,
225
- kind: 'link',
226
- link,
227
- })
228
- }
229
- }
230
- }
231
-
232
- for (const link of navigationGroups.value.trailingUngrouped) {
233
- items.push({ key: link.name, kind: 'link', link })
234
- }
235
-
236
- return items
237
- })
238
-
239
230
  function expandSectionContainingActiveRoute() {
240
231
  for (const section of navigationGroups.value.sections) {
241
232
  const hasActiveLink = section.items.some((link) => isActive(link))
@@ -254,126 +245,143 @@ watch(() => route.fullPath, () => {
254
245
  }, { immediate: true })
255
246
 
256
247
  function toggleStick() {
257
- isStuck.value = !isStuck.value
248
+ isStuck.value = !isStuck.value
258
249
  }
259
250
 
260
251
  function stick() {
261
- isStuck.value = true
252
+ isStuck.value = true
262
253
  }
263
254
 
264
255
  function unstick() {
265
- isStuck.value = false
256
+ isStuck.value = false
266
257
  }
267
258
 
268
259
  function toggle() {
269
- isOpen.value = !isOpen.value
270
- isStuck.value = false
260
+ isOpen.value = !isOpen.value
261
+ isStuck.value = false
271
262
  }
272
263
 
273
264
  function open() {
274
- isOpen.value = true
265
+ isOpen.value = true
275
266
  }
276
267
 
277
268
  function close() {
278
- isOpen.value = false
279
- isStuck.value = false
269
+ isOpen.value = false
270
+ isStuck.value = false
280
271
  }
281
272
 
282
273
  function isLinkActive(link) {
283
- if (link.disabled) {
284
- return false
285
- }
286
- return isActive(link)
274
+ if (link.disabled) {
275
+ return false
276
+ }
277
+ return isActive(link)
287
278
  }
288
279
 
289
- function showCount(link) {
290
- return !link.disabled && link.count != null && link.count > 0
280
+ function handleNavItemClick(link) {
281
+ if (link.disabled) {
282
+ return
283
+ }
284
+ if (link.type === 'callback' && link.callback) {
285
+ handleLinkClick(() => link.callback())
286
+ return
287
+ }
288
+ handleLinkClick()
291
289
  }
292
290
 
293
- function showIndicator(link) {
294
- return link.indicator && !link.disabled && !showCount(link)
291
+ function handleLinkClick(callback = null) {
292
+ if (callback) {
293
+ callback()
294
+ }
295
+ if (!isStuck.value) {
296
+ close()
297
+ }
295
298
  }
296
299
 
297
- function formatCount(count) {
298
- return count > 99 ? '99+' : String(count)
299
- }
300
+ function syncTogglerAria() {
301
+ const toggler = document.getElementById(TOGGLER_ID)
302
+ if (!toggler) {
303
+ return
304
+ }
300
305
 
301
- function handleNavItemClick(link) {
302
- if (link.disabled) {
303
- return
304
- }
305
- if (link.type === 'callback' && link.callback) {
306
- handleLinkClick(() => link.callback())
307
- return
308
- }
309
- handleLinkClick()
306
+ const expanded = isVisible.value
307
+ toggler.setAttribute('aria-expanded', String(expanded))
308
+ toggler.setAttribute('aria-controls', SIDEBAR_PANEL_ID)
309
+ toggler.setAttribute(
310
+ 'aria-label',
311
+ expanded ? 'Close sidebar navigation' : 'Open sidebar navigation',
312
+ )
313
+ toggler.removeAttribute('aria-pressed')
314
+ toggler.removeAttribute('aria-haspopup')
310
315
  }
311
316
 
312
- function handleLinkClick(callback = null) {
313
- if (callback) {
314
- callback()
315
- }
316
- if (!isStuck.value) {
317
- close()
318
- }
317
+ function handleClickOutside(event) {
318
+ if (!isOpen.value || isStuck.value) {
319
+ return
320
+ }
321
+
322
+ const target = event.target
323
+
324
+ const sidebarButton = document.getElementById('sidebar-button')
325
+ const sidebarTogglerButton = document.getElementById(TOGGLER_ID)
326
+
327
+ if (sidebarButton && sidebarButton.contains(target)) {
328
+ return
329
+ }
330
+
331
+ if (sidebarTogglerButton && sidebarTogglerButton.contains(target)) {
332
+ return
333
+ }
334
+
335
+ if (sidebarRef.value && !sidebarRef.value.contains(target)) {
336
+ close()
337
+ }
319
338
  }
320
339
 
321
- function handleClickOutside(event) {
322
- if (!isOpen.value || isStuck.value) {
323
- return
324
- }
325
-
326
- const target = event.target
327
-
328
- const sidebarButton = document.getElementById('sidebar-button')
329
- const sidebarTogglerButton = document.getElementById('sidebar-toggler-button')
330
-
331
- if (sidebarButton && sidebarButton.contains(target)) {
332
- return
333
- }
334
-
335
- if (sidebarTogglerButton && sidebarTogglerButton.contains(target)) {
336
- return
337
- }
338
-
339
- if (sidebarRef.value && !sidebarRef.value.contains(target)) {
340
- close()
341
- }
340
+ function handleDocumentKeydown(event) {
341
+ if (event.key !== 'Escape') {
342
+ return
343
+ }
344
+ if (!isOpen.value || isStuck.value) {
345
+ return
346
+ }
347
+ close()
348
+ document.getElementById(TOGGLER_ID)?.focus()
342
349
  }
343
350
 
344
351
  watch([isOpen, isStuck], ([open, stuck]) => {
345
- nextTick(() => {
346
- if (open && !stuck) {
347
- document.addEventListener('click', handleClickOutside)
348
- } else {
349
- document.removeEventListener('click', handleClickOutside)
350
- }
351
- })
352
+ nextTick(() => {
353
+ syncTogglerAria()
354
+ if (open && !stuck) {
355
+ document.addEventListener('click', handleClickOutside)
356
+ document.addEventListener('keydown', handleDocumentKeydown)
357
+ } else {
358
+ document.removeEventListener('click', handleClickOutside)
359
+ document.removeEventListener('keydown', handleDocumentKeydown)
360
+ }
361
+ })
352
362
  }, { immediate: true })
353
363
 
354
364
  onUnmounted(() => {
355
- document.removeEventListener('click', handleClickOutside)
365
+ document.removeEventListener('click', handleClickOutside)
366
+ document.removeEventListener('keydown', handleDocumentKeydown)
356
367
  })
357
368
 
358
369
  defineExpose({
359
- isOpen,
360
- stick,
361
- unstick,
362
- toggleStick,
363
- toggle,
364
- open,
365
- close,
370
+ isOpen,
371
+ isStuck,
372
+ isVisible,
373
+ stick,
374
+ unstick,
375
+ toggleStick,
376
+ toggle,
377
+ open,
378
+ close,
366
379
  })
367
380
  </script>
368
381
 
369
382
  <style scoped>
370
-
371
383
  h2 {
372
- padding: .75em;
373
- }
374
-
375
- .active {
376
- text-decoration: underline;
384
+ padding: .75em;
377
385
  }
378
386
 
379
387
  li {
@@ -385,45 +393,15 @@ button {
385
393
  border: 0;
386
394
  }
387
395
 
388
- .navigation-links a {
389
- display: flex;
390
- align-items: center;
391
- gap: 0.75rem;
392
- padding: .75em;
393
- border-radius: 0;
394
- position: relative;
395
- }
396
-
397
- .nav-link-title {
398
- flex: 1;
399
- min-width: 0;
400
- }
401
-
402
- .nav-link-indicator {
403
- width: 0.5rem;
404
- height: 0.5rem;
405
- border-radius: 50%;
406
- background: var(--indicator-color, #dc3545);
407
- flex-shrink: 0;
408
- }
409
-
410
- .nav-link-count {
411
- min-width: 1.25rem;
412
- padding: 0.1rem 0.4rem;
413
- border-radius: 999px;
414
- background: var(--indicator-color, #dc3545);
415
- color: #fff;
416
- font-size: 0.75em;
417
- font-weight: 600;
418
- line-height: 1.2;
419
- text-align: center;
420
- flex-shrink: 0;
396
+ .navigation-links,
397
+ .nav-section-links {
398
+ list-style: none;
399
+ margin: 0;
400
+ padding: 0;
421
401
  }
422
402
 
423
- .navigation-links a.disabled,
424
- .navigation-links li.disabled a {
425
- cursor: not-allowed;
426
- opacity: 0.55;
403
+ .nav-section {
404
+ list-style: none;
427
405
  }
428
406
 
429
407
  .nav-section-header {
@@ -452,12 +430,6 @@ button {
452
430
  opacity: 0.8;
453
431
  }
454
432
 
455
- .separator {
456
- height: 1px;
457
- background-color: #eee;
458
- margin: 0.5rem 0.75rem;
459
- }
460
-
461
433
  .icon {
462
434
  font-size: 1.2em;
463
435
  width: 1.5rem;
@@ -465,30 +437,22 @@ button {
465
437
  }
466
438
 
467
439
  html[data-theme="dark"] {
468
- .navigation-links a {
469
- color: #f8f9fa;
470
- }
471
-
472
- .nav-section-header {
473
- color: #f8f9fa;
474
- }
475
-
476
- .separator {
477
- background-color: #444;
478
- }
440
+ .nav-section-header {
441
+ color: #f8f9fa;
442
+ }
479
443
 
480
- .supplemental-links {
481
- border-top: 1px solid #444;
482
- }
444
+ .supplemental-links {
445
+ border-top: 1px solid #444;
446
+ }
483
447
  }
484
448
 
485
449
  @media (max-width: 768px) {
486
- .sidebar {
487
- left: -100%;
488
- }
450
+ .sidebar {
451
+ left: -100%;
452
+ }
489
453
 
490
- .sidebar.shown {
491
- left: 0;
492
- }
454
+ .sidebar.shown {
455
+ left: 0;
456
+ }
493
457
  }
494
458
  </style>