@mozaic-ds/vue 2.14.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 +1582 -500
- package/dist/mozaic-vue.js +8020 -3218
- 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 +6 -4
- package/src/components/DarkMode.mdx +115 -0
- package/src/components/actionlistbox/MActionListbox.spec.ts +20 -10
- package/src/components/actionlistbox/MActionListbox.stories.ts +15 -8
- package/src/components/actionlistbox/MActionListbox.vue +15 -12
- package/src/components/actionlistbox/README.md +2 -1
- package/src/components/avatar/MAvatar.stories.ts +1 -1
- package/src/components/breadcrumb/MBreadcrumb.vue +2 -2
- 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/pageheader/MPageHeader.spec.ts +12 -12
- package/src/components/pageheader/MPageHeader.stories.ts +9 -1
- package/src/components/pageheader/MPageHeader.vue +3 -6
- package/src/components/segmentedcontrol/MSegmentedControl.spec.ts +57 -25
- package/src/components/segmentedcontrol/MSegmentedControl.stories.ts +6 -19
- package/src/components/segmentedcontrol/MSegmentedControl.vue +27 -13
- package/src/components/segmentedcontrol/README.md +6 -3
- package/src/components/select/MSelect.vue +4 -3
- package/src/components/sidebar/stories/DefaultCase.stories.vue +2 -2
- package/src/components/sidebar/stories/README.md +8 -0
- package/src/components/sidebar/stories/WithExpandOnly.stories.vue +1 -1
- package/src/components/sidebar/stories/WithProfileInfoOnly.stories.vue +2 -2
- package/src/components/sidebar/stories/WithSingleLevel.stories.vue +2 -2
- package/src/components/stepperinline/MStepperInline.spec.ts +63 -28
- package/src/components/stepperinline/MStepperInline.stories.ts +18 -10
- package/src/components/stepperinline/MStepperInline.vue +24 -10
- package/src/components/stepperinline/README.md +6 -2
- 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/tabs/MTabs.stories.ts +18 -0
- package/src/components/tabs/MTabs.vue +30 -14
- package/src/components/tabs/Mtabs.spec.ts +56 -10
- package/src/components/tabs/README.md +6 -3
- package/src/components/textinput/MTextInput.vue +13 -1
- package/src/components/textinput/README.md +15 -1
- package/src/components/tileclickable/README.md +1 -1
- package/src/main.ts +10 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mozaic-ds/vue",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Mozaic-Vue is the Vue.js implementation of ADEO Design system",
|
|
6
6
|
"author": "ADEO - ADEO Design system",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"*.d.ts"
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@mozaic-ds/styles": "^2.
|
|
44
|
+
"@mozaic-ds/styles": "^2.13.0",
|
|
45
45
|
"@mozaic-ds/web-fonts": "^1.65.0",
|
|
46
46
|
"postcss-scss": "^4.0.9",
|
|
47
47
|
"vue": "^3.5.13"
|
|
@@ -56,17 +56,19 @@
|
|
|
56
56
|
"@storybook/addon-docs": "^10.0.4",
|
|
57
57
|
"@storybook/addon-themes": "^10.0.4",
|
|
58
58
|
"@storybook/vue3-vite": "^10.0.4",
|
|
59
|
-
"@types/jsdom": "^
|
|
59
|
+
"@types/jsdom": "^28.0.0",
|
|
60
|
+
"@types/lodash": "^4.17.23",
|
|
60
61
|
"@vitejs/plugin-vue": "^6.0.1",
|
|
61
62
|
"@vitest/coverage-v8": "^4.0.7",
|
|
62
63
|
"@vitest/eslint-plugin": "^1.1.38",
|
|
63
64
|
"@vue/eslint-config-prettier": "^10.2.0",
|
|
64
65
|
"@vue/eslint-config-typescript": "^14.5.0",
|
|
65
66
|
"@vue/test-utils": "^2.4.6",
|
|
66
|
-
"eslint": "^
|
|
67
|
+
"eslint": "^10.0.2",
|
|
67
68
|
"eslint-plugin-storybook": "^10.0.5",
|
|
68
69
|
"eslint-plugin-vue": "^10.0.0",
|
|
69
70
|
"eslint-plugin-vuejs-accessibility": "^2.4.1",
|
|
71
|
+
"globals": "^17.3.0",
|
|
70
72
|
"husky": "^9.1.7",
|
|
71
73
|
"jsdom": "^28.0.0",
|
|
72
74
|
"libphonenumber-js": "^1.12.23",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Meta, Source } from '@storybook/addon-docs/blocks';
|
|
2
|
+
|
|
3
|
+
<Meta title="Dark Mode" />
|
|
4
|
+
|
|
5
|
+
# Dark Mode
|
|
6
|
+
|
|
7
|
+
A concise guide explaining **how dark mode works** with your CSS variables and **how to use it** in Storybook.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## What dark mode is (high‑level)
|
|
12
|
+
|
|
13
|
+
Dark mode is implemented with **two sets of CSS variables** (tokens):
|
|
14
|
+
|
|
15
|
+
- **Light** values live under `:root`.
|
|
16
|
+
- **Dark** values override under `:root[data-theme="dark"]`.
|
|
17
|
+
|
|
18
|
+
Components only reference tokens with `var(--token-name)` — switching theme is just toggling the `data-theme` attribute (no component code changes).
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Token structure (SCSS → CSS)
|
|
23
|
+
|
|
24
|
+
Your presets export SCSS like this:
|
|
25
|
+
|
|
26
|
+
<Source
|
|
27
|
+
language="scss"
|
|
28
|
+
dark
|
|
29
|
+
code={`
|
|
30
|
+
$root-selector: ':root' !default;
|
|
31
|
+
$dark-selector: '[data-theme="dark"]' !default;
|
|
32
|
+
|
|
33
|
+
#{$root-selector} {
|
|
34
|
+
/_ Light tokens _/
|
|
35
|
+
--color-background-primary: #ffffff;
|
|
36
|
+
--color-text-primary: #000000;
|
|
37
|
+
/_ … all your light variables … _/
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
#{$root-selector}#{$dark-selector} {
|
|
41
|
+
/_ Dark tokens _/
|
|
42
|
+
--color-background-primary: #191919;
|
|
43
|
+
--color-text-primary: #d9d9d9;
|
|
44
|
+
/_ … all your dark variables … _/
|
|
45
|
+
}
|
|
46
|
+
`}
|
|
47
|
+
/>
|
|
48
|
+
|
|
49
|
+
After compilation, this becomes standard CSS:
|
|
50
|
+
|
|
51
|
+
<Source
|
|
52
|
+
language="css"
|
|
53
|
+
dark
|
|
54
|
+
code={`
|
|
55
|
+
:root {
|
|
56
|
+
/* light tokens */
|
|
57
|
+
}
|
|
58
|
+
:root[data-theme='dark'] {
|
|
59
|
+
/* dark tokens */
|
|
60
|
+
}
|
|
61
|
+
`}
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
> If you can’t (or don’t want to) target `:root`, you can pass a different `$root-selector` when building your theme and apply `data-theme="dark"` on that container instead.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Using tokens inside components
|
|
69
|
+
|
|
70
|
+
To enable the dark mode you have to ensure to:
|
|
71
|
+
|
|
72
|
+
- Add the `data-theme` attribute in your root element with the value `dark`,
|
|
73
|
+
- Use variables — never hard‑code colors or sizes
|
|
74
|
+
|
|
75
|
+
```html
|
|
76
|
+
<div class="root" data-theme="dark">…</div>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
<Source
|
|
80
|
+
language="sass"
|
|
81
|
+
dark
|
|
82
|
+
code={`
|
|
83
|
+
@use "@mozaic-ds/tokens" as *;
|
|
84
|
+
|
|
85
|
+
.mc-component: {
|
|
86
|
+
background-color: $--color-background-primary;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
`}
|
|
90
|
+
/>
|
|
91
|
+
|
|
92
|
+
When the theme changes, these values update automatically via CSS.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Accessibility & good practices
|
|
97
|
+
|
|
98
|
+
- Aim for **WCAG AA** contrast at minimum; verify text vs. background pairs.
|
|
99
|
+
- Prefer **semantic tokens** (`--button-color-…`, `--color-text-…`) over raw color hexes.
|
|
100
|
+
- Keep all component styles expressed in tokens so the **theme switch has zero component logic**.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Troubleshooting
|
|
105
|
+
|
|
106
|
+
- **Dark toggle does nothing** → Ensure the tokens were imported **before** component styles and that `data-theme="dark"` is set on the same selector the tokens target (usually `:root`).
|
|
107
|
+
- **Weird colors** → Search for hard‑coded values and replace them with tokens.
|
|
108
|
+
- **Variables undefined** → Check your build order and that the SCSS was compiled to CSS and loaded by Storybook.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Summary
|
|
113
|
+
|
|
114
|
+
- Light tokens on `:root`, dark overrides on `:root[data-theme="dark"]`.
|
|
115
|
+
- Components read tokens with `var(--$token-name)` — no runtime branching required.
|
|
@@ -24,6 +24,7 @@ const items = [
|
|
|
24
24
|
icon: DummyIcon,
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
|
+
id: 'move',
|
|
27
28
|
label: 'Move to...',
|
|
28
29
|
icon: DummyIcon,
|
|
29
30
|
},
|
|
@@ -87,10 +88,7 @@ describe('MActionListbox', () => {
|
|
|
87
88
|
});
|
|
88
89
|
|
|
89
90
|
it('applies disabled class when item.disabled is true', () => {
|
|
90
|
-
const disabledItems = [
|
|
91
|
-
...items,
|
|
92
|
-
{ label: 'Disabled', disabled: true },
|
|
93
|
-
];
|
|
91
|
+
const disabledItems = [...items, { label: 'Disabled', disabled: true }];
|
|
94
92
|
|
|
95
93
|
const wrapper = mountComponent({ items: disabledItems });
|
|
96
94
|
|
|
@@ -101,20 +99,19 @@ describe('MActionListbox', () => {
|
|
|
101
99
|
|
|
102
100
|
it('applies mc-listbox--bottom by default', () => {
|
|
103
101
|
const wrapper = mountComponent();
|
|
104
|
-
expect(wrapper.find('.mc-listbox').classes())
|
|
105
|
-
|
|
102
|
+
expect(wrapper.find('.mc-listbox').classes()).toContain(
|
|
103
|
+
'mc-listbox--bottom',
|
|
104
|
+
);
|
|
106
105
|
});
|
|
107
106
|
|
|
108
107
|
it('applies mc-listbox--top when position is "top"', () => {
|
|
109
108
|
const wrapper = mountComponent({ position: 'top' });
|
|
110
|
-
expect(wrapper.find('.mc-listbox').classes())
|
|
111
|
-
.toContain('mc-listbox--top');
|
|
109
|
+
expect(wrapper.find('.mc-listbox').classes()).toContain('mc-listbox--top');
|
|
112
110
|
});
|
|
113
111
|
|
|
114
112
|
it('applies mc-listbox--left when position is "left"', () => {
|
|
115
113
|
const wrapper = mountComponent({ position: 'left' });
|
|
116
|
-
expect(wrapper.find('.mc-listbox').classes())
|
|
117
|
-
.toContain('mc-listbox--left');
|
|
114
|
+
expect(wrapper.find('.mc-listbox').classes()).toContain('mc-listbox--left');
|
|
118
115
|
});
|
|
119
116
|
|
|
120
117
|
it('emits "close" when close button is clicked', async () => {
|
|
@@ -125,4 +122,17 @@ describe('MActionListbox', () => {
|
|
|
125
122
|
expect(wrapper.emitted('close')).toBeTruthy();
|
|
126
123
|
expect(wrapper.emitted('close')?.length).toBe(1);
|
|
127
124
|
});
|
|
125
|
+
|
|
126
|
+
it('emits "action" when an item is clicked', async () => {
|
|
127
|
+
const wrapper = mountComponent({ title: 'Action List' });
|
|
128
|
+
|
|
129
|
+
const actions = wrapper.findAll('.mc-action-list__button');
|
|
130
|
+
await actions[0].trigger('click');
|
|
131
|
+
|
|
132
|
+
expect(wrapper.emitted('action')).toBeTruthy();
|
|
133
|
+
expect(wrapper.emitted('action')?.[0][0]).toBe(0);
|
|
134
|
+
|
|
135
|
+
await actions[1].trigger('click');
|
|
136
|
+
expect(wrapper.emitted('action')?.[1][0]).toBe('move');
|
|
137
|
+
});
|
|
128
138
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
2
|
+
import { action } from 'storybook/actions';
|
|
2
3
|
|
|
3
4
|
import MActionListbox from './MActionListbox.vue';
|
|
4
5
|
import MButton from '../button/MButton.vue';
|
|
@@ -25,10 +26,10 @@ const meta: Meta<typeof MActionListbox> = {
|
|
|
25
26
|
<template>
|
|
26
27
|
<MActionListbox
|
|
27
28
|
:items="[
|
|
28
|
-
{ label: 'Duplicate', icon: Copy20, disabled: true },
|
|
29
|
-
{ label: 'Move to...', icon: ArrowTopRight20 },
|
|
30
|
-
{ label: 'Download', icon: Download20 },
|
|
31
|
-
{ label: 'Delete', icon: Trash20, appearance: 'danger', divider: true }
|
|
29
|
+
{ id: 'item-1', label: 'Duplicate', icon: Copy20, disabled: true },
|
|
30
|
+
{ id: 'item-2', label: 'Move to...', icon: ArrowTopRight20 },
|
|
31
|
+
{ id: 'item-3', label: 'Download', icon: Download20 },
|
|
32
|
+
{ id: 'item-4', label: 'Delete', icon: Trash20, appearance: 'danger', divider: true }
|
|
32
33
|
]"
|
|
33
34
|
title="Listbox title (optional)"
|
|
34
35
|
/>
|
|
@@ -41,19 +42,23 @@ const meta: Meta<typeof MActionListbox> = {
|
|
|
41
42
|
title: 'Listbox title (optional)',
|
|
42
43
|
items: [
|
|
43
44
|
{
|
|
45
|
+
id: 'item-1',
|
|
44
46
|
label: 'Duplicate',
|
|
45
47
|
icon: Copy20,
|
|
46
48
|
disabled: true,
|
|
47
49
|
},
|
|
48
50
|
{
|
|
51
|
+
id: 'item-2',
|
|
49
52
|
label: 'Move to...',
|
|
50
53
|
icon: ArrowTopRight20,
|
|
51
54
|
},
|
|
52
55
|
{
|
|
56
|
+
id: 'item-3',
|
|
53
57
|
label: 'Download',
|
|
54
58
|
icon: Download20,
|
|
55
59
|
},
|
|
56
60
|
{
|
|
61
|
+
id: 'item-4',
|
|
57
62
|
label: 'Delete',
|
|
58
63
|
icon: Trash20,
|
|
59
64
|
appearance: 'danger',
|
|
@@ -64,10 +69,11 @@ const meta: Meta<typeof MActionListbox> = {
|
|
|
64
69
|
render: (args) => ({
|
|
65
70
|
components: { MActionListbox },
|
|
66
71
|
setup() {
|
|
67
|
-
|
|
72
|
+
const handleAction = action('action');
|
|
73
|
+
return { args, handleAction };
|
|
68
74
|
},
|
|
69
75
|
template: `
|
|
70
|
-
<MActionListbox v-bind="args" />
|
|
76
|
+
<MActionListbox v-bind="args" @action="handleAction" />
|
|
71
77
|
`,
|
|
72
78
|
}),
|
|
73
79
|
};
|
|
@@ -80,11 +86,12 @@ export const Activator: Story = {
|
|
|
80
86
|
render: (args) => ({
|
|
81
87
|
components: { MActionListbox, MButton },
|
|
82
88
|
setup() {
|
|
83
|
-
|
|
89
|
+
const handleAction = action('action');
|
|
90
|
+
return { args, handleAction };
|
|
84
91
|
},
|
|
85
92
|
template: `
|
|
86
93
|
<div>
|
|
87
|
-
<MActionListbox v-bind="args">
|
|
94
|
+
<MActionListbox v-bind="args" @action="handleAction">
|
|
88
95
|
<template #activator="{id}">
|
|
89
96
|
<MButton :popovertarget="id">Activator</MButton>
|
|
90
97
|
</template>
|
|
@@ -42,7 +42,11 @@
|
|
|
42
42
|
]"
|
|
43
43
|
role="menuitem"
|
|
44
44
|
>
|
|
45
|
-
<button
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
class="mc-action-list__button"
|
|
48
|
+
@click="emit('action', item?.id || index)"
|
|
49
|
+
>
|
|
46
50
|
<component
|
|
47
51
|
v-if="item.icon"
|
|
48
52
|
class="mc-action-list__icon"
|
|
@@ -61,12 +65,7 @@
|
|
|
61
65
|
</template>
|
|
62
66
|
|
|
63
67
|
<script setup lang="ts">
|
|
64
|
-
import {
|
|
65
|
-
useId,
|
|
66
|
-
useTemplateRef,
|
|
67
|
-
type Component,
|
|
68
|
-
type VNode,
|
|
69
|
-
} from 'vue';
|
|
68
|
+
import { useId, useTemplateRef, type Component, type VNode } from 'vue';
|
|
70
69
|
import MIconButton from '../iconbutton/MIconButton.vue';
|
|
71
70
|
import MDivider from '../divider/MDivider.vue';
|
|
72
71
|
import { Cross24 } from '@mozaic-ds/icons-vue';
|
|
@@ -84,16 +83,16 @@ const props = withDefaults(
|
|
|
84
83
|
/**
|
|
85
84
|
* Defines the position of the listbox relative to its trigger or container.
|
|
86
85
|
*/
|
|
87
|
-
position?:
|
|
88
|
-
| 'top'
|
|
89
|
-
| 'bottom'
|
|
90
|
-
| 'left'
|
|
91
|
-
| 'right';
|
|
86
|
+
position?: 'top' | 'bottom' | 'left' | 'right';
|
|
92
87
|
/**
|
|
93
88
|
* An array of objects that allows you to provide all the data needed to generate the content for each item.
|
|
94
89
|
*/
|
|
95
90
|
|
|
96
91
|
items: Array<{
|
|
92
|
+
/**
|
|
93
|
+
* Unique identifier for the item.
|
|
94
|
+
*/
|
|
95
|
+
id?: string;
|
|
97
96
|
/**
|
|
98
97
|
* The icon displayed for the item from Mozaic-icon-vue.
|
|
99
98
|
*/
|
|
@@ -124,6 +123,10 @@ const emit = defineEmits<{
|
|
|
124
123
|
* Emits when the close button is clicked.
|
|
125
124
|
*/
|
|
126
125
|
(on: 'close'): void;
|
|
126
|
+
/**
|
|
127
|
+
* Emits when an item is clicked, providing its id or index.
|
|
128
|
+
*/
|
|
129
|
+
(on: 'action', value: string | number): void;
|
|
127
130
|
}>();
|
|
128
131
|
|
|
129
132
|
const slots = defineSlots<{
|
|
@@ -9,7 +9,7 @@ An action list is a contextual menu that presents a list of available actions re
|
|
|
9
9
|
| --- | --- | --- | --- |
|
|
10
10
|
| `title` | title displayed in mobile version. | `string` | - |
|
|
11
11
|
| `position` | Defines the position of the listbox relative to its trigger or container. | `"bottom"` `"top"` `"left"` `"right"` | `"bottom"` |
|
|
12
|
-
| `items*` | An array of objects that allows you to provide all the data needed to generate the content for each item. | `{ icon?: Component` `undefined; label: string; disabled?: boolean` `undefined; appearance?: "standard"` `"danger"` `undefined; divider?: boolean` `undefined; }[]` | - |
|
|
12
|
+
| `items*` | An array of objects that allows you to provide all the data needed to generate the content for each item. | `{ id?: string` `undefined; icon?: Component` `undefined; label: string; disabled?: boolean` `undefined; appearance?: "standard"` `"danger"` `undefined; divider?: boolean` `undefined; }[]` | - |
|
|
13
13
|
|
|
14
14
|
## Slots
|
|
15
15
|
|
|
@@ -22,6 +22,7 @@ An action list is a contextual menu that presents a list of available actions re
|
|
|
22
22
|
| Name | Description | Type |
|
|
23
23
|
| --- | --- | --- |
|
|
24
24
|
| `close` | Emits when the close button is clicked. | [] |
|
|
25
|
+
| `action` | Emits when an item is clicked, providing its id or index. | [value: string | number] |
|
|
25
26
|
|
|
26
27
|
## Dependencies
|
|
27
28
|
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
:aria-current="isLastLink(index) ? 'page' : undefined"
|
|
19
19
|
>
|
|
20
20
|
{{ link.label }}
|
|
21
|
-
<template v-if="index !==
|
|
22
|
-
<ChevronRight20/>
|
|
21
|
+
<template v-if="index !== links.length - 1" #icon>
|
|
22
|
+
<ChevronRight20 />
|
|
23
23
|
</template>
|
|
24
24
|
</MLink>
|
|
25
25
|
</li>
|
|
@@ -41,6 +41,7 @@ style MButton fill:#008240,stroke:#333,stroke-width:4px
|
|
|
41
41
|
|
|
42
42
|
- [MFileUploaderItem](../fileuploaderitem)
|
|
43
43
|
- [MNavigationIndicator](../navigationindicator)
|
|
44
|
+
- [MOptionListbox](../optionListbox)
|
|
44
45
|
- [MPagination](../pagination)
|
|
45
46
|
- [MPasswordInput](../passwordinput)
|
|
46
47
|
- [MStepperBottomBar](../stepperbottombar)
|
|
@@ -52,6 +53,7 @@ style MButton fill:#008240,stroke:#333,stroke-width:4px
|
|
|
52
53
|
graph TD;
|
|
53
54
|
MFileUploaderItem --> MButton
|
|
54
55
|
MNavigationIndicator --> MButton
|
|
56
|
+
MOptionListbox --> MButton
|
|
55
57
|
MPagination --> MButton
|
|
56
58
|
MPasswordInput --> MButton
|
|
57
59
|
MStepperBottomBar --> MButton
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import { defineComponent, ref, nextTick } from 'vue';
|
|
4
|
+
import MCombobox from './MCombobox.vue';
|
|
5
|
+
|
|
6
|
+
const MOptionListboxStub = defineComponent({
|
|
7
|
+
name: 'MOptionListbox',
|
|
8
|
+
props: [
|
|
9
|
+
'modelValue',
|
|
10
|
+
'open',
|
|
11
|
+
'multiple',
|
|
12
|
+
'search',
|
|
13
|
+
'actions',
|
|
14
|
+
'checkableSections',
|
|
15
|
+
'searchPlaceholder',
|
|
16
|
+
'selectLabel',
|
|
17
|
+
'clearLabel',
|
|
18
|
+
'options',
|
|
19
|
+
'id',
|
|
20
|
+
],
|
|
21
|
+
emits: ['update:modelValue', 'open', 'close'],
|
|
22
|
+
setup() {
|
|
23
|
+
const activeIndex = ref(-1);
|
|
24
|
+
const listboxEl = ref(document.createElement('div'));
|
|
25
|
+
|
|
26
|
+
// On crée une fonction mock que l’on expose
|
|
27
|
+
const toggleValue = vi.fn();
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
activeIndex,
|
|
31
|
+
listboxEl,
|
|
32
|
+
handleKeydown: () => {},
|
|
33
|
+
toggleValue, // <- expose ici
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
template: `<div />`,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const MTagStub = defineComponent({
|
|
40
|
+
name: 'MTag',
|
|
41
|
+
props: ['id', 'label', 'type', 'size'],
|
|
42
|
+
emits: ['remove-tag'],
|
|
43
|
+
template: `<div class="m-tag-stub">{{ label }}</div>`,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const MButtonStub = defineComponent({
|
|
47
|
+
name: 'MButton',
|
|
48
|
+
props: ['outlined', 'size'],
|
|
49
|
+
emits: ['click'],
|
|
50
|
+
template: `<button @click="$emit('click')"><slot/></button>`,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const CrossCircleFilled24 = defineComponent({
|
|
54
|
+
name: 'CrossCircleFilled24',
|
|
55
|
+
template: `<svg/>`,
|
|
56
|
+
});
|
|
57
|
+
const ChevronDown24 = defineComponent({
|
|
58
|
+
name: 'ChevronDown24',
|
|
59
|
+
template: `<svg/>`,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('MCombobox', () => {
|
|
63
|
+
const options = [
|
|
64
|
+
{ label: 'One', value: 1 },
|
|
65
|
+
{ label: 'Two', value: 2 },
|
|
66
|
+
{ label: 'Three', value: 3 },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
it('renders placeholder when no selection', () => {
|
|
70
|
+
const wrapper = mount(MCombobox, {
|
|
71
|
+
props: { modelValue: null, options },
|
|
72
|
+
global: {
|
|
73
|
+
components: {
|
|
74
|
+
MOptionListbox: MOptionListboxStub,
|
|
75
|
+
MTag: MTagStub,
|
|
76
|
+
MButton: MButtonStub,
|
|
77
|
+
CrossCircleFilled24,
|
|
78
|
+
ChevronDown24,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const control = wrapper.find('.mc-combobox__control');
|
|
84
|
+
expect(control.exists()).toBe(true);
|
|
85
|
+
expect(control.text()).toBe('Select an option');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('renders selected label for single value', () => {
|
|
89
|
+
const wrapper = mount(MCombobox, {
|
|
90
|
+
props: { modelValue: 1, options },
|
|
91
|
+
global: {
|
|
92
|
+
components: {
|
|
93
|
+
MOptionListbox: MOptionListboxStub,
|
|
94
|
+
MTag: MTagStub,
|
|
95
|
+
MButton: MButtonStub,
|
|
96
|
+
CrossCircleFilled24,
|
|
97
|
+
ChevronDown24,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const control = wrapper.find('.mc-combobox__control');
|
|
103
|
+
expect(control.text()).toBe('One');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('multiple selection shows joins values', async () => {
|
|
107
|
+
const wrapper = mount(MCombobox, {
|
|
108
|
+
props: { modelValue: [1, 2], multiple: true, options },
|
|
109
|
+
global: {
|
|
110
|
+
components: {
|
|
111
|
+
MOptionListbox: MOptionListboxStub,
|
|
112
|
+
MTag: MTagStub,
|
|
113
|
+
MButton: MButtonStub,
|
|
114
|
+
CrossCircleFilled24,
|
|
115
|
+
ChevronDown24,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(wrapper.find('.mc-combobox__control').text()).toBe('1, 2');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('toggles listbox open/close on control click', async () => {
|
|
124
|
+
const wrapper = mount(MCombobox, {
|
|
125
|
+
props: { modelValue: null, options },
|
|
126
|
+
global: {
|
|
127
|
+
components: {
|
|
128
|
+
MOptionListbox: MOptionListboxStub,
|
|
129
|
+
MTag: MTagStub,
|
|
130
|
+
MButton: MButtonStub,
|
|
131
|
+
CrossCircleFilled24,
|
|
132
|
+
ChevronDown24,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const root = wrapper.find('.mc-combobox');
|
|
138
|
+
const control = wrapper.find('.mc-combobox__control');
|
|
139
|
+
|
|
140
|
+
await control.trigger('click');
|
|
141
|
+
expect(root.classes()).toContain('mc-combobox--open');
|
|
142
|
+
|
|
143
|
+
await control.trigger('click');
|
|
144
|
+
expect(root.classes()).not.toContain('mc-combobox--open');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('clear button clears selection and emits update:modelValue', async () => {
|
|
148
|
+
const wrapperSingle = mount(MCombobox, {
|
|
149
|
+
props: { modelValue: 1, clearable: true, options },
|
|
150
|
+
global: {
|
|
151
|
+
components: {
|
|
152
|
+
MOptionListbox: MOptionListboxStub,
|
|
153
|
+
MTag: MTagStub,
|
|
154
|
+
MButton: MButtonStub,
|
|
155
|
+
CrossCircleFilled24,
|
|
156
|
+
ChevronDown24,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const clearBtnSingle = wrapperSingle.find('.mc-combobox__clear');
|
|
162
|
+
expect(clearBtnSingle.exists()).toBe(true);
|
|
163
|
+
await clearBtnSingle.trigger('click');
|
|
164
|
+
const emittedSingle = wrapperSingle.emitted('update:modelValue') || [];
|
|
165
|
+
expect(emittedSingle.length).toBeGreaterThan(0);
|
|
166
|
+
expect(emittedSingle[emittedSingle.length - 1][0]).toBeNull();
|
|
167
|
+
|
|
168
|
+
const wrapperMulti = mount(MCombobox, {
|
|
169
|
+
props: { modelValue: [1], multiple: true, clearable: true, options },
|
|
170
|
+
global: {
|
|
171
|
+
components: {
|
|
172
|
+
MOptionListbox: MOptionListboxStub,
|
|
173
|
+
MTag: MTagStub,
|
|
174
|
+
MButton: MButtonStub,
|
|
175
|
+
CrossCircleFilled24,
|
|
176
|
+
ChevronDown24,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const clearBtnMulti = wrapperMulti.find('.mc-combobox__clear');
|
|
182
|
+
expect(clearBtnMulti.exists()).toBe(true);
|
|
183
|
+
await clearBtnMulti.trigger('click');
|
|
184
|
+
const emittedMulti = wrapperMulti.emitted('update:modelValue') || [];
|
|
185
|
+
expect(emittedMulti.length).toBeGreaterThan(0);
|
|
186
|
+
|
|
187
|
+
const last = emittedMulti[emittedMulti.length - 1][0];
|
|
188
|
+
expect(Array.isArray(last)).toBe(true);
|
|
189
|
+
expect(last).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('activeDescendant reflects child listbox activeIndex', async () => {
|
|
193
|
+
const wrapper = mount(MCombobox, {
|
|
194
|
+
props: { modelValue: null, options },
|
|
195
|
+
global: {
|
|
196
|
+
components: {
|
|
197
|
+
MOptionListbox: MOptionListboxStub,
|
|
198
|
+
MTag: MTagStub,
|
|
199
|
+
MButton: MButtonStub,
|
|
200
|
+
CrossCircleFilled24,
|
|
201
|
+
ChevronDown24,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const listboxRef = (wrapper.vm as InstanceType<typeof MCombobox>).$refs
|
|
207
|
+
.listbox as { activeIndex: number };
|
|
208
|
+
expect(listboxRef).toBeTruthy();
|
|
209
|
+
|
|
210
|
+
listboxRef.activeIndex = 2;
|
|
211
|
+
await nextTick();
|
|
212
|
+
|
|
213
|
+
const control = wrapper.find('.mc-combobox__control');
|
|
214
|
+
const attr = control.attributes()['aria-activedescendant'];
|
|
215
|
+
expect(attr).toBeTruthy();
|
|
216
|
+
|
|
217
|
+
expect(attr.includes('-2')).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('clicking outside closes the listbox', async () => {
|
|
221
|
+
const wrapper = mount(MCombobox, {
|
|
222
|
+
props: { modelValue: null, options },
|
|
223
|
+
global: {
|
|
224
|
+
components: {
|
|
225
|
+
MOptionListbox: MOptionListboxStub,
|
|
226
|
+
MTag: MTagStub,
|
|
227
|
+
MButton: MButtonStub,
|
|
228
|
+
CrossCircleFilled24,
|
|
229
|
+
ChevronDown24,
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
attachTo: document.body,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const root = wrapper.find('.mc-combobox');
|
|
236
|
+
const control = wrapper.find('.mc-combobox__control');
|
|
237
|
+
|
|
238
|
+
await control.trigger('click');
|
|
239
|
+
expect(root.classes()).toContain('mc-combobox--open');
|
|
240
|
+
|
|
241
|
+
document.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
242
|
+
|
|
243
|
+
await nextTick();
|
|
244
|
+
expect(root.classes()).not.toContain('mc-combobox--open');
|
|
245
|
+
});
|
|
246
|
+
});
|