@vuepress-plume/plugin-search 1.0.0-rc.213 → 1.0.0-rc.215
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/dist/client/components/Search.vue +41 -11
- package/dist/client/components/SearchBox.vue +153 -74
- package/dist/client/components/SearchButton.vue +35 -8
- package/dist/client/composables/index.js +0 -1
- package/dist/client/config.js +0 -1
- package/dist/node/index.d.ts +0 -3
- package/dist/node/index.js +0 -1
- package/dist/shared/index.d.ts +22 -22
- package/dist/shared/index.js +22 -6
- package/package.json +6 -5
|
@@ -4,8 +4,10 @@ import { onKeyStroke } from '@vueuse/core'
|
|
|
4
4
|
import {
|
|
5
5
|
computed,
|
|
6
6
|
defineAsyncComponent,
|
|
7
|
+
nextTick,
|
|
7
8
|
ref,
|
|
8
9
|
toRef,
|
|
10
|
+
useTemplateRef,
|
|
9
11
|
} from 'vue'
|
|
10
12
|
import { useLocale } from '../composables/index.js'
|
|
11
13
|
import SearchButton from './SearchButton.vue'
|
|
@@ -19,19 +21,49 @@ const SearchBox = defineAsyncComponent(() => import('./SearchBox.vue'))
|
|
|
19
21
|
|
|
20
22
|
const showSearch = ref(false)
|
|
21
23
|
|
|
24
|
+
const searchButtonRef = useTemplateRef<{ focus: () => void }>('searchButton')
|
|
25
|
+
|
|
22
26
|
const locale = useLocale(toRef(() => props.locales))
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
|
-
*
|
|
29
|
+
* Hand focus back to the trigger button.
|
|
30
|
+
*
|
|
31
|
+
* 把焦点归还给触发按钮。
|
|
26
32
|
*
|
|
27
|
-
*
|
|
33
|
+
* The search box unmounts its own focus trap, so without this the focused input
|
|
34
|
+
* disappears and focus falls back to `<body>`, forcing keyboard users to tab
|
|
35
|
+
* through the whole page again.
|
|
28
36
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* `<noscript>` only renders with scripting disabled, and must stay plain text:
|
|
32
|
-
* with scripting enabled browsers parse its content as raw text, so elements or
|
|
33
|
-
* HTML entities would cause a hydration mismatch.
|
|
37
|
+
* 搜索框卸载时会一并销毁自身的焦点陷阱:若不显式归还,被聚焦的输入框随组件
|
|
38
|
+
* 消失后焦点会落到 `<body>`,键盘用户只能从页面第一个元素重新 Tab 一遍。
|
|
34
39
|
*/
|
|
40
|
+
function restoreSearchFocus() {
|
|
41
|
+
// 等待卸载完成(焦点陷阱销毁)后再归还焦点,否则会被其收尾逻辑覆盖。
|
|
42
|
+
// Wait until the unmount finished (the focus trap is destroyed), otherwise the
|
|
43
|
+
// trap teardown would override the focus we just set.
|
|
44
|
+
nextTick(() => searchButtonRef.value?.focus())
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function closeSearch(options?: { restoreFocus?: boolean }) {
|
|
48
|
+
showSearch.value = false
|
|
49
|
+
|
|
50
|
+
if (options?.restoreFocus === false) {
|
|
51
|
+
nextTick(() => {
|
|
52
|
+
const main = document.querySelector<HTMLElement>('main, [role="main"]')
|
|
53
|
+
if (!main) {
|
|
54
|
+
searchButtonRef.value?.focus()
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
if (!main.hasAttribute('tabindex'))
|
|
58
|
+
main.setAttribute('tabindex', '-1')
|
|
59
|
+
main.focus({ preventScroll: true })
|
|
60
|
+
})
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
restoreSearchFocus()
|
|
65
|
+
}
|
|
66
|
+
|
|
35
67
|
const noscriptText = computed(
|
|
36
68
|
() => locale.value.noscriptText || 'Search is unavailable without JavaScript.',
|
|
37
69
|
)
|
|
@@ -79,11 +111,11 @@ function isEditingContent(event: KeyboardEvent): boolean {
|
|
|
79
111
|
v-if="showSearch"
|
|
80
112
|
:locales="locales"
|
|
81
113
|
:options="options"
|
|
82
|
-
@close="
|
|
114
|
+
@close="closeSearch"
|
|
83
115
|
/>
|
|
84
116
|
|
|
85
117
|
<div id="local-search">
|
|
86
|
-
<SearchButton :locales="locales" @click="showSearch = true" />
|
|
118
|
+
<SearchButton ref="searchButton" :locales="locales" :expanded="showSearch" @click="showSearch = true" />
|
|
87
119
|
</div>
|
|
88
120
|
|
|
89
121
|
<noscript>{{ noscriptText }}</noscript>
|
|
@@ -96,8 +128,6 @@ function isEditingContent(event: KeyboardEvent): boolean {
|
|
|
96
128
|
align-items: center;
|
|
97
129
|
}
|
|
98
130
|
|
|
99
|
-
/* 仅在禁用 JavaScript 时可见的降级提示。
|
|
100
|
-
Fallback hint that is only visible when JavaScript is disabled. */
|
|
101
131
|
.search-wrapper noscript {
|
|
102
132
|
font-size: 13px;
|
|
103
133
|
line-height: 1.4;
|
|
@@ -22,8 +22,10 @@ import {
|
|
|
22
22
|
ref,
|
|
23
23
|
shallowRef,
|
|
24
24
|
toRef,
|
|
25
|
+
useId,
|
|
25
26
|
watch,
|
|
26
27
|
} from 'vue'
|
|
28
|
+
import { isNavigationFailure, NavigationFailureType } from 'vue-router'
|
|
27
29
|
import { usePageLang, useRouteLocale, useRouter, withBase } from 'vuepress/client'
|
|
28
30
|
import { createTokenizer } from '../../shared/index.js'
|
|
29
31
|
import { loadSearchIndexJSON, useLocale } from '../composables/index.js'
|
|
@@ -37,7 +39,11 @@ const { locales, options } = defineProps<{
|
|
|
37
39
|
}>()
|
|
38
40
|
|
|
39
41
|
const emit = defineEmits<{
|
|
40
|
-
|
|
42
|
+
// `restoreFocus: false` 表示关闭是因为页面已切换到搜索结果,焦点不应归还给触发按钮。
|
|
43
|
+
//
|
|
44
|
+
// `restoreFocus: false` means the box is closing because the page really moved to
|
|
45
|
+
// the result, so focus must not go back to the trigger button.
|
|
46
|
+
(e: 'close', options?: { restoreFocus?: boolean }): void
|
|
41
47
|
}>()
|
|
42
48
|
|
|
43
49
|
const routeLocale = useRouteLocale()
|
|
@@ -47,12 +53,21 @@ const lang = usePageLang()
|
|
|
47
53
|
const el = shallowRef<HTMLElement>()
|
|
48
54
|
const resultsEl = shallowRef<HTMLElement>()
|
|
49
55
|
|
|
56
|
+
const uid = useId()
|
|
57
|
+
const labelId = `${uid}-label`
|
|
58
|
+
const inputId = `${uid}-input`
|
|
59
|
+
const listId = `${uid}-list`
|
|
60
|
+
|
|
61
|
+
function optionId(index: number) {
|
|
62
|
+
return `${uid}-option-${index}`
|
|
63
|
+
}
|
|
64
|
+
|
|
50
65
|
interface Result {
|
|
51
66
|
title: string
|
|
52
67
|
titles: string[]
|
|
53
68
|
}
|
|
54
69
|
|
|
55
|
-
const { activate } = useFocusTrap(el, { immediate: true })
|
|
70
|
+
const { activate } = useFocusTrap(el, { immediate: true, returnFocusOnDeactivate: false })
|
|
56
71
|
|
|
57
72
|
const isSearchIndexLoading = ref(false)
|
|
58
73
|
const isSearching = ref(false)
|
|
@@ -129,8 +144,8 @@ const searchIndex = computedAsync(async (onCancel) => {
|
|
|
129
144
|
prefix: true,
|
|
130
145
|
boost: { title: 4, text: 2, titles: 1 },
|
|
131
146
|
tokenize,
|
|
147
|
+
...options.miniSearch?.searchOptions,
|
|
132
148
|
},
|
|
133
|
-
...options.miniSearch?.searchOptions,
|
|
134
149
|
...options.miniSearch?.options,
|
|
135
150
|
}),
|
|
136
151
|
)
|
|
@@ -160,6 +175,16 @@ watch(filterText, () => {
|
|
|
160
175
|
enableNoResults.value = false
|
|
161
176
|
})
|
|
162
177
|
|
|
178
|
+
const searchStatusText = computed(() => {
|
|
179
|
+
if (searchIndexError.value)
|
|
180
|
+
return searchIndexError.value
|
|
181
|
+
if (!filterText.value || !enableNoResults.value)
|
|
182
|
+
return ''
|
|
183
|
+
if (!results.value.length)
|
|
184
|
+
return `${locale.value.noResultsText || 'No results for'} "${filterText.value}"`
|
|
185
|
+
return `${results.value.length} results`
|
|
186
|
+
})
|
|
187
|
+
|
|
163
188
|
const mark = computedAsync(async () => {
|
|
164
189
|
if (!resultsEl.value)
|
|
165
190
|
return
|
|
@@ -197,10 +222,10 @@ watchDebounced(
|
|
|
197
222
|
if (canceled)
|
|
198
223
|
return
|
|
199
224
|
|
|
200
|
-
await new Promise((
|
|
225
|
+
await new Promise((done) => {
|
|
201
226
|
mark.value?.unmark({
|
|
202
227
|
done: () => {
|
|
203
|
-
mark.value?.markRegExp(formMarkRegex(terms), { done
|
|
228
|
+
mark.value?.markRegExp(formMarkRegex(terms), { done })
|
|
204
229
|
},
|
|
205
230
|
})
|
|
206
231
|
})
|
|
@@ -236,6 +261,10 @@ function onSearchBarClick(event: PointerEvent) {
|
|
|
236
261
|
const selectedIndex = ref(-1)
|
|
237
262
|
const disableMouseOver = ref(false)
|
|
238
263
|
|
|
264
|
+
const activeDescendantId = computed(() =>
|
|
265
|
+
results.value[selectedIndex.value] ? optionId(selectedIndex.value) : undefined,
|
|
266
|
+
)
|
|
267
|
+
|
|
239
268
|
watch(results, (r) => {
|
|
240
269
|
selectedIndex.value = r.length ? 0 : -1
|
|
241
270
|
scrollToSelectedResult()
|
|
@@ -264,6 +293,12 @@ function isMacCtrlShortcut(event: KeyboardEvent) {
|
|
|
264
293
|
|
|
265
294
|
function selectPreviousResult(event: KeyboardEvent) {
|
|
266
295
|
event.preventDefault()
|
|
296
|
+
|
|
297
|
+
if (!results.value.length) {
|
|
298
|
+
selectedIndex.value = -1
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
267
302
|
selectedIndex.value--
|
|
268
303
|
if (selectedIndex.value < 0)
|
|
269
304
|
selectedIndex.value = results.value.length - 1
|
|
@@ -274,6 +309,12 @@ function selectPreviousResult(event: KeyboardEvent) {
|
|
|
274
309
|
|
|
275
310
|
function selectNextResult(event: KeyboardEvent) {
|
|
276
311
|
event.preventDefault()
|
|
312
|
+
|
|
313
|
+
if (!results.value.length) {
|
|
314
|
+
selectedIndex.value = -1
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
277
318
|
selectedIndex.value++
|
|
278
319
|
if (selectedIndex.value >= results.value.length)
|
|
279
320
|
selectedIndex.value = 0
|
|
@@ -310,17 +351,33 @@ const router = useRouter()
|
|
|
310
351
|
let closedByPopstate = false
|
|
311
352
|
|
|
312
353
|
/**
|
|
313
|
-
* Whether the
|
|
354
|
+
* Whether the URL leaves the current page because a result is being opened, so
|
|
355
|
+
* unmount must not roll back the temporary history entry pushed on mount.
|
|
314
356
|
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
* In that case the URL must stay on the target page, so history must not be
|
|
318
|
-
* rolled back on unmount.
|
|
319
|
-
*
|
|
320
|
-
* 此时 URL 必须停留在目标页面,卸载时不能回退历史。
|
|
357
|
+
* 为 true 表示 URL 会离开当前页(搜索结果导航在途、已成功或被更新的导航接替),
|
|
358
|
+
* 卸载时不能回退挂载时压入的临时历史条目。
|
|
321
359
|
*/
|
|
322
360
|
let navigatingToResult = false
|
|
323
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Open a search result: navigate to the target page, then close the box.
|
|
364
|
+
*
|
|
365
|
+
* 打开搜索结果:跳转到目标页面后再关闭搜索框。
|
|
366
|
+
*/
|
|
367
|
+
async function openResult(id: string) {
|
|
368
|
+
navigatingToResult = true
|
|
369
|
+
// 用 replace 顶替挂载时 pushState 的临时历史条目,避免从结果页返回时多一次 Back。
|
|
370
|
+
const navigation = router.replace(id)
|
|
371
|
+
|
|
372
|
+
const navigated = await navigation.then(
|
|
373
|
+
failure => failure === undefined || isNavigationFailure(failure, NavigationFailureType.cancelled),
|
|
374
|
+
() => false,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
navigatingToResult = navigated
|
|
378
|
+
emit('close', { restoreFocus: !navigated })
|
|
379
|
+
}
|
|
380
|
+
|
|
324
381
|
onKeyStroke('Enter', (e) => {
|
|
325
382
|
if (e.isComposing)
|
|
326
383
|
return
|
|
@@ -334,14 +391,8 @@ onKeyStroke('Enter', (e) => {
|
|
|
334
391
|
return
|
|
335
392
|
}
|
|
336
393
|
|
|
337
|
-
if (selectedPackage)
|
|
338
|
-
|
|
339
|
-
// 用 replace 顶替挂载时 pushState 的临时历史条目,避免从结果页返回时多一次 Back。
|
|
340
|
-
// Replace the temporary history entry pushed on mount, so returning from the
|
|
341
|
-
// result page does not require an extra Back press.
|
|
342
|
-
router.replace(selectedPackage.id)
|
|
343
|
-
emit('close')
|
|
344
|
-
}
|
|
394
|
+
if (selectedPackage)
|
|
395
|
+
openResult(selectedPackage.id)
|
|
345
396
|
})
|
|
346
397
|
|
|
347
398
|
onKeyStroke('Escape', () => {
|
|
@@ -364,6 +415,8 @@ useEventListener('popstate', (event) => {
|
|
|
364
415
|
/** Lock body */
|
|
365
416
|
const isLocked = useScrollLock(typeof document !== 'undefined' ? document.body : null)
|
|
366
417
|
|
|
418
|
+
let appEl: Element | null = null
|
|
419
|
+
|
|
367
420
|
onMounted(() => {
|
|
368
421
|
nextTick(() => {
|
|
369
422
|
isLocked.value = true
|
|
@@ -371,15 +424,19 @@ onMounted(() => {
|
|
|
371
424
|
})
|
|
372
425
|
})
|
|
373
426
|
|
|
427
|
+
onMounted(() => {
|
|
428
|
+
appEl = document.querySelector('#app')
|
|
429
|
+
appEl?.setAttribute('inert', '')
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
onBeforeUnmount(() => {
|
|
433
|
+
appEl?.removeAttribute('inert')
|
|
434
|
+
})
|
|
435
|
+
|
|
374
436
|
onBeforeUnmount(() => {
|
|
375
437
|
isLocked.value = false
|
|
376
438
|
// 配对挂载时的 pushState,避免反复开关搜索框累积历史条目。
|
|
377
439
|
// 若该条目已被浏览器后退键(popstate)消费,或正在跳转到搜索结果,则不能回退。
|
|
378
|
-
//
|
|
379
|
-
// Pair the pushState made on mount so repeated open/close does not pile up
|
|
380
|
-
// history entries. Skip the rollback when the entry was already consumed by
|
|
381
|
-
// the browser back button (popstate), or when opening a result (the URL must
|
|
382
|
-
// stay on the target page).
|
|
383
440
|
if (!closedByPopstate && !navigatingToResult)
|
|
384
441
|
window.history.back()
|
|
385
442
|
})
|
|
@@ -404,12 +461,11 @@ function formMarkRegex(terms: Set<string>) {
|
|
|
404
461
|
}
|
|
405
462
|
|
|
406
463
|
function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
464
|
+
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
465
|
+
return
|
|
466
|
+
|
|
407
467
|
e.preventDefault()
|
|
408
|
-
|
|
409
|
-
// 同 Enter 键路径:replace 消费掉挂载时压入的临时历史条目。
|
|
410
|
-
// Same as the Enter path: replace consumes the temp entry pushed on mount.
|
|
411
|
-
router.replace(p.id)
|
|
412
|
-
emit('close')
|
|
468
|
+
openResult(p.id)
|
|
413
469
|
}
|
|
414
470
|
</script>
|
|
415
471
|
|
|
@@ -417,16 +473,23 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
417
473
|
<Teleport to="body">
|
|
418
474
|
<div
|
|
419
475
|
ref="el"
|
|
420
|
-
role="
|
|
421
|
-
|
|
422
|
-
aria-
|
|
423
|
-
aria-haspopup="listbox"
|
|
424
|
-
aria-labelledby="localsearch-label"
|
|
476
|
+
role="dialog"
|
|
477
|
+
aria-modal="true"
|
|
478
|
+
:aria-label="buttonText"
|
|
425
479
|
class="VPLocalSearchBox"
|
|
426
480
|
>
|
|
481
|
+
<!-- eslint-disable-next-line vue-a11y/no-static-element-interactions, vue-a11y/click-events-have-key-events -->
|
|
427
482
|
<div class="backdrop" @click="$emit('close')" />
|
|
428
483
|
|
|
429
484
|
<div class="shell">
|
|
485
|
+
<div
|
|
486
|
+
class="sr-only"
|
|
487
|
+
role="status"
|
|
488
|
+
aria-live="polite"
|
|
489
|
+
aria-atomic="true"
|
|
490
|
+
>
|
|
491
|
+
{{ searchStatusText }}
|
|
492
|
+
</div>
|
|
430
493
|
<form
|
|
431
494
|
class="search-bar"
|
|
432
495
|
@pointerup="onSearchBarClick($event)"
|
|
@@ -434,15 +497,18 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
434
497
|
>
|
|
435
498
|
<!-- eslint-disable vue-a11y/label-has-for -->
|
|
436
499
|
<label
|
|
437
|
-
id="
|
|
500
|
+
:id="labelId"
|
|
438
501
|
:title="buttonText"
|
|
439
|
-
for="
|
|
502
|
+
:for="inputId"
|
|
440
503
|
>
|
|
441
504
|
<SearchIcon class="search-icon" />
|
|
505
|
+
<span class="sr-only">{{ buttonText }}</span>
|
|
442
506
|
</label>
|
|
443
507
|
<div class="search-actions before">
|
|
444
508
|
<button
|
|
509
|
+
type="button"
|
|
445
510
|
class="back-button"
|
|
511
|
+
:aria-label="locale.backButtonTitle"
|
|
446
512
|
:title="locale.backButtonTitle"
|
|
447
513
|
@click="$emit('close')"
|
|
448
514
|
>
|
|
@@ -450,20 +516,23 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
450
516
|
</button>
|
|
451
517
|
</div>
|
|
452
518
|
<input
|
|
453
|
-
id="
|
|
519
|
+
:id="inputId"
|
|
454
520
|
ref="searchInput"
|
|
455
521
|
v-model="filterText"
|
|
456
522
|
:placeholder="buttonText"
|
|
457
|
-
|
|
523
|
+
role="combobox"
|
|
524
|
+
aria-autocomplete="list"
|
|
525
|
+
:aria-expanded="results.length > 0 ? 'true' : 'false'"
|
|
526
|
+
:aria-controls="listId"
|
|
527
|
+
:aria-labelledby="labelId"
|
|
528
|
+
:aria-activedescendant="activeDescendantId"
|
|
458
529
|
class="search-input"
|
|
459
530
|
>
|
|
460
531
|
<div class="search-actions">
|
|
461
532
|
<span
|
|
462
533
|
class="search-loading"
|
|
463
534
|
:class="{ active: showSearchSpinner }"
|
|
464
|
-
|
|
465
|
-
aria-live="polite"
|
|
466
|
-
:aria-label="showSearchSpinner ? 'Loading search results' : undefined"
|
|
535
|
+
aria-hidden="true"
|
|
467
536
|
/>
|
|
468
537
|
<button
|
|
469
538
|
class="clear-button"
|
|
@@ -478,40 +547,42 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
478
547
|
</form>
|
|
479
548
|
|
|
480
549
|
<ul
|
|
481
|
-
:id="
|
|
550
|
+
:id="listId"
|
|
482
551
|
ref="resultsEl"
|
|
483
|
-
|
|
484
|
-
:aria-labelledby="
|
|
552
|
+
role="listbox"
|
|
553
|
+
:aria-labelledby="labelId"
|
|
485
554
|
class="results"
|
|
555
|
+
tabindex="-1"
|
|
486
556
|
@mousemove="disableMouseOver = false"
|
|
487
557
|
>
|
|
488
558
|
<li
|
|
489
559
|
v-for="(p, index) in results"
|
|
560
|
+
:id="optionId(index)"
|
|
490
561
|
:key="p.id"
|
|
491
562
|
role="option"
|
|
492
563
|
:aria-selected="selectedIndex === index ? 'true' : 'false'"
|
|
564
|
+
tabindex="-1"
|
|
565
|
+
@mouseenter="!disableMouseOver && (selectedIndex = index)"
|
|
566
|
+
@focusin="selectedIndex = index"
|
|
493
567
|
>
|
|
494
568
|
<a
|
|
495
|
-
:href="withBase(p.id)"
|
|
496
569
|
class="result"
|
|
497
570
|
:class="{
|
|
498
571
|
selected: selectedIndex === index,
|
|
499
572
|
}"
|
|
500
|
-
:
|
|
501
|
-
@mouseenter="!disableMouseOver && (selectedIndex = index)"
|
|
502
|
-
@focusin="selectedIndex = index"
|
|
573
|
+
:href="withBase(p.id)"
|
|
503
574
|
@click="selectedClick($event, p)"
|
|
504
575
|
>
|
|
505
576
|
<div>
|
|
506
577
|
<div class="titles">
|
|
507
|
-
<span class="title-icon">#</span>
|
|
578
|
+
<span class="title-icon" aria-hidden="true">#</span>
|
|
508
579
|
<span
|
|
509
580
|
v-for="(t, i) in p.titles"
|
|
510
581
|
:key="i"
|
|
511
582
|
class="title"
|
|
512
583
|
>
|
|
513
584
|
<span class="text" v-html="t" />
|
|
514
|
-
<svg width="18" height="18" viewBox="0 0 24 24">
|
|
585
|
+
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
|
|
515
586
|
<path
|
|
516
587
|
fill="none"
|
|
517
588
|
stroke="currentColor"
|
|
@@ -529,24 +600,24 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
529
600
|
</div>
|
|
530
601
|
</a>
|
|
531
602
|
</li>
|
|
532
|
-
<li
|
|
533
|
-
v-if="searchIndexError"
|
|
534
|
-
class="no-results error"
|
|
535
|
-
role="alert"
|
|
536
|
-
>
|
|
537
|
-
{{ searchIndexError }}
|
|
538
|
-
</li>
|
|
539
|
-
<li
|
|
540
|
-
v-else-if="filterText && !results.length && enableNoResults"
|
|
541
|
-
class="no-results"
|
|
542
|
-
>
|
|
543
|
-
{{ locale.noResultsText }} "<strong>{{ filterText }}</strong>"
|
|
544
|
-
</li>
|
|
545
603
|
</ul>
|
|
546
604
|
|
|
605
|
+
<div
|
|
606
|
+
v-if="searchIndexError"
|
|
607
|
+
class="no-results error"
|
|
608
|
+
>
|
|
609
|
+
{{ searchIndexError }}
|
|
610
|
+
</div>
|
|
611
|
+
<div
|
|
612
|
+
v-else-if="filterText && !results.length && enableNoResults"
|
|
613
|
+
class="no-results"
|
|
614
|
+
>
|
|
615
|
+
{{ locale.noResultsText }} "<strong>{{ filterText }}</strong>"
|
|
616
|
+
</div>
|
|
617
|
+
|
|
547
618
|
<div class="search-keyboard-shortcuts">
|
|
548
619
|
<span>
|
|
549
|
-
<kbd :aria-label="locale.footer?.navigateUpKeyAriaLabel
|
|
620
|
+
<kbd :aria-label="locale.footer?.navigateUpKeyAriaLabel || 'up arrow'">
|
|
550
621
|
<svg width="14" height="14" viewBox="0 0 24 24">
|
|
551
622
|
<path
|
|
552
623
|
fill="none"
|
|
@@ -558,7 +629,7 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
558
629
|
/>
|
|
559
630
|
</svg>
|
|
560
631
|
</kbd>
|
|
561
|
-
<kbd :aria-label="locale.footer?.navigateDownKeyAriaLabel
|
|
632
|
+
<kbd :aria-label="locale.footer?.navigateDownKeyAriaLabel || 'down arrow'">
|
|
562
633
|
<svg width="14" height="14" viewBox="0 0 24 24">
|
|
563
634
|
<path
|
|
564
635
|
fill="none"
|
|
@@ -573,7 +644,7 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
573
644
|
{{ locale.footer?.navigateText ?? '' }}
|
|
574
645
|
</span>
|
|
575
646
|
<span>
|
|
576
|
-
<kbd :aria-label="locale.footer?.selectKeyAriaLabel
|
|
647
|
+
<kbd :aria-label="locale.footer?.selectKeyAriaLabel || 'enter'">
|
|
577
648
|
<svg width="14" height="14" viewBox="0 0 24 24">
|
|
578
649
|
<g
|
|
579
650
|
fill="none"
|
|
@@ -590,7 +661,7 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
590
661
|
{{ locale.footer?.selectText ?? '' }}
|
|
591
662
|
</span>
|
|
592
663
|
<span>
|
|
593
|
-
<kbd :aria-label="locale.footer?.closeKeyAriaLabel
|
|
664
|
+
<kbd :aria-label="locale.footer?.closeKeyAriaLabel || 'esc'">esc</kbd>
|
|
594
665
|
{{ locale.footer?.closeText ?? '' }}
|
|
595
666
|
</span>
|
|
596
667
|
</div>
|
|
@@ -632,6 +703,7 @@ svg {
|
|
|
632
703
|
|
|
633
704
|
.shell {
|
|
634
705
|
position: relative;
|
|
706
|
+
box-sizing: border-box;
|
|
635
707
|
display: flex;
|
|
636
708
|
flex-direction: column;
|
|
637
709
|
gap: 16px;
|
|
@@ -647,7 +719,7 @@ svg {
|
|
|
647
719
|
@media (max-width: 767px) {
|
|
648
720
|
.shell {
|
|
649
721
|
width: 100vw;
|
|
650
|
-
height:
|
|
722
|
+
height: 100dvh;
|
|
651
723
|
max-height: none;
|
|
652
724
|
margin: 0;
|
|
653
725
|
border-radius: 0;
|
|
@@ -659,7 +731,7 @@ svg {
|
|
|
659
731
|
align-items: center;
|
|
660
732
|
padding: 0 12px;
|
|
661
733
|
cursor: text;
|
|
662
|
-
border: 1px solid var(--vp-c-
|
|
734
|
+
border: 1px solid var(--vp-c-text-2);
|
|
663
735
|
border-radius: 4px;
|
|
664
736
|
}
|
|
665
737
|
|
|
@@ -744,7 +816,7 @@ svg {
|
|
|
744
816
|
height: 18px;
|
|
745
817
|
margin: 8px;
|
|
746
818
|
visibility: hidden;
|
|
747
|
-
border: 2px solid var(--vp-c-
|
|
819
|
+
border: 2px solid var(--vp-c-text-2);
|
|
748
820
|
border-top-color: var(--vp-c-brand-1);
|
|
749
821
|
border-radius: 50%;
|
|
750
822
|
}
|
|
@@ -828,6 +900,10 @@ svg {
|
|
|
828
900
|
list-style: none;
|
|
829
901
|
}
|
|
830
902
|
|
|
903
|
+
.results:empty {
|
|
904
|
+
display: none;
|
|
905
|
+
}
|
|
906
|
+
|
|
831
907
|
.result {
|
|
832
908
|
display: flex;
|
|
833
909
|
gap: 8px;
|
|
@@ -835,10 +911,14 @@ svg {
|
|
|
835
911
|
line-height: 1rem;
|
|
836
912
|
border: solid 2px var(--vp-mini-search-result-border);
|
|
837
913
|
border-radius: 4px;
|
|
838
|
-
outline: none;
|
|
839
914
|
transition: none;
|
|
840
915
|
}
|
|
841
916
|
|
|
917
|
+
.result:focus-visible {
|
|
918
|
+
outline: 2px solid var(--vp-c-brand-1);
|
|
919
|
+
outline-offset: 2px;
|
|
920
|
+
}
|
|
921
|
+
|
|
842
922
|
.result > div {
|
|
843
923
|
width: 100%;
|
|
844
924
|
margin: 12px;
|
|
@@ -872,8 +952,7 @@ svg {
|
|
|
872
952
|
|
|
873
953
|
.title-icon {
|
|
874
954
|
font-weight: 500;
|
|
875
|
-
color: var(--vp-c-
|
|
876
|
-
opacity: 0.5;
|
|
955
|
+
color: var(--vp-c-text-2);
|
|
877
956
|
}
|
|
878
957
|
|
|
879
958
|
.title :deep(svg) {
|
|
@@ -1,19 +1,46 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
2
|
import type { SearchBoxLocales } from '../../shared/index.js'
|
|
3
|
-
import { toRef } from 'vue'
|
|
3
|
+
import { toRef, useTemplateRef } from 'vue'
|
|
4
4
|
import { useLocale } from '../composables/index.js'
|
|
5
5
|
|
|
6
|
-
const { locales } = defineProps<{
|
|
6
|
+
const { locales, expanded } = defineProps<{
|
|
7
7
|
locales: SearchBoxLocales
|
|
8
|
+
expanded?: boolean
|
|
8
9
|
}>()
|
|
9
10
|
|
|
10
11
|
const locale = useLocale(toRef(() => locales))
|
|
12
|
+
|
|
13
|
+
const buttonEl = useTemplateRef<HTMLButtonElement>('button')
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Move focus back to the trigger button.
|
|
17
|
+
*
|
|
18
|
+
* 将焦点移回触发按钮。
|
|
19
|
+
*
|
|
20
|
+
* Exposed so the search box can hand focus back to this trigger after closing,
|
|
21
|
+
* which keeps keyboard users from being thrown back to the top of the page.
|
|
22
|
+
*
|
|
23
|
+
* 暴露该方法,使搜索框在关闭后能把焦点归还给该触发按钮,
|
|
24
|
+
* 避免键盘用户的焦点被弹回页面顶部。
|
|
25
|
+
*/
|
|
26
|
+
function focus() {
|
|
27
|
+
buttonEl.value?.focus({ preventScroll: true })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
defineExpose({ focus })
|
|
11
31
|
</script>
|
|
12
32
|
|
|
13
33
|
<template>
|
|
14
|
-
<button
|
|
34
|
+
<button
|
|
35
|
+
ref="button"
|
|
36
|
+
type="button"
|
|
37
|
+
class="mini-search mini-search-button"
|
|
38
|
+
:aria-label="locale.placeholder"
|
|
39
|
+
aria-haspopup="dialog"
|
|
40
|
+
:aria-expanded="expanded ? 'true' : 'false'"
|
|
41
|
+
>
|
|
15
42
|
<span class="mini-search-button-container">
|
|
16
|
-
<span class="mini-search-search-icon vpi-mini-search" aria-
|
|
43
|
+
<span class="mini-search-search-icon vpi-mini-search" aria-hidden="true" />
|
|
17
44
|
<span class="mini-search-button-placeholder">{{ locale.placeholder }}</span>
|
|
18
45
|
</span>
|
|
19
46
|
<span class="mini-search-button-keys">
|
|
@@ -40,13 +67,13 @@ const locale = useLocale(toRef(() => locales))
|
|
|
40
67
|
background: transparent;
|
|
41
68
|
}
|
|
42
69
|
|
|
43
|
-
.mini-search-button:focus {
|
|
44
|
-
outline:
|
|
45
|
-
outline:
|
|
70
|
+
.mini-search-button:focus-visible {
|
|
71
|
+
outline: var(--vp-focus-ring-width) solid var(--vp-focus-ring-color);
|
|
72
|
+
outline-offset: var(--vp-focus-ring-offset);
|
|
46
73
|
}
|
|
47
74
|
|
|
48
75
|
.mini-search-button:focus:not(:focus-visible) {
|
|
49
|
-
outline: none
|
|
76
|
+
outline: none;
|
|
50
77
|
}
|
|
51
78
|
|
|
52
79
|
@media (min-width: 768px) {
|
package/dist/client/config.js
CHANGED
package/dist/node/index.d.ts
CHANGED
|
@@ -20,9 +20,6 @@ interface SearchIndexOptions {
|
|
|
20
20
|
*
|
|
21
21
|
* 为 VuePress 应用中的所有页面准备搜索索引。
|
|
22
22
|
*
|
|
23
|
-
* This function indexes all pages concurrently, creating separate MiniSearch
|
|
24
|
-
* instances for each locale, and writes the indexes to temporary files.
|
|
25
|
-
*
|
|
26
23
|
* 此函数并发索引所有页面,为每个语言创建独立的 MiniSearch 实例,
|
|
27
24
|
* 并将索引写入临时文件。
|
|
28
25
|
*
|
package/dist/node/index.js
CHANGED
package/dist/shared/index.d.ts
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import {Options} from 'minisearch'
|
|
2
2
|
import {LocaleConfig, Page} from 'vuepress/core'
|
|
3
|
+
/**
|
|
4
|
+
* Create a tokenizer shared by the node (index build) and client (query) sides.
|
|
5
|
+
*
|
|
6
|
+
* 创建由 Node 端(构建索引)与客户端(查询)共享的分词器。
|
|
7
|
+
*
|
|
8
|
+
* MiniSearch requires the tokenizer used to build the index to be identical to
|
|
9
|
+
* the one used at query time. Defining it in one place guarantees both sides
|
|
10
|
+
* use the same strategy: `Intl.Segmenter` when available, otherwise the same
|
|
11
|
+
* deterministic whitespace fallback. Blank and punctuation-only tokens are
|
|
12
|
+
* discarded on both sides, so they never pollute the index or the query.
|
|
13
|
+
*
|
|
14
|
+
* MiniSearch 要求构建索引与查询时使用完全一致的分词器。集中定义可保证两端策略一致:
|
|
15
|
+
* 支持 `Intl.Segmenter` 时使用它分词,否则统一退化为确定性的空白符分词。
|
|
16
|
+
* 两端都会丢弃空白与纯标点词元,避免污染索引与查询。
|
|
17
|
+
*
|
|
18
|
+
* @param lang - Language code for word segmentation / 用于分词的语言代码
|
|
19
|
+
* @returns Tokenizer that splits a string into tokens / 将字符串切分为词元的函数
|
|
20
|
+
* @example
|
|
21
|
+
* const tokenize = createTokenizer('en')
|
|
22
|
+
* tokenize('Hello world') // ['Hello', 'world'] with Intl.Segmenter
|
|
23
|
+
*/
|
|
24
|
+
export declare function createTokenizer(lang: string): (text: string) => string[]
|
|
3
25
|
/**
|
|
4
26
|
* Locale options for search UI strings.
|
|
5
27
|
*
|
|
@@ -59,28 +81,6 @@ export interface SearchLocaleOptions {
|
|
|
59
81
|
* 搜索框的语言配置类型。
|
|
60
82
|
*/
|
|
61
83
|
export type SearchBoxLocales = LocaleConfig<SearchLocaleOptions>
|
|
62
|
-
/**
|
|
63
|
-
* Create a tokenizer shared by the node (index build) and client (query) sides.
|
|
64
|
-
*
|
|
65
|
-
* 创建由 Node 端(构建索引)与客户端(查询)共享的分词器。
|
|
66
|
-
*
|
|
67
|
-
* MiniSearch requires the tokenizer used to build the index to be identical to
|
|
68
|
-
* the one used at query time. Defining it in one place guarantees both sides
|
|
69
|
-
* use the same strategy: `Intl.Segmenter` when available, otherwise the same
|
|
70
|
-
* deterministic whitespace fallback. Blank and punctuation-only tokens are
|
|
71
|
-
* discarded on both sides, so they never pollute the index or the query.
|
|
72
|
-
*
|
|
73
|
-
* MiniSearch 要求构建索引与查询时使用完全一致的分词器。集中定义可保证两端策略一致:
|
|
74
|
-
* 支持 `Intl.Segmenter` 时使用它分词,否则统一退化为确定性的空白符分词。
|
|
75
|
-
* 两端都会丢弃空白与纯标点词元,避免污染索引与查询。
|
|
76
|
-
*
|
|
77
|
-
* @param lang - Language code for word segmentation / 用于分词的语言代码
|
|
78
|
-
* @returns Tokenizer that splits a string into tokens / 将字符串切分为词元的函数
|
|
79
|
-
* @example
|
|
80
|
-
* const tokenize = createTokenizer('en')
|
|
81
|
-
* tokenize('Hello world') // ['Hello', 'world'] with Intl.Segmenter
|
|
82
|
-
*/
|
|
83
|
-
export declare function createTokenizer(lang: string): (text: string) => string[]
|
|
84
84
|
/**
|
|
85
85
|
* Options for the search plugin.
|
|
86
86
|
*
|
package/dist/shared/index.js
CHANGED
|
@@ -1,16 +1,32 @@
|
|
|
1
|
+
const SPACE_OR_PUNCTUATION_RE = /[\n\r\p{Z}\p{P}]+/u
|
|
2
|
+
const UNSPACED_RUN_RE =
|
|
3
|
+
/[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana}]+|[^\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana}]+/gu
|
|
4
|
+
const UNSPACED_START_RE = /^[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana}]/u
|
|
5
|
+
const NO_SPACE_AND_PUNCTUATION_RE_RE = /\p{L}|\p{N}/u
|
|
6
|
+
|
|
1
7
|
function isWordToken(token) {
|
|
2
|
-
return
|
|
8
|
+
return NO_SPACE_AND_PUNCTUATION_RE_RE.test(token)
|
|
3
9
|
}
|
|
4
10
|
|
|
5
11
|
function createTokenizer(lang) {
|
|
6
12
|
if (typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function') {
|
|
7
13
|
const segmenter = new Intl.Segmenter(lang, {granularity: 'word'})
|
|
8
|
-
return (text) =>
|
|
9
|
-
|
|
10
|
-
.
|
|
14
|
+
return (text) => {
|
|
15
|
+
return text
|
|
16
|
+
.split(SPACE_OR_PUNCTUATION_RE)
|
|
17
|
+
.flatMap(
|
|
18
|
+
(term) =>
|
|
19
|
+
term
|
|
20
|
+
.match(UNSPACED_RUN_RE)
|
|
21
|
+
?.flatMap((run) =>
|
|
22
|
+
UNSPACED_START_RE.test(run)
|
|
23
|
+
? Array.from(segmenter.segment(run), (part) => part.segment)
|
|
24
|
+
: run,
|
|
25
|
+
) ?? term,
|
|
26
|
+
)
|
|
11
27
|
.filter(isWordToken)
|
|
28
|
+
}
|
|
12
29
|
}
|
|
13
|
-
return (text) => text.split(
|
|
30
|
+
return (text) => text.split(SPACE_OR_PUNCTUATION_RE).filter(isWordToken)
|
|
14
31
|
}
|
|
15
|
-
|
|
16
32
|
export {createTokenizer}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vuepress-plume/plugin-search",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0.0-rc.
|
|
4
|
+
"version": "1.0.0-rc.215",
|
|
5
5
|
"description": "The Plugin for VuePress 2 - local search",
|
|
6
6
|
"author": "pengzhanbo <volodymyr@foxmail.com>",
|
|
7
7
|
"license": "MIT",
|
|
@@ -44,12 +44,13 @@
|
|
|
44
44
|
"@vueuse/core": "^15.0.0",
|
|
45
45
|
"@vueuse/integrations": "^15.0.0",
|
|
46
46
|
"chokidar": "5.0.0",
|
|
47
|
-
"focus-trap": "^8.2.
|
|
47
|
+
"focus-trap": "^8.2.4",
|
|
48
48
|
"mark.js": "^8.11.1",
|
|
49
49
|
"minisearch": "^7.2.0",
|
|
50
|
-
"p-map": "^7.
|
|
51
|
-
"sanitize-html": "^2.18.
|
|
52
|
-
"vue": "^3.5.
|
|
50
|
+
"p-map": "^7.1.1",
|
|
51
|
+
"sanitize-html": "^2.18.1",
|
|
52
|
+
"vue": "^3.5.44",
|
|
53
|
+
"vue-router": "^5.4.0"
|
|
53
54
|
},
|
|
54
55
|
"publishConfig": {
|
|
55
56
|
"access": "public",
|