@mozaic-ds/vue 2.7.0 → 2.9.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 +183 -100
- package/dist/mozaic-vue.js +2918 -978
- package/dist/mozaic-vue.js.map +1 -1
- package/dist/mozaic-vue.umd.cjs +5 -1
- package/dist/mozaic-vue.umd.cjs.map +1 -1
- package/package.json +3 -2
- package/src/components/drawer/MDrawer.spec.ts +144 -5
- package/src/components/drawer/MDrawer.vue +94 -40
- package/src/components/drawer/README.md +2 -0
- package/src/components/field/MField.spec.ts +94 -85
- package/src/components/field/MField.stories.ts +16 -0
- package/src/components/field/MField.vue +8 -1
- package/src/components/field/README.md +1 -0
- package/src/components/iconbutton/MIconButton.vue +5 -0
- package/src/components/loader/MLoader.spec.ts +41 -0
- package/src/components/loader/MLoader.vue +7 -1
- package/src/components/loader/README.md +1 -1
- package/src/components/loadingoverlay/MLoadingOverlay.stories.ts +1 -1
- package/src/components/modal/MModal.spec.ts +63 -3
- package/src/components/modal/MModal.vue +50 -8
- package/src/components/modal/README.md +2 -0
- package/src/components/phonenumber/MPhoneNumber.spec.ts +294 -0
- package/src/components/phonenumber/MPhoneNumber.stories.ts +88 -0
- package/src/components/phonenumber/MPhoneNumber.vue +271 -0
- package/src/components/phonenumber/README.md +26 -0
- package/src/components/pincode/README.md +1 -1
- package/src/components/quantityselector/MQuantitySelector.stories.ts +0 -7
- package/src/components/togglegroup/MToggleGroup.vue +0 -2
- package/src/components/togglegroup/README.md +1 -1
- package/src/main.ts +1 -0
|
@@ -125,6 +125,22 @@ export const InputInvalid: Story = {
|
|
|
125
125
|
},
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
+
export const InputLoading: Story = {
|
|
129
|
+
args: {
|
|
130
|
+
label: 'Label',
|
|
131
|
+
id: 'inputLoadingId',
|
|
132
|
+
isLoading: true,
|
|
133
|
+
message: 'Loading message (Be concise and use comprehensive words).',
|
|
134
|
+
default: `
|
|
135
|
+
<MTextInput
|
|
136
|
+
id="inputInvalidId"
|
|
137
|
+
placeholder="Placeholder"
|
|
138
|
+
@update:modelValue="handleUpdate"
|
|
139
|
+
/>
|
|
140
|
+
`,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
128
144
|
export const Textarea: Story = {
|
|
129
145
|
args: {
|
|
130
146
|
label: 'Label',
|
|
@@ -16,11 +16,12 @@
|
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
18
|
<span
|
|
19
|
-
v-if="(isValid || isInvalid) && message"
|
|
19
|
+
v-if="(isValid || isInvalid || isLoading) && message"
|
|
20
20
|
class="mc-field__validation-message"
|
|
21
21
|
:id="messageId"
|
|
22
22
|
:class="classObjectValidation"
|
|
23
23
|
>
|
|
24
|
+
<MLoader v-if="isLoading" size="xs"></MLoader>
|
|
24
25
|
{{ message }}
|
|
25
26
|
</span>
|
|
26
27
|
</div>
|
|
@@ -28,6 +29,7 @@
|
|
|
28
29
|
|
|
29
30
|
<script setup lang="ts">
|
|
30
31
|
import { computed, type VNode } from 'vue';
|
|
32
|
+
import MLoader from '../loader/MLoader.vue';
|
|
31
33
|
/**
|
|
32
34
|
* A field label is a text element that identifies the purpose of an input field, providing users with clear guidance on what information to enter. It is typically placed above the input field and may include indicators for required or optional fields. Field Labels improve form usability, accessibility, and data entry accuracy by ensuring users understand the expected input.
|
|
33
35
|
*/
|
|
@@ -60,6 +62,10 @@ const props = defineProps<{
|
|
|
60
62
|
* If `true`, applies an invalid state to the form field.
|
|
61
63
|
*/
|
|
62
64
|
isInvalid?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* If `true`, applies a loading state to the form field.
|
|
67
|
+
*/
|
|
68
|
+
isLoading?: boolean;
|
|
63
69
|
/**
|
|
64
70
|
* The value of the `id` attribute set on the **validationMessage** element. _This value is mandatory when using a validationMessage in order to guarantee the accessibility of the component._
|
|
65
71
|
*/
|
|
@@ -81,6 +87,7 @@ const classObjectValidation = computed(() => {
|
|
|
81
87
|
return {
|
|
82
88
|
'is-valid': props.isValid,
|
|
83
89
|
'is-invalid': props.isInvalid,
|
|
90
|
+
'is-loading': props.isLoading,
|
|
84
91
|
};
|
|
85
92
|
});
|
|
86
93
|
</script>
|
|
@@ -14,6 +14,7 @@ A field label is a text element that identifies the purpose of an input field, p
|
|
|
14
14
|
| `helpId` | The value of the `id` attribute set on the **helpText** element. _This value is mandatory when using a helpText in order to guarantee the accessibility of the component._ | `string` | - |
|
|
15
15
|
| `isValid` | If `true`, applies a valid state to the form field. | `boolean` | - |
|
|
16
16
|
| `isInvalid` | If `true`, applies an invalid state to the form field. | `boolean` | - |
|
|
17
|
+
| `isLoading` | If `true`, applies a loading state to the form field. | `boolean` | - |
|
|
17
18
|
| `messageId` | The value of the `id` attribute set on the **validationMessage** element. _This value is mandatory when using a validationMessage in order to guarantee the accessibility of the component._ | `string` | - |
|
|
18
19
|
| `message` | message displayed when the form field has a valid or invalid state, usually indicating validation or errors. | `string` | - |
|
|
19
20
|
|
|
@@ -101,4 +101,45 @@ describe('MLoader component', () => {
|
|
|
101
101
|
await wrapper.setProps({ size: 'l' });
|
|
102
102
|
expect(circle.attributes('r')).toBe('19');
|
|
103
103
|
});
|
|
104
|
+
|
|
105
|
+
it('applies mc-loader--text-visible class when text prop is provided', () => {
|
|
106
|
+
const wrapper = mount(MLoader, {
|
|
107
|
+
props: { text: 'Loading data...' },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const loader = wrapper.find('.mc-loader');
|
|
111
|
+
expect(loader.classes()).toContain('mc-loader--text-visible');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('does not apply mc-loader--text-visible class when no text is provided', () => {
|
|
115
|
+
const wrapper = mount(MLoader);
|
|
116
|
+
const loader = wrapper.find('.mc-loader');
|
|
117
|
+
expect(loader.classes()).not.toContain('mc-loader--text-visible');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('uses default props correctly (appearance=standard, size=m)', () => {
|
|
121
|
+
const wrapper = mount(MLoader);
|
|
122
|
+
const loader = wrapper.find('.mc-loader');
|
|
123
|
+
|
|
124
|
+
expect(loader.classes()).not.toContain('mc-loader--standard');
|
|
125
|
+
expect(loader.classes()).not.toContain('mc-loader--m');
|
|
126
|
+
|
|
127
|
+
const svg = wrapper.find('svg.mc-loader__icon');
|
|
128
|
+
expect(svg.attributes('viewBox')).toBe('0 0 32 32');
|
|
129
|
+
|
|
130
|
+
const circle = wrapper.find('circle.mc-loader__path');
|
|
131
|
+
expect(circle.attributes('r')).toBe('9');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('sets correct viewBox for xs size', async () => {
|
|
135
|
+
const wrapper = mount(MLoader, { props: { size: 'xs' } });
|
|
136
|
+
const svg = wrapper.find('svg.mc-loader__icon');
|
|
137
|
+
expect(svg.attributes('viewBox')).toBe('0 0 24 24');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('sets correct circle radius for xs size', async () => {
|
|
141
|
+
const wrapper = mount(MLoader, { props: { size: 'xs' } });
|
|
142
|
+
const circle = wrapper.find('circle.mc-loader__path');
|
|
143
|
+
expect(circle.attributes('r')).toBe('3');
|
|
144
|
+
});
|
|
104
145
|
});
|
|
@@ -34,7 +34,7 @@ const props = withDefaults(
|
|
|
34
34
|
/**
|
|
35
35
|
* Defines the size of the loader.
|
|
36
36
|
*/
|
|
37
|
-
size?: 's' | 'm' | 'l';
|
|
37
|
+
size?: 'xs' | 's' | 'm' | 'l';
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Text to display alongside the loader when using the loader inside an `Overlay`.
|
|
@@ -60,6 +60,9 @@ const setViewBox = computed(() => {
|
|
|
60
60
|
let viewBox: string;
|
|
61
61
|
|
|
62
62
|
switch (props.size) {
|
|
63
|
+
case 'xs':
|
|
64
|
+
viewBox = '0 0 24 24';
|
|
65
|
+
break;
|
|
63
66
|
case 's':
|
|
64
67
|
viewBox = '0 0 24 24';
|
|
65
68
|
break;
|
|
@@ -76,6 +79,9 @@ const setCircleRadius = computed(() => {
|
|
|
76
79
|
let circleRadius: number;
|
|
77
80
|
|
|
78
81
|
switch (props.size) {
|
|
82
|
+
case 'xs':
|
|
83
|
+
circleRadius = 3;
|
|
84
|
+
break;
|
|
79
85
|
case 's':
|
|
80
86
|
circleRadius = 6;
|
|
81
87
|
break;
|
|
@@ -8,5 +8,5 @@ A loader is a visual indicator used to inform users that a process is in progres
|
|
|
8
8
|
| Name | Description | Type | Default |
|
|
9
9
|
| --- | --- | --- | --- |
|
|
10
10
|
| `appearance` | Specifies the visual appearance of the loader. | `"standard"` `"inverse"` `"accent"` | `"standard"` |
|
|
11
|
-
| `size` | Defines the size of the loader. | `"s"` `"m"` `"l"` | `"m"` |
|
|
11
|
+
| `size` | Defines the size of the loader. | `"s"` `"m"` `"l"` `"xs"` | `"m"` |
|
|
12
12
|
| `text` | Text to display alongside the loader when using the loader inside an `Overlay`. | `string` | - |
|
|
@@ -8,7 +8,8 @@ const stubs = {
|
|
|
8
8
|
template: `<button @click="$emit('click')"><slot name="icon"/></button>`,
|
|
9
9
|
},
|
|
10
10
|
MOverlay: {
|
|
11
|
-
|
|
11
|
+
name: 'MOverlay',
|
|
12
|
+
template: `<div class="overlay" @click="$emit('click')"><slot/></div>`,
|
|
12
13
|
},
|
|
13
14
|
};
|
|
14
15
|
|
|
@@ -53,8 +54,9 @@ describe('MModal component', () => {
|
|
|
53
54
|
|
|
54
55
|
await wrapper.find('button.mc-modal__close').trigger('click');
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
expect(
|
|
57
|
+
const emitted = wrapper.emitted('update:open');
|
|
58
|
+
expect(emitted).toBeTruthy();
|
|
59
|
+
expect(emitted![emitted!.length - 1]).toEqual([false]);
|
|
58
60
|
});
|
|
59
61
|
|
|
60
62
|
it('renders slots content', () => {
|
|
@@ -100,4 +102,62 @@ describe('MModal component', () => {
|
|
|
100
102
|
|
|
101
103
|
expect(wrapper.find('.mc-modal').classes()).toContain('is-open');
|
|
102
104
|
});
|
|
105
|
+
|
|
106
|
+
it('emits update:open with false when overlay clicked and closeOnOverlay is true', async () => {
|
|
107
|
+
const wrapper = mount(MModal, {
|
|
108
|
+
props: { open: true, title: 'Title', closeOnOverlay: true },
|
|
109
|
+
global: { stubs },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await wrapper.findComponent({ name: 'MOverlay' }).trigger('click');
|
|
113
|
+
|
|
114
|
+
const emitted = wrapper.emitted('update:open');
|
|
115
|
+
expect(emitted).toBeTruthy();
|
|
116
|
+
|
|
117
|
+
expect(emitted![emitted!.length - 1]).toEqual([false]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('does not emit update:open when overlay clicked and closeOnOverlay is false', async () => {
|
|
121
|
+
const wrapper = mount(MModal, {
|
|
122
|
+
props: { open: true, title: 'Title', closeOnOverlay: false },
|
|
123
|
+
global: { stubs },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await wrapper.findComponent({ name: 'MOverlay' }).trigger('click');
|
|
127
|
+
|
|
128
|
+
const emitted = wrapper.emitted('update:open');
|
|
129
|
+
|
|
130
|
+
if (emitted) {
|
|
131
|
+
const lastValue = emitted[emitted.length - 1][0];
|
|
132
|
+
expect(lastValue).not.toBe(false);
|
|
133
|
+
} else {
|
|
134
|
+
expect(emitted).toBeFalsy();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('locks scroll when open and unlocks when closed', async () => {
|
|
139
|
+
const wrapper = mount(MModal, {
|
|
140
|
+
props: { open: false, title: 'Title', scroll: false },
|
|
141
|
+
global: { stubs },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await wrapper.setProps({ open: true });
|
|
145
|
+
expect(document.body.style.overflow).toBe('hidden');
|
|
146
|
+
expect(document.documentElement.style.overflow).toBe('hidden');
|
|
147
|
+
|
|
148
|
+
await wrapper.setProps({ open: false });
|
|
149
|
+
expect(document.body.style.overflow).toBe('');
|
|
150
|
+
expect(document.documentElement.style.overflow).toBe('');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('does not lock scroll if scroll prop is true', async () => {
|
|
154
|
+
const wrapper = mount(MModal, {
|
|
155
|
+
props: { open: false, title: 'Title', scroll: true },
|
|
156
|
+
global: { stubs },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await wrapper.setProps({ open: true });
|
|
160
|
+
expect(document.body.style.overflow).toBe('');
|
|
161
|
+
expect(document.documentElement.style.overflow).toBe('');
|
|
162
|
+
});
|
|
103
163
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<MOverlay :is-visible="open" dialogLabel="modalTitle">
|
|
2
|
+
<MOverlay :is-visible="open" dialogLabel="modalTitle" @click="onClickOverlay">
|
|
3
3
|
<section
|
|
4
4
|
class="mc-modal"
|
|
5
5
|
:class="classObject"
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
:aria-hidden="!open"
|
|
11
11
|
v-bind="$attrs"
|
|
12
12
|
@keydown.esc="onClose"
|
|
13
|
+
@click.stop
|
|
13
14
|
>
|
|
14
15
|
<div class="mc-modal__dialog" role="document">
|
|
15
16
|
<header class="mc-modal__header">
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
</template>
|
|
48
49
|
|
|
49
50
|
<script setup lang="ts">
|
|
50
|
-
import { computed, watch, type VNode } from 'vue';
|
|
51
|
+
import { computed, onMounted, onUnmounted, watch, type VNode } from 'vue';
|
|
51
52
|
import Cross24 from '@mozaic-ds/icons-vue/src/components/Cross24/Cross24.vue';
|
|
52
53
|
import MIconButton from '../iconbutton/MIconButton.vue';
|
|
53
54
|
import MOverlay from '../overlay/MOverlay.vue';
|
|
@@ -72,9 +73,18 @@ const props = withDefaults(
|
|
|
72
73
|
* if `true`, display the close button.
|
|
73
74
|
*/
|
|
74
75
|
closable?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* if `false`, lock the scroll when open.
|
|
78
|
+
*/
|
|
79
|
+
scroll?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* if `true`, close the modal when clicking the overlay.
|
|
82
|
+
*/
|
|
83
|
+
closeOnOverlay?: boolean;
|
|
75
84
|
}>(),
|
|
76
85
|
{
|
|
77
86
|
closable: true,
|
|
87
|
+
scroll: true,
|
|
78
88
|
},
|
|
79
89
|
);
|
|
80
90
|
|
|
@@ -103,12 +113,44 @@ const classObject = computed(() => {
|
|
|
103
113
|
};
|
|
104
114
|
});
|
|
105
115
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
116
|
+
const isClient =
|
|
117
|
+
typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
118
|
+
|
|
119
|
+
const lockScroll = () => {
|
|
120
|
+
if (!isClient) return;
|
|
121
|
+
document.body.style.overflow = 'hidden';
|
|
122
|
+
document.documentElement.style.overflow = 'hidden';
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const unlockScroll = () => {
|
|
126
|
+
if (!isClient) return;
|
|
127
|
+
document.body.style.overflow = '';
|
|
128
|
+
document.documentElement.style.overflow = '';
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
onMounted(() => {
|
|
132
|
+
watch(
|
|
133
|
+
() => props.open,
|
|
134
|
+
(isOpen) => {
|
|
135
|
+
emit('update:open', isOpen);
|
|
136
|
+
if (props.scroll === false) {
|
|
137
|
+
if (isOpen) lockScroll();
|
|
138
|
+
else unlockScroll();
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
{ immediate: true },
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
onUnmounted(() => {
|
|
146
|
+
unlockScroll();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const onClickOverlay = () => {
|
|
150
|
+
if (props.closeOnOverlay) {
|
|
151
|
+
onClose();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
112
154
|
|
|
113
155
|
const onClose = () => {
|
|
114
156
|
emit('update:open', false);
|
|
@@ -11,6 +11,8 @@ A modal is a dialog window that appears on top of the main content, requiring us
|
|
|
11
11
|
| `title*` | Title of the modal. | `string` | - |
|
|
12
12
|
| `description` | Description of the modal. | `string` | - |
|
|
13
13
|
| `closable` | if `true`, display the close button. | `boolean` | `true` |
|
|
14
|
+
| `scroll` | if `false`, lock the scroll when open. | `boolean` | `true` |
|
|
15
|
+
| `closeOnOverlay` | if `true`, close the modal when clicking the overlay. | `boolean` | - |
|
|
14
16
|
|
|
15
17
|
## Slots
|
|
16
18
|
|
|
@@ -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
|
+
});
|