@energysage/es-ds-components 6.2.0 → 6.3.1

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