@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mozaic-ds/vue",
3
- "version": "2.9.0",
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.1",
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": "^9.0.18",
56
- "@storybook/addon-docs": "^9.0.18",
57
- "@storybook/addon-themes": "^9.0.18",
58
- "@storybook/vue3-vite": "^9.0.18",
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": "^3.0.9",
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": "^9.0.18",
79
- "storybook-addon-tag-badges": "^2.0.2",
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": "^3.0.9",
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. |
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/vue3';
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite';
2
2
  import MFlag from './MFlag.vue';
3
3
 
4
4
  const meta: Meta<typeof MFlag> = {
@@ -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 24 24');
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('3');
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 24 24';
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 = 3;
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
+ };