Skip to content

Commit 44322b1

Browse files
committed
feat(core): reveal unified search filters from the input
Signed-off-by: Peter Ringelmann <peter.ringelmann@nextcloud.com>
1 parent aae73b1 commit 44322b1

15 files changed

Lines changed: 826 additions & 74 deletions

core/src/components/UnifiedSearch/SearchFilterChip.vue

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@
99
<span v-if="pretext.length"> {{ pretext }} : </span>
1010
</span>
1111
<span class="text">{{ text }}</span>
12-
<span class="close-icon" @click="deleteChip">
12+
<button
13+
type="button"
14+
class="close-button"
15+
:aria-label="removeLabel"
16+
@click="deleteChip">
1317
<CloseIcon :size="18" />
14-
</span>
18+
</button>
1519
</div>
1620
</template>
1721

1822
<script>
23+
import { t } from '@nextcloud/l10n'
1924
import CloseIcon from 'vue-material-design-icons/Close.vue'
2025
2126
export default {
@@ -36,9 +41,19 @@ export default {
3641
},
3742
},
3843
44+
emits: ['delete'],
45+
46+
computed: {
47+
// Accessible name for the icon-only remove button (screen readers can't read a bare ×).
48+
removeLabel() {
49+
return t('core', 'Remove filter: {name}', { name: this.text })
50+
},
51+
},
52+
3953
methods: {
4054
deleteChip() {
41-
this.$emit('delete', this.filter)
55+
// The parent reads the filter from its own v-for scope, so no payload is needed.
56+
this.$emit('delete')
4257
},
4358
},
4459
}
@@ -71,12 +86,29 @@ export default {
7186
margin: 0 2px;
7287
}
7388
74-
.close-icon {
75-
cursor: pointer ;
89+
// Reset the global bare-<button> chrome (core/css/inputs.scss styles non-.button-vue buttons).
90+
.close-button {
91+
display: flex;
92+
align-items: center;
93+
width: auto;
94+
min-width: 0;
95+
min-height: 0;
96+
margin: 0;
97+
padding: 0;
98+
border: none;
99+
background: transparent;
100+
color: inherit;
101+
cursor: pointer;
102+
border-radius: var(--border-radius-element, 8px);
76103
77-
:hover {
104+
&:hover {
78105
filter: invert(20%);
79106
}
107+
108+
&:focus-visible {
109+
outline: 2px solid var(--color-main-text);
110+
outline-offset: 1px;
111+
}
80112
}
81113
}
82114
</style>

core/src/components/UnifiedSearch/SearchableList.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@
3333
:wide="true"
3434
@click="itemSelected(element)">
3535
<template #icon>
36-
<NcAvatar v-if="element.isUser" :user="element.user" hide-user-status />
36+
<NcAvatar v-if="element.isUser" :user="element.user" hide-status />
3737
<NcAvatar
3838
v-else
3939
:is-no-user="true"
4040
:display-name="element.displayName"
41-
hide-user-status />
41+
hide-status />
4242
</template>
4343
{{ element.displayName }}
4444
</NcButton>

core/src/components/UnifiedSearch/UnifiedSearchInput.vue

Lines changed: 89 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
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 }"
@@ -42,22 +44,31 @@
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'
7788
import NcHeaderButton from '@nextcloud/vue/components/NcHeaderButton'
7889
import NcKbd from '@nextcloud/vue/components/NcKbd'
7990
import IconClose from 'vue-material-design-icons/Close.vue'
91+
import IconFilterVariant from 'vue-material-design-icons/FilterVariant.vue'
8092
import 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
93103
const 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
101113
const 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
110126
const isSmallMobile = useIsSmallMobile()
@@ -114,19 +130,42 @@ const placeholderText = t('core', 'Apps, files, messages, and more')
114130
// the id on UnifiedSearchModal's panel.
115131
const 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.
120135
const directionByKey: Record<string, 'next' | 'prev'> = {
121136
ArrowDown: 'next',
122137
ArrowUp: 'prev',
123138
}
124139
140+
const fieldRef = ref<HTMLElement>()
125141
const inputRef = ref<HTMLInputElement>()
126142
const 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

Comments
 (0)