@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 +21 -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/components/es-collapse.vue +6 -1
- package/app/components/es-data-table-simple.vue +8 -1
- 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 +5 -3
|
@@ -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>
|
|
@@ -72,7 +72,7 @@ const onClick = (value: boolean) => {
|
|
|
72
72
|
<icon-chevron-down class="es-collapse-icon" />
|
|
73
73
|
</div>
|
|
74
74
|
</collapsible-trigger>
|
|
75
|
-
<collapsible-content class="es-collapse-content
|
|
75
|
+
<collapsible-content class="es-collapse-content">
|
|
76
76
|
<slot />
|
|
77
77
|
</collapsible-content>
|
|
78
78
|
</collapsible-root>
|
|
@@ -131,6 +131,11 @@ $easing-function: cubic-bezier(0.87, 0, 0.13, 1);
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
.es-collapse-content {
|
|
134
|
+
/* kept as a fallback for pre-2022 browsers that don't support clip */
|
|
135
|
+
overflow: hidden;
|
|
136
|
+
/* allows position sticky within the collapse to work */
|
|
137
|
+
overflow: clip;
|
|
138
|
+
|
|
134
139
|
@media not (prefers-reduced-motion) {
|
|
135
140
|
&[data-state='closed'] {
|
|
136
141
|
animation: slideUp $animation-duration $easing-function;
|
|
@@ -12,6 +12,10 @@ const props = defineProps({
|
|
|
12
12
|
type: [Array, String],
|
|
13
13
|
default: null,
|
|
14
14
|
},
|
|
15
|
+
reverseStripes: {
|
|
16
|
+
type: Boolean,
|
|
17
|
+
default: false,
|
|
18
|
+
},
|
|
15
19
|
striped: {
|
|
16
20
|
type: Boolean,
|
|
17
21
|
default: false,
|
|
@@ -41,7 +45,10 @@ const computedItems: ComputedRef<any[]> = computed(() => {
|
|
|
41
45
|
<table
|
|
42
46
|
v-if="$slots.default || items.length"
|
|
43
47
|
class="table table-borderless"
|
|
44
|
-
:class="{
|
|
48
|
+
:class="{
|
|
49
|
+
'table-striped': striped && !reverseStripes,
|
|
50
|
+
'table-striped-reverse': striped && reverseStripes,
|
|
51
|
+
}">
|
|
45
52
|
<template v-if="$slots.default">
|
|
46
53
|
<slot />
|
|
47
54
|
</template>
|
|
@@ -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
|
+
}
|