Skip to content

Commit e20fdcc

Browse files
committed
feat(core): keyboard navigation and focus shortcut for unified search
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
1 parent 651e84c commit e20fdcc

10 files changed

Lines changed: 1125 additions & 23 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/UnifiedSearchInput.vue

Lines changed: 112 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+
<kbd>{{ shortcutModifier }}</kbd>
66+
<kbd>K</kbd>
67+
</span>
5668
</div>
5769
</search>
5870
</template>
@@ -80,17 +92,40 @@ import IconMagnify from 'vue-material-design-icons/Magnify.vue'
8092
const props = defineProps<{
8193
/** Whether the popover the input controls is open. Bound to aria-expanded. */
8294
expanded?: boolean
95+
/** Id of the active result row, for aria-activedescendant. Empty when none. */
96+
activeDescendantId?: string
8397
query: string
8498
}>()
8599
86100
const emit = defineEmits<{
87101
click: [mouseEvent: MouseEvent]
88102
'update:query': [query: string]
103+
/** Move the result selection while focus stays in the input. */
104+
navigate: [direction: 'next' | 'prev' | 'first' | 'last']
105+
/** Open the currently selected result (Enter). */
106+
activate: []
89107
}>()
90108
91109
const isSmallMobile = useIsSmallMobile()
92110
const placeholderText = t('core', 'Apps, files, messages, and more')
93111
112+
// Id of the results popover the input controls (aria-controls). Kept in sync with
113+
// the id on UnifiedSearchModal's panel.
114+
const resultsContainerId = 'unified-search-results'
115+
116+
// Maps the navigation keys to a selection direction. Keys not listed are left
117+
// alone. Home/End are deliberately absent: in the combobox pattern they move the
118+
// textbox caret, not the result selection.
119+
const directionByKey: Record<string, 'next' | 'prev'> = {
120+
ArrowDown: 'next',
121+
ArrowUp: 'prev',
122+
}
123+
124+
// Glyph for the shortcut hint: ⌘ on macOS, Ctrl elsewhere. Display only; the
125+
// handler itself accepts both metaKey and ctrlKey.
126+
const isMac = /mac/i.test(navigator.platform)
127+
const shortcutModifier = isMac ? '' : 'Ctrl'
128+
94129
const inputRef = ref<HTMLInputElement>()
95130
const isFocused = ref(false)
96131
@@ -111,6 +146,46 @@ function clearQuery() {
111146
emit('update:query', '')
112147
inputRef.value?.focus()
113148
}
149+
150+
/**
151+
* Combobox key handling. While the popover is open arrows move the selection via
152+
* aria-activedescendant and Enter opens the active row, with focus staying in the
153+
* input; otherwise the input keeps normal typing and caret movement (Home/End),
154+
* except Escape, which drops focus like a native find bar.
155+
*
156+
* @param event the keydown event
157+
*/
158+
function onKeyDown(event: KeyboardEvent) {
159+
// Never intercept keys mid-IME-composition (CJK etc.): the popover is already
160+
// open, so Enter/Arrows must reach the input to commit or edit the composition.
161+
if (event.isComposing) {
162+
return
163+
}
164+
// Escape with the popover closed drops focus, like a native find bar. While
165+
// open, the modal owns Escape, so only the closed case is handled here.
166+
if (event.key === 'Escape' && !props.expanded) {
167+
inputRef.value?.blur()
168+
return
169+
}
170+
if (!props.expanded) {
171+
return
172+
}
173+
const direction = directionByKey[event.key]
174+
if (direction) {
175+
event.preventDefault()
176+
emit('navigate', direction)
177+
} else if (event.key === 'Enter') {
178+
event.preventDefault()
179+
emit('activate')
180+
}
181+
}
182+
183+
/** Focus the text field. Used by the global shortcut; a no-op on the mobile button. */
184+
function focus() {
185+
inputRef.value?.focus()
186+
}
187+
188+
defineExpose({ focus })
114189
</script>
115190

116191
<style lang="scss" scoped>
@@ -257,6 +332,42 @@ function clearQuery() {
257332
flex-shrink: 0;
258333
margin-inline-end: 2px;
259334
}
335+
336+
// Pinned to the trailing edge, overlaid on the input (pointer-events: none so a
337+
// click there still focuses the field).
338+
&__shortcut {
339+
position: absolute;
340+
inset-inline-end: var(--default-grid-baseline);
341+
top: 50%;
342+
transform: translateY(-50%);
343+
display: flex;
344+
gap: var(--default-grid-baseline);
345+
pointer-events: none;
346+
347+
// On a narrow field the centred placeholder runs under the hint, so drop it
348+
// below a usable width. Keyed to the field's own inline-size (its container),
349+
// not the viewport, so it holds however crowded the header gets.
350+
@container (max-width: 400px) {
351+
display: none;
352+
}
353+
354+
kbd {
355+
display: inline-flex;
356+
align-items: center;
357+
justify-content: center;
358+
min-width: 12px;
359+
height: 12px;
360+
padding-inline: 5px;
361+
border-radius: var(--border-radius-small, 4px);
362+
border: 1px solid color-mix(in srgb, var(--color-background-plain-text) 20%, transparent);
363+
border-block-end-width: 2px;
364+
box-shadow: none;
365+
color: color-mix(in srgb, var(--color-background-plain-text) 70%, var(--color-background-plain));
366+
font-family: var(--font-face);
367+
font-size: 13px;
368+
line-height: 1;
369+
}
370+
}
260371
}
261372
262373
// On dark themes the plain overlay is nearly invisible on the header, so tint

0 commit comments

Comments
 (0)