@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.
Files changed (52) hide show
  1. package/dist/mozaic-vue.css +1 -1
  2. package/dist/mozaic-vue.d.ts +1582 -500
  3. package/dist/mozaic-vue.js +8020 -3218
  4. package/dist/mozaic-vue.js.map +1 -1
  5. package/dist/mozaic-vue.umd.cjs +24 -5
  6. package/dist/mozaic-vue.umd.cjs.map +1 -1
  7. package/package.json +6 -4
  8. package/src/components/DarkMode.mdx +115 -0
  9. package/src/components/actionlistbox/MActionListbox.spec.ts +20 -10
  10. package/src/components/actionlistbox/MActionListbox.stories.ts +15 -8
  11. package/src/components/actionlistbox/MActionListbox.vue +15 -12
  12. package/src/components/actionlistbox/README.md +2 -1
  13. package/src/components/avatar/MAvatar.stories.ts +1 -1
  14. package/src/components/breadcrumb/MBreadcrumb.vue +2 -2
  15. package/src/components/button/README.md +2 -0
  16. package/src/components/combobox/MCombobox.spec.ts +246 -0
  17. package/src/components/combobox/MCombobox.stories.ts +190 -0
  18. package/src/components/combobox/MCombobox.vue +277 -0
  19. package/src/components/combobox/README.md +52 -0
  20. package/src/components/field/MField.stories.ts +105 -0
  21. package/src/components/optionListbox/MOptionListbox.spec.ts +527 -0
  22. package/src/components/optionListbox/MOptionListbox.vue +470 -0
  23. package/src/components/optionListbox/README.md +63 -0
  24. package/src/components/pageheader/MPageHeader.spec.ts +12 -12
  25. package/src/components/pageheader/MPageHeader.stories.ts +9 -1
  26. package/src/components/pageheader/MPageHeader.vue +3 -6
  27. package/src/components/segmentedcontrol/MSegmentedControl.spec.ts +57 -25
  28. package/src/components/segmentedcontrol/MSegmentedControl.stories.ts +6 -19
  29. package/src/components/segmentedcontrol/MSegmentedControl.vue +27 -13
  30. package/src/components/segmentedcontrol/README.md +6 -3
  31. package/src/components/select/MSelect.vue +4 -3
  32. package/src/components/sidebar/stories/DefaultCase.stories.vue +2 -2
  33. package/src/components/sidebar/stories/README.md +8 -0
  34. package/src/components/sidebar/stories/WithExpandOnly.stories.vue +1 -1
  35. package/src/components/sidebar/stories/WithProfileInfoOnly.stories.vue +2 -2
  36. package/src/components/sidebar/stories/WithSingleLevel.stories.vue +2 -2
  37. package/src/components/stepperinline/MStepperInline.spec.ts +63 -28
  38. package/src/components/stepperinline/MStepperInline.stories.ts +18 -10
  39. package/src/components/stepperinline/MStepperInline.vue +24 -10
  40. package/src/components/stepperinline/README.md +6 -2
  41. package/src/components/stepperstacked/MStepperStacked.spec.ts +162 -0
  42. package/src/components/stepperstacked/MStepperStacked.stories.ts +57 -0
  43. package/src/components/stepperstacked/MStepperStacked.vue +106 -0
  44. package/src/components/stepperstacked/README.md +15 -0
  45. package/src/components/tabs/MTabs.stories.ts +18 -0
  46. package/src/components/tabs/MTabs.vue +30 -14
  47. package/src/components/tabs/Mtabs.spec.ts +56 -10
  48. package/src/components/tabs/README.md +6 -3
  49. package/src/components/textinput/MTextInput.vue +13 -1
  50. package/src/components/textinput/README.md +15 -1
  51. package/src/components/tileclickable/README.md +1 -1
  52. 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.14.0",
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.11.0",
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": "^27.0.0",
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": "^9.22.0",
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
- .toContain('mc-listbox--bottom');
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
- return { args };
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
- return { args };
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 type="button" class="mc-action-list__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
 
@@ -17,7 +17,7 @@ const meta: Meta<typeof MAvatar> = {
17
17
  },
18
18
  args: {
19
19
  default: `
20
- <img src="/images/Avatar.png" alt="Dieter Rams" loading="lazy"/>
20
+ <img src="/mozaic-vue/images/Avatar.png" alt="Dieter Rams" loading="lazy"/>
21
21
  `,
22
22
  },
23
23
  render: (args) => ({
@@ -18,8 +18,8 @@
18
18
  :aria-current="isLastLink(index) ? 'page' : undefined"
19
19
  >
20
20
  {{ link.label }}
21
- <template v-if="index !== (links.length - 1)" #icon>
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
+ });