@vuepress-plume/plugin-search 1.0.0-rc.36

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,703 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ type Ref,
4
+ computed,
5
+ markRaw,
6
+ nextTick,
7
+ onBeforeUnmount,
8
+ onMounted,
9
+ ref,
10
+ shallowRef,
11
+ toRef,
12
+ watch,
13
+ } from 'vue'
14
+ import { useRouteLocale, useRouter } from 'vuepress/client'
15
+ import {
16
+ computedAsync,
17
+ debouncedWatch,
18
+ onKeyStroke,
19
+ useEventListener,
20
+ useScrollLock,
21
+ useSessionStorage,
22
+ } from '@vueuse/core'
23
+ import Mark from 'mark.js/src/vanilla.js'
24
+ import { useFocusTrap } from '@vueuse/integrations/useFocusTrap'
25
+ import MiniSearch, { type SearchResult } from 'minisearch'
26
+ import { useSearchIndex } from '../composables/index.js'
27
+ import type { SearchBoxLocales, SearchOptions } from '../../shared/index.js'
28
+ import { LRUCache } from '../utils/lru.js'
29
+ import { useLocale } from '../composables/locale.js'
30
+ import SearchIcon from './icons/SearchIcon.vue'
31
+ import ClearIcon from './icons/ClearIcon.vue'
32
+ import BackIcon from './icons/BackIcon.vue'
33
+
34
+ const props = defineProps<{
35
+ locales: SearchBoxLocales
36
+ options: SearchOptions
37
+ }>()
38
+
39
+ const emit = defineEmits<{
40
+ (e: 'close'): void
41
+ }>()
42
+
43
+ const routeLocale = useRouteLocale()
44
+ const locale = useLocale(toRef(props.locales))
45
+
46
+ const el = shallowRef<HTMLElement>()
47
+ const resultsEl = shallowRef<HTMLElement>()
48
+
49
+ const searchIndexData = useSearchIndex()
50
+
51
+ interface Result {
52
+ title: string
53
+ titles: string[]
54
+ text?: string
55
+ }
56
+
57
+ const { activate } = useFocusTrap(el, {
58
+ immediate: true,
59
+ })
60
+
61
+ const searchIndex = computedAsync(async () =>
62
+ markRaw(
63
+ MiniSearch.loadJSON<Result>(
64
+ (await searchIndexData.value[routeLocale.value]?.())?.default,
65
+ {
66
+ fields: ['title', 'titles', 'text'],
67
+ storeFields: ['title', 'titles'],
68
+ searchOptions: {
69
+ fuzzy: 0.2,
70
+ prefix: true,
71
+ boost: { title: 4, text: 2, titles: 1 },
72
+ },
73
+ ...props.options.miniSearch?.searchOptions,
74
+ ...props.options.miniSearch?.options,
75
+ },
76
+ ),
77
+ ),
78
+ )
79
+
80
+ const disableQueryPersistence = computed(() =>
81
+ props.options?.disableQueryPersistence === true,
82
+ )
83
+ const filterText = disableQueryPersistence.value
84
+ ? ref('')
85
+ : useSessionStorage('vuepress-plume:mini-search-filter', '')
86
+
87
+ const buttonText = computed(() => locale.value.buttonText || locale.value.placeholder || 'Search')
88
+
89
+ const results: Ref<(SearchResult & Result)[]> = shallowRef([])
90
+
91
+ const enableNoResults = ref(false)
92
+
93
+ watch(filterText, () => {
94
+ enableNoResults.value = false
95
+ })
96
+
97
+ const mark = computedAsync(async () => {
98
+ if (!resultsEl.value)
99
+ return
100
+ return markRaw(new Mark(resultsEl.value))
101
+ }, null)
102
+
103
+ const cache = new LRUCache<string, Map<string, string>>(16) // 16 files
104
+
105
+ debouncedWatch(
106
+ () => [searchIndex.value, filterText.value] as const,
107
+ async ([index, filterTextValue], old, onCleanup) => {
108
+ if (old?.[0] !== index) {
109
+ // in case of hmr
110
+ cache.clear()
111
+ }
112
+
113
+ let canceled = false
114
+ onCleanup(() => {
115
+ canceled = true
116
+ })
117
+
118
+ if (!index)
119
+ return
120
+
121
+ // Search
122
+ results.value = index
123
+ .search(filterTextValue)
124
+ .slice(0, 16)
125
+ .map((r) => {
126
+ r.titles = r.titles?.filter(Boolean) || []
127
+ return r
128
+ }) as (SearchResult & Result)[]
129
+ enableNoResults.value = true
130
+
131
+ const terms = new Set<string>()
132
+
133
+ results.value = results.value.map((r) => {
134
+ const [id, anchor] = r.id.split('#')
135
+ const map = cache.get(id)
136
+ const text = map?.get(anchor) ?? ''
137
+ for (const term in r.match)
138
+ terms.add(term)
139
+
140
+ return { ...r, text }
141
+ })
142
+
143
+ await nextTick()
144
+ if (canceled)
145
+ return
146
+
147
+ await new Promise((r) => {
148
+ mark.value?.unmark({
149
+ done: () => {
150
+ mark.value?.markRegExp(formMarkRegex(terms), { done: r })
151
+ },
152
+ })
153
+ })
154
+ },
155
+ { debounce: 200, immediate: true },
156
+ )
157
+
158
+ /* Search input focus */
159
+
160
+ const searchInput = ref<HTMLInputElement>()
161
+ const disableReset = computed(() => {
162
+ return filterText.value?.length <= 0
163
+ })
164
+ function focusSearchInput(select = true) {
165
+ searchInput.value?.focus()
166
+ select && searchInput.value?.select()
167
+ }
168
+
169
+ onMounted(() => {
170
+ focusSearchInput()
171
+ })
172
+
173
+ function onSearchBarClick(event: PointerEvent) {
174
+ if (event.pointerType === 'mouse')
175
+ focusSearchInput()
176
+ }
177
+
178
+ /* Search keyboard selection */
179
+
180
+ const selectedIndex = ref(-1)
181
+ const disableMouseOver = ref(false)
182
+
183
+ watch(results, (r) => {
184
+ selectedIndex.value = r.length ? 0 : -1
185
+ scrollToSelectedResult()
186
+ })
187
+
188
+ function scrollToSelectedResult() {
189
+ nextTick(() => {
190
+ const selectedEl = document.querySelector('.result.selected')
191
+ if (selectedEl) {
192
+ selectedEl.scrollIntoView({
193
+ block: 'nearest',
194
+ })
195
+ }
196
+ })
197
+ }
198
+
199
+ onKeyStroke('ArrowUp', (event) => {
200
+ event.preventDefault()
201
+ selectedIndex.value--
202
+ if (selectedIndex.value < 0)
203
+ selectedIndex.value = results.value.length - 1
204
+
205
+ disableMouseOver.value = true
206
+ scrollToSelectedResult()
207
+ })
208
+
209
+ onKeyStroke('ArrowDown', (event) => {
210
+ event.preventDefault()
211
+ selectedIndex.value++
212
+ if (selectedIndex.value >= results.value.length)
213
+ selectedIndex.value = 0
214
+
215
+ disableMouseOver.value = true
216
+ scrollToSelectedResult()
217
+ })
218
+
219
+ const router = useRouter()
220
+
221
+ onKeyStroke('Enter', (e) => {
222
+ if (e.isComposing)
223
+ return
224
+
225
+ if (e.target instanceof HTMLButtonElement && e.target.type !== 'submit')
226
+ return
227
+
228
+ const selectedPackage = results.value[selectedIndex.value]
229
+ if (e.target instanceof HTMLInputElement && !selectedPackage) {
230
+ e.preventDefault()
231
+ return
232
+ }
233
+
234
+ if (selectedPackage) {
235
+ router.go(selectedPackage.id)
236
+ emit('close')
237
+ }
238
+ })
239
+
240
+ onKeyStroke('Escape', () => {
241
+ emit('close')
242
+ })
243
+
244
+ // Back
245
+
246
+ onMounted(() => {
247
+ // Prevents going to previous site
248
+ window.history.pushState(null, '', null)
249
+ })
250
+
251
+ useEventListener('popstate', (event) => {
252
+ event.preventDefault()
253
+ emit('close')
254
+ })
255
+
256
+ /** Lock body */
257
+ const isLocked = useScrollLock(typeof document !== 'undefined' ? document.body : null)
258
+
259
+ onMounted(() => {
260
+ nextTick(() => {
261
+ isLocked.value = true
262
+ nextTick().then(() => activate())
263
+ })
264
+ })
265
+
266
+ onBeforeUnmount(() => {
267
+ isLocked.value = false
268
+ })
269
+
270
+ function resetSearch() {
271
+ filterText.value = ''
272
+ nextTick().then(() => focusSearchInput(false))
273
+ }
274
+
275
+ function escapeRegExp(str: string) {
276
+ return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d')
277
+ }
278
+
279
+ function formMarkRegex(terms: Set<string>) {
280
+ return new RegExp(
281
+ [...terms]
282
+ .sort((a, b) => b.length - a.length)
283
+ .map(term => `(${escapeRegExp(term)})`)
284
+ .join('|'),
285
+ 'gi',
286
+ )
287
+ }
288
+ </script>
289
+
290
+ <template>
291
+ <Teleport to="body">
292
+ <div
293
+ ref="el"
294
+ role="button"
295
+ :aria-owns="results?.length ? 'localsearch-list' : undefined"
296
+ aria-expanded="true"
297
+ aria-haspopup="listbox"
298
+ aria-labelledby="mini-search-label"
299
+ class="VPLocalSearchBox"
300
+ >
301
+ <div class="backdrop" @click="$emit('close')" />
302
+
303
+ <div class="shell">
304
+ <form
305
+ class="search-bar"
306
+ @pointerup="onSearchBarClick($event)"
307
+ @submit.prevent=""
308
+ >
309
+ <label
310
+ id="localsearch-label"
311
+ :title="buttonText"
312
+ for="localsearch-input"
313
+ >
314
+ <SearchIcon class="search-icon" />
315
+ </label>
316
+ <div class="search-actions before">
317
+ <button
318
+ class="back-button"
319
+ :title="locale.backButtonTitle"
320
+ @click="$emit('close')"
321
+ >
322
+ <BackIcon />
323
+ </button>
324
+ </div>
325
+ <input
326
+ id="localsearch-input"
327
+ ref="searchInput"
328
+ v-model="filterText"
329
+ :placeholder="buttonText"
330
+ aria-labelledby="localsearch-label"
331
+ class="search-input"
332
+ >
333
+ <div class="search-actions">
334
+ <button
335
+ class="clear-button"
336
+ type="reset"
337
+ :disabled="disableReset"
338
+ :title="locale.resetButtonTitle"
339
+ @click="resetSearch"
340
+ >
341
+ <ClearIcon />
342
+ </button>
343
+ </div>
344
+ </form>
345
+
346
+ <ul
347
+ :id="results?.length ? 'localsearch-list' : undefined"
348
+ ref="resultsEl"
349
+ :role="results?.length ? 'listbox' : undefined"
350
+ :aria-labelledby="results?.length ? 'localsearch-label' : undefined"
351
+ class="results"
352
+ @mousemove="disableMouseOver = false"
353
+ >
354
+ <li
355
+ v-for="(p, index) in results"
356
+ :key="p.id"
357
+ role="option"
358
+ :aria-selected="selectedIndex === index ? 'true' : 'false'"
359
+ >
360
+ <a
361
+ :href="p.id"
362
+ class="result"
363
+ :class="{
364
+ selected: selectedIndex === index,
365
+ }"
366
+ :aria-label="[...p.titles, p.title].join(' > ')"
367
+ @mouseenter="!disableMouseOver && (selectedIndex = index)"
368
+ @focusin="selectedIndex = index"
369
+ @click="$emit('close')"
370
+ >
371
+ <div>
372
+ <div class="titles">
373
+ <span class="title-icon">#</span>
374
+ <span
375
+ v-for="(t, i) in p.titles"
376
+ :key="i"
377
+ class="title"
378
+ >
379
+ <span class="text" v-html="t" />
380
+ <svg width="18" height="18" viewBox="0 0 24 24">
381
+ <path
382
+ fill="none"
383
+ stroke="currentColor"
384
+ stroke-linecap="round"
385
+ stroke-linejoin="round"
386
+ stroke-width="2"
387
+ d="m9 18l6-6l-6-6"
388
+ />
389
+ </svg>
390
+ </span>
391
+ <span class="title main">
392
+ <span class="text" v-html="p.title" />
393
+ </span>
394
+ </div>
395
+ </div>
396
+ </a>
397
+ </li>
398
+ <li
399
+ v-if="filterText && !results.length && enableNoResults"
400
+ class="no-results"
401
+ >
402
+ {{ locale.noResultsText }} "<strong>{{ filterText }}</strong>"
403
+ </li>
404
+ </ul>
405
+
406
+ <div class="search-keyboard-shortcuts">
407
+ <span>
408
+ <kbd :aria-label="locale.footer?.navigateUpKeyAriaLabel ?? ''">
409
+ <svg width="14" height="14" viewBox="0 0 24 24">
410
+ <path
411
+ fill="none"
412
+ stroke="currentColor"
413
+ stroke-linecap="round"
414
+ stroke-linejoin="round"
415
+ stroke-width="2"
416
+ d="M12 19V5m-7 7l7-7l7 7"
417
+ />
418
+ </svg>
419
+ </kbd>
420
+ <kbd :aria-label="locale.footer?.navigateDownKeyAriaLabel ?? ''">
421
+ <svg width="14" height="14" viewBox="0 0 24 24">
422
+ <path
423
+ fill="none"
424
+ stroke="currentColor"
425
+ stroke-linecap="round"
426
+ stroke-linejoin="round"
427
+ stroke-width="2"
428
+ d="M12 5v14m7-7l-7 7l-7-7"
429
+ />
430
+ </svg>
431
+ </kbd>
432
+ {{ locale.footer?.navigateText ?? '' }}
433
+ </span>
434
+ <span>
435
+ <kbd :aria-label="locale.footer?.selectKeyAriaLabel ?? ''">
436
+ <svg width="14" height="14" viewBox="0 0 24 24">
437
+ <g
438
+ fill="none"
439
+ stroke="currentcolor"
440
+ stroke-linecap="round"
441
+ stroke-linejoin="round"
442
+ stroke-width="2"
443
+ >
444
+ <path d="m9 10l-5 5l5 5" />
445
+ <path d="M20 4v7a4 4 0 0 1-4 4H4" />
446
+ </g>
447
+ </svg>
448
+ </kbd>
449
+ {{ locale.footer?.selectText ?? '' }}
450
+ </span>
451
+ <span>
452
+ <kbd :aria-label="locale.footer?.closeKeyAriaLabel ?? ''">esc</kbd>
453
+ {{ locale.footer?.closeText ?? '' }}
454
+ </span>
455
+ </div>
456
+ </div>
457
+ </div>
458
+ </Teleport>
459
+ </template>
460
+
461
+ <style>
462
+ :root {
463
+ --vp-mini-search-bg: var(--vp-c-bg);
464
+ --vp-mini-search-result-bg: var(--vp-c-bg);
465
+ --vp-mini-search-result-border: var(--vp-c-divider);
466
+ --vp-mini-search-result-selected-bg: var(--vp-c-bg);
467
+ --vp-mini-search-result-selected-border: var(--vp-c-brand-1);
468
+ --vp-mini-search-highlight-bg: var(--vp-c-brand-1);
469
+ --vp-mini-search-highlight-text: var(--vp-c-neutral-inverse);
470
+ }
471
+ </style>
472
+
473
+ <style scoped>
474
+ svg {
475
+ flex: none;
476
+ }
477
+
478
+ .VPLocalSearchBox {
479
+ position: fixed;
480
+ inset: 0;
481
+ z-index: 100;
482
+ display: flex;
483
+ }
484
+
485
+ .backdrop {
486
+ position: absolute;
487
+ inset: 0;
488
+ background: var(--vp-backdrop-bg-color);
489
+ transition: opacity 0.5s;
490
+ }
491
+
492
+ .shell {
493
+ position: relative;
494
+ display: flex;
495
+ flex-direction: column;
496
+ gap: 16px;
497
+ width: min(100vw - 60px, 900px);
498
+ height: min-content;
499
+ max-height: min(100vh - 128px, 900px);
500
+ padding: 12px;
501
+ margin: 64px auto;
502
+ background: var(--vp-mini-search-bg);
503
+ border-radius: 6px;
504
+ }
505
+
506
+ @media (max-width: 767px) {
507
+ .shell {
508
+ width: 100vw;
509
+ height: 100vh;
510
+ max-height: none;
511
+ margin: 0;
512
+ border-radius: 0;
513
+ }
514
+ }
515
+
516
+ .search-bar {
517
+ display: flex;
518
+ align-items: center;
519
+ padding: 0 12px;
520
+ cursor: text;
521
+ border: 1px solid var(--vp-c-divider);
522
+ border-radius: 4px;
523
+ }
524
+
525
+ @media (max-width: 767px) {
526
+ .search-bar {
527
+ padding: 0 8px;
528
+ }
529
+ }
530
+
531
+ .search-bar:focus-within {
532
+ border-color: var(--vp-c-brand-1);
533
+ }
534
+
535
+ .search-icon {
536
+ margin: 8px;
537
+ }
538
+
539
+ @media (max-width: 767px) {
540
+ .search-icon {
541
+ display: none;
542
+ }
543
+ }
544
+
545
+ .search-input {
546
+ width: 100%;
547
+ padding: 6px 12px;
548
+ font-size: inherit;
549
+ }
550
+
551
+ @media (max-width: 767px) {
552
+ .search-input {
553
+ padding: 6px 4px;
554
+ }
555
+ }
556
+
557
+ .search-actions {
558
+ display: flex;
559
+ gap: 4px;
560
+ }
561
+
562
+ @media (any-pointer: coarse) {
563
+ .search-actions {
564
+ gap: 8px;
565
+ }
566
+ }
567
+
568
+ @media (min-width: 769px) {
569
+ .search-actions.before {
570
+ display: none;
571
+ }
572
+ }
573
+
574
+ .search-actions button {
575
+ padding: 8px;
576
+ }
577
+
578
+ .search-actions button:not([disabled]):hover,
579
+ .toggle-layout-button.detailed-list {
580
+ color: var(--vp-c-brand-1);
581
+ }
582
+
583
+ .search-actions button.clear-button:disabled {
584
+ opacity: 0.37;
585
+ }
586
+
587
+ .search-keyboard-shortcuts {
588
+ display: flex;
589
+ flex-wrap: wrap;
590
+ gap: 16px;
591
+ font-size: 0.8rem;
592
+ line-height: 14px;
593
+ opacity: 0.75;
594
+ }
595
+
596
+ .search-keyboard-shortcuts span {
597
+ display: flex;
598
+ gap: 4px;
599
+ align-items: center;
600
+ }
601
+
602
+ @media (max-width: 767px) {
603
+ .search-keyboard-shortcuts {
604
+ display: none;
605
+ }
606
+ }
607
+
608
+ .search-keyboard-shortcuts kbd {
609
+ display: inline-block;
610
+ min-width: 24px;
611
+ padding: 3px 6px;
612
+ text-align: center;
613
+ vertical-align: middle;
614
+ background: rgba(128, 128, 128, 0.1);
615
+ border: 1px solid rgba(128, 128, 128, 0.15);
616
+ border-radius: 4px;
617
+ box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.1);
618
+ }
619
+
620
+ .results {
621
+ display: flex;
622
+ flex-direction: column;
623
+ gap: 6px;
624
+ overflow: hidden auto;
625
+ overscroll-behavior: contain;
626
+ }
627
+
628
+ .result {
629
+ display: flex;
630
+ gap: 8px;
631
+ align-items: center;
632
+ line-height: 1rem;
633
+ border: solid 2px var(--vp-mini-search-result-border);
634
+ border-radius: 4px;
635
+ outline: none;
636
+ transition: none;
637
+ }
638
+
639
+ .result > div {
640
+ width: 100%;
641
+ margin: 12px;
642
+ overflow: hidden;
643
+ }
644
+
645
+ @media (max-width: 767px) {
646
+ .result > div {
647
+ margin: 8px;
648
+ }
649
+ }
650
+
651
+ .titles {
652
+ position: relative;
653
+ z-index: 1001;
654
+ display: flex;
655
+ flex-wrap: wrap;
656
+ gap: 4px;
657
+ padding: 2px 0;
658
+ }
659
+
660
+ .title {
661
+ display: flex;
662
+ gap: 4px;
663
+ align-items: center;
664
+ }
665
+
666
+ .title.main {
667
+ font-weight: 500;
668
+ }
669
+
670
+ .title-icon {
671
+ font-weight: 500;
672
+ color: var(--vp-c-brand-1);
673
+ opacity: 0.5;
674
+ }
675
+
676
+ .title :deep(svg) {
677
+ opacity: 0.5;
678
+ }
679
+
680
+ .result.selected {
681
+ --vp-mini-search-result-bg: var(--vp-mini-search-result-selected-bg);
682
+
683
+ border-color: var(--vp-mini-search-result-selected-border);
684
+ }
685
+
686
+ .titles :deep(mark) {
687
+ padding: 0 2px;
688
+ color: var(--vp-mini-search-highlight-text);
689
+ background-color: var(--vp-mini-search-highlight-bg);
690
+ border-radius: 2px;
691
+ }
692
+
693
+ .result.selected .titles,
694
+ .result.selected .title-icon {
695
+ color: var(--vp-c-brand-1) !important;
696
+ }
697
+
698
+ .no-results {
699
+ padding: 12px;
700
+ font-size: 0.9rem;
701
+ text-align: center;
702
+ }
703
+ </style>