@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.
Files changed (97) hide show
  1. package/dist/design-system.css +1 -1
  2. package/dist/design-system.js +13826 -13252
  3. package/dist/design-system.js.map +1 -1
  4. package/dist/lib/js/components/Buttons/IconButton/IconButton.vue.d.ts +1 -1
  5. package/dist/lib/js/components/Callout/Callout.consts.d.ts +32 -0
  6. package/dist/lib/js/components/{ActionContent/ActionContent.vue.d.ts → Callout/Callout.vue.d.ts} +17 -6
  7. package/dist/lib/js/components/Callout/index.d.ts +4 -0
  8. package/dist/lib/js/components/DatePickers/DateBox/DateBox.vue.d.ts +1 -1
  9. package/dist/lib/js/components/DatePickers/DatePicker/DatePicker.vue.d.ts +5 -5
  10. package/dist/lib/js/components/DatePickers/DateRangePicker/DateRangePicker.vue.d.ts +2 -2
  11. package/dist/lib/js/components/Divider/Divider.vue.d.ts +1 -1
  12. package/dist/lib/js/components/Drawer/DrawerDivider/DrawerDivider.vue.d.ts +1 -1
  13. package/dist/lib/js/components/Drawer/DrawerSection/DrawerSection.vue.d.ts +4 -4
  14. package/dist/lib/js/components/Drawer/DrawerTile/DrawerTile.vue.d.ts +4 -4
  15. package/dist/lib/js/components/Dropdown/Dropdown.vue.d.ts +1 -1
  16. package/dist/lib/js/components/Form/FormField/FormField.utils.d.ts +1 -1
  17. package/dist/lib/js/components/Form/InputField/InputField.vue.d.ts +2 -2
  18. package/dist/lib/js/components/Form/PasswordField/PasswordField.vue.d.ts +2 -2
  19. package/dist/lib/js/components/Form/PinInputField/PinInputField.consts.d.ts +14 -0
  20. package/dist/lib/js/components/Form/PinInputField/PinInputField.types.d.ts +12 -0
  21. package/dist/lib/js/components/Form/PinInputField/PinInputField.utils.d.ts +2 -0
  22. package/dist/lib/js/components/Form/PinInputField/PinInputField.vue.d.ts +91 -0
  23. package/dist/lib/js/components/Form/PinInputField/index.d.ts +5 -0
  24. package/dist/lib/js/components/Form/PinInputField/usePinInputFieldWithinForm.d.ts +8 -0
  25. package/dist/lib/js/components/Form/SelectField/SelectField.vue.d.ts +2 -2
  26. package/dist/lib/js/components/Form/TextAreaField/TextAreaField.types.d.ts +9 -0
  27. package/dist/lib/js/components/Form/TextAreaField/TextAreaField.vue.d.ts +76 -0
  28. package/dist/lib/js/components/Form/TextAreaField/TextAreaFieldAutosized.vue.d.ts +6 -0
  29. package/dist/lib/js/components/Form/TextAreaField/index.d.ts +4 -0
  30. package/dist/lib/js/components/Headers/OverlayHeader/OverlayHeader.vue.d.ts +5 -5
  31. package/dist/lib/js/components/Headers/PageHeader/PageHeader.vue.d.ts +1 -1
  32. package/dist/lib/js/components/Headers/SectionHeader/SectionHeader.vue.d.ts +3 -3
  33. package/dist/lib/js/components/IconText/IconText.vue.d.ts +1 -1
  34. package/dist/lib/js/components/Image/Image.vue.d.ts +1 -1
  35. package/dist/lib/js/components/LabelValue/LabelValueItem/LabelValueItem.vue.d.ts +12 -1
  36. package/dist/lib/js/components/Menu/MenuDivider/MenuDivider.vue.d.ts +1 -1
  37. package/dist/lib/js/components/Modals/Modal/Modal.vue.d.ts +2 -2
  38. package/dist/lib/js/components/Modals/ModalDialog/ModalDialog.vue.d.ts +2 -2
  39. package/dist/lib/js/components/Pagination/Pagination.vue.d.ts +2 -2
  40. package/dist/lib/js/components/RichList/BasicRichListItem/BasicRichListItem.vue.d.ts +7 -7
  41. package/dist/lib/js/components/RichList/RichListItem/RichListItem.vue.d.ts +3 -3
  42. package/dist/lib/js/components/SelectList/SelectListItemDivider/SelectListItemDivider.vue.d.ts +1 -1
  43. package/dist/lib/js/components/Skeleton/Skeleton.vue.d.ts +1 -1
  44. package/dist/lib/js/components/SurveyQuestions/SurveyQuestionOpenEnded/SurveyQuestionOpenEnded.vue.d.ts +3 -3
  45. package/dist/lib/js/components/SurveyQuestions/SurveyQuestionScale/SurveyQuestionScale.vue.d.ts +3 -3
  46. package/dist/lib/js/components/Tile/Tile.sb.shared.d.ts +2 -2
  47. package/dist/lib/js/components/Tile/Tile.vue.d.ts +2 -2
  48. package/dist/lib/js/components/Toast/Toast.consts.d.ts +11 -9
  49. package/dist/lib/js/components/Toast/Toast.vue.d.ts +51 -433
  50. package/dist/lib/js/components/Toggles/ToggleButton/ToggleButton.vue.d.ts +1 -1
  51. package/dist/lib/js/{components/Form/InputField/useInputFieldWithinForm.d.ts → composables/useTextFieldWithinForm.d.ts} +1 -1
  52. package/dist/lib/js/index.d.ts +6 -1
  53. package/lib/js/components/Callout/Callout.consts.ts +25 -0
  54. package/lib/js/components/Callout/Callout.spec.ts +257 -0
  55. package/lib/js/components/Callout/Callout.stories.ts +209 -0
  56. package/lib/js/components/Callout/Callout.vue +161 -0
  57. package/lib/js/components/Callout/index.ts +4 -0
  58. package/lib/js/components/Cards/Card/Card.vue +1 -1
  59. package/lib/js/components/Form/CheckboxGroupField/CheckboxGroupField.spec.ts +15 -0
  60. package/lib/js/components/Form/CheckboxGroupField/CheckboxGroupField.vue +5 -2
  61. package/lib/js/components/Form/Form.stories.ts +23 -2
  62. package/lib/js/components/Form/InputField/InputField.spec.ts +11 -1
  63. package/lib/js/components/Form/InputField/InputField.vue +7 -4
  64. package/lib/js/components/Form/PasswordField/PasswordField.vue +1 -1
  65. package/lib/js/components/Form/PinInputField/PinInputField.consts.ts +18 -0
  66. package/lib/js/components/Form/PinInputField/PinInputField.spec.ts +394 -0
  67. package/lib/js/components/Form/PinInputField/PinInputField.stories.ts +124 -0
  68. package/lib/js/components/Form/PinInputField/PinInputField.types.ts +42 -0
  69. package/lib/js/components/Form/PinInputField/PinInputField.utils.ts +5 -0
  70. package/lib/js/components/Form/PinInputField/PinInputField.vue +211 -0
  71. package/lib/js/components/Form/PinInputField/index.ts +6 -0
  72. package/lib/js/components/Form/PinInputField/usePinInputFieldWithinForm.ts +29 -0
  73. package/lib/js/components/Form/SelectField/SelectField.stories.ts +0 -5
  74. package/lib/js/components/Form/SelectField/useSelectFieldWithinForm.ts +1 -1
  75. package/lib/js/components/Form/TextAreaField/TextAreaField.spec.ts +341 -0
  76. package/lib/js/components/Form/TextAreaField/TextAreaField.stories.ts +99 -0
  77. package/lib/js/components/Form/TextAreaField/TextAreaField.types.ts +23 -0
  78. package/lib/js/components/Form/TextAreaField/TextAreaField.vue +190 -0
  79. package/lib/js/components/Form/TextAreaField/TextAreaFieldAutosized.vue +19 -0
  80. package/lib/js/components/Form/TextAreaField/index.ts +5 -0
  81. package/lib/js/components/LabelValue/LabelValueItem/LabelValueItem.spec.ts +26 -0
  82. package/lib/js/components/LabelValue/LabelValueItem/LabelValueItem.stories.ts +16 -3
  83. package/lib/js/components/LabelValue/LabelValueItem/LabelValueItem.vue +5 -0
  84. package/lib/js/components/LoadingBar/LoadingBar.spec.ts +84 -0
  85. package/lib/js/components/LoadingBar/LoadingBar.vue +30 -19
  86. package/lib/js/components/Toast/Toast.consts.ts +4 -2
  87. package/lib/js/components/Toast/Toast.spec.ts +177 -0
  88. package/lib/js/components/Toast/Toast.stories.ts +65 -31
  89. package/lib/js/components/Toast/Toast.vue +176 -195
  90. package/lib/js/{components/Form/InputField/useInputFieldWithinForm.ts → composables/useTextFieldWithinForm.ts} +7 -2
  91. package/lib/js/index.ts +6 -1
  92. package/package.json +1 -1
  93. package/dist/lib/js/components/ActionContent/index.d.ts +0 -3
  94. package/lib/js/components/ActionContent/ActionContent.spec.ts +0 -99
  95. package/lib/js/components/ActionContent/ActionContent.stories.ts +0 -141
  96. package/lib/js/components/ActionContent/ActionContent.vue +0 -104
  97. 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: { InputField, CheckboxGroupField, Checkbox, SelectField, Button },
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('overrides state and message on vee-validate form error', async () => {
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 { useInputFieldWithinForm } from './useInputFieldWithinForm';
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
- } = useInputFieldWithinForm(() => name, modelValue);
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,
@@ -3,7 +3,7 @@
3
3
  v-bind="$props"
4
4
  v-model="value"
5
5
  :input-props="inputProps"
6
- :left-icon="ICONS.FA_UNLOCK_KEYHOLE"
6
+ :left-icon="ICONS.FA_LOCK_KEYHOLE"
7
7
  >
8
8
  <template #action>
9
9
  <icon-button
@@ -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
+ };