picocrank 1.21.2 → 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.2",
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": {
@@ -0,0 +1,30 @@
1
+ <template>
2
+ <label v-if="!fake" :for="controlId">{{ label }}</label>
3
+ <span v-else class="fake-label">{{ label }}</span>
4
+ <slot />
5
+ </template>
6
+
7
+ <script setup>
8
+ import { computed } from 'vue'
9
+
10
+ const props = defineProps({
11
+ label: {
12
+ type: String,
13
+ required: true,
14
+ },
15
+ for: {
16
+ type: String,
17
+ default: '',
18
+ },
19
+ htmlFor: {
20
+ type: String,
21
+ default: '',
22
+ },
23
+ fake: {
24
+ type: Boolean,
25
+ default: false,
26
+ },
27
+ })
28
+
29
+ const controlId = computed(() => props.htmlFor || props.for)
30
+ </script>
@@ -0,0 +1,8 @@
1
+ <template>
2
+ <form>
3
+ <slot />
4
+ <fieldset v-if="$slots.actions">
5
+ <slot name="actions" />
6
+ </fieldset>
7
+ </form>
8
+ </template>
@@ -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
 
@@ -20,14 +20,14 @@
20
20
  </Section>
21
21
 
22
22
  <Section title="Form actions" subtitle="Semantic button types in a fieldset">
23
- <form class="buttons-demo-form" @submit.prevent="onSubmit">
24
- <fieldset>
23
+ <FormLayout class="buttons-demo-form" @submit.prevent="onSubmit">
24
+ <template #actions>
25
25
  <button type="submit">Submit</button>
26
26
  <button type="cancel">Cancel</button>
27
27
  <button type="reset" @click="onReset">Reset</button>
28
- </fieldset>
29
- <p v-if="lastAction" class="subtle">{{ lastAction }}</p>
30
- </form>
28
+ </template>
29
+ </FormLayout>
30
+ <p v-if="lastAction" class="subtle">{{ lastAction }}</p>
31
31
  </Section>
32
32
 
33
33
  <Section title="Links as buttons" subtitle="Anchor elements with the .button class">
@@ -68,6 +68,7 @@
68
68
  <script setup>
69
69
  import { ref } from 'vue'
70
70
  import Section from '../components/Section.vue'
71
+ import FormLayout from '../components/FormLayout.vue'
71
72
 
72
73
  const lastAction = ref('')
73
74
 
@@ -7,181 +7,176 @@
7
7
  <button @click="resetForm">Reset</button>
8
8
  </template>
9
9
 
10
- <form @submit.prevent="handleSubmit">
11
- <!-- Name -->
12
- <label for="name">Name</label>
13
- <input
14
- id="name"
15
- v-model="formData.name"
16
- type="text"
17
- placeholder="Enter your name"
18
- required
19
- />
20
-
21
- <!-- Role (radio group) -->
22
- <span class="fake-label">Role</span>
23
- <div class="radio-group">
24
- <label>
25
- <input
26
- type="radio"
27
- name="role"
28
- value="sales"
29
- v-model="formData.role"
30
- />
31
- <span>Sales</span>
32
- </label>
33
- <label>
34
- <input
35
- type="radio"
36
- name="role"
37
- value="engineering"
38
- v-model="formData.role"
39
- />
40
- <span>Engineering</span>
41
- </label>
42
- <label>
43
- <input
44
- type="radio"
45
- name="role"
46
- value="hr"
47
- v-model="formData.role"
48
- />
49
- <span>HR</span>
50
- </label>
51
- </div>
52
-
53
- <!-- Salary (select) -->
54
- <label for="salary">Salary</label>
55
- <select id="salary" v-model="formData.salary">
56
- <option value="0">Select a salary</option>
57
- <option value="10000">£10,000</option>
58
- <option value="20000">£20,000</option>
59
- <option value="30000">£30,000</option>
60
- <option value="40000">£40,000</option>
61
- <option value="50000">£50,000</option>
62
- </select>
63
-
64
- <!-- Email -->
65
- <label for="email">Email</label>
66
- <input
67
- id="email"
68
- v-model="formData.email"
69
- type="email"
70
- placeholder="Enter your email"
71
- required
72
- />
73
-
74
- <!-- Administrator checkbox -->
75
- <label>Is Administrator?</label>
76
- <input
77
- type="checkbox"
78
- id="is-admin"
79
- v-model="formData.isAdmin"
80
- />
81
-
82
- <!-- Website -->
83
- <label for="website">Website</label>
84
- <input
85
- id="website"
86
- v-model="formData.website"
87
- type="url"
88
- placeholder="https://example.com"
89
- />
90
-
91
- <!-- Favourite food (disabled) -->
92
- <label for="favourite-food">Favourite Food</label>
93
- <input
94
- id="favourite-food"
95
- v-model="formData.favouriteFood"
96
- type="text"
97
- placeholder="e.g. Pizza"
98
- disabled
99
- />
100
-
101
- <!-- Favourite colour -->
102
- <label for="favourite-colour">Favourite Colour</label>
103
- <input
104
- id="favourite-colour"
105
- v-model="formData.favouriteColour"
106
- type="color"
107
- />
108
-
109
- <!-- Favourite number -->
110
- <label for="favourite-number">Favourite Number</label>
111
- <input
112
- id="favourite-number"
113
- v-model.number="formData.favouriteNumber"
114
- type="number"
115
- min="0"
116
- max="100"
117
- step="1"
118
- />
119
-
120
- <!-- Newsletters (checkbox group) -->
121
- <span class="fake-label">
122
- Newsletters
123
- </span>
124
- <div>
125
- <label>
126
- <input
127
- type="checkbox"
128
- id="newsletter1"
129
- v-model="formData.newsletter1"
130
- />
131
- <span>Newsletter 1</span>
132
- </label>
133
-
134
- <label>
135
- <input
136
- type="checkbox"
137
- id="newsletter2"
138
- v-model="formData.newsletter2"
139
- />
140
- <span>Newsletter 2</span>
141
- </label>
142
- </div>
143
-
144
- <!-- Comments -->
145
- <label for="comments">Comments</label>
146
- <textarea
147
- id="comments"
148
- v-model="formData.comments"
149
- ></textarea>
150
-
151
- <!-- Readonly textarea -->
152
- <label for="readonly">Readonly</label>
153
- <textarea
154
- id="readonly"
155
- v-model="formData.readonlyText"
156
- readonly
157
- ></textarea>
158
-
159
- <!-- Disabled textarea -->
160
- <label for="disabled">Disabled</label>
161
- <textarea
162
- id="disabled"
163
- v-model="formData.disabledText"
164
- disabled
165
- ></textarea>
166
-
167
- <!-- Datetime -->
168
- <label for="datetime">
169
- Datetime
170
- </label>
171
- <input
172
- id="datetime"
173
- type="datetime-local"
174
- v-model="formData.datetime"
175
- />
176
-
177
- <!-- Buttons in fieldset to match simple.html -->
178
- <fieldset>
10
+ <FormLayout @submit.prevent="handleSubmit">
11
+ <FormField label="Name" for="name">
12
+ <input
13
+ id="name"
14
+ v-model="formData.name"
15
+ type="text"
16
+ placeholder="Enter your name"
17
+ required
18
+ />
19
+ </FormField>
20
+
21
+ <FormField label="Role" fake>
22
+ <div class="radio-group">
23
+ <label>
24
+ <input
25
+ type="radio"
26
+ name="role"
27
+ value="sales"
28
+ v-model="formData.role"
29
+ />
30
+ <span>Sales</span>
31
+ </label>
32
+ <label>
33
+ <input
34
+ type="radio"
35
+ name="role"
36
+ value="engineering"
37
+ v-model="formData.role"
38
+ />
39
+ <span>Engineering</span>
40
+ </label>
41
+ <label>
42
+ <input
43
+ type="radio"
44
+ name="role"
45
+ value="hr"
46
+ v-model="formData.role"
47
+ />
48
+ <span>HR</span>
49
+ </label>
50
+ </div>
51
+ </FormField>
52
+
53
+ <FormField label="Salary" for="salary">
54
+ <select id="salary" v-model="formData.salary">
55
+ <option value="0">Select a salary</option>
56
+ <option value="10000">£10,000</option>
57
+ <option value="20000">£20,000</option>
58
+ <option value="30000">£30,000</option>
59
+ <option value="40000">£40,000</option>
60
+ <option value="50000">£50,000</option>
61
+ </select>
62
+ </FormField>
63
+
64
+ <FormField label="Email" for="email">
65
+ <input
66
+ id="email"
67
+ v-model="formData.email"
68
+ type="email"
69
+ placeholder="Enter your email"
70
+ required
71
+ />
72
+ </FormField>
73
+
74
+ <FormField label="Is Administrator?">
75
+ <input
76
+ type="checkbox"
77
+ id="is-admin"
78
+ v-model="formData.isAdmin"
79
+ />
80
+ </FormField>
81
+
82
+ <FormField label="Website" for="website">
83
+ <input
84
+ id="website"
85
+ v-model="formData.website"
86
+ type="url"
87
+ placeholder="https://example.com"
88
+ />
89
+ </FormField>
90
+
91
+ <FormField label="Favourite Food" for="favourite-food">
92
+ <input
93
+ id="favourite-food"
94
+ v-model="formData.favouriteFood"
95
+ type="text"
96
+ placeholder="e.g. Pizza"
97
+ disabled
98
+ />
99
+ </FormField>
100
+
101
+ <FormField label="Favourite Colour" for="favourite-colour">
102
+ <input
103
+ id="favourite-colour"
104
+ v-model="formData.favouriteColour"
105
+ type="color"
106
+ />
107
+ </FormField>
108
+
109
+ <FormField label="Favourite Number" for="favourite-number">
110
+ <input
111
+ id="favourite-number"
112
+ v-model.number="formData.favouriteNumber"
113
+ type="number"
114
+ min="0"
115
+ max="100"
116
+ step="1"
117
+ />
118
+ </FormField>
119
+
120
+ <FormField label="Newsletters" fake>
121
+ <div>
122
+ <label>
123
+ <input
124
+ type="checkbox"
125
+ id="newsletter1"
126
+ v-model="formData.newsletter1"
127
+ />
128
+ <span>Newsletter 1</span>
129
+ </label>
130
+
131
+ <label>
132
+ <input
133
+ type="checkbox"
134
+ id="newsletter2"
135
+ v-model="formData.newsletter2"
136
+ />
137
+ <span>Newsletter 2</span>
138
+ </label>
139
+ </div>
140
+ </FormField>
141
+
142
+ <FormField label="Comments" for="comments">
143
+ <textarea
144
+ id="comments"
145
+ v-model="formData.comments"
146
+ ></textarea>
147
+ </FormField>
148
+
149
+ <FormField label="Readonly" for="readonly">
150
+ <textarea
151
+ id="readonly"
152
+ v-model="formData.readonlyText"
153
+ readonly
154
+ ></textarea>
155
+ </FormField>
156
+
157
+ <FormField label="Disabled" for="disabled">
158
+ <textarea
159
+ id="disabled"
160
+ v-model="formData.disabledText"
161
+ disabled
162
+ ></textarea>
163
+ </FormField>
164
+
165
+ <FormField label="Datetime" for="datetime">
166
+ <input
167
+ id="datetime"
168
+ type="datetime-local"
169
+ v-model="formData.datetime"
170
+ />
171
+ </FormField>
172
+
173
+ <template #actions>
179
174
  <button type="submit">Submit</button>
180
175
  <button type="button" @click="handleCancel">Cancel</button>
181
176
  <button type="reset" @click="resetForm">Reset</button>
182
177
  <button type="submit" disabled>Disabled</button>
183
- </fieldset>
184
- </form>
178
+ </template>
179
+ </FormLayout>
185
180
 
186
181
  <div v-if="submitted" class="form-result">
187
182
  <h3>Form Submitted!</h3>
@@ -193,6 +188,8 @@
193
188
  <script setup>
194
189
  import { ref } from 'vue'
195
190
  import Section from '../components/Section.vue'
191
+ import FormLayout from '../components/FormLayout.vue'
192
+ import FormField from '../components/FormField.vue'
196
193
 
197
194
  const formData = ref({
198
195
  name: '',
@@ -246,4 +243,4 @@ function resetForm() {
246
243
  }
247
244
  submitted.value = false
248
245
  }
249
- </script>
246
+ </script>
@@ -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>
@@ -89,15 +89,16 @@
89
89
  title="In context"
90
90
  subtitle="Inline notifications beside or below form fields"
91
91
  >
92
- <form class="status-form" @submit.prevent="submitForm">
93
- <label for="status-email">Email</label>
94
- <input
95
- id="status-email"
96
- v-model="formEmail"
97
- type="email"
98
- placeholder="you@example.com"
99
- required
100
- />
92
+ <FormLayout class="status-form" @submit.prevent="submitForm">
93
+ <FormField label="Email" for="status-email">
94
+ <input
95
+ id="status-email"
96
+ v-model="formEmail"
97
+ type="email"
98
+ placeholder="you@example.com"
99
+ required
100
+ />
101
+ </FormField>
101
102
 
102
103
  <p
103
104
  v-if="formMessage"
@@ -109,17 +110,19 @@
109
110
  {{ formMessage }}
110
111
  </p>
111
112
 
112
- <fieldset>
113
+ <template #actions>
113
114
  <button type="submit" class="good">Save</button>
114
115
  <button type="button" @click="clearForm">Clear</button>
115
- </fieldset>
116
- </form>
116
+ </template>
117
+ </FormLayout>
117
118
  </Section>
118
119
  </template>
119
120
 
120
121
  <script setup>
121
122
  import { ref, computed } from 'vue'
122
123
  import Section from '../components/Section.vue'
124
+ import FormLayout from '../components/FormLayout.vue'
125
+ import FormField from '../components/FormField.vue'
123
126
  import { useNotificationPopups } from '../composables/useNotificationPopups.js'
124
127
 
125
128
  const { show: showPopup, dismissAll: dismissAllPopups } = useNotificationPopups()