@bethinkpl/design-system 43.0.0 → 45.0.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/design-system.css +1 -1
- package/dist/design-system.js +13826 -13252
- package/dist/design-system.js.map +1 -1
- package/dist/lib/js/components/Buttons/IconButton/IconButton.vue.d.ts +1 -1
- package/dist/lib/js/components/Callout/Callout.consts.d.ts +32 -0
- package/dist/lib/js/components/{ActionContent/ActionContent.vue.d.ts → Callout/Callout.vue.d.ts} +17 -6
- package/dist/lib/js/components/Callout/index.d.ts +4 -0
- package/dist/lib/js/components/DatePickers/DateBox/DateBox.vue.d.ts +1 -1
- package/dist/lib/js/components/DatePickers/DatePicker/DatePicker.vue.d.ts +5 -5
- package/dist/lib/js/components/DatePickers/DateRangePicker/DateRangePicker.vue.d.ts +2 -2
- package/dist/lib/js/components/Divider/Divider.vue.d.ts +1 -1
- package/dist/lib/js/components/Drawer/DrawerDivider/DrawerDivider.vue.d.ts +1 -1
- package/dist/lib/js/components/Drawer/DrawerSection/DrawerSection.vue.d.ts +4 -4
- package/dist/lib/js/components/Drawer/DrawerTile/DrawerTile.vue.d.ts +4 -4
- package/dist/lib/js/components/Dropdown/Dropdown.vue.d.ts +1 -1
- package/dist/lib/js/components/Form/FormField/FormField.utils.d.ts +1 -1
- package/dist/lib/js/components/Form/InputField/InputField.vue.d.ts +2 -2
- package/dist/lib/js/components/Form/PasswordField/PasswordField.vue.d.ts +2 -2
- package/dist/lib/js/components/Form/PinInputField/PinInputField.consts.d.ts +14 -0
- package/dist/lib/js/components/Form/PinInputField/PinInputField.types.d.ts +12 -0
- package/dist/lib/js/components/Form/PinInputField/PinInputField.utils.d.ts +2 -0
- package/dist/lib/js/components/Form/PinInputField/PinInputField.vue.d.ts +91 -0
- package/dist/lib/js/components/Form/PinInputField/index.d.ts +5 -0
- package/dist/lib/js/components/Form/PinInputField/usePinInputFieldWithinForm.d.ts +8 -0
- package/dist/lib/js/components/Form/SelectField/SelectField.vue.d.ts +2 -2
- package/dist/lib/js/components/Form/TextAreaField/TextAreaField.types.d.ts +9 -0
- package/dist/lib/js/components/Form/TextAreaField/TextAreaField.vue.d.ts +76 -0
- package/dist/lib/js/components/Form/TextAreaField/TextAreaFieldAutosized.vue.d.ts +6 -0
- package/dist/lib/js/components/Form/TextAreaField/index.d.ts +4 -0
- package/dist/lib/js/components/Headers/OverlayHeader/OverlayHeader.vue.d.ts +5 -5
- package/dist/lib/js/components/Headers/PageHeader/PageHeader.vue.d.ts +1 -1
- package/dist/lib/js/components/Headers/SectionHeader/SectionHeader.vue.d.ts +3 -3
- package/dist/lib/js/components/IconText/IconText.vue.d.ts +1 -1
- package/dist/lib/js/components/Image/Image.vue.d.ts +1 -1
- package/dist/lib/js/components/LabelValue/LabelValueItem/LabelValueItem.vue.d.ts +12 -1
- package/dist/lib/js/components/Menu/MenuDivider/MenuDivider.vue.d.ts +1 -1
- package/dist/lib/js/components/Modals/Modal/Modal.vue.d.ts +2 -2
- package/dist/lib/js/components/Modals/ModalDialog/ModalDialog.vue.d.ts +2 -2
- package/dist/lib/js/components/Pagination/Pagination.vue.d.ts +2 -2
- package/dist/lib/js/components/RichList/BasicRichListItem/BasicRichListItem.vue.d.ts +7 -7
- package/dist/lib/js/components/RichList/RichListItem/RichListItem.vue.d.ts +3 -3
- package/dist/lib/js/components/SelectList/SelectListItemDivider/SelectListItemDivider.vue.d.ts +1 -1
- package/dist/lib/js/components/Skeleton/Skeleton.vue.d.ts +1 -1
- package/dist/lib/js/components/SurveyQuestions/SurveyQuestionOpenEnded/SurveyQuestionOpenEnded.vue.d.ts +3 -3
- package/dist/lib/js/components/SurveyQuestions/SurveyQuestionScale/SurveyQuestionScale.vue.d.ts +3 -3
- package/dist/lib/js/components/Tile/Tile.sb.shared.d.ts +2 -2
- package/dist/lib/js/components/Tile/Tile.vue.d.ts +2 -2
- package/dist/lib/js/components/Toast/Toast.consts.d.ts +11 -9
- package/dist/lib/js/components/Toast/Toast.vue.d.ts +51 -433
- package/dist/lib/js/components/Toggles/ToggleButton/ToggleButton.vue.d.ts +1 -1
- package/dist/lib/js/{components/Form/InputField/useInputFieldWithinForm.d.ts → composables/useTextFieldWithinForm.d.ts} +1 -1
- package/dist/lib/js/index.d.ts +6 -1
- package/lib/js/components/Callout/Callout.consts.ts +25 -0
- package/lib/js/components/Callout/Callout.spec.ts +257 -0
- package/lib/js/components/Callout/Callout.stories.ts +209 -0
- package/lib/js/components/Callout/Callout.vue +161 -0
- package/lib/js/components/Callout/index.ts +4 -0
- package/lib/js/components/Cards/Card/Card.vue +1 -1
- package/lib/js/components/Form/CheckboxGroupField/CheckboxGroupField.spec.ts +15 -0
- package/lib/js/components/Form/CheckboxGroupField/CheckboxGroupField.vue +5 -2
- package/lib/js/components/Form/Form.stories.ts +23 -2
- package/lib/js/components/Form/InputField/InputField.spec.ts +11 -1
- package/lib/js/components/Form/InputField/InputField.vue +7 -4
- package/lib/js/components/Form/PasswordField/PasswordField.vue +1 -1
- package/lib/js/components/Form/PinInputField/PinInputField.consts.ts +18 -0
- package/lib/js/components/Form/PinInputField/PinInputField.spec.ts +394 -0
- package/lib/js/components/Form/PinInputField/PinInputField.stories.ts +124 -0
- package/lib/js/components/Form/PinInputField/PinInputField.types.ts +42 -0
- package/lib/js/components/Form/PinInputField/PinInputField.utils.ts +5 -0
- package/lib/js/components/Form/PinInputField/PinInputField.vue +211 -0
- package/lib/js/components/Form/PinInputField/index.ts +6 -0
- package/lib/js/components/Form/PinInputField/usePinInputFieldWithinForm.ts +29 -0
- package/lib/js/components/Form/SelectField/SelectField.stories.ts +0 -5
- package/lib/js/components/Form/SelectField/useSelectFieldWithinForm.ts +1 -1
- package/lib/js/components/Form/TextAreaField/TextAreaField.spec.ts +341 -0
- package/lib/js/components/Form/TextAreaField/TextAreaField.stories.ts +99 -0
- package/lib/js/components/Form/TextAreaField/TextAreaField.types.ts +23 -0
- package/lib/js/components/Form/TextAreaField/TextAreaField.vue +190 -0
- package/lib/js/components/Form/TextAreaField/TextAreaFieldAutosized.vue +19 -0
- package/lib/js/components/Form/TextAreaField/index.ts +5 -0
- package/lib/js/components/LabelValue/LabelValueItem/LabelValueItem.spec.ts +26 -0
- package/lib/js/components/LabelValue/LabelValueItem/LabelValueItem.stories.ts +16 -3
- package/lib/js/components/LabelValue/LabelValueItem/LabelValueItem.vue +5 -0
- package/lib/js/components/LoadingBar/LoadingBar.spec.ts +84 -0
- package/lib/js/components/LoadingBar/LoadingBar.vue +30 -19
- package/lib/js/components/Toast/Toast.consts.ts +4 -2
- package/lib/js/components/Toast/Toast.spec.ts +177 -0
- package/lib/js/components/Toast/Toast.stories.ts +65 -31
- package/lib/js/components/Toast/Toast.vue +176 -195
- package/lib/js/{components/Form/InputField/useInputFieldWithinForm.ts → composables/useTextFieldWithinForm.ts} +7 -2
- package/lib/js/index.ts +6 -1
- package/package.json +1 -1
- package/dist/lib/js/components/ActionContent/index.d.ts +0 -3
- package/lib/js/components/ActionContent/ActionContent.spec.ts +0 -99
- package/lib/js/components/ActionContent/ActionContent.stories.ts +0 -141
- package/lib/js/components/ActionContent/ActionContent.vue +0 -104
- package/lib/js/components/ActionContent/index.ts +0 -3
|
@@ -7,6 +7,8 @@ import { toTypedSchema } from '@vee-validate/zod';
|
|
|
7
7
|
import { array, object, string } from 'zod';
|
|
8
8
|
import CheckboxGroupField from './CheckboxGroupField/CheckboxGroupField.vue';
|
|
9
9
|
import SelectField from './SelectField';
|
|
10
|
+
import TextAreaField from './TextAreaField';
|
|
11
|
+
import PinInputField from './PinInputField';
|
|
10
12
|
import Button from '../Buttons/Button';
|
|
11
13
|
|
|
12
14
|
export default {
|
|
@@ -15,13 +17,23 @@ export default {
|
|
|
15
17
|
} as Meta<any>;
|
|
16
18
|
|
|
17
19
|
export const SimpleForm = () => ({
|
|
18
|
-
components: {
|
|
20
|
+
components: {
|
|
21
|
+
InputField,
|
|
22
|
+
CheckboxGroupField,
|
|
23
|
+
Checkbox,
|
|
24
|
+
SelectField,
|
|
25
|
+
TextAreaField,
|
|
26
|
+
PinInputField,
|
|
27
|
+
Button,
|
|
28
|
+
},
|
|
19
29
|
setup: () => {
|
|
20
30
|
const { handleSubmit } = useForm({
|
|
21
31
|
initialValues: {
|
|
22
32
|
fullName: '',
|
|
23
33
|
country: '',
|
|
24
34
|
newsletterTopics: [],
|
|
35
|
+
notes: '',
|
|
36
|
+
code: '',
|
|
25
37
|
},
|
|
26
38
|
validationSchema: toTypedSchema(
|
|
27
39
|
object({
|
|
@@ -31,6 +43,8 @@ export const SimpleForm = () => ({
|
|
|
31
43
|
1,
|
|
32
44
|
'Wybierz przynajmniej jeden temat newslettera',
|
|
33
45
|
),
|
|
46
|
+
notes: string().max(500, 'Maksymalnie 500 znaków'),
|
|
47
|
+
code: string().length(6, 'Kod ma 6 cyfr'),
|
|
34
48
|
}),
|
|
35
49
|
),
|
|
36
50
|
});
|
|
@@ -54,7 +68,7 @@ export const SimpleForm = () => ({
|
|
|
54
68
|
};
|
|
55
69
|
},
|
|
56
70
|
template: `
|
|
57
|
-
<form @submit.prevent="onSubmit" style="display: flex; flex-direction: column; gap: 16px; max-width: 400px;">
|
|
71
|
+
<form novalidate @submit.prevent="onSubmit" style="display: flex; flex-direction: column; gap: 16px; max-width: 400px;">
|
|
58
72
|
<InputField label="Imię i nazwisko" name="fullName" />
|
|
59
73
|
<SelectField
|
|
60
74
|
label="Kraj"
|
|
@@ -70,6 +84,13 @@ export const SimpleForm = () => ({
|
|
|
70
84
|
<Checkbox value="sports">Sport</Checkbox>
|
|
71
85
|
</template>
|
|
72
86
|
</CheckboxGroupField>
|
|
87
|
+
<TextAreaField
|
|
88
|
+
label="Uwagi"
|
|
89
|
+
name="notes"
|
|
90
|
+
is-autoresizing
|
|
91
|
+
:input-props="{ rows: 4, maxlength: 500, placeholder: 'Coś jeszcze?' }"
|
|
92
|
+
/>
|
|
93
|
+
<PinInputField label="Kod z e-maila" name="code" />
|
|
73
94
|
<Button as="button">
|
|
74
95
|
Wyślij
|
|
75
96
|
</Button>
|
|
@@ -87,6 +87,16 @@ describe('InputField', () => {
|
|
|
87
87
|
expect(wrapper.find(`#${messageId}`).exists()).toBe(true);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
it('should set aria-describedby only when a message is rendered', () => {
|
|
91
|
+
const wrapper = setup({
|
|
92
|
+
props: {
|
|
93
|
+
label: 'Label',
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(wrapper.find('input').attributes('aria-describedby')).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
90
100
|
it('should pass inputProps to the input element', () => {
|
|
91
101
|
const inputProps = {
|
|
92
102
|
placeholder: 'Enter text',
|
|
@@ -252,7 +262,7 @@ describe('InputField', () => {
|
|
|
252
262
|
}).toThrowError();
|
|
253
263
|
});
|
|
254
264
|
|
|
255
|
-
it('
|
|
265
|
+
it('should keep an explicit state and message over a vee-validate error', async () => {
|
|
256
266
|
const { wrapper, errorsRef } = setupWithForm({
|
|
257
267
|
state: FORM_FIELD_STATES.SUCCESS,
|
|
258
268
|
messageText: 'Success message',
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
:id="fieldId"
|
|
22
22
|
v-model="value"
|
|
23
23
|
class="ds-inputField__input"
|
|
24
|
-
:aria-describedby="messageId"
|
|
24
|
+
:aria-describedby="hasMessage ? messageId : undefined"
|
|
25
25
|
/>
|
|
26
26
|
<div v-if="suffixText" class="ds-inputField__suffixText">
|
|
27
27
|
{{ suffixText }}
|
|
@@ -141,10 +141,10 @@ import FormField, { FORM_FIELD_STATES, FormFieldProps } from '../FormField';
|
|
|
141
141
|
import Icon, { ICON_SIZES } from '../../Icons/Icon';
|
|
142
142
|
import { extractFormFieldProps } from '../FormField/FormField.utils';
|
|
143
143
|
import { InputFieldProps, InputFieldSlots } from './InputField.types';
|
|
144
|
-
import {
|
|
144
|
+
import { useTextFieldWithinForm } from '../../../composables/useTextFieldWithinForm';
|
|
145
145
|
|
|
146
146
|
const { inputProps, leftIcon, suffixText, name, ...rest } = defineProps<InputFieldProps>();
|
|
147
|
-
defineSlots<InputFieldSlots>();
|
|
147
|
+
const slots = defineSlots<InputFieldSlots>();
|
|
148
148
|
const modelValue = defineModel<string>();
|
|
149
149
|
|
|
150
150
|
const {
|
|
@@ -152,13 +152,16 @@ const {
|
|
|
152
152
|
errors,
|
|
153
153
|
onInput: onFormFieldInput,
|
|
154
154
|
onBlur: onFormFieldBlur,
|
|
155
|
-
} =
|
|
155
|
+
} = useTextFieldWithinForm(() => name, modelValue);
|
|
156
156
|
|
|
157
157
|
const formFieldProps = computed<FormFieldProps>(() => {
|
|
158
158
|
// this is needed to avoid passing modelValue to FormField as prop
|
|
159
159
|
return extractFormFieldProps(rest, errors.value);
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
+
// Avoids pointing `aria-describedby` at a message element that is never rendered.
|
|
163
|
+
const hasMessage = computed(() => !!(formFieldProps.value.messageText || slots.message));
|
|
164
|
+
|
|
162
165
|
const finalInputProps = computed<InputHTMLAttributes>(() => {
|
|
163
166
|
return {
|
|
164
167
|
disabled: formFieldProps.value.state === FORM_FIELD_STATES.DISABLED,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Value } from '../../../utils/type.utils';
|
|
2
|
+
|
|
3
|
+
export const PIN_INPUT_FIELD_STATES = {
|
|
4
|
+
DEFAULT: 'default',
|
|
5
|
+
ERROR: 'error',
|
|
6
|
+
LOADING: 'loading',
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
export type PinInputFieldState = Value<typeof PIN_INPUT_FIELD_STATES>;
|
|
10
|
+
|
|
11
|
+
export const PIN_INPUT_FIELD_TYPES = {
|
|
12
|
+
TEXT: 'text',
|
|
13
|
+
NUMBER: 'number',
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export type PinInputFieldType = Value<typeof PIN_INPUT_FIELD_TYPES>;
|
|
17
|
+
|
|
18
|
+
export const PIN_INPUT_FIELD_DEFAULT_LENGTH = 6;
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import { ComputedRef, Ref } from 'vue';
|
|
4
|
+
import { FormMeta, useForm } from 'vee-validate';
|
|
5
|
+
import { ComponentProps } from 'vue-component-type-helpers';
|
|
6
|
+
import PinInputField from './PinInputField.vue';
|
|
7
|
+
import Icon, { ICONS } from '../../Icons/Icon';
|
|
8
|
+
import { PIN_INPUT_FIELD_STATES, PIN_INPUT_FIELD_TYPES } from './PinInputField.consts';
|
|
9
|
+
import { waitForExpectShort } from '../../../tests/helpers';
|
|
10
|
+
|
|
11
|
+
const fieldId = 'form-field-v-0';
|
|
12
|
+
const labelId = `${fieldId}-label`;
|
|
13
|
+
const messageId = `${fieldId}-message`;
|
|
14
|
+
|
|
15
|
+
const BOX = 'input.ds-pinInputField__item';
|
|
16
|
+
|
|
17
|
+
function setup({ props }: { props?: ComponentProps<typeof PinInputField> } = {}) {
|
|
18
|
+
return mount(PinInputField, { props });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function boxes(wrapper: ReturnType<typeof setup>) {
|
|
22
|
+
return wrapper.findAll<HTMLInputElement>(BOX);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function boxValues(wrapper: ReturnType<typeof setup>) {
|
|
26
|
+
return boxes(wrapper).map((box) => box.element.value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function type(wrapper: ReturnType<typeof setup>, pin: string) {
|
|
30
|
+
const inputs = boxes(wrapper);
|
|
31
|
+
|
|
32
|
+
for (const [index, character] of [...pin].entries()) {
|
|
33
|
+
await inputs[index].setValue(character);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('PinInputField', () => {
|
|
38
|
+
it('should render', () => {
|
|
39
|
+
const wrapper = setup({ props: { label: 'Label' } });
|
|
40
|
+
|
|
41
|
+
expect(wrapper.exists()).toBe(true);
|
|
42
|
+
expect(wrapper.find('.ds-pinInputField').exists()).toBe(true);
|
|
43
|
+
expect(wrapper.find('label').text()).toContain('Label');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should render 6 boxes by default', () => {
|
|
47
|
+
expect(boxes(setup())).toHaveLength(6);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it.each([1, 4, 8])('should render %s boxes', (length) => {
|
|
51
|
+
expect(boxes(setup({ props: { length } }))).toHaveLength(length);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should fill the boxes from the model value', () => {
|
|
55
|
+
const wrapper = setup({ props: { modelValue: '15' } });
|
|
56
|
+
|
|
57
|
+
expect(boxValues(wrapper)).toEqual(['1', '5', '', '', '', '']);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should ignore model value characters beyond the length', () => {
|
|
61
|
+
const wrapper = setup({ props: { modelValue: '123456789', length: 4 } });
|
|
62
|
+
|
|
63
|
+
expect(boxValues(wrapper)).toEqual(['1', '2', '3', '4']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// A shrink leaves the joined value matching the model, so only the surplus entries reveal it.
|
|
67
|
+
// Keeping them would report characters that no longer have a box to edit them in.
|
|
68
|
+
it('should drop the surplus characters when the length shrinks', async () => {
|
|
69
|
+
const onUpdate = vi.fn();
|
|
70
|
+
const wrapper = setup({ props: { modelValue: '123456', 'onUpdate:modelValue': onUpdate } });
|
|
71
|
+
|
|
72
|
+
await wrapper.setProps({ length: 4 });
|
|
73
|
+
|
|
74
|
+
expect(boxValues(wrapper)).toEqual(['1', '2', '3', '4']);
|
|
75
|
+
expect(onUpdate).toHaveBeenLastCalledWith('1234');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should update the model value as the boxes are filled', async () => {
|
|
79
|
+
const onUpdate = vi.fn();
|
|
80
|
+
const wrapper = setup({ props: { 'onUpdate:modelValue': onUpdate } });
|
|
81
|
+
|
|
82
|
+
await type(wrapper, '123');
|
|
83
|
+
|
|
84
|
+
expect(onUpdate).toHaveBeenLastCalledWith('123');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Guards the array-as-source-of-truth mapping: deriving the boxes from the joined string would
|
|
88
|
+
// shift the later ones leftwards here.
|
|
89
|
+
it('should not shift the remaining boxes when a middle box is cleared', async () => {
|
|
90
|
+
const onUpdate = vi.fn();
|
|
91
|
+
const wrapper = setup({
|
|
92
|
+
props: { modelValue: '123456', 'onUpdate:modelValue': onUpdate },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await boxes(wrapper)[2].trigger('keydown', { key: 'Backspace' });
|
|
96
|
+
|
|
97
|
+
expect(boxValues(wrapper)).toEqual(['1', '2', '', '4', '5', '6']);
|
|
98
|
+
expect(onUpdate).toHaveBeenLastCalledWith('12456');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should distribute a pasted value across the boxes', async () => {
|
|
102
|
+
const wrapper = setup();
|
|
103
|
+
|
|
104
|
+
await boxes(wrapper)[0].trigger('paste', {
|
|
105
|
+
clipboardData: { getData: () => '123456' },
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(boxValues(wrapper)).toEqual(['1', '2', '3', '4', '5', '6']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should emit complete with the joined value once every box is filled', async () => {
|
|
112
|
+
const wrapper = setup();
|
|
113
|
+
|
|
114
|
+
await type(wrapper, '12345');
|
|
115
|
+
expect(wrapper.emitted('complete')).toBeUndefined();
|
|
116
|
+
|
|
117
|
+
await type(wrapper, '123456');
|
|
118
|
+
expect(wrapper.emitted('complete')).toEqual([['123456']]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should render with accessible ids', () => {
|
|
122
|
+
const wrapper = setup({ props: { label: 'Label', messageText: 'Message text' } });
|
|
123
|
+
const group = wrapper.find('[role="group"]');
|
|
124
|
+
|
|
125
|
+
expect(wrapper.find('label').attributes('for')).toBe(fieldId);
|
|
126
|
+
expect(wrapper.find(`input#${fieldId}`).exists()).toBe(true);
|
|
127
|
+
expect(group.attributes('aria-labelledby')).toBe(labelId);
|
|
128
|
+
expect(group.attributes('aria-describedby')).toBe(messageId);
|
|
129
|
+
expect(wrapper.find(`#${messageId}`).exists()).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should set aria-describedby only when a message is rendered', () => {
|
|
133
|
+
const wrapper = setup({ props: { label: 'Label' } });
|
|
134
|
+
|
|
135
|
+
expect(wrapper.find('[role="group"]').attributes('aria-describedby')).toBeUndefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should fall back to ariaLabel when no label is provided', () => {
|
|
139
|
+
const withLabel = setup({ props: { label: 'Label', ariaLabel: 'Aria label' } });
|
|
140
|
+
const withoutLabel = setup({ props: { ariaLabel: 'Aria label' } });
|
|
141
|
+
|
|
142
|
+
expect(withLabel.find('[role="group"]').attributes('aria-label')).toBeUndefined();
|
|
143
|
+
expect(withoutLabel.find('[role="group"]').attributes('aria-label')).toBe('Aria label');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should mark the group as required', () => {
|
|
147
|
+
const wrapper = setup({ props: { label: 'Label', hasRequiredIndicator: true } });
|
|
148
|
+
|
|
149
|
+
expect(wrapper.find(`input#${fieldId}`).attributes('required')).toBeDefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('state', () => {
|
|
153
|
+
it('should render a spinner instead of the boxes when loading', () => {
|
|
154
|
+
const wrapper = setup({ props: { state: PIN_INPUT_FIELD_STATES.LOADING } });
|
|
155
|
+
const spinner = wrapper.findComponent<typeof Icon>('.ds-pinInputField__spinner');
|
|
156
|
+
|
|
157
|
+
expect(boxes(wrapper)).toHaveLength(0);
|
|
158
|
+
expect(wrapper.find('.ds-pinInputField').classes()).toContain('-ds-loading');
|
|
159
|
+
expect(spinner.props().icon).toEqual(ICONS.FAD_SPINNER_THIRD);
|
|
160
|
+
expect(spinner.props().spinning).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should keep the value while loading', async () => {
|
|
164
|
+
const wrapper = setup({ props: { modelValue: '15' } });
|
|
165
|
+
|
|
166
|
+
await wrapper.setProps({ state: PIN_INPUT_FIELD_STATES.LOADING });
|
|
167
|
+
await wrapper.setProps({ state: PIN_INPUT_FIELD_STATES.DEFAULT });
|
|
168
|
+
|
|
169
|
+
expect(boxValues(wrapper)).toEqual(['1', '5', '', '', '', '']);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('otp and type', () => {
|
|
174
|
+
it('should mark the boxes as one-time-code inputs by default', () => {
|
|
175
|
+
expect(boxes(setup())[0].attributes('autocomplete')).toBe('one-time-code');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should not mark the boxes as one-time-code inputs when otp is false', () => {
|
|
179
|
+
expect(boxes(setup({ props: { otp: false } }))[0].attributes('autocomplete')).not.toBe(
|
|
180
|
+
'one-time-code',
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should use a numeric input mode by default', () => {
|
|
185
|
+
const box = boxes(setup())[0];
|
|
186
|
+
|
|
187
|
+
expect(box.attributes('inputmode')).toBe('numeric');
|
|
188
|
+
expect(box.attributes('pattern')).toBe('[0-9]*');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should use a text input mode when type is text', () => {
|
|
192
|
+
expect(
|
|
193
|
+
boxes(setup({ props: { type: PIN_INPUT_FIELD_TYPES.TEXT } }))[0].attributes(
|
|
194
|
+
'inputmode',
|
|
195
|
+
),
|
|
196
|
+
).toBe('text');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// reka stores numbers in numeric mode, so the model has to survive the mixed array.
|
|
200
|
+
it('should round-trip a numeric pin through the string model', async () => {
|
|
201
|
+
const onUpdate = vi.fn();
|
|
202
|
+
const wrapper = setup({ props: { 'onUpdate:modelValue': onUpdate } });
|
|
203
|
+
|
|
204
|
+
await type(wrapper, '123456');
|
|
205
|
+
|
|
206
|
+
expect(onUpdate).toHaveBeenLastCalledWith('123456');
|
|
207
|
+
expect(boxValues(wrapper)).toEqual(['1', '2', '3', '4', '5', '6']);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should discard non-digits in numeric mode and keep them in text mode', async () => {
|
|
211
|
+
const numeric = setup();
|
|
212
|
+
await boxes(numeric)[0].setValue('a');
|
|
213
|
+
expect(boxValues(numeric)[0]).toBe('');
|
|
214
|
+
|
|
215
|
+
const text = setup({ props: { type: PIN_INPUT_FIELD_TYPES.TEXT } });
|
|
216
|
+
await boxes(text)[0].setValue('a');
|
|
217
|
+
expect(boxValues(text)[0]).toBe('a');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('with vee-validate', () => {
|
|
222
|
+
const fieldName = 'code';
|
|
223
|
+
const initialValue = '15';
|
|
224
|
+
const errorMessage = 'Kod jest nieprawidłowy';
|
|
225
|
+
|
|
226
|
+
function setupWithForm(props?: ComponentProps<typeof PinInputField>) {
|
|
227
|
+
let controlledValuesRef: Ref<{ code: string }> | undefined;
|
|
228
|
+
let errorsRef: ComputedRef<Partial<Record<'code', string | undefined>>> | undefined;
|
|
229
|
+
let metaRef: Ref<FormMeta<{ code: string }>> | undefined;
|
|
230
|
+
|
|
231
|
+
const FormComponent = {
|
|
232
|
+
template: `
|
|
233
|
+
<form novalidate @submit.prevent="onSubmit">
|
|
234
|
+
<PinInputField v-bind="props" :name="name" />
|
|
235
|
+
</form>
|
|
236
|
+
`,
|
|
237
|
+
components: { PinInputField },
|
|
238
|
+
setup() {
|
|
239
|
+
const { controlledValues, errors, meta, handleSubmit } = useForm({
|
|
240
|
+
initialValues: {
|
|
241
|
+
[fieldName]: initialValue,
|
|
242
|
+
},
|
|
243
|
+
validationSchema: {
|
|
244
|
+
code: (val: string) => (val === '999999' ? true : errorMessage),
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
controlledValuesRef = controlledValues;
|
|
249
|
+
errorsRef = errors;
|
|
250
|
+
metaRef = meta;
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
name: fieldName,
|
|
254
|
+
props,
|
|
255
|
+
onSubmit: handleSubmit(() => {}),
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
wrapper: mount(FormComponent),
|
|
262
|
+
controlledValuesRef,
|
|
263
|
+
errorsRef,
|
|
264
|
+
metaRef,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
it('should bind the boxes to the vee-validate form values', async () => {
|
|
269
|
+
const { wrapper, controlledValuesRef } = setupWithForm();
|
|
270
|
+
|
|
271
|
+
expect(wrapper.findAll<HTMLInputElement>(BOX).map((box) => box.element.value)).toEqual([
|
|
272
|
+
'1',
|
|
273
|
+
'5',
|
|
274
|
+
'',
|
|
275
|
+
'',
|
|
276
|
+
'',
|
|
277
|
+
'',
|
|
278
|
+
]);
|
|
279
|
+
expect(controlledValuesRef?.value[fieldName]).toBe(initialValue);
|
|
280
|
+
|
|
281
|
+
await wrapper.findAll(BOX)[2].setValue('7');
|
|
282
|
+
|
|
283
|
+
expect(controlledValuesRef?.value[fieldName]).toBe('157');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// The gate keeps a half-typed pin from being marked invalid while the user is still filling it.
|
|
287
|
+
it('should not show an error while the pin is incomplete', async () => {
|
|
288
|
+
const { wrapper, errorsRef } = setupWithForm();
|
|
289
|
+
|
|
290
|
+
await wrapper.findAll(BOX)[2].setValue('7');
|
|
291
|
+
|
|
292
|
+
await waitForExpectShort(() => {
|
|
293
|
+
expect(errorsRef?.value?.code).toBeDefined();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
expect(wrapper.find(`#${messageId}`).exists()).toBe(false);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should show the error once the pin is complete', async () => {
|
|
300
|
+
const { wrapper, metaRef } = setupWithForm();
|
|
301
|
+
const inputs = wrapper.findAll(BOX);
|
|
302
|
+
|
|
303
|
+
for (const [index, character] of [...'123456'].entries()) {
|
|
304
|
+
await inputs[index].setValue(character);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
await waitForExpectShort(() => {
|
|
308
|
+
expect(wrapper.find(`#${messageId}`).text()).toBe(errorMessage);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(metaRef?.value.touched).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('should show the error once focus leaves the field', async () => {
|
|
315
|
+
const { wrapper, metaRef } = setupWithForm();
|
|
316
|
+
|
|
317
|
+
await wrapper.find('[role="group"]').trigger('focusout', { relatedTarget: null });
|
|
318
|
+
|
|
319
|
+
await waitForExpectShort(() => {
|
|
320
|
+
expect(wrapper.find(`#${messageId}`).text()).toBe(errorMessage);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expect(metaRef?.value.touched).toBe(true);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Boxes blur on every hop between boxes, which must not count as leaving the field.
|
|
327
|
+
it('should not show the error when focus moves between boxes', async () => {
|
|
328
|
+
const { wrapper, metaRef } = setupWithForm();
|
|
329
|
+
const inputs = wrapper.findAll(BOX);
|
|
330
|
+
|
|
331
|
+
await inputs[0].trigger('focusout', { relatedTarget: inputs[1].element });
|
|
332
|
+
|
|
333
|
+
expect(metaRef?.value.touched).toBe(false);
|
|
334
|
+
expect(wrapper.find(`#${messageId}`).exists()).toBe(false);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// `handleSubmit` touches every field, which a component-local touched flag would have missed.
|
|
338
|
+
it('should show the error after the form is submitted', async () => {
|
|
339
|
+
const { wrapper } = setupWithForm();
|
|
340
|
+
|
|
341
|
+
await wrapper.find('form').trigger('submit');
|
|
342
|
+
|
|
343
|
+
await waitForExpectShort(() => {
|
|
344
|
+
expect(wrapper.find(`#${messageId}`).text()).toBe(errorMessage);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('should clear the error once the pin is valid', async () => {
|
|
349
|
+
const { wrapper, errorsRef } = setupWithForm();
|
|
350
|
+
const inputs = wrapper.findAll(BOX);
|
|
351
|
+
|
|
352
|
+
await wrapper.find('form').trigger('submit');
|
|
353
|
+
|
|
354
|
+
await waitForExpectShort(() => {
|
|
355
|
+
expect(wrapper.find(`#${messageId}`).exists()).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
for (const [index, character] of [...'999999'].entries()) {
|
|
359
|
+
await inputs[index].setValue(character);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
await waitForExpectShort(() => {
|
|
363
|
+
expect(errorsRef?.value?.code).toBeUndefined();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(wrapper.find(`#${messageId}`).exists()).toBe(false);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('should throw an error if used outside of a form context', () => {
|
|
370
|
+
// A throw inside `setup()` leaves the component without a render function, so Vue emits
|
|
371
|
+
// an extra "Invalid vnode type" warning on top of the error under test.
|
|
372
|
+
expect(() =>
|
|
373
|
+
mount(PinInputField, {
|
|
374
|
+
props: { name: fieldName },
|
|
375
|
+
global: { config: { warnHandler: () => {} } },
|
|
376
|
+
}),
|
|
377
|
+
).toThrowError();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('should keep an explicit message over a vee-validate error', async () => {
|
|
381
|
+
const { wrapper, errorsRef } = setupWithForm({
|
|
382
|
+
messageText: 'Wpisz kod z e-maila',
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await wrapper.find('form').trigger('submit');
|
|
386
|
+
|
|
387
|
+
await waitForExpectShort(() => {
|
|
388
|
+
expect(errorsRef?.value?.code).toBe(errorMessage);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
expect(wrapper.find(`#${messageId}`).text()).toBe('Wpisz kod z e-maila');
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Meta, StoryObj } from '@storybook/vue3';
|
|
2
|
+
import { withActions } from '@storybook/addon-actions/decorator';
|
|
3
|
+
import { computed } from 'vue';
|
|
4
|
+
import PinInputField from './PinInputField.vue';
|
|
5
|
+
import HelpButton from '../../Buttons/HelpButton/HelpButton.vue';
|
|
6
|
+
import Modal from '../../Modals/Modal';
|
|
7
|
+
import { args, argTypes } from '../FormField/FormField.stories.shared';
|
|
8
|
+
import {
|
|
9
|
+
PIN_INPUT_FIELD_DEFAULT_LENGTH,
|
|
10
|
+
PIN_INPUT_FIELD_STATES,
|
|
11
|
+
PIN_INPUT_FIELD_TYPES,
|
|
12
|
+
PinInputFieldState,
|
|
13
|
+
} from './PinInputField.consts';
|
|
14
|
+
|
|
15
|
+
const meta: Meta<typeof PinInputField> = {
|
|
16
|
+
title: 'Components/Form/PinInputField',
|
|
17
|
+
component: PinInputField,
|
|
18
|
+
decorators: [withActions],
|
|
19
|
+
render: (args) => ({
|
|
20
|
+
components: { PinInputField, HelpButton, Modal },
|
|
21
|
+
setup() {
|
|
22
|
+
const props = computed(() => {
|
|
23
|
+
const { help, labelAside, message, fieldStatus, ...rest } = args;
|
|
24
|
+
return rest;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
props,
|
|
29
|
+
args,
|
|
30
|
+
PIN_INPUT_FIELD_STATES,
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
data: () => ({
|
|
34
|
+
value: '',
|
|
35
|
+
}),
|
|
36
|
+
template: `<PinInputField v-bind="props" v-model="value">
|
|
37
|
+
<template v-if="args.help" #help>
|
|
38
|
+
<HelpButton modal-title="Help modal title">
|
|
39
|
+
<template #modalContent>
|
|
40
|
+
Modal
|
|
41
|
+
</template>
|
|
42
|
+
</HelpButton>
|
|
43
|
+
</template>
|
|
44
|
+
<template #labelAside v-if="args.labelAside">
|
|
45
|
+
<div v-html="args.labelAside" />
|
|
46
|
+
</template>
|
|
47
|
+
<template #fieldStatus v-if="args.fieldStatus">
|
|
48
|
+
<div v-html="args.fieldStatus" />
|
|
49
|
+
</template>
|
|
50
|
+
<template #message v-if="args.message">
|
|
51
|
+
<div v-html="args.message" />
|
|
52
|
+
</template>
|
|
53
|
+
</PinInputField>`,
|
|
54
|
+
}),
|
|
55
|
+
argTypes: {
|
|
56
|
+
...argTypes,
|
|
57
|
+
state: {
|
|
58
|
+
control: 'select',
|
|
59
|
+
options: Object.values(PIN_INPUT_FIELD_STATES),
|
|
60
|
+
},
|
|
61
|
+
length: {
|
|
62
|
+
control: 'number',
|
|
63
|
+
},
|
|
64
|
+
otp: {
|
|
65
|
+
control: 'boolean',
|
|
66
|
+
},
|
|
67
|
+
type: {
|
|
68
|
+
control: 'select',
|
|
69
|
+
options: Object.values(PIN_INPUT_FIELD_TYPES),
|
|
70
|
+
},
|
|
71
|
+
ariaLabel: {
|
|
72
|
+
control: 'text',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
export default meta;
|
|
77
|
+
|
|
78
|
+
type Story = StoryObj<typeof PinInputField>;
|
|
79
|
+
|
|
80
|
+
export const Interactive: Story = {
|
|
81
|
+
args: {
|
|
82
|
+
...args,
|
|
83
|
+
state: PIN_INPUT_FIELD_STATES.DEFAULT as PinInputFieldState,
|
|
84
|
+
length: PIN_INPUT_FIELD_DEFAULT_LENGTH,
|
|
85
|
+
otp: true,
|
|
86
|
+
type: PIN_INPUT_FIELD_TYPES.NUMBER,
|
|
87
|
+
},
|
|
88
|
+
parameters: {
|
|
89
|
+
design: {
|
|
90
|
+
type: 'figma',
|
|
91
|
+
url: 'https://www.figma.com/design/xym3gXf0vBCofzksDtRDaU/INI-201---E-commerce?node-id=1763-34732&m=dev',
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const Error: Story = {
|
|
97
|
+
...Interactive,
|
|
98
|
+
args: {
|
|
99
|
+
...Interactive.args,
|
|
100
|
+
state: PIN_INPUT_FIELD_STATES.ERROR,
|
|
101
|
+
messageText: 'Kod jest nieprawidłowy',
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const Loading: Story = {
|
|
106
|
+
...Interactive,
|
|
107
|
+
args: {
|
|
108
|
+
...Interactive.args,
|
|
109
|
+
state: PIN_INPUT_FIELD_STATES.LOADING,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Both `otp` and `type` default on, which suits a numeric one-time code. An alphanumeric code has to
|
|
115
|
+
* opt out of `type: 'number'` — reka silently discards non-digits otherwise.
|
|
116
|
+
*/
|
|
117
|
+
export const Alphanumeric: Story = {
|
|
118
|
+
...Interactive,
|
|
119
|
+
args: {
|
|
120
|
+
...Interactive.args,
|
|
121
|
+
otp: false,
|
|
122
|
+
type: PIN_INPUT_FIELD_TYPES.TEXT,
|
|
123
|
+
},
|
|
124
|
+
};
|