@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.
- package/config/jest.js +3 -3
- package/package.json +3 -3
- package/src/components/MultiSelect/MultiSelect.css +273 -0
- package/src/components/MultiSelect/MultiSelect.stories.tsx +457 -0
- package/src/components/MultiSelect/MultiSelect.tsx +763 -0
- package/src/components/MultiSelect/index.ts +11 -0
- package/src/components/Table/Table.stories.tsx +88 -0
- package/src/components/Table/Table.tsx +70 -29
- package/src/components/index.ts +4 -3
- package/src/docs/slots.mdx +2 -0
- package/src/i18n/en_US.json +33 -0
- package/src/lib/aem/configs.ts +7 -4
- package/src/lib/slot.tsx +61 -5
|
@@ -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
|
+
*/
|