@mozaic-ds/vue 2.6.1 → 2.8.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.
Files changed (96) hide show
  1. package/README.md +3 -3
  2. package/dist/mozaic-vue.css +1 -1
  3. package/dist/mozaic-vue.d.ts +211 -101
  4. package/dist/mozaic-vue.js +3034 -1070
  5. package/dist/mozaic-vue.js.map +1 -1
  6. package/dist/mozaic-vue.umd.cjs +5 -1
  7. package/dist/mozaic-vue.umd.cjs.map +1 -1
  8. package/package.json +10 -7
  9. package/src/components/Contributing.mdx +1 -1
  10. package/src/components/GettingStarted.mdx +1 -1
  11. package/src/components/Introduction.mdx +1 -1
  12. package/src/components/Support.mdx +1 -1
  13. package/src/components/avatar/MAvatar.stories.ts +1 -0
  14. package/src/components/avatar/README.md +16 -0
  15. package/src/components/breadcrumb/README.md +11 -0
  16. package/src/components/button/MButton.stories.ts +1 -2
  17. package/src/components/button/MButton.vue +6 -1
  18. package/src/components/button/README.md +24 -0
  19. package/src/components/callout/MCallout.stories.ts +1 -0
  20. package/src/components/callout/README.md +19 -0
  21. package/src/components/checkbox/README.md +23 -0
  22. package/src/components/checkboxgroup/README.md +20 -0
  23. package/src/components/circularprogressbar/MCircularProgressbar.stories.ts +1 -0
  24. package/src/components/circularprogressbar/README.md +14 -0
  25. package/src/components/container/MContainer.stories.ts +8 -0
  26. package/src/components/container/MContainer.vue +1 -1
  27. package/src/components/container/README.md +16 -0
  28. package/src/components/datepicker/MDatepicker.stories.ts +1 -0
  29. package/src/components/datepicker/README.md +24 -0
  30. package/src/components/divider/MDivider.stories.ts +1 -0
  31. package/src/components/divider/README.md +18 -0
  32. package/src/components/drawer/MDrawer.spec.ts +95 -0
  33. package/src/components/drawer/MDrawer.stories.ts +1 -0
  34. package/src/components/drawer/MDrawer.vue +34 -3
  35. package/src/components/drawer/README.md +30 -0
  36. package/src/components/field/MField.vue +1 -1
  37. package/src/components/field/README.md +24 -0
  38. package/src/components/fieldgroup/README.md +22 -0
  39. package/src/components/flag/README.md +11 -0
  40. package/src/components/iconbutton/MIconButton.stories.ts +1 -0
  41. package/src/components/iconbutton/MIconButton.vue +6 -1
  42. package/src/components/iconbutton/README.md +21 -0
  43. package/src/components/linearprogressbarbuffer/MLinearProgressbarBuffer.stories.ts +1 -0
  44. package/src/components/linearprogressbarbuffer/README.md +11 -0
  45. package/src/components/linearprogressbarpercentage/MLinearProgressbarPercentage.stories.ts +1 -0
  46. package/src/components/linearprogressbarpercentage/README.md +10 -0
  47. package/src/components/link/MLink.vue +0 -2
  48. package/src/components/link/README.md +23 -0
  49. package/src/components/loader/MLoader.vue +1 -1
  50. package/src/components/loader/README.md +12 -0
  51. package/src/components/loadingoverlay/MLoadingOverlay.stories.ts +1 -1
  52. package/src/components/loadingoverlay/README.md +11 -0
  53. package/src/components/modal/MModal.spec.ts +36 -1
  54. package/src/components/modal/MModal.vue +11 -1
  55. package/src/components/modal/README.md +29 -0
  56. package/src/components/numberbadge/README.md +12 -0
  57. package/src/components/overlay/README.md +17 -0
  58. package/src/components/pagination/README.md +20 -0
  59. package/src/components/passwordinput/README.md +25 -0
  60. package/src/components/phonenumber/MPhoneNumber.spec.ts +294 -0
  61. package/src/components/phonenumber/MPhoneNumber.stories.ts +88 -0
  62. package/src/components/phonenumber/MPhoneNumber.vue +271 -0
  63. package/src/components/phonenumber/README.md +26 -0
  64. package/src/components/pincode/MPincode.spec.ts +4 -1
  65. package/src/components/pincode/MPincode.stories.ts +1 -0
  66. package/src/components/pincode/MPincode.vue +5 -1
  67. package/src/components/pincode/README.md +22 -0
  68. package/src/components/quantityselector/MQuantitySelector.stories.ts +0 -7
  69. package/src/components/quantityselector/README.md +27 -0
  70. package/src/components/radio/README.md +21 -0
  71. package/src/components/radiogroup/README.md +21 -0
  72. package/src/components/segmentedcontrol/MSegmentedControl.spec.ts +116 -0
  73. package/src/components/segmentedcontrol/MSegmentedControl.stories.ts +78 -0
  74. package/src/components/segmentedcontrol/MSegmentedControl.vue +92 -0
  75. package/src/components/segmentedcontrol/README.md +19 -0
  76. package/src/components/select/README.md +24 -0
  77. package/src/components/statusbadge/README.md +11 -0
  78. package/src/components/statusdot/MStatusDot.stories.ts +1 -0
  79. package/src/components/statusdot/README.md +11 -0
  80. package/src/components/statusnotification/README.md +25 -0
  81. package/src/components/tabs/MTabs.stories.ts +23 -1
  82. package/src/components/tabs/MTabs.vue +8 -0
  83. package/src/components/tabs/Mtabs.spec.ts +29 -8
  84. package/src/components/tabs/README.md +20 -0
  85. package/src/components/tag/README.md +25 -0
  86. package/src/components/textarea/README.md +25 -0
  87. package/src/components/textinput/README.md +32 -0
  88. package/src/components/toaster/MToaster.stories.ts +1 -0
  89. package/src/components/toaster/README.md +28 -0
  90. package/src/components/toggle/README.md +21 -0
  91. package/src/components/togglegroup/MToggleGroup.vue +1 -3
  92. package/src/components/togglegroup/README.md +20 -0
  93. package/src/components/tooltip/README.md +19 -0
  94. package/src/components/usingIcons.mdx +1 -1
  95. package/src/components/usingPresets.mdx +1 -1
  96. package/src/main.ts +2 -0
@@ -0,0 +1,294 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+ import { mount, VueWrapper } from '@vue/test-utils';
3
+ import { nextTick } from 'vue';
4
+ import MPhoneNumber from './MPhoneNumber.vue';
5
+ import { isValidPhoneNumber } from 'libphonenumber-js';
6
+
7
+ vi.mock('libphonenumber-js', () => ({
8
+ default: vi.fn(),
9
+ isValidPhoneNumber: vi.fn(),
10
+ getCountries: vi.fn(() => ['FR', 'US', 'PT', 'DE', 'ES']),
11
+ getCountryCallingCode: vi.fn((country) => {
12
+ const codes = { FR: '33', US: '1', PT: '351', DE: '49', ES: '34' };
13
+ return codes[country as keyof typeof codes] || '33';
14
+ }),
15
+ getExampleNumber: vi.fn(() => ({
16
+ formatNational: () => '01 23 45 67 89',
17
+ })),
18
+ parsePhoneNumberFromString: vi.fn((value) => {
19
+ if (!value) return null;
20
+ return {
21
+ formatNational: () => {
22
+ if (value === '+33123456789') return '01 23 45 67 89';
23
+ return value;
24
+ },
25
+ number: value.startsWith('+') ? value : `+${value}`,
26
+ };
27
+ }),
28
+ }));
29
+
30
+ vi.mock(
31
+ '@mozaic-ds/icons-vue/src/components/ChevronDown20/ChevronDown20.vue',
32
+ () => ({
33
+ default: {
34
+ name: 'ChevronDown20',
35
+ template: '<svg data-testid="chevron-icon" aria-hidden="true"></svg>',
36
+ },
37
+ }),
38
+ );
39
+
40
+ describe('MPhoneNumber', () => {
41
+ let wrapper: VueWrapper<InstanceType<typeof MPhoneNumber>>;
42
+
43
+ const defaultProps = {
44
+ id: 'phone-input',
45
+ defaultCountry: 'FR' as const,
46
+ modelValue: '',
47
+ };
48
+
49
+ beforeEach(() => {
50
+ wrapper = mount(MPhoneNumber, {
51
+ props: defaultProps,
52
+ });
53
+ });
54
+
55
+ afterEach(() => {
56
+ wrapper.unmount();
57
+ vi.clearAllMocks();
58
+ });
59
+
60
+ describe('Rendering', () => {
61
+ it('should render the component', () => {
62
+ expect(wrapper.find('.mc-phone-number-input').exists()).toBe(true);
63
+ });
64
+ });
65
+
66
+ describe('Country Selection', () => {
67
+ it('should render country selector and flag by default', () => {
68
+ expect(
69
+ wrapper.find('select.mc-phone-number-input__select').exists(),
70
+ ).toBe(true);
71
+ expect(wrapper.find('.mc-phone-number-input__flag').exists()).toBe(true);
72
+ });
73
+
74
+ it('should hide country selector when flag prop is false', () => {
75
+ wrapper = mount(MPhoneNumber, {
76
+ props: { ...defaultProps, flag: false },
77
+ });
78
+
79
+ expect(
80
+ wrapper.find('.mc-phone-number-input__select-wrapper').classes(),
81
+ ).toContain('mc-phone-number-input__select-wrapper--hidden');
82
+ });
83
+
84
+ it('should render all country options', () => {
85
+ const options = wrapper.findAll('option');
86
+ expect(options.length).toBeGreaterThan(1);
87
+ expect(options.some((opt) => opt.text().includes('+33'))).toBe(true);
88
+ expect(options.some((opt) => opt.text().includes('+1'))).toBe(true);
89
+ });
90
+
91
+ it('should display correct flag image and alt attribute', () => {
92
+ const flagImage = wrapper.find('img.mc-phone-number-input__flag-image');
93
+ expect(flagImage.exists()).toBe(true);
94
+ expect(flagImage.attributes('src')).toContain('flagcdn.com/fr.svg');
95
+ expect(flagImage.attributes('alt')).toBeDefined();
96
+ });
97
+
98
+ it('should render chevron icon with aria-hidden true', () => {
99
+ const chevron = wrapper.find('[data-testid="chevron-icon"]');
100
+ expect(chevron.exists()).toBe(true);
101
+ expect(chevron.attributes('aria-hidden')).toBe('true');
102
+ });
103
+ });
104
+
105
+ describe('Phone Input', () => {
106
+ it('should render phone input field with correct id', () => {
107
+ const input = wrapper.find('input[type="tel"]');
108
+ expect(input.exists()).toBe(true);
109
+ expect(input.attributes('id')).toBe(defaultProps.id);
110
+ });
111
+
112
+ it('should display country calling code prefix by default', () => {
113
+ expect(
114
+ wrapper.find('.mc-phone-number-input__country-code').exists(),
115
+ ).toBe(true);
116
+ expect(wrapper.find('.mc-phone-number-input__country-code').text()).toBe(
117
+ '+33',
118
+ );
119
+ });
120
+
121
+ it('should hide prefix when prefix prop is false', () => {
122
+ wrapper = mount(MPhoneNumber, {
123
+ props: { ...defaultProps, prefix: false },
124
+ });
125
+ expect(
126
+ wrapper.find('.mc-phone-number-input__country-code').exists(),
127
+ ).toBe(false);
128
+ });
129
+
130
+ it('should set custom placeholder if provided', () => {
131
+ const placeholder = 'Enter your phone number';
132
+ wrapper = mount(MPhoneNumber, {
133
+ props: { ...defaultProps, placeholder },
134
+ });
135
+ expect(wrapper.find('input[type="tel"]').attributes('placeholder')).toBe(
136
+ placeholder,
137
+ );
138
+ });
139
+
140
+ it('should use dynamic placeholder when no custom placeholder provided', () => {
141
+ expect(wrapper.find('input[type="tel"]').attributes('placeholder')).toBe(
142
+ '01 23 45 67 89',
143
+ );
144
+ });
145
+ });
146
+
147
+ describe('States', () => {
148
+ it('should be enabled by default', () => {
149
+ const input = wrapper.find('input[type="tel"]');
150
+ const select = wrapper.find('select');
151
+ expect(input.attributes('disabled')).toBeUndefined();
152
+ expect(input.attributes('readonly')).toBeUndefined();
153
+ expect(select.attributes('disabled')).toBeUndefined();
154
+ expect(select.attributes('readonly')).toBeUndefined();
155
+ });
156
+
157
+ it('should be disabled when disabled prop is true', () => {
158
+ wrapper = mount(MPhoneNumber, {
159
+ props: { ...defaultProps, disabled: true },
160
+ });
161
+ expect(
162
+ wrapper.find('input[type="tel"]').attributes('disabled'),
163
+ ).toBeDefined();
164
+ expect(wrapper.find('select').attributes('disabled')).toBeDefined();
165
+ });
166
+
167
+ it('should be readonly when readonly prop is true', () => {
168
+ wrapper = mount(MPhoneNumber, {
169
+ props: { ...defaultProps, readonly: true },
170
+ });
171
+ expect(
172
+ wrapper.find('input[type="tel"]').attributes('readonly'),
173
+ ).toBeDefined();
174
+ expect(wrapper.find('select').attributes('readonly')).toBeDefined();
175
+ });
176
+
177
+ it('should apply invalid class when isInvalid is true', () => {
178
+ wrapper = mount(MPhoneNumber, {
179
+ props: { ...defaultProps, isInvalid: true },
180
+ });
181
+ expect(wrapper.find('.mc-phone-number-input__input').classes()).toContain(
182
+ 'is-invalid',
183
+ );
184
+ });
185
+ });
186
+
187
+ describe('Sizes', () => {
188
+ it('should have medium size by default', () => {
189
+ expect(wrapper.find('select').classes()).not.toContain('mc-select--s');
190
+ expect(
191
+ wrapper.find('.mc-phone-number-input__input').classes(),
192
+ ).not.toContain('mc-text-input--s');
193
+ });
194
+
195
+ it('should apply small size when size prop is s', () => {
196
+ wrapper = mount(MPhoneNumber, {
197
+ props: { ...defaultProps, size: 's' },
198
+ });
199
+ expect(wrapper.find('select').classes()).toContain('mc-select--s');
200
+ expect(wrapper.find('.mc-phone-number-input__input').classes()).toContain(
201
+ 'mc-text-input--s',
202
+ );
203
+ });
204
+ });
205
+
206
+ describe('Events', () => {
207
+ it('should emit update:modelValue with international format on input', async () => {
208
+ const input = wrapper.find('input[type="tel"]');
209
+
210
+ await input.setValue('+33123456789');
211
+ await nextTick();
212
+
213
+ expect(wrapper.emitted('update:modelValue')).toBeTruthy();
214
+
215
+ const emittedVal = wrapper.emitted('update:modelValue')?.[0][0] as string;
216
+ expect(emittedVal.startsWith('+')).toBe(true);
217
+ });
218
+
219
+ it('should emit valid event on input', async () => {
220
+ const input = wrapper.find('input[type="tel"]');
221
+ await input.setValue('+33123456789');
222
+ await nextTick();
223
+ expect(wrapper.emitted('valid')).toBeTruthy();
224
+ });
225
+
226
+ it('should sanitize input removing invalid characters', async () => {
227
+ const input = wrapper.find('input[type="tel"]');
228
+ await input.setValue('123abc456def+() -');
229
+
230
+ expect((input.element as HTMLInputElement).value).toBe('123456+() -');
231
+ });
232
+ });
233
+
234
+ describe('Validation', () => {
235
+ beforeEach(() => {
236
+ vi.mocked(isValidPhoneNumber).mockImplementation((number: string) => {
237
+ const digits = number.replace(/\D/g, '');
238
+ return digits.length >= 10;
239
+ });
240
+ });
241
+
242
+ it('should mark input invalid if phone number is invalid', async () => {
243
+ wrapper = mount(MPhoneNumber, {
244
+ props: { ...defaultProps, modelValue: '123' },
245
+ });
246
+ await nextTick();
247
+ expect(wrapper.find('.mc-phone-number-input__input').classes()).toContain(
248
+ 'is-invalid',
249
+ );
250
+ });
251
+
252
+ it('should not mark input invalid if empty', async () => {
253
+ wrapper = mount(MPhoneNumber, {
254
+ props: { ...defaultProps, modelValue: '' },
255
+ });
256
+ await nextTick();
257
+ expect(
258
+ wrapper.find('.mc-phone-number-input__input').classes(),
259
+ ).not.toContain('is-invalid');
260
+ });
261
+ });
262
+
263
+ describe('Accessibility', () => {
264
+ it('should set aria-invalid attribute correctly', async () => {
265
+ await wrapper.setProps({ isInvalid: true });
266
+ const input = wrapper.find('input[type="tel"]');
267
+ expect(input.attributes('aria-invalid')).toBeDefined();
268
+ });
269
+
270
+ it('should use provided id on input', () => {
271
+ const input = wrapper.find('input[type="tel"]');
272
+ expect(input.attributes('id')).toBe(defaultProps.id);
273
+ });
274
+
275
+ it('should set alt attribute on flag image', () => {
276
+ const flagImg = wrapper.find('img.mc-phone-number-input__flag-image');
277
+ expect(flagImg.attributes('alt')).toBeTruthy();
278
+ });
279
+
280
+ it('should mark chevron icon aria-hidden as true', () => {
281
+ const chevron = wrapper.find('[data-testid="chevron-icon"]');
282
+ expect(chevron.attributes('aria-hidden')).toBe('true');
283
+ });
284
+ });
285
+
286
+ describe('Props reactivity', () => {
287
+ it('should update input when modelValue changes', async () => {
288
+ await wrapper.setProps({ modelValue: '+33123456789' });
289
+ await nextTick();
290
+ const input = wrapper.find('input[type="tel"]');
291
+ expect((input.element as HTMLInputElement).value).toBe('+33123456789');
292
+ });
293
+ });
294
+ });
@@ -0,0 +1,88 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite';
2
+ import { action } from 'storybook/actions';
3
+ import { ref } from 'vue';
4
+
5
+ import MPhoneNumber from './MPhoneNumber.vue';
6
+
7
+ const meta: Meta<typeof MPhoneNumber> = {
8
+ title: 'Form Elements/Phone Number',
9
+ component: MPhoneNumber,
10
+ tags: ['v2'],
11
+ parameters: {
12
+ docs: {
13
+ description: {
14
+ component:
15
+ 'A phone number input is a specialized input field designed to capture and validate phone numbers, ensuring correct formatting based on country-specific dialing codes. It often includes a country selector that automatically adjusts the international dialing code. This component improves user experience by standardizing phone number entries, reducing errors, and facilitating global compatibility. It is commonly used in registration forms, authentication flows, and contact information fields.',
16
+ },
17
+ },
18
+ },
19
+ render: (args) => ({
20
+ components: { MPhoneNumber },
21
+ setup() {
22
+ const modelValue = ref(args.modelValue);
23
+ const isValid = ref(false);
24
+
25
+ const handleUpdate = (val: string) => {
26
+ modelValue.value = val;
27
+ action('update:modelValue')(val);
28
+ };
29
+
30
+ const handleValid = (valid: boolean) => {
31
+ isValid.value = valid;
32
+ action('valid')(valid);
33
+ };
34
+
35
+ return { args, modelValue, isValid, handleUpdate, handleValid };
36
+ },
37
+ template: `
38
+ <MPhoneNumber
39
+ v-bind="{ ...args, modelValue }"
40
+ @update:modelValue="handleUpdate"
41
+ @valid="handleValid"
42
+ />
43
+ `,
44
+ }),
45
+ };
46
+ export default meta;
47
+ type Story = StoryObj<typeof MPhoneNumber>;
48
+
49
+ export const Default: Story = {};
50
+
51
+ export const Size: Story = {
52
+ args: {
53
+ size: 's',
54
+ },
55
+ };
56
+
57
+ export const IsInvalid: Story = {
58
+ args: {
59
+ isInvalid: true,
60
+ modelValue: '1912',
61
+ },
62
+ };
63
+
64
+ export const NoPrefix: Story = {
65
+ args: {
66
+ prefix: false,
67
+ },
68
+ };
69
+
70
+ export const NoFlag: Story = {
71
+ args: {
72
+ flag: false,
73
+ },
74
+ };
75
+
76
+ export const Disabled: Story = {
77
+ args: {
78
+ modelValue: '0103948374',
79
+ disabled: true,
80
+ },
81
+ };
82
+
83
+ export const ReadOnly: Story = {
84
+ args: {
85
+ modelValue: '0103948374',
86
+ readonly: true,
87
+ },
88
+ };
@@ -0,0 +1,271 @@
1
+ <template>
2
+ <div id="mc-phone-number-input" class="mc-phone-number-input">
3
+ <div
4
+ class="mc-phone-number-input__select-wrapper"
5
+ :class="selectWrapperClass"
6
+ >
7
+ <select
8
+ id="selectComponentId"
9
+ v-model="selectedCountry"
10
+ name="selectComponentName"
11
+ class="mc-select mc-phone-number-input__select"
12
+ :class="sizeSelectClass"
13
+ :disabled="isDisabled"
14
+ :readonly="isReadOnly"
15
+ >
16
+ <option value="" selected hidden></option>
17
+ <option
18
+ v-for="country in countries"
19
+ :key="country"
20
+ :value="country"
21
+ :data-flag="country.toLowerCase()"
22
+ >
23
+ {{ getCountryName(country) }} (+{{ getCountryCallingCode(country) }})
24
+ </option>
25
+ </select>
26
+
27
+ <div class="mc-phone-number-input__select-display">
28
+ <div class="mc-phone-number-input__flag">
29
+ <img
30
+ class="mc-phone-number-input__flag-image"
31
+ :src="getFlagUrl(selectedCountry)"
32
+ :alt="getCountryName(selectedCountry)"
33
+ width="20"
34
+ />
35
+ </div>
36
+ <ChevronDown20
37
+ class="mc-phone-number-input__chevron"
38
+ aria-hidden="true"
39
+ />
40
+ </div>
41
+ </div>
42
+
43
+ <div class="mc-text-input mc-phone-number-input__input" :class="inputClass">
44
+ <span v-if="prefix" class="mc-phone-number-input__country-code">
45
+ +{{ getCountryCallingCode(selectedCountry) }}
46
+ </span>
47
+ <input
48
+ v-model="phoneNumber"
49
+ type="tel"
50
+ :id="id"
51
+ class="mc-phone-number-input__control mc-text-input__control"
52
+ :placeholder="dynamicPlaceholder"
53
+ name="phone-number"
54
+ :aria-invalid="isInvalid"
55
+ :disabled="isDisabled"
56
+ :readonly="isReadOnly"
57
+ @input="handleInput"
58
+ />
59
+ </div>
60
+ </div>
61
+ </template>
62
+
63
+ <script setup lang="ts">
64
+ import { ref, computed, watch } from 'vue';
65
+ import parsePhoneNumberFromString, {
66
+ isValidPhoneNumber,
67
+ getCountries,
68
+ getCountryCallingCode,
69
+ getExampleNumber,
70
+ type CountryCode,
71
+ } from 'libphonenumber-js';
72
+ import examples from 'libphonenumber-js/mobile/examples';
73
+ import ChevronDown20 from '@mozaic-ds/icons-vue/src/components/ChevronDown20/ChevronDown20.vue';
74
+ /**
75
+ * A phone number input is a specialized input field designed to capture and validate phone numbers, ensuring correct formatting based on country-specific dialing codes. It often includes a country selector that automatically adjusts the international dialing code. This component improves user experience by standardizing phone number entries, reducing errors, and facilitating global compatibility. It is commonly used in registration forms, authentication flows, and contact information fields.
76
+ */
77
+ const props = withDefaults(
78
+ defineProps<{
79
+ /**
80
+ * A unique identifier for the phone number input element, used to associate the label with the form element.
81
+ */
82
+ id: string;
83
+ /**
84
+ * The current value of the phone number input field.
85
+ */
86
+ modelValue?: string;
87
+ /**
88
+ * Default country code for phone number formatting (e.g., 'FR', 'US', 'PT').
89
+ */
90
+ defaultCountry?: CountryCode;
91
+ /**
92
+ * A placeholder text to show in the phone number input when it is empty.
93
+ */
94
+ placeholder?: string;
95
+ /**
96
+ * Determines the size of the phone number input.
97
+ */
98
+ size?: 's' | 'm';
99
+ /**
100
+ * If `true`, applies an invalid state to the password input.
101
+ */
102
+ isInvalid?: boolean;
103
+ /**
104
+ * If `true`, disables the input, making it non-interactive.
105
+ */
106
+ disabled?: boolean;
107
+ /**
108
+ * If `true`, the input is read-only (cannot be edited).
109
+ */
110
+ readonly?: boolean;
111
+ /**
112
+ * If `true`, display the country calling code prefix (+33, +1, etc.).
113
+ */
114
+ prefix?: boolean;
115
+ /**
116
+ * If `true`, display the country flag selector
117
+ */
118
+ flag?: boolean;
119
+ }>(),
120
+ {
121
+ modelValue: '',
122
+ defaultCountry: 'FR' as CountryCode,
123
+ size: 'm',
124
+ prefix: true,
125
+ flag: true,
126
+ },
127
+ );
128
+
129
+ const phoneNumber = ref(props.modelValue);
130
+ const selectedCountry = ref<CountryCode>(props.defaultCountry);
131
+ const countries = getCountries();
132
+
133
+ const dynamicPlaceholder = computed(() => {
134
+ if (props.placeholder && props.placeholder.length > 0) {
135
+ return props.placeholder;
136
+ }
137
+ return getExampleNumber(selectedCountry.value, examples)?.formatNational();
138
+ });
139
+
140
+ const isValid = computed(() => {
141
+ if (!phoneNumber.value) return false;
142
+ try {
143
+ return isValidPhoneNumber(phoneNumber.value, selectedCountry.value);
144
+ } catch {
145
+ return false;
146
+ }
147
+ });
148
+
149
+ const parsedNumber = computed(() => {
150
+ if (!phoneNumber.value) return null;
151
+ try {
152
+ return parsePhoneNumberFromString(phoneNumber.value, selectedCountry.value);
153
+ } catch {
154
+ return null;
155
+ }
156
+ });
157
+
158
+ const getCountryName = (
159
+ countryCode: CountryCode,
160
+ locale: string = 'fr',
161
+ ): string => {
162
+ try {
163
+ const regionNames = new Intl.DisplayNames([locale], { type: 'region' });
164
+ return regionNames.of(countryCode) || countryCode;
165
+ } catch {
166
+ return countryCode;
167
+ }
168
+ };
169
+
170
+ watch(
171
+ [phoneNumber, selectedCountry],
172
+ () => {
173
+ // Auto-format input when number is valid
174
+ if (isValid.value && parsedNumber.value) {
175
+ const formattedNational = parsedNumber.value.formatNational();
176
+ if (formattedNational !== phoneNumber.value) {
177
+ phoneNumber.value = formattedNational;
178
+ }
179
+ }
180
+
181
+ // Emit international format for v-model
182
+ const fullNumber = parsedNumber.value?.number || phoneNumber.value;
183
+ emit('update:modelValue', fullNumber);
184
+ emit('valid', isValid.value);
185
+ },
186
+ { flush: 'post' },
187
+ );
188
+
189
+ watch(
190
+ () => props.modelValue,
191
+ (newValue) => {
192
+ if (newValue && newValue !== phoneNumber.value) {
193
+ if (newValue.startsWith('+')) {
194
+ try {
195
+ const parsed = parsePhoneNumberFromString(
196
+ newValue,
197
+ selectedCountry.value,
198
+ );
199
+ if (parsed) {
200
+ phoneNumber.value = parsed.formatNational();
201
+ } else {
202
+ phoneNumber.value = newValue;
203
+ }
204
+ } catch {
205
+ phoneNumber.value = newValue;
206
+ }
207
+ } else {
208
+ phoneNumber.value = newValue;
209
+ }
210
+ } else if (!newValue) {
211
+ phoneNumber.value = '';
212
+ }
213
+ },
214
+ { immediate: true },
215
+ );
216
+
217
+ const sizeSelectClass = computed(() => {
218
+ return props.size !== 'm' ? `mc-select--${props.size}` : '';
219
+ });
220
+
221
+ const sizeInputClass = computed(() => {
222
+ return props.size !== 'm' ? `mc-text-input--${props.size}` : '';
223
+ });
224
+
225
+ const isDisabled = computed(() => props.disabled);
226
+ const isReadOnly = computed(() => props.readonly);
227
+
228
+ const selectWrapperClass = computed(() => {
229
+ return { 'mc-phone-number-input__select-wrapper--hidden': !props.flag };
230
+ });
231
+
232
+ const inputClass = computed(() => {
233
+ const hasValue = phoneNumber.value && phoneNumber.value.trim().length > 0;
234
+ const isInvalidState = props.isInvalid || (hasValue && !isValid.value);
235
+ return [sizeInputClass.value, { 'is-invalid': isInvalidState }];
236
+ });
237
+
238
+ const getFlagUrl = (countryCode: CountryCode): string => {
239
+ return `https://flagcdn.com/${countryCode.toLowerCase()}.svg`;
240
+ };
241
+
242
+ const handleInput = (event: Event) => {
243
+ const input = event.target as HTMLInputElement;
244
+ const value = input.value;
245
+ const sanitized = value.replace(/[^0-9+\s()-]/g, '');
246
+
247
+ phoneNumber.value = sanitized;
248
+ if (value !== sanitized) {
249
+ input.value = sanitized;
250
+ }
251
+ };
252
+
253
+ const emit = defineEmits<{
254
+ /**
255
+ * Emits when the input value changes, updating the `modelValue` prop.
256
+ */
257
+ 'update:modelValue': [value: string];
258
+ /**
259
+ * Emits when the validation state of the phone number changes.
260
+ */
261
+ valid: [isValid: boolean];
262
+ }>();
263
+ </script>
264
+
265
+ <style lang="scss">
266
+ @use '@mozaic-ds/styles/components/text-input';
267
+ @use '@mozaic-ds/styles/components/button';
268
+ @use '@mozaic-ds/styles/components/listbox';
269
+ @use '@mozaic-ds/styles/components/select';
270
+ @use '@mozaic-ds/styles/components/phone-number-input';
271
+ </style>
@@ -0,0 +1,26 @@
1
+ # MPhoneNumber
2
+
3
+ A phone number input is a specialized input field designed to capture and validate phone numbers, ensuring correct formatting based on country-specific dialing codes. It often includes a country selector that automatically adjusts the international dialing code. This component improves user experience by standardizing phone number entries, reducing errors, and facilitating global compatibility. It is commonly used in registration forms, authentication flows, and contact information fields.
4
+
5
+
6
+ ## Props
7
+
8
+ | Name | Description | Type | Default |
9
+ | --- | --- | --- | --- |
10
+ | `id*` | A unique identifier for the phone number input element, used to associate the label with the form element. | `string` | - |
11
+ | `modelValue` | The current value of the phone number input field. | `string` | `""` |
12
+ | `defaultCountry` | Default country code for phone number formatting (e.g., 'FR', 'US', 'PT'). | `CountryCode` | `"FR" as CountryCode` |
13
+ | `placeholder` | A placeholder text to show in the phone number input when it is empty. | `string` | - |
14
+ | `size` | Determines the size of the phone number input. | `"s"` `"m"` | `"m"` |
15
+ | `isInvalid` | If `true`, applies an invalid state to the password input. | `boolean` | - |
16
+ | `disabled` | If `true`, disables the input, making it non-interactive. | `boolean` | - |
17
+ | `readonly` | If `true`, the input is read-only (cannot be edited). | `boolean` | - |
18
+ | `prefix` | If `true`, display the country calling code prefix (+33, +1, etc.). | `boolean` | `true` |
19
+ | `flag` | If `true`, display the country flag selector | `boolean` | `true` |
20
+
21
+ ## Events
22
+
23
+ | Name | Description | Type |
24
+ | --- | --- | --- |
25
+ | `update:modelValue` | - | `[value: string]` |
26
+ | `valid` | - | `[isValid: boolean]` |
@@ -93,7 +93,10 @@ describe('MPincode component', () => {
93
93
  },
94
94
  });
95
95
 
96
- expect(wrapper.classes()).toContain('is-invalid');
96
+ const inputs = wrapper.findAll('input');
97
+ for (const input of inputs) {
98
+ expect(input.classes()).toContain('is-invalid');
99
+ }
97
100
  });
98
101
 
99
102
  it('disables inputs when disabled is true', () => {
@@ -6,6 +6,7 @@ import MPincode from './MPincode.vue';
6
6
  const meta: Meta<typeof MPincode> = {
7
7
  title: 'Form Elements/Pincode',
8
8
  component: MPincode,
9
+ tags: ['v2'],
9
10
  parameters: {
10
11
  docs: {
11
12
  description: {