@mozaic-ds/vue 2.15.0 → 2.16.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/dist/mozaic-vue.css +1 -1
- package/dist/mozaic-vue.d.ts +963 -374
- package/dist/mozaic-vue.js +7736 -3601
- package/dist/mozaic-vue.js.map +1 -1
- package/dist/mozaic-vue.umd.cjs +24 -5
- package/dist/mozaic-vue.umd.cjs.map +1 -1
- package/package.json +3 -2
- package/src/components/actionlistbox/MActionListbox.spec.ts +14 -0
- package/src/components/actionlistbox/MActionListbox.stories.ts +15 -8
- package/src/components/actionlistbox/MActionListbox.vue +13 -1
- package/src/components/actionlistbox/README.md +2 -1
- package/src/components/button/README.md +2 -0
- package/src/components/combobox/MCombobox.spec.ts +246 -0
- package/src/components/combobox/MCombobox.stories.ts +190 -0
- package/src/components/combobox/MCombobox.vue +277 -0
- package/src/components/combobox/README.md +52 -0
- package/src/components/field/MField.stories.ts +105 -0
- package/src/components/optionListbox/MOptionListbox.spec.ts +527 -0
- package/src/components/optionListbox/MOptionListbox.vue +470 -0
- package/src/components/optionListbox/README.md +63 -0
- package/src/components/stepperstacked/MStepperStacked.spec.ts +162 -0
- package/src/components/stepperstacked/MStepperStacked.stories.ts +57 -0
- package/src/components/stepperstacked/MStepperStacked.vue +106 -0
- package/src/components/stepperstacked/README.md +15 -0
- package/src/components/textinput/MTextInput.vue +13 -1
- package/src/components/textinput/README.md +15 -1
- package/src/main.ts +1 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
ref="listboxEl"
|
|
4
|
+
class="mc-option-listbox mc-listbox__content mc-combobox__listbox"
|
|
5
|
+
>
|
|
6
|
+
<template v-if="search">
|
|
7
|
+
<div class="mc-option-listbox__search">
|
|
8
|
+
<MTextInput
|
|
9
|
+
ref="textInput"
|
|
10
|
+
v-model="searchText"
|
|
11
|
+
role="combobox"
|
|
12
|
+
:id="`search-${id}`"
|
|
13
|
+
size="s"
|
|
14
|
+
:placeholder="searchPlaceholder"
|
|
15
|
+
autocomplete="off"
|
|
16
|
+
:aria-expanded="open"
|
|
17
|
+
:aria-controls="`listbox-${id}`"
|
|
18
|
+
aria-autocomplete="list"
|
|
19
|
+
:aria-activedescendant="activeDescendantId"
|
|
20
|
+
@input="updateFilteredResults"
|
|
21
|
+
@keydown="handleKeydown"
|
|
22
|
+
>
|
|
23
|
+
<template #icon>
|
|
24
|
+
<Search24 />
|
|
25
|
+
</template>
|
|
26
|
+
</MTextInput>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<hr class="mc-option-listbox__separator" />
|
|
30
|
+
</template>
|
|
31
|
+
|
|
32
|
+
<template v-if="multiple && actions">
|
|
33
|
+
<div class="mc-option-listbox__actions">
|
|
34
|
+
<MButton appearance="accent" ghost size="s" @click="selectAll">
|
|
35
|
+
{{ selectLabel }}
|
|
36
|
+
</MButton>
|
|
37
|
+
<MButton appearance="standard" ghost size="s" @click="clearSelection">
|
|
38
|
+
{{ clearLabel }}
|
|
39
|
+
</MButton>
|
|
40
|
+
</div>
|
|
41
|
+
<hr class="mc-option-listbox__separator" />
|
|
42
|
+
</template>
|
|
43
|
+
|
|
44
|
+
<ul
|
|
45
|
+
class="mc-option-listbox__list"
|
|
46
|
+
role="listbox"
|
|
47
|
+
:id="`listbox-${id}`"
|
|
48
|
+
:tabindex="-1"
|
|
49
|
+
aria-label="Suggestions"
|
|
50
|
+
:aria-multiselectable="multiple"
|
|
51
|
+
>
|
|
52
|
+
<li
|
|
53
|
+
v-for="(item, index) in filteredResults"
|
|
54
|
+
:key="index"
|
|
55
|
+
:id="`option-${id}-${index}`"
|
|
56
|
+
:class="{
|
|
57
|
+
'mc-option-listbox__item': true,
|
|
58
|
+
'mc-option-listbox__item--section': item.type === 'section',
|
|
59
|
+
'mc-option-listbox__item--readonly': readonly,
|
|
60
|
+
'mc-option-listbox__item--disabled': item.disabled,
|
|
61
|
+
'mc-option-listbox__item--selectable': isSelectable(item),
|
|
62
|
+
'mc-option-listbox__item--active': activeIndex === index,
|
|
63
|
+
'mc-option-listbox__item--selected':
|
|
64
|
+
item.type === 'section'
|
|
65
|
+
? isSectionSelected(item) || isIndeterminate(item)
|
|
66
|
+
: isOptionSelected(item),
|
|
67
|
+
}"
|
|
68
|
+
v-bind="
|
|
69
|
+
item.type === 'section' && !checkableSections
|
|
70
|
+
? {
|
|
71
|
+
role: 'presentation',
|
|
72
|
+
}
|
|
73
|
+
: {
|
|
74
|
+
role: 'option',
|
|
75
|
+
['aria-disabled']: item.disabled,
|
|
76
|
+
['aria-selected']: isOptionSelected(item),
|
|
77
|
+
onClick: () =>
|
|
78
|
+
item.type === 'section'
|
|
79
|
+
? toggleSection(item)
|
|
80
|
+
: toggleValue(item),
|
|
81
|
+
}
|
|
82
|
+
"
|
|
83
|
+
>
|
|
84
|
+
<div class="mc-option-listbox__label">
|
|
85
|
+
<slot v-if="item.type !== 'section'" name="optionPrefix" />
|
|
86
|
+
|
|
87
|
+
<div class="mc-option-listbox__content">
|
|
88
|
+
<span
|
|
89
|
+
:class="
|
|
90
|
+
item.type === 'section'
|
|
91
|
+
? 'mc-option-listbox__section-title'
|
|
92
|
+
: 'mc-option-listbox__text'
|
|
93
|
+
"
|
|
94
|
+
>
|
|
95
|
+
{{ item.label }}
|
|
96
|
+
</span>
|
|
97
|
+
|
|
98
|
+
<span v-if="item.content" class="mc-option-listbox__additional">
|
|
99
|
+
{{ item.content }}
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div class="mc-option-listbox__spacer"></div>
|
|
104
|
+
|
|
105
|
+
<template v-if="isSelectable(item)">
|
|
106
|
+
<span
|
|
107
|
+
v-if="item.type === 'section'"
|
|
108
|
+
class="mc-option-listbox__checkbox"
|
|
109
|
+
>
|
|
110
|
+
<component :is="isIndeterminate(item) ? Less20 : Check20" />
|
|
111
|
+
</span>
|
|
112
|
+
|
|
113
|
+
<template v-else>
|
|
114
|
+
<span v-if="multiple" class="mc-option-listbox__checkbox">
|
|
115
|
+
<Check20 />
|
|
116
|
+
</span>
|
|
117
|
+
|
|
118
|
+
<CheckCircleFilled24
|
|
119
|
+
v-else
|
|
120
|
+
class="mc-option-listbox__selection-icon"
|
|
121
|
+
/>
|
|
122
|
+
</template>
|
|
123
|
+
</template>
|
|
124
|
+
</div>
|
|
125
|
+
</li>
|
|
126
|
+
</ul>
|
|
127
|
+
</div>
|
|
128
|
+
</template>
|
|
129
|
+
|
|
130
|
+
<script setup lang="ts">
|
|
131
|
+
import { computed, ref, useTemplateRef, watch, type VNode } from 'vue';
|
|
132
|
+
import MButton from '../button/MButton.vue';
|
|
133
|
+
import MTextInput from '../textinput/MTextInput.vue';
|
|
134
|
+
import {
|
|
135
|
+
CheckCircleFilled24,
|
|
136
|
+
Search24,
|
|
137
|
+
Less20,
|
|
138
|
+
Check20,
|
|
139
|
+
} from '@mozaic-ds/icons-vue';
|
|
140
|
+
import { debounce } from 'lodash';
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* An Option Listbox is a customizable, accessible listbox component designed to power dropdowns and comboboxes with advanced selection capabilities. It supports single or multiple selection, optional search, grouped options with section headers, and full keyboard navigation.
|
|
144
|
+
*/
|
|
145
|
+
export type ListboxOption = {
|
|
146
|
+
label: string;
|
|
147
|
+
content?: string;
|
|
148
|
+
value?: string | number;
|
|
149
|
+
disabled?: boolean;
|
|
150
|
+
type?: 'option' | 'section';
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const props = withDefaults(
|
|
154
|
+
defineProps<{
|
|
155
|
+
/**
|
|
156
|
+
* The current selected value(s) of the listbox.
|
|
157
|
+
*/
|
|
158
|
+
modelValue: string | number | null | (string | number)[];
|
|
159
|
+
/**
|
|
160
|
+
* Unique identifier for the listbox.
|
|
161
|
+
*/
|
|
162
|
+
id: string;
|
|
163
|
+
/**
|
|
164
|
+
* Whether the listbox is open.
|
|
165
|
+
*/
|
|
166
|
+
open?: boolean;
|
|
167
|
+
/**
|
|
168
|
+
* Enable multiple selection.
|
|
169
|
+
*/
|
|
170
|
+
multiple?: boolean;
|
|
171
|
+
/**
|
|
172
|
+
* Make the listbox read-only.
|
|
173
|
+
*/
|
|
174
|
+
readonly?: boolean;
|
|
175
|
+
/**
|
|
176
|
+
* Show a search input above the options.
|
|
177
|
+
*/
|
|
178
|
+
search?: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* Show select all / clear buttons when multiple.
|
|
181
|
+
*/
|
|
182
|
+
actions?: boolean;
|
|
183
|
+
/**
|
|
184
|
+
* Enable checkable section headers.
|
|
185
|
+
*/
|
|
186
|
+
checkableSections?: boolean;
|
|
187
|
+
/**
|
|
188
|
+
* Placeholder text for the search input.
|
|
189
|
+
*/
|
|
190
|
+
searchPlaceholder?: string;
|
|
191
|
+
/**
|
|
192
|
+
* Label for the "Select all" button.
|
|
193
|
+
*/
|
|
194
|
+
selectLabel?: string;
|
|
195
|
+
/**
|
|
196
|
+
* Label for the "Clear selection" button.
|
|
197
|
+
*/
|
|
198
|
+
clearLabel?: string;
|
|
199
|
+
/**
|
|
200
|
+
* Array of options and sections to display in the listbox.
|
|
201
|
+
*/
|
|
202
|
+
options: Array<ListboxOption>;
|
|
203
|
+
}>(),
|
|
204
|
+
{
|
|
205
|
+
searchPlaceholder: 'Find an option...',
|
|
206
|
+
selectLabel: 'Select all',
|
|
207
|
+
clearLabel: 'Clear',
|
|
208
|
+
},
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const emit = defineEmits<{
|
|
212
|
+
/**
|
|
213
|
+
* Emits when the selected value changes.
|
|
214
|
+
*/
|
|
215
|
+
(
|
|
216
|
+
on: 'update:modelValue',
|
|
217
|
+
value: string | number | null | (string | number)[],
|
|
218
|
+
): void;
|
|
219
|
+
/**
|
|
220
|
+
* Emits when the listbox should open.
|
|
221
|
+
*/
|
|
222
|
+
(on: 'open'): void;
|
|
223
|
+
/**
|
|
224
|
+
* Emits when the listbox should close.
|
|
225
|
+
*/
|
|
226
|
+
(on: 'close'): void;
|
|
227
|
+
}>();
|
|
228
|
+
|
|
229
|
+
defineSlots<{
|
|
230
|
+
/**
|
|
231
|
+
* Use this slot to add a prefix to options.
|
|
232
|
+
*/
|
|
233
|
+
optionPrefix: VNode;
|
|
234
|
+
}>();
|
|
235
|
+
|
|
236
|
+
const listboxEl = useTemplateRef('listboxEl');
|
|
237
|
+
const textInput = useTemplateRef('textInput');
|
|
238
|
+
|
|
239
|
+
const activeIndex = ref<number>(-1);
|
|
240
|
+
|
|
241
|
+
const searchText = ref('');
|
|
242
|
+
|
|
243
|
+
const filteredResults = ref<ListboxOption[]>(props.options);
|
|
244
|
+
|
|
245
|
+
const selection = computed({
|
|
246
|
+
get() {
|
|
247
|
+
return props.modelValue;
|
|
248
|
+
},
|
|
249
|
+
set(value: string | number | null | (string | number)[]) {
|
|
250
|
+
emit('update:modelValue', value);
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const activeDescendantId = computed(() => {
|
|
255
|
+
return activeIndex.value >= 0
|
|
256
|
+
? `option-${props.id}-${activeIndex.value}`
|
|
257
|
+
: undefined;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const updateFilteredResults = debounce(() => {
|
|
261
|
+
const search = searchText.value.toLowerCase().trim();
|
|
262
|
+
|
|
263
|
+
if (!search) {
|
|
264
|
+
filteredResults.value = props.options;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
filteredResults.value = props.options.filter((option) =>
|
|
269
|
+
option.label.toLowerCase().includes(search),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
activeIndex.value = filteredResults.value.length ? 0 : -1;
|
|
273
|
+
}, 200);
|
|
274
|
+
|
|
275
|
+
const sectionMap = computed(() => {
|
|
276
|
+
const map = new Map<string, ListboxOption[]>();
|
|
277
|
+
let currentSection: ListboxOption | null = null;
|
|
278
|
+
|
|
279
|
+
props.options.forEach((option) => {
|
|
280
|
+
if (option.type === 'section') {
|
|
281
|
+
currentSection = option;
|
|
282
|
+
map.set(currentSection?.value?.toString() || currentSection.label, []);
|
|
283
|
+
} else if (currentSection) {
|
|
284
|
+
map
|
|
285
|
+
.get(currentSection?.value?.toString() || currentSection.label)!
|
|
286
|
+
.push(option);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return map;
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
function toggleSection(item: ListboxOption) {
|
|
294
|
+
if (!props.checkableSections || !props.multiple) return;
|
|
295
|
+
|
|
296
|
+
const sectionItems =
|
|
297
|
+
sectionMap.value.get(item.value?.toString() || item.label) || [];
|
|
298
|
+
const selectedItems = selection.value as (string | number)[];
|
|
299
|
+
|
|
300
|
+
if (isSectionSelected(item)) {
|
|
301
|
+
selection.value = selectedItems.filter(
|
|
302
|
+
(opt) => !sectionItems.find((item) => item.value === opt),
|
|
303
|
+
);
|
|
304
|
+
} else {
|
|
305
|
+
selection.value = [
|
|
306
|
+
...selectedItems,
|
|
307
|
+
...sectionItems
|
|
308
|
+
.filter((opt) => !selectedItems.includes(opt.value!))
|
|
309
|
+
.map((item) => item.value!),
|
|
310
|
+
];
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function toggleValue(item?: ListboxOption) {
|
|
315
|
+
if (!item || !isSelectable(item)) return;
|
|
316
|
+
|
|
317
|
+
if (Array.isArray(selection.value)) {
|
|
318
|
+
if (isOptionSelected(item)) {
|
|
319
|
+
selection.value = (selection.value as (string | number)[]).filter(
|
|
320
|
+
(el) => el !== item.value,
|
|
321
|
+
);
|
|
322
|
+
} else {
|
|
323
|
+
selection.value = [...selection.value, item.value!];
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
selection.value = isOptionSelected(item) ? null : item.value!;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function selectAll() {
|
|
331
|
+
selection.value = [
|
|
332
|
+
...props.options
|
|
333
|
+
.filter(
|
|
334
|
+
(option) =>
|
|
335
|
+
!!option.value && !option.disabled && option.type !== 'section',
|
|
336
|
+
)
|
|
337
|
+
.map((item) => item.value!),
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function clearSelection() {
|
|
342
|
+
if (Array.isArray(selection.value)) {
|
|
343
|
+
selection.value = [];
|
|
344
|
+
} else {
|
|
345
|
+
selection.value = null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function isSelectable(item: ListboxOption) {
|
|
350
|
+
return (
|
|
351
|
+
(item.type !== 'section' && !item.disabled) ||
|
|
352
|
+
(item.type === 'section' && props.checkableSections && props.multiple)
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function isSectionSelected(item: ListboxOption) {
|
|
357
|
+
if (!props.checkableSections || !props.multiple) return false;
|
|
358
|
+
|
|
359
|
+
const sectionItems =
|
|
360
|
+
sectionMap.value.get(item.value?.toString() || item.label) || [];
|
|
361
|
+
const selectedItems = selection.value as (string | number)[];
|
|
362
|
+
|
|
363
|
+
return sectionItems.every((opt) => selectedItems.includes(opt.value!));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function isOptionSelected(item: ListboxOption) {
|
|
367
|
+
if (!item.value) return false;
|
|
368
|
+
|
|
369
|
+
if (Array.isArray(selection.value)) {
|
|
370
|
+
return (selection.value as (string | number)[])?.includes(item.value!);
|
|
371
|
+
} else {
|
|
372
|
+
return item.value === selection.value;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isIndeterminate(item: ListboxOption) {
|
|
377
|
+
const section = sectionMap.value.get(item.value?.toString() || item.label);
|
|
378
|
+
return (
|
|
379
|
+
section?.some((option) => isOptionSelected(option)) &&
|
|
380
|
+
!section?.every((option) => isOptionSelected(option))
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function moveActive(delta: number) {
|
|
385
|
+
if (!props.open || filteredResults.value.length === 0) return;
|
|
386
|
+
|
|
387
|
+
let nextIndex = activeIndex.value + delta;
|
|
388
|
+
|
|
389
|
+
if (nextIndex < 0) nextIndex = filteredResults.value.length - 1;
|
|
390
|
+
if (nextIndex >= filteredResults.value.length) nextIndex = 0;
|
|
391
|
+
|
|
392
|
+
while (!isSelectable(filteredResults.value[nextIndex])) {
|
|
393
|
+
nextIndex += delta > 0 ? 1 : -1;
|
|
394
|
+
if (nextIndex < 0) nextIndex = filteredResults.value.length - 1;
|
|
395
|
+
if (nextIndex >= filteredResults.value.length) nextIndex = 0;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
activeIndex.value = nextIndex;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function selectActive() {
|
|
402
|
+
const item = filteredResults.value[activeIndex.value];
|
|
403
|
+
if (!item || !isSelectable(item)) return;
|
|
404
|
+
|
|
405
|
+
if (item.type === 'section') {
|
|
406
|
+
toggleSection(item);
|
|
407
|
+
} else {
|
|
408
|
+
toggleValue(item);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
413
|
+
switch (event.key) {
|
|
414
|
+
case 'ArrowDown':
|
|
415
|
+
event.preventDefault();
|
|
416
|
+
if (!props.open) {
|
|
417
|
+
emit('open');
|
|
418
|
+
activeIndex.value = 0;
|
|
419
|
+
} else {
|
|
420
|
+
moveActive(1);
|
|
421
|
+
}
|
|
422
|
+
break;
|
|
423
|
+
case 'ArrowUp':
|
|
424
|
+
event.preventDefault();
|
|
425
|
+
if (!props.open) {
|
|
426
|
+
emit('open');
|
|
427
|
+
activeIndex.value = filteredResults.value.length - 1;
|
|
428
|
+
} else {
|
|
429
|
+
moveActive(-1);
|
|
430
|
+
}
|
|
431
|
+
break;
|
|
432
|
+
case 'Enter':
|
|
433
|
+
event.preventDefault();
|
|
434
|
+
if (!props.open) {
|
|
435
|
+
emit('open');
|
|
436
|
+
activeIndex.value = 0;
|
|
437
|
+
} else {
|
|
438
|
+
selectActive();
|
|
439
|
+
}
|
|
440
|
+
break;
|
|
441
|
+
case 'Escape':
|
|
442
|
+
event.preventDefault();
|
|
443
|
+
emit('close');
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
watch(
|
|
449
|
+
() => props.open,
|
|
450
|
+
(v) => {
|
|
451
|
+
if (v) {
|
|
452
|
+
setTimeout(() => {
|
|
453
|
+
textInput.value?.focus();
|
|
454
|
+
}, 50);
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
defineExpose({
|
|
460
|
+
handleKeydown,
|
|
461
|
+
toggleValue,
|
|
462
|
+
clearSelection,
|
|
463
|
+
listboxEl,
|
|
464
|
+
activeIndex,
|
|
465
|
+
});
|
|
466
|
+
</script>
|
|
467
|
+
|
|
468
|
+
<style lang="scss">
|
|
469
|
+
@use '@mozaic-ds/styles/components/option-listbox';
|
|
470
|
+
</style>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# MOptionListbox
|
|
2
|
+
|
|
3
|
+
An Option Listbox is a customizable, accessible listbox component designed to power dropdowns and comboboxes with advanced selection capabilities. It supports single or multiple selection, optional search, grouped options with section headers, and full keyboard navigation.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## Props
|
|
7
|
+
|
|
8
|
+
| Name | Description | Type | Default |
|
|
9
|
+
| --- | --- | --- | --- |
|
|
10
|
+
| `modelValue*` | The current selected value(s) of the listbox. | `string` `number` `(string` `number)[]` `null` | - |
|
|
11
|
+
| `id*` | Unique identifier for the listbox. | `string` | - |
|
|
12
|
+
| `open` | Whether the listbox is open. | `boolean` | - |
|
|
13
|
+
| `multiple` | Enable multiple selection. | `boolean` | - |
|
|
14
|
+
| `readonly` | Make the listbox read-only. | `boolean` | - |
|
|
15
|
+
| `search` | Show a search input above the options. | `boolean` | - |
|
|
16
|
+
| `actions` | Show select all / clear buttons when multiple. | `boolean` | - |
|
|
17
|
+
| `checkableSections` | Enable checkable section headers. | `boolean` | - |
|
|
18
|
+
| `searchPlaceholder` | Placeholder text for the search input. | `string` | `"Find an option..."` |
|
|
19
|
+
| `selectLabel` | Label for the "Select all" button. | `string` | `"Select all"` |
|
|
20
|
+
| `clearLabel` | Label for the "Clear selection" button. | `string` | `"Clear"` |
|
|
21
|
+
| `options*` | Array of options and sections to display in the listbox. | `ListboxOption[]` | - |
|
|
22
|
+
|
|
23
|
+
## Slots
|
|
24
|
+
|
|
25
|
+
| Name | Description |
|
|
26
|
+
| --- | --- |
|
|
27
|
+
| `optionPrefix` | Use this slot to add a prefix to options. |
|
|
28
|
+
|
|
29
|
+
## Events
|
|
30
|
+
|
|
31
|
+
| Name | Description | Type |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| `update:modelValue` | - | `[value: string` `number` `(string` `number)[]` `null]` |
|
|
34
|
+
| `close` | Emits when the listbox should close. | [] |
|
|
35
|
+
| `open` | Emits when the selected value changes. / ( on: 'update:modelValue', value: string | number | null | (string | number)[], ): void; /** Emits when the listbox should open. | [] |
|
|
36
|
+
|
|
37
|
+
## Dependencies
|
|
38
|
+
|
|
39
|
+
### Depends on
|
|
40
|
+
|
|
41
|
+
- [MButton](../button)
|
|
42
|
+
- [MTextInput](../textinput)
|
|
43
|
+
|
|
44
|
+
### Graph
|
|
45
|
+
|
|
46
|
+
```mermaid
|
|
47
|
+
graph TD;
|
|
48
|
+
MOptionListbox --> MButton
|
|
49
|
+
MOptionListbox --> MTextInput
|
|
50
|
+
style MOptionListbox fill:#008240,stroke:#333,stroke-width:4px
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Used By
|
|
54
|
+
|
|
55
|
+
- [MCombobox](../combobox)
|
|
56
|
+
|
|
57
|
+
### Graph
|
|
58
|
+
|
|
59
|
+
```mermaid
|
|
60
|
+
graph TD;
|
|
61
|
+
MCombobox --> MOptionListbox
|
|
62
|
+
style MOptionListbox fill:#008240,stroke:#333,stroke-width:4px
|
|
63
|
+
```
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import MStepperStacked from './MStepperStacked.vue';
|
|
4
|
+
|
|
5
|
+
const defaultSteps = [
|
|
6
|
+
{ id: '1', label: 'Step 1' },
|
|
7
|
+
{ id: '2', label: 'Step 2', additionalInfo: 'Additional info' },
|
|
8
|
+
{ id: '3', label: 'Step 3' },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
describe('MStepperStacked', () => {
|
|
12
|
+
describe('Basic rendering', () => {
|
|
13
|
+
it('renders as many li elements as there are steps', () => {
|
|
14
|
+
const wrapper = mount(MStepperStacked, {
|
|
15
|
+
props: { steps: defaultSteps },
|
|
16
|
+
});
|
|
17
|
+
const items = wrapper.findAll('.mc-stepper-stacked__item');
|
|
18
|
+
expect(items).toHaveLength(defaultSteps.length);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('renders the label of each step', () => {
|
|
22
|
+
const wrapper = mount(MStepperStacked, {
|
|
23
|
+
props: { steps: defaultSteps },
|
|
24
|
+
});
|
|
25
|
+
defaultSteps.forEach((step) => {
|
|
26
|
+
expect(wrapper.text()).toContain(step.label);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('renders step numbers (1, 2, 3...)', () => {
|
|
31
|
+
const wrapper = mount(MStepperStacked, {
|
|
32
|
+
props: { steps: defaultSteps, currentStep: '1' },
|
|
33
|
+
});
|
|
34
|
+
expect(wrapper.text()).toContain('2');
|
|
35
|
+
expect(wrapper.text()).toContain('3');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('renders additionalInfo when provided', () => {
|
|
39
|
+
const wrapper = mount(MStepperStacked, {
|
|
40
|
+
props: { steps: defaultSteps },
|
|
41
|
+
});
|
|
42
|
+
expect(wrapper.text()).toContain('Additional info');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('adds the has-additional class on items with additionalInfo', () => {
|
|
46
|
+
const wrapper = mount(MStepperStacked, {
|
|
47
|
+
props: { steps: defaultSteps },
|
|
48
|
+
});
|
|
49
|
+
const items = wrapper.findAll('.mc-stepper-stacked__item');
|
|
50
|
+
expect(items[1].classes()).toContain('has-additional');
|
|
51
|
+
expect(items[0].classes()).not.toContain('has-additional');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('Step states', () => {
|
|
56
|
+
it('marks the current step circle with the is-current class', () => {
|
|
57
|
+
const wrapper = mount(MStepperStacked, {
|
|
58
|
+
props: { steps: defaultSteps, currentStep: '2' },
|
|
59
|
+
});
|
|
60
|
+
const circles = wrapper.findAll('.mc-stepper-stacked__circle');
|
|
61
|
+
const currentCircle = circles.find((c) => c.classes('is-current'));
|
|
62
|
+
expect(currentCircle).toBeTruthy();
|
|
63
|
+
expect(currentCircle?.text()).toBe('2');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('marks the current step label with the is-current class', () => {
|
|
67
|
+
const wrapper = mount(MStepperStacked, {
|
|
68
|
+
props: { steps: defaultSteps, currentStep: '2' },
|
|
69
|
+
});
|
|
70
|
+
const labels = wrapper.findAll('.mc-stepper-stacked__label');
|
|
71
|
+
expect(labels[1].classes()).toContain('is-current');
|
|
72
|
+
expect(labels[0].classes()).not.toContain('is-current');
|
|
73
|
+
expect(labels[2].classes()).not.toContain('is-current');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('renders the Check icon for completed steps', () => {
|
|
77
|
+
const wrapper = mount(MStepperStacked, {
|
|
78
|
+
props: { steps: defaultSteps, currentStep: '3' },
|
|
79
|
+
});
|
|
80
|
+
const checkIcons = wrapper.findAll('.mc-stepper-stacked__icon--check');
|
|
81
|
+
expect(checkIcons).toHaveLength(2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('does not render the Check icon for the current step', () => {
|
|
85
|
+
const wrapper = mount(MStepperStacked, {
|
|
86
|
+
props: { steps: defaultSteps, currentStep: '1' },
|
|
87
|
+
});
|
|
88
|
+
const checkIcons = wrapper.findAll('.mc-stepper-stacked__icon--check');
|
|
89
|
+
expect(checkIcons).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('renders a numbered circle for non-completed steps', () => {
|
|
93
|
+
const wrapper = mount(MStepperStacked, {
|
|
94
|
+
props: { steps: defaultSteps, currentStep: '1' },
|
|
95
|
+
});
|
|
96
|
+
const circles = wrapper.findAll('.mc-stepper-stacked__circle');
|
|
97
|
+
expect(circles).toHaveLength(3);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('active step — string type', () => {
|
|
102
|
+
it('falls back to step 1 if currentStep is less than 1', () => {
|
|
103
|
+
const wrapper = mount(MStepperStacked, {
|
|
104
|
+
props: { steps: defaultSteps, currentStep: 'unknown id' },
|
|
105
|
+
});
|
|
106
|
+
const labels = wrapper.findAll('.mc-stepper-stacked__label');
|
|
107
|
+
expect(labels[0].classes()).toContain('is-current');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('works with no steps (default value)', () => {
|
|
111
|
+
const wrapper = mount(MStepperStacked);
|
|
112
|
+
expect(wrapper.findAll('.mc-stepper-stacked__item')).toHaveLength(0);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('active step — number type', () => {
|
|
117
|
+
it('clamps to step 1 when currentStep is 0 or less', () => {
|
|
118
|
+
const wrapper = mount(MStepperStacked, {
|
|
119
|
+
props: { steps: defaultSteps, currentStep: 0 },
|
|
120
|
+
});
|
|
121
|
+
const labels = wrapper.findAll('.mc-stepper-stacked__label');
|
|
122
|
+
expect(labels[0].classes()).toContain('is-current');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('clamps to the last step when currentStep exceeds steps length', () => {
|
|
126
|
+
const wrapper = mount(MStepperStacked, {
|
|
127
|
+
props: { steps: defaultSteps, currentStep: 99 },
|
|
128
|
+
});
|
|
129
|
+
const labels = wrapper.findAll('.mc-stepper-stacked__label');
|
|
130
|
+
expect(labels[defaultSteps.length - 1].classes()).toContain('is-current');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('Default currentStep', () => {
|
|
135
|
+
it('defaults to currentStep=1', () => {
|
|
136
|
+
const wrapper = mount(MStepperStacked, {
|
|
137
|
+
props: { steps: defaultSteps },
|
|
138
|
+
});
|
|
139
|
+
const labels = wrapper.findAll('.mc-stepper-stacked__label');
|
|
140
|
+
expect(labels[0].classes()).toContain('is-current');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('First and last step', () => {
|
|
145
|
+
it('has no completed steps when currentStep=1', () => {
|
|
146
|
+
const wrapper = mount(MStepperStacked, {
|
|
147
|
+
props: { steps: defaultSteps, currentStep: '1' },
|
|
148
|
+
});
|
|
149
|
+
expect(wrapper.findAll('.mc-stepper-stacked__icon--check')).toHaveLength(
|
|
150
|
+
0,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('marks all previous steps as completed when on the last step', () => {
|
|
155
|
+
const wrapper = mount(MStepperStacked, {
|
|
156
|
+
props: { steps: defaultSteps, currentStep: '3' },
|
|
157
|
+
});
|
|
158
|
+
const checkIcons = wrapper.findAll('.mc-stepper-stacked__icon--check');
|
|
159
|
+
expect(checkIcons).toHaveLength(defaultSteps.length - 1);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
});
|