@bethinkpl/design-system 43.0.0 → 44.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 +11664 -11121
- package/dist/design-system.js.map +1 -1
- 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/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/LabelValue/LabelValueItem/LabelValueItem.vue.d.ts +12 -1
- 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/Form/InputField/useInputFieldWithinForm.d.ts → composables/useTextFieldWithinForm.d.ts} +1 -1
- package/dist/lib/js/index.d.ts +4 -0
- 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/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 +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<form-field v-bind="formFieldProps">
|
|
3
|
+
<template #field="{ fieldId, messageId, labelId }">
|
|
4
|
+
<div v-if="isLoading" class="ds-pinInputField -ds-loading" aria-busy="true">
|
|
5
|
+
<ds-icon
|
|
6
|
+
class="ds-pinInputField__spinner"
|
|
7
|
+
:icon="ICONS.FAD_SPINNER_THIRD"
|
|
8
|
+
:size="ICON_SIZES.LARGE"
|
|
9
|
+
spinning
|
|
10
|
+
/>
|
|
11
|
+
</div>
|
|
12
|
+
<!--
|
|
13
|
+
`required` reaches a 1px-clipped, `aria-hidden` input inside reka's root, so in a
|
|
14
|
+
native `<form>` the browser blocks `submit` while the pin is empty and
|
|
15
|
+
`@submit.prevent` never runs. vee-validate forms need `novalidate`. `SelectField`
|
|
16
|
+
forwards it the same way.
|
|
17
|
+
-->
|
|
18
|
+
<pin-input-root
|
|
19
|
+
v-else
|
|
20
|
+
:id="fieldId"
|
|
21
|
+
class="ds-pinInputField"
|
|
22
|
+
role="group"
|
|
23
|
+
:model-value="chars as Array<string>"
|
|
24
|
+
:otp="otp"
|
|
25
|
+
:type="type"
|
|
26
|
+
:required="formFieldProps.hasRequiredIndicator"
|
|
27
|
+
:aria-labelledby="formFieldProps.label ? labelId : undefined"
|
|
28
|
+
:aria-label="formFieldProps.label ? undefined : ariaLabel"
|
|
29
|
+
:aria-describedby="hasMessage ? messageId : undefined"
|
|
30
|
+
@complete="onPinComplete"
|
|
31
|
+
@focusout="onFieldFocusOut"
|
|
32
|
+
@update:model-value="onCharsUpdate"
|
|
33
|
+
>
|
|
34
|
+
<pin-input-input
|
|
35
|
+
v-for="index in length"
|
|
36
|
+
:key="index"
|
|
37
|
+
class="ds-pinInputField__item"
|
|
38
|
+
:index="index - 1"
|
|
39
|
+
/>
|
|
40
|
+
</pin-input-root>
|
|
41
|
+
</template>
|
|
42
|
+
<!-- begin: FormField slots -->
|
|
43
|
+
<template v-if="$slots.help" #help>
|
|
44
|
+
<slot name="help" />
|
|
45
|
+
</template>
|
|
46
|
+
<template v-if="$slots.labelAside" #labelAside>
|
|
47
|
+
<slot name="labelAside" />
|
|
48
|
+
</template>
|
|
49
|
+
<template v-if="$slots.message" #message="{ messageId }">
|
|
50
|
+
<slot name="message" :message-id="messageId" />
|
|
51
|
+
</template>
|
|
52
|
+
<template v-if="$slots.fieldStatus" #fieldStatus>
|
|
53
|
+
<slot name="fieldStatus" />
|
|
54
|
+
</template>
|
|
55
|
+
<!-- end: FormField slots -->
|
|
56
|
+
</form-field>
|
|
57
|
+
</template>
|
|
58
|
+
|
|
59
|
+
<style lang="scss" scoped>
|
|
60
|
+
@import '../../../../styles/settings/spacings';
|
|
61
|
+
@import '../../../../styles/settings/radiuses';
|
|
62
|
+
@import '../../../../styles/settings/borders';
|
|
63
|
+
@import '../../../../styles/settings/colors/tokens';
|
|
64
|
+
@import '../../../../styles/settings/typography/tokens';
|
|
65
|
+
@import '../../../../styles/settings/shadows';
|
|
66
|
+
|
|
67
|
+
.ds-pinInputField {
|
|
68
|
+
align-items: center;
|
|
69
|
+
align-self: stretch;
|
|
70
|
+
display: flex;
|
|
71
|
+
gap: $space-4;
|
|
72
|
+
|
|
73
|
+
&.-ds-loading {
|
|
74
|
+
flex-direction: column;
|
|
75
|
+
min-height: 48px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
&__item {
|
|
79
|
+
@include formText-m-default-bold;
|
|
80
|
+
|
|
81
|
+
background: $color-default-background;
|
|
82
|
+
border: $border-xs solid $color-neutral-border-strong;
|
|
83
|
+
border-radius: $radius-s;
|
|
84
|
+
box-shadow: $shadow-inset-m;
|
|
85
|
+
color: $color-neutral-text-heavy;
|
|
86
|
+
flex: 0 1 48px;
|
|
87
|
+
height: 48px;
|
|
88
|
+
max-width: 48px;
|
|
89
|
+
min-width: 24px;
|
|
90
|
+
outline: none;
|
|
91
|
+
padding: 0 $space-3;
|
|
92
|
+
text-align: center;
|
|
93
|
+
|
|
94
|
+
&:focus {
|
|
95
|
+
border-color: $color-primary-border;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
&:hover:not(:focus) {
|
|
99
|
+
border-color: $color-neutral-border-strong-hovered;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
&__spinner {
|
|
104
|
+
color: $color-neutral-icon;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
</style>
|
|
108
|
+
|
|
109
|
+
<script lang="ts" setup>
|
|
110
|
+
import { computed, ref, watch } from 'vue';
|
|
111
|
+
import { PinInputInput, PinInputRoot } from 'reka-ui';
|
|
112
|
+
import FormField, { FORM_FIELD_STATES, FormFieldProps } from '../FormField';
|
|
113
|
+
import { extractFormFieldProps } from '../FormField/FormField.utils';
|
|
114
|
+
import DsIcon, { ICON_SIZES, ICONS } from '../../Icons/Icon';
|
|
115
|
+
import {
|
|
116
|
+
PIN_INPUT_FIELD_DEFAULT_LENGTH,
|
|
117
|
+
PIN_INPUT_FIELD_STATES,
|
|
118
|
+
PIN_INPUT_FIELD_TYPES,
|
|
119
|
+
} from './PinInputField.consts';
|
|
120
|
+
import { PinInputFieldProps, PinInputFieldSlots } from './PinInputField.types';
|
|
121
|
+
import { PinChars, toPinChars } from './PinInputField.utils';
|
|
122
|
+
import { usePinInputFieldWithinForm } from './usePinInputFieldWithinForm';
|
|
123
|
+
|
|
124
|
+
const {
|
|
125
|
+
length = PIN_INPUT_FIELD_DEFAULT_LENGTH,
|
|
126
|
+
state = PIN_INPUT_FIELD_STATES.DEFAULT,
|
|
127
|
+
otp = true,
|
|
128
|
+
type = PIN_INPUT_FIELD_TYPES.NUMBER,
|
|
129
|
+
ariaLabel,
|
|
130
|
+
name,
|
|
131
|
+
...rest
|
|
132
|
+
} = defineProps<PinInputFieldProps>();
|
|
133
|
+
|
|
134
|
+
const slots = defineSlots<PinInputFieldSlots>();
|
|
135
|
+
|
|
136
|
+
const emit = defineEmits<{
|
|
137
|
+
/** Fires whenever the pin becomes complete, including programmatic fills. */
|
|
138
|
+
complete: [value: string];
|
|
139
|
+
}>();
|
|
140
|
+
|
|
141
|
+
/** The filled boxes joined, in order. A cleared middle box shortens it (`1 2 _ 4 5 6` → `'12456'`). */
|
|
142
|
+
const modelValue = defineModel<string>();
|
|
143
|
+
|
|
144
|
+
const { value, errors, onComplete, onBlur } = usePinInputFieldWithinForm(() => name, modelValue);
|
|
145
|
+
|
|
146
|
+
// The array, not the joined string, is the source of truth: clearing a middle box leaves a hole in
|
|
147
|
+
// it (`delete` in `type: 'number'` mode, `''` in `type: 'text'` mode), so rebuilding it from the
|
|
148
|
+
// string would shift the later boxes leftwards. In numeric mode reka also stores numbers rather than
|
|
149
|
+
// strings, which its own `modelValue` type does not admit — hence the cast on the binding.
|
|
150
|
+
const chars = ref<PinChars>(toPinChars(value.value, length));
|
|
151
|
+
|
|
152
|
+
// reka rewrites the whole array on every keystroke; `join('')` flattens the numbers it stores in
|
|
153
|
+
// numeric mode and the holes a cleared box leaves behind.
|
|
154
|
+
function onCharsUpdate(next: PinChars) {
|
|
155
|
+
chars.value = next;
|
|
156
|
+
value.value = next.join('');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Inbound only: parent `v-model`, a vee-validate reset, or a shrinking `length`. The write from
|
|
160
|
+
// `onCharsUpdate` lands here too, but is already in sync — rebuilding would collapse a cleared
|
|
161
|
+
// middle box onto its neighbours.
|
|
162
|
+
watch([value, () => length], ([next]) => {
|
|
163
|
+
const nextValue = next ?? '';
|
|
164
|
+
|
|
165
|
+
if (chars.value.length <= length && chars.value.join('') === nextValue) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
chars.value = toPinChars(next, length);
|
|
170
|
+
|
|
171
|
+
// A shrink drops the surplus characters, and the model has to follow: they no longer have a box
|
|
172
|
+
// to edit them in.
|
|
173
|
+
const joined = chars.value.join('');
|
|
174
|
+
|
|
175
|
+
if (joined !== nextValue) {
|
|
176
|
+
value.value = joined;
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const formFieldProps = computed<FormFieldProps>(() => {
|
|
181
|
+
// this is needed to avoid passing modelValue to FormField as prop
|
|
182
|
+
return extractFormFieldProps(
|
|
183
|
+
{
|
|
184
|
+
...rest,
|
|
185
|
+
state: state === PIN_INPUT_FIELD_STATES.ERROR ? FORM_FIELD_STATES.ERROR : undefined,
|
|
186
|
+
},
|
|
187
|
+
errors.value,
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const isLoading = computed(() => state === PIN_INPUT_FIELD_STATES.LOADING);
|
|
192
|
+
|
|
193
|
+
// Avoids pointing `aria-describedby` at a message element that is never rendered.
|
|
194
|
+
const hasMessage = computed(() => !!(formFieldProps.value.messageText || slots.message));
|
|
195
|
+
|
|
196
|
+
// Boxes blur on every hop between boxes; only focus leaving the field is a real blur.
|
|
197
|
+
function onFieldFocusOut(event: FocusEvent) {
|
|
198
|
+
const root = event.currentTarget as HTMLElement | null;
|
|
199
|
+
|
|
200
|
+
if (root?.contains(event.relatedTarget as Node | null)) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
onBlur(event);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function onPinComplete() {
|
|
208
|
+
onComplete(new Event('blur'));
|
|
209
|
+
emit('complete', chars.value.join(''));
|
|
210
|
+
}
|
|
211
|
+
</script>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { computed, MaybeRefOrGetter, Ref } from 'vue';
|
|
2
|
+
import { useFormFieldWithinForm } from '../../../composables/useFormFieldWithinForm';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Pin counterpart of `useTextFieldWithinForm`. A pin has no single blur — its boxes blur on every
|
|
6
|
+
* hop between boxes — so the field is marked touched when the pin is completed or when focus leaves
|
|
7
|
+
* the field entirely.
|
|
8
|
+
*/
|
|
9
|
+
export function usePinInputFieldWithinForm(
|
|
10
|
+
name: MaybeRefOrGetter<string | undefined>,
|
|
11
|
+
modelValue: Ref<string | undefined>,
|
|
12
|
+
) {
|
|
13
|
+
const { value, errors, field } = useFormFieldWithinForm(name, modelValue);
|
|
14
|
+
|
|
15
|
+
const markTouched = (event: Event) => {
|
|
16
|
+
field?.handleBlur(event, true);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// `meta.touched` covers both callers below and `handleSubmit`, so a half-typed pin is never
|
|
20
|
+
// marked invalid but a submitted incomplete one still shows its error.
|
|
21
|
+
const visibleErrors = computed(() => (field?.meta.touched ? errors.value : []));
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
value,
|
|
25
|
+
errors: visibleErrors,
|
|
26
|
+
onComplete: markTouched,
|
|
27
|
+
onBlur: markTouched,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -174,7 +174,6 @@ export const WithGroups: Story = {
|
|
|
174
174
|
...Interactive.args,
|
|
175
175
|
options: GROUPED_OPTIONS,
|
|
176
176
|
},
|
|
177
|
-
parameters: {},
|
|
178
177
|
};
|
|
179
178
|
|
|
180
179
|
export const WithIconsAndEyebrow: Story = {
|
|
@@ -184,7 +183,6 @@ export const WithIconsAndEyebrow: Story = {
|
|
|
184
183
|
options: RICH_OPTIONS,
|
|
185
184
|
placeholder: 'Wybierz status',
|
|
186
185
|
},
|
|
187
|
-
parameters: {},
|
|
188
186
|
};
|
|
189
187
|
|
|
190
188
|
/**
|
|
@@ -230,7 +228,6 @@ export const LongList: Story = {
|
|
|
230
228
|
options: LONG_OPTIONS,
|
|
231
229
|
placeholder: 'Wybierz opcję',
|
|
232
230
|
},
|
|
233
|
-
parameters: {},
|
|
234
231
|
};
|
|
235
232
|
|
|
236
233
|
/**
|
|
@@ -273,7 +270,6 @@ export const Disabled: Story = {
|
|
|
273
270
|
...Interactive.args,
|
|
274
271
|
state: FORM_FIELD_STATES.DISABLED,
|
|
275
272
|
},
|
|
276
|
-
parameters: {},
|
|
277
273
|
};
|
|
278
274
|
|
|
279
275
|
export const Error: Story = {
|
|
@@ -283,5 +279,4 @@ export const Error: Story = {
|
|
|
283
279
|
state: FORM_FIELD_STATES.ERROR,
|
|
284
280
|
messageText: 'Error message text',
|
|
285
281
|
},
|
|
286
|
-
parameters: {},
|
|
287
282
|
};
|
|
@@ -3,7 +3,7 @@ import { useFormFieldWithinForm } from '../../../composables/useFormFieldWithinF
|
|
|
3
3
|
import { SelectFieldValue } from './SelectField.types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Select counterpart of `
|
|
6
|
+
* Select counterpart of `useTextFieldWithinForm`. A select has no `input` event, so closing
|
|
7
7
|
* the listbox stands in for blur — that is when the field is marked touched.
|
|
8
8
|
*
|
|
9
9
|
* Revalidation on selection needs no handler here: vee-validate's `useField` defaults to
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import TextAreaField from './TextAreaField.vue';
|
|
4
|
+
import TextAreaFieldAutosized from './TextAreaFieldAutosized.vue';
|
|
5
|
+
import { ComputedRef, Ref } from 'vue';
|
|
6
|
+
import { FORM_FIELD_STATES } from '../FormField';
|
|
7
|
+
import { ComponentProps } from 'vue-component-type-helpers';
|
|
8
|
+
import { FormMeta, useForm } from 'vee-validate';
|
|
9
|
+
import { waitForExpectShort } from '../../../tests/helpers';
|
|
10
|
+
|
|
11
|
+
const fieldId = 'form-field-v-0';
|
|
12
|
+
const messageId = `${fieldId}-message`;
|
|
13
|
+
|
|
14
|
+
function setup({ props }: { props?: ComponentProps<typeof TextAreaField> } = {}) {
|
|
15
|
+
return mount(TextAreaField, { props });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('TextAreaField', () => {
|
|
19
|
+
it('should render', () => {
|
|
20
|
+
const wrapper = setup({
|
|
21
|
+
props: {
|
|
22
|
+
label: 'Label',
|
|
23
|
+
messageText: 'Message text',
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(wrapper.find('.ds-textAreaField').exists()).toBe(true);
|
|
28
|
+
expect(wrapper.find('textarea').exists()).toBe(true);
|
|
29
|
+
expect(wrapper.find('label').text()).toContain('Label');
|
|
30
|
+
expect(wrapper.find(`#${messageId}`).text()).toBe('Message text');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should handle value updates', async () => {
|
|
34
|
+
const onUpdate = vi.fn();
|
|
35
|
+
const wrapper = setup({
|
|
36
|
+
props: {
|
|
37
|
+
label: 'Label',
|
|
38
|
+
modelValue: 'initial value',
|
|
39
|
+
'onUpdate:modelValue': onUpdate,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const textarea = wrapper.find<HTMLTextAreaElement>('textarea');
|
|
44
|
+
expect(textarea.element.value).toBe('initial value');
|
|
45
|
+
|
|
46
|
+
await textarea.setValue('updated value');
|
|
47
|
+
|
|
48
|
+
expect(onUpdate).toHaveBeenCalledWith('updated value');
|
|
49
|
+
expect(textarea.element.value).toBe('updated value');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should render with accessible ids', () => {
|
|
53
|
+
const wrapper = setup({
|
|
54
|
+
props: {
|
|
55
|
+
label: 'Label',
|
|
56
|
+
messageText: 'Message text',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(wrapper.find('label').attributes('for')).toBe(fieldId);
|
|
61
|
+
|
|
62
|
+
const textarea = wrapper.find('textarea');
|
|
63
|
+
|
|
64
|
+
expect(textarea.attributes('id')).toBe(fieldId);
|
|
65
|
+
expect(textarea.attributes('aria-describedby')).toBe(messageId);
|
|
66
|
+
expect(wrapper.find(`#${messageId}`).exists()).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should set aria-describedby only when a message is rendered', () => {
|
|
70
|
+
const wrapper = setup({
|
|
71
|
+
props: {
|
|
72
|
+
label: 'Label',
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(wrapper.find('textarea').attributes('aria-describedby')).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should pass inputProps to the textarea element', () => {
|
|
80
|
+
const inputProps = {
|
|
81
|
+
placeholder: 'Enter text',
|
|
82
|
+
disabled: true,
|
|
83
|
+
maxlength: 500,
|
|
84
|
+
'aria-label': 'Text area field',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const wrapper = setup({
|
|
88
|
+
props: {
|
|
89
|
+
label: 'Label',
|
|
90
|
+
inputProps,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const textarea = wrapper.find('textarea');
|
|
95
|
+
expect(textarea.attributes('placeholder')).toBe(inputProps.placeholder);
|
|
96
|
+
expect(textarea.attributes('disabled')).toBeDefined();
|
|
97
|
+
expect(textarea.attributes('maxlength')).toBe('500');
|
|
98
|
+
expect(textarea.attributes('aria-label')).toBe(inputProps['aria-label']);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should render 3 rows by default', () => {
|
|
102
|
+
const wrapper = setup();
|
|
103
|
+
|
|
104
|
+
expect(wrapper.find('textarea').attributes('rows')).toBe('3');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should let inputProps override the default rows', () => {
|
|
108
|
+
const wrapper = setup({
|
|
109
|
+
props: {
|
|
110
|
+
inputProps: {
|
|
111
|
+
rows: 6,
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(wrapper.find('textarea').attributes('rows')).toBe('6');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it.each([
|
|
120
|
+
{
|
|
121
|
+
state: FORM_FIELD_STATES.DISABLED,
|
|
122
|
+
expectedClass: '-ds-disabled',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
state: FORM_FIELD_STATES.ERROR,
|
|
126
|
+
expectedClass: '-ds-error',
|
|
127
|
+
},
|
|
128
|
+
])('should handle state: $state', ({ state, expectedClass }) => {
|
|
129
|
+
const wrapper = setup({
|
|
130
|
+
props: {
|
|
131
|
+
state,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
expect(wrapper.find('.ds-textAreaField').classes()).toContain(expectedClass);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should make textarea disabled when state is DISABLED', () => {
|
|
138
|
+
const wrapper = setup({
|
|
139
|
+
props: {
|
|
140
|
+
state: FORM_FIELD_STATES.DISABLED,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
const textarea = wrapper.find('textarea');
|
|
144
|
+
expect(textarea.attributes('disabled')).toBeDefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('autoresizing', () => {
|
|
148
|
+
it('should render a plain textarea by default', () => {
|
|
149
|
+
const wrapper = setup();
|
|
150
|
+
|
|
151
|
+
expect(wrapper.findComponent(TextAreaFieldAutosized).exists()).toBe(false);
|
|
152
|
+
expect(wrapper.find('.ds-textAreaField').classes()).not.toContain('-ds-autoresizing');
|
|
153
|
+
expect(wrapper.find('textarea').exists()).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should render the autosized textarea when isAutoresizing', () => {
|
|
157
|
+
const wrapper = setup({
|
|
158
|
+
props: {
|
|
159
|
+
isAutoresizing: true,
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(wrapper.findComponent(TextAreaFieldAutosized).exists()).toBe(true);
|
|
164
|
+
expect(wrapper.find('.ds-textAreaField').classes()).toContain('-ds-autoresizing');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should keep the field plumbing when autoresizing', async () => {
|
|
168
|
+
const onUpdate = vi.fn();
|
|
169
|
+
const wrapper = setup({
|
|
170
|
+
props: {
|
|
171
|
+
label: 'Label',
|
|
172
|
+
messageText: 'Message text',
|
|
173
|
+
isAutoresizing: true,
|
|
174
|
+
inputProps: {
|
|
175
|
+
rows: 4,
|
|
176
|
+
},
|
|
177
|
+
'onUpdate:modelValue': onUpdate,
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const textarea = wrapper.find('textarea');
|
|
182
|
+
expect(textarea.attributes('id')).toBe(fieldId);
|
|
183
|
+
expect(textarea.attributes('aria-describedby')).toBe(messageId);
|
|
184
|
+
expect(textarea.attributes('rows')).toBe('4');
|
|
185
|
+
expect(textarea.classes()).toContain('ds-textAreaField__input');
|
|
186
|
+
|
|
187
|
+
await textarea.setValue('updated value');
|
|
188
|
+
|
|
189
|
+
expect(onUpdate).toHaveBeenCalledWith('updated value');
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('with vee-validate', () => {
|
|
194
|
+
const fieldName = 'siema';
|
|
195
|
+
const initialValue = 'siemanko!';
|
|
196
|
+
function setupWithForm(props?: ComponentProps<typeof TextAreaField>) {
|
|
197
|
+
let controlledValuesRef: Ref<{ siema: string }> | undefined;
|
|
198
|
+
let errorsRef: ComputedRef<Partial<Record<'siema', string | undefined>>> | undefined;
|
|
199
|
+
let metaRef: Ref<FormMeta<{ siema: string }>> | undefined;
|
|
200
|
+
|
|
201
|
+
const FormComponent = {
|
|
202
|
+
template: `
|
|
203
|
+
<form>
|
|
204
|
+
<TextAreaField
|
|
205
|
+
v-bind="props"
|
|
206
|
+
:name="name"
|
|
207
|
+
/>
|
|
208
|
+
</form>
|
|
209
|
+
`,
|
|
210
|
+
components: { TextAreaField },
|
|
211
|
+
setup() {
|
|
212
|
+
const { controlledValues, errors, meta } = useForm({
|
|
213
|
+
initialValues: {
|
|
214
|
+
[fieldName]: initialValue,
|
|
215
|
+
},
|
|
216
|
+
validationSchema: {
|
|
217
|
+
siema: (val: string) => (val.length < 5 ? 'Too short' : true),
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
controlledValuesRef = controlledValues;
|
|
222
|
+
errorsRef = errors;
|
|
223
|
+
metaRef = meta;
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
name: fieldName,
|
|
227
|
+
props,
|
|
228
|
+
};
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
wrapper: mount(FormComponent),
|
|
234
|
+
controlledValuesRef,
|
|
235
|
+
errorsRef,
|
|
236
|
+
metaRef,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
it('should bind textarea value with vee-validate form values', async () => {
|
|
241
|
+
const { wrapper, controlledValuesRef } = setupWithForm();
|
|
242
|
+
|
|
243
|
+
const textarea = wrapper.find('textarea');
|
|
244
|
+
|
|
245
|
+
expect(textarea.element.value).toBe(initialValue);
|
|
246
|
+
expect(controlledValuesRef?.value[fieldName]).toBe(initialValue);
|
|
247
|
+
|
|
248
|
+
const newValue = 'no cześć!';
|
|
249
|
+
await textarea.setValue(newValue);
|
|
250
|
+
|
|
251
|
+
expect(textarea.element.value).toBe(newValue);
|
|
252
|
+
expect(controlledValuesRef?.value[fieldName]).toBe(newValue);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should call onBlur and mark form as touched on blur', async () => {
|
|
256
|
+
const onBlur = vi.fn();
|
|
257
|
+
const { wrapper, metaRef } = setupWithForm({
|
|
258
|
+
inputProps: {
|
|
259
|
+
onBlur,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
expect(metaRef?.value.touched).toBe(false);
|
|
263
|
+
|
|
264
|
+
const textarea = wrapper.find('textarea');
|
|
265
|
+
await textarea.trigger('blur');
|
|
266
|
+
|
|
267
|
+
expect(onBlur).toHaveBeenCalled();
|
|
268
|
+
expect(metaRef?.value.touched).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should call onInput and show error message', async () => {
|
|
272
|
+
const onInput = vi.fn();
|
|
273
|
+
const { wrapper, errorsRef } = setupWithForm({
|
|
274
|
+
inputProps: {
|
|
275
|
+
onInput,
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const textarea = wrapper.find('textarea');
|
|
280
|
+
await textarea.setValue('test');
|
|
281
|
+
|
|
282
|
+
expect(onInput).toHaveBeenCalled();
|
|
283
|
+
|
|
284
|
+
await textarea.trigger('blur');
|
|
285
|
+
|
|
286
|
+
await waitForExpectShort(() => {
|
|
287
|
+
expect(errorsRef?.value?.siema).toBeDefined();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
expect(wrapper.find('.ds-textAreaField').classes()).toContain('-ds-error');
|
|
291
|
+
expect(wrapper.find(`#${messageId}`).text()).toBe('Too short');
|
|
292
|
+
|
|
293
|
+
await textarea.setValue('valid value');
|
|
294
|
+
|
|
295
|
+
await waitForExpectShort(() => {
|
|
296
|
+
expect(errorsRef?.value?.siema).toBeUndefined();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('should bind the autosized textarea with vee-validate form values', async () => {
|
|
301
|
+
const { wrapper, controlledValuesRef } = setupWithForm({ isAutoresizing: true });
|
|
302
|
+
|
|
303
|
+
const textarea = wrapper.find('textarea');
|
|
304
|
+
expect(textarea.element.value).toBe(initialValue);
|
|
305
|
+
|
|
306
|
+
const newValue = 'no cześć!';
|
|
307
|
+
await textarea.setValue(newValue);
|
|
308
|
+
|
|
309
|
+
expect(controlledValuesRef?.value[fieldName]).toBe(newValue);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should throw an error if used outside of a form context', () => {
|
|
313
|
+
expect(() => {
|
|
314
|
+
setup({
|
|
315
|
+
props: {
|
|
316
|
+
name: 'siema',
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
}).toThrowError();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should keep an explicit state and message over a vee-validate error', async () => {
|
|
323
|
+
const { wrapper, errorsRef } = setupWithForm({
|
|
324
|
+
state: FORM_FIELD_STATES.SUCCESS,
|
|
325
|
+
messageText: 'Success message',
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const textarea = wrapper.find('textarea');
|
|
329
|
+
await textarea.setValue('test');
|
|
330
|
+
|
|
331
|
+
await textarea.trigger('blur');
|
|
332
|
+
|
|
333
|
+
await waitForExpectShort(() => {
|
|
334
|
+
expect(errorsRef?.value?.siema).toBeDefined();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
expect(wrapper.find(`#${messageId}`).classes()).toContain('-ds-success');
|
|
338
|
+
expect(wrapper.find(`#${messageId}`).text()).toBe('Success message');
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
});
|