@adobe-commerce/elsie 1.5.0-beta4 → 1.5.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.
@@ -0,0 +1,457 @@
1
+ /********************************************************************
2
+ * Copyright 2025 Adobe
3
+ * All Rights Reserved.
4
+ *
5
+ * NOTICE: Adobe permits you to use, modify, and distribute this
6
+ * file in accordance with the terms of the Adobe license agreement
7
+ * accompanying it.
8
+ *******************************************************************/
9
+
10
+ import type { Meta, StoryObj } from '@storybook/preact';
11
+ import { expect, userEvent, within } from '@storybook/test';
12
+ import {
13
+ MultiSelect,
14
+ type MultiSelectProps,
15
+ } from '@adobe-commerce/elsie/components/MultiSelect';
16
+ import { useState, useEffect } from 'preact/hooks';
17
+
18
+ export default {
19
+ title: 'Components/MultiSelect',
20
+ component: MultiSelect,
21
+ parameters: {
22
+ layout: 'fullscreen',
23
+ docs: {
24
+ description: {
25
+ component: `
26
+ The MultiSelect component allows users to select multiple options from a dropdown list.
27
+ It includes features like search filtering, keyboard navigation, bulk selection/deselection,
28
+ and comprehensive accessibility support.
29
+
30
+ ## Key Features
31
+ - **Keyboard Navigation**: Full arrow key navigation with Enter to select
32
+ - **Search Filtering**: Type to filter available options
33
+ - **Bulk Actions**: Select All and Deselect All buttons
34
+ - **Accessibility**: Screen reader announcements, proper ARIA attributes
35
+ - **Floating Labels**: Optional floating label support
36
+ - **Error/Success States**: Visual feedback for form validation
37
+ `,
38
+ },
39
+ },
40
+ a11y: {
41
+ config: {
42
+ rules: [{ id: 'color-contrast', enabled: false }],
43
+ },
44
+ },
45
+ },
46
+ args: {
47
+ options: [],
48
+ value: [],
49
+ },
50
+ argTypes: {
51
+ onChange: { action: 'changed' },
52
+ options: {
53
+ description: 'Array of selectable options',
54
+ control: { type: 'object' },
55
+ },
56
+ value: {
57
+ description: 'Currently selected values',
58
+ control: { type: 'object' },
59
+ },
60
+ placeholder: {
61
+ description: 'Placeholder text shown when no options are selected',
62
+ control: { type: 'text' },
63
+ },
64
+ floatingLabel: {
65
+ description: 'Floating label text',
66
+ control: { type: 'text' },
67
+ },
68
+ disabled: {
69
+ description: 'Disable the entire component',
70
+ control: { type: 'boolean' },
71
+ },
72
+ error: {
73
+ description: 'Show error state styling',
74
+ control: { type: 'boolean' },
75
+ },
76
+ success: {
77
+ description: 'Show success state styling',
78
+ control: { type: 'boolean' },
79
+ },
80
+ },
81
+ } as Meta<MultiSelectProps>;
82
+
83
+ const Template = {
84
+ render: (args: MultiSelectProps) => {
85
+ const [optionList] = useState(() =>
86
+ [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => ({
87
+ value: num,
88
+ label: `Option ${num}`,
89
+ }))
90
+ );
91
+
92
+ // Initialize selected options from story args, so preselected stories work
93
+ const [selectedOptions, setSelectedOptions] = useState<(string | number)[]>(
94
+ () => (args.value || []) as (string | number)[]
95
+ );
96
+
97
+ useEffect(() => {
98
+ setSelectedOptions((args.value || []) as (string | number)[]);
99
+ // eslint-disable-next-line react-hooks/exhaustive-deps
100
+ }, []);
101
+
102
+ return (
103
+ <div style={{ minHeight: '400px', padding: '50px' }}>
104
+ {/* Visually hidden label to give the internal search input an accessible name for a11y tests */}
105
+ <label
106
+ htmlFor={`${args.id || args.name || 'multi-select-sdk'}-search`}
107
+ style={{
108
+ position: 'absolute',
109
+ width: '1px',
110
+ height: '1px',
111
+ padding: 0,
112
+ margin: '-1px',
113
+ overflow: 'hidden',
114
+ clip: 'rect(0 0 0 0)',
115
+ whiteSpace: 'nowrap',
116
+ border: 0,
117
+ }}
118
+ >
119
+ Search
120
+ </label>
121
+ <MultiSelect
122
+ {...args}
123
+ key={JSON.stringify(args.value ?? [])}
124
+ options={optionList}
125
+ value={selectedOptions}
126
+ onChange={setSelectedOptions}
127
+ />
128
+ </div>
129
+ );
130
+ },
131
+ } satisfies StoryObj<MultiSelectProps>;
132
+
133
+ export const DefaultView = {
134
+ ...Template,
135
+ parameters: {
136
+ layout: 'fullscreen',
137
+ a11y: {
138
+ config: {
139
+ rules: [{ id: 'color-contrast', enabled: false }],
140
+ },
141
+ },
142
+ },
143
+ args: {
144
+ placeholder: 'Select your options',
145
+ selectAllText: 'Select All',
146
+ deselectAllText: 'Deselect All',
147
+ noResultsText: 'No results found',
148
+ name: 'example-multi-select',
149
+ disabled: false,
150
+ floatingLabel: '',
151
+ error: false,
152
+ success: false,
153
+ id: 'multi-select-id',
154
+ },
155
+ play: async ({ canvasElement }: any) => {
156
+ const canvas = within(canvasElement);
157
+ const container = canvas.getByTestId('multi-select-container');
158
+
159
+ // Verify initial state
160
+ await expect(canvas.getByTestId('multi-select')).toBeInTheDocument();
161
+ await expect(
162
+ canvas.queryByTestId('multi-select-dropdown')
163
+ ).not.toBeInTheDocument();
164
+
165
+ // Open dropdown
166
+ await userEvent.click(container);
167
+ await expect(
168
+ canvas.getByTestId('multi-select-dropdown')
169
+ ).toBeInTheDocument();
170
+
171
+ // Verify dropdown contents
172
+ await expect(
173
+ canvas.getByTestId('multi-select-options')
174
+ ).toBeInTheDocument();
175
+ await expect(
176
+ canvas.getByTestId('multi-select-select-all')
177
+ ).toBeInTheDocument();
178
+ await expect(
179
+ canvas.getByTestId('multi-select-deselect-all')
180
+ ).toBeInTheDocument();
181
+
182
+ // Select an option
183
+ const firstOption = canvas.getByTestId('multi-select-option-0');
184
+ await userEvent.click(firstOption);
185
+
186
+ // Verify option was selected (should show selected state)
187
+ await expect(firstOption.className).toMatch(/--selected/);
188
+
189
+ // Verify tag appears in the tags area (be more specific to avoid multiple matches)
190
+ const tagsArea = canvas.getByTestId('multi-select-tags-area');
191
+ await expect(tagsArea).toHaveTextContent('Option 1');
192
+ },
193
+ };
194
+
195
+ export const WithFloatingLabel = {
196
+ ...Template,
197
+ args: {
198
+ floatingLabel: 'Floating label text',
199
+ placeholder: 'Start typing...',
200
+ },
201
+ play: async ({ canvasElement }: any) => {
202
+ const canvas = within(canvasElement);
203
+ // floating label should be visible
204
+ const label = canvas.queryByText('Floating label text');
205
+ await expect(label).toBeInTheDocument();
206
+ },
207
+ };
208
+
209
+ export const DisabledState = {
210
+ ...Template,
211
+ args: {
212
+ disabled: true,
213
+ },
214
+ play: async ({ canvasElement }: any) => {
215
+ const canvas = within(canvasElement);
216
+ const container = canvas.getByTestId('multi-select-container');
217
+ // input should be disabled and dropdown shouldn't open on click
218
+ const hiddenInput = canvas.getByTestId('multi-select-hidden-input');
219
+ await expect(hiddenInput).toBeDisabled();
220
+ await userEvent.click(container);
221
+ // dropdown should not open
222
+ await expect(
223
+ canvas.queryByTestId('multi-select-dropdown')
224
+ ).not.toBeInTheDocument();
225
+ // container should have disabled class
226
+ await expect(container.className).toMatch(/--disabled/);
227
+ },
228
+ };
229
+
230
+ export const ErrorState = {
231
+ ...Template,
232
+ args: {
233
+ error: true,
234
+ },
235
+ play: async ({ canvasElement }: any) => {
236
+ const canvas = within(canvasElement);
237
+ const container = canvas.getByTestId('multi-select-container');
238
+ // container should have error class
239
+ await expect(container.className).toMatch(/--error/);
240
+ },
241
+ };
242
+
243
+ export const SuccessState = {
244
+ ...Template,
245
+ args: {
246
+ success: true,
247
+ },
248
+ play: async ({ canvasElement }: any) => {
249
+ const canvas = within(canvasElement);
250
+ const container = canvas.getByTestId('multi-select-container');
251
+ // container should have success class
252
+ await expect(container.className).toMatch(/--success/);
253
+ },
254
+ };
255
+
256
+ export const CustomTexts = {
257
+ ...Template,
258
+ args: {
259
+ placeholder: 'Pick items',
260
+ selectAllText: 'Add All',
261
+ deselectAllText: 'Remove All',
262
+ noResultsText: 'Nothing matches',
263
+ },
264
+ play: async ({ canvasElement }: any) => {
265
+ const canvas = within(canvasElement);
266
+ const container = canvas.getByTestId('multi-select-container');
267
+ const input = canvas.getByRole('combobox');
268
+
269
+ // Verify custom placeholder is used
270
+ await expect(input).toHaveAttribute('placeholder', 'Pick items');
271
+
272
+ // Open dropdown to check custom button texts
273
+ await userEvent.click(container);
274
+ await expect(
275
+ canvas.getByTestId('multi-select-dropdown')
276
+ ).toBeInTheDocument();
277
+
278
+ // Verify custom button texts
279
+ await expect(canvas.getByText('Add All')).toBeInTheDocument();
280
+ await expect(canvas.getByText('Remove All')).toBeInTheDocument();
281
+
282
+ // Clear any existing input and type something that won't match to test no results text
283
+ await userEvent.clear(input);
284
+ await userEvent.type(input, 'xyz');
285
+
286
+ // Wait for no results to appear and verify custom no results text
287
+ await expect(
288
+ canvas.getByTestId('multi-select-no-results')
289
+ ).toHaveTextContent('Nothing matches');
290
+ },
291
+ };
292
+
293
+ export const WithNameAndId = {
294
+ ...Template,
295
+ args: {
296
+ name: 'custom-name',
297
+ id: 'custom-id',
298
+ },
299
+ play: async ({ canvasElement }: any) => {
300
+ const canvas = within(canvasElement);
301
+ // Verify the hidden input has the correct name and id
302
+ const hiddenInput = canvas.getByTestId('multi-select-hidden-input');
303
+ await expect(hiddenInput).toHaveAttribute('name', 'custom-name');
304
+ await expect(hiddenInput).toHaveAttribute('id', 'custom-id');
305
+
306
+ // Verify the search input has the correct id pattern
307
+ const searchInput = canvas.getByRole('combobox');
308
+ await expect(searchInput).toHaveAttribute('id', 'custom-id-search');
309
+ },
310
+ };
311
+
312
+ export const PreselectedValues = {
313
+ ...Template,
314
+ args: {
315
+ value: [1, 3, 5],
316
+ placeholder: 'Already selected',
317
+ },
318
+ play: async ({ canvasElement }: any) => {
319
+ const canvas = within(canvasElement);
320
+ // Tags area should contain the preselected labels
321
+ const tagArea = canvas.getByTestId('multi-select-tags-area');
322
+ await expect(tagArea).toBeInTheDocument();
323
+ // Check for the presence of tags - they should be rendered as Tag components
324
+ await expect(canvas.queryByText('Option 1')).toBeInTheDocument();
325
+ await expect(canvas.queryByText('Option 3')).toBeInTheDocument();
326
+ await expect(canvas.queryByText('Option 5')).toBeInTheDocument();
327
+ // Verify the tags area has the appropriate class for having values
328
+ await expect(tagArea.className).toMatch(/--has-values/);
329
+ },
330
+ };
331
+
332
+ export const KeyboardNavigation = {
333
+ ...Template,
334
+ args: {
335
+ placeholder: 'Use keyboard to navigate',
336
+ },
337
+ play: async ({ canvasElement }: any) => {
338
+ const canvas = within(canvasElement);
339
+ const searchInput = canvas.getByRole('combobox');
340
+
341
+ // Focus the input and open dropdown
342
+ await userEvent.click(searchInput);
343
+ await expect(searchInput).toHaveFocus();
344
+
345
+ // Open dropdown with Arrow Down
346
+ await userEvent.keyboard('{ArrowDown}');
347
+ await expect(
348
+ canvas.getByTestId('multi-select-dropdown')
349
+ ).toBeInTheDocument();
350
+
351
+ // Test that keyboard navigation works by verifying we can:
352
+ // 1. Open dropdown with keyboard ✓ (done above)
353
+ // 2. Navigate and select an option
354
+ // 3. Close dropdown with Escape
355
+
356
+ // Navigate and select with keyboard
357
+ await userEvent.keyboard('{ArrowDown}'); // Move to first option
358
+ await userEvent.keyboard('{Enter}'); // Select first option
359
+
360
+ // Verify selection occurred by checking if a tag component appears
361
+ const tags = canvas.queryAllByTestId('dropin-tag-container');
362
+ await expect(tags.length).toBeGreaterThan(0);
363
+
364
+ // Test escape closes the dropdown
365
+ await userEvent.keyboard('{Escape}');
366
+ await expect(
367
+ canvas.queryByTestId('multi-select-dropdown')
368
+ ).not.toBeInTheDocument();
369
+
370
+ // Verify the search input still has focus after closing
371
+ await expect(searchInput).toHaveFocus();
372
+ },
373
+ };
374
+
375
+ export const SearchFiltering = {
376
+ ...Template,
377
+ args: {
378
+ placeholder: 'Type to search options',
379
+ },
380
+ play: async ({ canvasElement }: any) => {
381
+ const canvas = within(canvasElement);
382
+ const searchInput = canvas.getByRole('combobox');
383
+
384
+ // Type to search and open dropdown
385
+ await userEvent.type(searchInput, '1');
386
+ await expect(
387
+ canvas.getByTestId('multi-select-dropdown')
388
+ ).toBeInTheDocument();
389
+
390
+ // Should show only options containing '1' (Option 1, Option 10)
391
+ const firstFilteredOption = canvas.getByTestId('multi-select-option-0');
392
+ await expect(firstFilteredOption).toBeInTheDocument();
393
+ // Check the dropdown contains the filtered options
394
+ const dropdown = canvas.getByTestId('multi-select-dropdown');
395
+ await expect(dropdown).toHaveTextContent('Option 1');
396
+ await expect(dropdown).toHaveTextContent('Option 10');
397
+
398
+ // Clear search and type something that doesn't match
399
+ await userEvent.clear(searchInput);
400
+ await userEvent.type(searchInput, 'xyz');
401
+
402
+ // Should show no results state
403
+ const noResultsElement = canvas.getByTestId('multi-select-no-results');
404
+ await expect(noResultsElement).toBeInTheDocument();
405
+ // Verify the search term appears in quotes (the specific message may vary)
406
+ await expect(noResultsElement).toHaveTextContent('"xyz"');
407
+ },
408
+ };
409
+
410
+ export const BulkSelectionActions = {
411
+ ...Template,
412
+ args: {
413
+ placeholder: 'Demonstrate bulk selection',
414
+ },
415
+ play: async ({ canvasElement }: any) => {
416
+ const canvas = within(canvasElement);
417
+ const container = canvas.getByTestId('multi-select-container');
418
+
419
+ // Open dropdown
420
+ await userEvent.click(container);
421
+ await expect(
422
+ canvas.getByTestId('multi-select-dropdown')
423
+ ).toBeInTheDocument();
424
+
425
+ // Initially, Deselect All should be disabled (no selections)
426
+ const deselectAllButton = canvas.getByTestId('multi-select-deselect-all');
427
+ await expect(deselectAllButton).toBeDisabled();
428
+
429
+ // Click Select All
430
+ const selectAllButton = canvas.getByTestId('multi-select-select-all');
431
+ await userEvent.click(selectAllButton);
432
+
433
+ // All options should now be selected - check tags appear in the tags area
434
+ const tagsArea = canvas.getByTestId('multi-select-tags-area');
435
+ await expect(tagsArea).toHaveTextContent('Option 1');
436
+ await expect(tagsArea).toHaveTextContent('Option 2');
437
+ await expect(tagsArea).toHaveTextContent('Option 10');
438
+
439
+ // Deselect All should now be enabled
440
+ await expect(deselectAllButton).not.toBeDisabled();
441
+
442
+ // Click Deselect All
443
+ await userEvent.click(deselectAllButton);
444
+
445
+ // Tags should be gone from tags area
446
+ await expect(tagsArea).not.toHaveTextContent('Option 1');
447
+ await expect(tagsArea).not.toHaveTextContent('Option 2');
448
+
449
+ // Deselect All should be disabled again
450
+ await expect(deselectAllButton).toBeDisabled();
451
+ },
452
+ };
453
+
454
+ /**
455
+ * MultiSelect component allows users to select multiple options from a list.
456
+ * It supports keyboard navigation, search filtering, and accessibility features.
457
+ */