@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.
@@ -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': !listBoxHidden }"
5
- @keydown.down="hiddenGuard(onKeyDown)"
6
- @keydown.up="hiddenGuard(onKeyUp)"
7
- @keydown.home="hiddenGuard(onKeyHome)"
8
- @keydown.end="hiddenGuard(onKeyEnd)"
9
- @keydown.esc="makeInactive"
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="inputValue"
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="selectedOptionId"
27
+ :aria-activedescendant="getOptionIdByIndex(selectedIndex)"
27
28
  :aria-invalid="ariaInvalid"
28
29
  @input="handleInput"
29
30
  @change="handleChange"
30
- @blur="onInputBlur"
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="listBoxHidden" :aria-labelledby="`${comboboxid}-label`" :is-loading="isLoading">
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="`${optionId}-${index}`"
38
+ :id="getOptionIdByIndex(index)"
39
39
  :key="index"
40
- :option="option"
41
- :focused="selectedOption?.value === option?.value"
40
+ :option-label="getOptionLabel(option)"
41
+ :focused="selectedIndex === index"
42
42
  :search-value="searchValue"
43
- @mousedown="clickOption(option)"
43
+ @mousedown="handleMouseDownOption(option)"
44
44
  />
45
45
  </ListBox>
46
- <div aria-live="polite" role="status" class="mds-visually-hidden">{{ resultCountMessage }}</div>
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
- export default {
56
- name: 'Combobox',
57
- components: {
58
- ComboboxClear,
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
- return this.searchValue;
132
- },
133
- selectedOption: {
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
- return this.options;
157
- },
158
- listBoxId() {
159
- return `${this.comboboxid}-listbox`;
160
- },
161
- optionId() {
162
- return `${this.comboboxid}-option`;
163
- },
164
- isLoading() {
165
- return this.options.length === 0 && this.expanded;
166
- },
167
- selectedOptionId() {
168
- const index = this.visibleOptions.findIndex((obj) => obj.value == this.selectedOption?.value);
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
- if (index > -1) {
171
- return `${this.optionId}-${index}`;
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
- return undefined;
175
- },
176
- listBoxHidden() {
177
- return !this.expanded;
178
- },
179
- lastOptionIndex() {
180
- return this.visibleOptions.length - 1;
181
- },
182
- ariaExpanded() {
183
- // These must be strings to apply as an aria attribute of the same name
184
- return this.expanded ? 'true' : 'false';
185
- },
186
- ariaInvalid() {
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
- if (fallbackInput) fallbackInput.remove();
206
- if (fallbackSelect) fallbackSelect.removeAttribute('id');
207
- },
208
- methods: {
209
- makeActive() {
210
- this.expanded = true;
211
- },
212
- makeInactive() {
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
- this.selectedOption = this.visibleOptions[nextIndex];
279
- } else {
280
- [this.selectedOption] = this.visibleOptions;
281
- }
282
- },
283
- onKeyUp() {
284
- if (this.selectedOption) {
285
- const currentIndex = this.visibleOptions.findIndex((item) => item.value === this.selectedOption.value);
286
- const nextIndex = currentIndex === 0 ? currentIndex : currentIndex - 1;
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
- this.selectedOption = this.visibleOptions[nextIndex];
289
- } else {
290
- this.selectedOption = this.visibleOptions[this.lastOptionIndex];
291
- }
292
- },
293
- onKeyHome() {
294
- [this.selectedOption] = this.visibleOptions;
295
- },
296
- onKeyEnd() {
297
- this.selectedOption = this.visibleOptions[this.lastOptionIndex];
298
- },
299
- updateCount() {
300
- this.clearCount();
301
- setTimeout(() => {
302
- const message =
303
- this.visibleOptions.length === 1 ? this.i18nText.resultsMessage : this.i18nText.resultsMessage_plural;
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
- this.resultCountMessage = message.replace('{count}', this.visibleOptions.length);
306
- }, 1400);
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
- export default {
18
- name: 'ComboboxClear',
19
- inject: ['iconPath', 'clearInput'],
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
- export default {
17
- name: 'ListBox',
18
- props: {
19
- hidden: {
20
- type: Boolean,
21
- default: true,
22
- },
23
- isLoading: {
24
- type: Boolean,
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>