picocrank 1.18.1 → 1.18.3

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.
@@ -0,0 +1,118 @@
1
+ <template>
2
+ <div
3
+ class="notification-popup notification show"
4
+ :class="popup.class"
5
+ role="status"
6
+ >
7
+ <button
8
+ type="button"
9
+ class="notification-popup-dismiss"
10
+ aria-label="Dismiss notification"
11
+ @click.stop="emit('dismiss', popup.id)"
12
+ >
13
+ <HugeiconsIcon :icon="Cancel01Icon" width="1em" height="1em" />
14
+ </button>
15
+
16
+ <div class="notification-popup-body">
17
+ <p class="notification-popup-message">
18
+ <strong v-if="popup.label">{{ popup.label }}:</strong>
19
+ {{ popup.message }}
20
+ </p>
21
+
22
+ <RouterLink
23
+ v-if="popup.linkTo && !isExternalLink"
24
+ :to="popup.linkTo"
25
+ class="notification-popup-link"
26
+ @click="emit('dismiss', popup.id)"
27
+ >
28
+ {{ popup.linkLabel || 'View' }}
29
+ </RouterLink>
30
+
31
+ <a
32
+ v-else-if="popup.linkTo && isExternalLink"
33
+ :href="popup.linkTo"
34
+ class="notification-popup-link"
35
+ target="_blank"
36
+ rel="noopener noreferrer"
37
+ @click="emit('dismiss', popup.id)"
38
+ >
39
+ {{ popup.linkLabel || 'View' }}
40
+ </a>
41
+ </div>
42
+ </div>
43
+ </template>
44
+
45
+ <script setup>
46
+ import { computed } from 'vue'
47
+ import { RouterLink } from 'vue-router'
48
+ import { HugeiconsIcon } from '@hugeicons/vue'
49
+ import { Cancel01Icon } from '@hugeicons/core-free-icons'
50
+
51
+ const props = defineProps({
52
+ popup: {
53
+ type: Object,
54
+ required: true,
55
+ },
56
+ })
57
+
58
+ const emit = defineEmits(['dismiss'])
59
+
60
+ const isExternalLink = computed(() =>
61
+ typeof props.popup.linkTo === 'string' && /^https?:\/\//.test(props.popup.linkTo)
62
+ )
63
+ </script>
64
+
65
+ <style scoped>
66
+ .notification-popup {
67
+ position: relative;
68
+ width: min(22rem, calc(100vw - 2rem));
69
+ margin: 0;
70
+ padding: 0.75rem 2.25rem 0.75rem 0.75rem;
71
+ cursor: default;
72
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
73
+ }
74
+
75
+ .notification-popup-message {
76
+ margin: 0;
77
+ }
78
+
79
+ .notification-popup-body {
80
+ display: flex;
81
+ flex-direction: column;
82
+ gap: 0.5rem;
83
+ }
84
+
85
+ .notification-popup-link {
86
+ color: inherit;
87
+ font-weight: bold;
88
+ text-decoration: underline;
89
+ align-self: flex-start;
90
+ }
91
+
92
+ .notification-popup-link:hover {
93
+ opacity: 0.85;
94
+ }
95
+
96
+ .notification-popup-dismiss {
97
+ position: absolute;
98
+ top: 0.35rem;
99
+ right: 0.35rem;
100
+ display: inline-flex;
101
+ align-items: center;
102
+ justify-content: center;
103
+ width: 1.75rem;
104
+ height: 1.75rem;
105
+ padding: 0;
106
+ border: 0;
107
+ border-radius: 0.35rem;
108
+ background: transparent;
109
+ color: inherit;
110
+ cursor: pointer;
111
+ opacity: 0.75;
112
+ }
113
+
114
+ .notification-popup-dismiss:hover {
115
+ opacity: 1;
116
+ background: rgba(0, 0, 0, 0.08);
117
+ }
118
+ </style>
@@ -0,0 +1,73 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <div
4
+ class="notification-popup-stack"
5
+ aria-live="polite"
6
+ aria-relevant="additions"
7
+ >
8
+ <TransitionGroup name="notification-popup">
9
+ <NotificationPopup
10
+ v-for="popup in notificationPopups"
11
+ :key="popup.id"
12
+ :popup="popup"
13
+ @dismiss="dismissNotificationPopup"
14
+ />
15
+ </TransitionGroup>
16
+ </div>
17
+ </Teleport>
18
+ </template>
19
+
20
+ <script setup>
21
+ import { onUnmounted } from 'vue'
22
+ import NotificationPopup from './NotificationPopup.vue'
23
+ import {
24
+ notificationPopups,
25
+ dismissNotificationPopup,
26
+ dismissAllNotificationPopups,
27
+ showNotificationPopup,
28
+ clearNotificationPopupTimers,
29
+ } from '../composables/useNotificationPopups.js'
30
+
31
+ onUnmounted(() => {
32
+ clearNotificationPopupTimers()
33
+ })
34
+
35
+ defineExpose({
36
+ popups: notificationPopups,
37
+ show: showNotificationPopup,
38
+ dismiss: dismissNotificationPopup,
39
+ dismissAll: dismissAllNotificationPopups,
40
+ })
41
+ </script>
42
+
43
+ <style scoped>
44
+ .notification-popup-stack {
45
+ position: fixed;
46
+ right: 1rem;
47
+ bottom: 1rem;
48
+ z-index: 1000;
49
+ display: flex;
50
+ flex-direction: column;
51
+ gap: 0.75rem;
52
+ pointer-events: none;
53
+ }
54
+
55
+ .notification-popup-stack :deep(.notification-popup) {
56
+ pointer-events: auto;
57
+ }
58
+
59
+ .notification-popup-enter-active,
60
+ .notification-popup-leave-active {
61
+ transition: opacity 0.4s ease, transform 0.4s ease;
62
+ }
63
+
64
+ .notification-popup-enter-from,
65
+ .notification-popup-leave-to {
66
+ opacity: 0;
67
+ transform: translateX(1rem);
68
+ }
69
+
70
+ .notification-popup-move {
71
+ transition: transform 0.3s ease;
72
+ }
73
+ </style>
@@ -215,7 +215,7 @@ option {
215
215
  }
216
216
  }
217
217
 
218
- @media (prefers-color-scheme: dark) {
218
+ html[data-theme="dark"] {
219
219
  .pagination-text {
220
220
  color: #adb5bd;
221
221
  }
@@ -437,7 +437,7 @@ mark {
437
437
  }
438
438
 
439
439
  /* Dark theme */
440
- @media (prefers-color-scheme: dark) {
440
+ html[data-theme="dark"] {
441
441
  .search-input {
442
442
  background-color: #1f2937;
443
443
  border-color: #374151;
@@ -15,43 +15,177 @@
15
15
 
16
16
  <nav class="mainnav">
17
17
  <ul class="navigation-links">
18
- <li v-for="link in navigationLinks" :key="link.name" :title="link.title">
19
- <!-- Render separator if link is a separator -->
20
- <div v-if="link.type === 'separator'" class="separator"></div>
21
- <div v-else-if="link.type === 'callback'">
22
- <a href="#" @click.prevent="handleLinkClick(() => link.callback())">
23
- <HugeiconsIcon :icon="link.icon" />
24
- <span>{{ link.title }}</span>
25
- </a>
26
- </div>
27
- <div v-else-if="link.type === 'html'" v-html="link.html"></div>
28
- <router-link v-else v-bind="link.props || {}" :to="link.to || link.path" :class="{ active: isActive(link) }" @click="handleLinkClick()">
29
- <HugeiconsIcon :icon="link.icon" />
30
- <span>{{ link.title }}</span>
31
- </router-link>
32
- </li>
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
+ >
41
+ <div v-if="item.link.type === 'separator'" class="separator"></div>
42
+ <div v-else-if="item.link.type === 'callback'">
43
+ <a href="#" @click.prevent="handleLinkClick(() => item.link.callback())">
44
+ <HugeiconsIcon :icon="item.link.icon" />
45
+ <span>{{ item.link.title }}</span>
46
+ </a>
47
+ </div>
48
+ <div v-else-if="item.link.type === 'html'" v-html="item.link.html"></div>
49
+ <router-link
50
+ v-else
51
+ v-bind="item.link.props || {}"
52
+ :to="item.link.to || item.link.path"
53
+ :class="{ active: isActive(item.link) }"
54
+ @click="handleLinkClick()"
55
+ >
56
+ <HugeiconsIcon :icon="item.link.icon" />
57
+ <span>{{ item.link.title }}</span>
58
+ </router-link>
59
+ </li>
60
+ </template>
33
61
  </ul>
34
62
  </nav>
35
63
  </aside>
36
64
  </template>
37
65
 
38
66
  <script setup>
39
- import { ref, inject, onUnmounted, watch, nextTick } from 'vue'
67
+ import { ref, inject, onUnmounted, watch, nextTick, computed } from 'vue'
68
+ import { useRoute } from 'vue-router'
40
69
  import { HugeiconsIcon } from '@hugeicons/vue'
41
- import { Pin02Icon } from '@hugeicons/core-free-icons'
42
- import { PinIcon } from '@hugeicons/core-free-icons'
70
+ import { Pin02Icon, PinIcon, ArrowDown01Icon, ArrowRight01Icon } from '@hugeicons/core-free-icons'
71
+
72
+ const STORAGE_KEY = 'picocrank-nav-sections-collapsed'
43
73
 
44
74
  const isOpen = ref(false)
45
75
  const isStuck = ref(false)
46
76
  const sidebarRef = ref(null)
77
+ const route = useRoute()
47
78
 
48
- // Get navigation from Navigation component
49
79
  const navigation = inject('navigation', null)
50
80
 
51
- // Get navigation links and isActive function from navigation
52
81
  const navigationLinks = navigation ? navigation.navigationLinks : ref([])
53
82
  const isActive = navigation ? navigation.isActive : (() => false)
54
83
 
84
+ function loadCollapsedSections() {
85
+ try {
86
+ const raw = localStorage.getItem(STORAGE_KEY)
87
+ if (!raw) return new Set()
88
+ const parsed = JSON.parse(raw)
89
+ return new Set(Array.isArray(parsed) ? parsed : [])
90
+ } catch {
91
+ return new Set()
92
+ }
93
+ }
94
+
95
+ const collapsedSections = ref(loadCollapsedSections())
96
+
97
+ function saveCollapsedSections() {
98
+ localStorage.setItem(STORAGE_KEY, JSON.stringify([...collapsedSections.value]))
99
+ }
100
+
101
+ function isSectionCollapsed(sectionId) {
102
+ return collapsedSections.value.has(sectionId)
103
+ }
104
+
105
+ function toggleSection(sectionId) {
106
+ const next = new Set(collapsedSections.value)
107
+ if (next.has(sectionId)) {
108
+ next.delete(sectionId)
109
+ } else {
110
+ next.add(sectionId)
111
+ }
112
+ collapsedSections.value = next
113
+ saveCollapsedSections()
114
+ }
115
+
116
+ const navigationGroups = computed(() => {
117
+ const ungrouped = []
118
+ const sections = []
119
+ let currentSection = null
120
+
121
+ for (const link of navigationLinks.value) {
122
+ if (link.type === 'section') {
123
+ currentSection = { id: link.name, title: link.title, items: [] }
124
+ sections.push(currentSection)
125
+ continue
126
+ }
127
+
128
+ if (link.type === 'separator') {
129
+ continue
130
+ }
131
+
132
+ if (currentSection) {
133
+ currentSection.items.push(link)
134
+ } else {
135
+ ungrouped.push(link)
136
+ }
137
+ }
138
+
139
+ return { ungrouped, sections }
140
+ })
141
+
142
+ const visibleNavItems = computed(() => {
143
+ const items = []
144
+
145
+ for (const link of navigationGroups.value.ungrouped) {
146
+ items.push({ key: link.name, kind: 'link', link })
147
+ }
148
+
149
+ for (const section of navigationGroups.value.sections) {
150
+ const collapsed = isSectionCollapsed(section.id)
151
+ items.push({
152
+ key: section.id,
153
+ kind: 'section-header',
154
+ section,
155
+ collapsed,
156
+ })
157
+
158
+ if (!collapsed) {
159
+ for (const link of section.items) {
160
+ items.push({
161
+ key: link.name,
162
+ kind: 'link',
163
+ link,
164
+ })
165
+ }
166
+ }
167
+ }
168
+
169
+ return items
170
+ })
171
+
172
+ function expandSectionContainingActiveRoute() {
173
+ for (const section of navigationGroups.value.sections) {
174
+ const hasActiveLink = section.items.some((link) => isActive(link))
175
+ if (!hasActiveLink || !isSectionCollapsed(section.id)) {
176
+ continue
177
+ }
178
+ const next = new Set(collapsedSections.value)
179
+ next.delete(section.id)
180
+ collapsedSections.value = next
181
+ saveCollapsedSections()
182
+ }
183
+ }
184
+
185
+ watch(() => route.fullPath, () => {
186
+ expandSectionContainingActiveRoute()
187
+ }, { immediate: true })
188
+
55
189
  function toggleStick() {
56
190
  isStuck.value = !isStuck.value
57
191
  }
@@ -88,19 +222,15 @@ function handleLinkClick(callback = null) {
88
222
  }
89
223
 
90
224
  function handleClickOutside(event) {
91
- // Only close if sidebar is open and unpinned
92
225
  if (!isOpen.value || isStuck.value) {
93
226
  return
94
227
  }
95
228
 
96
229
  const target = event.target
97
230
 
98
- // Check if click is on the sidebar toggle button or its container
99
- // These elements should not trigger the close action
100
231
  const sidebarButton = document.getElementById('sidebar-button')
101
232
  const sidebarTogglerButton = document.getElementById('sidebar-toggler-button')
102
233
 
103
- // Don't close if clicking on toggle button or its parent container
104
234
  if (sidebarButton && sidebarButton.contains(target)) {
105
235
  return
106
236
  }
@@ -109,26 +239,21 @@ function handleClickOutside(event) {
109
239
  return
110
240
  }
111
241
 
112
- // Check if click is outside the sidebar element
113
242
  if (sidebarRef.value && !sidebarRef.value.contains(target)) {
114
243
  close()
115
244
  }
116
245
  }
117
246
 
118
- // Watch for sidebar open/close state to manage event listener
119
247
  watch([isOpen, isStuck], ([open, stuck]) => {
120
248
  nextTick(() => {
121
249
  if (open && !stuck) {
122
- // Add event listener when sidebar is open and unpinned
123
250
  document.addEventListener('click', handleClickOutside)
124
251
  } else {
125
- // Remove event listener when sidebar is closed or pinned
126
252
  document.removeEventListener('click', handleClickOutside)
127
253
  }
128
254
  })
129
255
  }, { immediate: true })
130
256
 
131
- // Clean up event listener on unmount
132
257
  onUnmounted(() => {
133
258
  document.removeEventListener('click', handleClickOutside)
134
259
  })
@@ -171,6 +296,31 @@ button {
171
296
  border-radius: 0;
172
297
  }
173
298
 
299
+ .nav-section-header {
300
+ display: flex;
301
+ align-items: center;
302
+ justify-content: space-between;
303
+ gap: 0.5rem;
304
+ width: 100%;
305
+ padding: 0.75em;
306
+ text-align: left;
307
+ font-weight: 600;
308
+ font-size: 1em;
309
+ color: inherit;
310
+ background: transparent;
311
+ cursor: pointer;
312
+ }
313
+
314
+ .nav-section-header:hover {
315
+ background-color: var(--hover-background-color);
316
+ color: white;
317
+ }
318
+
319
+ .nav-section-chevron {
320
+ flex-shrink: 0;
321
+ opacity: 0.8;
322
+ }
323
+
174
324
  .separator {
175
325
  height: 1px;
176
326
  background-color: #eee;
@@ -183,11 +333,15 @@ button {
183
333
  text-align: center;
184
334
  }
185
335
 
186
- @media (prefers-color-scheme: dark) {
336
+ html[data-theme="dark"] {
187
337
  .navigation-links a {
188
338
  color: #f8f9fa;
189
339
  }
190
340
 
341
+ .nav-section-header {
342
+ color: #f8f9fa;
343
+ }
344
+
191
345
  .separator {
192
346
  background-color: #444;
193
347
  }
@@ -206,4 +360,4 @@ button {
206
360
  left: 0;
207
361
  }
208
362
  }
209
- </style>
363
+ </style>
@@ -197,7 +197,7 @@ defineExpose({
197
197
  }
198
198
 
199
199
  /* Dark theme support */
200
- @media (prefers-color-scheme: dark) {
200
+ html[data-theme="dark"] {
201
201
  .tabs-header {
202
202
  border-bottom-color: var(--border-color, #444);
203
203
  }
@@ -2,8 +2,10 @@
2
2
  <nav class="topbar">
3
3
  <div class="topbar-links flex-row">
4
4
  <template v-for="link in navigationLinks" :key="link.name">
5
+ <!-- Skip section headers in the top bar -->
6
+ <template v-if="link.type === 'section'"></template>
5
7
  <!-- Render separator as a divider -->
6
- <div v-if="link.type === 'separator'" class="separator"></div>
8
+ <div v-else-if="link.type === 'separator'" class="separator"></div>
7
9
  <!-- Render callback link -->
8
10
  <a v-else-if="link.type === 'callback'" href="#" @click.prevent="handleLinkClick(() => link.callback())" class="button topbar-link">
9
11
  <HugeiconsIcon :icon="link.icon" width="1em" height="1em" />
@@ -120,7 +122,7 @@ function handleLinkClick(callback = null) {
120
122
  margin: 0 0.125rem;
121
123
  }
122
124
 
123
- @media (prefers-color-scheme: dark) {
125
+ html[data-theme="dark"] {
124
126
  .topbar-link {
125
127
  color: #f8f9fa;
126
128
  }
@@ -0,0 +1,78 @@
1
+ import { ref } from 'vue'
2
+
3
+ export const NOTIFICATION_POPUPS_KEY = Symbol('notificationPopups')
4
+
5
+ const DEFAULT_DURATION_MS = 5000
6
+
7
+ export const notificationPopups = ref([])
8
+ const timers = new Map()
9
+ let nextId = 0
10
+
11
+ function clearTimer(id) {
12
+ const timer = timers.get(id)
13
+ if (timer !== undefined) {
14
+ clearTimeout(timer)
15
+ timers.delete(id)
16
+ }
17
+ }
18
+
19
+ export function dismissNotificationPopup(id) {
20
+ clearTimer(id)
21
+ notificationPopups.value = notificationPopups.value.filter((popup) => popup.id !== id)
22
+ }
23
+
24
+ export function dismissAllNotificationPopups() {
25
+ for (const popup of notificationPopups.value) {
26
+ clearTimer(popup.id)
27
+ }
28
+ notificationPopups.value = []
29
+ }
30
+
31
+ export function showNotificationPopup(options = {}) {
32
+ const message = options.message?.trim()
33
+ if (!message) {
34
+ console.warn('[NotificationPopups] show() requires a message.')
35
+ return null
36
+ }
37
+
38
+ const id = options.id || `notification-popup-${Date.now()}-${nextId++}`
39
+ const popup = {
40
+ id,
41
+ message,
42
+ label: options.label || null,
43
+ class: options.class || 'info',
44
+ linkTo: options.linkTo || null,
45
+ linkLabel: options.linkLabel || null,
46
+ durationMs: options.durationMs ?? DEFAULT_DURATION_MS,
47
+ }
48
+
49
+ notificationPopups.value = [...notificationPopups.value, popup]
50
+
51
+ const durationMs = popup.durationMs
52
+ if (durationMs > 0) {
53
+ const timer = setTimeout(() => dismissNotificationPopup(id), durationMs)
54
+ timers.set(id, timer)
55
+ }
56
+
57
+ return id
58
+ }
59
+
60
+ /**
61
+ * Access the global notification popup stack.
62
+ * Requires `<NotificationPopups />` to be mounted (typically in App.vue).
63
+ */
64
+ export function useNotificationPopups() {
65
+ return {
66
+ popups: notificationPopups,
67
+ show: showNotificationPopup,
68
+ dismiss: dismissNotificationPopup,
69
+ dismissAll: dismissAllNotificationPopups,
70
+ }
71
+ }
72
+
73
+ export function clearNotificationPopupTimers() {
74
+ for (const timer of timers.values()) {
75
+ clearTimeout(timer)
76
+ }
77
+ timers.clear()
78
+ }
@@ -0,0 +1,60 @@
1
+ import { ref, computed, watch } from 'vue'
2
+
3
+ const STORAGE_KEY = 'picocrank-theme'
4
+
5
+ function getSystemTheme() {
6
+ if (typeof window === 'undefined') {
7
+ return 'light'
8
+ }
9
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
10
+ }
11
+
12
+ function readStoredTheme() {
13
+ if (typeof window === 'undefined') {
14
+ return 'light'
15
+ }
16
+ const stored = localStorage.getItem(STORAGE_KEY)
17
+ if (stored === 'light' || stored === 'dark') {
18
+ return stored
19
+ }
20
+ return getSystemTheme()
21
+ }
22
+
23
+ function applyTheme(value) {
24
+ if (typeof document === 'undefined') {
25
+ return
26
+ }
27
+ document.documentElement.setAttribute('data-theme', value)
28
+ }
29
+
30
+ export const theme = ref(readStoredTheme())
31
+
32
+ applyTheme(theme.value)
33
+
34
+ watch(theme, (value) => {
35
+ applyTheme(value)
36
+ if (typeof localStorage !== 'undefined') {
37
+ localStorage.setItem(STORAGE_KEY, value)
38
+ }
39
+ })
40
+
41
+ export function useTheme() {
42
+ const isDark = computed(() => theme.value === 'dark')
43
+
44
+ function toggleTheme() {
45
+ theme.value = theme.value === 'dark' ? 'light' : 'dark'
46
+ }
47
+
48
+ function setTheme(value) {
49
+ if (value === 'light' || value === 'dark') {
50
+ theme.value = value
51
+ }
52
+ }
53
+
54
+ return {
55
+ theme,
56
+ isDark,
57
+ toggleTheme,
58
+ setTheme,
59
+ }
60
+ }