@energysage/es-ds-components 6.2.0 → 6.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/app/components/es-autocomplete-clear-button.vue +48 -0
- package/app/components/es-autocomplete-desktop.vue +619 -0
- package/app/components/es-autocomplete-field.vue +110 -0
- package/app/components/es-autocomplete-item.vue +101 -0
- package/app/components/es-autocomplete-label.vue +28 -0
- package/app/components/es-autocomplete-mobile.vue +452 -0
- package/app/components/es-autocomplete-suggestion-text.vue +33 -0
- package/app/components/es-autocomplete.vue +224 -0
- package/app/composables/autocomplete-announcer.test.ts +70 -0
- package/app/composables/autocomplete-announcer.ts +41 -0
- package/app/composables/autocomplete-choreography.ts +258 -0
- package/app/composables/autocomplete-combobox.test.ts +464 -0
- package/app/composables/autocomplete-combobox.ts +386 -0
- package/app/composables/autocomplete-search.test.ts +246 -0
- package/app/composables/autocomplete-search.ts +128 -0
- package/app/composables/autocomplete-visible-rows.test.ts +209 -0
- package/app/composables/autocomplete-visible-rows.ts +116 -0
- package/app/types/es-autocomplete.ts +13 -0
- package/app/types/index.ts +1 -0
- package/app/utils/animation.ts +23 -0
- package/app/utils/autocomplete.test.ts +103 -0
- package/app/utils/autocomplete.ts +104 -0
- package/package.json +4 -2
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { EsAutocompleteCombobox } from '../composables/autocomplete-combobox';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* the field both shells share: the combobox input plus its clear button, with
|
|
6
|
+
* the ARIA wiring driven by the combobox core. each shell adds its own
|
|
7
|
+
* surroundings and its own listeners, through fallthrough attrs on the root.
|
|
8
|
+
*/
|
|
9
|
+
interface Props {
|
|
10
|
+
/** names the takeover's input, which has no visible label of its own */
|
|
11
|
+
ariaLabel?: string;
|
|
12
|
+
/**
|
|
13
|
+
* the input's autocomplete token: 'off' keeps the browser's own dropdown out
|
|
14
|
+
* of the way of the suggestion list, and a field token (e.g. 'street-address')
|
|
15
|
+
* trades that for the browser's saved-value autofill
|
|
16
|
+
*/
|
|
17
|
+
autocomplete: string;
|
|
18
|
+
clearText?: string;
|
|
19
|
+
combobox: EsAutocompleteCombobox;
|
|
20
|
+
describedBy: string;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
/** the input's id, linked from the shell's label; the takeover input has none */
|
|
23
|
+
inputId?: string;
|
|
24
|
+
/** the listbox is only announced as present while it is actually rendered */
|
|
25
|
+
listboxOpen: boolean;
|
|
26
|
+
model: string;
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
required?: boolean;
|
|
29
|
+
state?: boolean | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
defineProps<Props>();
|
|
33
|
+
|
|
34
|
+
const inputEl = ref<HTMLInputElement | null>(null);
|
|
35
|
+
defineExpose({ inputEl });
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<template>
|
|
39
|
+
<div
|
|
40
|
+
class="es-autocomplete-field es-form-input form-control align-items-center d-flex p-0"
|
|
41
|
+
:class="{ 'es-autocomplete-field--disabled': disabled, 'is-invalid': state === false }">
|
|
42
|
+
<!-- aria-autocomplete="both": keyboard navigation rewrites the field's
|
|
43
|
+
value (copy-on-highlight), and declaring that keeps VoiceOver's
|
|
44
|
+
cursor anchored in the field while it announces each option, as on
|
|
45
|
+
Google's and Amazon's comboboxes -->
|
|
46
|
+
<input
|
|
47
|
+
:id="inputId"
|
|
48
|
+
ref="inputEl"
|
|
49
|
+
aria-autocomplete="both"
|
|
50
|
+
class="es-autocomplete-input h-100 w-100 px-100"
|
|
51
|
+
role="combobox"
|
|
52
|
+
type="text"
|
|
53
|
+
:aria-activedescendant="combobox.activeDescendant.value"
|
|
54
|
+
:autocomplete="autocomplete"
|
|
55
|
+
:aria-controls="combobox.listboxId"
|
|
56
|
+
:aria-describedby="describedBy"
|
|
57
|
+
:aria-expanded="listboxOpen"
|
|
58
|
+
:aria-invalid="state === false ? true : undefined"
|
|
59
|
+
:aria-label="ariaLabel"
|
|
60
|
+
:disabled="disabled"
|
|
61
|
+
:placeholder="placeholder"
|
|
62
|
+
:required="required"
|
|
63
|
+
:value="combobox.displayValue.value"
|
|
64
|
+
@input="combobox.onInput"
|
|
65
|
+
@keydown="combobox.onKeydown" />
|
|
66
|
+
<es-autocomplete-clear-button
|
|
67
|
+
v-if="model && !disabled"
|
|
68
|
+
:clear-text="clearText"
|
|
69
|
+
@clear="combobox.onClear" />
|
|
70
|
+
</div>
|
|
71
|
+
</template>
|
|
72
|
+
|
|
73
|
+
<style lang="scss" scoped>
|
|
74
|
+
@use '@energysage/es-ds-styles/scss/variables' as variables;
|
|
75
|
+
|
|
76
|
+
/* es-form-input's disabled styling by hand, since its :disabled rules target
|
|
77
|
+
* form-control — here the wrapper, not the input. $gray-600 because
|
|
78
|
+
* $input-disabled-color reaches 2.93:1, and the placeholder takes it too. */
|
|
79
|
+
.es-autocomplete-field--disabled {
|
|
80
|
+
background-color: variables.$input-disabled-bg;
|
|
81
|
+
border: 0;
|
|
82
|
+
|
|
83
|
+
.es-autocomplete-input {
|
|
84
|
+
color: variables.$gray-600;
|
|
85
|
+
/* iOS fix for unreadable disabled content, as in es-ds-styles' form-control rule */
|
|
86
|
+
opacity: 1;
|
|
87
|
+
|
|
88
|
+
&::placeholder {
|
|
89
|
+
color: variables.$gray-600;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.es-autocomplete-input {
|
|
95
|
+
background: transparent;
|
|
96
|
+
border: none;
|
|
97
|
+
/* a flex item's automatic minimum is its content's width, and WebKit measures
|
|
98
|
+
* an input's from its value — so without this a long value stops the input
|
|
99
|
+
* shrinking and widens the field past the room it has */
|
|
100
|
+
min-width: 0;
|
|
101
|
+
|
|
102
|
+
&:focus-visible {
|
|
103
|
+
outline: none;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
&::placeholder {
|
|
107
|
+
color: variables.$input-color-placeholder;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
</style>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { EsAutocompleteSuggestion } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* one suggestion row, deliberately not focusable: the combobox pattern keeps
|
|
6
|
+
* real focus in the input and points aria-activedescendant here, so nothing can
|
|
7
|
+
* drag focus off the field while navigating. aria-selected tracks the highlight.
|
|
8
|
+
*/
|
|
9
|
+
interface Props {
|
|
10
|
+
/** whether this row is the highlighted one (aria-activedescendant target) */
|
|
11
|
+
highlighted?: boolean;
|
|
12
|
+
/** whether the current highlight comes from keyboard navigation (focus-visible ring) */
|
|
13
|
+
keyboardNav?: boolean;
|
|
14
|
+
optionId: string;
|
|
15
|
+
query: string;
|
|
16
|
+
suggestion: EsAutocompleteSuggestion;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
defineProps<Props>();
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits<{
|
|
22
|
+
pointermove: [];
|
|
23
|
+
select: [];
|
|
24
|
+
}>();
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<div
|
|
29
|
+
:id="optionId"
|
|
30
|
+
class="es-autocomplete-item d-block px-100 py-50"
|
|
31
|
+
:class="{ 'es-autocomplete-item--keyboard-nav': keyboardNav }"
|
|
32
|
+
data-es-autocomplete-item
|
|
33
|
+
role="option"
|
|
34
|
+
:aria-selected="highlighted"
|
|
35
|
+
:data-highlighted="highlighted ? '' : undefined"
|
|
36
|
+
@click="emit('select')"
|
|
37
|
+
@pointermove="emit('pointermove')">
|
|
38
|
+
<slot
|
|
39
|
+
:query="query"
|
|
40
|
+
:suggestion="suggestion">
|
|
41
|
+
<!-- the query-matching portions render regular weight and the predictive
|
|
42
|
+
portions render bold, so users scan what would be ADDED to their
|
|
43
|
+
query (the inverse of most libraries) -->
|
|
44
|
+
<es-autocomplete-suggestion-text
|
|
45
|
+
:query="query"
|
|
46
|
+
:text="suggestion.text" />
|
|
47
|
+
</slot>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<style lang="scss" scoped>
|
|
52
|
+
@use '@energysage/es-ds-styles/scss/variables' as variables;
|
|
53
|
+
|
|
54
|
+
.es-autocomplete-item {
|
|
55
|
+
/* 48px and up, padding inclusive: an adequate tap target on any touch device.
|
|
56
|
+
* rows share one uniform height per list, which the row trim
|
|
57
|
+
* divides the available height by to add or remove whole rows. */
|
|
58
|
+
align-content: center;
|
|
59
|
+
cursor: pointer;
|
|
60
|
+
min-height: 3rem;
|
|
61
|
+
|
|
62
|
+
@media not (prefers-reduced-motion) {
|
|
63
|
+
transition: background-color 0.05s ease-in-out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* the highlight, placed by the pointer or by the arrows */
|
|
67
|
+
&[data-highlighted] {
|
|
68
|
+
background-color: variables.$blue-50;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* a row under the pointer shades whether or not it holds the highlight,
|
|
72
|
+
* since clicking it chooses it either way. the arrows keep the highlight
|
|
73
|
+
* while they are navigating, and their focus ring is what tells the two
|
|
74
|
+
* apart. asked of pointer as well as hover, as Samsung's Android devices
|
|
75
|
+
* claim to hover and would otherwise shade a row until the next tap. */
|
|
76
|
+
@media (hover: hover) and (pointer: fine) {
|
|
77
|
+
&:hover {
|
|
78
|
+
background-color: variables.$blue-50;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* focus-visible ring for the keyboard-highlighted suggestion, as
|
|
83
|
+
* es-dropdown-select draws it: inset within the panel's overflow edge */
|
|
84
|
+
&--keyboard-nav[data-highlighted] {
|
|
85
|
+
position: relative;
|
|
86
|
+
|
|
87
|
+
&::after {
|
|
88
|
+
border: 0.125rem solid variables.$blue-600;
|
|
89
|
+
border-radius: 2px;
|
|
90
|
+
content: '';
|
|
91
|
+
inset: 0;
|
|
92
|
+
pointer-events: none;
|
|
93
|
+
position: absolute;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
&:active {
|
|
98
|
+
background-color: variables.$blue-100;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
</style>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
htmlFor: string;
|
|
4
|
+
label: string;
|
|
5
|
+
labelSrOnly?: boolean;
|
|
6
|
+
required?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
defineProps<Props>();
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<!-- eslint-disable-next-line vuejs-accessibility/label-has-for -->
|
|
14
|
+
<label
|
|
15
|
+
class="label justify-content-start"
|
|
16
|
+
:class="{ 'sr-only': labelSrOnly }"
|
|
17
|
+
:for="htmlFor">
|
|
18
|
+
{{ label }}
|
|
19
|
+
<!-- decoration: the field itself carries required/aria-required, so the
|
|
20
|
+
asterisk would only be read out as "star" on top of it -->
|
|
21
|
+
<span
|
|
22
|
+
v-if="required"
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
class="text-danger">
|
|
25
|
+
*
|
|
26
|
+
</span>
|
|
27
|
+
</label>
|
|
28
|
+
</template>
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
DialogClose,
|
|
4
|
+
DialogContent,
|
|
5
|
+
DialogOverlay,
|
|
6
|
+
DialogPortal,
|
|
7
|
+
DialogRoot,
|
|
8
|
+
DialogTitle,
|
|
9
|
+
DialogTrigger,
|
|
10
|
+
} from 'reka-ui';
|
|
11
|
+
import type { ComponentPublicInstance } from 'vue';
|
|
12
|
+
import type { EsAutocompleteSuggestion } from '../types';
|
|
13
|
+
|
|
14
|
+
// defaults live on the public es-autocomplete.vue wrapper, which always binds
|
|
15
|
+
// every prop; declaring them again here would be dead code that could drift
|
|
16
|
+
interface Props {
|
|
17
|
+
autoSelect: boolean;
|
|
18
|
+
selectOnBlur: boolean;
|
|
19
|
+
autocomplete: string;
|
|
20
|
+
clearText?: string;
|
|
21
|
+
closeText?: string;
|
|
22
|
+
describedBy: string;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
id: string;
|
|
25
|
+
label: string;
|
|
26
|
+
labelSrOnly?: boolean;
|
|
27
|
+
noResultsAnnouncement: string;
|
|
28
|
+
panelMessage: string;
|
|
29
|
+
placeholder?: string;
|
|
30
|
+
required?: boolean;
|
|
31
|
+
state?: boolean | null;
|
|
32
|
+
suggestionCountText: (count: number) => string;
|
|
33
|
+
suggestions: EsAutocompleteSuggestion[];
|
|
34
|
+
triggerDescribedBy: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const props = defineProps<Props>();
|
|
38
|
+
|
|
39
|
+
const emit = defineEmits<{
|
|
40
|
+
blur: [];
|
|
41
|
+
select: [suggestion: EsAutocompleteSuggestion];
|
|
42
|
+
}>();
|
|
43
|
+
|
|
44
|
+
const model = defineModel<string>({ default: '' });
|
|
45
|
+
|
|
46
|
+
const takeoverOpen = ref(false);
|
|
47
|
+
const triggerId = `${props.id}-trigger`;
|
|
48
|
+
const mobileRootEl = ref<HTMLElement | null>(null);
|
|
49
|
+
const fieldRef = ref<(ComponentPublicInstance & { inputEl: HTMLInputElement | null }) | null>(null);
|
|
50
|
+
const inputEl = computed(() => fieldRef.value?.inputEl ?? null);
|
|
51
|
+
const listEl = ref<HTMLElement | null>(null);
|
|
52
|
+
|
|
53
|
+
const { measured, remeasure, visibleSuggestions } = useEsAutocompleteVisibleRows(
|
|
54
|
+
listEl,
|
|
55
|
+
toRef(props, 'suggestions'),
|
|
56
|
+
ES_AUTOCOMPLETE_MAX_VISIBLE_SUGGESTIONS,
|
|
57
|
+
// the takeover sizes its list itself (visualViewport); viewport events are
|
|
58
|
+
// only relevant while it is open
|
|
59
|
+
{ active: () => takeoverOpen.value },
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const { closeTakeover, enterTransition } = useEsAutocompleteChoreography({
|
|
63
|
+
isOpen: () => takeoverOpen.value,
|
|
64
|
+
parts: () => ({
|
|
65
|
+
field: (fieldRef.value?.$el as HTMLElement | undefined) ?? null,
|
|
66
|
+
takeover: (inputEl.value?.closest('.es-autocomplete-takeover') as HTMLElement | null) ?? null,
|
|
67
|
+
trigger: mobileRootEl.value?.querySelector<HTMLInputElement>('.es-autocomplete-trigger') ?? null,
|
|
68
|
+
}),
|
|
69
|
+
setOpen: (value) => {
|
|
70
|
+
takeoverOpen.value = value;
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const combobox = useEsAutocompleteCombobox({
|
|
75
|
+
autoSelect: () => props.autoSelect,
|
|
76
|
+
selectOnBlur: () => props.selectOnBlur,
|
|
77
|
+
close: (selectedText) => {
|
|
78
|
+
void closeTakeover(selectedText);
|
|
79
|
+
},
|
|
80
|
+
emitSelect: (suggestion) => emit('select', suggestion),
|
|
81
|
+
idPrefix: `${props.id}-m`,
|
|
82
|
+
inputEl,
|
|
83
|
+
model,
|
|
84
|
+
// arrows navigate exactly what is displayed
|
|
85
|
+
suggestions: () => (takeoverOpen.value ? visibleSuggestions.value : []),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// the takeover's input announces "expanded" exactly when a list exists
|
|
89
|
+
const listboxOpen = computed(() => visibleSuggestions.value.length > 0);
|
|
90
|
+
|
|
91
|
+
// the takeover covers the page, and with it the label: a field with no
|
|
92
|
+
// placeholder would sit there with nothing to say what it takes, so the label
|
|
93
|
+
// stands in as one. the resting field keeps the consumer's intent.
|
|
94
|
+
const takeoverPlaceholder = computed(() => props.placeholder || props.label);
|
|
95
|
+
|
|
96
|
+
// announces how many suggestions are actually displayed, after the cap and the
|
|
97
|
+
// trim, or the no-results state. the inactive shell's region sits under
|
|
98
|
+
// display: none, which silences it.
|
|
99
|
+
const liveAnnouncement = computed(() => {
|
|
100
|
+
if (!visibleSuggestions.value.length) {
|
|
101
|
+
return props.noResultsAnnouncement;
|
|
102
|
+
}
|
|
103
|
+
const count = props.suggestionCountText(visibleSuggestions.value.length);
|
|
104
|
+
// automatic selection's choice leads, being the one thing a screen reader has
|
|
105
|
+
// no other way to learn, and the count follows it: only VoiceOver on the desk
|
|
106
|
+
// works the count out from the listbox itself, so the others would hear none.
|
|
107
|
+
// named from the list rather than the live highlight, so arrowing through the
|
|
108
|
+
// suggestions, which a screen reader reads from the field's own value, does
|
|
109
|
+
// not rewrite this region and have it read a second time.
|
|
110
|
+
return combobox.autoSelectedText.value ? `${combobox.autoSelectedText.value}, ${count}` : count;
|
|
111
|
+
});
|
|
112
|
+
// a screen reader meets the opening listbox with announcements of its own, which
|
|
113
|
+
// cut each other off; this text waits for them rather than being lost among them
|
|
114
|
+
const announcement = useEsAutocompleteAnnouncer(() => liveAnnouncement.value);
|
|
115
|
+
|
|
116
|
+
// 100dvh does not shrink when the iOS keyboard opens, so the list height comes
|
|
117
|
+
// from the visual viewport instead, where the keyboard is just a resize event
|
|
118
|
+
function updateListHeight() {
|
|
119
|
+
const el = listEl.value;
|
|
120
|
+
const viewport = window.visualViewport;
|
|
121
|
+
if (!el || !viewport) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const top = el.getBoundingClientRect().top;
|
|
125
|
+
el.style.height = `${Math.max(viewport.offsetTop + viewport.height - top, 0)}px`;
|
|
126
|
+
remeasure();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// the keyboard open/close animation emits a burst of visualViewport resizes;
|
|
130
|
+
// coalesce to one layout read + height write per frame
|
|
131
|
+
let listHeightFrame: number | null = null;
|
|
132
|
+
function onViewportResize() {
|
|
133
|
+
if (listHeightFrame !== null) {
|
|
134
|
+
cancelAnimationFrame(listHeightFrame);
|
|
135
|
+
}
|
|
136
|
+
listHeightFrame = requestAnimationFrame(() => {
|
|
137
|
+
listHeightFrame = null;
|
|
138
|
+
updateListHeight();
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
watch(takeoverOpen, async (isOpen) => {
|
|
143
|
+
combobox.resetHighlight();
|
|
144
|
+
const viewport = window.visualViewport;
|
|
145
|
+
if (isOpen) {
|
|
146
|
+
await nextTick();
|
|
147
|
+
updateListHeight();
|
|
148
|
+
viewport?.addEventListener('resize', onViewportResize);
|
|
149
|
+
enterTransition();
|
|
150
|
+
} else {
|
|
151
|
+
viewport?.removeEventListener('resize', onViewportResize);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
onBeforeUnmount(() => {
|
|
156
|
+
window.visualViewport?.removeEventListener('resize', onViewportResize);
|
|
157
|
+
if (listHeightFrame !== null) {
|
|
158
|
+
cancelAnimationFrame(listHeightFrame);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// iOS only shows the keyboard when focus happens inside the tap's event chain,
|
|
163
|
+
// so redirect the dialog's initial focus to the real input
|
|
164
|
+
function onOpenAutoFocus(event: Event) {
|
|
165
|
+
event.preventDefault();
|
|
166
|
+
inputEl.value?.focus();
|
|
167
|
+
combobox.revealCaretOnFocus();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// the user is done with the field, so the app hears a blur it can validate on.
|
|
171
|
+
// focus moving into the takeover is not leaving, though the portal puts it
|
|
172
|
+
// outside this root; nor is the focus Reka hands back on close.
|
|
173
|
+
function onRootFocusout(event: FocusEvent) {
|
|
174
|
+
const next = event.relatedTarget as Node | null;
|
|
175
|
+
if (takeoverOpen.value || (next && mobileRootEl.value?.contains(next))) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
emit('blur');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Reka's trigger opens on click, which a tap and a screen reader's activation
|
|
182
|
+
// both produce; a button's Enter and Space do nothing on an input, so the
|
|
183
|
+
// combobox's own opening keys are bound on the element
|
|
184
|
+
function openTakeover() {
|
|
185
|
+
if (!props.disabled) {
|
|
186
|
+
takeoverOpen.value = true;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// every close path — the Close button (DialogClose), Escape, and the combobox's
|
|
191
|
+
// own close on select or Enter — routes through closeTakeover so they all animate
|
|
192
|
+
function onTakeoverOpenChange(value: boolean) {
|
|
193
|
+
if (value) {
|
|
194
|
+
takeoverOpen.value = true;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// an automatic selection commits as the takeover closes, and closes it
|
|
198
|
+
// itself with the chosen text, so the exit animation reads the new value
|
|
199
|
+
if (!combobox.commitAutoSelection()) {
|
|
200
|
+
void closeTakeover();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
</script>
|
|
204
|
+
|
|
205
|
+
<template>
|
|
206
|
+
<div
|
|
207
|
+
ref="mobileRootEl"
|
|
208
|
+
@focusout="onRootFocusout">
|
|
209
|
+
<es-autocomplete-label
|
|
210
|
+
:html-for="triggerId"
|
|
211
|
+
:label="label"
|
|
212
|
+
:label-sr-only="labelSrOnly"
|
|
213
|
+
:required="required" />
|
|
214
|
+
<dialog-root
|
|
215
|
+
:open="takeoverOpen"
|
|
216
|
+
@update:open="onTakeoverOpenChange">
|
|
217
|
+
<!-- the resting field, which opens the takeover with the real input
|
|
218
|
+
focused. a readonly input rather than a button, so it is a form
|
|
219
|
+
field: the label names it, its value stays a value, and
|
|
220
|
+
aria-required/aria-invalid apply, none of which a button carries.
|
|
221
|
+
readonly is what keeps the on-screen keyboard from opening here
|
|
222
|
+
and again in the takeover -->
|
|
223
|
+
<!-- role="combobox" with a dialog popup is the APG date-picker shape,
|
|
224
|
+
and Reka supplies aria-haspopup, aria-expanded, aria-controls and
|
|
225
|
+
the click. aria-readonly="false" says what the HTML attribute
|
|
226
|
+
cannot, that the user does change this value; iOS VoiceOver
|
|
227
|
+
announces "read only" anyway, then "double tap to edit" -->
|
|
228
|
+
<!-- autocomplete stays 'off' whatever the consumer asked for:
|
|
229
|
+
browsers skip readonly fields, and the token belongs on the
|
|
230
|
+
input that is really filled in -->
|
|
231
|
+
<dialog-trigger
|
|
232
|
+
:id="triggerId"
|
|
233
|
+
as="input"
|
|
234
|
+
autocomplete="off"
|
|
235
|
+
class="es-autocomplete-trigger es-form-input form-control px-100 w-100"
|
|
236
|
+
readonly
|
|
237
|
+
role="combobox"
|
|
238
|
+
type="text"
|
|
239
|
+
aria-readonly="false"
|
|
240
|
+
:aria-describedby="triggerDescribedBy"
|
|
241
|
+
:aria-invalid="state === false ? true : undefined"
|
|
242
|
+
:aria-required="required ? true : undefined"
|
|
243
|
+
:class="{ 'is-invalid': state === false }"
|
|
244
|
+
:disabled="disabled"
|
|
245
|
+
:placeholder="placeholder"
|
|
246
|
+
:value="model"
|
|
247
|
+
@keydown.down.prevent="openTakeover"
|
|
248
|
+
@keydown.enter.prevent="openTakeover"
|
|
249
|
+
@keydown.space.prevent="openTakeover" />
|
|
250
|
+
<dialog-portal>
|
|
251
|
+
<!-- invisible behind the opaque takeover, but load-bearing: Reka's body
|
|
252
|
+
scroll lock lives in the overlay — including the iOS touchmove
|
|
253
|
+
preventer, without which the page beneath still pans on iOS Safari
|
|
254
|
+
(overflow: hidden on body does not stop touch scrolling there) -->
|
|
255
|
+
<dialog-overlay class="es-autocomplete-takeover-overlay" />
|
|
256
|
+
<!-- aria-describedby="" (an empty idref list) overrides Reka's dialog-level
|
|
257
|
+
description binding: iOS VoiceOver re-announces a dialog's description
|
|
258
|
+
after EVERY element focused inside it, and the guidance belongs to the
|
|
259
|
+
input alone, which carries the same text via its own aria-describedby -->
|
|
260
|
+
<dialog-content
|
|
261
|
+
aria-describedby=""
|
|
262
|
+
class="es-autocomplete-takeover bg-white d-flex flex-column"
|
|
263
|
+
@open-auto-focus="onOpenAutoFocus">
|
|
264
|
+
<dialog-title class="sr-only">
|
|
265
|
+
{{ label }}
|
|
266
|
+
</dialog-title>
|
|
267
|
+
<!-- a wrapping flex row with CSS order: the DOM runs field → list →
|
|
268
|
+
Close so a screen reader's linear navigation reaches the
|
|
269
|
+
suggestions right after the field, while visually the Close
|
|
270
|
+
button stays beside the field and the list fills the line below -->
|
|
271
|
+
<div class="align-content-start d-flex flex-grow-1 flex-wrap">
|
|
272
|
+
<div class="es-autocomplete-takeover-field order-1 flex-grow-1 py-100 pl-100">
|
|
273
|
+
<es-autocomplete-field
|
|
274
|
+
ref="fieldRef"
|
|
275
|
+
:aria-label="label"
|
|
276
|
+
:autocomplete="autocomplete"
|
|
277
|
+
:clear-text="clearText"
|
|
278
|
+
:combobox="combobox"
|
|
279
|
+
:described-by="describedBy"
|
|
280
|
+
:listbox-open="listboxOpen"
|
|
281
|
+
:model="model"
|
|
282
|
+
:placeholder="takeoverPlaceholder"
|
|
283
|
+
:required="required"
|
|
284
|
+
:state="state" />
|
|
285
|
+
</div>
|
|
286
|
+
<div
|
|
287
|
+
:id="combobox.listboxId"
|
|
288
|
+
ref="listEl"
|
|
289
|
+
:aria-hidden="visibleSuggestions.length === 0 ? 'true' : undefined"
|
|
290
|
+
:aria-label="label"
|
|
291
|
+
role="listbox"
|
|
292
|
+
:class="[
|
|
293
|
+
'es-autocomplete-takeover-list order-3 text-left w-100',
|
|
294
|
+
{ 'es-autocomplete-takeover-list--measuring': !measured },
|
|
295
|
+
]"
|
|
296
|
+
@mousedown="combobox.onListMousedown">
|
|
297
|
+
<es-autocomplete-item
|
|
298
|
+
v-for="(suggestion, index) in visibleSuggestions"
|
|
299
|
+
:key="suggestion.id"
|
|
300
|
+
:highlighted="combobox.selectedIndex.value === index"
|
|
301
|
+
:keyboard-nav="combobox.keyboardNav.value"
|
|
302
|
+
:option-id="combobox.optionId(index)"
|
|
303
|
+
:query="model"
|
|
304
|
+
:suggestion="suggestion"
|
|
305
|
+
@pointermove="combobox.onOptionPointermove(index)"
|
|
306
|
+
@select="combobox.onOptionClick(index)">
|
|
307
|
+
<template
|
|
308
|
+
v-if="$slots.item"
|
|
309
|
+
#default="slotProps">
|
|
310
|
+
<slot
|
|
311
|
+
name="item"
|
|
312
|
+
v-bind="slotProps" />
|
|
313
|
+
</template>
|
|
314
|
+
</es-autocomplete-item>
|
|
315
|
+
<!-- presentation only: screen readers get the same guidance
|
|
316
|
+
from the input's description and the live region -->
|
|
317
|
+
<div
|
|
318
|
+
v-if="panelMessage"
|
|
319
|
+
aria-hidden="true"
|
|
320
|
+
class="es-autocomplete-no-results px-100 py-50 text-gray-700">
|
|
321
|
+
{{ panelMessage }}
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
<!-- 'Close', not 'Cancel': dismissing keeps whatever is in the
|
|
325
|
+
input — the takeover is just a full-screen way of editing it.
|
|
326
|
+
In the DOM it follows the list (a screen reader reaches the
|
|
327
|
+
suggestions before it); the order class places it beside the
|
|
328
|
+
field visually. -->
|
|
329
|
+
<dialog-close
|
|
330
|
+
class="es-autocomplete-close align-self-center bg-transparent border-0 order-2 px-100">
|
|
331
|
+
{{ closeText }}
|
|
332
|
+
</dialog-close>
|
|
333
|
+
<div
|
|
334
|
+
class="sr-only"
|
|
335
|
+
role="status">
|
|
336
|
+
{{ announcement }}
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
</dialog-content>
|
|
340
|
+
</dialog-portal>
|
|
341
|
+
</dialog-root>
|
|
342
|
+
</div>
|
|
343
|
+
</template>
|
|
344
|
+
|
|
345
|
+
<style lang="scss" scoped>
|
|
346
|
+
@use '@energysage/es-ds-styles/scss/variables' as variables;
|
|
347
|
+
|
|
348
|
+
.es-autocomplete-trigger {
|
|
349
|
+
/* an input's intrinsic width ignores its value, so a long one cannot widen a
|
|
350
|
+
* content-sized ancestor — but that width is also a flex item's automatic
|
|
351
|
+
* minimum, which would stop the field shrinking into a narrow column */
|
|
352
|
+
min-width: 0;
|
|
353
|
+
|
|
354
|
+
&::placeholder {
|
|
355
|
+
color: variables.$input-color-placeholder;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/* the disabled colors es-autocomplete-field sets by hand, matched here: this
|
|
360
|
+
* trigger carries form-control itself, so es-ds-styles paints it $gray-500
|
|
361
|
+
* (2.93:1), and !important because that rule is written that way */
|
|
362
|
+
input.es-autocomplete-trigger:disabled {
|
|
363
|
+
color: variables.$gray-600 !important;
|
|
364
|
+
|
|
365
|
+
&::placeholder {
|
|
366
|
+
color: variables.$gray-600;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/* the base field look, re-asserted: es-ds-styles paints [readonly] like
|
|
371
|
+
* :disabled, but here it only keeps the keyboard away. a disabled field is
|
|
372
|
+
* excluded, and .is-invalid re-states its border color to keep it. */
|
|
373
|
+
input.es-autocomplete-trigger:not(:disabled) {
|
|
374
|
+
background-color: variables.$input-bg;
|
|
375
|
+
border: variables.$input-border-width solid variables.$input-border-color;
|
|
376
|
+
color: variables.$input-color !important;
|
|
377
|
+
/* it opens the takeover, so it takes a pointer rather than a text caret */
|
|
378
|
+
cursor: pointer;
|
|
379
|
+
|
|
380
|
+
&.is-invalid {
|
|
381
|
+
border-color: variables.$form-feedback-invalid-color;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* transparent: it exists for the scroll lock, and any tint would show through
|
|
386
|
+
* the takeover's cross-fade. DOM order puts it under the takeover. */
|
|
387
|
+
.es-autocomplete-takeover-overlay {
|
|
388
|
+
inset: 0;
|
|
389
|
+
position: fixed;
|
|
390
|
+
z-index: 1050;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
.es-autocomplete-takeover {
|
|
394
|
+
height: 100dvh;
|
|
395
|
+
inset: 0;
|
|
396
|
+
position: fixed;
|
|
397
|
+
/* above the page and any menu bar (1000); same layer as modals */
|
|
398
|
+
z-index: 1050;
|
|
399
|
+
|
|
400
|
+
/* deliberately no focus styling on the field: the full-screen takeover itself
|
|
401
|
+
* is the focus indicator (see es-autocomplete-desktop.vue for the rationale) */
|
|
402
|
+
|
|
403
|
+
/* ≥16px or iOS Safari auto-zooms on focus */
|
|
404
|
+
:deep(.es-autocomplete-input) {
|
|
405
|
+
font-size: 1rem;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/* the takeover carries no validation: the message stays on the page behind it,
|
|
409
|
+
* so the invalid look would be unexplained here — and the padding Bootstrap
|
|
410
|
+
* reserves for the icon widens the field's minimum, pushing Close off screen */
|
|
411
|
+
:deep(.es-autocomplete-field.is-invalid) {
|
|
412
|
+
background-image: none;
|
|
413
|
+
border-color: variables.$input-border-color;
|
|
414
|
+
/* es-ds-styles reserves the icon's room with !important, so taking the
|
|
415
|
+
* room back needs the same weight */
|
|
416
|
+
padding-right: 0 !important; // stylelint-disable-line declaration-no-important
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.es-autocomplete-close {
|
|
421
|
+
color: variables.$blue-600;
|
|
422
|
+
font-weight: variables.$font-weight-semibold;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/*
|
|
426
|
+
* updates the flex item's min width from content width to zero so it
|
|
427
|
+
* doesn't push the Close button off the screen when content is long
|
|
428
|
+
*/
|
|
429
|
+
.es-autocomplete-takeover-field {
|
|
430
|
+
min-width: 0;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.es-autocomplete-takeover-list {
|
|
434
|
+
/* no scrolling by design: the row trim only renders items that fit */
|
|
435
|
+
overflow: hidden;
|
|
436
|
+
position: relative;
|
|
437
|
+
|
|
438
|
+
&--measuring {
|
|
439
|
+
visibility: hidden;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/* ≥16px text for readability; the ≥48px row height comes from the shared
|
|
443
|
+
* es-autocomplete-item styles */
|
|
444
|
+
.es-autocomplete-item {
|
|
445
|
+
font-size: 1rem;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.es-autocomplete-no-results {
|
|
449
|
+
font-size: 1rem;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
</style>
|