@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,495 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { DOMWrapper, enableAutoUnmount, mount, VueWrapper } from '@vue/test-utils';
3
+ import { ComputedRef, nextTick, Ref } from 'vue';
4
+ import { ComponentProps } from 'vue-component-type-helpers';
5
+ import { FormMeta, useForm } from 'vee-validate';
6
+ import SelectField from './SelectField.vue';
7
+ import Icon, { ICONS } from '../../Icons/Icon';
8
+ import { FORM_FIELD_STATES } from '../FormField';
9
+ import { SelectFieldOption, SelectFieldOptionGroup } from './SelectField.types';
10
+ import { waitForExpectShort } from '../../../tests/helpers';
11
+
12
+ const OPTIONS: Array<SelectFieldOption> = [
13
+ { value: 'pl', label: 'Poland' },
14
+ { value: 'de', label: 'Germany' },
15
+ { value: 'jp', label: 'Japan', isDisabled: true },
16
+ ];
17
+
18
+ const GROUPED_OPTIONS: Array<SelectFieldOption | SelectFieldOptionGroup> = [
19
+ { value: 'all', label: 'All countries' },
20
+ { label: 'Europe', options: [{ value: 'pl', label: 'Poland' }] },
21
+ { label: 'Asia', options: [{ value: 'jp', label: 'Japan' }] },
22
+ ];
23
+
24
+ enableAutoUnmount(afterEach);
25
+
26
+ function setup(props?: Partial<ComponentProps<typeof SelectField>>) {
27
+ return mount(SelectField, {
28
+ props: { options: OPTIONS, ...props } as ComponentProps<typeof SelectField>,
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Asserts that mounting throws. A throw inside `setup()` leaves the component without a render
34
+ * function, so Vue emits an extra "Invalid vnode type" warning on top of the error under test —
35
+ * `warnHandler` swallows it so the run stays clean without hiding warnings elsewhere.
36
+ */
37
+ function expectMountToThrow(props: Partial<ComponentProps<typeof SelectField>>, expected?: RegExp) {
38
+ expect(() =>
39
+ mount(SelectField, {
40
+ props: { options: OPTIONS, ...props } as ComponentProps<typeof SelectField>,
41
+ global: { config: { warnHandler: () => {} } },
42
+ }),
43
+ ).toThrowError(expected);
44
+ }
45
+
46
+ /**
47
+ * The dropdown is portalled to `<body>`, so it sits outside the wrapper's DOM subtree and
48
+ * `wrapper.find*` cannot reach it. A body-scoped `DOMWrapper` keeps the Test Utils API while
49
+ * querying the portal.
50
+ *
51
+ * `wrapper.findAllComponents(SelectFieldOption)` also crosses the Teleport, but it walks the
52
+ * component tree, so it matches even while the listbox is closed — reka mounts the content into
53
+ * an offscreen fragment — which would defeat the closed-state assertions below.
54
+ */
55
+ const portal = () => new DOMWrapper(document.body);
56
+
57
+ const optionElements = () => portal().findAll('[role="option"]');
58
+
59
+ /** The trigger opens on `pointerdown`, which needs pointer-capture APIs jsdom lacks. */
60
+ async function open(wrapper: VueWrapper) {
61
+ await wrapper.find('button').trigger('keydown', { key: 'ArrowDown' });
62
+ await nextTick();
63
+ await nextTick();
64
+ }
65
+
66
+ /** reka-ui dismisses via `onKeyStroke`, which listens on `window`. */
67
+ async function close() {
68
+ // Triggered from <body> so the event bubbles up to that window listener.
69
+ await portal().trigger('keydown', { key: 'Escape' });
70
+ await nextTick();
71
+ }
72
+
73
+ describe('SelectField', () => {
74
+ describe('closed', () => {
75
+ it('should render the label and the placeholder when no value is selected', () => {
76
+ const wrapper = setup({ label: 'Label', placeholder: 'Select placeholder' });
77
+
78
+ expect(wrapper.find('.ds-selectField__trigger').exists()).toBe(true);
79
+ expect(wrapper.find('label').text()).toContain('Label');
80
+ expect(wrapper.find('.ds-selectField__value').text()).toBe('Select placeholder');
81
+ expect(
82
+ wrapper.find('.ds-selectField__value').attributes('data-placeholder'),
83
+ ).toBeDefined();
84
+ });
85
+
86
+ it('should render the selected option label', async () => {
87
+ const wrapper = setup({ label: 'Label', modelValue: 'de' });
88
+
89
+ // Options register with reka once the offscreen content fragment is mounted.
90
+ await nextTick();
91
+
92
+ await waitForExpectShort(() => {
93
+ expect(wrapper.find('.ds-selectField__value').text()).toBe('Germany');
94
+ });
95
+ expect(
96
+ wrapper.find('.ds-selectField__value').attributes('data-placeholder'),
97
+ ).toBeUndefined();
98
+ });
99
+
100
+ it('should render the left icon when provided', () => {
101
+ const wrapper = setup({ label: 'Label', leftIcon: ICONS.FA_TAG });
102
+
103
+ expect(
104
+ wrapper.findComponent<typeof Icon>('.ds-selectField__leftIcon').props().icon,
105
+ ).toEqual(ICONS.FA_TAG);
106
+ });
107
+
108
+ it('should not render the left icon by default', () => {
109
+ const wrapper = setup({ label: 'Label' });
110
+
111
+ expect(wrapper.find('.ds-selectField__leftIcon').exists()).toBe(false);
112
+ });
113
+
114
+ it('should render a combobox trigger associated with the label', () => {
115
+ const wrapper = setup({ label: 'Label' });
116
+
117
+ const trigger = wrapper.find('button');
118
+ const fieldId = wrapper.find('label').attributes('for');
119
+
120
+ expect(trigger.attributes('role')).toBe('combobox');
121
+ expect(trigger.attributes('id')).toBe(fieldId);
122
+ expect(trigger.attributes('aria-expanded')).toBe('false');
123
+ });
124
+
125
+ it('should not render options while closed', () => {
126
+ setup();
127
+
128
+ expect(optionElements()).toHaveLength(0);
129
+ });
130
+
131
+ it.each([
132
+ { state: FORM_FIELD_STATES.DISABLED, expectedClass: '-ds-disabled' },
133
+ { state: FORM_FIELD_STATES.ERROR, expectedClass: '-ds-error' },
134
+ ])('should handle state: $state', ({ state, expectedClass }) => {
135
+ const wrapper = setup({ state });
136
+
137
+ expect(wrapper.find('.ds-selectField__trigger').classes()).toContain(expectedClass);
138
+ });
139
+
140
+ it('should disable the trigger when state is DISABLED', () => {
141
+ const wrapper = setup({ state: FORM_FIELD_STATES.DISABLED });
142
+
143
+ expect(wrapper.find('button').attributes('disabled')).toBeDefined();
144
+ });
145
+
146
+ it('should throw when an option value is an empty string', () => {
147
+ expectMountToThrow(
148
+ { options: [{ value: '', label: 'Empty' }] },
149
+ /must not be an empty string/,
150
+ );
151
+ });
152
+ });
153
+
154
+ describe('accessible name and description', () => {
155
+ it('should set aria-describedby only when a message is rendered', () => {
156
+ const withMessage = setup({ label: 'Label', messageText: 'Message text' });
157
+ const messageId = `${withMessage.find('label').attributes('for')}-message`;
158
+
159
+ expect(withMessage.find('button').attributes('aria-describedby')).toBe(messageId);
160
+ expect(withMessage.find(`#${messageId}`).exists()).toBe(true);
161
+
162
+ const withoutMessage = setup({ label: 'Label' });
163
+
164
+ expect(withoutMessage.find('button').attributes('aria-describedby')).toBeUndefined();
165
+ });
166
+
167
+ it('should use ariaLabel as the accessible name when no visible label is given', () => {
168
+ const wrapper = setup({ ariaLabel: 'Country' });
169
+
170
+ expect(wrapper.find('label').exists()).toBe(false);
171
+ expect(wrapper.find('button').attributes('aria-label')).toBe('Country');
172
+ });
173
+
174
+ it('should prefer the visible label over ariaLabel', () => {
175
+ const wrapper = setup({ label: 'Label', ariaLabel: 'Country' });
176
+
177
+ expect(wrapper.find('button').attributes('aria-label')).toBeUndefined();
178
+ });
179
+
180
+ it('should mark the trigger as required when hasRequiredIndicator is set', () => {
181
+ const wrapper = setup({ label: 'Label', hasRequiredIndicator: true });
182
+
183
+ expect(wrapper.find('button').attributes('aria-required')).toBe('true');
184
+ });
185
+ });
186
+
187
+ describe('open', () => {
188
+ it('should open on ArrowDown and render one option per entry', async () => {
189
+ const wrapper = setup({ label: 'Label' });
190
+
191
+ await open(wrapper);
192
+
193
+ expect(wrapper.find('button').attributes('aria-expanded')).toBe('true');
194
+ expect(optionElements()).toHaveLength(OPTIONS.length);
195
+ });
196
+
197
+ it('should expose the styling hook on the portalled content', async () => {
198
+ const wrapper = setup({ label: 'Label' });
199
+
200
+ await open(wrapper);
201
+
202
+ const content = portal().find('.ds-selectField__content');
203
+
204
+ expect(content.exists()).toBe(true);
205
+ // Vue never forwards this component's scope id to SelectContent's element (it renders
206
+ // through Presence's slot), which is why `.ds-selectField__content` is styled from the
207
+ // unscoped block. Keep that in mind before moving the rule into the scoped one.
208
+ expect(content.attributes('role')).toBe('listbox');
209
+ });
210
+
211
+ it.each([
212
+ { maxHeight: 240, expected: '240px' },
213
+ { maxHeight: '50vh', expected: '50vh' },
214
+ { maxHeight: undefined, expected: '' },
215
+ ])(
216
+ 'should resolve the max-height custom property for maxHeight: $maxHeight',
217
+ async ({ maxHeight, expected }) => {
218
+ const wrapper = setup({ label: 'Label', maxHeight });
219
+
220
+ await open(wrapper);
221
+
222
+ const content = portal().find<HTMLElement>('.ds-selectField__content');
223
+
224
+ expect(content.element.style.getPropertyValue('--select-field-max-height')).toBe(
225
+ expected,
226
+ );
227
+ },
228
+ );
229
+
230
+ it('should match typeahead on the label only, ignoring the eyebrow text', async () => {
231
+ // 'Status' would win a prefix match on 's' if the eyebrow were part of `textValue`,
232
+ // and 'z' would then match nothing.
233
+ const wrapper = setup({
234
+ label: 'Label',
235
+ options: [
236
+ { value: 'draft', label: 'Draft', eyebrowText: 'Status' },
237
+ { value: 'archived', label: 'Zarchiwizowany', eyebrowText: 'Status' },
238
+ ],
239
+ });
240
+
241
+ await open(wrapper);
242
+
243
+ await portal().find('.ds-selectField__content').trigger('keydown', { key: 'z' });
244
+
245
+ // Must be the option element itself: asserting on textContent would also pass for the
246
+ // listbox, whose text concatenates every option.
247
+ expect(document.activeElement).toBe(optionElements()[1].element);
248
+ });
249
+
250
+ it('should include the eyebrow text in the accessible name', async () => {
251
+ const wrapper = setup({
252
+ label: 'Label',
253
+ options: [{ value: 'draft', label: 'Draft', eyebrowText: 'Status' }],
254
+ });
255
+
256
+ await open(wrapper);
257
+
258
+ // The eyebrow sits outside SelectItemText, so it would otherwise be dropped.
259
+ expect(optionElements()[0].attributes('aria-label')).toBe('Status Draft');
260
+ });
261
+
262
+ it('should name each option via an aria-labelledby that resolves', async () => {
263
+ const wrapper = setup({ label: 'Label' });
264
+
265
+ await open(wrapper);
266
+
267
+ optionElements().forEach((option, index) => {
268
+ const labelledBy = option.attributes('aria-labelledby');
269
+
270
+ expect(labelledBy).toBeTruthy();
271
+
272
+ // Resolving an IDREF is document-scoped by definition, and `getElementById` avoids
273
+ // having to escape reka's generated ids into a selector.
274
+ const nameElement = document.getElementById(labelledBy as string);
275
+
276
+ expect(nameElement).not.toBeNull();
277
+ expect(nameElement?.textContent?.trim()).toBe(OPTIONS[index].label);
278
+ });
279
+ });
280
+
281
+ it('should mark a disabled option as aria-disabled', async () => {
282
+ const wrapper = setup({ label: 'Label' });
283
+
284
+ await open(wrapper);
285
+
286
+ expect(optionElements()[2].attributes('aria-disabled')).toBe('true');
287
+ });
288
+
289
+ it('should mark the selected option as checked', async () => {
290
+ const wrapper = setup({ label: 'Label', modelValue: 'de' });
291
+
292
+ await open(wrapper);
293
+
294
+ const [poland, germany] = optionElements();
295
+
296
+ expect(germany.attributes('aria-selected')).toBe('true');
297
+ expect(germany.attributes('data-state')).toBe('checked');
298
+ expect(poland.attributes('aria-selected')).toBe('false');
299
+ });
300
+
301
+ it('should emit update:modelValue when an option is selected with Enter', async () => {
302
+ const onUpdate = vi.fn();
303
+ const wrapper = setup({ label: 'Label', 'onUpdate:modelValue': onUpdate });
304
+
305
+ await open(wrapper);
306
+
307
+ await optionElements()[1].trigger('keydown', { key: 'Enter' });
308
+
309
+ await waitForExpectShort(() => {
310
+ expect(onUpdate).toHaveBeenCalledWith('de');
311
+ });
312
+ });
313
+
314
+ it('should emit open-change when opening and closing', async () => {
315
+ const wrapper = setup({ label: 'Label' });
316
+
317
+ await open(wrapper);
318
+
319
+ expect(wrapper.emitted('open-change')).toEqual([[true]]);
320
+
321
+ await close();
322
+
323
+ expect(wrapper.find('button').attributes('aria-expanded')).toBe('false');
324
+ expect(wrapper.emitted('open-change')).toEqual([[true], [false]]);
325
+ });
326
+ });
327
+
328
+ describe('grouped options', () => {
329
+ it('should render a named group per labelled group and none for ungrouped options', async () => {
330
+ const wrapper = setup({ label: 'Label', options: GROUPED_OPTIONS });
331
+
332
+ await open(wrapper);
333
+
334
+ const groups = portal().findAll('[role="group"]');
335
+
336
+ // Only 'Europe' and 'Asia' are groups; 'All countries' is ungrouped.
337
+ expect(groups).toHaveLength(2);
338
+
339
+ groups.forEach((group) => {
340
+ const labelledBy = group.attributes('aria-labelledby');
341
+
342
+ expect(labelledBy).toBeTruthy();
343
+ expect(document.getElementById(labelledBy as string)).not.toBeNull();
344
+ });
345
+
346
+ expect(
347
+ groups.map((group) => {
348
+ const labelledBy = group.attributes('aria-labelledby') as string;
349
+
350
+ return document.getElementById(labelledBy)?.textContent?.trim();
351
+ }),
352
+ ).toEqual(['Europe', 'Asia']);
353
+
354
+ expect(optionElements()).toHaveLength(3);
355
+ });
356
+
357
+ it('should render the group labels in uppercase by default', async () => {
358
+ const wrapper = setup({ label: 'Label', options: GROUPED_OPTIONS });
359
+
360
+ await open(wrapper);
361
+
362
+ const titles = portal().findAll('.ds-selectListSectionTitle');
363
+
364
+ expect(titles).toHaveLength(2);
365
+ titles.forEach((title) => {
366
+ expect(title.classes()).toContain('-ds-isUppercase');
367
+ });
368
+ });
369
+
370
+ it('should not render the group labels in uppercase when isGroupLabelUppercase is false', async () => {
371
+ const wrapper = setup({
372
+ label: 'Label',
373
+ options: GROUPED_OPTIONS,
374
+ isGroupLabelUppercase: false,
375
+ });
376
+
377
+ await open(wrapper);
378
+
379
+ const titles = portal().findAll('.ds-selectListSectionTitle');
380
+
381
+ expect(titles).toHaveLength(2);
382
+ titles.forEach((title) => {
383
+ expect(title.classes()).not.toContain('-ds-isUppercase');
384
+ });
385
+ });
386
+
387
+ it('should hide the separators between groups from assistive technology', async () => {
388
+ const wrapper = setup({ label: 'Label', options: GROUPED_OPTIONS });
389
+
390
+ await open(wrapper);
391
+
392
+ const separators = portal().findAll('.ds-selectListItemDivider');
393
+
394
+ // One before 'Europe' and one before 'Asia'.
395
+ expect(separators).toHaveLength(2);
396
+ separators.forEach((separator) => {
397
+ expect(separator.attributes('aria-hidden')).toBe('true');
398
+ });
399
+ });
400
+ });
401
+
402
+ describe('autocomplete', () => {
403
+ /**
404
+ * reka renders the visually hidden native `<select>` that carries the autofill
405
+ * attributes only when the trigger sits inside a `<form>`.
406
+ */
407
+ function setupWithinForm(props?: Partial<ComponentProps<typeof SelectField>>) {
408
+ return mount({
409
+ template: '<form><SelectField v-bind="props" /></form>',
410
+ components: { SelectField },
411
+ setup: () => ({ props: { options: OPTIONS, ...props } }),
412
+ });
413
+ }
414
+
415
+ it('should forward autocomplete to the hidden native select', async () => {
416
+ const wrapper = setupWithinForm({ label: 'Label', autocomplete: 'country' });
417
+
418
+ await nextTick();
419
+
420
+ expect(wrapper.find('select').attributes('autocomplete')).toBe('country');
421
+ });
422
+
423
+ it('should leave the hidden native select without autocomplete by default', async () => {
424
+ const wrapper = setupWithinForm({ label: 'Label' });
425
+
426
+ await nextTick();
427
+
428
+ expect(wrapper.find('select').attributes('autocomplete')).toBeUndefined();
429
+ });
430
+ });
431
+
432
+ describe('with vee-validate', () => {
433
+ const fieldName = 'country';
434
+
435
+ function setupWithForm(props?: Partial<ComponentProps<typeof SelectField>>) {
436
+ let errorsRef: ComputedRef<Partial<Record<'country', string | undefined>>> | undefined;
437
+ let metaRef: Ref<FormMeta<{ country: string }>> | undefined;
438
+
439
+ const FormComponent = {
440
+ template: `
441
+ <form>
442
+ <SelectField v-bind="props" :name="name" :options="options" />
443
+ </form>
444
+ `,
445
+ components: { SelectField },
446
+ setup() {
447
+ const { errors, meta } = useForm({
448
+ initialValues: { [fieldName]: '' },
449
+ validationSchema: {
450
+ country: (val: string) => (val ? true : 'Country is required'),
451
+ },
452
+ });
453
+
454
+ errorsRef = errors;
455
+ metaRef = meta;
456
+
457
+ return { name: fieldName, options: OPTIONS, props };
458
+ },
459
+ };
460
+
461
+ return { wrapper: mount(FormComponent), errorsRef, metaRef };
462
+ }
463
+
464
+ it('should mark the form as touched when the listbox closes', async () => {
465
+ const { wrapper, metaRef } = setupWithForm();
466
+
467
+ expect(metaRef?.value.touched).toBe(false);
468
+
469
+ await open(wrapper);
470
+ await close();
471
+
472
+ await waitForExpectShort(() => {
473
+ expect(metaRef?.value.touched).toBe(true);
474
+ });
475
+ });
476
+
477
+ it('should surface a validation error as the field message', async () => {
478
+ const { wrapper, errorsRef } = setupWithForm();
479
+
480
+ await open(wrapper);
481
+ await close();
482
+
483
+ await waitForExpectShort(() => {
484
+ expect(errorsRef?.value?.country).toBe('Country is required');
485
+ });
486
+
487
+ expect(wrapper.find('.ds-selectField__trigger').classes()).toContain('-ds-error');
488
+ expect(wrapper.find('.ds-formFieldMessage').text()).toBe('Country is required');
489
+ });
490
+
491
+ it('should throw when name is used outside a form context', () => {
492
+ expectMountToThrow({ name: 'country' });
493
+ });
494
+ });
495
+ });