@energysage/es-ds-components 6.1.1 → 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 CHANGED
@@ -1,5 +1,26 @@
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
+
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)
11
+
12
+
13
+ ### Features
14
+
15
+ * add Utilities docs page, small enhancements to EsCollapse and EsDataTableSimple ([#1787](https://github.com/EnergySage/es-ds/issues/1787)) ([39770f6](https://github.com/EnergySage/es-ds/commit/39770f67a7bdd18a2a541a9279ae0209607c9c8a))
16
+
17
+
18
+ ### Dependencies
19
+
20
+ * The following workspace dependencies were updated
21
+ * devDependencies
22
+ * @energysage/es-ds-styles bumped from ^4.0.0 to ^4.1.0
23
+
3
24
  ## [6.1.1](https://github.com/EnergySage/es-ds/compare/es-ds-components-v6.1.0...es-ds-components-v6.1.1) (2026-09-29)
4
25
 
5
26
 
@@ -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>