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"
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'
6375import { computed , ref } from ' vue'
6476import NcButton from ' @nextcloud/vue/components/NcButton'
6577import NcHeaderButton from ' @nextcloud/vue/components/NcHeaderButton'
78+ import NcKbd from ' @nextcloud/vue/components/NcKbd'
6679import IconClose from ' vue-material-design-icons/Close.vue'
6780import IconMagnify from ' vue-material-design-icons/Magnify.vue'
6881
@@ -80,17 +93,35 @@ import IconMagnify from 'vue-material-design-icons/Magnify.vue'
8093const 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
86101const 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
91110const isSmallMobile = useIsSmallMobile ()
92111const 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+
94125const inputRef = ref <HTMLInputElement >()
95126const 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