@mozaic-ds/vue 2.9.0 → 2.10.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/README.md +4 -6
- package/dist/mozaic-vue.css +1 -1
- package/dist/mozaic-vue.d.ts +317 -250
- package/dist/mozaic-vue.js +1050 -896
- package/dist/mozaic-vue.js.map +1 -1
- package/dist/mozaic-vue.umd.cjs +5 -5
- package/dist/mozaic-vue.umd.cjs.map +1 -1
- package/package.json +14 -13
- package/src/components/carousel/MCarousel.spec.ts +138 -0
- package/src/components/carousel/MCarousel.stories.ts +94 -0
- package/src/components/carousel/MCarousel.vue +154 -0
- package/src/components/carousel/README.md +18 -0
- package/src/components/flag/MFlag.stories.ts +1 -1
- package/src/components/loader/MLoader.spec.ts +2 -2
- package/src/components/loader/MLoader.vue +2 -2
- package/src/components/phonenumber/MPhoneNumber.spec.ts +110 -1
- package/src/components/phonenumber/MPhoneNumber.stories.ts +14 -0
- package/src/components/phonenumber/MPhoneNumber.vue +16 -6
- package/src/components/textinput/MTextInput.stories.ts +1 -1
- package/src/components/usingPresets.mdx +1 -1
- package/src/main.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mozaic-ds/vue",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Mozaic-Vue is the Vue.js implementation of ADEO Design system",
|
|
6
6
|
"author": "ADEO - ADEO Design system",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"*.d.ts"
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@mozaic-ds/styles": "^2.0
|
|
45
|
-
"@mozaic-ds/web-fonts": "1.65.0",
|
|
44
|
+
"@mozaic-ds/styles": "^2.1.0",
|
|
45
|
+
"@mozaic-ds/web-fonts": "^1.65.0",
|
|
46
46
|
"postcss-scss": "^4.0.9",
|
|
47
47
|
"vue": "^3.5.13"
|
|
48
48
|
},
|
|
@@ -52,22 +52,24 @@
|
|
|
52
52
|
"@mozaic-ds/css-dev-tools": "1.75.0",
|
|
53
53
|
"@mozaic-ds/icons-vue": "^1.0.0",
|
|
54
54
|
"@release-it/conventional-changelog": "^10.0.1",
|
|
55
|
-
"@storybook/addon-a11y": "^
|
|
56
|
-
"@storybook/addon-docs": "^
|
|
57
|
-
"@storybook/addon-themes": "^
|
|
58
|
-
"@storybook/vue3-vite": "^
|
|
55
|
+
"@storybook/addon-a11y": "^10.0.4",
|
|
56
|
+
"@storybook/addon-docs": "^10.0.4",
|
|
57
|
+
"@storybook/addon-themes": "^10.0.4",
|
|
58
|
+
"@storybook/vue3-vite": "^10.0.4",
|
|
59
59
|
"@types/jsdom": "^27.0.0",
|
|
60
60
|
"@vitejs/plugin-vue": "^6.0.1",
|
|
61
|
-
"@vitest/coverage-v8": "^
|
|
61
|
+
"@vitest/coverage-v8": "^4.0.7",
|
|
62
62
|
"@vitest/eslint-plugin": "^1.1.38",
|
|
63
63
|
"@vue/eslint-config-prettier": "^10.2.0",
|
|
64
64
|
"@vue/eslint-config-typescript": "^14.5.0",
|
|
65
65
|
"@vue/test-utils": "^2.4.6",
|
|
66
66
|
"eslint": "^9.22.0",
|
|
67
|
+
"eslint-plugin-storybook": "^10.0.5",
|
|
67
68
|
"eslint-plugin-vue": "^10.0.0",
|
|
68
69
|
"eslint-plugin-vuejs-accessibility": "^2.4.1",
|
|
69
70
|
"husky": "^9.1.7",
|
|
70
71
|
"jsdom": "^27.0.0",
|
|
72
|
+
"libphonenumber-js": "^1.12.23",
|
|
71
73
|
"lint-staged": "^16.1.5",
|
|
72
74
|
"mdx-mermaid": "^2.0.3",
|
|
73
75
|
"mermaid": "^11.5.0",
|
|
@@ -75,15 +77,14 @@
|
|
|
75
77
|
"prettier": "^3.5.3",
|
|
76
78
|
"release-it": "^19.0.4",
|
|
77
79
|
"sass": "^1.86.0",
|
|
78
|
-
"storybook": "^
|
|
79
|
-
"storybook-addon-tag-badges": "^
|
|
80
|
+
"storybook": "^10.0.4",
|
|
81
|
+
"storybook-addon-tag-badges": "^3.0.2",
|
|
80
82
|
"typescript": "^5.7.2",
|
|
81
83
|
"vite": "^7.1.1",
|
|
82
84
|
"vite-plugin-dts": "^4.5.3",
|
|
83
|
-
"vitest": "^
|
|
85
|
+
"vitest": "^4.0.7",
|
|
84
86
|
"vue-component-meta": "^3.0.8",
|
|
85
|
-
"vue-eslint-parser": "^10.1.1"
|
|
86
|
-
"libphonenumber-js": "^1.12.23"
|
|
87
|
+
"vue-eslint-parser": "^10.1.1"
|
|
87
88
|
},
|
|
88
89
|
"bugs": {
|
|
89
90
|
"url": "https://github.com/adeo/mozaic-vue/issues"
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils';
|
|
2
|
+
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
|
3
|
+
import MCarousel from './MCarousel.vue';
|
|
4
|
+
import MIconButton from '../iconbutton/MIconButton.vue';
|
|
5
|
+
import ChevronLeft20 from '@mozaic-ds/icons-vue/src/components/ChevronLeft20/ChevronLeft20.vue';
|
|
6
|
+
import ChevronRight20 from '@mozaic-ds/icons-vue/src/components/ChevronRight20/ChevronRight20.vue';
|
|
7
|
+
|
|
8
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
9
|
+
|
|
10
|
+
class MockIntersectionObserver {
|
|
11
|
+
callback: any;
|
|
12
|
+
options: any;
|
|
13
|
+
constructor(callback: any, options?: any) {
|
|
14
|
+
this.callback = callback;
|
|
15
|
+
this.options = options;
|
|
16
|
+
}
|
|
17
|
+
observe = vi.fn();
|
|
18
|
+
unobserve = vi.fn();
|
|
19
|
+
disconnect = vi.fn();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('MCarousel component', () => {
|
|
23
|
+
let originalObserver: any;
|
|
24
|
+
|
|
25
|
+
beforeAll(() => {
|
|
26
|
+
originalObserver = global.IntersectionObserver;
|
|
27
|
+
global.IntersectionObserver = MockIntersectionObserver as any;
|
|
28
|
+
|
|
29
|
+
Object.defineProperty(window.HTMLElement.prototype, 'scrollIntoView', {
|
|
30
|
+
value: vi.fn(),
|
|
31
|
+
writable: true,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
global.IntersectionObserver = originalObserver;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const mockChildren = [
|
|
40
|
+
'<div class="slide">Slide 1</div>',
|
|
41
|
+
'<div class="slide">Slide 2</div>',
|
|
42
|
+
'<div class="slide">Slide 3</div>',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const mountCarousel = (options = {}) =>
|
|
46
|
+
mount(MCarousel, {
|
|
47
|
+
attachTo: document.body,
|
|
48
|
+
slots: {
|
|
49
|
+
default: mockChildren.join(''),
|
|
50
|
+
header: '<h2 id="mc-carousel__title">Carousel Header</h2>',
|
|
51
|
+
},
|
|
52
|
+
...options,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('renders correctly with header and default slot', () => {
|
|
56
|
+
const wrapper = mountCarousel();
|
|
57
|
+
expect(wrapper.find('.mc-carousel__headings').text()).toContain(
|
|
58
|
+
'Carousel Header',
|
|
59
|
+
);
|
|
60
|
+
expect(wrapper.findAll('.slide')).toHaveLength(3);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('renders navigation buttons with correct aria labels', () => {
|
|
64
|
+
const wrapper = mountCarousel({
|
|
65
|
+
props: {
|
|
66
|
+
previousButtonAriaLabel: 'Go back',
|
|
67
|
+
nextButtonAriaLabel: 'Go forward',
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const buttons = wrapper.findAllComponents(MIconButton);
|
|
72
|
+
expect(buttons).toHaveLength(2);
|
|
73
|
+
expect(buttons[0].attributes('aria-label')).toBe('Go back');
|
|
74
|
+
expect(buttons[1].attributes('aria-label')).toBe('Go forward');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('renders default aria labels when not provided', () => {
|
|
78
|
+
const wrapper = mountCarousel();
|
|
79
|
+
const buttons = wrapper.findAllComponents(MIconButton);
|
|
80
|
+
expect(buttons[0].attributes('aria-label')).toBe('previous');
|
|
81
|
+
expect(buttons[1].attributes('aria-label')).toBe('next');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('renders icon components inside navigation buttons', () => {
|
|
85
|
+
const wrapper = mountCarousel();
|
|
86
|
+
expect(wrapper.findComponent(ChevronLeft20).exists()).toBe(true);
|
|
87
|
+
expect(wrapper.findComponent(ChevronRight20).exists()).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('disables the previous button when on the first slide', () => {
|
|
91
|
+
const wrapper = mountCarousel();
|
|
92
|
+
const [prevButton] = wrapper.findAllComponents(MIconButton);
|
|
93
|
+
expect(prevButton.props('disabled')).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('scrolls to next slide when goNext is called', async () => {
|
|
97
|
+
const scrollIntoViewMock = vi.fn();
|
|
98
|
+
(window.HTMLElement.prototype.scrollIntoView as any) = scrollIntoViewMock;
|
|
99
|
+
|
|
100
|
+
const wrapper = mountCarousel();
|
|
101
|
+
const vm = wrapper.vm as any;
|
|
102
|
+
vi.spyOn(vm, 'getCarouselChildren').mockReturnValue([
|
|
103
|
+
{ scrollIntoView: scrollIntoViewMock },
|
|
104
|
+
{ scrollIntoView: scrollIntoViewMock },
|
|
105
|
+
{ scrollIntoView: scrollIntoViewMock },
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
vm.goNext();
|
|
109
|
+
expect(scrollIntoViewMock).toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('scrolls to previous slide when goPrevious is called', async () => {
|
|
113
|
+
const scrollIntoViewMock = vi.fn();
|
|
114
|
+
(window.HTMLElement.prototype.scrollIntoView as any) = scrollIntoViewMock;
|
|
115
|
+
|
|
116
|
+
const wrapper = mountCarousel();
|
|
117
|
+
const vm = wrapper.vm as any;
|
|
118
|
+
vi.spyOn(vm, 'getCarouselChildren').mockReturnValue([
|
|
119
|
+
{ scrollIntoView: scrollIntoViewMock },
|
|
120
|
+
{ scrollIntoView: scrollIntoViewMock },
|
|
121
|
+
{ scrollIntoView: scrollIntoViewMock },
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
vm.goNext();
|
|
125
|
+
vm.goNext();
|
|
126
|
+
vm.goPrevious();
|
|
127
|
+
|
|
128
|
+
expect(scrollIntoViewMock).toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('sets correct ARIA attributes on main container', () => {
|
|
132
|
+
const wrapper = mountCarousel();
|
|
133
|
+
const container = wrapper.find('.mc-carousel');
|
|
134
|
+
expect(container.attributes('role')).toBe('group');
|
|
135
|
+
expect(container.attributes('aria-roledescription')).toBe('carousel');
|
|
136
|
+
expect(container.attributes('aria-labelledby')).toBe('mc-carousel__title');
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
2
|
+
import MCarousel from './MCarousel.vue';
|
|
3
|
+
import MLink from '../link/MLink.vue';
|
|
4
|
+
import ArrowNext24 from '@mozaic-ds/icons-vue/src/components/ArrowNext24/ArrowNext24.vue';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof MCarousel> = {
|
|
7
|
+
title: 'Content/Carousel',
|
|
8
|
+
component: MCarousel,
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'A Carousel allows users to browse through multiple items within a horizontal container, using swipe gestures on mobile or navigation controls on desktop. It is primarily used to showcase products, promotions, or visual content, offering an engaging way to explore information in a condensed and interactive format. Carousels help optimize space while keeping content visually appealing and easily accessible.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
argTypes: {
|
|
18
|
+
'aria-labelledby': {
|
|
19
|
+
table: {
|
|
20
|
+
disable: true,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
args: {
|
|
25
|
+
'aria-labelledby': 'defaultCarousel',
|
|
26
|
+
header:
|
|
27
|
+
'<h2 class="mc-carousel__title mt-title--m" id="defaultCarousel">Title of the carousel</h2>',
|
|
28
|
+
default: `
|
|
29
|
+
<div class="free-content" style="padding: 16px;" aria-labelledby="free-content__1">
|
|
30
|
+
<img class="free-content__image"
|
|
31
|
+
src="https://picsum.photos/id/1/600/300" alt="card 1">
|
|
32
|
+
<div id="free-content__1" class="free-content__title">my card1</div>
|
|
33
|
+
</div>
|
|
34
|
+
<div class="free-content" style="padding: 16px;" aria-labelledby="free-content__2">
|
|
35
|
+
<img class="free-content__image"
|
|
36
|
+
src="https://picsum.photos/id/12/600/300" alt="card 2">
|
|
37
|
+
<div id="free-content__2" class="free-content__title">my card2</div>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="free-content" style="padding: 16px;" aria-labelledby="free-content__3">
|
|
40
|
+
<img class="free-content__image"
|
|
41
|
+
src="https://picsum.photos/id/23/600/300" alt="card 3">
|
|
42
|
+
<div id="free-content__3" class="free-content__title">my card3</div>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="free-content" style="padding: 16px;" aria-labelledby="free-content__4">
|
|
45
|
+
<img class="free-content__image"
|
|
46
|
+
src="https://picsum.photos/id/34/600/300" alt="card 4">
|
|
47
|
+
<div id="free-content__4" class="free-content__title">my card4</div>
|
|
48
|
+
</div>
|
|
49
|
+
`,
|
|
50
|
+
},
|
|
51
|
+
render: (args) => ({
|
|
52
|
+
components: {
|
|
53
|
+
MCarousel,
|
|
54
|
+
MLink,
|
|
55
|
+
ArrowNext24,
|
|
56
|
+
},
|
|
57
|
+
setup() {
|
|
58
|
+
return { args };
|
|
59
|
+
},
|
|
60
|
+
template: `
|
|
61
|
+
<MCarousel v-bind="args">
|
|
62
|
+
<template v-if="${'header' in args}" v-slot:header>${args.header}</template>
|
|
63
|
+
<template v-if="${'default' in args}" v-slot>${args.default}</template>
|
|
64
|
+
</MCarousel>
|
|
65
|
+
`,
|
|
66
|
+
}),
|
|
67
|
+
};
|
|
68
|
+
export default meta;
|
|
69
|
+
type Story = StoryObj<typeof MCarousel>;
|
|
70
|
+
|
|
71
|
+
export const Default: Story = {};
|
|
72
|
+
|
|
73
|
+
export const Subtitle: Story = {
|
|
74
|
+
args: {
|
|
75
|
+
'aria-labelledby': 'subtitleCarousel',
|
|
76
|
+
header: `
|
|
77
|
+
<h2 class="mc-carousel__title mt-title--m" id="subtitleCarousel">Title of the carousel</h2>
|
|
78
|
+
<p class="mc-carousel__sub-title mt-body-m">Longer description of the carousel</p>
|
|
79
|
+
`,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const Link: Story = {
|
|
84
|
+
'aria-labelledby': 'linkCarousel',
|
|
85
|
+
args: {
|
|
86
|
+
header: `
|
|
87
|
+
<h2 class="mc-carousel__title mt-title--m" id="linkCarousel">Title of the carousel</h2>
|
|
88
|
+
<MLink href="#" iconPosition="right">
|
|
89
|
+
Stand-alone link
|
|
90
|
+
<template #icon><ArrowNext24/></template>
|
|
91
|
+
</MLink>
|
|
92
|
+
`,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="mc-carousel"
|
|
4
|
+
role="group"
|
|
5
|
+
aria-roledescription="carousel"
|
|
6
|
+
aria-labelledby="mc-carousel__title"
|
|
7
|
+
>
|
|
8
|
+
<div class="mc-carousel__header">
|
|
9
|
+
<div class="mc-carousel__headings">
|
|
10
|
+
<slot name="header" />
|
|
11
|
+
</div>
|
|
12
|
+
<div class="mc-carousel__controls">
|
|
13
|
+
<MIconButton
|
|
14
|
+
size="s"
|
|
15
|
+
outlined
|
|
16
|
+
@click="goPrevious"
|
|
17
|
+
:disabled="isFirstChildActive"
|
|
18
|
+
:aria-label="previousButtonAriaLabel"
|
|
19
|
+
>
|
|
20
|
+
<template #icon>
|
|
21
|
+
<ChevronLeft20 />
|
|
22
|
+
</template>
|
|
23
|
+
</MIconButton>
|
|
24
|
+
<MIconButton
|
|
25
|
+
size="s"
|
|
26
|
+
outlined
|
|
27
|
+
@click="goNext"
|
|
28
|
+
:disabled="isLastChildActive"
|
|
29
|
+
:aria-label="nextButtonAriaLabel"
|
|
30
|
+
>
|
|
31
|
+
<template #icon>
|
|
32
|
+
<ChevronRight20 />
|
|
33
|
+
</template>
|
|
34
|
+
</MIconButton>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="mc-carousel__content" ref="contentContainer">
|
|
38
|
+
<template
|
|
39
|
+
v-for="(child, index) in $slots.default?.()"
|
|
40
|
+
:key="`carousel-slide-${index}`"
|
|
41
|
+
>
|
|
42
|
+
<component :is="child" />
|
|
43
|
+
</template>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</template>
|
|
47
|
+
|
|
48
|
+
<script setup lang="ts">
|
|
49
|
+
import { computed, onMounted, ref, type VNode } from 'vue';
|
|
50
|
+
import MIconButton from '../iconbutton/MIconButton.vue';
|
|
51
|
+
import ChevronLeft20 from '@mozaic-ds/icons-vue/src/components/ChevronLeft20/ChevronLeft20.vue';
|
|
52
|
+
import ChevronRight20 from '@mozaic-ds/icons-vue/src/components/ChevronRight20/ChevronRight20.vue';
|
|
53
|
+
/**
|
|
54
|
+
* A Carousel allows users to browse through multiple items within a horizontal container, using swipe gestures on mobile or navigation controls on desktop. It is primarily used to showcase products, promotions, or visual content, offering an engaging way to explore information in a condensed and interactive format. Carousels help optimize space while keeping content visually appealing and easily accessible.
|
|
55
|
+
*/
|
|
56
|
+
withDefaults(
|
|
57
|
+
defineProps<{
|
|
58
|
+
/**
|
|
59
|
+
* Aria label for the previous button.
|
|
60
|
+
*/
|
|
61
|
+
previousButtonAriaLabel?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Aria label for the next button.
|
|
64
|
+
*/
|
|
65
|
+
nextButtonAriaLabel?: string;
|
|
66
|
+
}>(),
|
|
67
|
+
{
|
|
68
|
+
previousButtonAriaLabel: 'previous',
|
|
69
|
+
nextButtonAriaLabel: 'next',
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
defineSlots<{
|
|
74
|
+
/**
|
|
75
|
+
* Use this slot to insert a list of components to be displayed in the carousel.
|
|
76
|
+
*/
|
|
77
|
+
default: () => VNode[];
|
|
78
|
+
/**
|
|
79
|
+
* Use this slot to insert the title, subtitle or link of the carousel.
|
|
80
|
+
*/
|
|
81
|
+
header: VNode;
|
|
82
|
+
}>();
|
|
83
|
+
|
|
84
|
+
const activeIndex = ref<number>(0);
|
|
85
|
+
const contentContainer = ref<HTMLElement | null>(null);
|
|
86
|
+
|
|
87
|
+
let observer: IntersectionObserver;
|
|
88
|
+
|
|
89
|
+
const scrollOptions: ScrollIntoViewOptions = {
|
|
90
|
+
behavior: 'smooth',
|
|
91
|
+
block: 'nearest',
|
|
92
|
+
inline: 'center',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
function getCarouselChildren() {
|
|
96
|
+
return contentContainer.value ? [...contentContainer.value.children] : [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
onMounted(() => {
|
|
100
|
+
observer = new IntersectionObserver(
|
|
101
|
+
(entries: IntersectionObserverEntry[]) => {
|
|
102
|
+
const entry = entries.find(
|
|
103
|
+
(e: IntersectionObserverEntry) => e.isIntersecting,
|
|
104
|
+
);
|
|
105
|
+
if (entry) {
|
|
106
|
+
activeIndex.value = getCarouselChildren().findIndex(
|
|
107
|
+
(e) => e === entry.target,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
root: contentContainer.value,
|
|
113
|
+
threshold: 0.9,
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
getCarouselChildren().forEach((el) => {
|
|
118
|
+
observer.observe(el);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
function scrollToChild(index: number) {
|
|
123
|
+
getCarouselChildren()[index].scrollIntoView(scrollOptions);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function goPrevious() {
|
|
127
|
+
if (activeIndex.value > 0) {
|
|
128
|
+
scrollToChild(activeIndex.value - 1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function goNext() {
|
|
133
|
+
if (activeIndex.value < getCarouselChildren().length - 1) {
|
|
134
|
+
scrollToChild(activeIndex.value + 1);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const isFirstChildActive = computed(() => activeIndex.value === 0);
|
|
139
|
+
const isLastChildActive = computed(
|
|
140
|
+
() => activeIndex.value === getCarouselChildren().length - 1,
|
|
141
|
+
);
|
|
142
|
+
</script>
|
|
143
|
+
|
|
144
|
+
<style scoped lang="scss">
|
|
145
|
+
@use '@mozaic-ds/styles/components/carousel';
|
|
146
|
+
|
|
147
|
+
::v-deep(.mc-carousel__title) {
|
|
148
|
+
margin: 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
::v-deep(.mc-carousel__sub-title) {
|
|
152
|
+
margin: 0;
|
|
153
|
+
}
|
|
154
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# MCarousel
|
|
2
|
+
|
|
3
|
+
A Carousel allows users to browse through multiple items within a horizontal container, using swipe gestures on mobile or navigation controls on desktop. It is primarily used to showcase products, promotions, or visual content, offering an engaging way to explore information in a condensed and interactive format. Carousels help optimize space while keeping content visually appealing and easily accessible.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
## Props
|
|
7
|
+
|
|
8
|
+
| Name | Description | Type | Default |
|
|
9
|
+
| --- | --- | --- | --- |
|
|
10
|
+
| `previousButtonAriaLabel` | Aria label for the previous button. | `string` | `"previous"` |
|
|
11
|
+
| `nextButtonAriaLabel` | Aria label for the next button. | `string` | `"next"` |
|
|
12
|
+
|
|
13
|
+
## Slots
|
|
14
|
+
|
|
15
|
+
| Name | Description |
|
|
16
|
+
| --- | --- |
|
|
17
|
+
| `default` | Use this slot to insert a list of components to be displayed in the carousel. |
|
|
18
|
+
| `header` | Use this slot to insert the title, subtitle or link of the carousel. |
|
|
@@ -134,12 +134,12 @@ describe('MLoader component', () => {
|
|
|
134
134
|
it('sets correct viewBox for xs size', async () => {
|
|
135
135
|
const wrapper = mount(MLoader, { props: { size: 'xs' } });
|
|
136
136
|
const svg = wrapper.find('svg.mc-loader__icon');
|
|
137
|
-
expect(svg.attributes('viewBox')).toBe('0 0
|
|
137
|
+
expect(svg.attributes('viewBox')).toBe('0 0 20 20');
|
|
138
138
|
});
|
|
139
139
|
|
|
140
140
|
it('sets correct circle radius for xs size', async () => {
|
|
141
141
|
const wrapper = mount(MLoader, { props: { size: 'xs' } });
|
|
142
142
|
const circle = wrapper.find('circle.mc-loader__path');
|
|
143
|
-
expect(circle.attributes('r')).toBe('
|
|
143
|
+
expect(circle.attributes('r')).toBe('6');
|
|
144
144
|
});
|
|
145
145
|
});
|
|
@@ -61,7 +61,7 @@ const setViewBox = computed(() => {
|
|
|
61
61
|
|
|
62
62
|
switch (props.size) {
|
|
63
63
|
case 'xs':
|
|
64
|
-
viewBox = '0 0
|
|
64
|
+
viewBox = '0 0 20 20';
|
|
65
65
|
break;
|
|
66
66
|
case 's':
|
|
67
67
|
viewBox = '0 0 24 24';
|
|
@@ -80,7 +80,7 @@ const setCircleRadius = computed(() => {
|
|
|
80
80
|
|
|
81
81
|
switch (props.size) {
|
|
82
82
|
case 'xs':
|
|
83
|
-
circleRadius =
|
|
83
|
+
circleRadius = 6;
|
|
84
84
|
break;
|
|
85
85
|
case 's':
|
|
86
86
|
circleRadius = 6;
|
|
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
|
2
2
|
import { mount, VueWrapper } from '@vue/test-utils';
|
|
3
3
|
import { nextTick } from 'vue';
|
|
4
4
|
import MPhoneNumber from './MPhoneNumber.vue';
|
|
5
|
-
import { isValidPhoneNumber } from 'libphonenumber-js';
|
|
5
|
+
import { isValidPhoneNumber, type CountryCode } from 'libphonenumber-js';
|
|
6
6
|
|
|
7
7
|
vi.mock('libphonenumber-js', () => ({
|
|
8
8
|
default: vi.fn(),
|
|
@@ -291,4 +291,113 @@ describe('MPhoneNumber', () => {
|
|
|
291
291
|
expect((input.element as HTMLInputElement).value).toBe('+33123456789');
|
|
292
292
|
});
|
|
293
293
|
});
|
|
294
|
+
|
|
295
|
+
describe('Locale', () => {
|
|
296
|
+
it('should use French locale by default', () => {
|
|
297
|
+
const options = wrapper.findAll('option');
|
|
298
|
+
const franceOption = options.find(
|
|
299
|
+
(opt) => opt.attributes('value') === 'FR',
|
|
300
|
+
);
|
|
301
|
+
expect(franceOption?.text()).toContain('France');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should display country names in specified locale', () => {
|
|
305
|
+
wrapper = mount(MPhoneNumber, {
|
|
306
|
+
props: { ...defaultProps, locale: 'en' },
|
|
307
|
+
});
|
|
308
|
+
const options = wrapper.findAll('option');
|
|
309
|
+
const franceOption = options.find(
|
|
310
|
+
(opt) => opt.attributes('value') === 'FR',
|
|
311
|
+
);
|
|
312
|
+
// Country names should be translated according to the locale
|
|
313
|
+
expect(franceOption?.text()).toBeTruthy();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should update country names when locale prop changes', async () => {
|
|
317
|
+
await wrapper.setProps({ locale: 'es' });
|
|
318
|
+
await nextTick();
|
|
319
|
+
const options = wrapper.findAll('option');
|
|
320
|
+
expect(options.length).toBeGreaterThan(1);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('Country Codes', () => {
|
|
325
|
+
it('should display all countries by default', () => {
|
|
326
|
+
const options = wrapper.findAll('option');
|
|
327
|
+
// Minus 1 for the empty hidden option
|
|
328
|
+
expect(options.length - 1).toBe(5); // Mocked to return 5 countries
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('should display only specified countries when countryCodes prop is provided', () => {
|
|
332
|
+
wrapper = mount(MPhoneNumber, {
|
|
333
|
+
props: {
|
|
334
|
+
...defaultProps,
|
|
335
|
+
countryCodes: ['FR', 'US', 'GB'] as CountryCode[],
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
const options = wrapper.findAll('option');
|
|
339
|
+
// Plus 1 for the empty hidden option
|
|
340
|
+
expect(options.length).toBe(4);
|
|
341
|
+
expect(options.some((opt) => opt.attributes('value') === 'FR')).toBe(
|
|
342
|
+
true,
|
|
343
|
+
);
|
|
344
|
+
expect(options.some((opt) => opt.attributes('value') === 'US')).toBe(
|
|
345
|
+
true,
|
|
346
|
+
);
|
|
347
|
+
expect(options.some((opt) => opt.attributes('value') === 'GB')).toBe(
|
|
348
|
+
true,
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should update available countries when countryCodes prop changes', async () => {
|
|
353
|
+
await wrapper.setProps({ countryCodes: ['FR', 'DE'] as CountryCode[] });
|
|
354
|
+
await nextTick();
|
|
355
|
+
const options = wrapper.findAll('option');
|
|
356
|
+
// Plus 1 for the empty hidden option
|
|
357
|
+
expect(options.length).toBe(3);
|
|
358
|
+
expect(options.some((opt) => opt.attributes('value') === 'FR')).toBe(
|
|
359
|
+
true,
|
|
360
|
+
);
|
|
361
|
+
expect(options.some((opt) => opt.attributes('value') === 'DE')).toBe(
|
|
362
|
+
true,
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should work with single country in countryCodes', () => {
|
|
367
|
+
wrapper = mount(MPhoneNumber, {
|
|
368
|
+
props: {
|
|
369
|
+
...defaultProps,
|
|
370
|
+
countryCodes: ['FR'] as CountryCode[],
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
const options = wrapper.findAll('option');
|
|
374
|
+
// Plus 1 for the empty hidden option
|
|
375
|
+
expect(options.length).toBe(2);
|
|
376
|
+
expect(options.some((opt) => opt.attributes('value') === 'FR')).toBe(
|
|
377
|
+
true,
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('should combine locale and countryCodes props', () => {
|
|
382
|
+
wrapper = mount(MPhoneNumber, {
|
|
383
|
+
props: {
|
|
384
|
+
...defaultProps,
|
|
385
|
+
locale: 'en',
|
|
386
|
+
countryCodes: ['US', 'GB', 'CA'] as CountryCode[],
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
const options = wrapper.findAll('option');
|
|
390
|
+
// Plus 1 for the empty hidden option
|
|
391
|
+
expect(options.length).toBe(4);
|
|
392
|
+
expect(options.some((opt) => opt.attributes('value') === 'US')).toBe(
|
|
393
|
+
true,
|
|
394
|
+
);
|
|
395
|
+
expect(options.some((opt) => opt.attributes('value') === 'GB')).toBe(
|
|
396
|
+
true,
|
|
397
|
+
);
|
|
398
|
+
expect(options.some((opt) => opt.attributes('value') === 'CA')).toBe(
|
|
399
|
+
true,
|
|
400
|
+
);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
294
403
|
});
|
|
@@ -86,3 +86,17 @@ export const ReadOnly: Story = {
|
|
|
86
86
|
readonly: true,
|
|
87
87
|
},
|
|
88
88
|
};
|
|
89
|
+
|
|
90
|
+
export const LimitedCountries: Story = {
|
|
91
|
+
args: {
|
|
92
|
+
countryCodes: ['FR', 'US', 'GB', 'DE', 'ES', 'IT'],
|
|
93
|
+
},
|
|
94
|
+
parameters: {
|
|
95
|
+
docs: {
|
|
96
|
+
description: {
|
|
97
|
+
story:
|
|
98
|
+
'Limit the country selector to only specific countries instead of showing all available countries.',
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|