@madgex/design-system-ce 5.6.1 → 5.6.3
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/components/combobox/Combobox.ce.vue +271 -268
- package/components/combobox/ComboboxAriaLive.vue +40 -0
- package/components/combobox/ComboboxClear.vue +5 -5
- package/components/combobox/ListBox.vue +10 -15
- package/components/combobox/ListBoxOption.vue +26 -37
- package/dist/custom-elements/mds-combobox.js +1 -1
- package/dist/custom-elements/mds-text-editor.js +20 -20
- package/dist/index.js +1 -1
- package/dist/manifest.json +17 -13
- package/dist/runtime-dom.esm-bundler.js +18 -0
- package/package.json +17 -12
- package/vite.config.js +1 -1
- package/components/combobox/Combobox.ce.spec.js +0 -76
- package/dist/plugin-vue_export-helper.js +0 -18
- package/eslint.config.js +0 -6
|
@@ -1,18 +1,19 @@
|
|
|
1
|
+
<!-- eslint-disable vue/multi-word-component-names -->
|
|
1
2
|
<template>
|
|
2
3
|
<div
|
|
3
4
|
class="mds-combobox"
|
|
4
|
-
:class="{ 'mds-combobox--active':
|
|
5
|
-
@keydown.down="
|
|
6
|
-
@keydown.up="
|
|
7
|
-
@keydown.home="
|
|
8
|
-
@keydown.end="
|
|
9
|
-
@keydown.esc="
|
|
5
|
+
:class="{ 'mds-combobox--active': expanded }"
|
|
6
|
+
@keydown.down="handleKeyDown"
|
|
7
|
+
@keydown.up="handleKeyUp"
|
|
8
|
+
@keydown.home="handleKeyHome"
|
|
9
|
+
@keydown.end="handleKeyEnd"
|
|
10
|
+
@keydown.esc="handleKeyEsc"
|
|
10
11
|
@keydown.enter="handleKeyDownEnter"
|
|
11
12
|
>
|
|
12
13
|
<input
|
|
13
14
|
:id="comboboxid"
|
|
14
|
-
ref="comboInput"
|
|
15
|
-
:value="
|
|
15
|
+
ref="$comboInput"
|
|
16
|
+
:value="searchValue"
|
|
16
17
|
class="mds-form-control"
|
|
17
18
|
autocomplete="off"
|
|
18
19
|
type="text"
|
|
@@ -23,291 +24,293 @@
|
|
|
23
24
|
:aria-expanded="ariaExpanded"
|
|
24
25
|
aria-autocomplete="list"
|
|
25
26
|
:aria-describedby="describedbyId"
|
|
26
|
-
:aria-activedescendant="
|
|
27
|
+
:aria-activedescendant="getOptionIdByIndex(selectedIndex)"
|
|
27
28
|
:aria-invalid="ariaInvalid"
|
|
28
29
|
@input="handleInput"
|
|
29
30
|
@change="handleChange"
|
|
30
|
-
@blur="
|
|
31
|
+
@blur="handleBlur"
|
|
31
32
|
@focus="handleFocus"
|
|
32
33
|
/>
|
|
33
|
-
|
|
34
34
|
<ComboboxClear v-if="searchValue.length > 0" @clear="handleClear" />
|
|
35
|
-
<ListBox :id="listBoxId" :hidden="
|
|
35
|
+
<ListBox :id="listBoxId" :hidden="!expanded" :aria-labelledby="`${comboboxid}-label`" :is-loading="isLoading">
|
|
36
36
|
<ListBoxOption
|
|
37
37
|
v-for="(option, index) in visibleOptions"
|
|
38
|
-
:id="
|
|
38
|
+
:id="getOptionIdByIndex(index)"
|
|
39
39
|
:key="index"
|
|
40
|
-
:option="option"
|
|
41
|
-
:focused="
|
|
40
|
+
:option-label="getOptionLabel(option)"
|
|
41
|
+
:focused="selectedIndex === index"
|
|
42
42
|
:search-value="searchValue"
|
|
43
|
-
@mousedown="
|
|
43
|
+
@mousedown="handleMouseDownOption(option)"
|
|
44
44
|
/>
|
|
45
45
|
</ListBox>
|
|
46
|
-
<
|
|
46
|
+
<ComboboxAriaLive
|
|
47
|
+
:visible-options="visibleOptions"
|
|
48
|
+
:expanded="expanded"
|
|
49
|
+
:results-message="i18nText.resultsMessage"
|
|
50
|
+
:results-message_plural="i18nText.resultsMessage_plural"
|
|
51
|
+
/>
|
|
52
|
+
<!-- No default <slot/> used, so fallback child content is destroyed on mount -->
|
|
53
|
+
<!-- target-inputs <slot/> so we can easily find inputs to populate with option selection -->
|
|
54
|
+
<span ref="$targetInputs"><slot name="target-inputs"></slot></span>
|
|
47
55
|
</div>
|
|
48
56
|
</template>
|
|
49
57
|
|
|
50
|
-
<script>
|
|
58
|
+
<script setup>
|
|
59
|
+
import { computed, provide, ref, useTemplateRef } from 'vue';
|
|
60
|
+
import safeGet from 'just-safe-get';
|
|
61
|
+
import debounce from 'just-debounce-it';
|
|
62
|
+
import Bourne from '@hapi/bourne';
|
|
51
63
|
import ComboboxClear from './ComboboxClear.vue';
|
|
52
64
|
import ListBox from './ListBox.vue';
|
|
53
65
|
import ListBoxOption from './ListBoxOption.vue';
|
|
66
|
+
import ComboboxAriaLive from './ComboboxAriaLive.vue';
|
|
67
|
+
|
|
68
|
+
/*
|
|
69
|
+
* as this is a Web Component, all props are string-ish types, hence why `options` is JSON parsed, see `parsedPropOptions`.
|
|
70
|
+
* https://vuejs.org/guide/extras/web-components.html#props
|
|
71
|
+
*/
|
|
72
|
+
const props = defineProps({
|
|
73
|
+
comboboxid: { type: String, required: true },
|
|
74
|
+
placeholder: { type: String, default: '' },
|
|
75
|
+
name: { type: [String, Boolean], default: false },
|
|
76
|
+
value: { type: String, default: '' },
|
|
77
|
+
options: { type: String, default: '[]' },
|
|
78
|
+
iconpath: { type: String, default: '/assets/icons.svg' },
|
|
79
|
+
dataAriaInvalid: { type: String, default: '' },
|
|
80
|
+
i18n: { type: String, default: '' },
|
|
81
|
+
describedbyId: { type: String, default: '' },
|
|
82
|
+
minSearchCharacters: { type: Number, default: 2 },
|
|
83
|
+
/** the api endpoint to fetch options on search input */
|
|
84
|
+
apiUrl: { type: String, default: undefined },
|
|
85
|
+
/** the query key name for the api endpoint */
|
|
86
|
+
apiQueryKey: { type: String, default: 'searchText' },
|
|
87
|
+
/** where to grab an array of options on api response, e.g. `data.options` would be an array of options, empty to use api response as array */
|
|
88
|
+
apiOptionsPath: { type: String, default: undefined },
|
|
89
|
+
/** where to grab the visual label from the option object e.g. 'label' or 'title' or 'nested.object.label' */
|
|
90
|
+
optionLabelPath: { type: String, default: 'label' },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const $comboInput = useTemplateRef('$comboInput');
|
|
94
|
+
const $targetInputs = useTemplateRef('$targetInputs');
|
|
95
|
+
|
|
96
|
+
const expanded = ref(false);
|
|
97
|
+
/** `selectedIndex` aka "highlighted option", set by using keyboard controls */
|
|
98
|
+
const selectedIndex = ref(null);
|
|
99
|
+
const searchValue = ref(props.value);
|
|
100
|
+
const isLoading = ref(false);
|
|
101
|
+
/** used if apiUrl is set, otherwise `parsedPropOptions` is used */
|
|
102
|
+
const apiOptions = ref([]);
|
|
103
|
+
|
|
104
|
+
// as props must be string-ish types, we parse the `options` into a real array
|
|
105
|
+
const parsedPropOptions = computed(() => {
|
|
106
|
+
try {
|
|
107
|
+
return Bourne.parse(props.options);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
console.error(e);
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const i18nText = computed(() => {
|
|
115
|
+
return props.i18n
|
|
116
|
+
? JSON.parse(props.i18n)
|
|
117
|
+
: {
|
|
118
|
+
loadingText: 'Loading',
|
|
119
|
+
resultsMessage: '{count} result available',
|
|
120
|
+
resultsMessage_plural: '{count} results available',
|
|
121
|
+
clearInput: 'clear input',
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const visibleOptions = computed(() => {
|
|
126
|
+
if (!props.apiUrl) {
|
|
127
|
+
return parsedPropOptions.value.filter((opt) =>
|
|
128
|
+
getOptionLabel(opt).toLowerCase().includes(searchValue.value.toLowerCase()),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return apiOptions.value;
|
|
133
|
+
});
|
|
134
|
+
const listBoxId = computed(() => {
|
|
135
|
+
return `${props.comboboxid}-listbox`;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/** generate an DOM `id` for a option, based on index number */
|
|
139
|
+
function getOptionIdByIndex(index) {
|
|
140
|
+
if (typeof index === 'number' && index > -1) {
|
|
141
|
+
return `${props.comboboxid}-option-${index}`;
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
54
145
|
|
|
55
|
-
|
|
56
|
-
name
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
ListBox,
|
|
60
|
-
ListBoxOption,
|
|
61
|
-
},
|
|
62
|
-
provide() {
|
|
63
|
-
return {
|
|
64
|
-
iconPath: this.iconpath,
|
|
65
|
-
loadingText: this.i18nText.loadingText,
|
|
66
|
-
clearInput: this.i18nText.clearInput,
|
|
67
|
-
};
|
|
68
|
-
},
|
|
69
|
-
props: {
|
|
70
|
-
comboboxid: {
|
|
71
|
-
type: String,
|
|
72
|
-
required: true,
|
|
73
|
-
},
|
|
74
|
-
placeholder: {
|
|
75
|
-
type: String,
|
|
76
|
-
default: '',
|
|
77
|
-
},
|
|
78
|
-
name: {
|
|
79
|
-
type: [String, Boolean],
|
|
80
|
-
default: false,
|
|
81
|
-
},
|
|
82
|
-
value: {
|
|
83
|
-
type: String,
|
|
84
|
-
default: '',
|
|
85
|
-
},
|
|
86
|
-
options: {
|
|
87
|
-
type: Array,
|
|
88
|
-
default: () => [],
|
|
89
|
-
},
|
|
90
|
-
filterOptions: {
|
|
91
|
-
type: Boolean,
|
|
92
|
-
default: true,
|
|
93
|
-
},
|
|
94
|
-
iconpath: {
|
|
95
|
-
type: String,
|
|
96
|
-
default: '/assets/icons.svg',
|
|
97
|
-
},
|
|
98
|
-
dataAriaInvalid: {
|
|
99
|
-
type: String,
|
|
100
|
-
default: '',
|
|
101
|
-
},
|
|
102
|
-
i18n: {
|
|
103
|
-
type: String,
|
|
104
|
-
default: '',
|
|
105
|
-
},
|
|
106
|
-
describedbyId: {
|
|
107
|
-
type: String,
|
|
108
|
-
default: ''
|
|
109
|
-
},
|
|
110
|
-
minSearchCharacters: {
|
|
111
|
-
type: Number,
|
|
112
|
-
default: 2
|
|
113
|
-
}
|
|
114
|
-
},
|
|
115
|
-
emits: ['search', 'select-option', 'clear-all'],
|
|
116
|
-
data() {
|
|
117
|
-
return {
|
|
118
|
-
expanded: false,
|
|
119
|
-
selected: null,
|
|
120
|
-
chosen: null,
|
|
121
|
-
searchValue: this.$props.value,
|
|
122
|
-
resultCountMessage: null,
|
|
123
|
-
};
|
|
124
|
-
},
|
|
125
|
-
computed: {
|
|
126
|
-
inputValue() {
|
|
127
|
-
if (this.chosenOption) {
|
|
128
|
-
return this.chosenOption.label;
|
|
129
|
-
}
|
|
146
|
+
const ariaExpanded = computed(() => {
|
|
147
|
+
// These must be strings to apply as an aria attribute of the same name
|
|
148
|
+
return expanded.value ? 'true' : 'false';
|
|
149
|
+
});
|
|
130
150
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
get() {
|
|
135
|
-
return this.selected;
|
|
136
|
-
},
|
|
137
|
-
set(newOption) {
|
|
138
|
-
this.selected = newOption;
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
chosenOption: {
|
|
142
|
-
get() {
|
|
143
|
-
return this.chosen;
|
|
144
|
-
},
|
|
145
|
-
set(newOption) {
|
|
146
|
-
this.chosen = newOption;
|
|
147
|
-
this.selectedOption = newOption;
|
|
148
|
-
this.$emit('select-option', this.chosen);
|
|
149
|
-
},
|
|
150
|
-
},
|
|
151
|
-
visibleOptions() {
|
|
152
|
-
if (this.filterOptions) {
|
|
153
|
-
return this.options.filter((opt) => opt.label.toLowerCase().includes(this.searchValue.toLowerCase()));
|
|
154
|
-
}
|
|
151
|
+
const ariaInvalid = computed(() => {
|
|
152
|
+
return props.dataAriaInvalid ? 'true' : 'false';
|
|
153
|
+
});
|
|
155
154
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
155
|
+
/**
|
|
156
|
+
* When user chooses an option:
|
|
157
|
+
* - set search input to chosen option's label
|
|
158
|
+
* - set any hidden target input values based on option
|
|
159
|
+
* - close list menu
|
|
160
|
+
* - reset selectedIndex
|
|
161
|
+
* @param newOption
|
|
162
|
+
*/
|
|
163
|
+
function chooseOption(newOption) {
|
|
164
|
+
searchValue.value = getOptionLabel(newOption);
|
|
165
|
+
setTargetValues(newOption);
|
|
166
|
+
makeInactive();
|
|
167
|
+
selectedIndex.value = null;
|
|
168
|
+
}
|
|
169
169
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
/**
|
|
171
|
+
* Update target inputs with value from an option.
|
|
172
|
+
* If option is not supplied, target input values will be cleared
|
|
173
|
+
* @param {object?} option
|
|
174
|
+
*/
|
|
175
|
+
function setTargetValues(option) {
|
|
176
|
+
const targetInputs = Array.from($targetInputs.value?.querySelectorAll('[data-key]'));
|
|
177
|
+
for (const el of targetInputs) {
|
|
178
|
+
// if no option, clear target value
|
|
179
|
+
el.value = option ? safeGet(option, el.getAttribute('data-key')) : '';
|
|
180
|
+
// ensure external code like htmx reacts to the new value
|
|
181
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function makeActive() {
|
|
185
|
+
expanded.value = true;
|
|
186
|
+
}
|
|
187
|
+
function makeInactive() {
|
|
188
|
+
expanded.value = false;
|
|
189
|
+
}
|
|
190
|
+
function clearField() {
|
|
191
|
+
searchValue.value = '';
|
|
192
|
+
setTargetValues();
|
|
193
|
+
}
|
|
173
194
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return this.dataAriaInvalid ? 'true' : 'false';
|
|
188
|
-
},
|
|
189
|
-
i18nText() {
|
|
190
|
-
return this.i18n
|
|
191
|
-
? JSON.parse(this.i18n)
|
|
192
|
-
: {
|
|
193
|
-
loadingText: 'Loading',
|
|
194
|
-
resultsMessage: '{count} result available',
|
|
195
|
-
resultsMessage_plural: '{count} results available',
|
|
196
|
-
clearInput: 'clear input',
|
|
197
|
-
};
|
|
198
|
-
},
|
|
199
|
-
},
|
|
200
|
-
mounted() {
|
|
201
|
-
// TODO: Get rid of this code which couples this to the nunjucks MdsCombobox template!
|
|
202
|
-
const fallbackInput = this.$el.parentElement?.parentElement?.querySelector('.mds-form-element__fallback input');
|
|
203
|
-
const fallbackSelect = this.$el.parentElement?.parentElement?.querySelector('.mds-form-element__fallback select');
|
|
195
|
+
/**
|
|
196
|
+
* if props.apiUrl is set, we fetch options from the API.
|
|
197
|
+
* `apiOptions` should always be populated with an array of objects.
|
|
198
|
+
*/
|
|
199
|
+
const debouncedFetchApiOptions = debounce(async function fetchApiOptions() {
|
|
200
|
+
if (!props.apiUrl) return;
|
|
201
|
+
if (isLoading.value) return; // prevent overlapping fetch
|
|
202
|
+
const searchQuery = searchValue?.value?.trim();
|
|
203
|
+
try {
|
|
204
|
+
isLoading.value = true;
|
|
205
|
+
let res = await fetch(`${props.apiUrl}?${props.apiQueryKey}=${encodeURIComponent(searchQuery)}`);
|
|
206
|
+
if (!res.ok) return;
|
|
207
|
+
res = await res.json();
|
|
204
208
|
|
|
205
|
-
|
|
206
|
-
if
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
this.expanded = false;
|
|
214
|
-
},
|
|
215
|
-
handleInput(event) {
|
|
216
|
-
// Reset any chosen option if user is typing again
|
|
217
|
-
this.chosenOption = null;
|
|
218
|
-
this.searchValue = event.target ? event.target.value : '';
|
|
219
|
-
this.handleChange();
|
|
220
|
-
this.$emit('search', this.searchValue);
|
|
221
|
-
if (this.visibleOptions.length > 0) {
|
|
222
|
-
this.updateCount();
|
|
223
|
-
}
|
|
224
|
-
},
|
|
225
|
-
handleChange() {
|
|
226
|
-
if (this.searchValue.length === 0) this.clearField();
|
|
227
|
-
if (this.searchValue.length >= this.minSearchCharacters) {
|
|
228
|
-
this.makeActive();
|
|
229
|
-
this.updateCount();
|
|
230
|
-
} else {
|
|
231
|
-
this.makeInactive();
|
|
232
|
-
}
|
|
233
|
-
},
|
|
234
|
-
handleFocus() {
|
|
235
|
-
this.handleChange();
|
|
236
|
-
if (this.visibleOptions.length > 1) {
|
|
237
|
-
this.updateCount();
|
|
238
|
-
}
|
|
239
|
-
},
|
|
240
|
-
handleClear() {
|
|
241
|
-
this.clearField();
|
|
242
|
-
this.$refs.comboInput.focus();
|
|
243
|
-
},
|
|
244
|
-
clearField() {
|
|
245
|
-
this.searchValue = '';
|
|
246
|
-
this.chosenOption = null;
|
|
247
|
-
this.$emit('clear-all');
|
|
248
|
-
},
|
|
249
|
-
clickOption(option = this.selectedOption) {
|
|
250
|
-
this.chosenOption = option;
|
|
251
|
-
this.makeInactive();
|
|
252
|
-
},
|
|
253
|
-
/* When expanded then enter key down selects an option, otherwise enter key will be native behaviour, eg submit form */
|
|
254
|
-
handleKeyDownEnter(event) {
|
|
255
|
-
if (this.expanded) {
|
|
256
|
-
event.preventDefault();
|
|
257
|
-
this.chooseOption();
|
|
258
|
-
}
|
|
259
|
-
},
|
|
260
|
-
chooseOption() {
|
|
261
|
-
this.chosenOption = this.selectedOption;
|
|
262
|
-
this.makeInactive();
|
|
263
|
-
this.clearCount();
|
|
264
|
-
},
|
|
265
|
-
hiddenGuard(fn) {
|
|
266
|
-
if (this.listBoxHidden) return;
|
|
267
|
-
fn.call(this);
|
|
268
|
-
},
|
|
269
|
-
onInputBlur() {
|
|
270
|
-
this.makeInactive();
|
|
271
|
-
this.clearCount();
|
|
272
|
-
},
|
|
273
|
-
onKeyDown() {
|
|
274
|
-
if (this.selectedOption) {
|
|
275
|
-
const currentIndex = this.visibleOptions.findIndex((item) => item.value === this.selectedOption.value);
|
|
276
|
-
const nextIndex = currentIndex === this.lastOptionIndex ? currentIndex : currentIndex + 1;
|
|
209
|
+
//where is the array of options on the api response?
|
|
210
|
+
// default to root if props.apiOptionsPath is not set
|
|
211
|
+
const data = props.apiOptionsPath ? safeGet(res, props.apiOptionsPath) : res;
|
|
212
|
+
apiOptions.value = data || [];
|
|
213
|
+
} finally {
|
|
214
|
+
isLoading.value = false;
|
|
215
|
+
}
|
|
216
|
+
}, 200);
|
|
277
217
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
218
|
+
/**
|
|
219
|
+
* where do we grab the label from the option object?
|
|
220
|
+
* option.label or option['nested.object.label.path']
|
|
221
|
+
* @param {object} option
|
|
222
|
+
* @returns {string} label
|
|
223
|
+
*/
|
|
224
|
+
function getOptionLabel(option) {
|
|
225
|
+
const label = safeGet(option, props.optionLabelPath);
|
|
226
|
+
return String(label);
|
|
227
|
+
}
|
|
287
228
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
229
|
+
/**
|
|
230
|
+
* - reset selectedIndex
|
|
231
|
+
* - copies the existing value into `searchValue` (because we manually handle input/change events)
|
|
232
|
+
* - fetch from api if applicable
|
|
233
|
+
* @param event input event
|
|
234
|
+
*/
|
|
235
|
+
function handleInput(event) {
|
|
236
|
+
selectedIndex.value = null;
|
|
237
|
+
searchValue.value = event.target ? event.target.value : '';
|
|
238
|
+
handleChange();
|
|
239
|
+
debouncedFetchApiOptions();
|
|
240
|
+
}
|
|
241
|
+
function handleChange() {
|
|
242
|
+
if (searchValue.value.length === 0) clearField();
|
|
243
|
+
if (searchValue.value.length >= props.minSearchCharacters) {
|
|
244
|
+
makeActive();
|
|
245
|
+
} else {
|
|
246
|
+
makeInactive();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function handleFocus() {
|
|
250
|
+
handleChange();
|
|
251
|
+
}
|
|
252
|
+
function handleClear() {
|
|
253
|
+
clearField();
|
|
254
|
+
$comboInput.value?.focus();
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* As the `Enter` key is handled seperately (see `handleKeyDownEnter`),
|
|
258
|
+
* we need this handler for mouse clicks on an option
|
|
259
|
+
* @param option
|
|
260
|
+
*/
|
|
261
|
+
function handleMouseDownOption(option) {
|
|
262
|
+
chooseOption(option);
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* When expanded then enter key down selects an option,
|
|
266
|
+
* otherwise enter key will be native behaviour, eg submit form
|
|
267
|
+
* @param event
|
|
268
|
+
*/
|
|
269
|
+
function handleKeyDownEnter(event) {
|
|
270
|
+
if (expanded.value) {
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
const selectedOption = visibleOptions.value?.[selectedIndex.value];
|
|
273
|
+
chooseOption(selectedOption);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
function handleBlur() {
|
|
277
|
+
makeInactive();
|
|
278
|
+
}
|
|
279
|
+
/** move down the list, selecting an option */
|
|
280
|
+
function handleKeyDown() {
|
|
281
|
+
if (!expanded.value) return;
|
|
282
|
+
if (selectedIndex.value !== null) {
|
|
283
|
+
selectedIndex.value = Math.min(selectedIndex.value + 1, visibleOptions.value.length - 1);
|
|
284
|
+
} else {
|
|
285
|
+
// nothing selected, start at top of the list
|
|
286
|
+
selectedIndex.value = 0;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** move up the list, selecting an option */
|
|
290
|
+
function handleKeyUp() {
|
|
291
|
+
if (!expanded.value) return;
|
|
292
|
+
if (selectedIndex.value !== null) {
|
|
293
|
+
selectedIndex.value = Math.max(selectedIndex.value - 1, 0);
|
|
294
|
+
} else {
|
|
295
|
+
// nothing selected, start at bottom of the list
|
|
296
|
+
selectedIndex.value = visibleOptions.value.length - 1;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** jump to top of list, selecting an option */
|
|
300
|
+
function handleKeyHome() {
|
|
301
|
+
if (!expanded.value) return;
|
|
302
|
+
selectedIndex.value = 0;
|
|
303
|
+
}
|
|
304
|
+
/** jump to bottom of list, selecting an option */
|
|
305
|
+
function handleKeyEnd() {
|
|
306
|
+
if (!expanded.value) return;
|
|
307
|
+
selectedIndex.value = visibleOptions.value.length - 1;
|
|
308
|
+
}
|
|
309
|
+
function handleKeyEsc() {
|
|
310
|
+
makeInactive();
|
|
311
|
+
}
|
|
304
312
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
clearCount() {
|
|
309
|
-
this.resultCountMessage = null;
|
|
310
|
-
},
|
|
311
|
-
},
|
|
312
|
-
};
|
|
313
|
+
provide('iconPath', props.iconpath);
|
|
314
|
+
provide('loadingText', i18nText.value.loadingText);
|
|
315
|
+
provide('clearInput', i18nText.value.clearInput);
|
|
313
316
|
</script>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div role="status" class="mds-visually-hidden">
|
|
3
|
+
{{ resultCountMessage }}
|
|
4
|
+
</div>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup>
|
|
8
|
+
import { ref, watch } from 'vue';
|
|
9
|
+
import debounce from 'just-debounce-it';
|
|
10
|
+
|
|
11
|
+
const props = defineProps({
|
|
12
|
+
visibleOptions: { type: Array, default: () => [] },
|
|
13
|
+
expanded: { type: Boolean, default: false },
|
|
14
|
+
resultsMessage: { type: String },
|
|
15
|
+
resultsMessage_plural: { type: String },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
watch(
|
|
19
|
+
[() => props.expanded, () => props.visibleOptions],
|
|
20
|
+
() => {
|
|
21
|
+
updateResultCount();
|
|
22
|
+
},
|
|
23
|
+
{ deep: true },
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const resultCountMessage = ref(null);
|
|
27
|
+
|
|
28
|
+
/** debounced so there is a delay, so screen readers react properly to updates */
|
|
29
|
+
const debounceUpdateResultCountMessage = debounce(function updateResultCountMessage() {
|
|
30
|
+
const messageTemplate = props.visibleOptions.length === 1 ? props.resultsMessage : props.resultsMessage_plural;
|
|
31
|
+
resultCountMessage.value = messageTemplate.replace('{count}', props.visibleOptions.length);
|
|
32
|
+
}, 1400);
|
|
33
|
+
/** immediately clear message, then proceed to update message. This process get the best screen reader results */
|
|
34
|
+
function updateResultCount() {
|
|
35
|
+
resultCountMessage.value = null;
|
|
36
|
+
if (props.expanded) {
|
|
37
|
+
debounceUpdateResultCountMessage();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
</button>
|
|
14
14
|
</template>
|
|
15
15
|
|
|
16
|
-
<script>
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
<script setup>
|
|
17
|
+
import { inject } from 'vue';
|
|
18
|
+
|
|
19
|
+
const iconPath = inject('iconPath');
|
|
20
|
+
const clearInput = inject('clearInput');
|
|
21
21
|
</script>
|
|
@@ -12,19 +12,14 @@
|
|
|
12
12
|
</ul>
|
|
13
13
|
</template>
|
|
14
14
|
|
|
15
|
-
<script>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
default: true,
|
|
26
|
-
},
|
|
27
|
-
},
|
|
28
|
-
inject: ['iconPath', 'loadingText'],
|
|
29
|
-
};
|
|
15
|
+
<script setup>
|
|
16
|
+
import { inject } from 'vue';
|
|
17
|
+
|
|
18
|
+
defineProps({
|
|
19
|
+
hidden: { type: Boolean, default: true },
|
|
20
|
+
isLoading: { type: Boolean, default: true },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const iconPath = inject('iconPath');
|
|
24
|
+
const loadingText = inject('loadingText');
|
|
30
25
|
</script>
|