@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.
@@ -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
- if (event.ctrlKey || event.metaKey) {
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
- if (!isEditingContent(event)) {
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="showSearch = false"
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 { useLocale, useSearchIndex } from '../composables/index.js'
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
- (e: 'close'): void
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 searchIndexData = useSearchIndex()
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
- const json = (await loadIndex())?.default
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.loadJSON<Result>(json, {
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], old, onCleanup) => {
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
- results.value = results.value.map((r) => {
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((r) => {
225
+ await new Promise((done) => {
210
226
  mark.value?.unmark({
211
227
  done: () => {
212
- mark.value?.markRegExp(formMarkRegex(terms), { done: r })
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
- router.push(selectedPackage.id)
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
- router.push(p.id)
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="button"
389
- :aria-owns="results?.length ? 'localsearch-list' : undefined"
390
- aria-expanded="true"
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="localsearch-label"
500
+ :id="labelId"
406
501
  :title="buttonText"
407
- for="localsearch-input"
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="localsearch-input"
519
+ :id="inputId"
422
520
  ref="searchInput"
423
521
  v-model="filterText"
424
522
  :placeholder="buttonText"
425
- aria-labelledby="localsearch-label"
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
- :role="showSearchSpinner ? 'status' : undefined"
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="results?.length ? 'localsearch-list' : undefined"
550
+ :id="listId"
450
551
  ref="resultsEl"
451
- :role="results?.length ? 'listbox' : undefined"
452
- :aria-labelledby="results?.length ? 'localsearch-label' : undefined"
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
- :aria-label="[...p.titles, p.title].join(' > ')"
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 ?? ''">esc</kbd>
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: 100vh;
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-divider);
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-divider);
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-brand-1);
844
- opacity: 0.5;
955
+ color: var(--vp-c-text-2);
845
956
  }
846
957
 
847
958
  .title :deep(svg) {