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>
@@ -188,7 +263,7 @@ function clearQuery() {
188263 align-items : center ;
189264 gap : var (--search-icon-gap );
190265 pointer-events : none ;
191- color : color-mix ( in srgb , var (--color-background-plain-text ) 70 % , var ( --color-background-plain ) );
266+ color : var (--color-background-plain-text );
192267 transform : translateX (calc (var (--slide-sign ) * (50 cqi - 50% - var (--search-icon-pad ))));
193268 transition :
194269 transform var (--search-anim-duration ) var (--search-anim-easing ),
@@ -207,6 +282,7 @@ function clearQuery() {
207282 overflow : hidden ;
208283 white-space : nowrap ;
209284 text-overflow : ellipsis ;
285+ font-weight : 500 ;
210286 transition : opacity var (--search-anim-duration ) var (--search-anim-easing );
211287 }
212288
@@ -257,6 +333,42 @@ function clearQuery() {
257333 flex-shrink : 0 ;
258334 margin-inline-end : 2px ;
259335 }
336+
337+ // Pinned to the trailing edge, overlaid on the input (pointer-events: none so a
338+ // click there still focuses the field).
339+ & __shortcut {
340+ position : absolute ;
341+ inset-inline-end : var (--default-grid-baseline );
342+ top : 50% ;
343+ transform : translateY (-50% );
344+ display : flex ;
345+ gap : var (--default-grid-baseline );
346+ pointer-events : none ;
347+
348+ // On a narrow field the centred placeholder runs under the hint, so drop it
349+ // below a usable width. Keyed to the field's own inline-size (its container),
350+ // not the viewport, so it holds however crowded the header gets.
351+ @container (max-width : 400px ) {
352+ display : none ;
353+ }
354+
355+ kbd {
356+ display : inline-flex ;
357+ align-items : center ;
358+ justify-content : center ;
359+ min-width : 12px ;
360+ height : 12px ;
361+ padding-inline : 5px ;
362+ border-radius : var (--border-radius-small , 4px );
363+ border : 1px solid color-mix (in srgb , var (--color-background-plain-text ) 20% , transparent );
364+ border-block-end-width : 2px ;
365+ color : var (--color-background-plain-text );
366+ font-family : var (--font-face );
367+ font-size : 14px ;
368+ font-weight : 500 ;
369+ line-height : 1 ;
370+ }
371+ }
260372}
261373
262374// On dark themes the plain overlay is nearly invisible on the header, so tint
0 commit comments