|
3 | 3 | - SPDX-License-Identifier: AGPL-3.0-or-later |
4 | 4 | --> |
5 | 5 | <template> |
6 | | - <search class="unified-search-input" :class="[{ 'unified-search-input--mobile': isSmallMobile }]"> |
| 6 | + <search |
| 7 | + class="unified-search-input" |
| 8 | + :class="{ 'unified-search-input--mobile': isSmallMobile }"> |
7 | 9 | <NcHeaderButton |
8 | 10 | v-if="isSmallMobile" |
9 | 11 | :aria-label="placeholderText" |
|
14 | 16 | <IconMagnify :size="20" /> |
15 | 17 | </template> |
16 | 18 | </NcHeaderButton> |
17 | | - <button |
| 19 | + <div |
18 | 20 | v-else |
19 | | - type="button" |
20 | | - class="unified-search-input__button" |
21 | | - aria-haspopup="dialog" |
22 | | - :aria-expanded="expanded ? 'true' : 'false'" |
23 | | - @click="$emit('click', $event)"> |
24 | | - <IconMagnify |
25 | | - class="unified-search-input__icon" |
26 | | - :size="20" |
27 | | - aria-hidden="true" /> |
28 | | - <span class="unified-search-input__label"> |
29 | | - {{ placeholderText }} |
30 | | - </span> |
31 | | - </button> |
| 21 | + class="unified-search-input__field" |
| 22 | + :class="{ 'unified-search-input__field--active': isActive }"> |
| 23 | + <!-- Resting overlay: centred magnifier + placeholder, purely decorative. |
| 24 | + A full-width input cannot group an icon with its own placeholder, so we |
| 25 | + paint the resting look on top and let clicks fall through to the input. --> |
| 26 | + <div class="unified-search-input__resting" aria-hidden="true"> |
| 27 | + <IconMagnify :size="20" /> |
| 28 | + <span>{{ placeholderText }}</span> |
| 29 | + </div> |
| 30 | + <IconMagnify class="unified-search-input__icon" :size="20" aria-hidden="true" /> |
| 31 | + <input |
| 32 | + ref="inputRef" |
| 33 | + type="text" |
| 34 | + role="combobox" |
| 35 | + class="unified-search-input__input" |
| 36 | + aria-autocomplete="list" |
| 37 | + :aria-expanded="expanded ? 'true' : 'false'" |
| 38 | + :aria-label="placeholderText" |
| 39 | + :placeholder="isActive ? placeholderText : ''" |
| 40 | + :value="query" |
| 41 | + @focus="onFocus" |
| 42 | + @blur="isFocused = false" |
| 43 | + @input="onInput"> |
| 44 | + <NcButton |
| 45 | + v-if="query.length > 0" |
| 46 | + variant="tertiary-no-background" |
| 47 | + class="unified-search-input__clear" |
| 48 | + :aria-label="t('core', 'Clear search')" |
| 49 | + @click="clearQuery"> |
| 50 | + <template #icon> |
| 51 | + <IconClose :size="20" /> |
| 52 | + </template> |
| 53 | + </NcButton> |
| 54 | + </div> |
32 | 55 | </search> |
33 | 56 | </template> |
34 | 57 |
|
35 | 58 | <script setup lang="ts"> |
36 | 59 | import { t } from '@nextcloud/l10n' |
37 | 60 | import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile' |
| 61 | +import { computed, ref } from 'vue' |
| 62 | +import NcButton from '@nextcloud/vue/components/NcButton' |
38 | 63 | import NcHeaderButton from '@nextcloud/vue/components/NcHeaderButton' |
| 64 | +import IconClose from 'vue-material-design-icons/Close.vue' |
39 | 65 | import IconMagnify from 'vue-material-design-icons/Magnify.vue' |
40 | 66 |
|
41 | 67 | /** |
42 | | - * First phase of the unified-search input: a button styled to look like an |
43 | | - * input field that opens the unified-search modal on click. A later phase |
44 | | - * will replace the button with a real input that filters results inline. |
| 68 | + * The unified-search input that lives in the header. |
45 | 69 | * |
46 | | - * Implemented as a custom component because no `@nextcloud/vue` component |
47 | | - * fits the design role here: NcInputField is a real input whose styling |
48 | | - * assumes a light page background and clashes with the themed header, |
49 | | - * and NcTextField has the same issue. On narrow viewports the trigger |
50 | | - * collapses to a standard NcHeaderButton so it matches the visual |
51 | | - * language of the other header items. |
| 70 | + * Implemented as a plain <input> rather than NcTextField/NcInputField: those |
| 71 | + * assume a light form background and a floating label, which clashes with the |
| 72 | + * themed header and the resting "button" look and would need heavy overrides of |
| 73 | + * their internals. A custom input also lets us own the combobox semantics the |
| 74 | + * results popover needs. On narrow viewports it collapses to an NcHeaderButton |
| 75 | + * to match the other header items. |
52 | 76 | */ |
53 | 77 |
|
54 | | -defineProps<{ |
55 | | - /** Whether the popup the input controls is currently open. Bound to aria-expanded. */ |
| 78 | +const props = defineProps<{ |
| 79 | + /** Whether the popover the input controls is open. Bound to aria-expanded. */ |
56 | 80 | expanded?: boolean |
| 81 | + query: string |
57 | 82 | }>() |
58 | 83 |
|
59 | | -defineEmits<{ |
| 84 | +const emit = defineEmits<{ |
60 | 85 | click: [mouseEvent: MouseEvent] |
| 86 | + focus: [focusEvent: FocusEvent] |
| 87 | + 'update:query': [query: string] |
61 | 88 | }>() |
62 | 89 |
|
63 | 90 | const isSmallMobile = useIsSmallMobile() |
64 | 91 | const placeholderText = t('core', 'Search apps, files, tags, messages …') |
| 92 | +
|
| 93 | +const inputRef = ref<HTMLInputElement>() |
| 94 | +const isFocused = ref(false) |
| 95 | +
|
| 96 | +/** Active = focused or holding a query; drives the input-vs-button styling. */ |
| 97 | +const isActive = computed(() => isFocused.value || props.query.length > 0) |
| 98 | +
|
| 99 | +/** |
| 100 | + * Track focus for the active styling and bubble the event so the parent can |
| 101 | + * react to it. |
| 102 | + * |
| 103 | + * @param event The focus event |
| 104 | + */ |
| 105 | +function onFocus(event: FocusEvent) { |
| 106 | + isFocused.value = true |
| 107 | + emit('focus', event) |
| 108 | +} |
| 109 | +
|
| 110 | +/** |
| 111 | + * Relay the typed value upward. |
| 112 | + * |
| 113 | + * @param event The input event |
| 114 | + */ |
| 115 | +function onInput(event: Event) { |
| 116 | + emit('update:query', (event.target as HTMLInputElement).value) |
| 117 | +} |
| 118 | +
|
| 119 | +/** Clear the query and keep focus in the input. */ |
| 120 | +function clearQuery() { |
| 121 | + emit('update:query', '') |
| 122 | + inputRef.value?.focus() |
| 123 | +} |
| 124 | +
|
| 125 | +defineExpose({ |
| 126 | + getInputElement: (): HTMLInputElement | null => inputRef.value ?? null, |
| 127 | +}) |
65 | 128 | </script> |
66 | 129 |
|
67 | 130 | <style lang="scss" scoped> |
68 | 131 | .unified-search-input { |
| 132 | + // Positioned so z-index applies and the input paints above the search scrim |
| 133 | + position: relative; |
| 134 | + z-index: 51; |
| 135 | +
|
69 | 136 | &:not(.unified-search-input--mobile) { |
70 | 137 | display: flex; |
71 | 138 | align-items: center; |
72 | 139 | width: clamp(200px, 35vw, 600px); |
73 | 140 | max-width: calc(100% - 32px); |
74 | | - pointer-events: none; |
75 | 141 | } |
76 | 142 |
|
77 | 143 | &--mobile { |
78 | 144 | display: contents; |
79 | 145 | } |
80 | 146 |
|
81 | | - &__button { |
82 | | - pointer-events: auto; |
| 147 | + &__field { |
| 148 | + --resting-background: rgba(0, 0, 0, 0.15); |
| 149 | + --resting-background-hover: rgba(0, 0, 0, 0.22); |
| 150 | + position: relative; |
83 | 151 | display: flex; |
84 | 152 | align-items: center; |
85 | | - justify-content: center; |
86 | | - gap: 8px; |
| 153 | + // Match the default clickable area so the inner <input> (which the global |
| 154 | + // input reset forces to that height) fills the field without an override. |
| 155 | + height: var(--default-clickable-area); |
87 | 156 | width: 100%; |
88 | | - height: calc(var(--default-clickable-area) - 8px); |
89 | | - padding: 0 12px; |
90 | | - border: none; |
91 | 157 | border-radius: var(--border-radius-element, 8px); |
92 | | - background-color: rgba(0, 0, 0, 0.15); |
| 158 | + box-shadow: inset 0 2px 0 rgba(0, 0, 0, 0.12); |
| 159 | + // Resting: subdued "button" look that sits on the themed header |
| 160 | + background-color: var(--resting-background); |
93 | 161 | -webkit-backdrop-filter: var(--filter-background-blur); |
94 | 162 | backdrop-filter: var(--filter-background-blur); |
95 | | - box-shadow: inset 0 2px 0 rgba(0, 0, 0, 0.12); |
96 | | - color: color-mix(in srgb, var(--color-background-plain-text) 70%, var(--color-background-plain)); |
97 | | - cursor: pointer; |
98 | | - text-align: center; |
99 | | - font: inherit; |
100 | 163 | transition: background-color var(--animation-quick) ease-in-out; |
101 | 164 |
|
102 | | - &:hover { |
103 | | - background-color: rgba(0, 0, 0, 0.22); |
| 165 | + &:hover:not(.unified-search-input__field--active) { |
| 166 | + background-color: var(--resting-background-hover); |
104 | 167 | } |
105 | 168 |
|
106 | | - &:focus-visible { |
107 | | - background-color: rgba(0, 0, 0, 0.22); |
108 | | - outline: 2px solid var(--color-background-plain-text); |
109 | | - outline-offset: 2px; |
| 169 | + // Active: real input surface once focused or filled |
| 170 | + &--active { |
| 171 | + background-color: var(--color-main-background); |
| 172 | + box-shadow: none; |
110 | 173 | } |
| 174 | + } |
111 | 175 |
|
112 | | - &:active { |
113 | | - background-color: rgba(0, 0, 0, 0.28) !important; |
114 | | - color: var(--color-background-plain-text) !important; |
115 | | - outline: none; |
| 176 | + // Resting look: magnifier + placeholder centred as a group, painted over the |
| 177 | + // (empty, pointer-transparent) input. Fades out as the field becomes active. |
| 178 | + &__resting { |
| 179 | + position: absolute; |
| 180 | + inset: 0; |
| 181 | + display: flex; |
| 182 | + align-items: center; |
| 183 | + justify-content: center; |
| 184 | + gap: 6px; |
| 185 | + padding-inline: 12px; |
| 186 | + pointer-events: none; |
| 187 | + color: color-mix(in srgb, var(--color-background-plain-text) 70%, var(--color-background-plain)); |
| 188 | + transition: opacity var(--animation-quick) ease-in-out; |
| 189 | +
|
| 190 | + span { |
| 191 | + overflow: hidden; |
| 192 | + white-space: nowrap; |
| 193 | + text-overflow: ellipsis; |
| 194 | + } |
| 195 | +
|
| 196 | + .unified-search-input__field--active & { |
| 197 | + opacity: 0; |
116 | 198 | } |
117 | 199 | } |
118 | 200 |
|
| 201 | + // Left magnifier for the active state; hidden while the resting overlay shows |
119 | 202 | &__icon { |
120 | | - flex-shrink: 0; |
| 203 | + position: absolute; |
| 204 | + inset-inline-start: 12px; |
121 | 205 | display: flex; |
122 | | - align-items: center; |
| 206 | + color: var(--color-main-text); |
| 207 | + opacity: 0; |
| 208 | + pointer-events: none; |
| 209 | + transition: opacity var(--animation-quick) ease-in-out; |
| 210 | +
|
| 211 | + .unified-search-input__field--active & { |
| 212 | + opacity: 1; |
| 213 | + } |
123 | 214 | } |
124 | 215 |
|
125 | | - &__label { |
| 216 | + // Only visible once active (at rest it's empty and covered by the overlay), |
| 217 | + // so it's styled for the active/white surface throughout. |
| 218 | + &__input { |
| 219 | + flex: 1; |
126 | 220 | min-width: 0; |
127 | | - overflow: hidden; |
128 | | - text-overflow: ellipsis; |
129 | | - white-space: nowrap; |
| 221 | + height: 100%; |
| 222 | + margin: 0; |
| 223 | + // Leading space for the active magnifier |
| 224 | + padding-inline: calc(var(--default-clickable-area) - var(--default-grid-baseline)) 12px; |
| 225 | + // Opt out of NC's global input chrome (core/css/inputs.scss adds a border, |
| 226 | + // radius and focus box-shadow to any text input not in its exclusion list). |
| 227 | + // !important because that global focus rule outweighs a scoped class. |
| 228 | + border: none !important; |
| 229 | + border-radius: 0 !important; |
| 230 | + box-shadow: none !important; |
| 231 | + background-color: transparent; |
| 232 | + color: var(--color-main-text); |
| 233 | + font-size: var(--default-font-size); |
| 234 | +
|
| 235 | + &::placeholder { |
| 236 | + opacity: 1; |
| 237 | + color: var(--color-text-maxcontrast); |
| 238 | + } |
| 239 | +
|
| 240 | + &:focus-visible { |
| 241 | + outline: none; |
| 242 | + } |
| 243 | + } |
| 244 | +
|
| 245 | + &__clear { |
| 246 | + flex-shrink: 0; |
| 247 | + margin-inline-end: 2px; |
130 | 248 | } |
131 | 249 | } |
132 | 250 |
|
| 251 | +// On dark themes the plain overlay is nearly invisible on the header, so tint |
| 252 | +// the resting background with the primary colour instead. |
| 253 | +[data-theme-dark] .unified-search-input__field, |
| 254 | +[data-theme-dark-highcontrast] .unified-search-input__field { |
| 255 | + --resting-background: color-mix(in srgb, var(--color-primary-element) 16%, transparent); |
| 256 | + --resting-background-hover: color-mix(in srgb, var(--color-primary-element) 22%, transparent); |
| 257 | +} |
| 258 | +
|
| 259 | +// Mobile: NcHeaderButton styling to match the other header items |
133 | 260 | .unified-search-input--mobile :deep(.header-menu) { |
134 | 261 | height: var(--default-clickable-area); |
135 | 262 | } |
@@ -158,23 +285,4 @@ const placeholderText = t('core', 'Search apps, files, tags, messages …') |
158 | 285 | box-shadow: inset 0 0 0 2px var(--color-background-plain-text) !important; |
159 | 286 | } |
160 | 287 | } |
161 | | -
|
162 | | -[data-theme-dark] .unified-search-input__button, |
163 | | -[data-theme-dark-highcontrast] .unified-search-input__button { |
164 | | - background-color: color-mix(in srgb, var(--color-primary-element) 16%, transparent); |
165 | | -
|
166 | | - &:hover { |
167 | | - background-color: color-mix(in srgb, var(--color-primary-element) 22%, transparent); |
168 | | - } |
169 | | -
|
170 | | - &:focus-visible { |
171 | | - background-color: color-mix(in srgb, var(--color-primary-element) 22%, transparent); |
172 | | - } |
173 | | -
|
174 | | - &:active { |
175 | | - background-color: color-mix(in srgb, var(--color-primary-element) 28%, transparent) !important; |
176 | | - color: var(--color-background-plain-text) !important; |
177 | | - outline: none; |
178 | | - } |
179 | | -} |
180 | 288 | </style> |
0 commit comments