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 +1 -1
- package/vue/components/Header.vue +34 -13
- package/vue/components/QuickSearch.vue +265 -61
- package/vue/components/Sidebar.vue +34 -3
- package/vue/composables/useMediaQuery.js +50 -0
- package/vue/composables/useResponsiveNav.js +42 -0
- package/vue/views/App.vue +12 -12
- package/vue/views/PageStructure.vue +7 -0
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<header>
|
|
3
3
|
<div
|
|
4
|
-
:id = "
|
|
5
|
-
:class="['image-and-title', 'flex-row', { 'disabled-branding': !showBranding, 'logo-home-link': !
|
|
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="
|
|
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="
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
:
|
|
24
|
-
:
|
|
25
|
-
|
|
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="
|
|
29
|
-
<
|
|
30
|
-
<
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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 {
|
|
54
|
-
import {
|
|
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-
|
|
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
|
|
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
|
-
|
|
107
|
-
const
|
|
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>
|