@mozaic-ds/vue 1.0.0-beta.8 → 1.0.0-beta.9
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 +1 -1
- package/dist/mozaic-vue.css +1 -1
- package/dist/mozaic-vue.d.ts +410 -198
- package/dist/mozaic-vue.js +1100 -777
- package/dist/mozaic-vue.js.map +1 -1
- package/dist/mozaic-vue.umd.cjs +1 -1
- package/dist/mozaic-vue.umd.cjs.map +1 -1
- package/package.json +8 -11
- package/src/components/Contributing.mdx +1 -1
- package/src/components/GettingStarted.mdx +2 -7
- package/src/components/Introduction.mdx +41 -21
- package/src/components/Support.mdx +1 -1
- package/src/components/breadcrumb/MBreadcrumb.stories.ts +11 -13
- package/src/components/breadcrumb/MBreadcrumb.vue +1 -1
- package/src/components/button/MButton.stories.ts +1 -8
- package/src/components/checkbox/MCheckbox.stories.ts +2 -2
- package/src/components/checkboxgroup/MCheckboxGroup.stories.ts +2 -2
- package/src/components/divider/MDivider.stories.ts +2 -2
- package/src/components/divider/MDivider.vue +2 -2
- package/src/components/drawer/MDrawer.spec.ts +100 -0
- package/src/components/drawer/MDrawer.stories.ts +128 -0
- package/src/components/drawer/MDrawer.vue +140 -0
- package/src/components/field/MField.stories.ts +2 -9
- package/src/components/fieldgroup/MFieldGroup.stories.ts +2 -9
- package/src/components/iconbutton/MIconButton.stories.ts +12 -4
- package/src/components/link/MLink.stories.ts +3 -12
- package/src/components/loader/MLoader.stories.ts +3 -5
- package/src/components/loader/MLoader.vue +1 -0
- package/src/components/loadingoverlay/MLoadingOverlay.spec.ts +37 -0
- package/src/components/loadingoverlay/MLoadingOverlay.stories.ts +40 -0
- package/src/components/loadingoverlay/MLoadingOverlay.vue +28 -0
- package/src/components/modal/MModal.spec.ts +103 -0
- package/src/components/modal/MModal.stories.ts +127 -0
- package/src/components/modal/MModal.vue +131 -0
- package/src/components/numberbadge/MNumberBadge.stories.ts +3 -5
- package/src/components/overlay/MOverlay.stories.ts +3 -8
- package/src/components/pagination/MPagination.stories.ts +3 -3
- package/src/components/pagination/MPagination.vue +5 -3
- package/src/components/passwordinput/MPasswordInput.stories.ts +2 -2
- package/src/components/passwordinput/MPasswordInput.vue +2 -5
- package/src/components/pincode/MPincode.spec.ts +126 -0
- package/src/components/pincode/MPincode.stories.ts +68 -0
- package/src/components/pincode/MPincode.vue +139 -0
- package/src/components/quantityselector/MQuantitySelector.stories.ts +2 -2
- package/src/components/radio/MRadio.stories.ts +2 -2
- package/src/components/radiogroup/MRadioGroup.stories.ts +2 -2
- package/src/components/select/MSelect.stories.ts +2 -2
- package/src/components/statusbadge/MStatusBadge.stories.ts +1 -1
- package/src/components/statusdot/MStatusDot.stories.ts +1 -1
- package/src/components/statusnotification/MStatusNotification.spec.ts +12 -8
- package/src/components/statusnotification/MStatusNotification.stories.ts +2 -9
- package/src/components/statusnotification/MStatusNotification.vue +8 -8
- package/src/components/tabs/MTabs.stories.ts +4 -4
- package/src/components/tabs/MTabs.vue +2 -2
- package/src/components/tabs/Mtabs.spec.ts +56 -61
- package/src/components/tag/MTag.stories.ts +2 -2
- package/src/components/tag/MTag.vue +1 -4
- package/src/components/textarea/MTextArea.stories.ts +2 -2
- package/src/components/textinput/MTextInput.stories.ts +2 -9
- package/src/components/toggle/MToggle.stories.ts +2 -2
- package/src/components/togglegroup/MToggleGroup.stories.ts +2 -2
- package/src/components/usingIcons.mdx +5 -13
- package/src/components/usingPresets.mdx +12 -9
- package/src/main.ts +4 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<MOverlay :is-visible="open" dialogLabel="modalTitle">
|
|
3
|
+
<section
|
|
4
|
+
class="mc-modal"
|
|
5
|
+
:class="classObject"
|
|
6
|
+
role="dialog"
|
|
7
|
+
aria-labelledby="modalTitle"
|
|
8
|
+
:aria-modal="open ? 'true' : 'false'"
|
|
9
|
+
tabindex="-1"
|
|
10
|
+
:aria-hidden="!open"
|
|
11
|
+
v-bind="$attrs"
|
|
12
|
+
@keydown.esc="onClose"
|
|
13
|
+
>
|
|
14
|
+
<div class="mc-modal__dialog" role="document">
|
|
15
|
+
<header class="mc-modal__header">
|
|
16
|
+
<span v-if="$slots.icon" class="mc-modal__icon">
|
|
17
|
+
<slot name="icon" />
|
|
18
|
+
</span>
|
|
19
|
+
<h2 class="mc-modal__title" id="modalTitle">
|
|
20
|
+
{{ title }}
|
|
21
|
+
</h2>
|
|
22
|
+
<MIconButton
|
|
23
|
+
v-if="closable"
|
|
24
|
+
class="mc-modal__close"
|
|
25
|
+
aria-label="Close"
|
|
26
|
+
ghost
|
|
27
|
+
@click="onClose"
|
|
28
|
+
>
|
|
29
|
+
<template #icon>
|
|
30
|
+
<Cross24 aria-hidden="true" />
|
|
31
|
+
</template>
|
|
32
|
+
</MIconButton>
|
|
33
|
+
</header>
|
|
34
|
+
<main class="mc-modal__body">
|
|
35
|
+
<p>{{ description }}</p>
|
|
36
|
+
<slot />
|
|
37
|
+
</main>
|
|
38
|
+
<footer v-if="$slots.footer" class="mc-modal__footer">
|
|
39
|
+
<span class="mc-modal__link">
|
|
40
|
+
<slot name="link" />
|
|
41
|
+
</span>
|
|
42
|
+
<slot name="footer" />
|
|
43
|
+
</footer>
|
|
44
|
+
</div>
|
|
45
|
+
</section>
|
|
46
|
+
</MOverlay>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<script setup lang="ts">
|
|
50
|
+
import { computed, watch, type VNode } from 'vue';
|
|
51
|
+
import Cross24 from '@mozaic-ds/icons-vue/src/components/Cross24/Cross24.vue';
|
|
52
|
+
import MIconButton from '../iconbutton/MIconButton.vue';
|
|
53
|
+
import MOverlay from '../overlay/MOverlay.vue';
|
|
54
|
+
/**
|
|
55
|
+
* A modal is a dialog window that appears on top of the main content, requiring user interaction before returning to the main interface. It is used to focus attention on a specific task, provide important information, or request confirmation for an action. Modals typically include a title, description, and primary/secondary actions and should be used for single, focused tasks to avoid disrupting the user experience.
|
|
56
|
+
*/
|
|
57
|
+
const props = withDefaults(
|
|
58
|
+
defineProps<{
|
|
59
|
+
/**
|
|
60
|
+
* if `true`, display the modal.
|
|
61
|
+
*/
|
|
62
|
+
open?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Title of the modal
|
|
65
|
+
*/
|
|
66
|
+
title: string;
|
|
67
|
+
/**
|
|
68
|
+
* Description of the modal
|
|
69
|
+
*/
|
|
70
|
+
description?: string;
|
|
71
|
+
/**
|
|
72
|
+
* if `true`, display the close button.
|
|
73
|
+
*/
|
|
74
|
+
closable?: boolean;
|
|
75
|
+
}>(),
|
|
76
|
+
{
|
|
77
|
+
closable: true,
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
defineSlots<{
|
|
82
|
+
/**
|
|
83
|
+
* Use this slot to insert an icon next to the title of the modal
|
|
84
|
+
*/
|
|
85
|
+
icon?: VNode;
|
|
86
|
+
/**
|
|
87
|
+
* Use this slot to insert the content of the modal
|
|
88
|
+
*/
|
|
89
|
+
default?: VNode;
|
|
90
|
+
/**
|
|
91
|
+
* Use this slot to insert a link in the footer
|
|
92
|
+
*/
|
|
93
|
+
link?: VNode;
|
|
94
|
+
/**
|
|
95
|
+
* Use this slot to insert buttons in the footer
|
|
96
|
+
*/
|
|
97
|
+
footer?: VNode;
|
|
98
|
+
}>();
|
|
99
|
+
|
|
100
|
+
const classObject = computed(() => {
|
|
101
|
+
return {
|
|
102
|
+
'is-open': props.open,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
watch(
|
|
107
|
+
() => props.open,
|
|
108
|
+
(newValue) => {
|
|
109
|
+
emit('update:open', newValue);
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const onClose = () => {
|
|
114
|
+
emit('update:open', false);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const emit = defineEmits<{
|
|
118
|
+
/**
|
|
119
|
+
* Emits when the checkbox value changes, updating the modelValue prop.
|
|
120
|
+
*/
|
|
121
|
+
(on: 'update:open', value: boolean): void;
|
|
122
|
+
}>();
|
|
123
|
+
</script>
|
|
124
|
+
|
|
125
|
+
<style lang="scss" scoped>
|
|
126
|
+
@use '@mozaic-ds/styles/components/modal';
|
|
127
|
+
|
|
128
|
+
.mc-overlay {
|
|
129
|
+
filter: none;
|
|
130
|
+
}
|
|
131
|
+
</style>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from '@storybook/vue3';
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
2
2
|
import MNumberBadge from './MNumberBadge.vue';
|
|
3
3
|
|
|
4
4
|
const meta: Meta<typeof MNumberBadge> = {
|
|
@@ -37,10 +37,8 @@ export const Danger: Story = {
|
|
|
37
37
|
};
|
|
38
38
|
|
|
39
39
|
export const Inverse: Story = {
|
|
40
|
-
|
|
41
|
-
backgrounds: {
|
|
42
|
-
default: 'Inverse',
|
|
43
|
-
},
|
|
40
|
+
globals: {
|
|
41
|
+
backgrounds: { value: 'inverse' },
|
|
44
42
|
},
|
|
45
43
|
args: { appearance: 'inverse' },
|
|
46
44
|
};
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from '@storybook/vue3';
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
2
2
|
import MOverlay from './MOverlay.vue';
|
|
3
3
|
|
|
4
4
|
const meta: Meta<typeof MOverlay> = {
|
|
5
5
|
title: 'Overlay/Overlay',
|
|
6
6
|
component: MOverlay,
|
|
7
7
|
parameters: {
|
|
8
|
+
layout: 'fullscreen',
|
|
8
9
|
docs: {
|
|
10
|
+
story: { height: '400px' },
|
|
9
11
|
description: {
|
|
10
12
|
component:
|
|
11
13
|
'An overlay component is a UI element that appears above the main content to display additional information or interactions, often blocking or dimming the background.',
|
|
@@ -15,13 +17,6 @@ const meta: Meta<typeof MOverlay> = {
|
|
|
15
17
|
args: {
|
|
16
18
|
isVisible: true,
|
|
17
19
|
},
|
|
18
|
-
argTypes: {
|
|
19
|
-
$slots: {
|
|
20
|
-
table: {
|
|
21
|
-
disable: true,
|
|
22
|
-
},
|
|
23
|
-
},
|
|
24
|
-
},
|
|
25
20
|
render: (args) => ({
|
|
26
21
|
components: { MOverlay },
|
|
27
22
|
setup() {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from '@storybook/vue3';
|
|
2
|
-
import { action } from '
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
2
|
+
import { action } from 'storybook/actions';
|
|
3
3
|
|
|
4
4
|
import MPagination from './MPagination.vue';
|
|
5
5
|
|
|
6
6
|
const meta: Meta<typeof MPagination> = {
|
|
7
|
-
title: '
|
|
7
|
+
title: 'Navigation/Pagination',
|
|
8
8
|
component: MPagination,
|
|
9
9
|
parameters: {
|
|
10
10
|
docs: {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
</div>
|
|
32
32
|
|
|
33
33
|
<span v-if="compact" class="mc-pagination__label" aria-current="page">
|
|
34
|
-
{{ options.find(option => option.value === currentValue)?.text }}
|
|
34
|
+
{{ options.find((option) => option.value === currentValue)?.text }}
|
|
35
35
|
</span>
|
|
36
36
|
|
|
37
37
|
<MButton
|
|
@@ -108,11 +108,13 @@ watch(currentValue, (newVal) => {
|
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
const currentIndex = computed(() =>
|
|
111
|
-
props.options.findIndex(opt => opt.value === currentValue.value)
|
|
111
|
+
props.options.findIndex((opt) => opt.value === currentValue.value),
|
|
112
112
|
);
|
|
113
113
|
|
|
114
114
|
const isFirstPage = computed(() => currentIndex.value === 0);
|
|
115
|
-
const isLastPage = computed(
|
|
115
|
+
const isLastPage = computed(
|
|
116
|
+
() => currentIndex.value === props.options.length - 1,
|
|
117
|
+
);
|
|
116
118
|
|
|
117
119
|
const previous = () => {
|
|
118
120
|
const currentIndex = props.options.findIndex(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from '@storybook/vue3';
|
|
2
|
-
import { action } from '
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
2
|
+
import { action } from 'storybook/actions';
|
|
3
3
|
|
|
4
4
|
import MPasswordInput from './MPasswordInput.vue';
|
|
5
5
|
|
|
@@ -16,10 +16,7 @@
|
|
|
16
16
|
"
|
|
17
17
|
/>
|
|
18
18
|
<div v-if="isClearable && modelValue" class="mc-controls-options">
|
|
19
|
-
<button
|
|
20
|
-
class="mc-controls-options__button"
|
|
21
|
-
@click="clearValue"
|
|
22
|
-
>
|
|
19
|
+
<button class="mc-controls-options__button" @click="clearValue">
|
|
23
20
|
<CrossCircleFilled24
|
|
24
21
|
class="mc-controls-options__icon"
|
|
25
22
|
aria-hidden="true"
|
|
@@ -33,7 +30,7 @@
|
|
|
33
30
|
:aria-checked="ariaChecked"
|
|
34
31
|
:disabled="disabled"
|
|
35
32
|
@click="toggleVisibility"
|
|
36
|
-
size="s"
|
|
33
|
+
size="s"
|
|
37
34
|
ghost
|
|
38
35
|
>
|
|
39
36
|
{{ isVisible ? buttonLabel.hide : buttonLabel.show }}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import MPincode from './MPincode.vue';
|
|
4
|
+
|
|
5
|
+
describe('MPincode component', () => {
|
|
6
|
+
if (typeof ClipboardEvent === 'undefined') {
|
|
7
|
+
global.ClipboardEvent = class ClipboardEvent extends Event {
|
|
8
|
+
clipboardData: DataTransfer;
|
|
9
|
+
constructor(type: string, eventInitDict?: ClipboardEventInit) {
|
|
10
|
+
super(type, eventInitDict);
|
|
11
|
+
this.clipboardData =
|
|
12
|
+
(eventInitDict && eventInitDict.clipboardData) || new DataTransfer();
|
|
13
|
+
}
|
|
14
|
+
// eslint-disable-next-line
|
|
15
|
+
} as any;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
it('renders correct number of input fields based on length', () => {
|
|
19
|
+
const wrapper = mount(MPincode, {
|
|
20
|
+
props: {
|
|
21
|
+
id: 'otp',
|
|
22
|
+
length: 6,
|
|
23
|
+
modelValue: '',
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const inputs = wrapper.findAll('input');
|
|
28
|
+
expect(inputs).toHaveLength(6);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('updates modelValue as user types', async () => {
|
|
32
|
+
const wrapper = mount(MPincode, {
|
|
33
|
+
props: {
|
|
34
|
+
id: 'otp',
|
|
35
|
+
length: 4,
|
|
36
|
+
modelValue: '',
|
|
37
|
+
'onUpdate:modelValue': vi.fn(),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const inputs = wrapper.findAll('input');
|
|
42
|
+
await inputs[0].setValue('1');
|
|
43
|
+
await inputs[1].setValue('2');
|
|
44
|
+
await inputs[2].setValue('3');
|
|
45
|
+
await inputs[3].setValue('4');
|
|
46
|
+
|
|
47
|
+
expect(wrapper.emitted('update:modelValue')).toBeTruthy();
|
|
48
|
+
const emitted = wrapper.emitted('update:modelValue');
|
|
49
|
+
const lastEmitted = emitted?.[emitted.length - 1][0];
|
|
50
|
+
expect(lastEmitted).toBe('1234');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('moves focus to next input on input', async () => {
|
|
54
|
+
const wrapper = mount(MPincode, {
|
|
55
|
+
attachTo: document.body, // Needed for focus
|
|
56
|
+
props: {
|
|
57
|
+
id: 'otp',
|
|
58
|
+
length: 4,
|
|
59
|
+
modelValue: '',
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const inputs = wrapper.findAll('input');
|
|
64
|
+
await inputs[0].setValue('5');
|
|
65
|
+
expect(document.activeElement).toBe(inputs[1].element);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('moves focus to previous input on backspace if current is empty', async () => {
|
|
69
|
+
const wrapper = mount(MPincode, {
|
|
70
|
+
attachTo: document.body,
|
|
71
|
+
props: {
|
|
72
|
+
id: 'otp',
|
|
73
|
+
length: 4,
|
|
74
|
+
modelValue: '',
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const inputs = wrapper.findAll('input');
|
|
79
|
+
await inputs[0].setValue('5');
|
|
80
|
+
await inputs[1].element.focus();
|
|
81
|
+
const event = new KeyboardEvent('keydown', { key: 'Backspace' });
|
|
82
|
+
inputs[1].element.dispatchEvent(event);
|
|
83
|
+
await wrapper.vm.$nextTick();
|
|
84
|
+
|
|
85
|
+
expect(document.activeElement).toBe(inputs[0].element);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('renders invalid class when isInvalid is true', () => {
|
|
89
|
+
const wrapper = mount(MPincode, {
|
|
90
|
+
props: {
|
|
91
|
+
id: 'otp',
|
|
92
|
+
isInvalid: true,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(wrapper.classes()).toContain('is-invalid');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('disables inputs when disabled is true', () => {
|
|
100
|
+
const wrapper = mount(MPincode, {
|
|
101
|
+
props: {
|
|
102
|
+
id: 'otp',
|
|
103
|
+
disabled: true,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const inputs = wrapper.findAll('input');
|
|
108
|
+
for (const input of inputs) {
|
|
109
|
+
expect(input.attributes('disabled')).toBeDefined();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('makes inputs readonly when readonly is true', () => {
|
|
114
|
+
const wrapper = mount(MPincode, {
|
|
115
|
+
props: {
|
|
116
|
+
id: 'otp',
|
|
117
|
+
readonly: true,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const inputs = wrapper.findAll('input');
|
|
122
|
+
for (const input of inputs) {
|
|
123
|
+
expect(input.attributes('readonly')).toBeDefined();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
2
|
+
import { action } from 'storybook/actions';
|
|
3
|
+
|
|
4
|
+
import MPincode from './MPincode.vue';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof MPincode> = {
|
|
7
|
+
title: 'Form Elements/Pincode',
|
|
8
|
+
component: MPincode,
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'A pincode input is a specialized input field used to enter short numeric codes, such as verification codes, security PINs, or authentication tokens. It typically separates each digit into individual fields to improve readability and ease of entry. This component is commonly used in two-factor authentication (2FA), password recovery, and secure access flows, ensuring a structured and user-friendly experience.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
id: 'pincodeId',
|
|
19
|
+
ariaLabel: 'enter your code',
|
|
20
|
+
},
|
|
21
|
+
render: (args) => ({
|
|
22
|
+
components: { MPincode },
|
|
23
|
+
setup() {
|
|
24
|
+
const handleUpdate = action('update:modelValue');
|
|
25
|
+
|
|
26
|
+
return { args, handleUpdate };
|
|
27
|
+
},
|
|
28
|
+
template: `
|
|
29
|
+
<MPincode
|
|
30
|
+
v-bind="args"
|
|
31
|
+
@update:modelValue="handleUpdate"
|
|
32
|
+
/>
|
|
33
|
+
`,
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
export default meta;
|
|
37
|
+
type Story = StoryObj<typeof MPincode>;
|
|
38
|
+
|
|
39
|
+
export const WithValue: Story = {
|
|
40
|
+
args: {
|
|
41
|
+
id: 'valueId',
|
|
42
|
+
modelValue: '123098',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const Default: Story = {};
|
|
47
|
+
|
|
48
|
+
export const Disabled: Story = {
|
|
49
|
+
args: {
|
|
50
|
+
id: 'disableId',
|
|
51
|
+
disabled: true,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const ReadOnly: Story = {
|
|
56
|
+
args: {
|
|
57
|
+
id: 'readonlyId',
|
|
58
|
+
modelValue: '123098',
|
|
59
|
+
readonly: true,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const Invalid: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
id: 'invalidId',
|
|
66
|
+
isInvalid: true,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="mc-pincode-input" :class="classObject" @paste="onPaste">
|
|
3
|
+
<input
|
|
4
|
+
v-for="(digit, index) in otp"
|
|
5
|
+
:key="index"
|
|
6
|
+
:id="`pincodeItem${index}`"
|
|
7
|
+
:ref="(el) => setInputRef(el, index)"
|
|
8
|
+
type="text"
|
|
9
|
+
inputmode="numeric"
|
|
10
|
+
maxlength="1"
|
|
11
|
+
pattern="\d*"
|
|
12
|
+
autocomplete="one-time-code"
|
|
13
|
+
:name="name || `pincode-${id}`"
|
|
14
|
+
class="mc-pincode-input__control"
|
|
15
|
+
:disabled="disabled"
|
|
16
|
+
:readonly="readonly"
|
|
17
|
+
:value="digit"
|
|
18
|
+
v-bind="$attrs"
|
|
19
|
+
@input="(e) => onInput(e, index)"
|
|
20
|
+
@keydown.backspace="(e) => onBackspace(e, index)"
|
|
21
|
+
@keydown="(e) => onKeyDown(e, index)"
|
|
22
|
+
/>
|
|
23
|
+
</div>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<script setup lang="ts">
|
|
27
|
+
import { ref, computed, watch, nextTick, type ComponentPublicInstance } from 'vue';
|
|
28
|
+
/**
|
|
29
|
+
* A pincode input is a specialized input field used to enter short numeric codes, such as verification codes, security PINs, or authentication tokens. It typically separates each digit into individual fields to improve readability and ease of entry. This component is commonly used in two-factor authentication (2FA), password recovery, and secure access flows, ensuring a structured and user-friendly experience.
|
|
30
|
+
*/
|
|
31
|
+
const props = withDefaults(
|
|
32
|
+
defineProps<{
|
|
33
|
+
/**
|
|
34
|
+
* A unique identifier for the pincode element, used to associate the label with the form element.
|
|
35
|
+
*/
|
|
36
|
+
id: string;
|
|
37
|
+
/**
|
|
38
|
+
* The number of input displayed in the pincode element.
|
|
39
|
+
*/
|
|
40
|
+
length?: 4 | 5 | 6;
|
|
41
|
+
/**
|
|
42
|
+
* The name attribute for the pincode element, typically used for form submission.
|
|
43
|
+
*/
|
|
44
|
+
name?: string;
|
|
45
|
+
/**
|
|
46
|
+
* The current value of the pincode field.
|
|
47
|
+
*/
|
|
48
|
+
modelValue?: string | number;
|
|
49
|
+
/**
|
|
50
|
+
* If `true`, applies an invalid state to the pincode.
|
|
51
|
+
*/
|
|
52
|
+
isInvalid?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* If `true`, disables the pincode, making it non-interactive.
|
|
55
|
+
*/
|
|
56
|
+
disabled?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* If `true`, the pincode is read-only (cannot be edited).
|
|
59
|
+
*/
|
|
60
|
+
readonly?: boolean;
|
|
61
|
+
}>(),
|
|
62
|
+
{
|
|
63
|
+
length: 6,
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const classObject = computed(() => {
|
|
68
|
+
return {
|
|
69
|
+
'is-invalid': props.isInvalid,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const emit = defineEmits<{
|
|
74
|
+
(on: 'update:modelValue', value: string): void;
|
|
75
|
+
}>();
|
|
76
|
+
|
|
77
|
+
const otp = ref<string[]>(Array(props.length).fill(''));
|
|
78
|
+
const inputRefs = ref<(HTMLInputElement | null)[]>([]);
|
|
79
|
+
|
|
80
|
+
const setInputRef = (el: Element | ComponentPublicInstance | null, index: number) => {
|
|
81
|
+
inputRefs.value[index] = el as HTMLInputElement | null;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
watch(
|
|
85
|
+
() => props.modelValue,
|
|
86
|
+
(value) => {
|
|
87
|
+
const str = String(value ?? '');
|
|
88
|
+
otp.value = Array.from({ length: props.length }, (_, i) => str[i] ?? '');
|
|
89
|
+
},
|
|
90
|
+
{ immediate: true },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const focusInput = (index: number) => {
|
|
94
|
+
nextTick(() => inputRefs.value[index]?.focus());
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const onInput = (e: Event, index: number) => {
|
|
98
|
+
const val = (e.target as HTMLInputElement).value.replace(/\D/g, '');
|
|
99
|
+
if (val) {
|
|
100
|
+
otp.value[index] = val[0];
|
|
101
|
+
emit('update:modelValue', otp.value.join(''));
|
|
102
|
+
if (index + 1 < props.length) focusInput(index + 1);
|
|
103
|
+
} else {
|
|
104
|
+
otp.value[index] = '';
|
|
105
|
+
emit('update:modelValue', otp.value.join(''));
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const onKeyDown = (e: KeyboardEvent, index: number) => {
|
|
110
|
+
if (e.key === 'ArrowLeft' && index > 0) {
|
|
111
|
+
focusInput(index - 1);
|
|
112
|
+
} else if (e.key === 'ArrowRight' && index < props.length - 1) {
|
|
113
|
+
focusInput(index + 1);
|
|
114
|
+
} else if (e.key === 'Backspace') {
|
|
115
|
+
onBackspace(e, index);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const onBackspace = (e: KeyboardEvent, index: number) => {
|
|
120
|
+
if (otp.value[index] === '' && index > 0) {
|
|
121
|
+
otp.value[index - 1] = '';
|
|
122
|
+
emit('update:modelValue', otp.value.join(''));
|
|
123
|
+
focusInput(index - 1);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const onPaste = (e: ClipboardEvent) => {
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
const pasted = e.clipboardData?.getData('text') ?? '';
|
|
130
|
+
const digits = pasted.replace(/\D/g, '').slice(0, props.length).split('');
|
|
131
|
+
otp.value = Array.from({ length: props.length }, (_, i) => digits[i] ?? '');
|
|
132
|
+
emit('update:modelValue', otp.value.join(''));
|
|
133
|
+
focusInput(Math.min(digits.length, props.length - 1));
|
|
134
|
+
};
|
|
135
|
+
</script>
|
|
136
|
+
|
|
137
|
+
<style lang="scss" scoped>
|
|
138
|
+
@use '@mozaic-ds/styles/components/pincode-input';
|
|
139
|
+
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from '@storybook/vue3';
|
|
2
|
-
import { action } from '
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue3-vite';
|
|
2
|
+
import { action } from 'storybook/actions';
|
|
3
3
|
|
|
4
4
|
import MQuantitySelector from './MQuantitySelector.vue';
|
|
5
5
|
|