@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.
- package/dist/mozaic-vue.css +1 -1
- package/dist/mozaic-vue.d.ts +25 -17
- package/dist/mozaic-vue.js +1240 -1130
- package/dist/mozaic-vue.js.map +1 -1
- package/dist/mozaic-vue.umd.cjs +6 -6
- package/dist/mozaic-vue.umd.cjs.map +1 -1
- package/package.json +3 -3
- package/src/components/BrandPresets.mdx +20 -2
- package/src/components/actionlistbox/MActionListbox.spec.ts +99 -0
- package/src/components/actionlistbox/MActionListbox.vue +54 -7
- package/src/components/breadcrumb/MBreadcrumb.vue +1 -1
- package/src/components/button/MButton.spec.ts +26 -0
- package/src/components/button/MButton.vue +2 -0
- package/src/components/callout/MCallout.stories.ts +0 -3
- package/src/components/callout/MCallout.vue +4 -3
- package/src/components/callout/README.md +2 -2
- package/src/components/carousel/MCarousel.spec.ts +26 -2
- package/src/components/carousel/MCarousel.vue +10 -4
- package/src/components/combobox/MCombobox.vue +7 -0
- package/src/components/drawer/MDrawer.spec.ts +102 -3
- package/src/components/drawer/MDrawer.vue +73 -14
- package/src/components/field/MField.vue +1 -0
- package/src/components/fileuploader/MFileUploader.vue +2 -2
- package/src/components/fileuploaderitem/MFileUploaderItem.vue +2 -7
- package/src/components/iconbutton/MIconButton.spec.ts +15 -0
- package/src/components/iconbutton/MIconButton.vue +1 -0
- package/src/components/kpiitem/MKpiItem.spec.ts +13 -0
- package/src/components/kpiitem/MKpiItem.vue +1 -1
- package/src/components/modal/MModal.spec.ts +115 -3
- package/src/components/modal/MModal.vue +91 -11
- package/src/components/modal/README.md +1 -1
- package/src/components/overlay/MOverlay.spec.ts +1 -1
- package/src/components/overlay/MOverlay.vue +1 -1
- package/src/components/phonenumber/MPhoneNumber.spec.ts +6 -2
- package/src/components/phonenumber/MPhoneNumber.vue +20 -16
- package/src/components/sidebarexpandableitem/MSidebarExpandableItem.spec.ts +12 -0
- package/src/components/sidebarexpandableitem/MSidebarExpandableItem.vue +1 -0
- package/src/components/steppercompact/MStepperCompact.spec.ts +9 -0
- package/src/components/steppercompact/MStepperCompact.vue +1 -1
- package/src/components/stepperinline/MStepperInline.spec.ts +11 -0
- package/src/components/stepperinline/MStepperInline.vue +1 -1
- package/src/components/stepperstacked/MStepperStacked.spec.ts +13 -0
- package/src/components/stepperstacked/MStepperStacked.vue +1 -0
- package/src/components/textinput/MTextInput.vue +2 -2
- 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.
|
|
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": "^
|
|
55
|
-
"@commitlint/config-conventional": "^
|
|
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
|
|
107
|
+
@use '@mozaic-ds/tokens/adeo/theme' as *;
|
|
90
108
|
|
|
91
109
|
body {
|
|
92
|
-
|
|
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
|
|
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="
|
|
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');
|
|
@@ -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
|
|
41
|
+
title?: string;
|
|
41
42
|
/**
|
|
42
43
|
* Description of the callout.
|
|
43
44
|
*/
|
|
44
|
-
description
|
|
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
|
|
11
|
-
| `description
|
|
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 {
|
|
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
|
-
|
|
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="
|
|
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
|
|
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
|
|
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: {
|