1919 </NcHeaderButton >
2020 <div
2121 v-else
22+ ref =" fieldRef"
2223 class =" unified-search-input__field"
23- :class =" { 'unified-search-input__field--active': isActive }" >
24- <!-- Decorative overlay: an input can't group an icon with its own placeholder,
25- so we paint the magnifier + placeholder on top and let clicks fall through.
26- It slides to the leading edge on focus (see styles). -->
24+ :class =" { 'unified-search-input__field--active': isActive }"
25+ @focusin =" isFocused = true"
26+ @focusout =" onFocusOut" >
27+ <!-- Decorative overlay: magnifier + placeholder painted over the input
28+ (clicks fall through), sliding to the leading edge on focus. -->
2729 <div
2830 class =" unified-search-input__resting"
2931 :class =" { 'unified-search-input__resting--filled': query.length > 0 }"
4244 :aria-activedescendant =" expanded ? (activeDescendantId || undefined) : undefined"
4345 :aria-label =" placeholderText"
4446 :value =" query"
45- @focus =" isFocused = true"
46- @blur =" isFocused = false"
4747 @input =" onInput"
4848 @keydown =" onKeyDown" >
49+ <!-- Pre-typing funnel: reveals the filters on a focused, empty input. -->
4950 <NcButton
50- v-if =" query .length > 0 "
51+ v-if =" showFunnel "
52+ variant="tertiary-no-background"
53+ class="unified-search-input__filter"
54+ :aria-label =" t (' core' , ' Filters' )"
55+ @click =" openFilters " >
56+ <template #icon >
57+ <IconFilterVariant :size =" 20 " />
58+ </template >
59+ </NcButton >
60+ <!-- Trailing X: clears the query, or closes the search when the field is empty. -->
61+ <NcButton
62+ v-if =" isActive "
5163 variant="tertiary-no-background"
5264 class="unified-search-input__clear"
53- :aria-label =" t (' core' , ' Clear search' )"
54- @click =" clearQuery " >
65+ :aria-label =" query . length > 0 ? t (' core' , ' Clear search ' ) : t ( ' core ' , ' Close search' )"
66+ @click =" clearOrClose " >
5567 <template #icon >
5668 <IconClose :size =" 20 " />
5769 </template >
5870 </NcButton >
59- <!-- Decorative focus-shortcut hint, shown only while resting (unfocused +
60- empty) so it never collides with the clear button. -->
71+ <!-- Focus-shortcut hint, shown only at rest so it never collides with the controls. -->
6172 <span
6273 v-if =" !isActive"
6374 class =" unified-search-input__shortcut"
@@ -77,17 +88,16 @@ import NcButton from '@nextcloud/vue/components/NcButton'
7788import NcHeaderButton from ' @nextcloud/vue/components/NcHeaderButton'
7889import NcKbd from ' @nextcloud/vue/components/NcKbd'
7990import IconClose from ' vue-material-design-icons/Close.vue'
91+ import IconFilterVariant from ' vue-material-design-icons/FilterVariant.vue'
8092import IconMagnify from ' vue-material-design-icons/Magnify.vue'
8193
8294/**
83- * The unified-search input that lives in the header.
95+ * The unified-search input in the header.
8496 *
85- * Implemented as a plain <input> rather than NcTextField/NcInputField: those
86- * assume a light form background and a floating label, which clashes with the
87- * themed header and the resting "button" look and would need heavy overrides of
88- * their internals. A custom input also lets us own the combobox semantics the
89- * results popover needs. On narrow viewports it collapses to an NcHeaderButton
90- * to match the other header items.
97+ * A plain <input> rather than NcTextField/NcInputField: those assume a light form
98+ * surface and floating label that clash with the themed header and the resting
99+ * "button" look. A custom input also lets us own the combobox semantics the results
100+ * popover needs. Collapses to an NcHeaderButton on narrow viewports.
91101 */
92102
93103const props = defineProps <{
@@ -96,6 +106,8 @@ const props = defineProps<{
96106 /** Id of the active result row, for aria-activedescendant. Empty when none. */
97107 activeDescendantId? : string
98108 query: string
109+ /** Filters are already revealed, so hide the pre-typing funnel. */
110+ filtersRevealed? : boolean
99111}>()
100112
101113const emit = defineEmits <{
@@ -105,6 +117,10 @@ const emit = defineEmits<{
105117 navigate: [direction : ' next' | ' prev' | ' first' | ' last' ]
106118 /** Open the currently selected result (Enter). */
107119 activate: []
120+ /** Reveal the popover filters from the pre-typing funnel. */
121+ ' open-filters' : []
122+ /** Dismiss the search from the trailing X on an already-empty field. */
123+ close: []
108124}>()
109125
110126const isSmallMobile = useIsSmallMobile ()
@@ -114,19 +130,42 @@ const placeholderText = t('core', 'Apps, files, messages, and more')
114130// the id on UnifiedSearchModal's panel.
115131const resultsContainerId = ' unified-search-results'
116132
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.
133+ // Navigation keys mapped to a selection direction. Home/End are deliberately absent:
134+ // in the combobox pattern they move the caret, not the selection.
120135const directionByKey: Record <string , ' next' | ' prev' > = {
121136 ArrowDown: ' next' ,
122137 ArrowUp: ' prev' ,
123138}
124139
140+ const fieldRef = ref <HTMLElement >()
125141const inputRef = ref <HTMLInputElement >()
126142const isFocused = ref (false )
127143
128- /** Active = focused or holding a query; drives the resting-vs-active styling. */
129- const isActive = computed (() => isFocused .value || props .query .length > 0 )
144+ /**
145+ * Active = focused, has a query, or popover open. `expanded` is included so the
146+ * field keeps its white surface when focus moves into a teleported filter menu
147+ * (which blurs the input) instead of flashing back to resting.
148+ */
149+ const isActive = computed (() => isFocused .value || props .query .length > 0 || Boolean (props .expanded ))
150+
151+ /**
152+ * The pre-typing funnel shows on a focused, empty input, until the filters are revealed.
153+ */
154+ const showFunnel = computed (() => isFocused .value && props .query .length === 0 && ! props .filtersRevealed )
155+
156+ /**
157+ * Track focus at the field level, not the input, so the field stays active while
158+ * focus moves onto its trailing controls (funnel / clear-X) instead of blurring the
159+ * moment it leaves the input.
160+ *
161+ * @param event The focusout event; relatedTarget is the element gaining focus
162+ */
163+ function onFocusOut(event : FocusEvent ) {
164+ if (fieldRef .value ?.contains (event .relatedTarget as Node | null )) {
165+ return
166+ }
167+ isFocused .value = false
168+ }
130169
131170/**
132171 * Relay the typed value upward.
@@ -137,10 +176,32 @@ function onInput(event: Event) {
137176 emit (' update:query' , (event .target as HTMLInputElement ).value )
138177}
139178
140- /** Clear the query and keep focus in the input. */
141- function clearQuery() {
142- emit (' update:query' , ' ' )
179+ /**
180+ * Funnel click: focus the input before emitting. Revealing the filters unmounts the
181+ * funnel, so without this focus would fall to <body> and the modal's focus trap would
182+ * capture that as its return target (returning focus to <body> on close). Keeping focus
183+ * on the always-mounted input makes it the return target instead.
184+ */
185+ function openFilters() {
143186 inputRef .value ?.focus ()
187+ emit (' open-filters' )
188+ }
189+
190+ /**
191+ * Trailing X: with a query, clear the text and keep focus; on an empty field,
192+ * dismiss (blur, and let the parent close the popover).
193+ */
194+ function clearOrClose() {
195+ if (props .query .length > 0 ) {
196+ emit (' update:query' , ' ' )
197+ inputRef .value ?.focus ()
198+ return
199+ }
200+ // Empty field: dismiss. Focus is on the X (inside the field), so blur it; the
201+ // field's focusout then clears isFocused. Let the parent close the popover.
202+ const focused = document .activeElement as HTMLElement | null
203+ focused ?.blur ()
204+ emit (' close' )
144205}
145206
146207/**
@@ -324,7 +385,8 @@ defineExpose({ focus })
324385 }
325386 }
326387
327- & __clear {
388+ & __clear ,
389+ & __filter {
328390 flex-shrink : 0 ;
329391 margin-inline-end : 2px ;
330392 }
0 commit comments