@mozaic-ds/vue 2.17.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 +158 -72
- package/dist/mozaic-vue.js +1728 -4748
- package/dist/mozaic-vue.js.map +1 -1
- package/dist/mozaic-vue.umd.cjs +6 -25
- package/dist/mozaic-vue.umd.cjs.map +1 -1
- package/package.json +16 -10
- package/src/components/BrandPresets.mdx +20 -2
- package/src/components/Migration.mdx +651 -0
- package/src/components/accordionlistitem/MAccordionListItem.spec.ts +22 -3
- package/src/components/accordionlistitem/MAccordionListItem.vue +38 -28
- 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/builtinmenu/MBuiltInMenu.spec.ts +30 -1
- package/src/components/builtinmenu/MBuiltInMenu.vue +26 -17
- package/src/components/builtinmenu/README.md +2 -0
- package/src/components/button/MButton.spec.ts +26 -0
- package/src/components/button/MButton.vue +2 -0
- package/src/components/callout/MCallout.spec.ts +35 -0
- package/src/components/callout/MCallout.stories.ts +0 -3
- package/src/components/callout/MCallout.vue +26 -7
- package/src/components/callout/README.md +4 -2
- package/src/components/carousel/MCarousel.spec.ts +26 -2
- package/src/components/carousel/MCarousel.vue +10 -4
- package/src/components/checklistmenu/MCheckListMenu.spec.ts +12 -1
- package/src/components/checklistmenu/MCheckListMenu.vue +6 -0
- package/src/components/checklistmenu/README.md +2 -0
- package/src/components/combobox/MCombobox.vue +7 -0
- package/src/components/datatable/datatable.mdx +3 -2
- 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/navigationindicator/MNavigationIndicator.spec.ts +75 -18
- package/src/components/navigationindicator/MNavigationIndicator.vue +10 -12
- package/src/components/optionListbox/MOptionListbox.vue +16 -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/popover/MPopover.spec.ts +126 -0
- package/src/components/popover/MPopover.vue +36 -1
- package/src/components/segmentedcontrol/MSegmentedControl.spec.ts +92 -0
- package/src/components/segmentedcontrol/MSegmentedControl.vue +61 -2
- package/src/components/sidebarexpandableitem/MSidebarExpandableItem.spec.ts +12 -0
- package/src/components/sidebarexpandableitem/MSidebarExpandableItem.vue +1 -0
- package/src/components/starrating/MStarRating.spec.ts +19 -22
- package/src/components/starrating/MStarRating.vue +3 -2
- 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/tabs/MTabs.vue +90 -4
- package/src/components/tabs/Mtabs.spec.ts +162 -0
- package/src/components/textinput/MTextInput.vue +2 -2
- package/src/components/toggle/MToggle.vue +1 -1
- package/src/main.ts +1 -0
- package/src/components/ComponentsMapping.mdx +0 -98
|
@@ -103,4 +103,130 @@ describe('MPopover.vue', () => {
|
|
|
103
103
|
expect(activator.exists()).toBe(true);
|
|
104
104
|
expect(activator.attributes('popovertarget')).toBe(id);
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
describe('Focus management (toggle)', () => {
|
|
108
|
+
it('focuses the first focusable element inside when opened', async () => {
|
|
109
|
+
const wrapper = mount(MPopover, {
|
|
110
|
+
attachTo: document.body,
|
|
111
|
+
props: { closable: true },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const popoverEl = wrapper.find('.mc-popover__wrapper').element;
|
|
115
|
+
const toggleEvent = new Event('toggle') as ToggleEvent;
|
|
116
|
+
Object.defineProperty(toggleEvent, 'newState', { value: 'open' });
|
|
117
|
+
popoverEl.dispatchEvent(toggleEvent);
|
|
118
|
+
await wrapper.vm.$nextTick();
|
|
119
|
+
|
|
120
|
+
const firstFocusable = popoverEl.querySelector<HTMLElement>(
|
|
121
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
122
|
+
);
|
|
123
|
+
expect(document.activeElement).toBe(firstFocusable);
|
|
124
|
+
wrapper.unmount();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('focuses the popover container when opened without focusable content', async () => {
|
|
128
|
+
const wrapper = mount(MPopover, {
|
|
129
|
+
attachTo: document.body,
|
|
130
|
+
props: {
|
|
131
|
+
closable: false,
|
|
132
|
+
title: 'Information',
|
|
133
|
+
description: 'Read-only content',
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const popoverEl = wrapper.find('.mc-popover__wrapper')
|
|
138
|
+
.element as HTMLElement;
|
|
139
|
+
const toggleEvent = new Event('toggle') as ToggleEvent;
|
|
140
|
+
Object.defineProperty(toggleEvent, 'newState', { value: 'open' });
|
|
141
|
+
popoverEl.dispatchEvent(toggleEvent);
|
|
142
|
+
await wrapper.vm.$nextTick();
|
|
143
|
+
|
|
144
|
+
expect(document.activeElement).toBe(popoverEl);
|
|
145
|
+
wrapper.unmount();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('returns focus to the trigger when closed with focus inside the popover', async () => {
|
|
149
|
+
const wrapper = mount(MPopover, {
|
|
150
|
+
attachTo: document.body,
|
|
151
|
+
props: { closable: true },
|
|
152
|
+
slots: {
|
|
153
|
+
activator: (slotProps: { id: string }) =>
|
|
154
|
+
h('button', {
|
|
155
|
+
popovertarget: slotProps.id,
|
|
156
|
+
class: 'trigger-btn',
|
|
157
|
+
}),
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Simulate focus being on the close button (inside the popover)
|
|
162
|
+
const closeBtn = wrapper.findComponent(MIconButton).find('button')
|
|
163
|
+
.element as HTMLElement;
|
|
164
|
+
closeBtn.focus();
|
|
165
|
+
|
|
166
|
+
const popoverEl = wrapper.find('.mc-popover__wrapper').element;
|
|
167
|
+
const toggleEvent = new Event('toggle') as ToggleEvent;
|
|
168
|
+
Object.defineProperty(toggleEvent, 'newState', { value: 'closed' });
|
|
169
|
+
popoverEl.dispatchEvent(toggleEvent);
|
|
170
|
+
await wrapper.vm.$nextTick();
|
|
171
|
+
|
|
172
|
+
const trigger = wrapper.find('.trigger-btn').element as HTMLElement;
|
|
173
|
+
expect(document.activeElement).toBe(trigger);
|
|
174
|
+
wrapper.unmount();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns focus to the trigger when closed with focus on document.body', async () => {
|
|
178
|
+
const wrapper = mount(MPopover, {
|
|
179
|
+
attachTo: document.body,
|
|
180
|
+
slots: {
|
|
181
|
+
activator: (slotProps: { id: string }) =>
|
|
182
|
+
h('button', {
|
|
183
|
+
popovertarget: slotProps.id,
|
|
184
|
+
class: 'trigger-btn',
|
|
185
|
+
}),
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Simulate browser moving focus to body after popover collapses
|
|
190
|
+
(document.activeElement as HTMLElement)?.blur?.();
|
|
191
|
+
|
|
192
|
+
const popoverEl = wrapper.find('.mc-popover__wrapper').element;
|
|
193
|
+
const toggleEvent = new Event('toggle') as ToggleEvent;
|
|
194
|
+
Object.defineProperty(toggleEvent, 'newState', { value: 'closed' });
|
|
195
|
+
popoverEl.dispatchEvent(toggleEvent);
|
|
196
|
+
await wrapper.vm.$nextTick();
|
|
197
|
+
|
|
198
|
+
const trigger = wrapper.find('.trigger-btn').element as HTMLElement;
|
|
199
|
+
expect(document.activeElement).toBe(trigger);
|
|
200
|
+
wrapper.unmount();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('does not steal focus when closed by an outside click (focus already on another element)', async () => {
|
|
204
|
+
const wrapper = mount(MPopover, {
|
|
205
|
+
attachTo: document.body,
|
|
206
|
+
slots: {
|
|
207
|
+
activator: (slotProps: { id: string }) =>
|
|
208
|
+
h('button', {
|
|
209
|
+
popovertarget: slotProps.id,
|
|
210
|
+
class: 'trigger-btn',
|
|
211
|
+
}),
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Simulate user clicking an outside input
|
|
216
|
+
const outsideInput = document.createElement('input');
|
|
217
|
+
document.body.appendChild(outsideInput);
|
|
218
|
+
outsideInput.focus();
|
|
219
|
+
|
|
220
|
+
const popoverEl = wrapper.find('.mc-popover__wrapper').element;
|
|
221
|
+
const toggleEvent = new Event('toggle') as ToggleEvent;
|
|
222
|
+
Object.defineProperty(toggleEvent, 'newState', { value: 'closed' });
|
|
223
|
+
popoverEl.dispatchEvent(toggleEvent);
|
|
224
|
+
await wrapper.vm.$nextTick();
|
|
225
|
+
|
|
226
|
+
// Focus should remain on the outside input, not be stolen by the trigger
|
|
227
|
+
expect(document.activeElement).toBe(outsideInput);
|
|
228
|
+
outsideInput.remove();
|
|
229
|
+
wrapper.unmount();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
106
232
|
});
|
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
>
|
|
11
11
|
<div
|
|
12
12
|
:id="id"
|
|
13
|
+
ref="popoverRef"
|
|
13
14
|
class="mc-popover__wrapper"
|
|
14
15
|
popover
|
|
16
|
+
tabindex="-1"
|
|
15
17
|
:aria-labelledby="title && `${id}-title`"
|
|
16
18
|
:aria-describedby="description && `${id}-description`"
|
|
19
|
+
@toggle="onToggle"
|
|
17
20
|
>
|
|
18
21
|
<div class="mc-popover__content">
|
|
19
22
|
<div v-if="title || description" class="mc-popover__headings">
|
|
@@ -58,7 +61,7 @@
|
|
|
58
61
|
</template>
|
|
59
62
|
|
|
60
63
|
<script setup lang="ts">
|
|
61
|
-
import { useId, type VNode } from 'vue';
|
|
64
|
+
import { useId, useTemplateRef, nextTick, type VNode } from 'vue';
|
|
62
65
|
import { Cross20 } from '@mozaic-ds/icons-vue';
|
|
63
66
|
import MIconButton from '../iconbutton/MIconButton.vue';
|
|
64
67
|
/**
|
|
@@ -124,6 +127,38 @@ defineSlots<{
|
|
|
124
127
|
}>();
|
|
125
128
|
|
|
126
129
|
const id = useId();
|
|
130
|
+
const popoverRef = useTemplateRef('popoverRef');
|
|
131
|
+
|
|
132
|
+
function onToggle(event: ToggleEvent) {
|
|
133
|
+
if (event.newState === 'open') {
|
|
134
|
+
nextTick(() => {
|
|
135
|
+
const focusable = popoverRef.value?.querySelector<HTMLElement>(
|
|
136
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
137
|
+
);
|
|
138
|
+
if (focusable) {
|
|
139
|
+
focusable.focus();
|
|
140
|
+
} else {
|
|
141
|
+
popoverRef.value?.focus();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
} else {
|
|
145
|
+
const activeEl = document.activeElement as HTMLElement | null;
|
|
146
|
+
const closedFromInside =
|
|
147
|
+
!activeEl ||
|
|
148
|
+
activeEl === document.body ||
|
|
149
|
+
popoverRef.value?.contains(activeEl);
|
|
150
|
+
|
|
151
|
+
if (closedFromInside) {
|
|
152
|
+
const triggers = document.querySelectorAll<HTMLElement>(
|
|
153
|
+
`[popovertarget="${id}"]:not(.mc-popover__close)`,
|
|
154
|
+
);
|
|
155
|
+
const trigger = Array.from(triggers).find(
|
|
156
|
+
(el) => !popoverRef.value?.contains(el),
|
|
157
|
+
);
|
|
158
|
+
trigger?.focus();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
127
162
|
</script>
|
|
128
163
|
|
|
129
164
|
<style lang="scss" scoped>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mount } from '@vue/test-utils';
|
|
2
2
|
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { nextTick } from 'vue';
|
|
3
4
|
import MSegmentedControl from './MSegmentedControl.vue';
|
|
4
5
|
|
|
5
6
|
describe('MSegmentedControl.vue', () => {
|
|
@@ -145,4 +146,95 @@ describe('MSegmentedControl.vue', () => {
|
|
|
145
146
|
expect(button.attributes('role')).toBe('radio');
|
|
146
147
|
});
|
|
147
148
|
});
|
|
149
|
+
|
|
150
|
+
it('forwards attrs to the radiogroup so callers can name the group', () => {
|
|
151
|
+
const wrapper = mount(MSegmentedControl, {
|
|
152
|
+
props: { segments },
|
|
153
|
+
attrs: { 'aria-label': 'View options' },
|
|
154
|
+
});
|
|
155
|
+
expect(wrapper.find('[role="radiogroup"]').attributes('aria-label')).toBe(
|
|
156
|
+
'View options',
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('Keyboard navigation (radiogroup pattern)', () => {
|
|
161
|
+
it('selected button has tabindex="0", others have tabindex="-1"', () => {
|
|
162
|
+
const wrapper = mount(MSegmentedControl, {
|
|
163
|
+
props: { segments, modelValue: '2' },
|
|
164
|
+
});
|
|
165
|
+
const buttons = wrapper.findAll('button');
|
|
166
|
+
expect(buttons[0].attributes('tabindex')).toBe('-1');
|
|
167
|
+
expect(buttons[1].attributes('tabindex')).toBe('0');
|
|
168
|
+
expect(buttons[2].attributes('tabindex')).toBe('-1');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('keeps the first segment tabbable when modelValue does not match any segment', () => {
|
|
172
|
+
const wrapper = mount(MSegmentedControl, {
|
|
173
|
+
props: { segments, modelValue: 'unknown' },
|
|
174
|
+
});
|
|
175
|
+
const buttons = wrapper.findAll('button');
|
|
176
|
+
expect(buttons[0].attributes('tabindex')).toBe('0');
|
|
177
|
+
expect(buttons[1].attributes('tabindex')).toBe('-1');
|
|
178
|
+
expect(buttons[2].attributes('tabindex')).toBe('-1');
|
|
179
|
+
expect(buttons[0].attributes('aria-checked')).toBe('false');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('ArrowRight selects and focuses the next segment', async () => {
|
|
183
|
+
const wrapper = mount(MSegmentedControl, {
|
|
184
|
+
attachTo: document.body,
|
|
185
|
+
props: { segments, modelValue: '1' },
|
|
186
|
+
});
|
|
187
|
+
const buttons = wrapper.findAll('button');
|
|
188
|
+
await buttons[0].element.focus();
|
|
189
|
+
await buttons[0].trigger('keydown', { key: 'ArrowRight' });
|
|
190
|
+
await nextTick();
|
|
191
|
+
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['2']);
|
|
192
|
+
expect(document.activeElement).toBe(buttons[1].element);
|
|
193
|
+
wrapper.unmount();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('ArrowLeft selects and focuses the previous segment', async () => {
|
|
197
|
+
const wrapper = mount(MSegmentedControl, {
|
|
198
|
+
attachTo: document.body,
|
|
199
|
+
props: { segments, modelValue: '2' },
|
|
200
|
+
});
|
|
201
|
+
const buttons = wrapper.findAll('button');
|
|
202
|
+
await buttons[1].trigger('keydown', { key: 'ArrowLeft' });
|
|
203
|
+
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['1']);
|
|
204
|
+
wrapper.unmount();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('ArrowRight wraps around from last to first segment', async () => {
|
|
208
|
+
const wrapper = mount(MSegmentedControl, {
|
|
209
|
+
attachTo: document.body,
|
|
210
|
+
props: { segments, modelValue: '3' },
|
|
211
|
+
});
|
|
212
|
+
const buttons = wrapper.findAll('button');
|
|
213
|
+
await buttons[2].trigger('keydown', { key: 'ArrowRight' });
|
|
214
|
+
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['1']);
|
|
215
|
+
wrapper.unmount();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('Home selects the first segment', async () => {
|
|
219
|
+
const wrapper = mount(MSegmentedControl, {
|
|
220
|
+
attachTo: document.body,
|
|
221
|
+
props: { segments, modelValue: '3' },
|
|
222
|
+
});
|
|
223
|
+
const buttons = wrapper.findAll('button');
|
|
224
|
+
await buttons[2].trigger('keydown', { key: 'Home' });
|
|
225
|
+
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['1']);
|
|
226
|
+
wrapper.unmount();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('End selects the last segment', async () => {
|
|
230
|
+
const wrapper = mount(MSegmentedControl, {
|
|
231
|
+
attachTo: document.body,
|
|
232
|
+
props: { segments, modelValue: '1' },
|
|
233
|
+
});
|
|
234
|
+
const buttons = wrapper.findAll('button');
|
|
235
|
+
await buttons[0].trigger('keydown', { key: 'End' });
|
|
236
|
+
expect(wrapper.emitted('update:modelValue')![0]).toEqual(['3']);
|
|
237
|
+
wrapper.unmount();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
148
240
|
});
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
2
|
+
<div
|
|
3
|
+
class="mc-segmented-control"
|
|
4
|
+
:class="classObject"
|
|
5
|
+
role="radiogroup"
|
|
6
|
+
v-bind="$attrs"
|
|
7
|
+
>
|
|
3
8
|
<button
|
|
4
9
|
v-for="(segment, index) in segments"
|
|
5
10
|
:key="`segment-${index}`"
|
|
11
|
+
:ref="(el) => setButtonRef(el, index)"
|
|
6
12
|
type="button"
|
|
7
13
|
class="mc-segmented-control__segment"
|
|
8
14
|
:class="{
|
|
@@ -13,7 +19,9 @@
|
|
|
13
19
|
}"
|
|
14
20
|
:aria-checked="isSegmentSelected(index, segment.id)"
|
|
15
21
|
role="radio"
|
|
22
|
+
:tabindex="index === selectedIndex ? 0 : -1"
|
|
16
23
|
@click="onClickSegment(index, segment.id)"
|
|
24
|
+
@keydown="onKeydown($event, index)"
|
|
17
25
|
>
|
|
18
26
|
{{ segment.label }}
|
|
19
27
|
</button>
|
|
@@ -21,7 +29,13 @@
|
|
|
21
29
|
</template>
|
|
22
30
|
|
|
23
31
|
<script setup lang="ts">
|
|
24
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
computed,
|
|
34
|
+
nextTick,
|
|
35
|
+
ref,
|
|
36
|
+
watch,
|
|
37
|
+
type ComponentPublicInstance,
|
|
38
|
+
} from 'vue';
|
|
25
39
|
/**
|
|
26
40
|
* A Segmented Control allows users to switch between multiple options or views within a single container. It provides a compact and efficient way to toggle between sections without requiring a dropdown or separate navigation. Segmented Controls are commonly used in filters, tabbed navigation, and content selection to enhance user interaction and accessibility.
|
|
27
41
|
*/
|
|
@@ -71,6 +85,17 @@ const classObject = computed(() => {
|
|
|
71
85
|
|
|
72
86
|
const modelValue = ref<string | number | undefined>(props.modelValue);
|
|
73
87
|
|
|
88
|
+
const selectedIndex = computed(() => {
|
|
89
|
+
const index = props.segments.findIndex((segment, segmentIndex) => {
|
|
90
|
+
const value =
|
|
91
|
+
typeof props.modelValue === 'number' ? segmentIndex : segment.id;
|
|
92
|
+
|
|
93
|
+
return modelValue.value === value;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return index >= 0 ? index : 0;
|
|
97
|
+
});
|
|
98
|
+
|
|
74
99
|
watch(
|
|
75
100
|
() => props.modelValue,
|
|
76
101
|
(newVal) => {
|
|
@@ -93,6 +118,40 @@ const isSegmentSelected = (index: number, id?: string) => {
|
|
|
93
118
|
return modelValue.value === value;
|
|
94
119
|
};
|
|
95
120
|
|
|
121
|
+
const buttonRefs = ref<(HTMLButtonElement | null)[]>([]);
|
|
122
|
+
|
|
123
|
+
function setButtonRef(
|
|
124
|
+
el: Element | ComponentPublicInstance | null,
|
|
125
|
+
index: number,
|
|
126
|
+
) {
|
|
127
|
+
buttonRefs.value[index] = el as HTMLButtonElement | null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function onKeydown(event: KeyboardEvent, index: number) {
|
|
131
|
+
let nextIndex: number | null = null;
|
|
132
|
+
|
|
133
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
nextIndex = (index + 1) % props.segments.length;
|
|
136
|
+
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
nextIndex = (index - 1 + props.segments.length) % props.segments.length;
|
|
139
|
+
} else if (event.key === 'Home') {
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
nextIndex = 0;
|
|
142
|
+
} else if (event.key === 'End') {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
nextIndex = props.segments.length - 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (nextIndex !== null) {
|
|
148
|
+
onClickSegment(nextIndex, props.segments[nextIndex].id);
|
|
149
|
+
nextTick(() => buttonRefs.value[nextIndex!]?.focus());
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
defineOptions({ inheritAttrs: false });
|
|
154
|
+
|
|
96
155
|
const emit = defineEmits<{
|
|
97
156
|
/**
|
|
98
157
|
* Emits when the selected segment changes, updating the modelValue prop.
|
|
@@ -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' },
|
|
@@ -4,20 +4,6 @@ import { nextTick } from 'vue';
|
|
|
4
4
|
import MStarRating from './MStarRating.vue';
|
|
5
5
|
import { StarFilled24, StarHalf24 } from '@mozaic-ds/icons-vue';
|
|
6
6
|
|
|
7
|
-
function mockRect(el: Element, { left = 0, width = 100 } = {}) {
|
|
8
|
-
Object.defineProperty(el, 'getBoundingClientRect', {
|
|
9
|
-
value: () => ({
|
|
10
|
-
left,
|
|
11
|
-
width,
|
|
12
|
-
top: 0,
|
|
13
|
-
bottom: 0,
|
|
14
|
-
right: left + width,
|
|
15
|
-
height: 0,
|
|
16
|
-
}),
|
|
17
|
-
configurable: true,
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
|
|
21
7
|
describe('MStarRating', () => {
|
|
22
8
|
it('renders 5 stars by default', () => {
|
|
23
9
|
const wrapper = shallowMount(MStarRating, { props: { modelValue: 0 } });
|
|
@@ -39,8 +25,7 @@ describe('MStarRating', () => {
|
|
|
39
25
|
});
|
|
40
26
|
const stars = wrapper.findAll('.mc-star-rating__icon');
|
|
41
27
|
const first = stars[0];
|
|
42
|
-
|
|
43
|
-
await first.trigger('mousemove', { clientX: 10 });
|
|
28
|
+
await first.trigger('pointermove', { pointerType: 'mouse' });
|
|
44
29
|
expect(wrapper.findComponent(StarHalf24).exists()).toBe(false);
|
|
45
30
|
});
|
|
46
31
|
|
|
@@ -60,8 +45,7 @@ describe('MStarRating', () => {
|
|
|
60
45
|
});
|
|
61
46
|
const stars = wrapper.findAll('.mc-star-rating__icon');
|
|
62
47
|
const first = stars[0];
|
|
63
|
-
|
|
64
|
-
await first.trigger('click', { clientX: 10 });
|
|
48
|
+
await first.trigger('click');
|
|
65
49
|
const emitted = wrapper.emitted('update:modelValue') || [];
|
|
66
50
|
expect(emitted.length).toBe(1);
|
|
67
51
|
expect(emitted[0][0]).toBe(1);
|
|
@@ -109,9 +93,8 @@ describe('MStarRating', () => {
|
|
|
109
93
|
|
|
110
94
|
const stars = wrapper.findAll('.mc-star-rating__icon');
|
|
111
95
|
const first = stars[0];
|
|
112
|
-
mockRect(first.element, { left: 0, width: 100 });
|
|
113
96
|
|
|
114
|
-
await first.trigger('
|
|
97
|
+
await first.trigger('pointermove', { pointerType: 'mouse' });
|
|
115
98
|
await nextTick();
|
|
116
99
|
|
|
117
100
|
// aria-label should reflect hovered value (0.5) not modelValue (2)
|
|
@@ -124,6 +107,21 @@ describe('MStarRating', () => {
|
|
|
124
107
|
expect(root.attributes('aria-label')).toContain('2');
|
|
125
108
|
});
|
|
126
109
|
|
|
110
|
+
it('ignores non-mouse pointermove events for hover state', async () => {
|
|
111
|
+
const wrapper = shallowMount(MStarRating, {
|
|
112
|
+
props: { modelValue: 3, size: 'm', readonly: false },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const stars = wrapper.findAll('.mc-star-rating__icon');
|
|
116
|
+
const first = stars[0];
|
|
117
|
+
const root = wrapper.find('[role="slider"]');
|
|
118
|
+
|
|
119
|
+
await first.trigger('pointermove', { pointerType: 'touch' });
|
|
120
|
+
await nextTick();
|
|
121
|
+
|
|
122
|
+
expect(root.attributes('aria-label')).toContain('3');
|
|
123
|
+
});
|
|
124
|
+
|
|
127
125
|
it('resets hover to null on blur (aria-label falls back to modelValue)', async () => {
|
|
128
126
|
const wrapper = shallowMount(MStarRating, {
|
|
129
127
|
props: { modelValue: 3, size: 'm', readonly: false },
|
|
@@ -131,12 +129,11 @@ describe('MStarRating', () => {
|
|
|
131
129
|
|
|
132
130
|
const stars = wrapper.findAll('.mc-star-rating__icon');
|
|
133
131
|
const secondStar = stars[1];
|
|
134
|
-
mockRect(secondStar.element, { left: 0, width: 100 });
|
|
135
132
|
|
|
136
133
|
const root = wrapper.find('[role="slider"]');
|
|
137
134
|
|
|
138
135
|
await root.trigger('focus');
|
|
139
|
-
await secondStar.trigger('
|
|
136
|
+
await secondStar.trigger('pointermove', { pointerType: 'mouse' });
|
|
140
137
|
await nextTick();
|
|
141
138
|
expect(root.attributes('aria-label')).toContain('2');
|
|
142
139
|
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
v-on="
|
|
41
41
|
!isReadonly
|
|
42
42
|
? {
|
|
43
|
-
|
|
43
|
+
pointermove: (event: PointerEvent) => onHover(event, index),
|
|
44
44
|
click: () => onClick(index),
|
|
45
45
|
}
|
|
46
46
|
: {}
|
|
@@ -152,7 +152,8 @@ function getStarComponent(index: number) {
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
function onHover(index: number) {
|
|
155
|
+
function onHover(event: PointerEvent, index: number) {
|
|
156
|
+
if (event.pointerType !== 'mouse') return;
|
|
156
157
|
hover.value = index + 1;
|
|
157
158
|
}
|
|
158
159
|
|
|
@@ -50,6 +50,15 @@ describe('MStepperCompact component', () => {
|
|
|
50
50
|
expect(progressbar.props('contentValue')).toBe('3 / 7');
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
it('passes a dynamic aria-label reflecting current step to MCircularProgressbar', () => {
|
|
54
|
+
const wrapper = mount(MStepperCompact, {
|
|
55
|
+
props: { label: 'Step', value: 2, maxSteps: 5 },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const progressbar = wrapper.findComponent({ name: 'MCircularProgressbar' });
|
|
59
|
+
expect(progressbar.attributes('aria-label')).toBe('Step 2 / 5');
|
|
60
|
+
});
|
|
61
|
+
|
|
53
62
|
it('passes correct props to MCircularProgressbar', () => {
|
|
54
63
|
const wrapper = mount(MStepperCompact, {
|
|
55
64
|
props: {
|
|
@@ -9,6 +9,17 @@ describe('MStepperInline', () => {
|
|
|
9
9
|
{ id: '3', label: 'Step 3' },
|
|
10
10
|
];
|
|
11
11
|
|
|
12
|
+
it('sets aria-current="step" on the current step item', () => {
|
|
13
|
+
const wrapper = mount(MStepperInline, {
|
|
14
|
+
props: { currentStep: '2', steps: defaultSteps },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const items = wrapper.findAll('.mc-stepper-inline__item');
|
|
18
|
+
expect(items[1].attributes('aria-current')).toBe('step');
|
|
19
|
+
expect(items[0].attributes('aria-current')).toBeUndefined();
|
|
20
|
+
expect(items[2].attributes('aria-current')).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
|
|
12
23
|
it('renders correctly with default props', () => {
|
|
13
24
|
const wrapper = mount(MStepperInline, {
|
|
14
25
|
props: {
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
class="mc-stepper-inline__item"
|
|
8
8
|
:class="{ 'is-completed': stepStates[i].completed }"
|
|
9
9
|
:tabindex="stepStates[i].completed ? 0 : undefined"
|
|
10
|
+
:aria-current="stepStates[i].current ? 'step' : undefined"
|
|
10
11
|
>
|
|
11
12
|
<Check24
|
|
12
13
|
class="mc-stepper-inline__icon mc-stepper-inline__icon--check"
|
|
13
14
|
v-if="stepStates[i].completed"
|
|
14
|
-
aria-hidden="true"
|
|
15
15
|
/>
|
|
16
16
|
<span
|
|
17
17
|
v-else
|
|
@@ -9,6 +9,19 @@ const defaultSteps = [
|
|
|
9
9
|
];
|
|
10
10
|
|
|
11
11
|
describe('MStepperStacked', () => {
|
|
12
|
+
describe('Accessibility', () => {
|
|
13
|
+
it('sets aria-current="step" on the current step item', () => {
|
|
14
|
+
const wrapper = mount(MStepperStacked, {
|
|
15
|
+
props: { steps: defaultSteps, currentStep: '2' },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const items = wrapper.findAll('.mc-stepper-stacked__item');
|
|
19
|
+
expect(items[1].attributes('aria-current')).toBe('step');
|
|
20
|
+
expect(items[0].attributes('aria-current')).toBeUndefined();
|
|
21
|
+
expect(items[2].attributes('aria-current')).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
12
25
|
describe('Basic rendering', () => {
|
|
13
26
|
it('renders as many li elements as there are steps', () => {
|
|
14
27
|
const wrapper = mount(MStepperStacked, {
|