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+ <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'
8092const 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
86100const 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
91109const isSmallMobile = useIsSmallMobile ()
92110const 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+
94129const inputRef = ref <HTMLInputElement >()
95130const 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