@sproutsocial/seeds-react-menu 1.13.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutsocial/seeds-react-menu",
3
- "version": "1.13.0",
3
+ "version": "1.15.10",
4
4
  "description": "Seeds React Menu",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -36,18 +36,18 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@radix-ui/react-popover": "^1.1.2",
39
- "@sproutsocial/seeds-react-box": "^1.1.16",
40
- "@sproutsocial/seeds-react-button": "^2.0.5",
41
- "@sproutsocial/seeds-react-checkbox": "^1.3.33",
42
- "@sproutsocial/seeds-react-icon": "^2.3.7",
43
- "@sproutsocial/seeds-react-input": "^1.5.16",
44
- "@sproutsocial/seeds-react-label": "^1.0.16",
45
- "@sproutsocial/seeds-react-popout": "^2.4.38",
46
- "@sproutsocial/seeds-react-radio": "^1.3.16",
47
- "@sproutsocial/seeds-react-switch": "^1.2.32",
48
- "@sproutsocial/seeds-react-system-props": "^3.0.2",
49
- "@sproutsocial/seeds-react-theme": "^3.6.2",
50
- "@sproutsocial/seeds-react-token-input": "^1.4.38",
39
+ "@sproutsocial/seeds-react-box": "^1.1.21",
40
+ "@sproutsocial/seeds-react-button": "^2.2.1",
41
+ "@sproutsocial/seeds-react-checkbox": "^1.3.41",
42
+ "@sproutsocial/seeds-react-icon": "^2.4.0",
43
+ "@sproutsocial/seeds-react-input": "^1.5.26",
44
+ "@sproutsocial/seeds-react-label": "^1.0.22",
45
+ "@sproutsocial/seeds-react-popout": "^2.5.9",
46
+ "@sproutsocial/seeds-react-radio": "^1.3.22",
47
+ "@sproutsocial/seeds-react-switch": "^1.2.39",
48
+ "@sproutsocial/seeds-react-system-props": "^3.1.1",
49
+ "@sproutsocial/seeds-react-theme": "^4.1.0",
50
+ "@sproutsocial/seeds-react-token-input": "^1.4.48",
51
51
  "@sproutsocial/seeds-react-utilities": "^4.3.0",
52
52
  "@base-ui/react": "^1.3.0",
53
53
  "@tanstack/react-virtual": "^3.13.12",
@@ -57,7 +57,7 @@
57
57
  "styled-system": "^5.1.5"
58
58
  },
59
59
  "devDependencies": {
60
- "@sproutsocial/seeds-react-tooltip": "^1.1.20",
60
+ "@sproutsocial/seeds-react-tooltip": "^1.1.27",
61
61
  "@sproutsocial/eslint-config-seeds": "*",
62
62
  "@sproutsocial/seeds-react-testing-library": "*",
63
63
  "@sproutsocial/seeds-testing": "*",
@@ -9,16 +9,21 @@ export const ActionMenuPositioner = styled(Menu.Positioner)`
9
9
  z-index: 7;
10
10
  `;
11
11
 
12
- export const ActionMenuPopup = styled(Menu.Popup)`
12
+ // Opt out of Base UI Drawer's swipe-to-dismiss pointer capture when the menu
13
+ // is rendered inside a Drawer. Without this, the Drawer's viewport captures
14
+ // pointermove/up on portaled popups and MenuItem clicks never fire.
15
+ export const ActionMenuPopup = styled(Menu.Popup).attrs({
16
+ "data-base-ui-swipe-ignore": "",
17
+ })`
13
18
  font-family: ${({ theme }) => theme.fontFamily};
14
19
  background: ${({ theme }) => theme.colors.container.background.base};
15
20
  border: 1px solid ${({ theme }) => theme.colors.container.border.base};
16
21
  border-radius: ${({ theme }) => theme.radii[500]};
17
22
  box-shadow: ${({ theme }) => theme.shadows.low};
18
23
  outline: none;
19
- max-height: min(var(--base-ui-available-height, 400px), 400px);
24
+ max-height: min(var(--available-height, 400px), 400px);
20
25
  overflow-y: auto;
21
- padding: ${({ theme }) => theme.space[200]};
26
+ padding: ${({ theme }) => theme.space[300]};
22
27
 
23
28
  &[data-open] {
24
29
  animation: action-menu-popup-in 120ms ease-out;
@@ -56,18 +61,14 @@ export const ActionMenuGroup = styled(Menu.Group)`
56
61
  `;
57
62
 
58
63
  export const ActionMenuGroupLabel = styled(Menu.GroupLabel)`
59
- padding: ${({ theme }) =>
60
- `${theme.space[300]} ${theme.space[300]} ${theme.space[200]}`};
64
+ padding: ${({ theme }) => `${theme.space[300]}`};
61
65
  font-size: ${({ theme }) => theme.typography[100].fontSize};
62
- font-weight: ${({ theme }) => theme.fontWeights.semibold};
63
- text-transform: uppercase;
64
- letter-spacing: 0.05em;
65
- color: ${({ theme }) => theme.colors.text.subtext};
66
+ color: ${({ theme }) => theme.colors.text.body};
66
67
  `;
67
68
 
68
69
  export const ActionMenuSeparator = styled(Menu.Separator)`
69
70
  height: 1px;
70
- margin: ${({ theme }) => `${theme.space[200]} ${theme.space[300]}`};
71
+ margin: ${({ theme }) => `${theme.space[300]} -${theme.space[300]}`};
71
72
  background: ${({ theme }) => theme.colors.container.border.base};
72
73
  `;
73
74
 
@@ -1,7 +1,16 @@
1
1
  import { Combobox } from "@base-ui/react/combobox";
2
- import styled from "styled-components";
2
+ import styled, { css } from "styled-components";
3
3
  import { focusRing } from "@sproutsocial/seeds-react-mixins";
4
4
  import Checkbox from "@sproutsocial/seeds-react-checkbox";
5
+ import { StyledChevron } from "./SelectIcons";
6
+
7
+ const invalidStyles = css`
8
+ border-color: ${({ theme }) => theme.colors.form.border.error};
9
+
10
+ ${StyledChevron} {
11
+ color: ${({ theme }) => theme.colors.icon.error};
12
+ }
13
+ `;
5
14
 
6
15
  export const ComboboxLabel = styled.label`
7
16
  font-size: ${({ theme }) => theme.typography[200].fontSize};
@@ -9,7 +18,9 @@ export const ComboboxLabel = styled.label`
9
18
  color: ${({ theme }) => theme.colors.text.body};
10
19
  `;
11
20
 
12
- export const ComboboxInputGroup = styled(Combobox.InputGroup)`
21
+ export const ComboboxInputGroup = styled(Combobox.InputGroup)<{
22
+ $isInvalid?: boolean;
23
+ }>`
13
24
  display: flex;
14
25
  align-items: center;
15
26
  min-width: 160px;
@@ -30,6 +41,8 @@ export const ComboboxInputGroup = styled(Combobox.InputGroup)`
30
41
  opacity: 0.4;
31
42
  cursor: not-allowed;
32
43
  }
44
+
45
+ ${({ $isInvalid }) => $isInvalid && invalidStyles}
33
46
  `;
34
47
 
35
48
  export const ComboboxInput = styled(Combobox.Input)`
@@ -81,7 +94,7 @@ export const ComboboxActionButtons = styled.div`
81
94
  gap: ${({ theme }) => theme.space[100]};
82
95
  `;
83
96
 
84
- export const ComboboxClear = styled(Combobox.Clear)`
97
+ export const ComboboxClear = styled(Combobox.Clear).attrs({ tabIndex: 0 })`
85
98
  display: flex;
86
99
  align-items: center;
87
100
  justify-content: center;
@@ -98,6 +111,10 @@ export const ComboboxClear = styled(Combobox.Clear)`
98
111
  color: ${({ theme }) => theme.colors.text.body};
99
112
  }
100
113
 
114
+ &:focus-visible {
115
+ ${focusRing}
116
+ }
117
+
101
118
  &[data-empty] {
102
119
  display: none;
103
120
  }
@@ -133,7 +150,7 @@ export const ComboboxPopup = styled(Combobox.Popup)`
133
150
  box-shadow: ${({ theme }) => theme.shadows.medium};
134
151
  outline: none;
135
152
  width: 100%;
136
- max-height: min(var(--base-ui-available-height, 400px), 400px);
153
+ max-height: min(var(--available-height, 400px), 400px);
137
154
  overflow-y: auto;
138
155
  padding: 0;
139
156
 
@@ -315,9 +332,12 @@ export const ComboboxToken = styled(Combobox.Chips)`
315
332
  display: flex;
316
333
  width: 100%;
317
334
  flex-wrap: wrap;
335
+ gap: ${({ theme }) => theme.space[200]};
318
336
  `;
319
337
 
320
- export const ComboboxTokenInputGroup = styled(Combobox.InputGroup)`
338
+ export const ComboboxTokenInputGroup = styled(Combobox.InputGroup)<{
339
+ $isInvalid?: boolean;
340
+ }>`
321
341
  display: flex;
322
342
  align-items: center;
323
343
  flex-wrap: wrap;
@@ -341,6 +361,8 @@ export const ComboboxTokenInputGroup = styled(Combobox.InputGroup)`
341
361
  opacity: 0.4;
342
362
  cursor: not-allowed;
343
363
  }
364
+
365
+ ${({ $isInvalid }) => $isInvalid && invalidStyles}
344
366
  `;
345
367
 
346
368
  export const Token = styled.span`
@@ -375,11 +397,17 @@ export const TokenRemove = styled.button`
375
397
  cursor: pointer;
376
398
  padding: 0;
377
399
  line-height: 1;
400
+
401
+ &:focus-visible {
402
+ ${focusRing}
403
+ }
378
404
  `;
379
405
 
380
406
  // Searchable select components
381
407
 
382
- export const SearchableSelectTrigger = styled(Combobox.Trigger)`
408
+ export const SearchableSelectTrigger = styled(Combobox.Trigger)<{
409
+ $isInvalid?: boolean;
410
+ }>`
383
411
  display: flex;
384
412
  align-items: center;
385
413
  justify-content: space-between;
@@ -411,6 +439,8 @@ export const SearchableSelectTrigger = styled(Combobox.Trigger)`
411
439
  opacity: 0.4;
412
440
  cursor: not-allowed;
413
441
  }
442
+
443
+ ${({ $isInvalid }) => $isInvalid && invalidStyles}
414
444
  `;
415
445
 
416
446
  export const SearchableSelectTriggerIcon = styled(Combobox.Icon)`
@@ -464,7 +494,9 @@ export const SearchableSelectList = styled(Combobox.List)`
464
494
  }
465
495
  `;
466
496
 
467
- export const SearchableSelectTokenTrigger = styled(Combobox.Trigger)`
497
+ export const SearchableSelectTokenTrigger = styled(Combobox.Trigger)<{
498
+ $isInvalid?: boolean;
499
+ }>`
468
500
  display: flex;
469
501
  align-items: center;
470
502
  flex-wrap: wrap;
@@ -500,4 +532,6 @@ export const SearchableSelectTokenTrigger = styled(Combobox.Trigger)`
500
532
  opacity: 0.4;
501
533
  cursor: not-allowed;
502
534
  }
535
+
536
+ ${({ $isInvalid }) => $isInvalid && invalidStyles}
503
537
  `;
@@ -1,6 +1,15 @@
1
1
  import { Select } from "@base-ui/react/select";
2
- import styled from "styled-components";
2
+ import styled, { css } from "styled-components";
3
3
  import { focusRing } from "@sproutsocial/seeds-react-mixins";
4
+ import { StyledChevron } from "./SelectIcons";
5
+
6
+ const invalidStyles = css`
7
+ border-color: ${({ theme }) => theme.colors.form.border.error};
8
+
9
+ ${StyledChevron} {
10
+ color: ${({ theme }) => theme.colors.icon.error};
11
+ }
12
+ `;
4
13
 
5
14
  export const Field = styled.div<{ $fullWidth?: boolean }>`
6
15
  display: ${({ $fullWidth }) =>
@@ -16,7 +25,9 @@ export const SelectLabel = styled(Select.Label)`
16
25
  color: ${({ theme }) => theme.colors.text.body};
17
26
  `;
18
27
 
19
- export const SelectTrigger = styled(Select.Trigger)`
28
+ export const SelectTrigger = styled(Select.Trigger)<{
29
+ $isInvalid?: boolean;
30
+ }>`
20
31
  display: flex;
21
32
  align-items: center;
22
33
  justify-content: space-between;
@@ -51,6 +62,8 @@ export const SelectTrigger = styled(Select.Trigger)`
51
62
  opacity: 0.4;
52
63
  cursor: not-allowed;
53
64
  }
65
+
66
+ ${({ $isInvalid }) => $isInvalid && invalidStyles}
54
67
  `;
55
68
 
56
69
  export const SelectValue = styled(Select.Value)`
@@ -97,7 +110,7 @@ export const SelectPopup = styled(Select.Popup)`
97
110
  border-radius: ${({ theme }) => theme.radii[500]};
98
111
  box-shadow: ${({ theme }) => theme.shadows.low};
99
112
  outline: none;
100
- max-height: min(var(--base-ui-available-height, 400px), 400px);
113
+ max-height: min(var(--available-height, 400px), 400px);
101
114
  overflow-y: auto;
102
115
  padding: ${({ theme }) => theme.space[200]};
103
116
 
@@ -0,0 +1,20 @@
1
+ import * as React from "react";
2
+
3
+ // When the popup is closed, Base UI's ComboboxInput onKeyDown handler clears
4
+ // both the typed input value and the committed selection on Escape (an
5
+ // ARIA-permitted but destructive interpretation of the combobox pattern). We
6
+ // suppress that path by stopping the synthetic event in capture phase before
7
+ // it reaches the input's React bubble-phase handler. The popup-open Escape
8
+ // (handled by Base UI's document listener in useDismiss) and the parent
9
+ // Modal/Dialog Escape (handled by Radix's document-capture listener, which
10
+ // runs before any React synthetic dispatch) are both unaffected.
11
+ export function useSuppressEscapeClear(open: boolean) {
12
+ return React.useCallback(
13
+ (event: React.KeyboardEvent) => {
14
+ if (event.key === "Escape" && !open) {
15
+ event.stopPropagation();
16
+ }
17
+ },
18
+ [open]
19
+ );
20
+ }
@@ -0,0 +1,118 @@
1
+ import React from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { action } from "@storybook/addon-actions";
4
+ import { Box } from "@sproutsocial/seeds-react-box";
5
+ import { MenuButton } from "./MenuButton";
6
+ import { MenuItem } from "./ActionMenu";
7
+
8
+ const meta: Meta<typeof MenuButton> = {
9
+ title: "Really Under Development/V3/MenuButton",
10
+ component: MenuButton,
11
+ decorators: [
12
+ (Story) => (
13
+ <Box>
14
+ <Story />
15
+ </Box>
16
+ ),
17
+ ],
18
+ };
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof MenuButton>;
22
+
23
+ // ─── Basic ────────────────────────────────────────────────────────────────────
24
+
25
+ export const Basic: Story = {
26
+ name: "Basic",
27
+ render: () => (
28
+ <MenuButton
29
+ menuButtonAriaLabel="More compose options"
30
+ actionMenu={
31
+ <>
32
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
33
+ <MenuItem onClick={action("View Messages")}>View Messages</MenuItem>
34
+ <MenuItem onClick={action("Analyze post")}>Analyze post</MenuItem>
35
+ </>
36
+ }
37
+ onClick={action("Open Menu")}
38
+ >
39
+ Open Menu
40
+ </MenuButton>
41
+ ),
42
+ };
43
+
44
+ // ─── Primary ──────────────────────────────────────────────────────────────────
45
+
46
+ export const Primary: Story = {
47
+ name: "Primary",
48
+ render: () => {
49
+ // The dropdown selects the action; the main button reflects and performs it.
50
+ const actions = [
51
+ { label: "Save as Draft", run: action("Save as Draft") },
52
+ { label: "Save and Activate", run: action("Save and Activate") },
53
+ ];
54
+ const [selected, setSelected] = React.useState(actions[0]);
55
+
56
+ return (
57
+ <MenuButton
58
+ appearance="primary"
59
+ menuButtonAriaLabel="More save options"
60
+ actionMenu={
61
+ <>
62
+ {actions.map((a) => (
63
+ <MenuItem key={a.label} onClick={() => setSelected(a)}>
64
+ {a.label}
65
+ </MenuItem>
66
+ ))}
67
+ </>
68
+ }
69
+ onClick={(e) => selected.run(e)}
70
+ >
71
+ {selected.label}
72
+ </MenuButton>
73
+ );
74
+ },
75
+ };
76
+
77
+ // ─── Secondary ────────────────────────────────────────────────────────────────
78
+
79
+ export const Secondary: Story = {
80
+ name: "Secondary",
81
+ render: () => (
82
+ <MenuButton
83
+ appearance="secondary"
84
+ menuButtonAriaLabel="More actions"
85
+ actionMenu={
86
+ <>
87
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
88
+ <MenuItem onClick={action("View Messages")}>View Messages</MenuItem>
89
+ </>
90
+ }
91
+ onClick={action("Open Menu")}
92
+ >
93
+ Open Menu
94
+ </MenuButton>
95
+ ),
96
+ };
97
+
98
+ // ─── Disabled ─────────────────────────────────────────────────────────────────
99
+
100
+ export const Disabled: Story = {
101
+ name: "Disabled",
102
+ render: () => (
103
+ <MenuButton
104
+ appearance="primary"
105
+ disabled
106
+ menuButtonAriaLabel="More actions"
107
+ actionMenu={
108
+ <>
109
+ <MenuItem onClick={action("Compose")}>Compose</MenuItem>
110
+ <MenuItem onClick={action("View Messages")}>View Messages</MenuItem>
111
+ </>
112
+ }
113
+ onClick={action("Open Menu")}
114
+ >
115
+ Open Menu
116
+ </MenuButton>
117
+ ),
118
+ };
@@ -0,0 +1,68 @@
1
+ import * as React from "react";
2
+ import { type TypeButtonProps } from "@sproutsocial/seeds-react-button";
3
+ import { Icon } from "@sproutsocial/seeds-react-icon";
4
+ import { ActionMenu } from "./ActionMenu";
5
+ import {
6
+ MenuButtonContainer,
7
+ MainButton,
8
+ TriggerButton,
9
+ } from "./MenuButtonStyles";
10
+
11
+ // ─── MenuButton ─────────────────────────────────────────────────────────────
12
+
13
+ export interface MenuButtonProps extends TypeButtonProps {
14
+ /**
15
+ * Menu item content rendered inside the dropdown half — the children of an
16
+ * ActionMenu (MenuItems / MenuGroups).
17
+ */
18
+ actionMenu: React.ReactNode;
19
+ /**
20
+ * Accessible label for the chevron-only trigger button. Required so consumers
21
+ * supply a localizable label rather than relying on a hardcoded fallback.
22
+ */
23
+ menuButtonAriaLabel: string;
24
+ }
25
+
26
+ /**
27
+ * A "split button": a primary Button rendered immediately next to an ActionMenu
28
+ * whose trigger is an identical Button showing only a chevron-down icon. The
29
+ * two read as one control — the main button's right corners and the trigger's
30
+ * left corners are squared off with a 1px gap between them.
31
+ */
32
+ export function MenuButton({
33
+ actionMenu,
34
+ appearance = "primary",
35
+ size,
36
+ disabled,
37
+ menuButtonAriaLabel,
38
+ children,
39
+ ...rest
40
+ }: MenuButtonProps) {
41
+ return (
42
+ <MenuButtonContainer>
43
+ <MainButton
44
+ appearance={appearance}
45
+ size={size}
46
+ disabled={disabled}
47
+ {...rest}
48
+ >
49
+ {children}
50
+ </MainButton>
51
+ <ActionMenu
52
+ disabled={disabled}
53
+ trigger={
54
+ <TriggerButton
55
+ appearance={appearance}
56
+ size={size}
57
+ disabled={disabled}
58
+ ariaLabel={menuButtonAriaLabel}
59
+ >
60
+ <Icon name="chevron-down-outline" fixedWidth aria-hidden />
61
+ </TriggerButton>
62
+ }
63
+ >
64
+ {actionMenu}
65
+ </ActionMenu>
66
+ </MenuButtonContainer>
67
+ );
68
+ }
@@ -0,0 +1,38 @@
1
+ import styled from "styled-components";
2
+ import { Button } from "@sproutsocial/seeds-react-button";
3
+
4
+ // A 1px gap separates the two halves of the split button (fixed pixel value
5
+ // per the design, not a theme token).
6
+ export const MenuButtonContainer = styled.div`
7
+ display: inline-flex;
8
+ gap: 1px;
9
+ `;
10
+
11
+ // Square off the inner (right) corners of the main button so it reads as one
12
+ // control with the trigger. The `&&` specificity bump beats the base Button
13
+ // Container's own `border-radius` (equal-specificity, source-order dependent).
14
+ export const MainButton = styled(Button)`
15
+ && {
16
+ border-top-right-radius: 0;
17
+ border-bottom-right-radius: 0;
18
+ }
19
+ `;
20
+
21
+ // Square off the inner (left) corners of the chevron trigger button. The
22
+ // chevron rotates 180° while the menu is open: base-ui's Menu.Trigger sets
23
+ // `data-popup-open` on this element (the base Button spreads it onto its root
24
+ // DOM node), mirroring the open-state selector convention in ActionMenuStyles.
25
+ export const TriggerButton = styled(Button)`
26
+ && {
27
+ border-top-left-radius: 0;
28
+ border-bottom-left-radius: 0;
29
+ }
30
+
31
+ .Icon-svg {
32
+ transition: transform ${({ theme }) => theme.duration.fast} ease;
33
+ }
34
+
35
+ &[data-popup-open] .Icon-svg {
36
+ transform: rotate(180deg);
37
+ }
38
+ `;
@@ -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: () => (
@@ -472,3 +500,115 @@ export const Creatable: Story = {
472
500
  );
473
501
  },
474
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
+ };