@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,99 @@
|
|
|
1
|
+
import { Meta, StoryObj } from '@storybook/vue3';
|
|
2
|
+
import TextAreaField from './TextAreaField.vue';
|
|
3
|
+
import HelpButton from '../../Buttons/HelpButton/HelpButton.vue';
|
|
4
|
+
import Modal from '../../Modals/Modal';
|
|
5
|
+
import { args, argTypes } from '../FormField/FormField.stories.shared';
|
|
6
|
+
import { FORM_FIELD_STATES } from '../FormField/FormField.consts';
|
|
7
|
+
import { reactive, toRefs } from 'vue';
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof TextAreaField> = {
|
|
10
|
+
title: 'Components/Form/TextAreaField',
|
|
11
|
+
component: TextAreaField,
|
|
12
|
+
render: (args) => ({
|
|
13
|
+
components: { TextAreaField, HelpButton, Modal },
|
|
14
|
+
setup() {
|
|
15
|
+
const { help, labelAside, message, fieldStatus, ...restRefs } = toRefs(args);
|
|
16
|
+
const props = reactive({ ...restRefs });
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
props,
|
|
20
|
+
labelAside,
|
|
21
|
+
fieldStatus,
|
|
22
|
+
message,
|
|
23
|
+
help,
|
|
24
|
+
FORM_FIELD_STATES,
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
data: () => ({
|
|
28
|
+
value: '',
|
|
29
|
+
}),
|
|
30
|
+
template: `<TextAreaField v-bind="props" v-model="value">
|
|
31
|
+
<template v-if="help" #help>
|
|
32
|
+
<HelpButton :is-disabled="props.state === FORM_FIELD_STATES.DISABLED" modal-title="Help modal title">
|
|
33
|
+
<template #modalContent>
|
|
34
|
+
Modal
|
|
35
|
+
</template>
|
|
36
|
+
</HelpButton>
|
|
37
|
+
</template>
|
|
38
|
+
<template #labelAside v-if="labelAside">
|
|
39
|
+
<div v-html="labelAside" />
|
|
40
|
+
</template>
|
|
41
|
+
<template #fieldStatus v-if="fieldStatus">
|
|
42
|
+
<div v-html="fieldStatus" />
|
|
43
|
+
</template>
|
|
44
|
+
<template #message v-if="message">
|
|
45
|
+
<div v-if="message" v-html="message" />
|
|
46
|
+
</template>
|
|
47
|
+
</TextAreaField>`,
|
|
48
|
+
}),
|
|
49
|
+
argTypes: {
|
|
50
|
+
...argTypes,
|
|
51
|
+
inputProps: {
|
|
52
|
+
control: 'object',
|
|
53
|
+
},
|
|
54
|
+
isAutoresizing: {
|
|
55
|
+
control: 'boolean',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
export default meta;
|
|
60
|
+
|
|
61
|
+
type Story = StoryObj<typeof TextAreaField>;
|
|
62
|
+
|
|
63
|
+
export const Interactive: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
...args,
|
|
66
|
+
isAutoresizing: false,
|
|
67
|
+
inputProps: {
|
|
68
|
+
rows: 3,
|
|
69
|
+
placeholder: 'TextArea placeholder',
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
parameters: {
|
|
73
|
+
design: {
|
|
74
|
+
type: 'figma',
|
|
75
|
+
url: 'https://www.figma.com/design/izQdYyiBR1GQgFkaOIfIJI/LMS---DS-Components?node-id=13216-6572',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The field grows with its content instead of showing the resize handle. `rows` stays the starting
|
|
82
|
+
* height, and the field grows up to 500px, after which it scrolls.
|
|
83
|
+
*/
|
|
84
|
+
export const Autoresizing: Story = {
|
|
85
|
+
...Interactive,
|
|
86
|
+
args: {
|
|
87
|
+
...Interactive.args,
|
|
88
|
+
isAutoresizing: true,
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const Error: Story = {
|
|
93
|
+
...Interactive,
|
|
94
|
+
args: {
|
|
95
|
+
...Interactive.args,
|
|
96
|
+
state: FORM_FIELD_STATES.ERROR,
|
|
97
|
+
messageText: 'Error message text',
|
|
98
|
+
},
|
|
99
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { TextareaHTMLAttributes } from 'vue';
|
|
2
|
+
import { FormFieldProps, FormFieldSlots } from '../FormField';
|
|
3
|
+
|
|
4
|
+
export interface TextAreaFieldProps extends FormFieldProps {
|
|
5
|
+
/**
|
|
6
|
+
* Forwarded to the `<textarea>`, so this is also where `rows` (default 3), `placeholder` and
|
|
7
|
+
* `maxlength` go. Overrides the defaults the component applies.
|
|
8
|
+
*/
|
|
9
|
+
inputProps?: TextareaHTMLAttributes;
|
|
10
|
+
/**
|
|
11
|
+
* Grows the field with its content instead of showing the resize handle. `rows` stays the
|
|
12
|
+
* starting height, and the field grows up to 500px, after which it scrolls.
|
|
13
|
+
*
|
|
14
|
+
* @default false
|
|
15
|
+
*/
|
|
16
|
+
isAutoresizing?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* vee-validate field name. Requires a surrounding form context, e.g. from `useForm()`.
|
|
19
|
+
*/
|
|
20
|
+
name?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type TextAreaFieldSlots = Omit<FormFieldSlots, 'field'>;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<form-field v-bind="formFieldProps">
|
|
3
|
+
<template #field="{ fieldId, messageId }">
|
|
4
|
+
<div
|
|
5
|
+
:class="[
|
|
6
|
+
'ds-textAreaField',
|
|
7
|
+
{
|
|
8
|
+
'-ds-autoresizing': isAutoresizing,
|
|
9
|
+
'-ds-error': formFieldProps.state === FORM_FIELD_STATES.ERROR,
|
|
10
|
+
'-ds-disabled': isDisabled,
|
|
11
|
+
},
|
|
12
|
+
]"
|
|
13
|
+
>
|
|
14
|
+
<text-area-field-autosized
|
|
15
|
+
v-if="isAutoresizing"
|
|
16
|
+
v-bind="finalInputProps"
|
|
17
|
+
:id="fieldId"
|
|
18
|
+
v-model="value"
|
|
19
|
+
class="ds-textAreaField__input"
|
|
20
|
+
:aria-describedby="hasMessage ? messageId : undefined"
|
|
21
|
+
/>
|
|
22
|
+
<textarea
|
|
23
|
+
v-else
|
|
24
|
+
v-bind="finalInputProps"
|
|
25
|
+
:id="fieldId"
|
|
26
|
+
v-model="value"
|
|
27
|
+
class="ds-textAreaField__input"
|
|
28
|
+
:aria-describedby="hasMessage ? messageId : undefined"
|
|
29
|
+
/>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
32
|
+
<!-- begin: FormField slots -->
|
|
33
|
+
<template v-if="$slots.help" #help>
|
|
34
|
+
<slot name="help" />
|
|
35
|
+
</template>
|
|
36
|
+
<template v-if="$slots.labelAside" #labelAside>
|
|
37
|
+
<slot name="labelAside" />
|
|
38
|
+
</template>
|
|
39
|
+
<template v-if="$slots.message" #message="{ messageId }">
|
|
40
|
+
<slot name="message" :message-id="messageId" />
|
|
41
|
+
</template>
|
|
42
|
+
<template v-if="$slots.fieldStatus" #fieldStatus>
|
|
43
|
+
<slot name="fieldStatus" />
|
|
44
|
+
</template>
|
|
45
|
+
<!-- end: FormField slots -->
|
|
46
|
+
</form-field>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<style lang="scss" scoped>
|
|
50
|
+
@import '../../../../styles/settings/spacings';
|
|
51
|
+
@import '../../../../styles/settings/radiuses';
|
|
52
|
+
@import '../../../../styles/settings/borders';
|
|
53
|
+
@import '../../../../styles/settings/colors/tokens';
|
|
54
|
+
@import '../../../../styles/settings/typography/tokens';
|
|
55
|
+
@import '../../../../styles/settings/shadows';
|
|
56
|
+
|
|
57
|
+
$input-padding: $space-4;
|
|
58
|
+
$autoresizing-max-height: 500px;
|
|
59
|
+
|
|
60
|
+
.ds-textAreaField {
|
|
61
|
+
$root: &;
|
|
62
|
+
|
|
63
|
+
align-self: stretch;
|
|
64
|
+
background: $color-default-background;
|
|
65
|
+
border: $border-xs solid $color-neutral-border-strong;
|
|
66
|
+
border-radius: $radius-s;
|
|
67
|
+
box-shadow: $shadow-inset-m;
|
|
68
|
+
display: flex;
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
|
|
71
|
+
&__input {
|
|
72
|
+
@include formText-s-default-regular;
|
|
73
|
+
|
|
74
|
+
background: transparent;
|
|
75
|
+
border: none;
|
|
76
|
+
color: $color-neutral-text-heavy;
|
|
77
|
+
flex: 1;
|
|
78
|
+
min-height: 32px;
|
|
79
|
+
min-width: 0;
|
|
80
|
+
outline: none;
|
|
81
|
+
padding: $input-padding;
|
|
82
|
+
resize: vertical;
|
|
83
|
+
|
|
84
|
+
&::placeholder {
|
|
85
|
+
color: $color-neutral-text-weak;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
&:focus-within {
|
|
90
|
+
border-color: $color-primary-border;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
&.-ds-error:not(:focus-within) {
|
|
94
|
+
border-color: $color-danger-border;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
&:hover:not(.-ds-disabled):not(:focus-within) {
|
|
98
|
+
border-color: $color-neutral-border-strong-hovered;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
&.-ds-autoresizing {
|
|
102
|
+
#{$root}__input {
|
|
103
|
+
max-height: $autoresizing-max-height;
|
|
104
|
+
// The autosize writes an explicit `height`, which makes the browser ignore `rows`, so
|
|
105
|
+
// the starting height has to be restated here. Capped as well, since `min-height` would
|
|
106
|
+
// otherwise win over `max-height`.
|
|
107
|
+
min-height: min(
|
|
108
|
+
#{$autoresizing-max-height},
|
|
109
|
+
calc(v-bind(rows) * #{$typography-line-height-2xs} + #{$input-padding * 2})
|
|
110
|
+
);
|
|
111
|
+
overflow-y: auto;
|
|
112
|
+
resize: none;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
&.-ds-disabled {
|
|
117
|
+
border-color: $color-neutral-border-strong-disabled;
|
|
118
|
+
box-shadow: none;
|
|
119
|
+
|
|
120
|
+
#{$root}__input {
|
|
121
|
+
color: $color-neutral-text-heavy-disabled;
|
|
122
|
+
|
|
123
|
+
&::placeholder {
|
|
124
|
+
color: $color-neutral-text-weak-disabled;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
</style>
|
|
130
|
+
|
|
131
|
+
<script lang="ts" setup>
|
|
132
|
+
import { computed, TextareaHTMLAttributes } from 'vue';
|
|
133
|
+
import FormField, { FORM_FIELD_STATES, FormFieldProps } from '../FormField';
|
|
134
|
+
import { extractFormFieldProps } from '../FormField/FormField.utils';
|
|
135
|
+
import TextAreaFieldAutosized from './TextAreaFieldAutosized.vue';
|
|
136
|
+
import { TextAreaFieldProps, TextAreaFieldSlots } from './TextAreaField.types';
|
|
137
|
+
import { useTextFieldWithinForm } from '../../../composables/useTextFieldWithinForm';
|
|
138
|
+
|
|
139
|
+
const DEFAULT_ROWS = 3;
|
|
140
|
+
|
|
141
|
+
const { inputProps, isAutoresizing = false, name, ...rest } = defineProps<TextAreaFieldProps>();
|
|
142
|
+
|
|
143
|
+
const slots = defineSlots<TextAreaFieldSlots>();
|
|
144
|
+
const modelValue = defineModel<string>();
|
|
145
|
+
|
|
146
|
+
const {
|
|
147
|
+
value,
|
|
148
|
+
errors,
|
|
149
|
+
onInput: onFormFieldInput,
|
|
150
|
+
onBlur: onFormFieldBlur,
|
|
151
|
+
} = useTextFieldWithinForm(() => name, modelValue);
|
|
152
|
+
|
|
153
|
+
const { formFieldProps, isDisabled, hasMessage } = useFormFieldState();
|
|
154
|
+
const { finalInputProps } = useTextArea();
|
|
155
|
+
|
|
156
|
+
// Used by the `min-height` of the autoresizing field, see the style block.
|
|
157
|
+
const rows = computed(() => finalInputProps.value.rows);
|
|
158
|
+
|
|
159
|
+
function useFormFieldState() {
|
|
160
|
+
const formFieldProps = computed<FormFieldProps>(() =>
|
|
161
|
+
// this is needed to avoid passing modelValue to FormField as prop
|
|
162
|
+
extractFormFieldProps(rest, errors.value),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const isDisabled = computed(() => formFieldProps.value.state === FORM_FIELD_STATES.DISABLED);
|
|
166
|
+
|
|
167
|
+
// Avoids pointing `aria-describedby` at a message element that is never rendered.
|
|
168
|
+
const hasMessage = computed(() => !!(formFieldProps.value.messageText || slots.message));
|
|
169
|
+
|
|
170
|
+
return { formFieldProps, isDisabled, hasMessage };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function useTextArea() {
|
|
174
|
+
const finalInputProps = computed<TextareaHTMLAttributes>(() => ({
|
|
175
|
+
disabled: isDisabled.value,
|
|
176
|
+
rows: DEFAULT_ROWS,
|
|
177
|
+
...inputProps,
|
|
178
|
+
onInput: (event: Event) => {
|
|
179
|
+
onFormFieldInput();
|
|
180
|
+
inputProps?.onInput?.(event);
|
|
181
|
+
},
|
|
182
|
+
onBlur: (event: FocusEvent) => {
|
|
183
|
+
onFormFieldBlur(event);
|
|
184
|
+
inputProps?.onBlur?.(event);
|
|
185
|
+
},
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
return { finalInputProps };
|
|
189
|
+
}
|
|
190
|
+
</script>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<textarea ref="textarea" v-model="model" />
|
|
3
|
+
</template>
|
|
4
|
+
|
|
5
|
+
<script lang="ts" setup>
|
|
6
|
+
import { computed, useTemplateRef } from 'vue';
|
|
7
|
+
import { useTextareaAutosize } from '@vueuse/core';
|
|
8
|
+
|
|
9
|
+
const model = defineModel<string>();
|
|
10
|
+
|
|
11
|
+
const textareaEl = useTemplateRef<HTMLTextAreaElement>('textarea');
|
|
12
|
+
|
|
13
|
+
// Writes `height`, so the `min-height` / `max-height` from the CSS stay in charge of the bounds —
|
|
14
|
+
// `min-height` would otherwise win over `max-height` and let the field grow past the cap.
|
|
15
|
+
useTextareaAutosize({
|
|
16
|
+
element: textareaEl,
|
|
17
|
+
input: computed(() => model.value ?? ''),
|
|
18
|
+
});
|
|
19
|
+
</script>
|
|
@@ -19,6 +19,7 @@ interface createComponentOptions {
|
|
|
19
19
|
size?: LabelValueItemSize;
|
|
20
20
|
isLabelStrong?: boolean;
|
|
21
21
|
valueColor?: LabelValueItemValueColor;
|
|
22
|
+
slots?: Record<string, string>;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
describe('LabelValueItem', () => {
|
|
@@ -29,6 +30,7 @@ describe('LabelValueItem', () => {
|
|
|
29
30
|
size = LABEL_VALUE_ITEM_SIZES.MEDIUM,
|
|
30
31
|
isLabelStrong = false,
|
|
31
32
|
valueColor = LABEL_VALUE_ITEM_VALUE_COLORS.NEUTRAL,
|
|
33
|
+
slots = {},
|
|
32
34
|
}: createComponentOptions = {}) => {
|
|
33
35
|
return mount(LabelValueItem, {
|
|
34
36
|
props: {
|
|
@@ -39,6 +41,7 @@ describe('LabelValueItem', () => {
|
|
|
39
41
|
isLabelStrong,
|
|
40
42
|
valueColor,
|
|
41
43
|
},
|
|
44
|
+
slots,
|
|
42
45
|
});
|
|
43
46
|
};
|
|
44
47
|
|
|
@@ -122,4 +125,27 @@ describe('LabelValueItem', () => {
|
|
|
122
125
|
'-ds-color-primary',
|
|
123
126
|
);
|
|
124
127
|
});
|
|
128
|
+
|
|
129
|
+
it('does not render the accessory slot content when the slot is empty', () => {
|
|
130
|
+
const component = createComponent();
|
|
131
|
+
|
|
132
|
+
expect(component.find('.accessory-content').exists()).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('renders the accessory slot content', () => {
|
|
136
|
+
const component = createComponent({
|
|
137
|
+
slots: { accessory: '<span class="accessory-content">chip</span>' },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(component.find('.accessory-content').text()).toBe('chip');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('renders the accessory slot in the loading state', () => {
|
|
144
|
+
const component = createComponent({
|
|
145
|
+
state: LABEL_VALUE_ITEM_STATES.LOADING,
|
|
146
|
+
slots: { accessory: '<span class="accessory-content">chip</span>' },
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(component.find('.accessory-content').exists()).toBe(true);
|
|
150
|
+
});
|
|
125
151
|
});
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import LabelValueItem from './LabelValueItem.vue';
|
|
2
|
+
import SlotPlaceholder, {
|
|
3
|
+
SLOT_PLACEHOLDER_SIZES,
|
|
4
|
+
} from '../../../../../.storybook/SlotPlaceholder/SlotPlaceholder.vue';
|
|
2
5
|
|
|
3
6
|
import { Args, ArgTypes, Meta, StoryFn } from '@storybook/vue3';
|
|
4
7
|
import {
|
|
@@ -6,6 +9,7 @@ import {
|
|
|
6
9
|
LABEL_VALUE_ITEM_STATES,
|
|
7
10
|
LABEL_VALUE_ITEM_VALUE_COLORS,
|
|
8
11
|
} from './LabelValueItem.consts';
|
|
12
|
+
import { toRefs } from 'vue';
|
|
9
13
|
|
|
10
14
|
export default {
|
|
11
15
|
title: 'Components/LabelValue/LabelValueItem',
|
|
@@ -13,9 +17,9 @@ export default {
|
|
|
13
17
|
} as Meta<typeof LabelValueItem>;
|
|
14
18
|
|
|
15
19
|
const StoryTemplate: StoryFn<typeof LabelValueItem> = (args) => ({
|
|
16
|
-
components: { LabelValueItem },
|
|
20
|
+
components: { LabelValueItem, SlotPlaceholder },
|
|
17
21
|
setup() {
|
|
18
|
-
return args;
|
|
22
|
+
return { ...toRefs(args), SLOT_PLACEHOLDER_SIZES };
|
|
19
23
|
},
|
|
20
24
|
template: `<div style="height: 300px; width: 200px;">
|
|
21
25
|
<label-value-item
|
|
@@ -25,7 +29,12 @@ const StoryTemplate: StoryFn<typeof LabelValueItem> = (args) => ({
|
|
|
25
29
|
:size="size"
|
|
26
30
|
:is-label-strong="isLabelStrong"
|
|
27
31
|
:value-color="valueColor"
|
|
28
|
-
|
|
32
|
+
>
|
|
33
|
+
<template #accessory>
|
|
34
|
+
<div v-if="accessorySlot" v-html="accessorySlot" />
|
|
35
|
+
<slot-placeholder v-else :size="SLOT_PLACEHOLDER_SIZES.SMALL" label="accessory" />
|
|
36
|
+
</template>
|
|
37
|
+
</label-value-item>
|
|
29
38
|
</div>`,
|
|
30
39
|
});
|
|
31
40
|
|
|
@@ -38,6 +47,7 @@ const args = {
|
|
|
38
47
|
size: LABEL_VALUE_ITEM_SIZES.MEDIUM,
|
|
39
48
|
isLabelStrong: false,
|
|
40
49
|
valueColor: LABEL_VALUE_ITEM_VALUE_COLORS.NEUTRAL,
|
|
50
|
+
accessorySlot: 'accessory slot',
|
|
41
51
|
} as Args;
|
|
42
52
|
|
|
43
53
|
const argTypes = {
|
|
@@ -56,6 +66,9 @@ const argTypes = {
|
|
|
56
66
|
isLabelStrong: {
|
|
57
67
|
control: 'boolean',
|
|
58
68
|
},
|
|
69
|
+
accessorySlot: {
|
|
70
|
+
control: 'text',
|
|
71
|
+
},
|
|
59
72
|
} as ArgTypes;
|
|
60
73
|
|
|
61
74
|
Interactive.argTypes = argTypes;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
<div class="ds-labelValueItem__label" :class="{ '-ds-strong': isLabelStrong }">
|
|
4
4
|
{{ label }}
|
|
5
5
|
</div>
|
|
6
|
+
<slot name="accessory" />
|
|
6
7
|
<ds-icon
|
|
7
8
|
v-if="state === LABEL_VALUE_ITEM_STATES.LOADING"
|
|
8
9
|
:icon="ICONS.FAD_SPINNER_THIRD"
|
|
@@ -98,4 +99,8 @@ const {
|
|
|
98
99
|
isLabelStrong?: boolean;
|
|
99
100
|
valueColor?: LabelValueItemValueColor;
|
|
100
101
|
}>();
|
|
102
|
+
|
|
103
|
+
defineSlots<{
|
|
104
|
+
accessory?: () => any;
|
|
105
|
+
}>();
|
|
101
106
|
</script>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import { ComponentProps } from 'vue-component-type-helpers';
|
|
4
|
+
import LoadingBar from './LoadingBar.vue';
|
|
5
|
+
import DsContainerRibbon from '../ContainerRibbon/ContainerRibbon.vue';
|
|
6
|
+
import { CONTAINER_RIBBON_COLORS, CONTAINER_RIBBON_SIZES } from '../ContainerRibbon';
|
|
7
|
+
import { LOADING_BAR_COLORS, LOADING_BAR_SIZES } from './LoadingBar.consts';
|
|
8
|
+
|
|
9
|
+
const setup = (props: ComponentProps<typeof LoadingBar>) => mount(LoadingBar, { props });
|
|
10
|
+
|
|
11
|
+
const progressStyle = (wrapper: ReturnType<typeof setup>) =>
|
|
12
|
+
wrapper.find('.ds-loadingBar__progress').attributes('style') ?? '';
|
|
13
|
+
|
|
14
|
+
// jsdom neither runs animations nor applies the component's `<style scoped>` block, so nothing here
|
|
15
|
+
// observes the fill itself — that needs a real browser. What these do cover is the declarative wiring
|
|
16
|
+
// the animation depends on: the duration reaching the element, `'0'` opting out, and no JS being
|
|
17
|
+
// involved in starting it.
|
|
18
|
+
describe('LoadingBar', () => {
|
|
19
|
+
it('should render the track and the progress element', () => {
|
|
20
|
+
const wrapper = setup({ time: '5' });
|
|
21
|
+
|
|
22
|
+
expect(wrapper.find('.ds-loadingBar').exists()).toBe(true);
|
|
23
|
+
expect(wrapper.find('.ds-loadingBar__progress').exists()).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should set the animation duration from the time prop', () => {
|
|
27
|
+
const wrapper = setup({ time: '5' });
|
|
28
|
+
|
|
29
|
+
expect(progressStyle(wrapper)).toContain('animation-duration: 5s');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should not set a width on the progress element when animating', () => {
|
|
33
|
+
// the keyframes own the width — an inline one would override the animated value
|
|
34
|
+
const wrapper = setup({ time: '5' });
|
|
35
|
+
|
|
36
|
+
expect(progressStyle(wrapper)).not.toContain('width');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should show a full bar with no animation for time 0', () => {
|
|
40
|
+
const wrapper = setup({ time: '0' });
|
|
41
|
+
|
|
42
|
+
expect(progressStyle(wrapper)).toContain('animation: none');
|
|
43
|
+
expect(progressStyle(wrapper)).toContain('width: 100%');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// The fill used to be started from `onMounted`, which raced the browser's first style pass and
|
|
47
|
+
// left the bar snapped to full when it lost. The rendered output must be complete on mount.
|
|
48
|
+
it('should render its final styles synchronously on mount', () => {
|
|
49
|
+
const wrapper = setup({ time: '5' });
|
|
50
|
+
const styleOnMount = progressStyle(wrapper);
|
|
51
|
+
|
|
52
|
+
expect(styleOnMount).toBe(progressStyle(wrapper));
|
|
53
|
+
expect(styleOnMount).toContain('animation-duration: 5s');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it.each([
|
|
57
|
+
{ size: LOADING_BAR_SIZES.SMALL, expected: CONTAINER_RIBBON_SIZES.SMALL },
|
|
58
|
+
{ size: LOADING_BAR_SIZES.MEDIUM, expected: CONTAINER_RIBBON_SIZES.MEDIUM },
|
|
59
|
+
{ size: LOADING_BAR_SIZES.LARGE, expected: CONTAINER_RIBBON_SIZES.LARGE },
|
|
60
|
+
])('should pass ribbon size $expected for size $size', ({ size, expected }) => {
|
|
61
|
+
const wrapper = setup({ size, time: '5' });
|
|
62
|
+
|
|
63
|
+
expect(wrapper.findComponent(DsContainerRibbon).props('size')).toBe(expected);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it.each([
|
|
67
|
+
{
|
|
68
|
+
color: LOADING_BAR_COLORS.NEUTRAL_HEAVY,
|
|
69
|
+
expected: CONTAINER_RIBBON_COLORS.NEUTRAL_HEAVY,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
color: LOADING_BAR_COLORS.NEUTRAL_STRONG,
|
|
73
|
+
expected: CONTAINER_RIBBON_COLORS.NEUTRAL_STRONG,
|
|
74
|
+
},
|
|
75
|
+
{ color: LOADING_BAR_COLORS.SUCCESS, expected: CONTAINER_RIBBON_COLORS.SUCCESS },
|
|
76
|
+
{ color: LOADING_BAR_COLORS.WARNING, expected: CONTAINER_RIBBON_COLORS.WARNING },
|
|
77
|
+
{ color: LOADING_BAR_COLORS.DANGER, expected: CONTAINER_RIBBON_COLORS.DANGER },
|
|
78
|
+
{ color: LOADING_BAR_COLORS.INFO, expected: CONTAINER_RIBBON_COLORS.INFO },
|
|
79
|
+
])('should pass ribbon color $expected for color $color', ({ color, expected }) => {
|
|
80
|
+
const wrapper = setup({ color, time: '5' });
|
|
81
|
+
|
|
82
|
+
expect(wrapper.findComponent(DsContainerRibbon).props('color')).toBe(expected);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -13,12 +13,33 @@
|
|
|
13
13
|
<style scoped lang="scss">
|
|
14
14
|
@import '../../../styles/settings/colors/tokens';
|
|
15
15
|
|
|
16
|
+
// A keyframe animation carries its own start value, so — unlike a transition — it needs no resolved
|
|
17
|
+
// *before-change* style and runs from the first style pass the element gets. That is what makes it
|
|
18
|
+
// the right tool here: the bar is mounted and animated in the same breath, and the previous
|
|
19
|
+
// `width: 0 -> 100%` transition (flipped from JS just after mount) could lose the race against the
|
|
20
|
+
// browser's first style pass. When it did, the freshly inserted node went straight to its final
|
|
21
|
+
// width with nothing to interpolate from, and the bar snapped to full instead of filling.
|
|
22
|
+
// Only the duration is set from JS, inline. The animation *name* has to stay in this block so that
|
|
23
|
+
// `scoped` rewrites it together with the keyframes — Vue does not rewrite inline styles.
|
|
24
|
+
@keyframes ds-loading-bar-fill {
|
|
25
|
+
from {
|
|
26
|
+
width: 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
to {
|
|
30
|
+
width: 100%;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
16
34
|
.ds-loadingBar {
|
|
17
35
|
background-color: $color-neutral-background;
|
|
18
36
|
display: flex;
|
|
19
37
|
width: 100%;
|
|
20
38
|
|
|
21
39
|
&__progress {
|
|
40
|
+
animation-fill-mode: forwards;
|
|
41
|
+
animation-name: ds-loading-bar-fill;
|
|
42
|
+
animation-timing-function: linear;
|
|
22
43
|
overflow: hidden;
|
|
23
44
|
width: 0;
|
|
24
45
|
}
|
|
@@ -26,7 +47,7 @@
|
|
|
26
47
|
</style>
|
|
27
48
|
|
|
28
49
|
<script setup lang="ts">
|
|
29
|
-
import { computed
|
|
50
|
+
import { computed } from 'vue';
|
|
30
51
|
import DsContainerRibbon from '../ContainerRibbon/ContainerRibbon.vue';
|
|
31
52
|
import {
|
|
32
53
|
CONTAINER_RIBBON_COLORS,
|
|
@@ -52,8 +73,6 @@ const props = withDefaults(
|
|
|
52
73
|
},
|
|
53
74
|
);
|
|
54
75
|
|
|
55
|
-
const width = ref(0);
|
|
56
|
-
|
|
57
76
|
const ribbonSize = computed(() => {
|
|
58
77
|
const sizeMap = {
|
|
59
78
|
[LOADING_BAR_SIZES.SMALL]: CONTAINER_RIBBON_SIZES.SMALL,
|
|
@@ -75,20 +94,12 @@ const ribbonColor = computed(() => {
|
|
|
75
94
|
return colorMap[props.color] || CONTAINER_RIBBON_COLORS.NEUTRAL_HEAVY;
|
|
76
95
|
});
|
|
77
96
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
// without postponing the width change, the transition won't work,
|
|
89
|
-
// and the loading bar is 100% width right away
|
|
90
|
-
setTimeout(() => {
|
|
91
|
-
width.value = 100;
|
|
92
|
-
}, 0);
|
|
93
|
-
});
|
|
97
|
+
// The fill is driven entirely by the `ds-loading-bar-fill` keyframes in this component's styles, so
|
|
98
|
+
// there is nothing to start from JS — no mounted hook, no timer, no frame juggling. `'0'` opts out
|
|
99
|
+
// of the animation altogether and shows a full bar right away.
|
|
100
|
+
const loadingBarStyles = computed(() =>
|
|
101
|
+
props.time === '0'
|
|
102
|
+
? { animation: 'none', width: '100%' }
|
|
103
|
+
: { animationDuration: `${props.time}s` },
|
|
104
|
+
);
|
|
94
105
|
</script>
|
|
@@ -3,6 +3,7 @@ import { Value } from '../../utils/type.utils';
|
|
|
3
3
|
export const TOAST_SIZES = {
|
|
4
4
|
SMALL: 'small',
|
|
5
5
|
MEDIUM: 'medium',
|
|
6
|
+
LARGE: 'large',
|
|
6
7
|
} as const;
|
|
7
8
|
|
|
8
9
|
export type ToastSizes = Value<typeof TOAST_SIZES>;
|
|
@@ -14,7 +15,7 @@ export const TOAST_COLORS = {
|
|
|
14
15
|
WARNING: 'warning',
|
|
15
16
|
DANGER: 'danger',
|
|
16
17
|
INFO: 'info',
|
|
17
|
-
};
|
|
18
|
+
} as const;
|
|
18
19
|
|
|
19
20
|
export type ToastColors = Value<typeof TOAST_COLORS>;
|
|
20
21
|
|
|
@@ -22,6 +23,7 @@ export const TOAST_POSITIONS = {
|
|
|
22
23
|
LEFT: 'left',
|
|
23
24
|
CENTER: 'center',
|
|
24
25
|
RIGHT: 'right',
|
|
25
|
-
|
|
26
|
+
NONE: 'none',
|
|
27
|
+
} as const;
|
|
26
28
|
|
|
27
29
|
export type ToastPositions = Value<typeof TOAST_POSITIONS>;
|