picocrank 1.21.3 → 1.21.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "picocrank",
3
- "version": "1.21.3",
3
+ "version": "1.21.4",
4
4
  "description": "A set of zero dependency Vue components built on the Femtocrank global CSS theme.",
5
5
  "main": "vite.config.js",
6
6
  "scripts": {
@@ -1,8 +1,8 @@
1
1
  <template>
2
2
  <header>
3
3
  <div
4
- :id = "sidebarEnabled ? 'sidebar-button' : null"
5
- :class="['image-and-title', 'flex-row', { 'disabled-branding': !showBranding, 'logo-home-link': !sidebarEnabled && showBranding }]"
4
+ :id = "showSidebarChrome ? 'sidebar-button' : null"
5
+ :class="['image-and-title', 'flex-row', { 'disabled-branding': !showBranding, 'logo-home-link': !showSidebarChrome && showBranding }]"
6
6
  @click = "onImageAndTitleClick"
7
7
  >
8
8
  <img v-if="showBranding" :src = "logoUrl" alt = "Logo" class = "logo" />
@@ -10,12 +10,12 @@
10
10
 
11
11
  <div class = "fg1" />
12
12
 
13
- <button v-if="sidebarEnabled" id = "sidebar-toggler-button" aria-label = "Open sidebar navigation" aria-pressed = "false" aria-haspopup = "menu" class = "neutral">
13
+ <button v-if="showSidebarChrome" id = "sidebar-toggler-button" aria-label = "Open sidebar navigation" aria-pressed = "false" aria-haspopup = "menu" class = "neutral">
14
14
  <HugeiconsIcon :icon = "Menu01Icon" width = "1em" height = "1em" :strokeWidth = 3 />
15
15
  </button>
16
16
  </div>
17
17
 
18
- <TopBar v-if="topBarEnabled" :navigation="navigation" />
18
+ <TopBar v-if="showTopBar" :navigation="navigation" />
19
19
 
20
20
  <Breadcrumbs v-if="breadcrumbs" />
21
21
 
@@ -40,28 +40,26 @@
40
40
  </div>
41
41
  </slot>
42
42
  </header>
43
+
44
+ <!-- Top-bar-only mobile: Header owns the fallback sidebar so consumers need no extra wiring. -->
45
+ <Sidebar v-if="needsFallbackSidebar" ref="fallbackSidebar" :navigation="navigation" />
43
46
  </template>
44
47
 
45
48
  <script setup>
49
+ import { ref } from "vue";
46
50
  import { HugeiconsIcon } from "@hugeicons/vue";
47
51
  import { Menu01Icon, Moon02Icon, Sun01Icon } from "@hugeicons/core-free-icons";
48
52
 
49
53
  import Breadcrumbs from "./Breadcrumbs.vue";
50
54
  import TopBar from "./TopBar.vue";
55
+ import Sidebar from "./Sidebar.vue";
51
56
  import { useTheme } from '../composables/useTheme.js';
57
+ import { useResponsiveNav } from '../composables/useResponsiveNav.js';
52
58
 
53
59
  const { isDark, toggleTheme } = useTheme();
54
60
 
55
61
  const emit = defineEmits(["toggleSidebar", "logoClick"]);
56
62
 
57
- function onImageAndTitleClick() {
58
- if (props.sidebarEnabled) {
59
- emit("toggleSidebar");
60
- } else {
61
- emit("logoClick");
62
- }
63
- }
64
-
65
63
  const props = defineProps({
66
64
  breadcrumbs: {
67
65
  type: Boolean,
@@ -100,6 +98,29 @@
100
98
  default: null,
101
99
  },
102
100
  });
101
+
102
+ const fallbackSidebar = ref(null);
103
+
104
+ const { showTopBar, showSidebarChrome, needsFallbackSidebar } = useResponsiveNav(
105
+ () => props.sidebarEnabled,
106
+ () => props.topBarEnabled,
107
+ );
108
+
109
+ function toggleNavSidebar() {
110
+ if (needsFallbackSidebar.value) {
111
+ fallbackSidebar.value?.toggle();
112
+ return;
113
+ }
114
+ emit("toggleSidebar");
115
+ }
116
+
117
+ function onImageAndTitleClick() {
118
+ if (showSidebarChrome.value) {
119
+ toggleNavSidebar();
120
+ } else {
121
+ emit("logoClick");
122
+ }
123
+ }
103
124
  </script>
104
125
 
105
126
  <style scoped>
@@ -132,4 +153,4 @@ button:hover {
132
153
  .theme-toggle {
133
154
  margin-right: 0.25rem;
134
155
  }
135
- </style>
156
+ </style>
@@ -1,57 +1,101 @@
1
1
  <template>
2
- <div class="quick-search">
3
- <div class="search-container">
4
- <input
5
- ref="searchInput"
6
- v-model="searchQuery"
7
- type="text"
8
- class="search-input"
9
- :placeholder="placeholder"
10
- @input="onSearch"
11
- @focus="onFocus"
12
- @blur="onBlur"
13
- @keydown="onKeydown"
2
+ <!-- Mobile: collapse to a search button in the header -->
3
+ <button
4
+ v-if="isMobile"
5
+ type="button"
6
+ class="quick-search-trigger neutral"
7
+ aria-label="Open search"
8
+ aria-haspopup="dialog"
9
+ :aria-expanded="overlayOpen"
10
+ @click="openOverlay"
11
+ >
12
+ <HugeiconsIcon :icon="SearchIcon" width="1em" height="1em" />
13
+ </button>
14
+
15
+ <Teleport to="body" :disabled="!isMobile">
16
+ <div
17
+ v-if="!isMobile || overlayOpen"
18
+ :class="{ 'quick-search-overlay-root': isMobile }"
19
+ >
20
+ <div
21
+ v-if="isMobile"
22
+ class="quick-search-backdrop"
23
+ aria-hidden="true"
24
+ @click="closeOverlay"
14
25
  />
15
- <div class="search-icon">
16
- <HugeiconsIcon :icon="SearchIcon" width="1em" height="1em" />
17
- </div>
18
- </div>
19
-
20
- <div v-if="showResults && filteredItems.length > 0" class="search-results">
26
+
21
27
  <div
22
- v-for="(item, index) in filteredItems"
23
- :key="item.id"
24
- :class="['search-result-item', { active: selectedIndex === index }]"
25
- @click="selectItem(item)"
26
- @mouseenter="selectedIndex = index"
28
+ :class="['quick-search', { 'quick-search--overlay': isMobile }]"
29
+ :role="isMobile ? 'dialog' : undefined"
30
+ :aria-modal="isMobile ? 'true' : undefined"
31
+ :aria-label="isMobile ? 'Search' : undefined"
27
32
  >
28
- <div class="result-content">
29
- <div class="result-title" v-html="highlightText(item.title, searchQuery)"></div>
30
- <div v-if="item.description" class="result-description" v-html="highlightText(item.description, searchQuery)"></div>
31
- <div v-if="item.category" class="result-category">{{ item.category }}</div>
33
+ <div v-if="isMobile" class="quick-search-overlay-header">
34
+ <span class="quick-search-overlay-title">Search</span>
35
+ <button
36
+ type="button"
37
+ class="quick-search-overlay-close neutral"
38
+ aria-label="Close search"
39
+ @click="closeOverlay"
40
+ >
41
+ <HugeiconsIcon :icon="Cancel01Icon" width="1em" height="1em" />
42
+ </button>
32
43
  </div>
33
- <div v-if="item.icon" class="result-icon">
34
- <HugeiconsIcon :icon="item.icon" width="1.2em" height="1.2em" />
44
+
45
+ <div class="search-container">
46
+ <input
47
+ ref="searchInput"
48
+ v-model="searchQuery"
49
+ type="text"
50
+ class="search-input"
51
+ :placeholder="placeholder"
52
+ @input="onSearch"
53
+ @focus="onFocus"
54
+ @blur="onBlur"
55
+ @keydown="onKeydown"
56
+ />
57
+ <div class="search-icon">
58
+ <HugeiconsIcon :icon="SearchIcon" width="1em" height="1em" />
59
+ </div>
60
+ </div>
61
+
62
+ <div v-if="showResults && filteredItems.length > 0" class="search-results">
63
+ <div
64
+ v-for="(item, index) in filteredItems"
65
+ :key="item.id"
66
+ :class="['search-result-item', { active: selectedIndex === index }]"
67
+ @click="selectItem(item)"
68
+ @mouseenter="selectedIndex = index"
69
+ >
70
+ <div v-if="item.icon" class="result-icon">
71
+ <HugeiconsIcon :icon="item.icon" width="1.2em" height="1.2em" />
72
+ </div>
73
+ <div class="result-content">
74
+ <div class="result-title" v-html="highlightText(item.title, searchQuery)"></div>
75
+ <div v-if="item.description" class="result-description" v-html="highlightText(item.description, searchQuery)"></div>
76
+ <div v-if="item.category" class="result-category">{{ item.category }}</div>
77
+ </div>
78
+ </div>
79
+ </div>
80
+
81
+ <div v-if="showResults && searchQuery && filteredItems.length === 0" class="no-results">
82
+ <div class="no-results-content">
83
+ <HugeiconsIcon :icon="SearchRemoveIcon" width="2em" height="2em" />
84
+ <p>No results found for "{{ searchQuery }}"</p>
85
+ </div>
35
86
  </div>
36
87
  </div>
37
88
  </div>
38
-
39
- <div v-if="showResults && searchQuery && filteredItems.length === 0" class="no-results">
40
- <div class="no-results-content">
41
- <HugeiconsIcon :icon="SearchRemoveIcon" width="2em" height="2em" />
42
- <p>No results found for "{{ searchQuery }}"</p>
43
- </div>
44
- </div>
45
- </div>
89
+ </Teleport>
46
90
  </template>
47
91
 
48
92
  <script setup>
49
93
  import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
50
94
  import { useRouter } from 'vue-router'
51
95
  import { HugeiconsIcon } from '@hugeicons/vue'
52
- import { SearchIcon } from '@hugeicons/core-free-icons'
53
- import { SearchRemoveIcon } from '@hugeicons/core-free-icons'
54
- import { ViewIcon } from '@hugeicons/core-free-icons'
96
+ import { SearchIcon, SearchRemoveIcon, ViewIcon, Cancel01Icon } from '@hugeicons/core-free-icons'
97
+ import { useMediaQuery } from '../composables/useMediaQuery.js'
98
+ import { MOBILE_NAV_QUERY } from '../composables/useResponsiveNav.js'
55
99
 
56
100
  const props = defineProps({
57
101
  placeholder: {
@@ -87,6 +131,8 @@ const props = defineProps({
87
131
  const emit = defineEmits(['select', 'search', 'focus', 'blur'])
88
132
 
89
133
  const router = useRouter()
134
+ const isMobile = useMediaQuery(MOBILE_NAV_QUERY)
135
+ const overlayOpen = ref(false)
90
136
  const searchInput = ref(null)
91
137
  const searchQuery = ref('')
92
138
  const showResults = ref(false)
@@ -127,16 +173,35 @@ const filteredItems = computed(() => {
127
173
  return filtered.slice(0, props.maxResults)
128
174
  })
129
175
 
176
+ function openOverlay() {
177
+ if (!isMobile.value) {
178
+ return
179
+ }
180
+ overlayOpen.value = true
181
+ nextTick(() => {
182
+ searchInput.value?.focus()
183
+ showResults.value = true
184
+ })
185
+ }
186
+
187
+ function closeOverlay() {
188
+ if (!overlayOpen.value) {
189
+ return
190
+ }
191
+ overlayOpen.value = false
192
+ clear()
193
+ }
194
+
130
195
  function handleGlobalKeydown(event) {
131
196
  // Check if Ctrl+K is pressed
132
197
  if (event.ctrlKey && event.key === 'k') {
133
198
  event.preventDefault()
134
-
199
+
135
200
  // Don't trigger if user is typing in an input/textarea
136
201
  if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') {
137
202
  return
138
203
  }
139
-
204
+
140
205
  focus()
141
206
  }
142
207
  }
@@ -156,15 +221,31 @@ function onFocus() {
156
221
  }
157
222
 
158
223
  function onBlur() {
224
+ emit('blur')
225
+ // Keep overlay results visible on mobile; only collapse the inline dropdown.
226
+ if (isMobile.value) {
227
+ return
228
+ }
159
229
  // Delay hiding results to allow for click events
160
230
  setTimeout(() => {
161
231
  showResults.value = false
162
232
  selectedIndex.value = -1
163
- emit('blur')
164
233
  }, 150)
165
234
  }
166
235
 
167
236
  function onKeydown(event) {
237
+ if (event.key === 'Escape') {
238
+ if (isMobile.value && overlayOpen.value) {
239
+ event.preventDefault()
240
+ closeOverlay()
241
+ return
242
+ }
243
+ showResults.value = false
244
+ selectedIndex.value = -1
245
+ searchInput.value?.blur()
246
+ return
247
+ }
248
+
168
249
  if (!showResults.value || filteredItems.value.length === 0) return
169
250
 
170
251
  switch (event.key) {
@@ -182,11 +263,6 @@ function onKeydown(event) {
182
263
  selectItem(filteredItems.value[selectedIndex.value])
183
264
  }
184
265
  break
185
- case 'Escape':
186
- showResults.value = false
187
- selectedIndex.value = -1
188
- searchInput.value.blur()
189
- break
190
266
  }
191
267
  }
192
268
 
@@ -205,6 +281,10 @@ function selectItem(item) {
205
281
  searchQuery.value = ''
206
282
  showResults.value = false
207
283
  selectedIndex.value = -1
284
+
285
+ if (isMobile.value && overlayOpen.value) {
286
+ overlayOpen.value = false
287
+ }
208
288
  }
209
289
 
210
290
  function highlightText(text, query) {
@@ -241,10 +321,18 @@ function setItems(newItems) {
241
321
  }
242
322
 
243
323
  function focus() {
324
+ if (isMobile.value) {
325
+ openOverlay()
326
+ return
327
+ }
244
328
  searchInput.value?.focus()
245
329
  }
246
330
 
247
331
  function blur() {
332
+ if (isMobile.value && overlayOpen.value) {
333
+ closeOverlay()
334
+ return
335
+ }
248
336
  searchInput.value?.blur()
249
337
  }
250
338
 
@@ -260,6 +348,19 @@ function refreshRoutes() {
260
348
  }
261
349
  }
262
350
 
351
+ watch(isMobile, (mobile) => {
352
+ if (!mobile && overlayOpen.value) {
353
+ closeOverlay()
354
+ }
355
+ })
356
+
357
+ watch(overlayOpen, (open) => {
358
+ if (typeof document === 'undefined') {
359
+ return
360
+ }
361
+ document.body.style.overflow = open ? 'hidden' : ''
362
+ })
363
+
263
364
  // Global shortcut management
264
365
  onMounted(() => {
265
366
  if (props.enableGlobalShortcut) {
@@ -276,6 +377,9 @@ onUnmounted(() => {
276
377
  if (props.enableGlobalShortcut) {
277
378
  document.removeEventListener('keydown', handleGlobalKeydown)
278
379
  }
380
+ if (typeof document !== 'undefined') {
381
+ document.body.style.overflow = ''
382
+ }
279
383
  })
280
384
 
281
385
  // Watch for external items changes
@@ -293,18 +397,87 @@ defineExpose({
293
397
  blur,
294
398
  clear,
295
399
  refreshRoutes,
400
+ openOverlay,
401
+ closeOverlay,
296
402
  searchQuery,
297
403
  filteredItems,
298
- showResults
404
+ showResults,
405
+ overlayOpen
299
406
  })
300
407
  </script>
301
408
 
302
409
  <style scoped>
410
+ .quick-search-trigger {
411
+ flex-shrink: 0;
412
+ display: inline-flex;
413
+ align-items: center;
414
+ justify-content: center;
415
+ border: 0;
416
+ color: #fff;
417
+ padding: 0.5em;
418
+ cursor: pointer;
419
+ background: transparent;
420
+ }
421
+
422
+ .quick-search-trigger:hover {
423
+ background-color: var(--header-hover-background-color);
424
+ color: var(--header-hover-text-color);
425
+ }
426
+
427
+ .quick-search-overlay-root {
428
+ position: fixed;
429
+ inset: 0;
430
+ z-index: 2000;
431
+ display: flex;
432
+ align-items: flex-start;
433
+ justify-content: center;
434
+ padding: 1rem;
435
+ padding-top: max(1rem, env(safe-area-inset-top));
436
+ }
437
+
438
+ .quick-search-backdrop {
439
+ position: absolute;
440
+ inset: 0;
441
+ background: rgba(0, 0, 0, 0.45);
442
+ }
443
+
303
444
  .quick-search {
304
445
  position: relative;
305
446
  width: 100%;
306
447
  }
307
448
 
449
+ .quick-search--overlay {
450
+ position: relative;
451
+ z-index: 1;
452
+ width: min(100%, 28rem);
453
+ margin-top: 10vh;
454
+ padding: 0.75rem;
455
+ border-radius: 0.5rem;
456
+ background: #fff;
457
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
458
+ }
459
+
460
+ .quick-search-overlay-header {
461
+ display: flex;
462
+ align-items: center;
463
+ justify-content: space-between;
464
+ gap: 0.5rem;
465
+ margin-bottom: 0.5rem;
466
+ }
467
+
468
+ .quick-search-overlay-title {
469
+ font-weight: 600;
470
+ color: #1f2937;
471
+ }
472
+
473
+ .quick-search-overlay-close {
474
+ border: 0;
475
+ padding: 0.35em;
476
+ cursor: pointer;
477
+ color: #1f2937;
478
+ background: transparent;
479
+ }
480
+
308
481
  .search-container {
309
482
  position: relative;
310
483
  display: flex;
@@ -323,6 +496,16 @@ defineExpose({
323
496
  color: #fff;
324
497
  }
325
498
 
499
+ .quick-search--overlay .search-input {
500
+ background-color: #f3f4f6;
501
+ color: #111827;
502
+ padding: 0.65em 2.25em 0.65em 0.75em;
503
+ }
504
+
505
+ .quick-search--overlay .search-input::placeholder {
506
+ color: #9ca3af;
507
+ }
508
+
326
509
  .search-input::placeholder {
327
510
  color: #bbb;
328
511
  }
@@ -354,6 +537,19 @@ defineExpose({
354
537
  z-index: 1000;
355
538
  }
356
539
 
540
+ .quick-search--overlay .search-results,
541
+ .quick-search--overlay .no-results {
542
+ position: relative;
543
+ top: auto;
544
+ left: auto;
545
+ right: auto;
546
+ margin-top: 0.5rem;
547
+ border: 1px solid #e1e5e9;
548
+ border-radius: 0.4rem;
549
+ max-height: min(50vh, 320px);
550
+ box-shadow: none;
551
+ }
552
+
357
553
  .search-result-item {
358
554
  display: flex;
359
555
  align-items: center;
@@ -397,7 +593,7 @@ defineExpose({
397
593
  }
398
594
 
399
595
  .result-icon {
400
- margin-left: 0.75rem;
596
+ margin-right: 0.75rem;
401
597
  color: #6b7280;
402
598
  flex-shrink: 0;
403
599
  }
@@ -438,6 +634,20 @@ mark {
438
634
 
439
635
  /* Dark theme */
440
636
  html[data-theme="dark"] {
637
+ .quick-search--overlay {
638
+ background: #111827;
639
+ }
640
+
641
+ .quick-search-overlay-title,
642
+ .quick-search-overlay-close {
643
+ color: #f9fafb;
644
+ }
645
+
646
+ .quick-search--overlay .search-input {
647
+ background-color: #1f2937;
648
+ color: #f9fafb;
649
+ }
650
+
441
651
  .search-input {
442
652
  background-color: #1f2937;
443
653
  border-color: #374151;
@@ -492,20 +702,14 @@ html[data-theme="dark"] {
492
702
  color: #9ca3af;
493
703
  }
494
704
 
705
+ .quick-search--overlay .search-results,
706
+ .quick-search--overlay .no-results {
707
+ border-color: #374151;
708
+ }
709
+
495
710
  mark {
496
711
  background-color: #451a03;
497
712
  color: #fbbf24;
498
713
  }
499
714
  }
500
-
501
- /* Responsive */
502
- @media (max-width: 640px) {
503
- .quick-search {
504
- max-width: 100%;
505
- }
506
-
507
- .search-results {
508
- max-height: 250px;
509
- }
510
- }
511
715
  </style>
@@ -96,15 +96,46 @@ import { Pin02Icon, PinIcon, ArrowDown01Icon, ArrowRight01Icon } from '@hugeicon
96
96
 
97
97
  const STORAGE_KEY = 'picocrank-nav-sections-collapsed'
98
98
 
99
+ const props = defineProps({
100
+ navigation: {
101
+ type: Object,
102
+ default: null,
103
+ },
104
+ })
105
+
99
106
  const isOpen = ref(false)
100
107
  const isStuck = ref(false)
101
108
  const sidebarRef = ref(null)
102
109
  const route = useRoute()
103
110
 
104
- const navigation = inject('navigation', null)
111
+ const injectedNavigation = inject('navigation', null)
112
+
113
+ const navigation = computed(() => {
114
+ const nav = props.navigation || injectedNavigation
115
+ if (!nav) return null
116
+ // Component ref (or ref-like): use exposed API
117
+ if (nav.value) {
118
+ return nav.value
119
+ }
120
+ return nav
121
+ })
122
+
123
+ const navigationLinks = computed(() => {
124
+ const nav = navigation.value
125
+ if (!nav?.navigationLinks) {
126
+ return []
127
+ }
128
+ const links = nav.navigationLinks
129
+ return Array.isArray(links) ? links : (links?.value ?? [])
130
+ })
105
131
 
106
- const navigationLinks = navigation ? navigation.navigationLinks : ref([])
107
- const isActive = navigation ? navigation.isActive : (() => false)
132
+ function isActive(link) {
133
+ const nav = navigation.value
134
+ if (!nav?.isActive) {
135
+ return false
136
+ }
137
+ return nav.isActive(link)
138
+ }
108
139
 
109
140
  function loadCollapsedSections() {
110
141
  try {
@@ -0,0 +1,50 @@
1
+ import { ref, onUnmounted } from 'vue'
2
+
3
+ /** Shared matchMedia state keyed by query string. */
4
+ const stores = new Map()
5
+
6
+ /**
7
+ * Reactive match for a CSS media query (e.g. '(max-width: 768px)').
8
+ * Identical queries share one listener so callers stay in sync.
9
+ */
10
+ export function useMediaQuery(query) {
11
+ let store = stores.get(query)
12
+
13
+ if (!store) {
14
+ const getMatches = () =>
15
+ typeof window !== 'undefined' && window.matchMedia(query).matches
16
+
17
+ const matches = ref(getMatches())
18
+ let mediaQueryList = null
19
+
20
+ function onChange(event) {
21
+ matches.value = event.matches
22
+ }
23
+
24
+ if (typeof window !== 'undefined') {
25
+ mediaQueryList = window.matchMedia(query)
26
+ matches.value = mediaQueryList.matches
27
+ mediaQueryList.addEventListener('change', onChange)
28
+ }
29
+
30
+ store = {
31
+ matches,
32
+ mediaQueryList,
33
+ onChange,
34
+ subscriberCount: 0,
35
+ }
36
+ stores.set(query, store)
37
+ }
38
+
39
+ store.subscriberCount += 1
40
+
41
+ onUnmounted(() => {
42
+ store.subscriberCount -= 1
43
+ if (store.subscriberCount <= 0 && store.mediaQueryList) {
44
+ store.mediaQueryList.removeEventListener('change', store.onChange)
45
+ stores.delete(query)
46
+ }
47
+ })
48
+
49
+ return store.matches
50
+ }
@@ -0,0 +1,42 @@
1
+ import { computed, toValue } from 'vue'
2
+ import { useMediaQuery } from './useMediaQuery.js'
3
+
4
+ /** Matches Sidebar mobile breakpoint. */
5
+ export const MOBILE_NAV_QUERY = '(max-width: 768px)'
6
+
7
+ /**
8
+ * Mobile nav precedence:
9
+ * - Sidebar + top bar → sidebar wins; top bar is hidden
10
+ * - Top bar only → top bar wins; Header mounts a fallback sidebar
11
+ * - Sidebar only → sidebar
12
+ *
13
+ * Explicit sidebar mounting stays with the app. Header owns the top-bar-only
14
+ * mobile fallback via `needsFallbackSidebar`.
15
+ */
16
+ export function useResponsiveNav(sidebarEnabled, topBarEnabled) {
17
+ const isMobile = useMediaQuery(MOBILE_NAV_QUERY)
18
+
19
+ const hasExplicitSidebar = computed(() => !!toValue(sidebarEnabled))
20
+ const topBarOnly = computed(
21
+ () => !!toValue(topBarEnabled) && !toValue(sidebarEnabled),
22
+ )
23
+
24
+ const needsFallbackSidebar = computed(
25
+ () => topBarOnly.value && isMobile.value,
26
+ )
27
+
28
+ const showSidebarChrome = computed(
29
+ () => hasExplicitSidebar.value || needsFallbackSidebar.value,
30
+ )
31
+
32
+ const showTopBar = computed(
33
+ () => !!toValue(topBarEnabled) && !isMobile.value,
34
+ )
35
+
36
+ return {
37
+ isMobile,
38
+ showTopBar,
39
+ showSidebarChrome,
40
+ needsFallbackSidebar,
41
+ }
42
+ }
package/vue/views/App.vue CHANGED
@@ -1,17 +1,17 @@
1
1
  <template>
2
- <Header username = "Guest" @toggleSidebar="toggleSidebar" @logoClick="goToIndex" title = "PicoCrank" :logoUrl="logoUrl" :sidebarEnabled="sidebarEnabled" :navigation="navigation" :topBarEnabled="topBarEnabled" :showBranding="brandingEnabled" :breadcrumbs="breadcrumbsEnabled" :themeToggleEnabled="true">
3
- <template #toolbar>
4
- <QuickSearch
5
- v-if="quickSearchEnabled"
6
- ref="quickSearchRef"
7
- placeholder="Search items..."
8
- :search-fields="['title', 'name']"
9
- :max-results="15"
10
- />
11
- </template>
12
- </Header>
13
-
14
2
  <Navigation ref="navigation">
3
+ <Header username = "Guest" @toggleSidebar="toggleSidebar" @logoClick="goToIndex" title = "PicoCrank" :logoUrl="logoUrl" :sidebarEnabled="sidebarEnabled" :navigation="navigation" :topBarEnabled="topBarEnabled" :showBranding="brandingEnabled" :breadcrumbs="breadcrumbsEnabled" :themeToggleEnabled="true">
4
+ <template #toolbar>
5
+ <QuickSearch
6
+ v-if="quickSearchEnabled"
7
+ ref="quickSearchRef"
8
+ placeholder="Search items..."
9
+ :search-fields="['title', 'name']"
10
+ :max-results="15"
11
+ />
12
+ </template>
13
+ </Header>
14
+
15
15
  <div id = "layout">
16
16
  <Sidebar v-if="sidebarEnabled" ref = "sidebar" />
17
17
 
@@ -33,6 +33,9 @@
33
33
  />
34
34
  <span>Show QuickSearch in the header toolbar</span>
35
35
  </label>
36
+ <p class="subtle">
37
+ On narrow screens it collapses to a search button that opens search in an overlay.
38
+ </p>
36
39
  </dd>
37
40
 
38
41
  <dt>Top bar</dt>
@@ -45,6 +48,10 @@
45
48
  />
46
49
  <span>Show horizontal navigation links below the header</span>
47
50
  </label>
51
+ <p class="subtle">
52
+ On narrow screens: if both are on, the sidebar takes precedence and the top bar is hidden.
53
+ If only the top bar is on, Header collapses it into a mobile sidebar automatically.
54
+ </p>
48
55
  </dd>
49
56
 
50
57
  <dt>Breadcrumbs</dt>