@bethinkpl/design-system 42.0.0 → 43.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 (44) hide show
  1. package/dist/design-system.css +1 -1
  2. package/dist/design-system.js +16798 -13006
  3. package/dist/design-system.js.map +1 -1
  4. package/dist/lib/js/components/Cards/Card/Card.consts.d.ts +14 -0
  5. package/dist/lib/js/components/Cards/Card/Card.vue.d.ts +10 -5
  6. package/dist/lib/js/components/Cards/CardExpandable/CardExpandable.vue.d.ts +33 -15
  7. package/dist/lib/js/components/Form/InputField/useInputFieldWithinForm.d.ts +1 -1
  8. package/dist/lib/js/components/Form/SelectField/SelectField.types.d.ts +26 -0
  9. package/dist/lib/js/components/Form/SelectField/SelectField.utils.d.ts +7 -0
  10. package/dist/lib/js/components/Form/SelectField/SelectField.vue.d.ts +107 -0
  11. package/dist/lib/js/components/Form/SelectField/SelectFieldOption.vue.d.ts +19 -0
  12. package/dist/lib/js/components/Form/SelectField/index.d.ts +4 -0
  13. package/dist/lib/js/components/Form/SelectField/useSelectFieldWithinForm.d.ts +8 -0
  14. package/dist/lib/js/components/Pagination/Pagination.vue.d.ts +2 -0
  15. package/dist/lib/js/components/SelectList/SelectListItem/SelectListItem.vue.d.ts +2 -0
  16. package/dist/lib/js/components/SelectList/SelectListItemToggle/SelectListItemToggle.vue.d.ts +2 -0
  17. package/dist/lib/js/components/SurveyQuestions/SurveyQuestionOpenEnded/SurveyQuestionOpenEnded.vue.d.ts +33 -15
  18. package/dist/lib/js/components/SurveyQuestions/SurveyQuestionScale/SurveyQuestionScale.vue.d.ts +33 -15
  19. package/dist/lib/js/components/Toast/Toast.vue.d.ts +33 -15
  20. package/dist/lib/js/composables/useFormFieldWithinForm.d.ts +3 -3
  21. package/dist/lib/js/index.d.ts +2 -0
  22. package/lib/js/components/Cards/Card/Card.consts.ts +18 -0
  23. package/lib/js/components/Cards/Card/Card.spec.ts +159 -33
  24. package/lib/js/components/Cards/Card/Card.stories.ts +21 -2
  25. package/lib/js/components/Cards/Card/Card.vue +90 -23
  26. package/lib/js/components/Dropdown/Dropdown.vue +6 -10
  27. package/lib/js/components/Form/Form.stories.ts +15 -1
  28. package/lib/js/components/Form/SelectField/SelectField.spec.ts +495 -0
  29. package/lib/js/components/Form/SelectField/SelectField.stories.ts +287 -0
  30. package/lib/js/components/Form/SelectField/SelectField.types.ts +65 -0
  31. package/lib/js/components/Form/SelectField/SelectField.utils.ts +83 -0
  32. package/lib/js/components/Form/SelectField/SelectField.vue +342 -0
  33. package/lib/js/components/Form/SelectField/SelectFieldOption.vue +51 -0
  34. package/lib/js/components/Form/SelectField/index.ts +5 -0
  35. package/lib/js/components/Form/SelectField/useSelectFieldWithinForm.ts +27 -0
  36. package/lib/js/components/SelectList/SelectListItem/SelectListItem.spec.ts +77 -0
  37. package/lib/js/components/SelectList/SelectListItem/SelectListItem.vue +4 -1
  38. package/lib/js/composables/useFormFieldWithinForm.ts +4 -4
  39. package/lib/js/index.ts +2 -0
  40. package/lib/js/styles/Shadows/Shadows.stories.scss +16 -0
  41. package/lib/js/styles/Shadows/Shadows.stories.ts +4 -0
  42. package/lib/styles/mixins/_dropdown-surface.scss +24 -0
  43. package/lib/styles/settings/_shadows.scss +18 -0
  44. package/package.json +1 -1
@@ -0,0 +1,287 @@
1
+ import { Meta, StoryObj } from '@storybook/vue3';
2
+ import { computed } from 'vue';
3
+ import { withActions } from '@storybook/addon-actions/decorator';
4
+ import SelectField from './SelectField.vue';
5
+ import HelpButton from '../../Buttons/HelpButton/HelpButton.vue';
6
+ import Modal from '../../Modals/Modal';
7
+ import { ICONS } from '../../Icons/Icon';
8
+ import { args, argTypes } from '../FormField/FormField.stories.shared';
9
+ import { FORM_FIELD_STATES } from '../FormField/FormField.consts';
10
+ import { SelectFieldOption, SelectFieldOptionGroup } from './SelectField.types';
11
+
12
+ const OPTIONS: Array<SelectFieldOption> = [
13
+ { value: 'pl', label: 'Polska' },
14
+ { value: 'de', label: 'Niemcy' },
15
+ { value: 'cz', label: 'Czechy' },
16
+ { value: 'sk', label: 'Słowacja', isDisabled: true },
17
+ { value: 'jp', label: 'Japonia' },
18
+ ];
19
+
20
+ const GROUPED_OPTIONS: Array<SelectFieldOption | SelectFieldOptionGroup> = [
21
+ { value: 'all', label: 'Wszystkie kraje' },
22
+ {
23
+ label: 'Europa',
24
+ options: [
25
+ { value: 'pl', label: 'Polska' },
26
+ { value: 'de', label: 'Niemcy' },
27
+ { value: 'cz', label: 'Czechy' },
28
+ ],
29
+ },
30
+ {
31
+ label: 'Azja',
32
+ options: [
33
+ { value: 'jp', label: 'Japonia' },
34
+ { value: 'kr', label: 'Korea Południowa' },
35
+ ],
36
+ },
37
+ ];
38
+
39
+ const RICH_OPTIONS: Array<SelectFieldOption> = [
40
+ { value: 'draft', label: 'Szkic', eyebrowText: 'Status', iconLeft: ICONS.FA_PENCIL },
41
+ {
42
+ value: 'published',
43
+ label: 'Opublikowany',
44
+ eyebrowText: 'Status',
45
+ iconLeft: ICONS.FA_CHECK_SOLID,
46
+ },
47
+ { value: 'archived', label: 'Zarchiwizowany', eyebrowText: 'Status', iconLeft: ICONS.FA_BOX },
48
+ ];
49
+
50
+ const LONG_OPTIONS: Array<SelectFieldOption> = Array.from({ length: 30 }, (_, index) => ({
51
+ value: `option-${index + 1}`,
52
+ label: `Opcja numer ${index + 1}`,
53
+ }));
54
+
55
+ const LONG_LABEL_OPTIONS: Array<SelectFieldOption> = [
56
+ { value: 'anatomia', label: 'Anatomia prawidłowa człowieka z elementami histologii' },
57
+ { value: 'biochemia', label: 'Biochemia kliniczna i diagnostyka laboratoryjna' },
58
+ { value: 'farmakologia', label: 'Farmakologia z toksykologią dla kierunku lekarskiego' },
59
+ { value: 'short', label: 'Krótka nazwa' },
60
+ {
61
+ value: 'bardzo-dlugi',
62
+ label: 'Wyjątkowo długa nazwa kursu, która nie zmieści się w dostępnej szerokości okna i zostanie skrócona wielokropkiem',
63
+ },
64
+ ];
65
+
66
+ /**
67
+ * The `maxHeight` control is a text input, so a unitless `320` would reach the component as the
68
+ * string `'320'` and produce invalid CSS. Turn such a value into a number, which the component
69
+ * reads as pixels; anything carrying a unit is passed through.
70
+ */
71
+ function normalizeMaxHeight(maxHeight: string | number | undefined): string | number | undefined {
72
+ if (
73
+ typeof maxHeight === 'string' &&
74
+ maxHeight.trim() !== '' &&
75
+ !Number.isNaN(Number(maxHeight))
76
+ ) {
77
+ return Number(maxHeight);
78
+ }
79
+
80
+ return maxHeight;
81
+ }
82
+
83
+ const meta: Meta<typeof SelectField> = {
84
+ title: 'Components/Form/SelectField',
85
+ component: SelectField,
86
+ decorators: [withActions],
87
+ render: (args) => ({
88
+ components: { SelectField, HelpButton, Modal },
89
+ setup() {
90
+ const props = computed(() => {
91
+ const { help, labelAside, message, fieldStatus, ...rest } = args;
92
+
93
+ return { ...rest, maxHeight: normalizeMaxHeight(rest.maxHeight) };
94
+ });
95
+
96
+ return {
97
+ args,
98
+ props,
99
+ FORM_FIELD_STATES,
100
+ ICONS,
101
+ };
102
+ },
103
+ data: () => ({
104
+ value: undefined,
105
+ }),
106
+ template: `<SelectField v-bind="props" :left-icon="props.leftIcon ? ICONS[props.leftIcon] : null" v-model="value">
107
+ <template v-if="args.help" #help>
108
+ <HelpButton :is-disabled="props.state === FORM_FIELD_STATES.DISABLED" modal-title="Help modal title">
109
+ <template #modalContent>
110
+ Modal
111
+ </template>
112
+ </HelpButton>
113
+ </template>
114
+ <template #labelAside v-if="args.labelAside">
115
+ <div v-html="args.labelAside" />
116
+ </template>
117
+ <template #fieldStatus v-if="args.fieldStatus">
118
+ <div v-html="args.fieldStatus" />
119
+ </template>
120
+ <template #message v-if="args.message">
121
+ <div v-html="args.message" />
122
+ </template>
123
+ </SelectField>`,
124
+ }),
125
+ argTypes: {
126
+ ...argTypes,
127
+ options: {
128
+ control: 'object',
129
+ },
130
+ placeholder: {
131
+ control: 'text',
132
+ },
133
+ leftIcon: {
134
+ control: 'select',
135
+ options: [null, ...Object.keys(ICONS)],
136
+ },
137
+ ariaLabel: {
138
+ control: 'text',
139
+ },
140
+ maxHeight: {
141
+ control: 'text',
142
+ },
143
+ isGroupLabelUppercase: {
144
+ control: 'boolean',
145
+ },
146
+ autocomplete: {
147
+ control: 'text',
148
+ },
149
+ },
150
+ };
151
+ export default meta;
152
+
153
+ type Story = StoryObj<typeof SelectField>;
154
+
155
+ export const Interactive: Story = {
156
+ args: {
157
+ ...args,
158
+ options: OPTIONS,
159
+ placeholder: 'Wybierz kraj',
160
+ leftIcon: null,
161
+ isGroupLabelUppercase: true,
162
+ },
163
+ parameters: {
164
+ design: {
165
+ type: 'figma',
166
+ url: 'https://www.figma.com/design/izQdYyiBR1GQgFkaOIfIJI/LMS---DS-Components?node-id=13216-6915',
167
+ },
168
+ },
169
+ };
170
+
171
+ export const WithGroups: Story = {
172
+ ...Interactive,
173
+ args: {
174
+ ...Interactive.args,
175
+ options: GROUPED_OPTIONS,
176
+ },
177
+ parameters: {},
178
+ };
179
+
180
+ export const WithIconsAndEyebrow: Story = {
181
+ ...Interactive,
182
+ args: {
183
+ ...Interactive.args,
184
+ options: RICH_OPTIONS,
185
+ placeholder: 'Wybierz status',
186
+ },
187
+ parameters: {},
188
+ };
189
+
190
+ /**
191
+ * The field is constrained to 360px while the option labels are much longer. Two behaviours to
192
+ * check here:
193
+ *
194
+ * - the trigger keeps the field's width and ellipsizes the selected label;
195
+ * - the dropdown uses the trigger width only as a *minimum*, widening to fit the longest
196
+ * option, and stops at the space available in the viewport — where the last option, which is
197
+ * longer still, ellipsizes inside the list.
198
+ *
199
+ * Try it near the right edge of the canvas, and switch to a mobile viewport, to see the
200
+ * `max-width` clamp take over.
201
+ */
202
+ export const ConstrainedWidthWithLongLabels: Story = {
203
+ render: (args) => ({
204
+ components: { SelectField },
205
+ setup() {
206
+ return { args };
207
+ },
208
+ data: () => ({
209
+ // Preselected so the trigger's ellipsis is visible without opening the dropdown.
210
+ value: 'farmakologia',
211
+ }),
212
+ template: `
213
+ <div style="width: 360px;">
214
+ <SelectField v-bind="args" v-model="value" />
215
+ </div>
216
+ `,
217
+ }),
218
+ args: {
219
+ label: 'Kurs',
220
+ placeholder: 'Wybierz kurs',
221
+ options: LONG_LABEL_OPTIONS,
222
+ },
223
+ };
224
+
225
+ /** The dropdown caps at `maxHeight` (320px by default) and scrolls beyond it. */
226
+ export const LongList: Story = {
227
+ ...Interactive,
228
+ args: {
229
+ ...Interactive.args,
230
+ options: LONG_OPTIONS,
231
+ placeholder: 'Wybierz opcję',
232
+ },
233
+ parameters: {},
234
+ };
235
+
236
+ /**
237
+ * The field sits at the bottom of the viewport, so there is no room for the dropdown below the
238
+ * trigger — the popper flips and the list opens upwards. Uses the long list to make sure the
239
+ * dropdown is tall enough to force the flip on any viewport height.
240
+ */
241
+ export const OpeningUpwards: Story = {
242
+ parameters: {
243
+ layout: 'fullscreen',
244
+ },
245
+ render: (args) => ({
246
+ components: { SelectField },
247
+ setup() {
248
+ return { args };
249
+ },
250
+ data: () => ({
251
+ value: undefined,
252
+ }),
253
+ template: `
254
+ <div
255
+ style="display: flex; align-items: flex-end; box-sizing: border-box; min-height: 100vh; padding: 16px;"
256
+ >
257
+ <div style="width: 360px;">
258
+ <SelectField v-bind="args" v-model="value" />
259
+ </div>
260
+ </div>
261
+ `,
262
+ }),
263
+ args: {
264
+ label: 'Opcja',
265
+ placeholder: 'Wybierz opcję',
266
+ options: LONG_OPTIONS,
267
+ },
268
+ };
269
+
270
+ export const Disabled: Story = {
271
+ ...Interactive,
272
+ args: {
273
+ ...Interactive.args,
274
+ state: FORM_FIELD_STATES.DISABLED,
275
+ },
276
+ parameters: {},
277
+ };
278
+
279
+ export const Error: Story = {
280
+ ...Interactive,
281
+ args: {
282
+ ...Interactive.args,
283
+ state: FORM_FIELD_STATES.ERROR,
284
+ messageText: 'Error message text',
285
+ },
286
+ parameters: {},
287
+ };
@@ -0,0 +1,65 @@
1
+ import { FormFieldProps, FormFieldSlots } from '../FormField';
2
+ import { IconItem } from '../../Icons/Icon';
3
+
4
+ /**
5
+ * reka-ui reserves the empty string for clearing the selection, so `''` must never be
6
+ * used as an option value. `undefined` (or `''`/`null` coming from a form) renders the
7
+ * placeholder.
8
+ */
9
+ export type SelectFieldValue = string | number;
10
+
11
+ export interface SelectFieldOption {
12
+ value: SelectFieldValue;
13
+ label: string;
14
+ iconLeft?: IconItem | null;
15
+ eyebrowText?: string;
16
+ isDisabled?: boolean;
17
+ }
18
+
19
+ export interface SelectFieldOptionGroup {
20
+ /**
21
+ * When set, the group is rendered as an accessible `role="group"` with this text as its
22
+ * accessible name. Groups without a label render their options as direct children of the
23
+ * listbox instead.
24
+ */
25
+ label?: string;
26
+ options: Array<SelectFieldOption>;
27
+ }
28
+
29
+ export interface SelectFieldProps extends FormFieldProps {
30
+ options: Array<SelectFieldOption | SelectFieldOptionGroup>;
31
+ placeholder?: string;
32
+ leftIcon?: IconItem | null;
33
+ /**
34
+ * Used as `aria-label` when no visible `label` is provided, so the field always has an
35
+ * accessible name.
36
+ */
37
+ ariaLabel?: string;
38
+ /**
39
+ * Caps the dropdown height. A `number` is treated as pixels; a string is used as-is, so
40
+ * any CSS length works. The dropdown never exceeds the space available in the viewport
41
+ * regardless of this value.
42
+ *
43
+ * @default '320px'
44
+ */
45
+ maxHeight?: string | number;
46
+ /**
47
+ * vee-validate field name. Requires a surrounding `useForm()` context.
48
+ */
49
+ name?: string;
50
+ /**
51
+ * Native `autocomplete` attribute of the visually hidden `<select>` reka renders for form
52
+ * autofill (e.g. `'country'`, `'off'`). That element only exists when the field is inside a
53
+ * `<form>`, so outside one the prop has no effect.
54
+ */
55
+ autocomplete?: string;
56
+ /**
57
+ * Renders the labels of grouped options in the uppercase section-title style. Set to `false`
58
+ * for a lighter, sentence-case label.
59
+ *
60
+ * @default true
61
+ */
62
+ isGroupLabelUppercase?: boolean;
63
+ }
64
+
65
+ export type SelectFieldSlots = Omit<FormFieldSlots, 'field'>;
@@ -0,0 +1,83 @@
1
+ import { SelectFieldOption, SelectFieldOptionGroup } from './SelectField.types';
2
+
3
+ export function isSelectFieldOptionGroup(
4
+ option: SelectFieldOption | SelectFieldOptionGroup,
5
+ ): option is SelectFieldOptionGroup {
6
+ return 'options' in option;
7
+ }
8
+
9
+ /**
10
+ * Normalises the mixed options prop into a list of groups, preserving order.
11
+ * Consecutive ungrouped options collapse into a single group without a label — such a group
12
+ * renders its options as direct children of the listbox, because an unlabelled reka-ui
13
+ * `SelectGroup` emits `role="group"` with a dangling `aria-labelledby`.
14
+ *
15
+ * Never mutates the `options` prop: synthesised groups own their arrays, and groups passed in
16
+ * by the consumer are copied before anything is appended to them.
17
+ */
18
+ export function normalizeOptions(
19
+ options: Array<SelectFieldOption | SelectFieldOptionGroup>,
20
+ ): Array<SelectFieldOptionGroup> {
21
+ const groups: Array<SelectFieldOptionGroup> = [];
22
+ // Only a group synthesised here may absorb following ungrouped options.
23
+ let openGroup: SelectFieldOptionGroup | null = null;
24
+
25
+ options.forEach((option) => {
26
+ if (isSelectFieldOptionGroup(option)) {
27
+ groups.push({ ...option, options: [...option.options] });
28
+ openGroup = null;
29
+
30
+ return;
31
+ }
32
+
33
+ if (openGroup) {
34
+ openGroup.options.push(option);
35
+
36
+ return;
37
+ }
38
+
39
+ openGroup = { options: [option] };
40
+ groups.push(openGroup);
41
+ });
42
+
43
+ return groups;
44
+ }
45
+
46
+ /**
47
+ * reka-ui throws an opaque error when a `SelectItem` receives an empty string value.
48
+ * Fail earlier with a message that names the component.
49
+ */
50
+ export function assertOptionValues(groups: Array<SelectFieldOptionGroup>): void {
51
+ const hasEmptyValue = groups.some(({ options }) => options.some(({ value }) => value === ''));
52
+
53
+ if (hasEmptyValue) {
54
+ throw new Error(
55
+ '[SelectField]: An option value must not be an empty string, because an empty string clears the selection.',
56
+ );
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Resolves a CSS length prop: a `number` is treated as pixels, a string is passed through.
62
+ * Returns `undefined` for anything that must not reach the style attribute, so `0` still
63
+ * produces a usable `0px`.
64
+ */
65
+ export function toCssLength(value: string | number | undefined): string | undefined {
66
+ if (typeof value === 'number') {
67
+ return `${value}px`;
68
+ }
69
+
70
+ return value || undefined;
71
+ }
72
+
73
+ /**
74
+ * reka-ui names an option after its `SelectItemText`, which only wraps the label, so the
75
+ * eyebrow text would be dropped from the accessible name. Prepend it so assistive technology
76
+ * announces everything that is visible.
77
+ *
78
+ * Deliberately *not* used for reka-ui's `textValue`: typeahead matches on a prefix, and users
79
+ * type the label rather than the eyebrow.
80
+ */
81
+ export function getOptionAccessibleName(option: SelectFieldOption): string {
82
+ return option.eyebrowText ? `${option.eyebrowText} ${option.label}` : option.label;
83
+ }