@sproutsocial/seeds-react-menu 1.12.3 → 1.15.10
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/.turbo/turbo-build.log +12 -12
- package/CHANGELOG.md +193 -0
- package/dist/esm/v3/index.js +726 -186
- package/dist/esm/v3/index.js.map +1 -1
- package/dist/index.d.mts +22 -22
- package/dist/index.d.ts +22 -22
- package/dist/v3/index.d.mts +199 -50
- package/dist/v3/index.d.ts +199 -50
- package/dist/v3/index.js +732 -185
- package/dist/v3/index.js.map +1 -1
- package/package.json +14 -14
- package/src/v3/ActionMenu.stories.tsx +260 -0
- package/src/v3/ActionMenu.tsx +345 -0
- package/src/v3/ActionMenuStyles.tsx +154 -0
- package/src/v3/Common/ComboboxStyles.tsx +41 -7
- package/src/v3/Common/SelectStyles.tsx +20 -6
- package/src/v3/Common/useSuppressEscapeClear.ts +20 -0
- package/src/v3/MenuButton.stories.tsx +118 -0
- package/src/v3/MenuButton.tsx +68 -0
- package/src/v3/MenuButtonStyles.tsx +38 -0
- package/src/v3/ModalStories.stories.tsx +159 -0
- package/src/v3/MultiCombobox.stories.tsx +157 -0
- package/src/v3/MultiCombobox.tsx +28 -8
- package/src/v3/MultiSearchableSelect.stories.tsx +42 -0
- package/src/v3/MultiSearchableSelect.tsx +11 -6
- package/src/v3/MultiSelect.stories.tsx +40 -0
- package/src/v3/MultiSelect.tsx +12 -3
- package/src/v3/SingleCombobox.stories.tsx +36 -0
- package/src/v3/SingleCombobox.tsx +17 -7
- package/src/v3/SingleSearchableSelect.stories.tsx +38 -0
- package/src/v3/SingleSearchableSelect.tsx +15 -7
- package/src/v3/SingleSelect.stories.tsx +36 -0
- package/src/v3/SingleSelect.tsx +12 -3
- package/src/v3/__tests__/MenuButton.test.tsx +80 -0
- package/src/v3/index.ts +2 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
|
+
import { action } from "@storybook/addon-actions";
|
|
3
4
|
import { FormField } from "@sproutsocial/seeds-react-form-field";
|
|
4
5
|
import { Button } from "@sproutsocial/seeds-react-button";
|
|
5
6
|
import { Input } from "@sproutsocial/seeds-react-input";
|
|
@@ -16,6 +17,13 @@ import { SingleCombobox } from "./SingleCombobox";
|
|
|
16
17
|
import { MultiCombobox } from "./MultiCombobox";
|
|
17
18
|
import { SingleSearchableSelect } from "./SingleSearchableSelect";
|
|
18
19
|
import { MultiSearchableSelect } from "./MultiSearchableSelect";
|
|
20
|
+
import {
|
|
21
|
+
ActionMenu,
|
|
22
|
+
MenuGroup,
|
|
23
|
+
MenuItem,
|
|
24
|
+
MenuSub,
|
|
25
|
+
MenuSubTrigger,
|
|
26
|
+
} from "./ActionMenu";
|
|
19
27
|
import { books, itemToString } from "./storyData";
|
|
20
28
|
|
|
21
29
|
// ─── Shared content ───────────────────────────────────────────────────────────
|
|
@@ -179,3 +187,154 @@ export const V2Modal: Story = {
|
|
|
179
187
|
</>
|
|
180
188
|
),
|
|
181
189
|
};
|
|
190
|
+
|
|
191
|
+
// ─── Action Menu in Modal ─────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
export const ActionMenuInV1Modal: Story = {
|
|
194
|
+
name: "Action Menu in V1 Modal",
|
|
195
|
+
render: () => {
|
|
196
|
+
const [isOpen, setIsOpen] = React.useState(false);
|
|
197
|
+
return (
|
|
198
|
+
<>
|
|
199
|
+
<Button appearance="primary" onClick={() => setIsOpen(true)}>
|
|
200
|
+
Open V1 Modal
|
|
201
|
+
</Button>
|
|
202
|
+
<ModalV1
|
|
203
|
+
appElementSelector="#root"
|
|
204
|
+
isOpen={isOpen}
|
|
205
|
+
onClose={() => setIsOpen(false)}
|
|
206
|
+
closeButtonLabel="Close"
|
|
207
|
+
label="Action Menu in V1 Modal"
|
|
208
|
+
>
|
|
209
|
+
<ModalV1.Header title="" subtitle="" bordered>
|
|
210
|
+
Actions
|
|
211
|
+
</ModalV1.Header>
|
|
212
|
+
<ModalV1.Content>
|
|
213
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
214
|
+
<ActionMenu
|
|
215
|
+
trigger={<Button appearance="secondary">Basic Menu</Button>}
|
|
216
|
+
>
|
|
217
|
+
<MenuItem onClick={action("Compose")}>Compose</MenuItem>
|
|
218
|
+
<MenuItem onClick={action("View Messages")}>
|
|
219
|
+
View Messages
|
|
220
|
+
</MenuItem>
|
|
221
|
+
<MenuItem onClick={action("Analyze post")}>
|
|
222
|
+
Analyze post
|
|
223
|
+
</MenuItem>
|
|
224
|
+
</ActionMenu>
|
|
225
|
+
|
|
226
|
+
<ActionMenu
|
|
227
|
+
trigger={
|
|
228
|
+
<Button appearance="secondary">Menu with Groups</Button>
|
|
229
|
+
}
|
|
230
|
+
>
|
|
231
|
+
<MenuGroup label="Actions" showSeparator>
|
|
232
|
+
<MenuItem onClick={action("Edit")}>Edit</MenuItem>
|
|
233
|
+
<MenuItem onClick={action("Duplicate")}>Duplicate</MenuItem>
|
|
234
|
+
</MenuGroup>
|
|
235
|
+
<MenuGroup label="Danger">
|
|
236
|
+
<MenuItem onClick={action("Delete")} disabled>
|
|
237
|
+
Delete
|
|
238
|
+
</MenuItem>
|
|
239
|
+
</MenuGroup>
|
|
240
|
+
</ActionMenu>
|
|
241
|
+
|
|
242
|
+
<ActionMenu
|
|
243
|
+
trigger={
|
|
244
|
+
<Button appearance="secondary">Menu with Submenu</Button>
|
|
245
|
+
}
|
|
246
|
+
>
|
|
247
|
+
<MenuItem onClick={action("Compose")}>Compose</MenuItem>
|
|
248
|
+
<MenuSub trigger={<MenuSubTrigger>Share</MenuSubTrigger>}>
|
|
249
|
+
<MenuItem onClick={action("Share to Twitter")}>
|
|
250
|
+
Twitter
|
|
251
|
+
</MenuItem>
|
|
252
|
+
<MenuItem onClick={action("Share to LinkedIn")}>
|
|
253
|
+
LinkedIn
|
|
254
|
+
</MenuItem>
|
|
255
|
+
<MenuSub
|
|
256
|
+
trigger={<MenuSubTrigger>More platforms</MenuSubTrigger>}
|
|
257
|
+
>
|
|
258
|
+
<MenuItem onClick={action("Share to Facebook")}>
|
|
259
|
+
Facebook
|
|
260
|
+
</MenuItem>
|
|
261
|
+
<MenuItem onClick={action("Share to Instagram")}>
|
|
262
|
+
Instagram
|
|
263
|
+
</MenuItem>
|
|
264
|
+
</MenuSub>
|
|
265
|
+
</MenuSub>
|
|
266
|
+
<MenuItem onClick={action("Delete")} disabled>
|
|
267
|
+
Delete
|
|
268
|
+
</MenuItem>
|
|
269
|
+
</ActionMenu>
|
|
270
|
+
</div>
|
|
271
|
+
</ModalV1.Content>
|
|
272
|
+
<ModalV1.Footer>Footer</ModalV1.Footer>
|
|
273
|
+
</ModalV1>
|
|
274
|
+
</>
|
|
275
|
+
);
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
export const ActionMenuInModal: Story = {
|
|
280
|
+
name: "Action Menu in V2 Modal",
|
|
281
|
+
render: () => (
|
|
282
|
+
<ModalV2
|
|
283
|
+
title="Actions"
|
|
284
|
+
modalTrigger={<Button appearance="primary">Open Modal</Button>}
|
|
285
|
+
closeButtonAriaLabel="Close"
|
|
286
|
+
>
|
|
287
|
+
<ModalBody>
|
|
288
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
289
|
+
<ActionMenu
|
|
290
|
+
trigger={<Button appearance="secondary">Basic Menu</Button>}
|
|
291
|
+
>
|
|
292
|
+
<MenuItem onClick={action("Compose")}>Compose</MenuItem>
|
|
293
|
+
<MenuItem onClick={action("View Messages")}>View Messages</MenuItem>
|
|
294
|
+
<MenuItem onClick={action("Analyze post")}>Analyze post</MenuItem>
|
|
295
|
+
</ActionMenu>
|
|
296
|
+
|
|
297
|
+
<ActionMenu
|
|
298
|
+
trigger={<Button appearance="secondary">Menu with Groups</Button>}
|
|
299
|
+
>
|
|
300
|
+
<MenuGroup label="Actions" showSeparator>
|
|
301
|
+
<MenuItem onClick={action("Edit")}>Edit</MenuItem>
|
|
302
|
+
<MenuItem onClick={action("Duplicate")}>Duplicate</MenuItem>
|
|
303
|
+
</MenuGroup>
|
|
304
|
+
<MenuGroup label="Danger">
|
|
305
|
+
<MenuItem onClick={action("Delete")} disabled>
|
|
306
|
+
Delete
|
|
307
|
+
</MenuItem>
|
|
308
|
+
</MenuGroup>
|
|
309
|
+
</ActionMenu>
|
|
310
|
+
|
|
311
|
+
<ActionMenu
|
|
312
|
+
trigger={<Button appearance="secondary">Menu with Submenu</Button>}
|
|
313
|
+
>
|
|
314
|
+
<MenuItem onClick={action("Compose")}>Compose</MenuItem>
|
|
315
|
+
<MenuSub trigger={<MenuSubTrigger>Share</MenuSubTrigger>}>
|
|
316
|
+
<MenuItem onClick={action("Share to Twitter")}>Twitter</MenuItem>
|
|
317
|
+
<MenuItem onClick={action("Share to LinkedIn")}>
|
|
318
|
+
LinkedIn
|
|
319
|
+
</MenuItem>
|
|
320
|
+
<MenuSub
|
|
321
|
+
trigger={<MenuSubTrigger>More platforms</MenuSubTrigger>}
|
|
322
|
+
>
|
|
323
|
+
<MenuItem onClick={action("Share to Facebook")}>
|
|
324
|
+
Facebook
|
|
325
|
+
</MenuItem>
|
|
326
|
+
<MenuItem onClick={action("Share to Instagram")}>
|
|
327
|
+
Instagram
|
|
328
|
+
</MenuItem>
|
|
329
|
+
</MenuSub>
|
|
330
|
+
</MenuSub>
|
|
331
|
+
<MenuItem onClick={action("Delete")} disabled>
|
|
332
|
+
Delete
|
|
333
|
+
</MenuItem>
|
|
334
|
+
</ActionMenu>
|
|
335
|
+
</div>
|
|
336
|
+
</ModalBody>
|
|
337
|
+
<ModalFooter cancelButton={<Button>Close</Button>} />
|
|
338
|
+
</ModalV2>
|
|
339
|
+
),
|
|
340
|
+
};
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
3
3
|
import { FormField } from "@sproutsocial/seeds-react-form-field";
|
|
4
|
+
import { Button } from "@sproutsocial/seeds-react-button";
|
|
5
|
+
import {
|
|
6
|
+
ModalV2,
|
|
7
|
+
ModalBody,
|
|
8
|
+
ModalFooter,
|
|
9
|
+
} from "@sproutsocial/seeds-react-modal";
|
|
4
10
|
import { MultiCombobox } from "./MultiCombobox";
|
|
5
11
|
import {
|
|
6
12
|
books,
|
|
@@ -301,6 +307,28 @@ export const MaxTokens: Story = {
|
|
|
301
307
|
),
|
|
302
308
|
};
|
|
303
309
|
|
|
310
|
+
export const LargeBookSetWithMaxTokens: Story = {
|
|
311
|
+
name: "Large book set with max tokens",
|
|
312
|
+
render: () => (
|
|
313
|
+
<div style={{ width: "300px" }}>
|
|
314
|
+
<FormField label="Pick books">
|
|
315
|
+
{({ id }) => (
|
|
316
|
+
<MultiCombobox
|
|
317
|
+
id={id}
|
|
318
|
+
data={largeBookSet}
|
|
319
|
+
itemToString={itemToString}
|
|
320
|
+
renderItem={renderItem}
|
|
321
|
+
placeholder="Search books..."
|
|
322
|
+
isVirtualized
|
|
323
|
+
selectAll
|
|
324
|
+
maxTokens={5}
|
|
325
|
+
/>
|
|
326
|
+
)}
|
|
327
|
+
</FormField>
|
|
328
|
+
</div>
|
|
329
|
+
),
|
|
330
|
+
};
|
|
331
|
+
|
|
304
332
|
export const Loading: Story = {
|
|
305
333
|
name: "Loading",
|
|
306
334
|
render: () => (
|
|
@@ -396,6 +424,23 @@ export const VirtualizedSelectAll: Story = {
|
|
|
396
424
|
),
|
|
397
425
|
};
|
|
398
426
|
|
|
427
|
+
export const Inline: Story = {
|
|
428
|
+
name: "Inline (fullWidth=false)",
|
|
429
|
+
render: () => (
|
|
430
|
+
<FormField label="Pick books">
|
|
431
|
+
{({ id }) => (
|
|
432
|
+
<MultiCombobox
|
|
433
|
+
id={id}
|
|
434
|
+
data={books}
|
|
435
|
+
itemToString={itemToString}
|
|
436
|
+
placeholder="Search books..."
|
|
437
|
+
fullWidth={false}
|
|
438
|
+
/>
|
|
439
|
+
)}
|
|
440
|
+
</FormField>
|
|
441
|
+
),
|
|
442
|
+
};
|
|
443
|
+
|
|
399
444
|
export const CustomSearch: Story = {
|
|
400
445
|
name: "Custom search (title + author)",
|
|
401
446
|
render: () => (
|
|
@@ -455,3 +500,115 @@ export const Creatable: Story = {
|
|
|
455
500
|
);
|
|
456
501
|
},
|
|
457
502
|
};
|
|
503
|
+
|
|
504
|
+
// Story to help visualize what the combobox component looks like when it's invalid within a FormField component
|
|
505
|
+
export const Invalid: Story = {
|
|
506
|
+
name: "Invalid",
|
|
507
|
+
render: () => (
|
|
508
|
+
<div style={{ width: "300px" }}>
|
|
509
|
+
<FormField
|
|
510
|
+
label="Pick books"
|
|
511
|
+
isInvalid
|
|
512
|
+
error="Please pick at least one book."
|
|
513
|
+
>
|
|
514
|
+
{({ id, isInvalid }) => (
|
|
515
|
+
<MultiCombobox
|
|
516
|
+
id={id}
|
|
517
|
+
isInvalid={isInvalid}
|
|
518
|
+
data={books}
|
|
519
|
+
itemToString={itemToString}
|
|
520
|
+
placeholder="Search books..."
|
|
521
|
+
/>
|
|
522
|
+
)}
|
|
523
|
+
</FormField>
|
|
524
|
+
</div>
|
|
525
|
+
),
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// Story to help visualize what the combobox component looks like when it's required within a FormField component
|
|
529
|
+
export const Required: Story = {
|
|
530
|
+
name: "Required",
|
|
531
|
+
render: () => (
|
|
532
|
+
<div style={{ width: "300px" }}>
|
|
533
|
+
<FormField label="Pick books" required>
|
|
534
|
+
{({ id, required }) => (
|
|
535
|
+
<MultiCombobox
|
|
536
|
+
id={id}
|
|
537
|
+
required={required}
|
|
538
|
+
data={books}
|
|
539
|
+
itemToString={itemToString}
|
|
540
|
+
placeholder="Search books..."
|
|
541
|
+
/>
|
|
542
|
+
)}
|
|
543
|
+
</FormField>
|
|
544
|
+
</div>
|
|
545
|
+
),
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
export const LargeBookSetWithMaxTokensInModal: Story = {
|
|
549
|
+
name: "Large book set with max tokens (in modal)",
|
|
550
|
+
render: () => (
|
|
551
|
+
<ModalV2
|
|
552
|
+
title="Pick some books"
|
|
553
|
+
modalTrigger={<Button appearance="primary">Open Modal</Button>}
|
|
554
|
+
closeButtonAriaLabel="Close"
|
|
555
|
+
>
|
|
556
|
+
<ModalBody>
|
|
557
|
+
<div style={{ width: "300px" }}>
|
|
558
|
+
<FormField label="Pick books">
|
|
559
|
+
{({ id }) => (
|
|
560
|
+
<MultiCombobox
|
|
561
|
+
id={id}
|
|
562
|
+
data={largeBookSet}
|
|
563
|
+
itemToString={itemToString}
|
|
564
|
+
renderItem={renderItem}
|
|
565
|
+
placeholder="Search books..."
|
|
566
|
+
isVirtualized
|
|
567
|
+
selectAll
|
|
568
|
+
maxTokens={5}
|
|
569
|
+
/>
|
|
570
|
+
)}
|
|
571
|
+
</FormField>
|
|
572
|
+
</div>
|
|
573
|
+
</ModalBody>
|
|
574
|
+
<ModalFooter cancelButton={<Button>Cancel</Button>} />
|
|
575
|
+
</ModalV2>
|
|
576
|
+
),
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
// Verifies the a11y fix for Escape key containment. Open the modal, open the
|
|
580
|
+
// combobox dropdown, then press Escape: the dropdown should close and focus
|
|
581
|
+
// should return to the input — the modal should stay open. Pressing Escape
|
|
582
|
+
// again (with the dropdown closed) should close the modal.
|
|
583
|
+
export const EscapeKeyInModal: Story = {
|
|
584
|
+
name: "Escape Key in Modal (a11y)",
|
|
585
|
+
render: () => (
|
|
586
|
+
<ModalV2
|
|
587
|
+
title="Pick some books"
|
|
588
|
+
modalTrigger={<Button appearance="primary">Open Modal</Button>}
|
|
589
|
+
closeButtonAriaLabel="Close"
|
|
590
|
+
>
|
|
591
|
+
<ModalBody>
|
|
592
|
+
<div style={{ width: "300px", padding: "16px 0" }}>
|
|
593
|
+
<FormField label="Pick books">
|
|
594
|
+
{({ id }) => (
|
|
595
|
+
<MultiCombobox
|
|
596
|
+
id={id}
|
|
597
|
+
data={books}
|
|
598
|
+
itemToString={itemToString}
|
|
599
|
+
placeholder="Search books..."
|
|
600
|
+
/>
|
|
601
|
+
)}
|
|
602
|
+
</FormField>
|
|
603
|
+
<p style={{ marginTop: 16, fontSize: 14 }}>
|
|
604
|
+
<strong>To verify:</strong> open the combobox, press{" "}
|
|
605
|
+
<kbd>Escape</kbd>. The dropdown should close and focus should return
|
|
606
|
+
to the input. The modal should remain open. Press <kbd>Escape</kbd>{" "}
|
|
607
|
+
again to close the modal.
|
|
608
|
+
</p>
|
|
609
|
+
</div>
|
|
610
|
+
</ModalBody>
|
|
611
|
+
<ModalFooter cancelButton={<Button>Cancel</Button>} />
|
|
612
|
+
</ModalV2>
|
|
613
|
+
),
|
|
614
|
+
};
|
package/src/v3/MultiCombobox.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Combobox } from "@base-ui/react/combobox";
|
|
|
3
3
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
4
4
|
import VirtualizedComboboxList from "./Common/VirtualizedComboboxList";
|
|
5
5
|
import { useSeedsPortalContainer } from "./SeedsPortal";
|
|
6
|
+
import { useSuppressEscapeClear } from "./Common/useSuppressEscapeClear";
|
|
6
7
|
import ComboboxItem from "./Common/ComboboxItem";
|
|
7
8
|
import type { DataWithId } from "./Common/types";
|
|
8
9
|
import { groupItems } from "./Common/groupItems";
|
|
@@ -74,6 +75,9 @@ interface MultiComboboxBaseProps {
|
|
|
74
75
|
inputValue?: string;
|
|
75
76
|
onInputValueChange?: (value: string) => void;
|
|
76
77
|
"aria-describedby"?: string;
|
|
78
|
+
isInvalid?: boolean;
|
|
79
|
+
required?: boolean;
|
|
80
|
+
fullWidth?: boolean;
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
interface MultiComboboxDataProps<T extends DataWithId>
|
|
@@ -141,8 +145,10 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
141
145
|
filter,
|
|
142
146
|
inputValue,
|
|
143
147
|
onInputValueChange,
|
|
148
|
+
fullWidth = true,
|
|
144
149
|
} = props;
|
|
145
150
|
const ariaDescribedBy = props["aria-describedby"];
|
|
151
|
+
const isInvalid = props.isInvalid;
|
|
146
152
|
|
|
147
153
|
const isChildrenMode = "children" in props && props.children != null;
|
|
148
154
|
|
|
@@ -211,6 +217,9 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
211
217
|
const virtualizerRef = React.useRef<ReturnType<
|
|
212
218
|
typeof useVirtualizer<HTMLDivElement, Element>
|
|
213
219
|
> | null>(null);
|
|
220
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
221
|
+
|
|
222
|
+
const onInputKeyDownCapture = useSuppressEscapeClear(open);
|
|
214
223
|
|
|
215
224
|
const isControlled = selectedItemIds !== undefined;
|
|
216
225
|
|
|
@@ -366,6 +375,7 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
366
375
|
e.preventDefault();
|
|
367
376
|
e.stopPropagation();
|
|
368
377
|
setSelectedItems(value.filter((v) => v.id !== item.id));
|
|
378
|
+
inputRef.current?.focus();
|
|
369
379
|
}
|
|
370
380
|
|
|
371
381
|
// ─── Render sentinel item ─────────────────────────────────────────────────
|
|
@@ -481,7 +491,7 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
481
491
|
: (value as unknown as T[]);
|
|
482
492
|
|
|
483
493
|
return (
|
|
484
|
-
<Field>
|
|
494
|
+
<Field $fullWidth={fullWidth}>
|
|
485
495
|
<div ref={containerRef} style={{ position: "relative" }} />
|
|
486
496
|
<Combobox.Root
|
|
487
497
|
multiple
|
|
@@ -490,7 +500,10 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
490
500
|
open={open}
|
|
491
501
|
disabled={props.disabled}
|
|
492
502
|
onOpenChange={(nextOpen, eventDetails) => {
|
|
493
|
-
if (!nextOpen && eventDetails.reason === "item-press")
|
|
503
|
+
if (!nextOpen && eventDetails.reason === "item-press") {
|
|
504
|
+
eventDetails.cancel();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
494
507
|
setOpen(nextOpen);
|
|
495
508
|
}}
|
|
496
509
|
filter={
|
|
@@ -535,13 +548,14 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
535
548
|
if (!item || !virt) return;
|
|
536
549
|
const isStart = index === 0;
|
|
537
550
|
const isEnd = index === virt.options.count - 1;
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
551
|
+
// Only handle keyboard wrap-around: when navigation loops from
|
|
552
|
+
// the last item to the first (or vice versa), the wrapped item
|
|
553
|
+
// is outside the virtualized window and standard scroll-into-
|
|
554
|
+
// view can't find it.
|
|
555
|
+
if (reason === "keyboard" && (isStart || isEnd)) {
|
|
542
556
|
queueMicrotask(() => {
|
|
543
557
|
virt.scrollToIndex(index, {
|
|
544
|
-
align:
|
|
558
|
+
align: isStart ? "start" : "end",
|
|
545
559
|
});
|
|
546
560
|
});
|
|
547
561
|
}
|
|
@@ -584,7 +598,10 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
584
598
|
}}
|
|
585
599
|
items={rootItems}
|
|
586
600
|
>
|
|
587
|
-
<ComboboxTokenInputGroup
|
|
601
|
+
<ComboboxTokenInputGroup
|
|
602
|
+
$isInvalid={isInvalid}
|
|
603
|
+
onKeyDownCapture={onInputKeyDownCapture}
|
|
604
|
+
>
|
|
588
605
|
<ComboboxToken>
|
|
589
606
|
{renderSelection
|
|
590
607
|
? renderSelection(value)
|
|
@@ -614,9 +631,12 @@ export function MultiCombobox<T extends DataWithId>(
|
|
|
614
631
|
);
|
|
615
632
|
})()}
|
|
616
633
|
<ComboboxTokenInput
|
|
634
|
+
ref={inputRef}
|
|
617
635
|
id={id}
|
|
618
636
|
placeholder={value.length > 0 ? "" : placeholder}
|
|
619
637
|
aria-describedby={ariaDescribedBy}
|
|
638
|
+
aria-invalid={isInvalid || undefined}
|
|
639
|
+
aria-required={props.required || undefined}
|
|
620
640
|
/>
|
|
621
641
|
<ComboboxActionButtons>
|
|
622
642
|
<ComboboxClear aria-label="Clear all">
|
|
@@ -37,6 +37,30 @@ export const Basic: Story = {
|
|
|
37
37
|
),
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
export const Invalid: Story = {
|
|
41
|
+
name: "Invalid",
|
|
42
|
+
render: () => (
|
|
43
|
+
<div style={{ width: "300px" }}>
|
|
44
|
+
<FormField
|
|
45
|
+
label="Pick books"
|
|
46
|
+
isInvalid
|
|
47
|
+
error="Please pick at least one book."
|
|
48
|
+
>
|
|
49
|
+
{({ id, isInvalid }) => (
|
|
50
|
+
<MultiSearchableSelect
|
|
51
|
+
id={id}
|
|
52
|
+
isInvalid={isInvalid}
|
|
53
|
+
data={books}
|
|
54
|
+
itemToString={itemToString}
|
|
55
|
+
placeholder="Select books"
|
|
56
|
+
searchPlaceholder="Search books..."
|
|
57
|
+
/>
|
|
58
|
+
)}
|
|
59
|
+
</FormField>
|
|
60
|
+
</div>
|
|
61
|
+
),
|
|
62
|
+
};
|
|
63
|
+
|
|
40
64
|
export const CustomRenderItem: Story = {
|
|
41
65
|
name: "Custom render item",
|
|
42
66
|
render: () => (
|
|
@@ -413,6 +437,24 @@ export const LoadingWithToggle: Story = {
|
|
|
413
437
|
},
|
|
414
438
|
};
|
|
415
439
|
|
|
440
|
+
export const Inline: Story = {
|
|
441
|
+
name: "Inline (fullWidth=false)",
|
|
442
|
+
render: () => (
|
|
443
|
+
<FormField label="Pick books">
|
|
444
|
+
{({ id }) => (
|
|
445
|
+
<MultiSearchableSelect
|
|
446
|
+
id={id}
|
|
447
|
+
data={books}
|
|
448
|
+
itemToString={itemToString}
|
|
449
|
+
placeholder="Select books"
|
|
450
|
+
searchPlaceholder="Search books..."
|
|
451
|
+
fullWidth={false}
|
|
452
|
+
/>
|
|
453
|
+
)}
|
|
454
|
+
</FormField>
|
|
455
|
+
),
|
|
456
|
+
};
|
|
457
|
+
|
|
416
458
|
export const CustomSearch: Story = {
|
|
417
459
|
name: "Custom search (title + author)",
|
|
418
460
|
render: () => (
|
|
@@ -80,6 +80,9 @@ interface MultiSearchableSelectBaseProps {
|
|
|
80
80
|
disabled?: boolean;
|
|
81
81
|
filter?: ((item: unknown, query: string) => boolean) | null;
|
|
82
82
|
"aria-describedby"?: string;
|
|
83
|
+
isInvalid?: boolean;
|
|
84
|
+
required?: boolean;
|
|
85
|
+
fullWidth?: boolean;
|
|
83
86
|
}
|
|
84
87
|
|
|
85
88
|
interface MultiSearchableSelectDataProps<T extends DataWithId>
|
|
@@ -145,8 +148,10 @@ export function MultiSearchableSelect<T extends DataWithId>(
|
|
|
145
148
|
isLoading = false,
|
|
146
149
|
loadingText = "Loading...",
|
|
147
150
|
filter,
|
|
151
|
+
fullWidth = true,
|
|
148
152
|
} = props;
|
|
149
153
|
const ariaDescribedBy = props["aria-describedby"];
|
|
154
|
+
const isInvalid = props.isInvalid;
|
|
150
155
|
|
|
151
156
|
const isChildrenMode = "children" in props && props.children != null;
|
|
152
157
|
|
|
@@ -461,7 +466,7 @@ export function MultiSearchableSelect<T extends DataWithId>(
|
|
|
461
466
|
: (value as unknown as T[]);
|
|
462
467
|
|
|
463
468
|
return (
|
|
464
|
-
<Field>
|
|
469
|
+
<Field $fullWidth={fullWidth}>
|
|
465
470
|
<div ref={containerRef} style={{ position: "relative" }} />
|
|
466
471
|
<Combobox.Root
|
|
467
472
|
multiple
|
|
@@ -500,13 +505,10 @@ export function MultiSearchableSelect<T extends DataWithId>(
|
|
|
500
505
|
if (!item || !virt) return;
|
|
501
506
|
const isStart = index === 0;
|
|
502
507
|
const isEnd = index === virt.options.count - 1;
|
|
503
|
-
if (
|
|
504
|
-
reason === "none" ||
|
|
505
|
-
(reason === "keyboard" && (isStart || isEnd))
|
|
506
|
-
) {
|
|
508
|
+
if (reason === "keyboard" && (isStart || isEnd)) {
|
|
507
509
|
queueMicrotask(() => {
|
|
508
510
|
virt.scrollToIndex(index, {
|
|
509
|
-
align:
|
|
511
|
+
align: isStart ? "start" : "end",
|
|
510
512
|
});
|
|
511
513
|
});
|
|
512
514
|
}
|
|
@@ -552,6 +554,9 @@ export function MultiSearchableSelect<T extends DataWithId>(
|
|
|
552
554
|
<SearchableSelectTokenTrigger
|
|
553
555
|
id={id}
|
|
554
556
|
aria-describedby={ariaDescribedBy}
|
|
557
|
+
aria-invalid={isInvalid || undefined}
|
|
558
|
+
aria-required={props.required || undefined}
|
|
559
|
+
$isInvalid={isInvalid}
|
|
555
560
|
>
|
|
556
561
|
{renderSelection ? (
|
|
557
562
|
renderSelection(value)
|
|
@@ -37,6 +37,29 @@ export const Basic: Story = {
|
|
|
37
37
|
),
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
export const Invalid: Story = {
|
|
41
|
+
name: "Invalid",
|
|
42
|
+
render: () => (
|
|
43
|
+
<div style={{ width: "300px" }}>
|
|
44
|
+
<FormField
|
|
45
|
+
label="Pick books"
|
|
46
|
+
isInvalid
|
|
47
|
+
error="Please pick at least one book."
|
|
48
|
+
>
|
|
49
|
+
{({ id, isInvalid }) => (
|
|
50
|
+
<MultiSelect
|
|
51
|
+
id={id}
|
|
52
|
+
isInvalid={isInvalid}
|
|
53
|
+
data={books}
|
|
54
|
+
itemToString={itemToString}
|
|
55
|
+
placeholder="Select books..."
|
|
56
|
+
/>
|
|
57
|
+
)}
|
|
58
|
+
</FormField>
|
|
59
|
+
</div>
|
|
60
|
+
),
|
|
61
|
+
};
|
|
62
|
+
|
|
40
63
|
export const CustomRenderItem: Story = {
|
|
41
64
|
name: "Custom render item",
|
|
42
65
|
render: () => (
|
|
@@ -266,6 +289,23 @@ export const RemoveSelectedItems: Story = {
|
|
|
266
289
|
),
|
|
267
290
|
};
|
|
268
291
|
|
|
292
|
+
export const Inline: Story = {
|
|
293
|
+
name: "Inline (fullWidth=false)",
|
|
294
|
+
render: () => (
|
|
295
|
+
<FormField label="Pick books">
|
|
296
|
+
{({ id }) => (
|
|
297
|
+
<MultiSelect
|
|
298
|
+
id={id}
|
|
299
|
+
data={books}
|
|
300
|
+
itemToString={itemToString}
|
|
301
|
+
placeholder="Select books..."
|
|
302
|
+
fullWidth={false}
|
|
303
|
+
/>
|
|
304
|
+
)}
|
|
305
|
+
</FormField>
|
|
306
|
+
),
|
|
307
|
+
};
|
|
308
|
+
|
|
269
309
|
export const Children: Story = {
|
|
270
310
|
name: "Children API",
|
|
271
311
|
render: () => (
|
package/src/v3/MultiSelect.tsx
CHANGED
|
@@ -44,6 +44,9 @@ interface MultiSelectBaseProps {
|
|
|
44
44
|
placeholder?: string;
|
|
45
45
|
disabled?: boolean;
|
|
46
46
|
"aria-describedby"?: string;
|
|
47
|
+
isInvalid?: boolean;
|
|
48
|
+
required?: boolean;
|
|
49
|
+
fullWidth?: boolean;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
interface MultiSelectDataProps<T extends DataWithId>
|
|
@@ -88,8 +91,9 @@ export type MultiSelectProps<T extends DataWithId> =
|
|
|
88
91
|
// ─── Component ────────────────────────────────────────────────────────────────
|
|
89
92
|
|
|
90
93
|
export function MultiSelect<T extends DataWithId>(props: MultiSelectProps<T>) {
|
|
91
|
-
const { id, placeholder = "Select..." } = props;
|
|
94
|
+
const { id, placeholder = "Select...", fullWidth = true } = props;
|
|
92
95
|
const ariaDescribedBy = props["aria-describedby"];
|
|
96
|
+
const isInvalid = props.isInvalid;
|
|
93
97
|
|
|
94
98
|
const isChildrenMode = "children" in props && props.children != null;
|
|
95
99
|
|
|
@@ -171,7 +175,7 @@ export function MultiSelect<T extends DataWithId>(props: MultiSelectProps<T>) {
|
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
return (
|
|
174
|
-
<Field>
|
|
178
|
+
<Field $fullWidth={fullWidth}>
|
|
175
179
|
<div ref={containerRef} style={{ position: "relative" }} />
|
|
176
180
|
<Select.Root
|
|
177
181
|
multiple
|
|
@@ -181,7 +185,12 @@ export function MultiSelect<T extends DataWithId>(props: MultiSelectProps<T>) {
|
|
|
181
185
|
disabled={props.disabled}
|
|
182
186
|
id={id}
|
|
183
187
|
>
|
|
184
|
-
<SelectTrigger
|
|
188
|
+
<SelectTrigger
|
|
189
|
+
aria-describedby={ariaDescribedBy}
|
|
190
|
+
aria-invalid={isInvalid || undefined}
|
|
191
|
+
aria-required={props.required || undefined}
|
|
192
|
+
$isInvalid={isInvalid}
|
|
193
|
+
>
|
|
185
194
|
<SelectValue>
|
|
186
195
|
{() => {
|
|
187
196
|
if (renderSelection) return renderSelection(selectedItems);
|