@vuepress-plume/plugin-search 1.0.0-rc.212 → 1.0.0-rc.214
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 +75 -5
- package/dist/client/components/SearchBox.vue +198 -87
- package/dist/client/components/SearchButton.vue +35 -8
- package/dist/client/composables/index.d.ts +47 -9
- package/dist/client/composables/index.js +21 -1
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +2 -2
- package/dist/client/shim.d.ts +3 -1
- package/dist/node/index.d.ts +1 -0
- package/dist/node/index.js +82 -20
- package/dist/shared/index.d.ts +17 -2
- package/dist/shared/index.js +9 -2
- package/package.json +11 -10
- package/dist/client/utils/index.d.ts +0 -84
- package/dist/client/utils/index.js +0 -35
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
import type { SearchBoxLocales, SearchOptions } from '../../shared/index.js'
|
|
3
3
|
import { onKeyStroke } from '@vueuse/core'
|
|
4
4
|
import {
|
|
5
|
+
computed,
|
|
5
6
|
defineAsyncComponent,
|
|
7
|
+
nextTick,
|
|
6
8
|
ref,
|
|
9
|
+
toRef,
|
|
10
|
+
useTemplateRef,
|
|
7
11
|
} from 'vue'
|
|
12
|
+
import { useLocale } from '../composables/index.js'
|
|
8
13
|
import SearchButton from './SearchButton.vue'
|
|
9
14
|
|
|
10
|
-
defineProps<{
|
|
15
|
+
const props = defineProps<{
|
|
11
16
|
locales: SearchBoxLocales
|
|
12
17
|
options: SearchOptions
|
|
13
18
|
}>()
|
|
@@ -16,15 +21,72 @@ const SearchBox = defineAsyncComponent(() => import('./SearchBox.vue'))
|
|
|
16
21
|
|
|
17
22
|
const showSearch = ref(false)
|
|
18
23
|
|
|
24
|
+
const searchButtonRef = useTemplateRef<{ focus: () => void }>('searchButton')
|
|
25
|
+
|
|
26
|
+
const locale = useLocale(toRef(() => props.locales))
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Hand focus back to the trigger button.
|
|
30
|
+
*
|
|
31
|
+
* 把焦点归还给触发按钮。
|
|
32
|
+
*
|
|
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.
|
|
36
|
+
*
|
|
37
|
+
* 搜索框卸载时会一并销毁自身的焦点陷阱:若不显式归还,被聚焦的输入框随组件
|
|
38
|
+
* 消失后焦点会落到 `<body>`,键盘用户只能从页面第一个元素重新 Tab 一遍。
|
|
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
|
+
|
|
67
|
+
const noscriptText = computed(
|
|
68
|
+
() => locale.value.noscriptText || 'Search is unavailable without JavaScript.',
|
|
69
|
+
)
|
|
70
|
+
|
|
19
71
|
onKeyStroke('k', (event) => {
|
|
20
|
-
|
|
72
|
+
// 在输入框 / 可编辑区域内不劫持 Ctrl/Cmd + K(如 macOS 的删除至行尾)。
|
|
73
|
+
// Do not hijack Ctrl/Cmd + K while editing content (e.g. macOS delete-to-end-of-line).
|
|
74
|
+
if ((event.ctrlKey || event.metaKey) && !isEditingContent(event)) {
|
|
21
75
|
event.preventDefault()
|
|
22
76
|
showSearch.value = true
|
|
23
77
|
}
|
|
24
78
|
})
|
|
25
79
|
|
|
26
80
|
onKeyStroke('/', (event) => {
|
|
27
|
-
|
|
81
|
+
// 仅在无修饰键且不在编辑内容时劫持,避免与浏览器/系统组合键冲突。
|
|
82
|
+
// Only hijack without modifiers and outside editable content to avoid clashing
|
|
83
|
+
// with browser/system shortcuts.
|
|
84
|
+
if (
|
|
85
|
+
!event.ctrlKey
|
|
86
|
+
&& !event.metaKey
|
|
87
|
+
&& !event.altKey
|
|
88
|
+
&& !isEditingContent(event)
|
|
89
|
+
) {
|
|
28
90
|
event.preventDefault()
|
|
29
91
|
showSearch.value = true
|
|
30
92
|
}
|
|
@@ -49,12 +111,14 @@ function isEditingContent(event: KeyboardEvent): boolean {
|
|
|
49
111
|
v-if="showSearch"
|
|
50
112
|
:locales="locales"
|
|
51
113
|
:options="options"
|
|
52
|
-
@close="
|
|
114
|
+
@close="closeSearch"
|
|
53
115
|
/>
|
|
54
116
|
|
|
55
117
|
<div id="local-search">
|
|
56
|
-
<SearchButton :locales="locales" @click="showSearch = true" />
|
|
118
|
+
<SearchButton ref="searchButton" :locales="locales" :expanded="showSearch" @click="showSearch = true" />
|
|
57
119
|
</div>
|
|
120
|
+
|
|
121
|
+
<noscript>{{ noscriptText }}</noscript>
|
|
58
122
|
</div>
|
|
59
123
|
</template>
|
|
60
124
|
|
|
@@ -64,6 +128,12 @@ function isEditingContent(event: KeyboardEvent): boolean {
|
|
|
64
128
|
align-items: center;
|
|
65
129
|
}
|
|
66
130
|
|
|
131
|
+
.search-wrapper noscript {
|
|
132
|
+
font-size: 13px;
|
|
133
|
+
line-height: 1.4;
|
|
134
|
+
color: var(--vp-c-text-2);
|
|
135
|
+
}
|
|
136
|
+
|
|
67
137
|
@media (min-width: 768px) {
|
|
68
138
|
.search-wrapper {
|
|
69
139
|
flex-grow: 1;
|
|
@@ -22,12 +22,13 @@ 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
|
-
import {
|
|
30
|
-
import { LRUCache } from '../utils/index.js'
|
|
31
|
+
import { loadSearchIndexJSON, useLocale } from '../composables/index.js'
|
|
31
32
|
import BackIcon from './icons/BackIcon.vue'
|
|
32
33
|
import ClearIcon from './icons/ClearIcon.vue'
|
|
33
34
|
import SearchIcon from './icons/SearchIcon.vue'
|
|
@@ -38,7 +39,11 @@ const { locales, options } = defineProps<{
|
|
|
38
39
|
}>()
|
|
39
40
|
|
|
40
41
|
const emit = defineEmits<{
|
|
41
|
-
|
|
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
|
|
42
47
|
}>()
|
|
43
48
|
|
|
44
49
|
const routeLocale = useRouteLocale()
|
|
@@ -48,15 +53,21 @@ const lang = usePageLang()
|
|
|
48
53
|
const el = shallowRef<HTMLElement>()
|
|
49
54
|
const resultsEl = shallowRef<HTMLElement>()
|
|
50
55
|
|
|
51
|
-
const
|
|
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
|
+
}
|
|
52
64
|
|
|
53
65
|
interface Result {
|
|
54
66
|
title: string
|
|
55
67
|
titles: string[]
|
|
56
|
-
text?: string
|
|
57
68
|
}
|
|
58
69
|
|
|
59
|
-
const { activate } = useFocusTrap(el, { immediate: true })
|
|
70
|
+
const { activate } = useFocusTrap(el, { immediate: true, returnFocusOnDeactivate: false })
|
|
60
71
|
|
|
61
72
|
const isSearchIndexLoading = ref(false)
|
|
62
73
|
const isSearching = ref(false)
|
|
@@ -105,19 +116,27 @@ const searchIndex = computedAsync(async (onCancel) => {
|
|
|
105
116
|
// Use the same tokenizer as the node build side so query tokens always match the index.
|
|
106
117
|
const tokenize = createTokenizer(lang.value)
|
|
107
118
|
|
|
108
|
-
const loadIndex = searchIndexData.value[routeLocale.value]
|
|
109
|
-
// 当前语言缺少索引文件时回退到空索引,并给出可见提示,避免静默无结果。
|
|
110
|
-
// Fall back to an empty index and show a visible message when the locale has no index.
|
|
111
|
-
if (!loadIndex) {
|
|
112
|
-
if (!canceled)
|
|
113
|
-
searchIndexError.value = searchIndexErrorText.value
|
|
114
|
-
return markRaw(createEmptyIndex())
|
|
115
|
-
}
|
|
116
|
-
|
|
117
119
|
try {
|
|
118
|
-
|
|
120
|
+
// 分片索引在客户端按片合并并让出主线程,避免大索引长时间阻塞渲染。
|
|
121
|
+
// Sharded indexes are merged piece by piece on the client, yielding to the main
|
|
122
|
+
// thread, so a large index never blocks rendering for long.
|
|
123
|
+
const indexJson = await loadSearchIndexJSON(routeLocale.value)
|
|
124
|
+
|
|
125
|
+
// 当前语言缺少索引文件时回退到空索引,并给出可见提示,避免静默无结果。
|
|
126
|
+
// Fall back to an empty index and show a visible message when the locale has no index.
|
|
127
|
+
if (!indexJson) {
|
|
128
|
+
if (!canceled)
|
|
129
|
+
searchIndexError.value = searchIndexErrorText.value
|
|
130
|
+
return markRaw(createEmptyIndex())
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 使用公开的异步反序列化 API(`loadJSAsync` 为 `@ignore` 的内部 API),
|
|
134
|
+
// 它在批次之间让出主线程,避免大索引同步解析长时间阻塞。
|
|
135
|
+
// Use the public async deserialization API (`loadJSAsync` is an `@ignore`d
|
|
136
|
+
// internal API). It yields to the main thread between batches, so a large index
|
|
137
|
+
// never blocks rendering for long.
|
|
119
138
|
return markRaw(
|
|
120
|
-
MiniSearch.
|
|
139
|
+
await MiniSearch.loadJSONAsync<Result>(indexJson, {
|
|
121
140
|
fields: ['title', 'titles', 'text'],
|
|
122
141
|
storeFields: ['title', 'titles'],
|
|
123
142
|
searchOptions: {
|
|
@@ -125,8 +144,8 @@ const searchIndex = computedAsync(async (onCancel) => {
|
|
|
125
144
|
prefix: true,
|
|
126
145
|
boost: { title: 4, text: 2, titles: 1 },
|
|
127
146
|
tokenize,
|
|
147
|
+
...options.miniSearch?.searchOptions,
|
|
128
148
|
},
|
|
129
|
-
...options.miniSearch?.searchOptions,
|
|
130
149
|
...options.miniSearch?.options,
|
|
131
150
|
}),
|
|
132
151
|
)
|
|
@@ -156,22 +175,25 @@ watch(filterText, () => {
|
|
|
156
175
|
enableNoResults.value = false
|
|
157
176
|
})
|
|
158
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
|
+
|
|
159
188
|
const mark = computedAsync(async () => {
|
|
160
189
|
if (!resultsEl.value)
|
|
161
190
|
return
|
|
162
191
|
return markRaw(new Mark(resultsEl.value))
|
|
163
192
|
}, null)
|
|
164
193
|
|
|
165
|
-
const cache = new LRUCache<string, Map<string, string>>(64) // 64 files
|
|
166
|
-
|
|
167
194
|
watchDebounced(
|
|
168
195
|
() => [searchIndex.value, filterText.value] as const,
|
|
169
|
-
async ([index, filterTextValue],
|
|
170
|
-
if (old?.[0] !== index) {
|
|
171
|
-
// in case of hmr
|
|
172
|
-
cache.clear()
|
|
173
|
-
}
|
|
174
|
-
|
|
196
|
+
async ([index, filterTextValue], _old, onCleanup) => {
|
|
175
197
|
let canceled = false
|
|
176
198
|
onCleanup(() => {
|
|
177
199
|
canceled = true
|
|
@@ -191,25 +213,19 @@ watchDebounced(
|
|
|
191
213
|
enableNoResults.value = true
|
|
192
214
|
|
|
193
215
|
const terms = new Set<string>()
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const [id, anchor] = r.id.split('#')
|
|
197
|
-
const map = cache.get(id)
|
|
198
|
-
const text = map?.get(anchor) ?? ''
|
|
199
|
-
for (const term in r.match)
|
|
216
|
+
for (const result of results.value) {
|
|
217
|
+
for (const term in result.match)
|
|
200
218
|
terms.add(term)
|
|
201
|
-
|
|
202
|
-
return { ...r, text }
|
|
203
|
-
})
|
|
219
|
+
}
|
|
204
220
|
|
|
205
221
|
await nextTick()
|
|
206
222
|
if (canceled)
|
|
207
223
|
return
|
|
208
224
|
|
|
209
|
-
await new Promise((
|
|
225
|
+
await new Promise((done) => {
|
|
210
226
|
mark.value?.unmark({
|
|
211
227
|
done: () => {
|
|
212
|
-
mark.value?.markRegExp(formMarkRegex(terms), { done
|
|
228
|
+
mark.value?.markRegExp(formMarkRegex(terms), { done })
|
|
213
229
|
},
|
|
214
230
|
})
|
|
215
231
|
})
|
|
@@ -245,6 +261,10 @@ function onSearchBarClick(event: PointerEvent) {
|
|
|
245
261
|
const selectedIndex = ref(-1)
|
|
246
262
|
const disableMouseOver = ref(false)
|
|
247
263
|
|
|
264
|
+
const activeDescendantId = computed(() =>
|
|
265
|
+
results.value[selectedIndex.value] ? optionId(selectedIndex.value) : undefined,
|
|
266
|
+
)
|
|
267
|
+
|
|
248
268
|
watch(results, (r) => {
|
|
249
269
|
selectedIndex.value = r.length ? 0 : -1
|
|
250
270
|
scrollToSelectedResult()
|
|
@@ -273,6 +293,12 @@ function isMacCtrlShortcut(event: KeyboardEvent) {
|
|
|
273
293
|
|
|
274
294
|
function selectPreviousResult(event: KeyboardEvent) {
|
|
275
295
|
event.preventDefault()
|
|
296
|
+
|
|
297
|
+
if (!results.value.length) {
|
|
298
|
+
selectedIndex.value = -1
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
276
302
|
selectedIndex.value--
|
|
277
303
|
if (selectedIndex.value < 0)
|
|
278
304
|
selectedIndex.value = results.value.length - 1
|
|
@@ -283,6 +309,12 @@ function selectPreviousResult(event: KeyboardEvent) {
|
|
|
283
309
|
|
|
284
310
|
function selectNextResult(event: KeyboardEvent) {
|
|
285
311
|
event.preventDefault()
|
|
312
|
+
|
|
313
|
+
if (!results.value.length) {
|
|
314
|
+
selectedIndex.value = -1
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
286
318
|
selectedIndex.value++
|
|
287
319
|
if (selectedIndex.value >= results.value.length)
|
|
288
320
|
selectedIndex.value = 0
|
|
@@ -306,6 +338,46 @@ onKeyStroke(['n', 'N'], (event) => {
|
|
|
306
338
|
|
|
307
339
|
const router = useRouter()
|
|
308
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Whether the browser back button already closed the search box.
|
|
343
|
+
*
|
|
344
|
+
* 浏览器后退键是否已关闭搜索框。
|
|
345
|
+
*
|
|
346
|
+
* When true the extra history entry created on mount has been consumed, so the
|
|
347
|
+
* unmount handler must not roll history back again.
|
|
348
|
+
*
|
|
349
|
+
* 为 true 时说明挂载时压入的额外历史条目已被消费,卸载时不应再回退历史。
|
|
350
|
+
*/
|
|
351
|
+
let closedByPopstate = false
|
|
352
|
+
|
|
353
|
+
/**
|
|
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.
|
|
356
|
+
*
|
|
357
|
+
* 为 true 表示 URL 会离开当前页(搜索结果导航在途、已成功或被更新的导航接替),
|
|
358
|
+
* 卸载时不能回退挂载时压入的临时历史条目。
|
|
359
|
+
*/
|
|
360
|
+
let navigatingToResult = false
|
|
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
|
+
|
|
309
381
|
onKeyStroke('Enter', (e) => {
|
|
310
382
|
if (e.isComposing)
|
|
311
383
|
return
|
|
@@ -319,10 +391,8 @@ onKeyStroke('Enter', (e) => {
|
|
|
319
391
|
return
|
|
320
392
|
}
|
|
321
393
|
|
|
322
|
-
if (selectedPackage)
|
|
323
|
-
|
|
324
|
-
emit('close')
|
|
325
|
-
}
|
|
394
|
+
if (selectedPackage)
|
|
395
|
+
openResult(selectedPackage.id)
|
|
326
396
|
})
|
|
327
397
|
|
|
328
398
|
onKeyStroke('Escape', () => {
|
|
@@ -338,12 +408,15 @@ onMounted(() => {
|
|
|
338
408
|
|
|
339
409
|
useEventListener('popstate', (event) => {
|
|
340
410
|
event.preventDefault()
|
|
411
|
+
closedByPopstate = true
|
|
341
412
|
emit('close')
|
|
342
413
|
})
|
|
343
414
|
|
|
344
415
|
/** Lock body */
|
|
345
416
|
const isLocked = useScrollLock(typeof document !== 'undefined' ? document.body : null)
|
|
346
417
|
|
|
418
|
+
let appEl: Element | null = null
|
|
419
|
+
|
|
347
420
|
onMounted(() => {
|
|
348
421
|
nextTick(() => {
|
|
349
422
|
isLocked.value = true
|
|
@@ -351,8 +424,21 @@ onMounted(() => {
|
|
|
351
424
|
})
|
|
352
425
|
})
|
|
353
426
|
|
|
427
|
+
onMounted(() => {
|
|
428
|
+
appEl = document.querySelector('#app')
|
|
429
|
+
appEl?.setAttribute('inert', '')
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
onBeforeUnmount(() => {
|
|
433
|
+
appEl?.removeAttribute('inert')
|
|
434
|
+
})
|
|
435
|
+
|
|
354
436
|
onBeforeUnmount(() => {
|
|
355
437
|
isLocked.value = false
|
|
438
|
+
// 配对挂载时的 pushState,避免反复开关搜索框累积历史条目。
|
|
439
|
+
// 若该条目已被浏览器后退键(popstate)消费,或正在跳转到搜索结果,则不能回退。
|
|
440
|
+
if (!closedByPopstate && !navigatingToResult)
|
|
441
|
+
window.history.back()
|
|
356
442
|
})
|
|
357
443
|
|
|
358
444
|
function resetSearch() {
|
|
@@ -375,9 +461,11 @@ function formMarkRegex(terms: Set<string>) {
|
|
|
375
461
|
}
|
|
376
462
|
|
|
377
463
|
function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
464
|
+
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
465
|
+
return
|
|
466
|
+
|
|
378
467
|
e.preventDefault()
|
|
379
|
-
|
|
380
|
-
emit('close')
|
|
468
|
+
openResult(p.id)
|
|
381
469
|
}
|
|
382
470
|
</script>
|
|
383
471
|
|
|
@@ -385,16 +473,23 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
385
473
|
<Teleport to="body">
|
|
386
474
|
<div
|
|
387
475
|
ref="el"
|
|
388
|
-
role="
|
|
389
|
-
|
|
390
|
-
aria-
|
|
391
|
-
aria-haspopup="listbox"
|
|
392
|
-
aria-labelledby="mini-search-label"
|
|
476
|
+
role="dialog"
|
|
477
|
+
aria-modal="true"
|
|
478
|
+
:aria-label="buttonText"
|
|
393
479
|
class="VPLocalSearchBox"
|
|
394
480
|
>
|
|
481
|
+
<!-- eslint-disable-next-line vue-a11y/no-static-element-interactions, vue-a11y/click-events-have-key-events -->
|
|
395
482
|
<div class="backdrop" @click="$emit('close')" />
|
|
396
483
|
|
|
397
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>
|
|
398
493
|
<form
|
|
399
494
|
class="search-bar"
|
|
400
495
|
@pointerup="onSearchBarClick($event)"
|
|
@@ -402,15 +497,18 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
402
497
|
>
|
|
403
498
|
<!-- eslint-disable vue-a11y/label-has-for -->
|
|
404
499
|
<label
|
|
405
|
-
id="
|
|
500
|
+
:id="labelId"
|
|
406
501
|
:title="buttonText"
|
|
407
|
-
for="
|
|
502
|
+
:for="inputId"
|
|
408
503
|
>
|
|
409
504
|
<SearchIcon class="search-icon" />
|
|
505
|
+
<span class="sr-only">{{ buttonText }}</span>
|
|
410
506
|
</label>
|
|
411
507
|
<div class="search-actions before">
|
|
412
508
|
<button
|
|
509
|
+
type="button"
|
|
413
510
|
class="back-button"
|
|
511
|
+
:aria-label="locale.backButtonTitle"
|
|
414
512
|
:title="locale.backButtonTitle"
|
|
415
513
|
@click="$emit('close')"
|
|
416
514
|
>
|
|
@@ -418,20 +516,23 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
418
516
|
</button>
|
|
419
517
|
</div>
|
|
420
518
|
<input
|
|
421
|
-
id="
|
|
519
|
+
:id="inputId"
|
|
422
520
|
ref="searchInput"
|
|
423
521
|
v-model="filterText"
|
|
424
522
|
:placeholder="buttonText"
|
|
425
|
-
|
|
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"
|
|
426
529
|
class="search-input"
|
|
427
530
|
>
|
|
428
531
|
<div class="search-actions">
|
|
429
532
|
<span
|
|
430
533
|
class="search-loading"
|
|
431
534
|
:class="{ active: showSearchSpinner }"
|
|
432
|
-
|
|
433
|
-
aria-live="polite"
|
|
434
|
-
:aria-label="showSearchSpinner ? 'Loading search results' : undefined"
|
|
535
|
+
aria-hidden="true"
|
|
435
536
|
/>
|
|
436
537
|
<button
|
|
437
538
|
class="clear-button"
|
|
@@ -446,40 +547,42 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
446
547
|
</form>
|
|
447
548
|
|
|
448
549
|
<ul
|
|
449
|
-
:id="
|
|
550
|
+
:id="listId"
|
|
450
551
|
ref="resultsEl"
|
|
451
|
-
|
|
452
|
-
:aria-labelledby="
|
|
552
|
+
role="listbox"
|
|
553
|
+
:aria-labelledby="labelId"
|
|
453
554
|
class="results"
|
|
555
|
+
tabindex="-1"
|
|
454
556
|
@mousemove="disableMouseOver = false"
|
|
455
557
|
>
|
|
456
558
|
<li
|
|
457
559
|
v-for="(p, index) in results"
|
|
560
|
+
:id="optionId(index)"
|
|
458
561
|
:key="p.id"
|
|
459
562
|
role="option"
|
|
460
563
|
:aria-selected="selectedIndex === index ? 'true' : 'false'"
|
|
564
|
+
tabindex="-1"
|
|
565
|
+
@mouseenter="!disableMouseOver && (selectedIndex = index)"
|
|
566
|
+
@focusin="selectedIndex = index"
|
|
461
567
|
>
|
|
462
568
|
<a
|
|
463
|
-
:href="withBase(p.id)"
|
|
464
569
|
class="result"
|
|
465
570
|
:class="{
|
|
466
571
|
selected: selectedIndex === index,
|
|
467
572
|
}"
|
|
468
|
-
:
|
|
469
|
-
@mouseenter="!disableMouseOver && (selectedIndex = index)"
|
|
470
|
-
@focusin="selectedIndex = index"
|
|
573
|
+
:href="withBase(p.id)"
|
|
471
574
|
@click="selectedClick($event, p)"
|
|
472
575
|
>
|
|
473
576
|
<div>
|
|
474
577
|
<div class="titles">
|
|
475
|
-
<span class="title-icon">#</span>
|
|
578
|
+
<span class="title-icon" aria-hidden="true">#</span>
|
|
476
579
|
<span
|
|
477
580
|
v-for="(t, i) in p.titles"
|
|
478
581
|
:key="i"
|
|
479
582
|
class="title"
|
|
480
583
|
>
|
|
481
584
|
<span class="text" v-html="t" />
|
|
482
|
-
<svg width="18" height="18" viewBox="0 0 24 24">
|
|
585
|
+
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
|
|
483
586
|
<path
|
|
484
587
|
fill="none"
|
|
485
588
|
stroke="currentColor"
|
|
@@ -497,24 +600,24 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
497
600
|
</div>
|
|
498
601
|
</a>
|
|
499
602
|
</li>
|
|
500
|
-
<li
|
|
501
|
-
v-if="searchIndexError"
|
|
502
|
-
class="no-results error"
|
|
503
|
-
role="alert"
|
|
504
|
-
>
|
|
505
|
-
{{ searchIndexError }}
|
|
506
|
-
</li>
|
|
507
|
-
<li
|
|
508
|
-
v-else-if="filterText && !results.length && enableNoResults"
|
|
509
|
-
class="no-results"
|
|
510
|
-
>
|
|
511
|
-
{{ locale.noResultsText }} "<strong>{{ filterText }}</strong>"
|
|
512
|
-
</li>
|
|
513
603
|
</ul>
|
|
514
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
|
+
|
|
515
618
|
<div class="search-keyboard-shortcuts">
|
|
516
619
|
<span>
|
|
517
|
-
<kbd :aria-label="locale.footer?.navigateUpKeyAriaLabel
|
|
620
|
+
<kbd :aria-label="locale.footer?.navigateUpKeyAriaLabel || 'up arrow'">
|
|
518
621
|
<svg width="14" height="14" viewBox="0 0 24 24">
|
|
519
622
|
<path
|
|
520
623
|
fill="none"
|
|
@@ -526,7 +629,7 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
526
629
|
/>
|
|
527
630
|
</svg>
|
|
528
631
|
</kbd>
|
|
529
|
-
<kbd :aria-label="locale.footer?.navigateDownKeyAriaLabel
|
|
632
|
+
<kbd :aria-label="locale.footer?.navigateDownKeyAriaLabel || 'down arrow'">
|
|
530
633
|
<svg width="14" height="14" viewBox="0 0 24 24">
|
|
531
634
|
<path
|
|
532
635
|
fill="none"
|
|
@@ -541,7 +644,7 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
541
644
|
{{ locale.footer?.navigateText ?? '' }}
|
|
542
645
|
</span>
|
|
543
646
|
<span>
|
|
544
|
-
<kbd :aria-label="locale.footer?.selectKeyAriaLabel
|
|
647
|
+
<kbd :aria-label="locale.footer?.selectKeyAriaLabel || 'enter'">
|
|
545
648
|
<svg width="14" height="14" viewBox="0 0 24 24">
|
|
546
649
|
<g
|
|
547
650
|
fill="none"
|
|
@@ -558,7 +661,7 @@ function selectedClick(e: MouseEvent, p: SearchResult & Result) {
|
|
|
558
661
|
{{ locale.footer?.selectText ?? '' }}
|
|
559
662
|
</span>
|
|
560
663
|
<span>
|
|
561
|
-
<kbd :aria-label="locale.footer?.closeKeyAriaLabel
|
|
664
|
+
<kbd :aria-label="locale.footer?.closeKeyAriaLabel || 'esc'">esc</kbd>
|
|
562
665
|
{{ locale.footer?.closeText ?? '' }}
|
|
563
666
|
</span>
|
|
564
667
|
</div>
|
|
@@ -600,6 +703,7 @@ svg {
|
|
|
600
703
|
|
|
601
704
|
.shell {
|
|
602
705
|
position: relative;
|
|
706
|
+
box-sizing: border-box;
|
|
603
707
|
display: flex;
|
|
604
708
|
flex-direction: column;
|
|
605
709
|
gap: 16px;
|
|
@@ -615,7 +719,7 @@ svg {
|
|
|
615
719
|
@media (max-width: 767px) {
|
|
616
720
|
.shell {
|
|
617
721
|
width: 100vw;
|
|
618
|
-
height:
|
|
722
|
+
height: 100dvh;
|
|
619
723
|
max-height: none;
|
|
620
724
|
margin: 0;
|
|
621
725
|
border-radius: 0;
|
|
@@ -627,7 +731,7 @@ svg {
|
|
|
627
731
|
align-items: center;
|
|
628
732
|
padding: 0 12px;
|
|
629
733
|
cursor: text;
|
|
630
|
-
border: 1px solid var(--vp-c-
|
|
734
|
+
border: 1px solid var(--vp-c-text-2);
|
|
631
735
|
border-radius: 4px;
|
|
632
736
|
}
|
|
633
737
|
|
|
@@ -712,7 +816,7 @@ svg {
|
|
|
712
816
|
height: 18px;
|
|
713
817
|
margin: 8px;
|
|
714
818
|
visibility: hidden;
|
|
715
|
-
border: 2px solid var(--vp-c-
|
|
819
|
+
border: 2px solid var(--vp-c-text-2);
|
|
716
820
|
border-top-color: var(--vp-c-brand-1);
|
|
717
821
|
border-radius: 50%;
|
|
718
822
|
}
|
|
@@ -796,6 +900,10 @@ svg {
|
|
|
796
900
|
list-style: none;
|
|
797
901
|
}
|
|
798
902
|
|
|
903
|
+
.results:empty {
|
|
904
|
+
display: none;
|
|
905
|
+
}
|
|
906
|
+
|
|
799
907
|
.result {
|
|
800
908
|
display: flex;
|
|
801
909
|
gap: 8px;
|
|
@@ -803,10 +911,14 @@ svg {
|
|
|
803
911
|
line-height: 1rem;
|
|
804
912
|
border: solid 2px var(--vp-mini-search-result-border);
|
|
805
913
|
border-radius: 4px;
|
|
806
|
-
outline: none;
|
|
807
914
|
transition: none;
|
|
808
915
|
}
|
|
809
916
|
|
|
917
|
+
.result:focus-visible {
|
|
918
|
+
outline: 2px solid var(--vp-c-brand-1);
|
|
919
|
+
outline-offset: 2px;
|
|
920
|
+
}
|
|
921
|
+
|
|
810
922
|
.result > div {
|
|
811
923
|
width: 100%;
|
|
812
924
|
margin: 12px;
|
|
@@ -840,8 +952,7 @@ svg {
|
|
|
840
952
|
|
|
841
953
|
.title-icon {
|
|
842
954
|
font-weight: 500;
|
|
843
|
-
color: var(--vp-c-
|
|
844
|
-
opacity: 0.5;
|
|
955
|
+
color: var(--vp-c-text-2);
|
|
845
956
|
}
|
|
846
957
|
|
|
847
958
|
.title :deep(svg) {
|