@sproutsocial/seeds-react-menu 1.13.0 → 1.16.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.
@@ -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
+ });
@@ -0,0 +1,91 @@
1
+ import React from "react";
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ screen,
6
+ within,
7
+ } from "@sproutsocial/seeds-react-testing-library";
8
+ import { MultiCombobox } from "../MultiCombobox";
9
+ import { books, itemToString, type Book } from "../storyData";
10
+
11
+ describe("MultiCombobox — disabled group headings", () => {
12
+ const renderCombobox = (
13
+ props: {
14
+ onSelectedItemIdsChange?: (ids: Array<Book["id"]>) => void;
15
+ isGroupHeaderDisabled?: (groupName: string) => boolean;
16
+ } = {}
17
+ ) =>
18
+ render(
19
+ <MultiCombobox
20
+ data={books}
21
+ itemToString={itemToString}
22
+ placeholder="Search books..."
23
+ groupBy={(book) => book.author}
24
+ selectHeadings
25
+ {...props}
26
+ />
27
+ );
28
+
29
+ const openPopup = () => {
30
+ fireEvent.click(screen.getByRole("button", { name: "Open popup" }));
31
+ };
32
+
33
+ // The group header row is the option whose accessible text is the group name.
34
+ const getGroupHeader = (groupName: string) =>
35
+ screen.getByText(groupName).closest('[role="option"]') as HTMLElement;
36
+
37
+ test("renders a header row for each group", () => {
38
+ renderCombobox();
39
+ openPopup();
40
+ expect(getGroupHeader("Harper Lee")).toBeInTheDocument();
41
+ expect(getGroupHeader("Lev Tolstoy")).toBeInTheDocument();
42
+ });
43
+
44
+ test("marks a disabled group's header with data-disabled", () => {
45
+ renderCombobox({
46
+ isGroupHeaderDisabled: (groupName) => groupName === "Harper Lee",
47
+ });
48
+ openPopup();
49
+ expect(getGroupHeader("Harper Lee")).toHaveAttribute("data-disabled");
50
+ expect(getGroupHeader("Lev Tolstoy")).not.toHaveAttribute("data-disabled");
51
+ });
52
+
53
+ test("clicking a disabled header does not select the group's items", () => {
54
+ const onSelectedItemIdsChange = jest.fn();
55
+ renderCombobox({
56
+ onSelectedItemIdsChange,
57
+ isGroupHeaderDisabled: (groupName) => groupName === "Harper Lee",
58
+ });
59
+ openPopup();
60
+ fireEvent.click(getGroupHeader("Harper Lee"));
61
+ expect(onSelectedItemIdsChange).not.toHaveBeenCalled();
62
+ });
63
+
64
+ test("clicking an enabled header still selects the whole group", () => {
65
+ const onSelectedItemIdsChange = jest.fn();
66
+ renderCombobox({
67
+ onSelectedItemIdsChange,
68
+ isGroupHeaderDisabled: (groupName) => groupName === "Harper Lee",
69
+ });
70
+ openPopup();
71
+ fireEvent.click(getGroupHeader("Lev Tolstoy"));
72
+ // Lev Tolstoy has two books: War and Peace + Anna Karenina.
73
+ expect(onSelectedItemIdsChange).toHaveBeenCalledWith(
74
+ expect.arrayContaining(["book-2", "book-9"])
75
+ );
76
+ });
77
+
78
+ test("items inside a disabled group remain individually selectable", () => {
79
+ const onSelectedItemIdsChange = jest.fn();
80
+ renderCombobox({
81
+ onSelectedItemIdsChange,
82
+ isGroupHeaderDisabled: (groupName) => groupName === "Harper Lee",
83
+ });
84
+ openPopup();
85
+ const option = screen
86
+ .getByText("To Kill a Mockingbird")
87
+ .closest('[role="option"]') as HTMLElement;
88
+ fireEvent.click(within(option).getByText("To Kill a Mockingbird"));
89
+ expect(onSelectedItemIdsChange).toHaveBeenCalledWith(["book-1"]);
90
+ });
91
+ });
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";