@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,33 @@
1
+ <script setup lang="ts">
2
+ import type { EsAutocompleteTextSegment } from '../types';
3
+ import { splitEsAutocompleteText } from '../utils/autocomplete';
4
+
5
+ interface Props {
6
+ query?: string;
7
+ /**
8
+ * pre-computed segments, e.g. from splitEsAutocompleteTextLines or a search
9
+ * API's own match offsets. when provided, text/query are ignored.
10
+ */
11
+ segments?: EsAutocompleteTextSegment[] | null;
12
+ text?: string;
13
+ }
14
+
15
+ const props = withDefaults(defineProps<Props>(), {
16
+ query: '',
17
+ segments: null,
18
+ text: '',
19
+ });
20
+
21
+ const resolvedSegments = computed(() => props.segments ?? splitEsAutocompleteText(props.text, props.query));
22
+ </script>
23
+
24
+ <template>
25
+ <span>
26
+ <span
27
+ v-for="(segment, index) in resolvedSegments"
28
+ :key="index"
29
+ :class="{ 'font-weight-bold': segment.predictive }"
30
+ >{{ segment.text }}</span
31
+ >
32
+ </span>
33
+ </template>
@@ -0,0 +1,224 @@
1
+ <script setup lang="ts">
2
+ import type { EsAutocompleteSuggestion } from '../types';
3
+
4
+ interface Props {
5
+ autoSelect?: boolean;
6
+ autocomplete?: string;
7
+ clearText?: string;
8
+ closeText?: string;
9
+ delay?: number;
10
+ disabled?: boolean;
11
+ helpText?: string;
12
+ label: string;
13
+ labelSrOnly?: boolean;
14
+ minChars?: number;
15
+ noResultsText?: string;
16
+ placeholder?: string;
17
+ promptText?: string;
18
+ required?: boolean;
19
+ selectOnBlur?: boolean;
20
+ showOverlayOnFocus?: boolean;
21
+ state?: boolean | null;
22
+ suggestionCountText?: (count: number) => string;
23
+ suggestions: EsAutocompleteSuggestion[];
24
+ triggerHelpText?: string;
25
+ }
26
+
27
+ const props = withDefaults(defineProps<Props>(), {
28
+ autoSelect: false,
29
+ // keeps browser autofill from competing with our
30
+ // suggestions list, but can be overridden if necessary
31
+ autocomplete: 'off',
32
+ clearText: 'Clear',
33
+ closeText: 'Close',
34
+ delay: 300,
35
+ disabled: false,
36
+ helpText: 'Type your search and select from dropdown suggestions.',
37
+ labelSrOnly: false,
38
+ minChars: 1,
39
+ noResultsText: 'No results found',
40
+ placeholder: '',
41
+ promptText: 'Type for suggestions',
42
+ required: false,
43
+ selectOnBlur: false,
44
+ showOverlayOnFocus: false,
45
+ state: null,
46
+ suggestionCountText: (count: number) =>
47
+ count === 1 ? '1 suggestion available' : `${count} suggestions available`,
48
+ triggerHelpText: 'Opens a search with suggestions as you type.',
49
+ });
50
+
51
+ const emit = defineEmits<{
52
+ blur: [];
53
+ complete: [query: string];
54
+ select: [suggestion: EsAutocompleteSuggestion];
55
+ }>();
56
+
57
+ const model = defineModel<string>({ default: '' });
58
+
59
+ const slots = useSlots();
60
+
61
+ const id = useId();
62
+ const errorId = `${id}-error`;
63
+ const helpId = `${id}-help`;
64
+ const triggerHelpId = `${id}-trigger-help`;
65
+ const showError = computed(() => props.state === false && (!!slots.errorMessage || props.required));
66
+ const describedBy = computed(() => (showError.value ? `${helpId} ${errorId}` : helpId));
67
+ // the mobile trigger gets its own hint: it says what activating the field does,
68
+ // while the takeover input's says what to do once it has focus
69
+ const triggerDescribedBy = computed(() => (showError.value ? `${triggerHelpId} ${errorId}` : triggerHelpId));
70
+
71
+ // the debounced 'complete' contract, minChars gating, and prompt/no-results
72
+ // messaging live in useEsAutocompleteSearch so the contract is unit-testable
73
+ const { effectiveSuggestions, noResultsAnnouncement, onSelect, panelMessage } = useEsAutocompleteSearch({
74
+ delay: () => props.delay,
75
+ emitComplete: (query) => emit('complete', query),
76
+ emitSelect: (suggestion) => emit('select', suggestion),
77
+ minChars: () => props.minChars,
78
+ model,
79
+ noResultsText: () => props.noResultsText,
80
+ promptText: () => props.promptText,
81
+ suggestions: () => props.suggestions,
82
+ });
83
+ </script>
84
+
85
+ <template>
86
+ <div class="es-autocomplete">
87
+ <div class="es-autocomplete-shell-desktop">
88
+ <es-autocomplete-desktop
89
+ :id="id"
90
+ v-model="model"
91
+ :auto-select="autoSelect"
92
+ :autocomplete="autocomplete"
93
+ :clear-text="clearText"
94
+ :described-by="describedBy"
95
+ :disabled="disabled"
96
+ :label="label"
97
+ :label-sr-only="labelSrOnly"
98
+ :no-results-announcement="noResultsAnnouncement"
99
+ :panel-message="panelMessage"
100
+ :placeholder="placeholder"
101
+ :required="required"
102
+ :select-on-blur="selectOnBlur"
103
+ :show-overlay-on-focus="showOverlayOnFocus"
104
+ :state="state"
105
+ :suggestion-count-text="suggestionCountText"
106
+ :suggestions="effectiveSuggestions"
107
+ @blur="emit('blur')"
108
+ @select="onSelect">
109
+ <template
110
+ v-if="$slots.item"
111
+ #item="slotProps">
112
+ <slot
113
+ name="item"
114
+ v-bind="slotProps" />
115
+ </template>
116
+ </es-autocomplete-desktop>
117
+ </div>
118
+ <div class="es-autocomplete-shell-mobile">
119
+ <es-autocomplete-mobile
120
+ :id="id"
121
+ v-model="model"
122
+ :auto-select="autoSelect"
123
+ :autocomplete="autocomplete"
124
+ :clear-text="clearText"
125
+ :close-text="closeText"
126
+ :described-by="describedBy"
127
+ :disabled="disabled"
128
+ :label="label"
129
+ :label-sr-only="labelSrOnly"
130
+ :no-results-announcement="noResultsAnnouncement"
131
+ :panel-message="panelMessage"
132
+ :placeholder="placeholder"
133
+ :required="required"
134
+ :select-on-blur="selectOnBlur"
135
+ :state="state"
136
+ :suggestion-count-text="suggestionCountText"
137
+ :suggestions="effectiveSuggestions"
138
+ :trigger-described-by="triggerDescribedBy"
139
+ @blur="emit('blur')"
140
+ @select="onSelect">
141
+ <template
142
+ v-if="$slots.item"
143
+ #item="slotProps">
144
+ <slot
145
+ name="item"
146
+ v-bind="slotProps" />
147
+ </template>
148
+ </es-autocomplete-mobile>
149
+ </div>
150
+ <small
151
+ v-if="showError"
152
+ :id="errorId"
153
+ aria-live="polite"
154
+ class="text-danger"
155
+ role="status">
156
+ <slot
157
+ v-if="$slots.errorMessage"
158
+ name="errorMessage" />
159
+ <template v-else> This field is required. </template>
160
+ </small>
161
+ <small
162
+ v-else-if="$slots.message"
163
+ class="text-muted">
164
+ <slot name="message" />
165
+ </small>
166
+ <!-- aria-hidden keeps these hints out of a screen reader's reading order —
167
+ they exist only to be resolved through aria-describedby on focus.
168
+ kept as separate elements so their IDs can be listed along with the
169
+ error message ID, when applicable. -->
170
+ <div
171
+ :id="helpId"
172
+ aria-hidden="true"
173
+ class="sr-only">
174
+ {{ helpText }}
175
+ </div>
176
+ <div
177
+ :id="triggerHelpId"
178
+ aria-hidden="true"
179
+ class="sr-only">
180
+ {{ triggerHelpText }}
181
+ </div>
182
+ </div>
183
+ </template>
184
+
185
+ <style lang="scss" scoped>
186
+ @use '@energysage/es-ds-styles/scss/mixins/breakpoints' as breakpoints;
187
+ @use '@energysage/es-ds-styles/scss/variables' as variables;
188
+
189
+ .es-autocomplete {
190
+ /* match EsFormInput, but allow override by utility classes */
191
+ margin-bottom: variables.$spacer;
192
+ }
193
+
194
+ /* ensures that the given rules are applied at XS breakpoint on touch devices.
195
+ * "hover: none" covers most touch devices except Samsung (which has a bug),
196
+ * so we also apply them on "pointer: coarse" to catch Samsung devices. */
197
+ @mixin small-touch-screen {
198
+ @include breakpoints.media-breakpoint-down(sm) {
199
+ @media (hover: none) {
200
+ @content;
201
+ }
202
+
203
+ @media (pointer: coarse) {
204
+ @content;
205
+ }
206
+ }
207
+ }
208
+
209
+ /* show the desktop autocomplete by default */
210
+ .es-autocomplete-shell-mobile {
211
+ display: none;
212
+ }
213
+
214
+ /* show the mobile fullscreen takeover experience only on small touch devices */
215
+ @include small-touch-screen {
216
+ .es-autocomplete-shell-desktop {
217
+ display: none;
218
+ }
219
+
220
+ .es-autocomplete-shell-mobile {
221
+ display: block;
222
+ }
223
+ }
224
+ </style>
@@ -0,0 +1,70 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import type { App } from 'vue';
4
+ import { createApp, nextTick, ref } from 'vue';
5
+ import { useEsAutocompleteAnnouncer } from './autocomplete-announcer';
6
+
7
+ /** run the composable inside a real component so watchers are scoped */
8
+ function withSetup<T>(composable: () => T): { app: App; result: T } {
9
+ let result!: T;
10
+ const app = createApp({
11
+ setup() {
12
+ result = composable();
13
+ return () => null;
14
+ },
15
+ });
16
+ app.mount(document.createElement('div'));
17
+ return { app, result };
18
+ }
19
+
20
+ function makeAnnouncer(initial = '') {
21
+ const source = ref(initial);
22
+ const { app, result: announced } = withSetup(() => useEsAutocompleteAnnouncer(() => source.value));
23
+ return { announced, app, source };
24
+ }
25
+
26
+ describe('useEsAutocompleteAnnouncer', () => {
27
+ beforeEach(() => vi.useFakeTimers());
28
+ afterEach(() => vi.useRealTimers());
29
+
30
+ it('holds the text back until the listbox has stopped changing', async () => {
31
+ const { announced, source } = makeAnnouncer();
32
+ source.value = '34 Prospect St';
33
+ await nextTick();
34
+ expect(announced.value).toBe('');
35
+ vi.advanceTimersByTime(250);
36
+ expect(announced.value).toBe('34 Prospect St');
37
+ });
38
+
39
+ it('speaks only the last of several texts arriving together', async () => {
40
+ const { announced, source } = makeAnnouncer();
41
+ source.value = '34 Prospect St';
42
+ await nextTick();
43
+ vi.advanceTimersByTime(100);
44
+ source.value = '355 Cedar Ct';
45
+ await nextTick();
46
+ vi.advanceTimersByTime(100);
47
+ expect(announced.value).toBe('');
48
+ vi.advanceTimersByTime(150);
49
+ expect(announced.value).toBe('355 Cedar Ct');
50
+ });
51
+
52
+ it('empties at once, having nothing to wait behind', async () => {
53
+ const { announced, source } = makeAnnouncer();
54
+ source.value = '34 Prospect St';
55
+ await nextTick();
56
+ vi.advanceTimersByTime(250);
57
+ source.value = '';
58
+ await nextTick();
59
+ expect(announced.value).toBe('');
60
+ });
61
+
62
+ it('drops a text still waiting when the component goes away', async () => {
63
+ const { announced, app, source } = makeAnnouncer();
64
+ source.value = '34 Prospect St';
65
+ await nextTick();
66
+ app.unmount();
67
+ vi.advanceTimersByTime(250);
68
+ expect(announced.value).toBe('');
69
+ });
70
+ });
@@ -0,0 +1,41 @@
1
+ import { onUnmounted, ref, watch } from 'vue';
2
+
3
+ // long enough that the changes to the autocomplete have already been read out by
4
+ // screen reader, short enough that the announcement we want (e.g. the
5
+ // auto-selected value and/or number of suggestions) is read out immediately afterward
6
+ const ANNOUNCE_DELAY_MS = 250;
7
+
8
+ /**
9
+ * holds a live region's text back until the DOM around it has settled. a screen
10
+ * reader answers an opening listbox with a burst of its own — expanded, the
11
+ * listbox, how many items it holds — where each announcement cuts off the one
12
+ * before, and a polite update made in the same breath is cut off with them.
13
+ * arriving after the burst, the text is spoken instead of swallowed.
14
+ */
15
+ export function useEsAutocompleteAnnouncer(text: () => string) {
16
+ const announced = ref('');
17
+ let timer: ReturnType<typeof setTimeout> | null = null;
18
+
19
+ function cancel() {
20
+ if (timer) {
21
+ clearTimeout(timer);
22
+ timer = null;
23
+ }
24
+ }
25
+
26
+ watch(text, (next) => {
27
+ cancel();
28
+ // emptying a region announces nothing, so it has nothing to wait behind
29
+ if (!next) {
30
+ announced.value = '';
31
+ return;
32
+ }
33
+ timer = setTimeout(() => {
34
+ announced.value = next;
35
+ }, ANNOUNCE_DELAY_MS);
36
+ });
37
+
38
+ onUnmounted(cancel);
39
+
40
+ return announced;
41
+ }
@@ -0,0 +1,258 @@
1
+ interface TakeoverParts {
2
+ field: HTMLElement | null;
3
+ takeover: HTMLElement | null;
4
+ /** the resting readonly combobox input, which the flying ghost clones */
5
+ trigger: HTMLInputElement | null;
6
+ }
7
+
8
+ interface TakeoverChoreographyOptions {
9
+ isOpen: () => boolean;
10
+ parts: () => TakeoverParts;
11
+ setOpen: (value: boolean) => void;
12
+ }
13
+
14
+ const TRANSITION_MS = 300;
15
+
16
+ /**
17
+ * the takeover's enter/exit choreography: it cross-fades while a "ghost" of the
18
+ * field flies between the resting field and the one inside it. the ghost is an
19
+ * inert clone, so the real input keeps the focus iOS needs to show the keyboard.
20
+ */
21
+ export function useEsAutocompleteChoreography(options: TakeoverChoreographyOptions) {
22
+ function transitionsDisabled() {
23
+ return (
24
+ typeof matchMedia === 'undefined' ||
25
+ matchMedia('(prefers-reduced-motion: reduce)').matches ||
26
+ !('animate' in Element.prototype)
27
+ );
28
+ }
29
+
30
+ function settle(animation: Animation) {
31
+ return awaitAnimationOrTimeout(animation, TRANSITION_MS);
32
+ }
33
+
34
+ /**
35
+ * where a field keeps its text: the gaps from its own box to the text region.
36
+ * measured, never assumed — only the takeover's field has a clear button,
37
+ * and it reserves that width over its padding.
38
+ */
39
+ function textInsets(input: HTMLInputElement, clearButton: HTMLElement | null) {
40
+ const style = getComputedStyle(input);
41
+ return {
42
+ left: Number.parseFloat(style.paddingLeft) || 0,
43
+ right: (Number.parseFloat(style.paddingRight) || 0) + (clearButton?.offsetWidth ?? 0),
44
+ };
45
+ }
46
+
47
+ /** one end of a flight: where the field sits, and what it shows there */
48
+ interface FlightEnd {
49
+ insets: { left: number; right: number };
50
+ placeholder: string;
51
+ rect: DOMRect;
52
+ }
53
+
54
+ function flyGhost(
55
+ trigger: HTMLInputElement,
56
+ from: FlightEnd,
57
+ to: FlightEnd,
58
+ decorate?: (ghost: HTMLElement, field: HTMLInputElement) => void,
59
+ ) {
60
+ // the flying element wraps the cloned field, because the clear button's
61
+ // clone must render inside the ghost and an <input> cannot have children.
62
+ // the clone fills the wrapper, so its own border and background fly.
63
+ const ghost = document.createElement('div');
64
+ ghost.setAttribute('aria-hidden', 'true');
65
+ ghost.style.cssText = 'margin: 0; overflow: hidden; pointer-events: none; position: fixed; z-index: 1060;';
66
+ const field = trigger.cloneNode(true) as HTMLInputElement;
67
+ field.removeAttribute('id');
68
+ // an input's value is a property, so cloning attributes does not bring it
69
+ field.value = trigger.value;
70
+ // never a tab stop, and never a second field for an assistive technology
71
+ field.tabIndex = -1;
72
+ // an empty field shows the placeholder of wherever it is going, not the
73
+ // one it came from: the takeover's stands in for the label it covers
74
+ field.placeholder = to.placeholder;
75
+ // px-100 is !important, and an important declaration outranks an
76
+ // animation — the animated insets below own the field's padding
77
+ field.classList.remove('px-100');
78
+ field.style.cssText = 'height: 100%; width: 100%;';
79
+ ghost.append(field);
80
+ document.body.append(ghost);
81
+
82
+ const timing = {
83
+ duration: TRANSITION_MS,
84
+ easing: 'cubic-bezier(0.2, 0, 0, 1)',
85
+ fill: 'both',
86
+ } as const;
87
+ const flight = ghost.animate(
88
+ [from, to].map((end) => ({
89
+ height: `${end.rect.height}px`,
90
+ left: `${end.rect.left}px`,
91
+ top: `${end.rect.top}px`,
92
+ width: `${end.rect.width}px`,
93
+ })),
94
+ timing,
95
+ );
96
+ // the text region travels with the box, widening or narrowing by the clear
97
+ // button's width over the same 300ms the clone spends fading, so a value
98
+ // pinned to its end slides to its landing place instead of stepping
99
+ field.animate(
100
+ [from, to].map((end) => ({
101
+ paddingLeft: `${end.insets.left}px`,
102
+ paddingRight: `${end.insets.right}px`,
103
+ })),
104
+ timing,
105
+ );
106
+ decorate?.(ghost, field);
107
+ return settle(flight).finally(() => ghost.remove());
108
+ }
109
+
110
+ // only an input's scroll offset positions a value too long to fit, and held at
111
+ // the end — where both real fields leave the caret — one that fits clamps to
112
+ // zero anyway. the frame loop is because the flight re-clamps scrollLeft.
113
+ function holdScrolledToEnd(field: HTMLInputElement) {
114
+ const hold = () => {
115
+ if (!field.isConnected) {
116
+ return;
117
+ }
118
+ field.scrollLeft = field.scrollWidth;
119
+ requestAnimationFrame(hold);
120
+ };
121
+ hold();
122
+ }
123
+
124
+ // what the endpoints do not share: the clear button, which only the takeover's
125
+ // field carries. a clone fades in toward the takeover and out toward the
126
+ // page, over the space the animated insets reserve.
127
+ function decorateGhost(
128
+ ghost: HTMLElement,
129
+ field: HTMLInputElement,
130
+ clearButton: HTMLElement | null,
131
+ direction: 'in' | 'out',
132
+ text?: string,
133
+ ) {
134
+ if (text !== undefined) {
135
+ // a selection's text: the close starts before the model write is
136
+ // rendered into the cloned trigger, so the ghost takes the final
137
+ // text explicitly
138
+ field.value = text;
139
+ }
140
+ holdScrolledToEnd(field);
141
+ if (direction === 'in') {
142
+ // the takeover shows no validation, so a ghost flying into it must not
143
+ // either — the cloned trigger's icon would appear for the flight and
144
+ // vanish on landing. it also frees the insets below: the icon's room is
145
+ // reserved with !important, which an animation cannot outrank.
146
+ field.classList.remove('is-invalid');
147
+ }
148
+ if (clearButton) {
149
+ const clone = clearButton.cloneNode(true) as HTMLElement;
150
+ // pinned inside the field's border, where the real field lays the
151
+ // button out in its content box. h-100 comes off for the same reason
152
+ // px-100 does on the field: !important would beat the insets.
153
+ const fieldStyle = getComputedStyle(field);
154
+ const borderY = Number.parseFloat(fieldStyle.borderTopWidth) || 0;
155
+ const borderX = Number.parseFloat(fieldStyle.borderRightWidth) || 0;
156
+ clone.classList.remove('h-100');
157
+ clone.style.cssText = `position: absolute; inset: ${borderY}px ${borderX}px ${borderY}px auto;`;
158
+ ghost.append(clone);
159
+ clone.animate(direction === 'in' ? [{ opacity: 0 }, { opacity: 1 }] : [{ opacity: 1 }, { opacity: 0 }], {
160
+ duration: TRANSITION_MS,
161
+ easing: 'ease',
162
+ fill: 'both',
163
+ });
164
+ }
165
+ }
166
+
167
+ function enterTransition() {
168
+ if (transitionsDisabled()) {
169
+ return;
170
+ }
171
+ const { field, takeover, trigger } = options.parts();
172
+ if (!field || !takeover || !trigger) {
173
+ return;
174
+ }
175
+ // hide both real elements while the ghost flies, so it reads as one element
176
+ // leaving and arriving. opacity, not visibility: the field holds focus, and
177
+ // hiding a focused element can blur it and lose the iOS keyboard.
178
+ field.style.opacity = '0';
179
+ trigger.style.opacity = '0';
180
+ const clearButton = field.querySelector<HTMLElement>('.es-autocomplete-clear');
181
+ const landingInput = field.querySelector('input');
182
+ void settle(
183
+ takeover.animate([{ opacity: 0 }, { opacity: 1 }], { duration: TRANSITION_MS, easing: 'ease-out' }),
184
+ );
185
+ void flyGhost(
186
+ trigger,
187
+ {
188
+ insets: textInsets(trigger, null),
189
+ placeholder: trigger.placeholder,
190
+ rect: trigger.getBoundingClientRect(),
191
+ },
192
+ {
193
+ insets: textInsets(landingInput ?? trigger, clearButton),
194
+ placeholder: landingInput?.placeholder ?? trigger.placeholder,
195
+ rect: field.getBoundingClientRect(),
196
+ },
197
+ (ghost, ghostField) => decorateGhost(ghost, ghostField, clearButton, 'in'),
198
+ ).then(() => {
199
+ field.style.opacity = '';
200
+ // a close begun during the enter flight owns the trigger's visibility
201
+ if (!exiting) {
202
+ trigger.style.opacity = '';
203
+ }
204
+ });
205
+ }
206
+
207
+ let exiting = false;
208
+ async function closeTakeover(selectedText?: string) {
209
+ if (!options.isOpen() || exiting) {
210
+ return;
211
+ }
212
+ if (transitionsDisabled()) {
213
+ options.setOpen(false);
214
+ return;
215
+ }
216
+ exiting = true;
217
+ const { field, takeover, trigger } = options.parts();
218
+ if (!field || !takeover || !trigger) {
219
+ options.setOpen(false);
220
+ exiting = false;
221
+ return;
222
+ }
223
+ takeover.style.pointerEvents = 'none';
224
+ // hide both real elements while the ghost flies back (see enterTransition)
225
+ trigger.style.opacity = '0';
226
+ field.style.opacity = '0';
227
+ const clearButton = field.querySelector<HTMLElement>('.es-autocomplete-clear');
228
+ const fade = takeover.animate([{ opacity: 1 }, { opacity: 0 }], {
229
+ duration: TRANSITION_MS,
230
+ easing: 'ease-in',
231
+ fill: 'forwards',
232
+ });
233
+ const landingInput = field.querySelector('input');
234
+ await Promise.all([
235
+ flyGhost(
236
+ trigger,
237
+ {
238
+ insets: textInsets(landingInput ?? trigger, clearButton),
239
+ placeholder: landingInput?.placeholder ?? trigger.placeholder,
240
+ rect: field.getBoundingClientRect(),
241
+ },
242
+ {
243
+ insets: textInsets(trigger, null),
244
+ placeholder: trigger.placeholder,
245
+ rect: trigger.getBoundingClientRect(),
246
+ },
247
+ (ghost, ghostField) => decorateGhost(ghost, ghostField, clearButton, 'out', selectedText),
248
+ ),
249
+ settle(fade),
250
+ ]);
251
+ trigger.style.opacity = '';
252
+ field.style.opacity = '';
253
+ options.setOpen(false);
254
+ exiting = false;
255
+ }
256
+
257
+ return { closeTakeover, enterTransition };
258
+ }