@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.
@@ -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,8 @@ interface MultiComboboxBaseProps {
74
75
  inputValue?: string;
75
76
  onInputValueChange?: (value: string) => void;
76
77
  "aria-describedby"?: string;
78
+ isInvalid?: boolean;
79
+ required?: boolean;
77
80
  fullWidth?: boolean;
78
81
  }
79
82
 
@@ -145,6 +148,7 @@ export function MultiCombobox<T extends DataWithId>(
145
148
  fullWidth = true,
146
149
  } = props;
147
150
  const ariaDescribedBy = props["aria-describedby"];
151
+ const isInvalid = props.isInvalid;
148
152
 
149
153
  const isChildrenMode = "children" in props && props.children != null;
150
154
 
@@ -213,6 +217,9 @@ export function MultiCombobox<T extends DataWithId>(
213
217
  const virtualizerRef = React.useRef<ReturnType<
214
218
  typeof useVirtualizer<HTMLDivElement, Element>
215
219
  > | null>(null);
220
+ const inputRef = React.useRef<HTMLInputElement>(null);
221
+
222
+ const onInputKeyDownCapture = useSuppressEscapeClear(open);
216
223
 
217
224
  const isControlled = selectedItemIds !== undefined;
218
225
 
@@ -368,6 +375,7 @@ export function MultiCombobox<T extends DataWithId>(
368
375
  e.preventDefault();
369
376
  e.stopPropagation();
370
377
  setSelectedItems(value.filter((v) => v.id !== item.id));
378
+ inputRef.current?.focus();
371
379
  }
372
380
 
373
381
  // ─── Render sentinel item ─────────────────────────────────────────────────
@@ -492,7 +500,10 @@ export function MultiCombobox<T extends DataWithId>(
492
500
  open={open}
493
501
  disabled={props.disabled}
494
502
  onOpenChange={(nextOpen, eventDetails) => {
495
- if (!nextOpen && eventDetails.reason === "item-press") return;
503
+ if (!nextOpen && eventDetails.reason === "item-press") {
504
+ eventDetails.cancel();
505
+ return;
506
+ }
496
507
  setOpen(nextOpen);
497
508
  }}
498
509
  filter={
@@ -537,13 +548,14 @@ export function MultiCombobox<T extends DataWithId>(
537
548
  if (!item || !virt) return;
538
549
  const isStart = index === 0;
539
550
  const isEnd = index === virt.options.count - 1;
540
- if (
541
- reason === "none" ||
542
- (reason === "keyboard" && (isStart || isEnd))
543
- ) {
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)) {
544
556
  queueMicrotask(() => {
545
557
  virt.scrollToIndex(index, {
546
- align: isEnd ? "start" : "end",
558
+ align: isStart ? "start" : "end",
547
559
  });
548
560
  });
549
561
  }
@@ -586,7 +598,10 @@ export function MultiCombobox<T extends DataWithId>(
586
598
  }}
587
599
  items={rootItems}
588
600
  >
589
- <ComboboxTokenInputGroup>
601
+ <ComboboxTokenInputGroup
602
+ $isInvalid={isInvalid}
603
+ onKeyDownCapture={onInputKeyDownCapture}
604
+ >
590
605
  <ComboboxToken>
591
606
  {renderSelection
592
607
  ? renderSelection(value)
@@ -616,9 +631,12 @@ export function MultiCombobox<T extends DataWithId>(
616
631
  );
617
632
  })()}
618
633
  <ComboboxTokenInput
634
+ ref={inputRef}
619
635
  id={id}
620
636
  placeholder={value.length > 0 ? "" : placeholder}
621
637
  aria-describedby={ariaDescribedBy}
638
+ aria-invalid={isInvalid || undefined}
639
+ aria-required={props.required || undefined}
622
640
  />
623
641
  <ComboboxActionButtons>
624
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: () => (
@@ -80,6 +80,8 @@ 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;
83
85
  fullWidth?: boolean;
84
86
  }
85
87
 
@@ -149,6 +151,7 @@ export function MultiSearchableSelect<T extends DataWithId>(
149
151
  fullWidth = true,
150
152
  } = props;
151
153
  const ariaDescribedBy = props["aria-describedby"];
154
+ const isInvalid = props.isInvalid;
152
155
 
153
156
  const isChildrenMode = "children" in props && props.children != null;
154
157
 
@@ -502,13 +505,10 @@ export function MultiSearchableSelect<T extends DataWithId>(
502
505
  if (!item || !virt) return;
503
506
  const isStart = index === 0;
504
507
  const isEnd = index === virt.options.count - 1;
505
- if (
506
- reason === "none" ||
507
- (reason === "keyboard" && (isStart || isEnd))
508
- ) {
508
+ if (reason === "keyboard" && (isStart || isEnd)) {
509
509
  queueMicrotask(() => {
510
510
  virt.scrollToIndex(index, {
511
- align: isEnd ? "start" : "end",
511
+ align: isStart ? "start" : "end",
512
512
  });
513
513
  });
514
514
  }
@@ -554,6 +554,9 @@ export function MultiSearchableSelect<T extends DataWithId>(
554
554
  <SearchableSelectTokenTrigger
555
555
  id={id}
556
556
  aria-describedby={ariaDescribedBy}
557
+ aria-invalid={isInvalid || undefined}
558
+ aria-required={props.required || undefined}
559
+ $isInvalid={isInvalid}
557
560
  >
558
561
  {renderSelection ? (
559
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: () => (
@@ -44,6 +44,8 @@ interface MultiSelectBaseProps {
44
44
  placeholder?: string;
45
45
  disabled?: boolean;
46
46
  "aria-describedby"?: string;
47
+ isInvalid?: boolean;
48
+ required?: boolean;
47
49
  fullWidth?: boolean;
48
50
  }
49
51
 
@@ -91,6 +93,7 @@ export type MultiSelectProps<T extends DataWithId> =
91
93
  export function MultiSelect<T extends DataWithId>(props: MultiSelectProps<T>) {
92
94
  const { id, placeholder = "Select...", fullWidth = true } = props;
93
95
  const ariaDescribedBy = props["aria-describedby"];
96
+ const isInvalid = props.isInvalid;
94
97
 
95
98
  const isChildrenMode = "children" in props && props.children != null;
96
99
 
@@ -182,7 +185,12 @@ export function MultiSelect<T extends DataWithId>(props: MultiSelectProps<T>) {
182
185
  disabled={props.disabled}
183
186
  id={id}
184
187
  >
185
- <SelectTrigger aria-describedby={ariaDescribedBy}>
188
+ <SelectTrigger
189
+ aria-describedby={ariaDescribedBy}
190
+ aria-invalid={isInvalid || undefined}
191
+ aria-required={props.required || undefined}
192
+ $isInvalid={isInvalid}
193
+ >
186
194
  <SelectValue>
187
195
  {() => {
188
196
  if (renderSelection) return renderSelection(selectedItems);
@@ -36,6 +36,25 @@ export const Basic: Story = {
36
36
  ),
37
37
  };
38
38
 
39
+ export const Invalid: Story = {
40
+ name: "Invalid",
41
+ render: () => (
42
+ <div style={{ width: "300px" }}>
43
+ <FormField label="Pick a book" isInvalid error="Please pick a book.">
44
+ {({ id, isInvalid }) => (
45
+ <SingleCombobox
46
+ id={id}
47
+ isInvalid={isInvalid}
48
+ data={books}
49
+ itemToString={itemToString}
50
+ placeholder="Search books..."
51
+ />
52
+ )}
53
+ </FormField>
54
+ </div>
55
+ ),
56
+ };
57
+
39
58
  export const CustomRenderItem: Story = {
40
59
  name: "Custom render item",
41
60
  render: () => (
@@ -2,6 +2,7 @@ import * as React from "react";
2
2
  import { Combobox } from "@base-ui/react/combobox";
3
3
  import { useVirtualizer } from "@tanstack/react-virtual";
4
4
  import { useSeedsPortalContainer } from "./SeedsPortal";
5
+ import { useSuppressEscapeClear } from "./Common/useSuppressEscapeClear";
5
6
  import ComboboxItem from "./Common/ComboboxItem";
6
7
  import VirtualizedComboboxList from "./Common/VirtualizedComboboxList";
7
8
  import type { DataWithId } from "./Common/types";
@@ -32,6 +33,8 @@ interface SingleComboboxBaseProps {
32
33
  disabled?: boolean;
33
34
  filter?: ((item: unknown, query: string) => boolean) | null;
34
35
  "aria-describedby"?: string;
36
+ isInvalid?: boolean;
37
+ required?: boolean;
35
38
  fullWidth?: boolean;
36
39
  }
37
40
 
@@ -93,6 +96,7 @@ export function SingleCombobox<T extends DataWithId>(
93
96
  fullWidth = true,
94
97
  } = props;
95
98
  const ariaDescribedBy = props["aria-describedby"];
99
+ const isInvalid = props.isInvalid;
96
100
 
97
101
  const isChildrenMode = "children" in props && props.children != null;
98
102
 
@@ -137,6 +141,8 @@ export function SingleCombobox<T extends DataWithId>(
137
141
  typeof useVirtualizer<HTMLDivElement, Element>
138
142
  > | null>(null);
139
143
 
144
+ const onInputKeyDownCapture = useSuppressEscapeClear(open);
145
+
140
146
  const isControlled = selectedItemId !== undefined;
141
147
 
142
148
  const idToItem = React.useCallback(
@@ -193,13 +199,10 @@ export function SingleCombobox<T extends DataWithId>(
193
199
  if (!item || !virt) return;
194
200
  const isStart = index === 0;
195
201
  const isEnd = index === virt.options.count - 1;
196
- if (
197
- reason === "none" ||
198
- (reason === "keyboard" && (isStart || isEnd))
199
- ) {
202
+ if (reason === "keyboard" && (isStart || isEnd)) {
200
203
  queueMicrotask(() => {
201
204
  virt.scrollToIndex(index, {
202
- align: isEnd ? "start" : "end",
205
+ align: isStart ? "start" : "end",
203
206
  });
204
207
  });
205
208
  }
@@ -207,11 +210,16 @@ export function SingleCombobox<T extends DataWithId>(
207
210
  : undefined
208
211
  }
209
212
  >
210
- <ComboboxInputGroup>
213
+ <ComboboxInputGroup
214
+ $isInvalid={isInvalid}
215
+ onKeyDownCapture={onInputKeyDownCapture}
216
+ >
211
217
  <ComboboxInput
212
218
  placeholder={placeholder}
213
219
  id={id}
214
220
  aria-describedby={ariaDescribedBy}
221
+ aria-invalid={isInvalid || undefined}
222
+ aria-required={props.required || undefined}
215
223
  />
216
224
  <ComboboxActionButtons>
217
225
  <ComboboxClear aria-label="Clear selection">
@@ -37,6 +37,26 @@ 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 label="Pick a book" isInvalid error="Please pick a book.">
45
+ {({ id, isInvalid }) => (
46
+ <SingleSearchableSelect
47
+ id={id}
48
+ isInvalid={isInvalid}
49
+ data={books}
50
+ itemToString={itemToString}
51
+ placeholder="Select a book"
52
+ searchPlaceholder="Search books..."
53
+ />
54
+ )}
55
+ </FormField>
56
+ </div>
57
+ ),
58
+ };
59
+
40
60
  export const CustomRenderItem: Story = {
41
61
  name: "Custom render item",
42
62
  render: () => (
@@ -42,6 +42,8 @@ interface SingleSearchableSelectBaseProps {
42
42
  disabled?: boolean;
43
43
  filter?: ((item: unknown, query: string) => boolean) | null;
44
44
  "aria-describedby"?: string;
45
+ isInvalid?: boolean;
46
+ required?: boolean;
45
47
  fullWidth?: boolean;
46
48
  }
47
49
 
@@ -110,6 +112,7 @@ export function SingleSearchableSelect<T extends DataWithId>(
110
112
  fullWidth = true,
111
113
  } = props;
112
114
  const ariaDescribedBy = props["aria-describedby"];
115
+ const isInvalid = props.isInvalid;
113
116
 
114
117
  const isChildrenMode = "children" in props && props.children != null;
115
118
 
@@ -226,13 +229,10 @@ export function SingleSearchableSelect<T extends DataWithId>(
226
229
  if (!item || !virt) return;
227
230
  const isStart = index === 0;
228
231
  const isEnd = index === virt.options.count - 1;
229
- if (
230
- reason === "none" ||
231
- (reason === "keyboard" && (isStart || isEnd))
232
- ) {
232
+ if (reason === "keyboard" && (isStart || isEnd)) {
233
233
  queueMicrotask(() => {
234
234
  virt.scrollToIndex(index, {
235
- align: isEnd ? "start" : "end",
235
+ align: isStart ? "start" : "end",
236
236
  });
237
237
  });
238
238
  }
@@ -240,7 +240,13 @@ export function SingleSearchableSelect<T extends DataWithId>(
240
240
  : undefined
241
241
  }
242
242
  >
243
- <SearchableSelectTrigger id={id} aria-describedby={ariaDescribedBy}>
243
+ <SearchableSelectTrigger
244
+ id={id}
245
+ aria-describedby={ariaDescribedBy}
246
+ aria-invalid={isInvalid || undefined}
247
+ aria-required={props.required || undefined}
248
+ $isInvalid={isInvalid}
249
+ >
244
250
  <SearchableSelectPlaceholder>
245
251
  <Combobox.Value placeholder={placeholder}>
246
252
  {value
@@ -37,6 +37,25 @@ 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 label="Pick a book" isInvalid error="Please pick a book.">
45
+ {({ id, isInvalid }) => (
46
+ <SingleSelect
47
+ id={id}
48
+ isInvalid={isInvalid}
49
+ data={books}
50
+ itemToString={itemToString}
51
+ placeholder="Select a book"
52
+ />
53
+ )}
54
+ </FormField>
55
+ </div>
56
+ ),
57
+ };
58
+
40
59
  export const Empty: Story = {
41
60
  name: "Empty",
42
61
  render: () => (
@@ -40,6 +40,8 @@ interface SingleSelectBaseProps {
40
40
  includePlaceholderItem?: boolean;
41
41
  disabled?: boolean;
42
42
  "aria-describedby"?: string;
43
+ isInvalid?: boolean;
44
+ required?: boolean;
43
45
  fullWidth?: boolean;
44
46
  }
45
47
 
@@ -83,6 +85,7 @@ export function SingleSelect<T extends DataWithId>(
83
85
  ) {
84
86
  const { id, placeholder, includePlaceholderItem, fullWidth = true } = props;
85
87
  const ariaDescribedBy = props["aria-describedby"];
88
+ const isInvalid = props.isInvalid;
86
89
 
87
90
  const isChildrenMode = "children" in props && props.children != null;
88
91
 
@@ -169,7 +172,12 @@ export function SingleSelect<T extends DataWithId>(
169
172
  onValueChange={handleValueChange}
170
173
  id={id}
171
174
  >
172
- <SelectTrigger aria-describedby={ariaDescribedBy}>
175
+ <SelectTrigger
176
+ aria-describedby={ariaDescribedBy}
177
+ aria-invalid={isInvalid || undefined}
178
+ aria-required={props.required || undefined}
179
+ $isInvalid={isInvalid}
180
+ >
173
181
  <StyledValue placeholder={placeholder}>
174
182
  {renderSelection && selectedItem
175
183
  ? renderSelection(selectedItem)
@@ -0,0 +1,80 @@
1
+ import React from "react";
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ screen,
6
+ } from "@sproutsocial/seeds-react-testing-library";
7
+ import { MenuButton } from "../MenuButton";
8
+ import { MenuItem } from "../ActionMenu";
9
+
10
+ describe("MenuButton", () => {
11
+ const renderMenuButton = (
12
+ props: Partial<React.ComponentProps<typeof MenuButton>> = {}
13
+ ) => {
14
+ const onMainClick = jest.fn();
15
+ render(
16
+ <MenuButton
17
+ appearance="primary"
18
+ menuButtonAriaLabel="More actions"
19
+ onClick={onMainClick}
20
+ actionMenu={
21
+ <>
22
+ <MenuItem onClick={jest.fn()}>Compose</MenuItem>
23
+ <MenuItem onClick={jest.fn()}>View Messages</MenuItem>
24
+ </>
25
+ }
26
+ {...props}
27
+ >
28
+ Open Menu
29
+ </MenuButton>
30
+ );
31
+ return { onMainClick };
32
+ };
33
+
34
+ test("renders the main button with its children", () => {
35
+ renderMenuButton();
36
+ expect(
37
+ screen.getByRole("button", { name: "Open Menu" })
38
+ ).toBeInTheDocument();
39
+ });
40
+
41
+ test("renders a separate trigger button with an accessible label", () => {
42
+ renderMenuButton();
43
+ const trigger = screen.getByRole("button", { name: "More actions" });
44
+ expect(trigger).toBeInTheDocument();
45
+ // Two distinct buttons: the main action and the menu trigger.
46
+ expect(screen.getAllByRole("button")).toHaveLength(2);
47
+ });
48
+
49
+ test("uses a custom trigger aria-label when provided", () => {
50
+ renderMenuButton({ menuButtonAriaLabel: "Save options" });
51
+ expect(
52
+ screen.getByRole("button", { name: "Save options" })
53
+ ).toBeInTheDocument();
54
+ });
55
+
56
+ test("marks the trigger as open so the chevron can rotate", () => {
57
+ renderMenuButton();
58
+ const trigger = screen.getByRole("button", { name: "More actions" });
59
+ // The chevron rotation is driven purely by the `data-popup-open` attribute
60
+ // base-ui sets on the trigger while the menu is open (jsdom does not compute
61
+ // the resulting CSS transform, so assert the driving attribute instead).
62
+ expect(trigger).not.toHaveAttribute("data-popup-open");
63
+ fireEvent.click(trigger);
64
+ expect(trigger).toHaveAttribute("data-popup-open");
65
+ });
66
+
67
+ test("fires the main button onClick without opening the menu", () => {
68
+ const { onMainClick } = renderMenuButton();
69
+ fireEvent.click(screen.getByRole("button", { name: "Open Menu" }));
70
+ expect(onMainClick).toHaveBeenCalledTimes(1);
71
+ // The menu items live behind the trigger and are not rendered until it opens.
72
+ expect(screen.queryByText("Compose")).not.toBeInTheDocument();
73
+ });
74
+
75
+ test("does not fire the main onClick when disabled", () => {
76
+ const { onMainClick } = renderMenuButton({ disabled: true });
77
+ fireEvent.click(screen.getByRole("button", { name: "Open Menu" }));
78
+ expect(onMainClick).not.toHaveBeenCalled();
79
+ });
80
+ });
package/src/v3/index.ts CHANGED
@@ -6,3 +6,4 @@ export * from "./SingleSearchableSelect";
6
6
  export * from "./MultiSearchableSelect";
7
7
  export * from "./Common/ComboboxStyles";
8
8
  export * from "./ActionMenu";
9
+ export * from "./MenuButton";