@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
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<MOverlay
|
|
3
3
|
:is-visible="open"
|
|
4
|
-
dialogLabel="drawerTitle"
|
|
4
|
+
:dialogLabel="`drawerTitle-${id}`"
|
|
5
5
|
@click="onClickOverlay"
|
|
6
6
|
>
|
|
7
7
|
<section
|
|
8
|
+
ref="sectionRef"
|
|
8
9
|
class="mc-drawer"
|
|
9
10
|
:class="classObject"
|
|
10
11
|
role="dialog"
|
|
11
|
-
aria-labelledby="drawerTitle"
|
|
12
|
-
:aria-modal="open ? 'true' : 'false'"
|
|
12
|
+
:aria-labelledby="`drawerTitle-${id}`"
|
|
13
13
|
tabindex="-1"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
v-bind="{
|
|
15
|
+
...$attrs,
|
|
16
|
+
...(open
|
|
17
|
+
? {
|
|
18
|
+
onKeydown: handleKeydown,
|
|
19
|
+
'aria-modal': 'true',
|
|
20
|
+
}
|
|
21
|
+
: {
|
|
22
|
+
inert: 'true',
|
|
23
|
+
}),
|
|
24
|
+
}"
|
|
18
25
|
>
|
|
19
26
|
<div class="mc-drawer__dialog" role="document">
|
|
20
27
|
<div class="mc-drawer__header">
|
|
@@ -26,13 +33,13 @@
|
|
|
26
33
|
@click="emit('back')"
|
|
27
34
|
>
|
|
28
35
|
<template #icon>
|
|
29
|
-
<ArrowBack24
|
|
36
|
+
<ArrowBack24 />
|
|
30
37
|
</template>
|
|
31
38
|
</MIconButton>
|
|
32
39
|
<h2
|
|
33
40
|
class="mc-drawer__title"
|
|
34
41
|
tabindex="-1"
|
|
35
|
-
id="drawerTitle"
|
|
42
|
+
:id="`drawerTitle-${id}`"
|
|
36
43
|
ref="titleRef"
|
|
37
44
|
>
|
|
38
45
|
{{ title }}
|
|
@@ -44,12 +51,12 @@
|
|
|
44
51
|
@click="onClose"
|
|
45
52
|
>
|
|
46
53
|
<template #icon>
|
|
47
|
-
<Cross24
|
|
54
|
+
<Cross24 />
|
|
48
55
|
</template>
|
|
49
56
|
</MIconButton>
|
|
50
57
|
</div>
|
|
51
58
|
<div class="mc-drawer__body">
|
|
52
|
-
<div class="mc-drawer__content"
|
|
59
|
+
<div class="mc-drawer__content">
|
|
53
60
|
<h2 v-if="contentTitle" class="mc-drawer__content__title">
|
|
54
61
|
{{ contentTitle }}
|
|
55
62
|
</h2>
|
|
@@ -65,7 +72,15 @@
|
|
|
65
72
|
</template>
|
|
66
73
|
|
|
67
74
|
<script setup lang="ts">
|
|
68
|
-
import {
|
|
75
|
+
import {
|
|
76
|
+
computed,
|
|
77
|
+
watch,
|
|
78
|
+
type VNode,
|
|
79
|
+
ref,
|
|
80
|
+
onMounted,
|
|
81
|
+
onUnmounted,
|
|
82
|
+
useId,
|
|
83
|
+
} from 'vue';
|
|
69
84
|
import { ArrowBack24, Cross24 } from '@mozaic-ds/icons-vue';
|
|
70
85
|
import MIconButton from '../iconbutton/MIconButton.vue';
|
|
71
86
|
import MOverlay from '../overlay/MOverlay.vue';
|
|
@@ -123,6 +138,8 @@ defineSlots<{
|
|
|
123
138
|
footer?: VNode;
|
|
124
139
|
}>();
|
|
125
140
|
|
|
141
|
+
const id = useId();
|
|
142
|
+
|
|
126
143
|
const classObject = computed(() => {
|
|
127
144
|
return {
|
|
128
145
|
'is-open': props.open,
|
|
@@ -133,6 +150,45 @@ const classObject = computed(() => {
|
|
|
133
150
|
});
|
|
134
151
|
|
|
135
152
|
const titleRef = ref<HTMLElement | null>(null);
|
|
153
|
+
const sectionRef = ref<HTMLElement | null>(null);
|
|
154
|
+
|
|
155
|
+
function getFocusableElements(): HTMLElement[] {
|
|
156
|
+
if (!sectionRef.value) return [];
|
|
157
|
+
return Array.from(
|
|
158
|
+
sectionRef.value.querySelectorAll<HTMLElement>(
|
|
159
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
165
|
+
if (e.key === 'Escape') {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
e.stopPropagation();
|
|
168
|
+
onClose();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (e.key === 'Tab') {
|
|
172
|
+
const focusable = getFocusableElements();
|
|
173
|
+
if (!focusable.length) {
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const first = focusable[0];
|
|
178
|
+
const last = focusable[focusable.length - 1];
|
|
179
|
+
if (e.shiftKey) {
|
|
180
|
+
if (document.activeElement === first) {
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
last.focus();
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
if (document.activeElement === last) {
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
first.focus();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
136
192
|
const isClient =
|
|
137
193
|
typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
138
194
|
|
|
@@ -169,8 +225,11 @@ onUnmounted(() => {
|
|
|
169
225
|
unlockScroll();
|
|
170
226
|
});
|
|
171
227
|
|
|
172
|
-
const onClickOverlay = () => {
|
|
173
|
-
if (
|
|
228
|
+
const onClickOverlay = (event: MouseEvent) => {
|
|
229
|
+
if (
|
|
230
|
+
props.closeOnOverlay &&
|
|
231
|
+
!sectionRef.value?.contains(event.target as Node)
|
|
232
|
+
) {
|
|
174
233
|
onClose();
|
|
175
234
|
}
|
|
176
235
|
};
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
<input
|
|
10
10
|
ref="fileInput"
|
|
11
11
|
type="file"
|
|
12
|
-
aria-
|
|
12
|
+
aria-hidden="true"
|
|
13
|
+
tabindex="-1"
|
|
13
14
|
:accept="props.accept"
|
|
14
15
|
:multiple="props.multiple"
|
|
15
16
|
class="mc-file-uploader__hidden-input"
|
|
16
17
|
:disabled="props.disabled"
|
|
17
|
-
:aria-disabled="props.disabled"
|
|
18
18
|
@change="onChange"
|
|
19
19
|
/>
|
|
20
20
|
|
|
@@ -40,13 +40,7 @@
|
|
|
40
40
|
<template v-if="isStacked">
|
|
41
41
|
<MDivider />
|
|
42
42
|
<div class="mc-file-uploader-item__actions-container">
|
|
43
|
-
<MButton
|
|
44
|
-
ghost
|
|
45
|
-
size="s"
|
|
46
|
-
icon-position="left"
|
|
47
|
-
aria-label="Delete file"
|
|
48
|
-
@click="emit('delete')"
|
|
49
|
-
>
|
|
43
|
+
<MButton ghost size="s" icon-position="left" @click="emit('delete')">
|
|
50
44
|
{{ props.deleteButtonLabel }}
|
|
51
45
|
|
|
52
46
|
<template #icon>
|
|
@@ -78,6 +72,7 @@
|
|
|
78
72
|
<span
|
|
79
73
|
v-if="!valid && (props.errorMessage || slots.errorMessage)"
|
|
80
74
|
class="mc-file-uploader-item__error-message"
|
|
75
|
+
role="alert"
|
|
81
76
|
>
|
|
82
77
|
<slot name="errorMessage">
|
|
83
78
|
{{ props.errorMessage }}
|
|
@@ -92,6 +92,21 @@ describe('MButton component', () => {
|
|
|
92
92
|
expect(button.attributes('type')).toBe('button');
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
+
it('sets aria-busy when isLoading is true', () => {
|
|
96
|
+
const wrapper = mount(MIconButton, {
|
|
97
|
+
props: { isLoading: true },
|
|
98
|
+
slots: { icon: [ChevronRight24] },
|
|
99
|
+
});
|
|
100
|
+
expect(wrapper.find('button').attributes('aria-busy')).toBe('true');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('does not set aria-busy when not loading', () => {
|
|
104
|
+
const wrapper = mount(MIconButton, {
|
|
105
|
+
slots: { icon: [ChevronRight24] },
|
|
106
|
+
});
|
|
107
|
+
expect(wrapper.find('button').attributes('aria-busy')).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
95
110
|
it('can have type="submit" when the type prop is "submit"', () => {
|
|
96
111
|
const wrapper = mount(MIconButton, {
|
|
97
112
|
props: {
|
|
@@ -67,5 +67,18 @@ describe('MKpiItem component', () => {
|
|
|
67
67
|
|
|
68
68
|
expect(wrapper.find('.mc-kpi__icon').exists()).toBe(true);
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
it.each([
|
|
72
|
+
['increasing'],
|
|
73
|
+
['decreasing'],
|
|
74
|
+
['stable'],
|
|
75
|
+
] as const)('gives the trend icon an accessible label for "%s"', (trend) => {
|
|
76
|
+
const wrapper = mount(KpiItem, {
|
|
77
|
+
props: { value: '123', trend },
|
|
78
|
+
});
|
|
79
|
+
const icon = wrapper.find('.mc-kpi__icon');
|
|
80
|
+
expect(icon.attributes('role')).toBe('img');
|
|
81
|
+
expect(icon.attributes('aria-label')).toBe(trend);
|
|
82
|
+
});
|
|
70
83
|
});
|
|
71
84
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { nextTick } from 'vue';
|
|
2
3
|
import { mount } from '@vue/test-utils';
|
|
3
4
|
import MModal from './MModal.vue';
|
|
4
5
|
|
|
@@ -77,17 +78,17 @@ describe('MModal component', () => {
|
|
|
77
78
|
expect(wrapper.find('.link-slot').exists()).toBe(true);
|
|
78
79
|
});
|
|
79
80
|
|
|
80
|
-
it('has
|
|
81
|
+
it('has inert attribute set correctly based on open prop', async () => {
|
|
81
82
|
const wrapper = mount(MModal, {
|
|
82
83
|
props: { open: false, title: 'Title' },
|
|
83
84
|
global: { stubs },
|
|
84
85
|
});
|
|
85
86
|
|
|
86
|
-
expect(wrapper.find('.mc-modal').attributes('
|
|
87
|
+
expect(wrapper.find('.mc-modal').attributes('inert')).toBeDefined();
|
|
87
88
|
|
|
88
89
|
await wrapper.setProps({ open: true });
|
|
89
90
|
|
|
90
|
-
expect(wrapper.find('.mc-modal').attributes('
|
|
91
|
+
expect(wrapper.find('.mc-modal').attributes('inert')).toBeUndefined();
|
|
91
92
|
});
|
|
92
93
|
|
|
93
94
|
it('adds "is-open" class when open is true', async () => {
|
|
@@ -160,4 +161,115 @@ describe('MModal component', () => {
|
|
|
160
161
|
expect(document.body.style.overflow).toBe('');
|
|
161
162
|
expect(document.documentElement.style.overflow).toBe('');
|
|
162
163
|
});
|
|
164
|
+
|
|
165
|
+
it('has role="dialog" on the modal element', () => {
|
|
166
|
+
const wrapper = mount(MModal, {
|
|
167
|
+
props: { open: true, title: 'Title' },
|
|
168
|
+
global: { stubs },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(wrapper.find('.mc-modal').attributes('role')).toBe('dialog');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('sets aria-modal based on open prop', async () => {
|
|
175
|
+
const wrapper = mount(MModal, {
|
|
176
|
+
props: { open: false, title: 'Title' },
|
|
177
|
+
global: { stubs },
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(wrapper.find('.mc-modal').attributes('aria-modal')).toBe('false');
|
|
181
|
+
|
|
182
|
+
await wrapper.setProps({ open: true });
|
|
183
|
+
|
|
184
|
+
expect(wrapper.find('.mc-modal').attributes('aria-modal')).toBe('true');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('links aria-labelledby to the title element', () => {
|
|
188
|
+
const wrapper = mount(MModal, {
|
|
189
|
+
props: { open: true, title: 'Accessible Title' },
|
|
190
|
+
global: { stubs },
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const modal = wrapper.find('.mc-modal');
|
|
194
|
+
const labelledBy = modal.attributes('aria-labelledby');
|
|
195
|
+
expect(labelledBy).toMatch(/^modalTitle-/);
|
|
196
|
+
|
|
197
|
+
const title = wrapper.find(`#${labelledBy}`);
|
|
198
|
+
expect(title.exists()).toBe(true);
|
|
199
|
+
expect(title.text()).toBe('Accessible Title');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('closes when Escape key is pressed', async () => {
|
|
203
|
+
const wrapper = mount(MModal, {
|
|
204
|
+
props: { open: true, title: 'Title' },
|
|
205
|
+
global: { stubs },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await wrapper.find('.mc-modal').trigger('keydown', { key: 'Escape' });
|
|
209
|
+
|
|
210
|
+
const emitted = wrapper.emitted('update:open');
|
|
211
|
+
expect(emitted).toBeTruthy();
|
|
212
|
+
expect(emitted![emitted!.length - 1]).toEqual([false]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('moves focus to the modal when opened', async () => {
|
|
216
|
+
const wrapper = mount(MModal, {
|
|
217
|
+
props: { open: false, title: 'Title' },
|
|
218
|
+
global: { stubs },
|
|
219
|
+
attachTo: document.body,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
await wrapper.setProps({ open: true });
|
|
223
|
+
await nextTick();
|
|
224
|
+
|
|
225
|
+
expect(document.activeElement).toBe(wrapper.find('.mc-modal').element);
|
|
226
|
+
|
|
227
|
+
wrapper.unmount();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('traps focus from last to first element on Tab', async () => {
|
|
231
|
+
const wrapper = mount(MModal, {
|
|
232
|
+
props: { open: true, title: 'Title', closable: true },
|
|
233
|
+
slots: {
|
|
234
|
+
default: '<button data-test="last-focusable">Last</button>',
|
|
235
|
+
},
|
|
236
|
+
global: { stubs },
|
|
237
|
+
attachTo: document.body,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await nextTick();
|
|
241
|
+
|
|
242
|
+
const closeButton = wrapper.find('button.mc-modal__close');
|
|
243
|
+
const lastFocusable = wrapper.find('[data-test="last-focusable"]');
|
|
244
|
+
|
|
245
|
+
(lastFocusable.element as HTMLButtonElement).focus();
|
|
246
|
+
await lastFocusable.trigger('keydown', { key: 'Tab' });
|
|
247
|
+
|
|
248
|
+
expect(document.activeElement).toBe(closeButton.element);
|
|
249
|
+
|
|
250
|
+
wrapper.unmount();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('traps focus from first to last element on Shift+Tab', async () => {
|
|
254
|
+
const wrapper = mount(MModal, {
|
|
255
|
+
props: { open: true, title: 'Title', closable: true },
|
|
256
|
+
slots: {
|
|
257
|
+
default: '<button data-test="last-focusable">Last</button>',
|
|
258
|
+
},
|
|
259
|
+
global: { stubs },
|
|
260
|
+
attachTo: document.body,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
await nextTick();
|
|
264
|
+
|
|
265
|
+
const closeButton = wrapper.find('button.mc-modal__close');
|
|
266
|
+
const lastFocusable = wrapper.find('[data-test="last-focusable"]');
|
|
267
|
+
|
|
268
|
+
(closeButton.element as HTMLButtonElement).focus();
|
|
269
|
+
await closeButton.trigger('keydown', { key: 'Tab', shiftKey: true });
|
|
270
|
+
|
|
271
|
+
expect(document.activeElement).toBe(lastFocusable.element);
|
|
272
|
+
|
|
273
|
+
wrapper.unmount();
|
|
274
|
+
});
|
|
163
275
|
});
|
|
@@ -1,23 +1,38 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<MOverlay
|
|
2
|
+
<MOverlay
|
|
3
|
+
:is-visible="open"
|
|
4
|
+
:dialogLabel="`modalTitle-${id}`"
|
|
5
|
+
@click="onClickOverlay"
|
|
6
|
+
>
|
|
3
7
|
<section
|
|
8
|
+
ref="modalRef"
|
|
4
9
|
class="mc-modal"
|
|
5
10
|
:class="classObject"
|
|
6
11
|
role="dialog"
|
|
7
|
-
aria-labelledby="modalTitle"
|
|
8
|
-
:aria-modal="open ? 'true' : 'false'"
|
|
12
|
+
:aria-labelledby="`modalTitle-${id}`"
|
|
9
13
|
tabindex="-1"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
v-bind="{
|
|
15
|
+
...$attrs,
|
|
16
|
+
...(open
|
|
17
|
+
? {
|
|
18
|
+
'aria-modal': 'true',
|
|
19
|
+
onKeydown: onKeydown,
|
|
20
|
+
onClick: (event: MouseEvent) => {
|
|
21
|
+
event.stopPropagation();
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
: {
|
|
25
|
+
'aria-modal': 'false',
|
|
26
|
+
inert: true,
|
|
27
|
+
}),
|
|
28
|
+
}"
|
|
14
29
|
>
|
|
15
30
|
<div class="mc-modal__dialog" role="document">
|
|
16
31
|
<header class="mc-modal__header">
|
|
17
32
|
<span v-if="$slots.icon" class="mc-modal__icon">
|
|
18
33
|
<slot name="icon" />
|
|
19
34
|
</span>
|
|
20
|
-
<h2 class="mc-modal__title" id="modalTitle">
|
|
35
|
+
<h2 v-if="title" class="mc-modal__title" :id="`modalTitle-${id}`">
|
|
21
36
|
{{ title }}
|
|
22
37
|
</h2>
|
|
23
38
|
<MIconButton
|
|
@@ -48,7 +63,16 @@
|
|
|
48
63
|
</template>
|
|
49
64
|
|
|
50
65
|
<script setup lang="ts">
|
|
51
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
computed,
|
|
68
|
+
onMounted,
|
|
69
|
+
onUnmounted,
|
|
70
|
+
ref,
|
|
71
|
+
watch,
|
|
72
|
+
nextTick,
|
|
73
|
+
type VNode,
|
|
74
|
+
useId,
|
|
75
|
+
} from 'vue';
|
|
52
76
|
import { Cross24 } from '@mozaic-ds/icons-vue';
|
|
53
77
|
import MIconButton from '../iconbutton/MIconButton.vue';
|
|
54
78
|
import MOverlay from '../overlay/MOverlay.vue';
|
|
@@ -64,7 +88,7 @@ const props = withDefaults(
|
|
|
64
88
|
/**
|
|
65
89
|
* Title of the modal.
|
|
66
90
|
*/
|
|
67
|
-
title
|
|
91
|
+
title?: string;
|
|
68
92
|
/**
|
|
69
93
|
* Description of the modal.
|
|
70
94
|
*/
|
|
@@ -107,6 +131,19 @@ defineSlots<{
|
|
|
107
131
|
footer?: VNode;
|
|
108
132
|
}>();
|
|
109
133
|
|
|
134
|
+
const id = useId();
|
|
135
|
+
|
|
136
|
+
const modalRef = ref<HTMLElement | null>(null);
|
|
137
|
+
|
|
138
|
+
const FOCUSABLE_SELECTOR = [
|
|
139
|
+
'a[href]',
|
|
140
|
+
'button:not([disabled])',
|
|
141
|
+
'input:not([disabled])',
|
|
142
|
+
'select:not([disabled])',
|
|
143
|
+
'textarea:not([disabled])',
|
|
144
|
+
'[tabindex]:not([tabindex="-1"])',
|
|
145
|
+
].join(', ');
|
|
146
|
+
|
|
110
147
|
const classObject = computed(() => {
|
|
111
148
|
return {
|
|
112
149
|
'is-open': props.open,
|
|
@@ -131,12 +168,16 @@ const unlockScroll = () => {
|
|
|
131
168
|
onMounted(() => {
|
|
132
169
|
watch(
|
|
133
170
|
() => props.open,
|
|
134
|
-
(isOpen) => {
|
|
171
|
+
async (isOpen) => {
|
|
135
172
|
emit('update:open', isOpen);
|
|
136
173
|
if (props.scroll === false) {
|
|
137
174
|
if (isOpen) lockScroll();
|
|
138
175
|
else unlockScroll();
|
|
139
176
|
}
|
|
177
|
+
if (isOpen) {
|
|
178
|
+
await nextTick();
|
|
179
|
+
modalRef.value?.focus();
|
|
180
|
+
}
|
|
140
181
|
},
|
|
141
182
|
{ immediate: true },
|
|
142
183
|
);
|
|
@@ -156,6 +197,45 @@ const onClose = () => {
|
|
|
156
197
|
emit('update:open', false);
|
|
157
198
|
};
|
|
158
199
|
|
|
200
|
+
const onKeydown = (event: KeyboardEvent) => {
|
|
201
|
+
if (event.key === 'Escape') {
|
|
202
|
+
onClose();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (event.key !== 'Tab' || !props.open || !isClient) return;
|
|
207
|
+
|
|
208
|
+
const modalElement = modalRef.value;
|
|
209
|
+
if (!modalElement) return;
|
|
210
|
+
|
|
211
|
+
const focusableElements = Array.from(
|
|
212
|
+
modalElement.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
|
213
|
+
).filter((element) => !element.hasAttribute('disabled'));
|
|
214
|
+
|
|
215
|
+
if (!focusableElements.length) {
|
|
216
|
+
event.preventDefault();
|
|
217
|
+
modalElement.focus();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const firstFocusable = focusableElements[0];
|
|
222
|
+
const lastFocusable = focusableElements[focusableElements.length - 1];
|
|
223
|
+
const activeElement = document.activeElement as HTMLElement | null;
|
|
224
|
+
|
|
225
|
+
if (event.shiftKey) {
|
|
226
|
+
if (activeElement === firstFocusable || activeElement === modalElement) {
|
|
227
|
+
event.preventDefault();
|
|
228
|
+
lastFocusable.focus();
|
|
229
|
+
}
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (activeElement === lastFocusable || activeElement === modalElement) {
|
|
234
|
+
event.preventDefault();
|
|
235
|
+
firstFocusable.focus();
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
|
|
159
239
|
const emit = defineEmits<{
|
|
160
240
|
/**
|
|
161
241
|
* Emits when the modal display changes, updating the modelValue prop.
|
|
@@ -8,7 +8,7 @@ A modal is a dialog window that appears on top of the main content, requiring us
|
|
|
8
8
|
| Name | Description | Type | Default |
|
|
9
9
|
| --- | --- | --- | --- |
|
|
10
10
|
| `open` | if `true`, display the modal. | `boolean` | - |
|
|
11
|
-
| `title
|
|
11
|
+
| `title` | Title of the modal. | `string` | - |
|
|
12
12
|
| `description` | Description of the modal. | `string` | - |
|
|
13
13
|
| `closable` | if `true`, display the close button. | `boolean` | `true` |
|
|
14
14
|
| `scroll` | if `false`, lock the scroll when open. | `boolean` | `true` |
|
|
@@ -67,7 +67,9 @@ describe('MPhoneNumber', () => {
|
|
|
67
67
|
describe('Country Selection', () => {
|
|
68
68
|
it('should render country selector and flag by default', () => {
|
|
69
69
|
expect(
|
|
70
|
-
wrapper
|
|
70
|
+
wrapper
|
|
71
|
+
.find('.mc-phone-number-input__select .mc-select__control')
|
|
72
|
+
.exists(),
|
|
71
73
|
).toBe(true);
|
|
72
74
|
expect(wrapper.find('.mc-phone-number-input__flag').exists()).toBe(true);
|
|
73
75
|
});
|
|
@@ -197,7 +199,9 @@ describe('MPhoneNumber', () => {
|
|
|
197
199
|
wrapper = mount(MPhoneNumber, {
|
|
198
200
|
props: { ...defaultProps, size: 's' },
|
|
199
201
|
});
|
|
200
|
-
expect(
|
|
202
|
+
expect(
|
|
203
|
+
wrapper.find('.mc-phone-number-input__select').classes(),
|
|
204
|
+
).toContain('mc-select--s');
|
|
201
205
|
expect(wrapper.find('.mc-phone-number-input__input').classes()).toContain(
|
|
202
206
|
'mc-text-input--s',
|
|
203
207
|
);
|
|
@@ -4,25 +4,29 @@
|
|
|
4
4
|
class="mc-phone-number-input__select-wrapper"
|
|
5
5
|
:class="selectWrapperClass"
|
|
6
6
|
>
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
v-model="selectedCountry"
|
|
10
|
-
name="selectComponentName"
|
|
11
|
-
class="mc-select mc-phone-number-input__select"
|
|
7
|
+
<div
|
|
8
|
+
class="mc-select mc-phone-number-input__select"
|
|
12
9
|
:class="sizeSelectClass"
|
|
13
|
-
:disabled="isDisabled"
|
|
14
|
-
:readonly="isReadOnly"
|
|
15
10
|
>
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
:
|
|
11
|
+
<select
|
|
12
|
+
id="selectComponentId"
|
|
13
|
+
class="mc-select__control"
|
|
14
|
+
v-model="selectedCountry"
|
|
15
|
+
name="selectComponentName"
|
|
16
|
+
:disabled="isDisabled"
|
|
17
|
+
:readonly="isReadOnly"
|
|
22
18
|
>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
<option value="" selected hidden></option>
|
|
20
|
+
<option
|
|
21
|
+
v-for="country in countries"
|
|
22
|
+
:key="country"
|
|
23
|
+
:value="country"
|
|
24
|
+
:data-flag="country.toLowerCase()"
|
|
25
|
+
>
|
|
26
|
+
{{ getCountryName(country) }} (+{{ getCountryCallingCode(country) }})
|
|
27
|
+
</option>
|
|
28
|
+
</select>
|
|
29
|
+
</div>
|
|
26
30
|
|
|
27
31
|
<div class="mc-phone-number-input__select-display">
|
|
28
32
|
<div class="mc-phone-number-input__flag">
|
|
@@ -131,6 +131,18 @@ describe('MSidebarExpandableItem', () => {
|
|
|
131
131
|
expect(onListboxKeydown).toHaveBeenCalled();
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
it('collapsed trigger button has aria-label equal to label prop', () => {
|
|
135
|
+
const wrapper = mount(MSidebarExpandableItem, {
|
|
136
|
+
props: { label: 'Products', menuLabel: 'Products menu' },
|
|
137
|
+
global: {
|
|
138
|
+
provide: { [EXPANDED_SIDEBAR_KEY]: false },
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const button = wrapper.find('button.mc-sidebar__trigger');
|
|
143
|
+
expect(button.attributes('aria-label')).toBe('Products');
|
|
144
|
+
});
|
|
145
|
+
|
|
134
146
|
it('calls hideFloatingItem on mouseleave and blur on the listbox', async () => {
|
|
135
147
|
const wrapper = mount(MSidebarExpandableItem, {
|
|
136
148
|
props: { label: 'Item', menuLabel: 'Menu' },
|