@mozaic-ds/vue 2.18.0 → 2.19.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 (45) hide show
  1. package/dist/mozaic-vue.css +1 -1
  2. package/dist/mozaic-vue.d.ts +25 -17
  3. package/dist/mozaic-vue.js +1240 -1130
  4. package/dist/mozaic-vue.js.map +1 -1
  5. package/dist/mozaic-vue.umd.cjs +6 -6
  6. package/dist/mozaic-vue.umd.cjs.map +1 -1
  7. package/package.json +3 -3
  8. package/src/components/BrandPresets.mdx +20 -2
  9. package/src/components/actionlistbox/MActionListbox.spec.ts +99 -0
  10. package/src/components/actionlistbox/MActionListbox.vue +54 -7
  11. package/src/components/breadcrumb/MBreadcrumb.vue +1 -1
  12. package/src/components/button/MButton.spec.ts +26 -0
  13. package/src/components/button/MButton.vue +2 -0
  14. package/src/components/callout/MCallout.stories.ts +0 -3
  15. package/src/components/callout/MCallout.vue +4 -3
  16. package/src/components/callout/README.md +2 -2
  17. package/src/components/carousel/MCarousel.spec.ts +26 -2
  18. package/src/components/carousel/MCarousel.vue +10 -4
  19. package/src/components/combobox/MCombobox.vue +7 -0
  20. package/src/components/drawer/MDrawer.spec.ts +102 -3
  21. package/src/components/drawer/MDrawer.vue +73 -14
  22. package/src/components/field/MField.vue +1 -0
  23. package/src/components/fileuploader/MFileUploader.vue +2 -2
  24. package/src/components/fileuploaderitem/MFileUploaderItem.vue +2 -7
  25. package/src/components/iconbutton/MIconButton.spec.ts +15 -0
  26. package/src/components/iconbutton/MIconButton.vue +1 -0
  27. package/src/components/kpiitem/MKpiItem.spec.ts +13 -0
  28. package/src/components/kpiitem/MKpiItem.vue +1 -1
  29. package/src/components/modal/MModal.spec.ts +115 -3
  30. package/src/components/modal/MModal.vue +91 -11
  31. package/src/components/modal/README.md +1 -1
  32. package/src/components/overlay/MOverlay.spec.ts +1 -1
  33. package/src/components/overlay/MOverlay.vue +1 -1
  34. package/src/components/phonenumber/MPhoneNumber.spec.ts +6 -2
  35. package/src/components/phonenumber/MPhoneNumber.vue +20 -16
  36. package/src/components/sidebarexpandableitem/MSidebarExpandableItem.spec.ts +12 -0
  37. package/src/components/sidebarexpandableitem/MSidebarExpandableItem.vue +1 -0
  38. package/src/components/steppercompact/MStepperCompact.spec.ts +9 -0
  39. package/src/components/steppercompact/MStepperCompact.vue +1 -1
  40. package/src/components/stepperinline/MStepperInline.spec.ts +11 -0
  41. package/src/components/stepperinline/MStepperInline.vue +1 -1
  42. package/src/components/stepperstacked/MStepperStacked.spec.ts +13 -0
  43. package/src/components/stepperstacked/MStepperStacked.vue +1 -0
  44. package/src/components/textinput/MTextInput.vue +2 -2
  45. package/src/components/toggle/MToggle.vue +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mozaic-ds/vue",
3
- "version": "2.18.0",
3
+ "version": "2.19.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",
@@ -51,8 +51,8 @@
51
51
  "vue": "^3.5.13"
52
52
  },
53
53
  "devDependencies": {
54
- "@commitlint/cli": "^20.1.0",
55
- "@commitlint/config-conventional": "^20.0.0",
54
+ "@commitlint/cli": "^21.0.1",
55
+ "@commitlint/config-conventional": "^21.0.1",
56
56
  "@figma/code-connect": "^1.4.1",
57
57
  "@mozaic-ds/css-dev-tools": "1.75.0",
58
58
  "@mozaic-ds/datatable-vue": "^1.0.0",
@@ -80,16 +80,34 @@ The table below summarises which font to use depending on the brand.
80
80
  </tr>
81
81
  </table>
82
82
 
83
+ For example, here is how to include the Roboto font in your HTML for the Adeo brand:
84
+
85
+ <Source
86
+ language="html"
87
+ dark
88
+ code={`
89
+ <head>
90
+ <meta charset="UTF-8" />
91
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
92
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
93
+ <link
94
+ href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap"
95
+ rel="stylesheet"
96
+ />
97
+ </head>
98
+ `}
99
+ />
100
+
83
101
  From there, we can update the main style sheet of your project, in order to import the right font.
84
102
 
85
103
  <Source
86
104
  language='css'
87
105
  dark
88
106
  code={`
89
- @use "@mozaic-ds/styles/tools/t.font" as *;
107
+ @use '@mozaic-ds/tokens/adeo/theme' as *;
90
108
 
91
109
  body {
92
- @include set-font-family();
110
+ font-family: var(--font-family, 'Roboto', sans-serif);
93
111
  }
94
112
  `} />
95
113
 
@@ -135,4 +135,103 @@ describe('MActionListbox', () => {
135
135
  await actions[1].trigger('click');
136
136
  expect(wrapper.emitted('action')?.[1][0]).toBe('move');
137
137
  });
138
+
139
+ it('has role="menu" on the list and role="menuitem" on each button', () => {
140
+ const wrapper = mountComponent();
141
+ expect(wrapper.find('ul.mc-action-list').attributes('role')).toBe('menu');
142
+ const menuItems = wrapper.findAll('button[role="menuitem"]');
143
+ expect(menuItems.length).toBe(items.length);
144
+ });
145
+
146
+ describe('keyboard navigation', () => {
147
+ function mountAttached(props = {}) {
148
+ const div = document.createElement('div');
149
+ document.body.appendChild(div);
150
+ const wrapper = mount(MActionListbox, {
151
+ props: { items, ...props },
152
+ attachTo: div,
153
+ global: { components: { MDivider, MIconButton, Cross24 } },
154
+ });
155
+ return wrapper;
156
+ }
157
+
158
+ it('ArrowDown moves focus to the next item', async () => {
159
+ const wrapper = mountAttached();
160
+ const buttons = wrapper.findAll('button[role="menuitem"]');
161
+ await buttons[0].element.focus();
162
+ await wrapper.find('ul[role="menu"]').trigger('keydown', { key: 'ArrowDown' });
163
+ expect(document.activeElement).toBe(buttons[1].element);
164
+ wrapper.unmount();
165
+ });
166
+
167
+ it('ArrowDown wraps from last to first item', async () => {
168
+ const wrapper = mountAttached();
169
+ const buttons = wrapper.findAll('button[role="menuitem"]');
170
+ await buttons[buttons.length - 1].element.focus();
171
+ await wrapper.find('ul[role="menu"]').trigger('keydown', { key: 'ArrowDown' });
172
+ expect(document.activeElement).toBe(buttons[0].element);
173
+ wrapper.unmount();
174
+ });
175
+
176
+ it('ArrowUp moves focus to the previous item', async () => {
177
+ const wrapper = mountAttached();
178
+ const buttons = wrapper.findAll('button[role="menuitem"]');
179
+ await buttons[1].element.focus();
180
+ await wrapper.find('ul[role="menu"]').trigger('keydown', { key: 'ArrowUp' });
181
+ expect(document.activeElement).toBe(buttons[0].element);
182
+ wrapper.unmount();
183
+ });
184
+
185
+ it('ArrowUp wraps from first to last item', async () => {
186
+ const wrapper = mountAttached();
187
+ const buttons = wrapper.findAll('button[role="menuitem"]');
188
+ await buttons[0].element.focus();
189
+ await wrapper.find('ul[role="menu"]').trigger('keydown', { key: 'ArrowUp' });
190
+ expect(document.activeElement).toBe(buttons[buttons.length - 1].element);
191
+ wrapper.unmount();
192
+ });
193
+
194
+ it('Home moves focus to the first item', async () => {
195
+ const wrapper = mountAttached();
196
+ const buttons = wrapper.findAll('button[role="menuitem"]');
197
+ await buttons[2].element.focus();
198
+ await wrapper.find('ul[role="menu"]').trigger('keydown', { key: 'Home' });
199
+ expect(document.activeElement).toBe(buttons[0].element);
200
+ wrapper.unmount();
201
+ });
202
+
203
+ it('End moves focus to the last item', async () => {
204
+ const wrapper = mountAttached();
205
+ const buttons = wrapper.findAll('button[role="menuitem"]');
206
+ await buttons[0].element.focus();
207
+ await wrapper.find('ul[role="menu"]').trigger('keydown', { key: 'End' });
208
+ expect(document.activeElement).toBe(buttons[buttons.length - 1].element);
209
+ wrapper.unmount();
210
+ });
211
+
212
+ it('Escape emits "close"', async () => {
213
+ const wrapper = mountAttached();
214
+ await wrapper.find('ul[role="menu"]').trigger('keydown', { key: 'Escape' });
215
+ expect(wrapper.emitted('close')).toBeTruthy();
216
+ wrapper.unmount();
217
+ });
218
+
219
+ it('disabled items are skipped during ArrowDown navigation', async () => {
220
+ const itemsWithDisabled = [
221
+ { label: 'First' },
222
+ { label: 'Disabled', disabled: true },
223
+ { label: 'Third' },
224
+ ];
225
+ const wrapper = mount(MActionListbox, {
226
+ props: { items: itemsWithDisabled },
227
+ attachTo: document.body,
228
+ global: { components: { MDivider, MIconButton, Cross24 } },
229
+ });
230
+ const enabledButtons = wrapper.findAll('button[role="menuitem"]:not([disabled])');
231
+ await enabledButtons[0].element.focus();
232
+ await wrapper.find('ul[role="menu"]').trigger('keydown', { key: 'ArrowDown' });
233
+ expect(document.activeElement).toBe(enabledButtons[1].element);
234
+ wrapper.unmount();
235
+ });
236
+ });
138
237
  });
@@ -9,9 +9,10 @@
9
9
  ref="popover"
10
10
  class="mc-listbox__content"
11
11
  v-bind="$slots.activator ? { id, popover: '' } : {}"
12
+ @toggle="onPopoverToggle"
12
13
  >
13
14
  <div class="mc-listbox__header">
14
- <h3 v-if="title" class="mc-listbox__title">{{ title }}</h3>
15
+ <h3 v-if="title" :id="`${id}-title`" class="mc-listbox__title">{{ title }}</h3>
15
16
  <MIconButton
16
17
  class="mc-listbox__close"
17
18
  ghost
@@ -24,7 +25,15 @@
24
25
  </MIconButton>
25
26
  </div>
26
27
  <div class="mc-listbox__body">
27
- <ul class="mc-action-list" role="menu">
28
+ <ul
29
+ ref="menuEl"
30
+ class="mc-action-list"
31
+ role="menu"
32
+ tabindex="-1"
33
+ :aria-label="title || undefined"
34
+ :aria-labelledby="title ? `${id}-title` : undefined"
35
+ @keydown="onMenuKeydown"
36
+ >
28
37
  <template v-for="(item, index) in items" :key="`item-${index}`">
29
38
  <MDivider
30
39
  v-if="item.divider"
@@ -35,22 +44,24 @@
35
44
  :class="[
36
45
  'mc-action-list__element',
37
46
  {
38
- 'mc-action-list__element--danger':
39
- item.appearance === 'danger',
47
+ 'mc-action-list__element--danger': item.appearance === 'danger',
40
48
  'mc-action-list__element--disabled': item.disabled,
41
49
  },
42
50
  ]"
43
- role="menuitem"
51
+ role="presentation"
44
52
  >
45
53
  <button
46
54
  type="button"
55
+ role="menuitem"
47
56
  class="mc-action-list__button"
57
+ :disabled="item.disabled || undefined"
48
58
  @click="emit('action', item?.id || index)"
49
59
  >
50
60
  <component
51
61
  v-if="item.icon"
52
62
  class="mc-action-list__icon"
53
63
  :is="item.icon"
64
+ aria-hidden="true"
54
65
  />
55
66
  <p class="mc-action-list__text">{{ item.label }}</p>
56
67
  </button>
@@ -65,7 +76,7 @@
65
76
  </template>
66
77
 
67
78
  <script setup lang="ts">
68
- import { useId, useTemplateRef, type Component, type VNode } from 'vue';
79
+ import { nextTick, useId, useTemplateRef, type Component, type VNode } from 'vue';
69
80
  import MIconButton from '../iconbutton/MIconButton.vue';
70
81
  import MDivider from '../divider/MDivider.vue';
71
82
  import { Cross24 } from '@mozaic-ds/icons-vue';
@@ -137,8 +148,44 @@ const slots = defineSlots<{
137
148
  }>();
138
149
 
139
150
  const id = useId();
140
-
141
151
  const popover = useTemplateRef('popover');
152
+ const menuEl = useTemplateRef('menuEl');
153
+
154
+ function getMenuItems(): HTMLButtonElement[] {
155
+ return Array.from(
156
+ menuEl.value?.querySelectorAll<HTMLButtonElement>('button[role="menuitem"]:not(:disabled)') ?? [],
157
+ );
158
+ }
159
+
160
+ function onMenuKeydown(e: KeyboardEvent) {
161
+ const items = getMenuItems();
162
+ if (!items.length) return;
163
+ const current = items.findIndex((el) => el === document.activeElement);
164
+
165
+ if (e.key === 'ArrowDown') {
166
+ e.preventDefault();
167
+ items[(current + 1) % items.length].focus();
168
+ } else if (e.key === 'ArrowUp') {
169
+ e.preventDefault();
170
+ items[(current - 1 + items.length) % items.length].focus();
171
+ } else if (e.key === 'Home') {
172
+ e.preventDefault();
173
+ items[0].focus();
174
+ } else if (e.key === 'End') {
175
+ e.preventDefault();
176
+ items[items.length - 1].focus();
177
+ } else if (e.key === 'Escape') {
178
+ close();
179
+ }
180
+ }
181
+
182
+ function onPopoverToggle(e: ToggleEvent) {
183
+ if (e.newState === 'open') {
184
+ nextTick(() => getMenuItems()[0]?.focus());
185
+ } else {
186
+ document.querySelector<HTMLElement>(`[popovertarget="${id}"]`)?.focus();
187
+ }
188
+ }
142
189
 
143
190
  function close() {
144
191
  emit('close');
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <nav class="mc-breadcrumb" :class="classObject">
2
+ <nav class="mc-breadcrumb" :class="classObject" aria-label="Breadcrumb">
3
3
  <ul class="mc-breadcrumb__container">
4
4
  <li
5
5
  class="mc-breadcrumb__item"
@@ -188,4 +188,30 @@ describe('MButton component', () => {
188
188
  expect(label.exists()).toBe(true);
189
189
  expect(label.text()).toBe('Normal Button');
190
190
  });
191
+
192
+ it('sets aria-busy="true" when isLoading is true', () => {
193
+ const wrapper = mount(MButton, {
194
+ props: { isLoading: true },
195
+ slots: { default: 'Loading' },
196
+ });
197
+ expect(wrapper.find('button').attributes('aria-busy')).toBe('true');
198
+ });
199
+
200
+ it('does not set aria-busy when isLoading is false', () => {
201
+ const wrapper = mount(MButton, {
202
+ props: { isLoading: false },
203
+ slots: { default: 'Normal' },
204
+ });
205
+ expect(wrapper.find('button').attributes('aria-busy')).toBeUndefined();
206
+ });
207
+
208
+ it('sets aria-hidden on the loader wrapper', () => {
209
+ const wrapper = mount(MButton, {
210
+ props: { isLoading: true },
211
+ slots: { default: 'Loading' },
212
+ });
213
+ const loaderWrapper = wrapper.find('.mc-button__icon[aria-hidden]');
214
+ expect(loaderWrapper.exists()).toBe(true);
215
+ expect(loaderWrapper.attributes('aria-hidden')).toBe('true');
216
+ });
191
217
  });
@@ -4,6 +4,7 @@
4
4
  :class="classObject"
5
5
  :disabled="disabled"
6
6
  :type="type"
7
+ :aria-busy="isLoading || undefined"
7
8
  >
8
9
  <span
9
10
  v-if="$slots.icon && iconPosition == 'left' && !isLoading"
@@ -15,6 +16,7 @@
15
16
  v-if="isLoading"
16
17
  class="mc-button__icon"
17
18
  :style="{ position: 'absolute' }"
19
+ aria-hidden="true"
18
20
  >
19
21
  <MLoader :style="{ color: 'currentColor' }" size="s" />
20
22
  </span>
@@ -18,9 +18,6 @@ const meta: Meta<typeof MCallout> = {
18
18
  },
19
19
  args: {
20
20
  icon: '<ImageAlt32 aria-hidden="true" />',
21
- title:
22
- 'This is a title, be concise and use the description message to give details.',
23
- description: 'Description message.',
24
21
  },
25
22
  render: (args) => ({
26
23
  components: { MCallout, MButton, MLink, ArrowNext20, ImageAlt32 },
@@ -10,13 +10,14 @@
10
10
  </div>
11
11
  <div class="mc-callout__content">
12
12
  <component
13
+ v-if="title"
13
14
  :is="props.tag"
14
15
  :id="`callout-title-${id}`"
15
16
  class="mc-callout__title"
16
17
  >{{ title }}</component
17
18
  >
18
19
 
19
- <p class="mc-callout__message">
20
+ <p v-if="description" class="mc-callout__message">
20
21
  {{ description }}
21
22
  </p>
22
23
 
@@ -37,11 +38,11 @@ const props = withDefaults(
37
38
  /**
38
39
  * Title of the callout.
39
40
  */
40
- title: string;
41
+ title?: string;
41
42
  /**
42
43
  * Description of the callout.
43
44
  */
44
- description: string;
45
+ description?: string;
45
46
  /**
46
47
  * Allows to define the callout appearance.
47
48
  */
@@ -7,8 +7,8 @@ A callout is used to highlight additional information that can assist users with
7
7
 
8
8
  | Name | Description | Type | Default |
9
9
  | --- | --- | --- | --- |
10
- | `title*` | Title of the callout. | `string` | - |
11
- | `description*` | Description of the callout. | `string` | - |
10
+ | `title` | Title of the callout. | `string` | - |
11
+ | `description` | Description of the callout. | `string` | - |
12
12
  | `appearance` | Allows to define the callout appearance. | `"standard"` `"inverse"` `"accent"` `"tips"` | `"standard"` |
13
13
  | `tag` | Heading level for the callout title (h2–h6). Adjust to match the
14
14
  heading hierarchy of the page where the callout is used. | `"h2"` `"h1"` `"h3"` `"h4"` `"h5"` `"h6"` | `"h2"` |
@@ -1,17 +1,28 @@
1
1
  import { mount } from '@vue/test-utils';
2
- import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
2
+ import {
3
+ describe,
4
+ it,
5
+ expect,
6
+ vi,
7
+ beforeAll,
8
+ afterAll,
9
+ beforeEach,
10
+ } from 'vitest';
3
11
  import MCarousel from './MCarousel.vue';
4
12
  import MIconButton from '../iconbutton/MIconButton.vue';
5
13
  import { ChevronLeft20, ChevronRight20 } from '@mozaic-ds/icons-vue';
6
14
 
7
15
  /* eslint-disable @typescript-eslint/no-explicit-any */
8
16
 
17
+ const observerInstances: MockIntersectionObserver[] = [];
18
+
9
19
  class MockIntersectionObserver {
10
20
  callback: any;
11
21
  options: any;
12
22
  constructor(callback: any, options?: any) {
13
23
  this.callback = callback;
14
24
  this.options = options;
25
+ observerInstances.push(this);
15
26
  }
16
27
  observe = vi.fn();
17
28
  unobserve = vi.fn();
@@ -21,6 +32,10 @@ class MockIntersectionObserver {
21
32
  describe('MCarousel component', () => {
22
33
  let originalObserver: any;
23
34
 
35
+ beforeEach(() => {
36
+ observerInstances.length = 0;
37
+ });
38
+
24
39
  beforeAll(() => {
25
40
  originalObserver = global.IntersectionObserver;
26
41
  global.IntersectionObserver = MockIntersectionObserver as any;
@@ -132,6 +147,15 @@ describe('MCarousel component', () => {
132
147
  const container = wrapper.find('.mc-carousel');
133
148
  expect(container.attributes('role')).toBe('group');
134
149
  expect(container.attributes('aria-roledescription')).toBe('carousel');
135
- expect(container.attributes('aria-labelledby')).toBe('mc-carousel__title');
150
+ // aria-labelledby should point to the headings wrapper (dynamic id)
151
+ const labelledby = container.attributes('aria-labelledby');
152
+ expect(labelledby).toBeDefined();
153
+ expect(wrapper.find(`#${labelledby}`).exists()).toBe(true);
154
+ });
155
+
156
+ it('slide container has aria-live="polite"', () => {
157
+ const wrapper = mountCarousel();
158
+ const content = wrapper.find('.mc-carousel__content');
159
+ expect(content.attributes('aria-live')).toBe('polite');
136
160
  });
137
161
  });
@@ -3,10 +3,10 @@
3
3
  class="mc-carousel"
4
4
  role="group"
5
5
  aria-roledescription="carousel"
6
- aria-labelledby="mc-carousel__title"
6
+ :aria-labelledby="titleId"
7
7
  >
8
8
  <div class="mc-carousel__header">
9
- <div class="mc-carousel__headings">
9
+ <div :id="titleId" class="mc-carousel__headings">
10
10
  <slot name="header" />
11
11
  </div>
12
12
  <div class="mc-carousel__controls">
@@ -34,7 +34,12 @@
34
34
  </MIconButton>
35
35
  </div>
36
36
  </div>
37
- <div class="mc-carousel__content" ref="contentContainer">
37
+ <div
38
+ class="mc-carousel__content"
39
+ ref="contentContainer"
40
+ aria-live="polite"
41
+ aria-atomic="false"
42
+ >
38
43
  <template
39
44
  v-for="(child, index) in $slots.default?.()"
40
45
  :key="`carousel-slide-${index}`"
@@ -46,7 +51,7 @@
46
51
  </template>
47
52
 
48
53
  <script setup lang="ts">
49
- import { computed, onMounted, ref, type VNode } from 'vue';
54
+ import { computed, onMounted, ref, useId, type VNode } from 'vue';
50
55
  import MIconButton from '../iconbutton/MIconButton.vue';
51
56
  import { ChevronLeft20, ChevronRight20 } from '@mozaic-ds/icons-vue';
52
57
  /**
@@ -80,6 +85,7 @@ defineSlots<{
80
85
  header: VNode;
81
86
  }>();
82
87
 
88
+ const titleId = useId();
83
89
  const activeIndex = ref<number>(0);
84
90
  const contentContainer = ref<HTMLElement | null>(null);
85
91
 
@@ -65,6 +65,7 @@
65
65
  <MOptionListbox
66
66
  ref="listbox"
67
67
  v-model="selection"
68
+ @update:model-value="onSelectionUpdate"
68
69
  :id
69
70
  :open="isOpen"
70
71
  :multiple
@@ -267,6 +268,12 @@ function close() {
267
268
  document.removeEventListener('click', handleClickOutside);
268
269
  }
269
270
 
271
+ function onSelectionUpdate() {
272
+ if (!props.multiple) {
273
+ close();
274
+ }
275
+ }
276
+
270
277
  function toggleListbox() {
271
278
  return isOpen.value ? close() : open();
272
279
  }
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, vi } from 'vitest';
2
2
  import { mount } from '@vue/test-utils';
3
3
  import MDrawer from '@/components/drawer/MDrawer.vue';
4
4
 
@@ -11,7 +11,7 @@ const stubs = {
11
11
  },
12
12
  MOverlay: {
13
13
  name: 'MOverlay',
14
- template: `<div class="overlay" @click="$emit('click')"><slot/></div>`,
14
+ template: `<div class="overlay" @click="$emit('click', $event)"><slot/></div>`,
15
15
  },
16
16
  };
17
17
 
@@ -211,11 +211,39 @@ describe('MDrawer component', () => {
211
211
  global: { stubs },
212
212
  });
213
213
 
214
- await wrapper.find('section.mc-drawer').trigger('keydown.esc');
214
+ await wrapper
215
+ .find('section.mc-drawer')
216
+ .trigger('keydown', { key: 'Escape' });
215
217
  expect(wrapper.emitted('update:open')).toBeTruthy();
216
218
  expect(wrapper.emitted('update:open')!.at(-1)).toEqual([false]);
217
219
  });
218
220
 
221
+ it('stops Escape propagation after closing', async () => {
222
+ const onDocumentKeydown = vi.fn();
223
+ document.addEventListener('keydown', onDocumentKeydown);
224
+
225
+ const wrapper = mount(MDrawer, {
226
+ props: {
227
+ open: true,
228
+ title: 'Test Title',
229
+ },
230
+ attachTo: document.body,
231
+ global: { stubs },
232
+ });
233
+
234
+ wrapper
235
+ .find('section.mc-drawer')
236
+ .element.dispatchEvent(
237
+ new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
238
+ );
239
+
240
+ expect(wrapper.emitted('update:open')!.at(-1)).toEqual([false]);
241
+ expect(onDocumentKeydown).not.toHaveBeenCalled();
242
+
243
+ document.removeEventListener('keydown', onDocumentKeydown);
244
+ wrapper.unmount();
245
+ });
246
+
219
247
  it('locks and unlocks scroll when scroll=false and open changes', async () => {
220
248
  const wrapper = mount(MDrawer, {
221
249
  props: {
@@ -252,6 +280,77 @@ describe('MDrawer component', () => {
252
280
  expect(document.body.style.overflow).toBe('');
253
281
  });
254
282
 
283
+ it('sets inert on section when closed', async () => {
284
+ const wrapper = mount(MDrawer, {
285
+ props: { open: false, title: 'Test' },
286
+ global: { stubs },
287
+ });
288
+ // JSDOM renders :inert="true" as "true" — not.toBeUndefined() is the correct check
289
+ expect(
290
+ wrapper.find('section.mc-drawer').attributes('inert'),
291
+ ).not.toBeUndefined();
292
+
293
+ await wrapper.setProps({ open: true });
294
+ expect(
295
+ wrapper.find('section.mc-drawer').attributes('inert'),
296
+ ).toBeUndefined();
297
+ });
298
+
299
+ it('does not render aria-modal when closed', async () => {
300
+ const wrapper = mount(MDrawer, {
301
+ props: { open: false, title: 'Test' },
302
+ global: { stubs },
303
+ });
304
+ expect(
305
+ wrapper.find('section.mc-drawer').attributes('aria-modal'),
306
+ ).toBeUndefined();
307
+
308
+ await wrapper.setProps({ open: true });
309
+ expect(wrapper.find('section.mc-drawer').attributes('aria-modal')).toBe(
310
+ 'true',
311
+ );
312
+ });
313
+
314
+ it('traps focus forward: Tab from last focusable wraps to first', async () => {
315
+ const wrapper = mount(MDrawer, {
316
+ props: { open: true, title: 'Test', back: true },
317
+ attachTo: document.body,
318
+ global: { stubs },
319
+ });
320
+
321
+ const buttons = wrapper.findAll('button');
322
+ const lastEl = buttons[buttons.length - 1].element as HTMLElement;
323
+ lastEl.focus();
324
+
325
+ await wrapper
326
+ .find('section.mc-drawer')
327
+ .trigger('keydown', { key: 'Tab', shiftKey: false });
328
+
329
+ // focus should wrap to the first focusable button (back button)
330
+ expect(document.activeElement).toBe(buttons[0].element);
331
+ wrapper.unmount();
332
+ });
333
+
334
+ it('traps focus backward: Shift+Tab from first focusable wraps to last', async () => {
335
+ const wrapper = mount(MDrawer, {
336
+ props: { open: true, title: 'Test', back: true },
337
+ attachTo: document.body,
338
+ global: { stubs },
339
+ });
340
+
341
+ const buttons = wrapper.findAll('button');
342
+ const firstEl = buttons[0].element as HTMLElement;
343
+ firstEl.focus();
344
+
345
+ await wrapper
346
+ .find('section.mc-drawer')
347
+ .trigger('keydown', { key: 'Tab', shiftKey: true });
348
+
349
+ // focus should wrap to the last focusable button (close button)
350
+ expect(document.activeElement).toBe(buttons[buttons.length - 1].element);
351
+ wrapper.unmount();
352
+ });
353
+
255
354
  it('emits update:open on mount reflecting initial state', () => {
256
355
  const wrapper = mount(MDrawer, {
257
356
  props: {