@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.
Files changed (35) hide show
  1. package/.turbo/turbo-build.log +12 -12
  2. package/CHANGELOG.md +193 -0
  3. package/dist/esm/v3/index.js +726 -186
  4. package/dist/esm/v3/index.js.map +1 -1
  5. package/dist/index.d.mts +22 -22
  6. package/dist/index.d.ts +22 -22
  7. package/dist/v3/index.d.mts +199 -50
  8. package/dist/v3/index.d.ts +199 -50
  9. package/dist/v3/index.js +732 -185
  10. package/dist/v3/index.js.map +1 -1
  11. package/package.json +14 -14
  12. package/src/v3/ActionMenu.stories.tsx +260 -0
  13. package/src/v3/ActionMenu.tsx +345 -0
  14. package/src/v3/ActionMenuStyles.tsx +154 -0
  15. package/src/v3/Common/ComboboxStyles.tsx +41 -7
  16. package/src/v3/Common/SelectStyles.tsx +20 -6
  17. package/src/v3/Common/useSuppressEscapeClear.ts +20 -0
  18. package/src/v3/MenuButton.stories.tsx +118 -0
  19. package/src/v3/MenuButton.tsx +68 -0
  20. package/src/v3/MenuButtonStyles.tsx +38 -0
  21. package/src/v3/ModalStories.stories.tsx +159 -0
  22. package/src/v3/MultiCombobox.stories.tsx +157 -0
  23. package/src/v3/MultiCombobox.tsx +28 -8
  24. package/src/v3/MultiSearchableSelect.stories.tsx +42 -0
  25. package/src/v3/MultiSearchableSelect.tsx +11 -6
  26. package/src/v3/MultiSelect.stories.tsx +40 -0
  27. package/src/v3/MultiSelect.tsx +12 -3
  28. package/src/v3/SingleCombobox.stories.tsx +36 -0
  29. package/src/v3/SingleCombobox.tsx +17 -7
  30. package/src/v3/SingleSearchableSelect.stories.tsx +38 -0
  31. package/src/v3/SingleSearchableSelect.tsx +15 -7
  32. package/src/v3/SingleSelect.stories.tsx +36 -0
  33. package/src/v3/SingleSelect.tsx +12 -3
  34. package/src/v3/__tests__/MenuButton.test.tsx +80 -0
  35. package/src/v3/index.ts +2 -0
@@ -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: () => (
@@ -192,6 +211,23 @@ export const Virtualized: Story = {
192
211
  ),
193
212
  };
194
213
 
214
+ export const Inline: Story = {
215
+ name: "Inline (fullWidth=false)",
216
+ render: () => (
217
+ <FormField label="Pick a book">
218
+ {({ id }) => (
219
+ <SingleCombobox
220
+ id={id}
221
+ data={books}
222
+ itemToString={itemToString}
223
+ placeholder="Search books..."
224
+ fullWidth={false}
225
+ />
226
+ )}
227
+ </FormField>
228
+ ),
229
+ };
230
+
195
231
  export const CustomSearch: Story = {
196
232
  name: "Custom search (title + author)",
197
233
  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,9 @@ 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;
38
+ fullWidth?: boolean;
35
39
  }
36
40
 
37
41
  interface SingleComboboxDataBaseProps<T extends DataWithId>
@@ -89,8 +93,10 @@ export function SingleCombobox<T extends DataWithId>(
89
93
  placeholder = "Search...",
90
94
  emptyText = "No results found.",
91
95
  filter,
96
+ fullWidth = true,
92
97
  } = props;
93
98
  const ariaDescribedBy = props["aria-describedby"];
99
+ const isInvalid = props.isInvalid;
94
100
 
95
101
  const isChildrenMode = "children" in props && props.children != null;
96
102
 
@@ -135,6 +141,8 @@ export function SingleCombobox<T extends DataWithId>(
135
141
  typeof useVirtualizer<HTMLDivElement, Element>
136
142
  > | null>(null);
137
143
 
144
+ const onInputKeyDownCapture = useSuppressEscapeClear(open);
145
+
138
146
  const isControlled = selectedItemId !== undefined;
139
147
 
140
148
  const idToItem = React.useCallback(
@@ -168,7 +176,7 @@ export function SingleCombobox<T extends DataWithId>(
168
176
  );
169
177
 
170
178
  return (
171
- <Field>
179
+ <Field $fullWidth={fullWidth}>
172
180
  <div ref={containerRef} style={{ position: "relative" }} />
173
181
  <Combobox.Root
174
182
  id={id}
@@ -191,13 +199,10 @@ export function SingleCombobox<T extends DataWithId>(
191
199
  if (!item || !virt) return;
192
200
  const isStart = index === 0;
193
201
  const isEnd = index === virt.options.count - 1;
194
- if (
195
- reason === "none" ||
196
- (reason === "keyboard" && (isStart || isEnd))
197
- ) {
202
+ if (reason === "keyboard" && (isStart || isEnd)) {
198
203
  queueMicrotask(() => {
199
204
  virt.scrollToIndex(index, {
200
- align: isEnd ? "start" : "end",
205
+ align: isStart ? "start" : "end",
201
206
  });
202
207
  });
203
208
  }
@@ -205,11 +210,16 @@ export function SingleCombobox<T extends DataWithId>(
205
210
  : undefined
206
211
  }
207
212
  >
208
- <ComboboxInputGroup>
213
+ <ComboboxInputGroup
214
+ $isInvalid={isInvalid}
215
+ onKeyDownCapture={onInputKeyDownCapture}
216
+ >
209
217
  <ComboboxInput
210
218
  placeholder={placeholder}
211
219
  id={id}
212
220
  aria-describedby={ariaDescribedBy}
221
+ aria-invalid={isInvalid || undefined}
222
+ aria-required={props.required || undefined}
213
223
  />
214
224
  <ComboboxActionButtons>
215
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: () => (
@@ -304,6 +324,24 @@ export const LoadingWithToggle: Story = {
304
324
  },
305
325
  };
306
326
 
327
+ export const Inline: Story = {
328
+ name: "Inline (fullWidth=false)",
329
+ render: () => (
330
+ <FormField label="Pick a book">
331
+ {({ id }) => (
332
+ <SingleSearchableSelect
333
+ id={id}
334
+ data={books}
335
+ itemToString={itemToString}
336
+ placeholder="Select a book"
337
+ searchPlaceholder="Search books..."
338
+ fullWidth={false}
339
+ />
340
+ )}
341
+ </FormField>
342
+ ),
343
+ };
344
+
307
345
  export const CustomSearch: Story = {
308
346
  name: "Custom search (title + author)",
309
347
  render: () => (
@@ -42,6 +42,9 @@ 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;
47
+ fullWidth?: boolean;
45
48
  }
46
49
 
47
50
  interface SingleSearchableSelectDataBaseProps<T extends DataWithId>
@@ -106,8 +109,10 @@ export function SingleSearchableSelect<T extends DataWithId>(
106
109
  isLoading = false,
107
110
  loadingText = "Loading...",
108
111
  filter,
112
+ fullWidth = true,
109
113
  } = props;
110
114
  const ariaDescribedBy = props["aria-describedby"];
115
+ const isInvalid = props.isInvalid;
111
116
 
112
117
  const isChildrenMode = "children" in props && props.children != null;
113
118
 
@@ -190,7 +195,7 @@ export function SingleSearchableSelect<T extends DataWithId>(
190
195
  );
191
196
 
192
197
  return (
193
- <Field>
198
+ <Field $fullWidth={fullWidth}>
194
199
  <div ref={containerRef} style={{ position: "relative" }} />
195
200
  <Combobox.Root
196
201
  id={id}
@@ -224,13 +229,10 @@ export function SingleSearchableSelect<T extends DataWithId>(
224
229
  if (!item || !virt) return;
225
230
  const isStart = index === 0;
226
231
  const isEnd = index === virt.options.count - 1;
227
- if (
228
- reason === "none" ||
229
- (reason === "keyboard" && (isStart || isEnd))
230
- ) {
232
+ if (reason === "keyboard" && (isStart || isEnd)) {
231
233
  queueMicrotask(() => {
232
234
  virt.scrollToIndex(index, {
233
- align: isEnd ? "start" : "end",
235
+ align: isStart ? "start" : "end",
234
236
  });
235
237
  });
236
238
  }
@@ -238,7 +240,13 @@ export function SingleSearchableSelect<T extends DataWithId>(
238
240
  : undefined
239
241
  }
240
242
  >
241
- <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
+ >
242
250
  <SearchableSelectPlaceholder>
243
251
  <Combobox.Value placeholder={placeholder}>
244
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: () => (
@@ -259,6 +278,23 @@ export const DisabledItems: Story = {
259
278
  ),
260
279
  };
261
280
 
281
+ export const Inline: Story = {
282
+ name: "Inline (fullWidth=false)",
283
+ render: () => (
284
+ <FormField label="Pick a book">
285
+ {({ id }) => (
286
+ <SingleSelect
287
+ id={id}
288
+ data={books}
289
+ itemToString={itemToString}
290
+ placeholder="Select a book"
291
+ fullWidth={false}
292
+ />
293
+ )}
294
+ </FormField>
295
+ ),
296
+ };
297
+
262
298
  export const Children: Story = {
263
299
  name: "Children API",
264
300
  render: () => (
@@ -40,6 +40,9 @@ interface SingleSelectBaseProps {
40
40
  includePlaceholderItem?: boolean;
41
41
  disabled?: boolean;
42
42
  "aria-describedby"?: string;
43
+ isInvalid?: boolean;
44
+ required?: boolean;
45
+ fullWidth?: boolean;
43
46
  }
44
47
 
45
48
  interface SingleSelectDataProps<T extends DataWithId>
@@ -80,8 +83,9 @@ export type SingleSelectProps<T extends DataWithId> =
80
83
  export function SingleSelect<T extends DataWithId>(
81
84
  props: SingleSelectProps<T>
82
85
  ) {
83
- const { id, placeholder, includePlaceholderItem } = props;
86
+ const { id, placeholder, includePlaceholderItem, fullWidth = true } = props;
84
87
  const ariaDescribedBy = props["aria-describedby"];
88
+ const isInvalid = props.isInvalid;
85
89
 
86
90
  const isChildrenMode = "children" in props && props.children != null;
87
91
 
@@ -159,7 +163,7 @@ export function SingleSelect<T extends DataWithId>(
159
163
  }
160
164
 
161
165
  return (
162
- <Field>
166
+ <Field $fullWidth={fullWidth}>
163
167
  <div ref={containerRef} style={{ position: "relative" }} />
164
168
  <Select.Root
165
169
  items={items}
@@ -168,7 +172,12 @@ export function SingleSelect<T extends DataWithId>(
168
172
  onValueChange={handleValueChange}
169
173
  id={id}
170
174
  >
171
- <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
+ >
172
181
  <StyledValue placeholder={placeholder}>
173
182
  {renderSelection && selectedItem
174
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
@@ -5,3 +5,5 @@ export * from "./MultiCombobox";
5
5
  export * from "./SingleSearchableSelect";
6
6
  export * from "./MultiSearchableSelect";
7
7
  export * from "./Common/ComboboxStyles";
8
+ export * from "./ActionMenu";
9
+ export * from "./MenuButton";