Skip to content

Commit aae73b1

Browse files
authored
Merge pull request #62157 from nextcloud/feat/search-keyboard-nav
feat(core): keyboard navigation and focus shortcut for unified search
2 parents 50d6a96 + 55a94d3 commit aae73b1

36 files changed

Lines changed: 1428 additions & 86 deletions

core/src/components/UnifiedSearch/SearchResult.vue

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
-->
55
<template>
66
<NcListItem
7+
:id="elementId"
78
class="result-item"
89
:name="title"
910
:bold="false"
11+
:active="active"
1012
:href="resourceUrl"
1113
target="_self">
1214
<template #icon>
@@ -80,11 +82,21 @@ export default {
8082
},
8183
8284
/**
83-
* Only used for the first result as a visual feedback
84-
* so we can keep the search input focused but pressing
85-
* enter still opens the first result
85+
* DOM id set on the option element (the <li>). The combobox input points
86+
* aria-activedescendant at this id to name the active row while focus stays
87+
* in the input.
8688
*/
87-
focused: {
89+
elementId: {
90+
type: String,
91+
default: undefined,
92+
},
93+
94+
/**
95+
* Whether this row is the selected result. Highlights it (via NcListItem's
96+
* active state) so the auto-selected first result and arrow navigation are
97+
* visible while the search input keeps focus.
98+
*/
99+
active: {
88100
type: Boolean,
89101
default: false,
90102
},

core/src/components/UnifiedSearch/SearchableList.vue

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
<template>
77
<NcPopover
88
:shown="opened"
9-
@show="opened = true"
10-
@hide="opened = false">
9+
@show="setOpened(true)"
10+
@hide="setOpened(false)">
1111
<template #trigger>
1212
<slot ref="popoverTrigger" name="trigger" />
1313
</template>
@@ -118,10 +118,16 @@ export default {
118118
this.searchTerm = ''
119119
},
120120
121+
setOpened(value) {
122+
this.opened = value
123+
},
124+
121125
itemSelected(element) {
126+
// Kebab-case event name matched by the modal's @item-selected listener.
127+
// Vue 2.7 does not normalize v-on names, so both sides must use the same casing.
122128
this.$emit('item-selected', element)
123129
this.clearSearch()
124-
this.opened = false
130+
this.setOpened(false)
125131
},
126132
127133
searchTermChanged(term) {

core/src/components/UnifiedSearch/UnifiedSearchInput.vue

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,14 @@
3838
class="unified-search-input__input"
3939
aria-autocomplete="list"
4040
:aria-expanded="expanded ? 'true' : 'false'"
41+
:aria-controls="expanded ? resultsContainerId : undefined"
42+
:aria-activedescendant="expanded ? (activeDescendantId || undefined) : undefined"
4143
:aria-label="placeholderText"
4244
:value="query"
4345
@focus="isFocused = true"
4446
@blur="isFocused = false"
45-
@input="onInput">
47+
@input="onInput"
48+
@keydown="onKeyDown">
4649
<NcButton
4750
v-if="query.length > 0"
4851
variant="tertiary-no-background"
@@ -53,6 +56,15 @@
5356
<IconClose :size="20" />
5457
</template>
5558
</NcButton>
59+
<!-- Decorative focus-shortcut hint, shown only while resting (unfocused +
60+
empty) so it never collides with the clear button. -->
61+
<span
62+
v-if="!isActive"
63+
class="unified-search-input__shortcut"
64+
aria-hidden="true">
65+
<NcKbd symbol="Control" />
66+
<NcKbd symbol="K" />
67+
</span>
5668
</div>
5769
</search>
5870
</template>
@@ -63,6 +75,7 @@ import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile'
6375
import { computed, ref } from 'vue'
6476
import NcButton from '@nextcloud/vue/components/NcButton'
6577
import NcHeaderButton from '@nextcloud/vue/components/NcHeaderButton'
78+
import NcKbd from '@nextcloud/vue/components/NcKbd'
6679
import IconClose from 'vue-material-design-icons/Close.vue'
6780
import IconMagnify from 'vue-material-design-icons/Magnify.vue'
6881
@@ -80,17 +93,35 @@ import IconMagnify from 'vue-material-design-icons/Magnify.vue'
8093
const props = defineProps<{
8194
/** Whether the popover the input controls is open. Bound to aria-expanded. */
8295
expanded?: boolean
96+
/** Id of the active result row, for aria-activedescendant. Empty when none. */
97+
activeDescendantId?: string
8398
query: string
8499
}>()
85100
86101
const emit = defineEmits<{
87102
click: [mouseEvent: MouseEvent]
88103
'update:query': [query: string]
104+
/** Move the result selection while focus stays in the input. */
105+
navigate: [direction: 'next' | 'prev' | 'first' | 'last']
106+
/** Open the currently selected result (Enter). */
107+
activate: []
89108
}>()
90109
91110
const isSmallMobile = useIsSmallMobile()
92111
const placeholderText = t('core', 'Apps, files, messages, and more')
93112
113+
// Id of the results popover the input controls (aria-controls). Kept in sync with
114+
// the id on UnifiedSearchModal's panel.
115+
const resultsContainerId = 'unified-search-results'
116+
117+
// Maps the navigation keys to a selection direction. Keys not listed are left
118+
// alone. Home/End are deliberately absent: in the combobox pattern they move the
119+
// textbox caret, not the result selection.
120+
const directionByKey: Record<string, 'next' | 'prev'> = {
121+
ArrowDown: 'next',
122+
ArrowUp: 'prev',
123+
}
124+
94125
const inputRef = ref<HTMLInputElement>()
95126
const isFocused = ref(false)
96127
@@ -111,6 +142,46 @@ function clearQuery() {
111142
emit('update:query', '')
112143
inputRef.value?.focus()
113144
}
145+
146+
/**
147+
* Combobox key handling. While the popover is open arrows move the selection via
148+
* aria-activedescendant and Enter opens the active row, with focus staying in the
149+
* input; otherwise the input keeps normal typing and caret movement (Home/End),
150+
* except Escape, which drops focus like a native find bar.
151+
*
152+
* @param event the keydown event
153+
*/
154+
function onKeyDown(event: KeyboardEvent) {
155+
// Never intercept keys mid-IME-composition (CJK etc.): the popover is already
156+
// open, so Enter/Arrows must reach the input to commit or edit the composition.
157+
if (event.isComposing) {
158+
return
159+
}
160+
// Escape with the popover closed drops focus, like a native find bar. While
161+
// open, the modal owns Escape, so only the closed case is handled here.
162+
if (event.key === 'Escape' && !props.expanded) {
163+
inputRef.value?.blur()
164+
return
165+
}
166+
if (!props.expanded) {
167+
return
168+
}
169+
const direction = directionByKey[event.key]
170+
if (direction) {
171+
event.preventDefault()
172+
emit('navigate', direction)
173+
} else if (event.key === 'Enter') {
174+
event.preventDefault()
175+
emit('activate')
176+
}
177+
}
178+
179+
/** Focus the text field. Used by the global shortcut; a no-op on the mobile button. */
180+
function focus() {
181+
inputRef.value?.focus()
182+
}
183+
184+
defineExpose({ focus })
114185
</script>
115186

116187
<style lang="scss" scoped>
@@ -257,6 +328,35 @@ function clearQuery() {
257328
flex-shrink: 0;
258329
margin-inline-end: 2px;
259330
}
331+
332+
// Pinned to the trailing edge, overlaid on the input (pointer-events: none so a
333+
// click there still focuses the field).
334+
&__shortcut {
335+
position: absolute;
336+
inset-inline-end: var(--default-grid-baseline);
337+
top: 50%;
338+
transform: translateY(-50%);
339+
display: flex;
340+
pointer-events: none;
341+
342+
// On a narrow field the centred placeholder runs under the hint, so drop it
343+
// below a usable width. Keyed to the field's own inline-size (its container),
344+
// not the viewport, so it holds however crowded the header gets.
345+
@container (max-width: 400px) {
346+
display: none;
347+
}
348+
349+
:deep(kbd) {
350+
min-width: 12px;
351+
height: 12px;
352+
padding-inline: 5px;
353+
border: 1px solid color-mix(in srgb, var(--color-background-plain-text) 20%, transparent);
354+
border-block-end-width: 2px;
355+
border-radius: var(--border-radius-small, 4px);
356+
color: color-mix(in srgb, var(--color-background-plain-text) 70%, var(--color-background-plain));
357+
font-size: 13px;
358+
}
359+
}
260360
}
261361
262362
// On dark themes the plain overlay is nearly invisible on the header, so tint

0 commit comments

Comments
 (0)