@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.
Files changed (51) hide show
  1. package/dist/design-system.css +1 -1
  2. package/dist/design-system.js +11664 -11121
  3. package/dist/design-system.js.map +1 -1
  4. package/dist/lib/js/components/Form/PinInputField/PinInputField.consts.d.ts +14 -0
  5. package/dist/lib/js/components/Form/PinInputField/PinInputField.types.d.ts +12 -0
  6. package/dist/lib/js/components/Form/PinInputField/PinInputField.utils.d.ts +2 -0
  7. package/dist/lib/js/components/Form/PinInputField/PinInputField.vue.d.ts +91 -0
  8. package/dist/lib/js/components/Form/PinInputField/index.d.ts +5 -0
  9. package/dist/lib/js/components/Form/PinInputField/usePinInputFieldWithinForm.d.ts +8 -0
  10. package/dist/lib/js/components/Form/TextAreaField/TextAreaField.types.d.ts +9 -0
  11. package/dist/lib/js/components/Form/TextAreaField/TextAreaField.vue.d.ts +76 -0
  12. package/dist/lib/js/components/Form/TextAreaField/TextAreaFieldAutosized.vue.d.ts +6 -0
  13. package/dist/lib/js/components/Form/TextAreaField/index.d.ts +4 -0
  14. package/dist/lib/js/components/LabelValue/LabelValueItem/LabelValueItem.vue.d.ts +12 -1
  15. package/dist/lib/js/components/Toast/Toast.consts.d.ts +11 -9
  16. package/dist/lib/js/components/Toast/Toast.vue.d.ts +51 -433
  17. package/dist/lib/js/{components/Form/InputField/useInputFieldWithinForm.d.ts → composables/useTextFieldWithinForm.d.ts} +1 -1
  18. package/dist/lib/js/index.d.ts +4 -0
  19. package/lib/js/components/Form/CheckboxGroupField/CheckboxGroupField.spec.ts +15 -0
  20. package/lib/js/components/Form/CheckboxGroupField/CheckboxGroupField.vue +5 -2
  21. package/lib/js/components/Form/Form.stories.ts +23 -2
  22. package/lib/js/components/Form/InputField/InputField.spec.ts +11 -1
  23. package/lib/js/components/Form/InputField/InputField.vue +7 -4
  24. package/lib/js/components/Form/PinInputField/PinInputField.consts.ts +18 -0
  25. package/lib/js/components/Form/PinInputField/PinInputField.spec.ts +394 -0
  26. package/lib/js/components/Form/PinInputField/PinInputField.stories.ts +124 -0
  27. package/lib/js/components/Form/PinInputField/PinInputField.types.ts +42 -0
  28. package/lib/js/components/Form/PinInputField/PinInputField.utils.ts +5 -0
  29. package/lib/js/components/Form/PinInputField/PinInputField.vue +211 -0
  30. package/lib/js/components/Form/PinInputField/index.ts +6 -0
  31. package/lib/js/components/Form/PinInputField/usePinInputFieldWithinForm.ts +29 -0
  32. package/lib/js/components/Form/SelectField/SelectField.stories.ts +0 -5
  33. package/lib/js/components/Form/SelectField/useSelectFieldWithinForm.ts +1 -1
  34. package/lib/js/components/Form/TextAreaField/TextAreaField.spec.ts +341 -0
  35. package/lib/js/components/Form/TextAreaField/TextAreaField.stories.ts +99 -0
  36. package/lib/js/components/Form/TextAreaField/TextAreaField.types.ts +23 -0
  37. package/lib/js/components/Form/TextAreaField/TextAreaField.vue +190 -0
  38. package/lib/js/components/Form/TextAreaField/TextAreaFieldAutosized.vue +19 -0
  39. package/lib/js/components/Form/TextAreaField/index.ts +5 -0
  40. package/lib/js/components/LabelValue/LabelValueItem/LabelValueItem.spec.ts +26 -0
  41. package/lib/js/components/LabelValue/LabelValueItem/LabelValueItem.stories.ts +16 -3
  42. package/lib/js/components/LabelValue/LabelValueItem/LabelValueItem.vue +5 -0
  43. package/lib/js/components/LoadingBar/LoadingBar.spec.ts +84 -0
  44. package/lib/js/components/LoadingBar/LoadingBar.vue +30 -19
  45. package/lib/js/components/Toast/Toast.consts.ts +4 -2
  46. package/lib/js/components/Toast/Toast.spec.ts +177 -0
  47. package/lib/js/components/Toast/Toast.stories.ts +65 -31
  48. package/lib/js/components/Toast/Toast.vue +176 -195
  49. package/lib/js/{components/Form/InputField/useInputFieldWithinForm.ts → composables/useTextFieldWithinForm.ts} +7 -2
  50. package/lib/js/index.ts +4 -0
  51. 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>
@@ -0,0 +1,5 @@
1
+ import TextAreaField from './TextAreaField.vue';
2
+
3
+ export * from './TextAreaField.types';
4
+
5
+ export default TextAreaField;
@@ -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, onMounted, ref } from 'vue';
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
- const loadingBarStyles = computed(() => ({
79
- width: `${width.value}%`,
80
- transition: `width ${props.time}s linear`,
81
- }));
82
-
83
- onMounted(() => {
84
- if (props.time === '0') {
85
- width.value = 100;
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>;