@energysage/es-ds-components 6.2.0 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/app/components/es-autocomplete-clear-button.vue +48 -0
- package/app/components/es-autocomplete-desktop.vue +619 -0
- package/app/components/es-autocomplete-field.vue +110 -0
- package/app/components/es-autocomplete-item.vue +101 -0
- package/app/components/es-autocomplete-label.vue +28 -0
- package/app/components/es-autocomplete-mobile.vue +452 -0
- package/app/components/es-autocomplete-suggestion-text.vue +33 -0
- package/app/components/es-autocomplete.vue +224 -0
- package/app/composables/autocomplete-announcer.test.ts +70 -0
- package/app/composables/autocomplete-announcer.ts +41 -0
- package/app/composables/autocomplete-choreography.ts +258 -0
- package/app/composables/autocomplete-combobox.test.ts +464 -0
- package/app/composables/autocomplete-combobox.ts +386 -0
- package/app/composables/autocomplete-search.test.ts +246 -0
- package/app/composables/autocomplete-search.ts +128 -0
- package/app/composables/autocomplete-visible-rows.test.ts +209 -0
- package/app/composables/autocomplete-visible-rows.ts +116 -0
- package/app/types/es-autocomplete.ts +13 -0
- package/app/types/index.ts +1 -0
- package/app/utils/animation.ts +23 -0
- package/app/utils/autocomplete.test.ts +103 -0
- package/app/utils/autocomplete.ts +104 -0
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [6.3.0](https://github.com/EnergySage/es-ds/compare/es-ds-components-v6.2.0...es-ds-components-v6.3.0) (2026-10-07)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* added EsAutocomplete ([#1777](https://github.com/EnergySage/es-ds/issues/1777)) ([2bf3f1e](https://github.com/EnergySage/es-ds/commit/2bf3f1ec79a1af24c96508cdb3662b5503f64700))
|
|
9
|
+
|
|
3
10
|
## [6.2.0](https://github.com/EnergySage/es-ds/compare/es-ds-components-v6.1.1...es-ds-components-v6.2.0) (2026-10-02)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
clearText?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
defineProps<Props>();
|
|
7
|
+
|
|
8
|
+
const emit = defineEmits<{
|
|
9
|
+
clear: [];
|
|
10
|
+
}>();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<!-- out of the tab order, not out of the accessibility tree: a screen reader
|
|
15
|
+
cursor, click, or tap still work, while a keyboard user can use select-all
|
|
16
|
+
and delete to clear the text and doesn't have tabbing to the next field impeded
|
|
17
|
+
by the clear button -->
|
|
18
|
+
<button
|
|
19
|
+
class="es-autocomplete-clear align-items-center bg-transparent border-0 d-flex flex-shrink-0 h-100 justify-content-center p-0 rounded-xs text-gray-700"
|
|
20
|
+
tabindex="-1"
|
|
21
|
+
type="button"
|
|
22
|
+
:aria-label="clearText"
|
|
23
|
+
@click="emit('clear')">
|
|
24
|
+
<icon-x
|
|
25
|
+
height="20px"
|
|
26
|
+
width="20px" />
|
|
27
|
+
</button>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<style lang="scss" scoped>
|
|
31
|
+
@use '@energysage/es-ds-styles/scss/variables' as variables;
|
|
32
|
+
|
|
33
|
+
/* ≥44px square so it is comfortably tappable; a flex sibling of the input, so
|
|
34
|
+
* it can never overlap the entered text */
|
|
35
|
+
.es-autocomplete-clear {
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
width: 2.75rem;
|
|
38
|
+
|
|
39
|
+
&:hover {
|
|
40
|
+
color: variables.$gray-900;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
&:focus-visible {
|
|
44
|
+
outline: 0.125rem solid variables.$blue-600;
|
|
45
|
+
outline-offset: -0.25rem;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ComponentPublicInstance } from 'vue';
|
|
3
|
+
import type { EsAutocompleteSuggestion } from '../types';
|
|
4
|
+
|
|
5
|
+
// defaults live on the public es-autocomplete.vue wrapper, which always binds
|
|
6
|
+
// every prop; declaring them again here would be dead code that could drift
|
|
7
|
+
interface Props {
|
|
8
|
+
autoSelect: boolean;
|
|
9
|
+
selectOnBlur: boolean;
|
|
10
|
+
autocomplete: string;
|
|
11
|
+
clearText?: string;
|
|
12
|
+
describedBy: string;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
labelSrOnly?: boolean;
|
|
17
|
+
noResultsAnnouncement: string;
|
|
18
|
+
panelMessage: string;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
required?: boolean;
|
|
21
|
+
showOverlayOnFocus?: boolean;
|
|
22
|
+
state?: boolean | null;
|
|
23
|
+
suggestionCountText: (count: number) => string;
|
|
24
|
+
suggestions: EsAutocompleteSuggestion[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const props = defineProps<Props>();
|
|
28
|
+
|
|
29
|
+
const emit = defineEmits<{
|
|
30
|
+
blur: [];
|
|
31
|
+
select: [suggestion: EsAutocompleteSuggestion];
|
|
32
|
+
}>();
|
|
33
|
+
|
|
34
|
+
const model = defineModel<string>({ default: '' });
|
|
35
|
+
|
|
36
|
+
const open = ref(false);
|
|
37
|
+
// the listbox renders only when there are suggestions to browse, so a screen
|
|
38
|
+
// reader hears "expanded" exactly when there is a list; the prompt/no-results
|
|
39
|
+
// message shows in an aria-hidden lookalike panel instead
|
|
40
|
+
const listboxOpen = computed(() => open.value && props.suggestions.length > 0);
|
|
41
|
+
// the visible panel hosts whichever applies, listbox or message, so the message
|
|
42
|
+
// slides like the listbox and swapping between them morphs the height
|
|
43
|
+
const panelOpen = computed(() => open.value && (props.suggestions.length > 0 || props.panelMessage !== ''));
|
|
44
|
+
|
|
45
|
+
const rootEl = ref<HTMLElement | null>(null);
|
|
46
|
+
const fieldRef = ref<(ComponentPublicInstance & { inputEl: HTMLInputElement | null }) | null>(null);
|
|
47
|
+
// the field component's root is a single plain div, so $el is reliable
|
|
48
|
+
const fieldEl = computed(() => (fieldRef.value?.$el as HTMLElement | undefined) ?? null);
|
|
49
|
+
const inputEl = computed(() => fieldRef.value?.inputEl ?? null);
|
|
50
|
+
const panelEl = ref<HTMLElement | null>(null);
|
|
51
|
+
|
|
52
|
+
// with anchor positioning the panel is the browser's own popover, anchored to
|
|
53
|
+
// the field: the top layer escapes any ancestor's overflow or z-index, and
|
|
54
|
+
// manual mode keeps light-dismiss from closing it on a click in our own input.
|
|
55
|
+
// without it the styles place the panel below the field and script stands down.
|
|
56
|
+
//
|
|
57
|
+
// checked after mount, not in setup, to avoid hydration errors.
|
|
58
|
+
const supportsAnchor = ref(false);
|
|
59
|
+
onMounted(() => {
|
|
60
|
+
supportsAnchor.value = typeof CSS !== 'undefined' && CSS.supports('anchor-name: --a');
|
|
61
|
+
});
|
|
62
|
+
const anchorName = `--es-autocomplete-${props.id}`;
|
|
63
|
+
const panelAbove = ref(false);
|
|
64
|
+
|
|
65
|
+
// chooses the panel's side and max-height from the space around the field, from
|
|
66
|
+
// the same numbers the row trim divides into rows. below unless the
|
|
67
|
+
// untrimmed list only fits above, as any popper would.
|
|
68
|
+
function positionPanel() {
|
|
69
|
+
const panel = panelEl.value;
|
|
70
|
+
const anchor = fieldEl.value;
|
|
71
|
+
if (!panel || !anchor) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const offset = 4; // the 0.25rem field-to-panel gap (the panel's margin inside the clip)
|
|
75
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
76
|
+
const spaceBelow = window.innerHeight - anchorRect.bottom - offset;
|
|
77
|
+
const spaceAbove = anchorRect.top - offset;
|
|
78
|
+
const rowHeight = panel.querySelector<HTMLElement>('[data-es-autocomplete-item]')?.offsetHeight ?? 0;
|
|
79
|
+
const borders = Math.max(panel.offsetHeight - panel.clientHeight, 0);
|
|
80
|
+
const natural = rowHeight * Math.min(props.suggestions.length, ES_AUTOCOMPLETE_MAX_VISIBLE_SUGGESTIONS) + borders;
|
|
81
|
+
const above = supportsAnchor.value && natural > spaceBelow && natural <= spaceAbove;
|
|
82
|
+
panelAbove.value = above;
|
|
83
|
+
panel.style.maxHeight = `${Math.max(above ? spaceAbove : spaceBelow, 0)}px`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { remeasure, visibleSuggestions } = useEsAutocompleteVisibleRows(
|
|
87
|
+
panelEl,
|
|
88
|
+
toRef(props, 'suggestions'),
|
|
89
|
+
ES_AUTOCOMPLETE_MAX_VISIBLE_SUGGESTIONS,
|
|
90
|
+
{
|
|
91
|
+
// the always-mounted panel must not re-measure on every page scroll while closed
|
|
92
|
+
active: () => panelOpen.value,
|
|
93
|
+
beforeMeasure: positionPanel,
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const combobox = useEsAutocompleteCombobox({
|
|
98
|
+
autoSelect: () => props.autoSelect,
|
|
99
|
+
selectOnBlur: () => props.selectOnBlur,
|
|
100
|
+
close: () => {
|
|
101
|
+
open.value = false;
|
|
102
|
+
},
|
|
103
|
+
emitSelect: (suggestion) => emit('select', suggestion),
|
|
104
|
+
idPrefix: props.id,
|
|
105
|
+
inputEl,
|
|
106
|
+
model,
|
|
107
|
+
// arrows navigate exactly what is displayed; nothing while the panel is closed
|
|
108
|
+
suggestions: () => (listboxOpen.value ? visibleSuggestions.value : []),
|
|
109
|
+
});
|
|
110
|
+
watch(open, combobox.resetHighlight);
|
|
111
|
+
|
|
112
|
+
// announces how many suggestions are actually displayed, after the cap and the
|
|
113
|
+
// trim, or the no results state. the inactive shell's region sits under
|
|
114
|
+
// display: none, which silences it.
|
|
115
|
+
const liveAnnouncement = computed(() => {
|
|
116
|
+
if (!visibleSuggestions.value.length) {
|
|
117
|
+
return props.noResultsAnnouncement;
|
|
118
|
+
}
|
|
119
|
+
const count = props.suggestionCountText(visibleSuggestions.value.length);
|
|
120
|
+
// automatic selection's choice leads, being the one thing a screen reader has
|
|
121
|
+
// no other way to learn, and the count follows it: only VoiceOver on the desk
|
|
122
|
+
// works the count out from the listbox itself, so the others would hear none.
|
|
123
|
+
// named from the list rather than the live highlight, so arrowing through the
|
|
124
|
+
// suggestions, which a screen reader reads from the field's own value, does
|
|
125
|
+
// not rewrite this region and have it read a second time.
|
|
126
|
+
return combobox.autoSelectedText.value ? `${combobox.autoSelectedText.value}, ${count}` : count;
|
|
127
|
+
});
|
|
128
|
+
// a screen reader meets the opening listbox with announcements of its own, which
|
|
129
|
+
// cut each other off; this text waits for them rather than being lost among them
|
|
130
|
+
const announcement = useEsAutocompleteAnnouncer(() => liveAnnouncement.value);
|
|
131
|
+
|
|
132
|
+
// a popover element displays only once shown; manual popovers never light-dismiss.
|
|
133
|
+
//
|
|
134
|
+
// one limitation comes with using a popover: if a modal <dialog> is ever used for
|
|
135
|
+
// EsModal, the popover suggestion pane of an autocomplete within it will not be
|
|
136
|
+
// visible, as it will be underneath the <dialog>.
|
|
137
|
+
function showAsPopover(el: HTMLElement | null) {
|
|
138
|
+
if (el && supportsAnchor.value) {
|
|
139
|
+
try {
|
|
140
|
+
el.showPopover();
|
|
141
|
+
} catch {
|
|
142
|
+
// already shown, or not a popover in this browser
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Escape belongs to the panel only while the panel is up: dismissing it must not
|
|
148
|
+
// also reach a dialog the field sits in, and a closed field must let it through
|
|
149
|
+
function onEscape(event: KeyboardEvent) {
|
|
150
|
+
if (!open.value) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
event.stopPropagation();
|
|
154
|
+
open.value = false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// each side parks the closed panel differently, so the side is settled before
|
|
158
|
+
// the open: a transition reads the value from before the frame it starts in.
|
|
159
|
+
// the re-park runs with transitions off, or it becomes the slide's start point.
|
|
160
|
+
async function parkPanel() {
|
|
161
|
+
const wasAbove = panelAbove.value;
|
|
162
|
+
positionPanel();
|
|
163
|
+
if (panelAbove.value === wasAbove) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
await nextTick();
|
|
167
|
+
const panel = panelEl.value;
|
|
168
|
+
if (!panel) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
panel.style.transition = 'none';
|
|
172
|
+
// forces the new park into the DOM before the transition comes back
|
|
173
|
+
void panel.offsetHeight;
|
|
174
|
+
panel.style.transition = '';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// the panel, and the overlay with showOverlayOnFocus, stays up for the whole
|
|
178
|
+
// interaction: focus opens it; Escape, a selection, Enter, focus moving to
|
|
179
|
+
// another control or a pointerdown outside closes it — never a bare blur.
|
|
180
|
+
async function onFieldActivity() {
|
|
181
|
+
if (props.disabled) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!open.value) {
|
|
185
|
+
await parkPanel();
|
|
186
|
+
}
|
|
187
|
+
open.value = true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// our own restore after an arrow-driven collapse continues the navigation it
|
|
191
|
+
// interrupted, so it is not an entry into the field (see onFocusIn)
|
|
192
|
+
let restoringFocus = false;
|
|
193
|
+
// blur is reported once per visit, so the paths that can each notice the same
|
|
194
|
+
// departure do not announce it twice
|
|
195
|
+
let visiting = false;
|
|
196
|
+
|
|
197
|
+
function onFocusIn() {
|
|
198
|
+
// entering the field starts a fresh interaction, from the typed text: a
|
|
199
|
+
// leftover highlight keeps aria-activedescendant pointed into the list, and
|
|
200
|
+
// a screen reader arriving here follows it straight back out to that option
|
|
201
|
+
if (!restoringFocus) {
|
|
202
|
+
combobox.resetHighlight();
|
|
203
|
+
}
|
|
204
|
+
visiting = true;
|
|
205
|
+
onFieldActivity();
|
|
206
|
+
combobox.revealCaretOnFocus();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// the user is done with the field, so the panel goes down and the app hears a
|
|
210
|
+
// blur it can validate on. Escape and select are not this — each leaves focus in
|
|
211
|
+
// the input, and the interaction continues.
|
|
212
|
+
function leaveField() {
|
|
213
|
+
if (visiting) {
|
|
214
|
+
visiting = false;
|
|
215
|
+
// an automatic selection commits on the way out, so the app validates
|
|
216
|
+
// the value the field was already showing selected
|
|
217
|
+
combobox.commitAutoSelection();
|
|
218
|
+
open.value = false;
|
|
219
|
+
emit('blur');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
open.value = false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// focus went nowhere rather than to another control: no relatedTarget at all,
|
|
226
|
+
// or the document standing in for one (a screen reader's "web area")
|
|
227
|
+
function isFocusCollapse(next: Node | null) {
|
|
228
|
+
return !next || next === document.body || next === document.documentElement;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function onRootFocusout(event: FocusEvent) {
|
|
232
|
+
const next = event.relatedTarget as Node | null;
|
|
233
|
+
if (!isFocusCollapse(next)) {
|
|
234
|
+
if (!rootEl.value?.contains(next)) {
|
|
235
|
+
leaveField();
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
// a collapse says nothing on its own: a screen reader echoing a value this
|
|
240
|
+
// component wrote, a click on dead space and its cursor simply moving off all
|
|
241
|
+
// look alike, so the answer waits for what the browser settles on
|
|
242
|
+
answerFocusCollapse(combobox.consumeRewriteBlur());
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// deferred a frame: by then an outside pointerdown has closed the panel, and a
|
|
246
|
+
// focus move still in flight has landed somewhere real. the window losing focus
|
|
247
|
+
// leaves the input active, so it needs nothing.
|
|
248
|
+
function answerFocusCollapse(followsValueRewrite: boolean) {
|
|
249
|
+
requestAnimationFrame(() => {
|
|
250
|
+
const el = inputEl.value;
|
|
251
|
+
const active = document.activeElement;
|
|
252
|
+
const stillNowhere = !active || active === document.body || active === document.documentElement;
|
|
253
|
+
if (!document.hasFocus() || !stillNowhere) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (followsValueRewrite && open.value && el) {
|
|
257
|
+
restoringFocus = true;
|
|
258
|
+
el.focus();
|
|
259
|
+
restoringFocus = false;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
// while the panel is up the widget is still in use: a screen reader walks
|
|
263
|
+
// the list with real focus left behind, and a blur would validate the
|
|
264
|
+
// field mid-interaction. with it down, focus gone means the user has left
|
|
265
|
+
if (!open.value) {
|
|
266
|
+
leaveField();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// clicks outside close the widget, and only the pointer event tells them from a
|
|
272
|
+
// screen reader's identical collapse. inside means the working parts, not the
|
|
273
|
+
// root: its dead space beside the label would otherwise strand an open list.
|
|
274
|
+
function onDocumentPointerdown(event: Event) {
|
|
275
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
276
|
+
const part = target?.closest('.es-autocomplete-field, .es-autocomplete-panel, label');
|
|
277
|
+
if (part && rootEl.value?.contains(part)) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// a press outside is the user leaving, so an automatic selection commits
|
|
281
|
+
// here, while the list is still up: focusout arrives once it is down
|
|
282
|
+
combobox.commitAutoSelection();
|
|
283
|
+
open.value = false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
watch(open, (isOpen) => {
|
|
287
|
+
if (isOpen) {
|
|
288
|
+
document.addEventListener('pointerdown', onDocumentPointerdown, true);
|
|
289
|
+
} else {
|
|
290
|
+
document.removeEventListener('pointerdown', onDocumentPointerdown, true);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
onBeforeUnmount(() => {
|
|
295
|
+
document.removeEventListener('pointerdown', onDocumentPointerdown, true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// --- panel drawer ------------------------------------------------------------
|
|
299
|
+
// the slide is pure CSS on the --open class, so interruption, reversal and
|
|
300
|
+
// reduced motion come from the platform; script shows the popover,
|
|
301
|
+
// re-positions, and morphs the height.
|
|
302
|
+
const PANEL_SLIDE_MS = 200;
|
|
303
|
+
|
|
304
|
+
function morphDisabled() {
|
|
305
|
+
return (
|
|
306
|
+
typeof matchMedia === 'undefined' ||
|
|
307
|
+
matchMedia('(prefers-reduced-motion: reduce)').matches ||
|
|
308
|
+
!('animate' in Element.prototype)
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const wrapperEl = ref<HTMLElement | null>(null);
|
|
313
|
+
// the popover attribute only appears once the post-mount upgrade lands, so the
|
|
314
|
+
// show re-runs when supportsAnchor flips — and post-flush, after the attribute
|
|
315
|
+
// itself is in the DOM (showPopover throws on a non-popover element)
|
|
316
|
+
watch([wrapperEl, supportsAnchor], () => showAsPopover(wrapperEl.value), { flush: 'post' });
|
|
317
|
+
|
|
318
|
+
// the always-mounted panel keeps its last measure, so a reopen re-derives the
|
|
319
|
+
// side and max-height from wherever the field sits now
|
|
320
|
+
watch(panelOpen, (isOpen) => {
|
|
321
|
+
if (isOpen) {
|
|
322
|
+
void remeasure();
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// the morph animates the panel itself, whose overflow: hidden clips the rows, so
|
|
327
|
+
// growing reveals them and shrinking pulls the bottom edge up. the observer
|
|
328
|
+
// watches the inner content, so every event is a real content change.
|
|
329
|
+
const panelContentEl = ref<HTMLElement | null>(null);
|
|
330
|
+
let heightAnimation: Animation | null = null;
|
|
331
|
+
let lastPanelHeight: number | null = null;
|
|
332
|
+
let panelResizeObserver: ResizeObserver | null = null;
|
|
333
|
+
watch(panelContentEl, (el) => {
|
|
334
|
+
panelResizeObserver?.disconnect();
|
|
335
|
+
panelResizeObserver = null;
|
|
336
|
+
lastPanelHeight = null;
|
|
337
|
+
if (!el || typeof ResizeObserver === 'undefined') {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
panelResizeObserver = new ResizeObserver(() => {
|
|
341
|
+
const panel = panelEl.value;
|
|
342
|
+
if (!panel) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// mid-morph, the rendered height is wherever the keyframes are; idle,
|
|
346
|
+
// it is already the NEW natural height, so the old one comes from the
|
|
347
|
+
// previous observation
|
|
348
|
+
const wasMidMorph = heightAnimation !== null;
|
|
349
|
+
const rendered = panel.getBoundingClientRect().height;
|
|
350
|
+
heightAnimation?.cancel();
|
|
351
|
+
heightAnimation = null;
|
|
352
|
+
const natural = panel.getBoundingClientRect().height;
|
|
353
|
+
const from = wasMidMorph ? rendered : lastPanelHeight;
|
|
354
|
+
lastPanelHeight = natural;
|
|
355
|
+
if (from === null || !panelOpen.value || morphDisabled() || Math.abs(natural - from) < 1) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const animation = panel.animate([{ height: `${from}px` }, { height: `${natural}px` }], {
|
|
359
|
+
duration: PANEL_SLIDE_MS,
|
|
360
|
+
easing: 'cubic-bezier(0.2, 0, 0, 1)',
|
|
361
|
+
fill: 'both',
|
|
362
|
+
});
|
|
363
|
+
heightAnimation = animation;
|
|
364
|
+
void awaitAnimationOrTimeout(animation, PANEL_SLIDE_MS).then(() => {
|
|
365
|
+
if (heightAnimation === animation) {
|
|
366
|
+
heightAnimation = null;
|
|
367
|
+
// the natural height equals the end keyframe, so dropping the
|
|
368
|
+
// finished animation leaves no jump
|
|
369
|
+
animation.cancel();
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
panelResizeObserver.observe(el);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
onBeforeUnmount(() => {
|
|
377
|
+
panelResizeObserver?.disconnect();
|
|
378
|
+
});
|
|
379
|
+
</script>
|
|
380
|
+
|
|
381
|
+
<template>
|
|
382
|
+
<div
|
|
383
|
+
ref="rootEl"
|
|
384
|
+
class="position-relative"
|
|
385
|
+
@focusout="onRootFocusout">
|
|
386
|
+
<es-autocomplete-label
|
|
387
|
+
:html-for="id"
|
|
388
|
+
:label="label"
|
|
389
|
+
:label-sr-only="labelSrOnly"
|
|
390
|
+
:required="required" />
|
|
391
|
+
<es-autocomplete-field
|
|
392
|
+
ref="fieldRef"
|
|
393
|
+
:class="{
|
|
394
|
+
'es-autocomplete-field--focus-ring': !showOverlayOnFocus && !combobox.keyboardHighlightActive.value,
|
|
395
|
+
'es-autocomplete-field--raised': open && showOverlayOnFocus,
|
|
396
|
+
}"
|
|
397
|
+
:autocomplete="autocomplete"
|
|
398
|
+
:clear-text="clearText"
|
|
399
|
+
:combobox="combobox"
|
|
400
|
+
:described-by="describedBy"
|
|
401
|
+
:disabled="disabled"
|
|
402
|
+
:input-id="id"
|
|
403
|
+
:listbox-open="listboxOpen"
|
|
404
|
+
:model="model"
|
|
405
|
+
:placeholder="placeholder"
|
|
406
|
+
:required="required"
|
|
407
|
+
:state="state"
|
|
408
|
+
:style="{ anchorName }"
|
|
409
|
+
@click="onFieldActivity"
|
|
410
|
+
@focusin="onFocusIn"
|
|
411
|
+
@input="onFieldActivity"
|
|
412
|
+
@keydown.esc="onEscape" />
|
|
413
|
+
<!-- the panel sits right after the field in the DOM, as the APG combobox
|
|
414
|
+
examples do, so a screen reader leaving the list lands back near the
|
|
415
|
+
input. the outer div is the clip wrapper the drawer slides within,
|
|
416
|
+
the inner one the panel that translates -->
|
|
417
|
+
<!-- popover and position-anchor are unconditional, and the
|
|
418
|
+
anchored/fallback split lives in a CSS @supports block, so server
|
|
419
|
+
and client render identical markup -->
|
|
420
|
+
<div
|
|
421
|
+
ref="wrapperEl"
|
|
422
|
+
:aria-hidden="panelOpen ? undefined : 'true'"
|
|
423
|
+
popover="manual"
|
|
424
|
+
:class="[
|
|
425
|
+
'es-autocomplete-clip',
|
|
426
|
+
{
|
|
427
|
+
'es-autocomplete-clip--above': panelAbove,
|
|
428
|
+
'es-autocomplete-clip--open': panelOpen,
|
|
429
|
+
},
|
|
430
|
+
]"
|
|
431
|
+
:style="{ positionAnchor: anchorName }">
|
|
432
|
+
<div
|
|
433
|
+
ref="panelEl"
|
|
434
|
+
class="es-autocomplete-panel bg-white rounded-xs text-gray-900 font-size-75 text-left"
|
|
435
|
+
@mousedown="combobox.onListMousedown">
|
|
436
|
+
<!-- persistent inner box: the height-morph observer watches it,
|
|
437
|
+
so the panel's own height animation cannot feed back into it -->
|
|
438
|
+
<div ref="panelContentEl">
|
|
439
|
+
<div
|
|
440
|
+
v-if="visibleSuggestions.length"
|
|
441
|
+
:id="combobox.listboxId"
|
|
442
|
+
:aria-label="label"
|
|
443
|
+
role="listbox">
|
|
444
|
+
<es-autocomplete-item
|
|
445
|
+
v-for="(suggestion, index) in visibleSuggestions"
|
|
446
|
+
:key="suggestion.id"
|
|
447
|
+
:highlighted="combobox.selectedIndex.value === index"
|
|
448
|
+
:keyboard-nav="combobox.keyboardNav.value"
|
|
449
|
+
:option-id="combobox.optionId(index)"
|
|
450
|
+
:query="model"
|
|
451
|
+
:suggestion="suggestion"
|
|
452
|
+
@pointermove="combobox.onOptionPointermove(index)"
|
|
453
|
+
@select="combobox.onOptionClick(index)">
|
|
454
|
+
<template
|
|
455
|
+
v-if="$slots.item"
|
|
456
|
+
#default="slotProps">
|
|
457
|
+
<slot
|
|
458
|
+
name="item"
|
|
459
|
+
v-bind="slotProps" />
|
|
460
|
+
</template>
|
|
461
|
+
</es-autocomplete-item>
|
|
462
|
+
</div>
|
|
463
|
+
<!-- presentation only: screen readers get the same guidance from
|
|
464
|
+
the input's description and the live region, so this is hidden
|
|
465
|
+
and the combobox stays collapsed -->
|
|
466
|
+
<div
|
|
467
|
+
v-else-if="panelMessage"
|
|
468
|
+
aria-hidden="true"
|
|
469
|
+
class="es-autocomplete-no-results px-100 py-50 text-gray-700">
|
|
470
|
+
{{ panelMessage }}
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
<div
|
|
476
|
+
class="sr-only"
|
|
477
|
+
role="status">
|
|
478
|
+
{{ announcement }}
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
<teleport to="body">
|
|
482
|
+
<transition name="es-autocomplete-overlay">
|
|
483
|
+
<!-- no shell gate of its own: the overlay exists only while this shell
|
|
484
|
+
is open, which it cannot be while the wrapper hides it -->
|
|
485
|
+
<div
|
|
486
|
+
v-if="open && showOverlayOnFocus"
|
|
487
|
+
aria-hidden="true"
|
|
488
|
+
class="es-autocomplete-overlay" />
|
|
489
|
+
</transition>
|
|
490
|
+
</teleport>
|
|
491
|
+
</template>
|
|
492
|
+
|
|
493
|
+
<style lang="scss" scoped>
|
|
494
|
+
@use '@energysage/es-ds-styles/scss/variables' as variables;
|
|
495
|
+
|
|
496
|
+
/* without the overlay, the field takes es-dropdown-select's focus ring, whose
|
|
497
|
+
* contrast change es-form-input's lighter border lacks. the class gate hands
|
|
498
|
+
* that ring to the highlighted option during keyboard navigation. */
|
|
499
|
+
.es-autocomplete-field--focus-ring:focus-within {
|
|
500
|
+
border-color: variables.$blue-600;
|
|
501
|
+
outline: 0.125rem solid variables.$blue-600;
|
|
502
|
+
outline-offset: 0.125rem;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/* with showOverlayOnFocus the field has no focus styling of its own: the
|
|
506
|
+
* page-dim overlay is the focus indicator, and a lighter border would read as
|
|
507
|
+
* the border disappearing against the dimmed page */
|
|
508
|
+
|
|
509
|
+
/* while the panel is open, lift the input above the page-dim overlay so it
|
|
510
|
+
* stays fully visible and interactive */
|
|
511
|
+
.es-autocomplete-field--raised {
|
|
512
|
+
position: relative;
|
|
513
|
+
z-index: 1000;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/* the invisible clip wrapper the drawer slides within, cropping the panel only
|
|
517
|
+
* at the field's edge. these base rules are the no-anchor fallback and clear the
|
|
518
|
+
* UA's [popover] defaults — the display among them shows an unshown popover. */
|
|
519
|
+
.es-autocomplete-clip {
|
|
520
|
+
background: transparent;
|
|
521
|
+
border: 0;
|
|
522
|
+
clip-path: inset(0 -2rem -2rem -2rem);
|
|
523
|
+
display: block;
|
|
524
|
+
inset: auto;
|
|
525
|
+
left: 0;
|
|
526
|
+
margin: 0;
|
|
527
|
+
max-width: min(90vw, 30rem);
|
|
528
|
+
min-width: 100%;
|
|
529
|
+
overflow: visible;
|
|
530
|
+
padding: 0;
|
|
531
|
+
/* never a hit target: a top-layer element swallows clicks over its whole box
|
|
532
|
+
* even when transparent, so only the open panel inside takes them */
|
|
533
|
+
pointer-events: none;
|
|
534
|
+
position: absolute;
|
|
535
|
+
top: 100%;
|
|
536
|
+
width: max-content;
|
|
537
|
+
/* above .es-autocomplete-overlay when not in the top layer */
|
|
538
|
+
z-index: 1000;
|
|
539
|
+
|
|
540
|
+
/* flipped: the drawer emerges upward, so the crop moves to the bottom edge
|
|
541
|
+
* and the panel pins there, revealing rows from the top */
|
|
542
|
+
&--above {
|
|
543
|
+
align-content: end;
|
|
544
|
+
clip-path: inset(-2rem -2rem 0 -2rem);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/* glued to the field by anchor positioning, which keeps it attached between
|
|
548
|
+
* the script's re-measures. flush against the field, so the crop edge sits
|
|
549
|
+
* where the drawer disappears behind it; the gap is the panel's margin. */
|
|
550
|
+
@supports (anchor-name: --a) {
|
|
551
|
+
left: anchor(left);
|
|
552
|
+
min-width: anchor-size(width);
|
|
553
|
+
position: fixed;
|
|
554
|
+
top: anchor(bottom);
|
|
555
|
+
|
|
556
|
+
&.es-autocomplete-clip--above {
|
|
557
|
+
bottom: anchor(top);
|
|
558
|
+
top: auto;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/* the visual panel that slides, parked behind the field with its shadow faded
|
|
564
|
+
* until --open transitions it out. visibility flips discretely, at the slide's
|
|
565
|
+
* start and the retract's end, so the browser owns the whole exit. */
|
|
566
|
+
.es-autocomplete-panel {
|
|
567
|
+
border: variables.$border-width solid variables.$gray-500;
|
|
568
|
+
box-shadow: none;
|
|
569
|
+
margin-top: 0.25rem;
|
|
570
|
+
overflow: hidden;
|
|
571
|
+
/* clicks pass through except while open, so a closed or retracting panel
|
|
572
|
+
* never blocks the field or the page beneath it */
|
|
573
|
+
pointer-events: none;
|
|
574
|
+
transform: translateY(calc(-100% - 0.25rem));
|
|
575
|
+
visibility: hidden;
|
|
576
|
+
|
|
577
|
+
@media not (prefers-reduced-motion) {
|
|
578
|
+
transition:
|
|
579
|
+
box-shadow 0.2s cubic-bezier(0.2, 0, 0, 1),
|
|
580
|
+
transform 0.2s cubic-bezier(0.2, 0, 0, 1),
|
|
581
|
+
visibility 0.2s allow-discrete;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
.es-autocomplete-clip--above & {
|
|
585
|
+
margin-bottom: 0.25rem;
|
|
586
|
+
margin-top: 0;
|
|
587
|
+
transform: translateY(calc(100% + 0.25rem));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/* declared after the --above override so the open state wins on both sides */
|
|
591
|
+
.es-autocomplete-clip--open & {
|
|
592
|
+
box-shadow: variables.$popover-box-shadow;
|
|
593
|
+
pointer-events: auto;
|
|
594
|
+
transform: translateY(0);
|
|
595
|
+
visibility: visible;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
.es-autocomplete-overlay {
|
|
600
|
+
background-color: variables.$black;
|
|
601
|
+
inset: 0;
|
|
602
|
+
opacity: 0.25;
|
|
603
|
+
position: fixed;
|
|
604
|
+
/* matches .es-menu-bar-overlay in es-menu-bar; the two are never open at once */
|
|
605
|
+
z-index: 999;
|
|
606
|
+
|
|
607
|
+
@media not (prefers-reduced-motion) {
|
|
608
|
+
&-enter-active,
|
|
609
|
+
&-leave-active {
|
|
610
|
+
transition: opacity 0.15s ease-in-out;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
&-enter-from,
|
|
614
|
+
&-leave-to {
|
|
615
|
+
opacity: 0;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
</style>
|