@sproutsocial/seeds-react-menu 1.10.7 → 1.10.9

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.10.7",
3
+ "version": "1.10.9",
4
4
  "description": "Seeds React Menu",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -48,11 +48,19 @@ const MenuGroup = <I extends TypeMenuItemProps = TypeMenuItemProps>({
48
48
  return null;
49
49
  }
50
50
  return (
51
+ // role="none" on the outer <li>: this wrapper is purely visual (it exists
52
+ // for the border-bottom divider between groups in the parent menu <ul>).
53
+ // The ARIA group semantics live on the inner <ul> below — axe rejects
54
+ // role="group" on <li>, and a native <ul> here would trigger the `list`
55
+ // rule because its children are <li role="none"> menuitem wrappers.
56
+ //
57
+ // TODO(DS-4273): drop the list-based DOM entirely (MenuContent/MenuGroup
58
+ // as <div>, no <ul>/<li>, no role="none" wrappers). This is the followup
59
+ // to DS-4271's minimal ARIA patch.
51
60
  <StyledMenuGroup
52
- role="group"
61
+ role="none"
53
62
  id={id}
54
63
  isSingleSelect={isSingleSelect}
55
- aria-labelledby={title ? titleId : undefined}
56
64
  // TODO: fix this type since `color` should be valid here. TS can't resolve the correct type.
57
65
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
58
66
  // @ts-ignore
@@ -64,7 +72,12 @@ const MenuGroup = <I extends TypeMenuItemProps = TypeMenuItemProps>({
64
72
  {title}
65
73
  </StyledTitle>
66
74
  )}
67
- <StyledMenuGroupUl>{children}</StyledMenuGroupUl>
75
+ <StyledMenuGroupUl
76
+ role="group"
77
+ aria-labelledby={title ? titleId : undefined}
78
+ >
79
+ {children}
80
+ </StyledMenuGroupUl>
68
81
  </StyledMenuGroup>
69
82
  );
70
83
  };
@@ -207,6 +207,25 @@ describe("MenuGroup", () => {
207
207
  ).toBeInTheDocument();
208
208
  });
209
209
 
210
+ it("does not place role=group on an <li> (axe aria-allowed-role)", () => {
211
+ render(
212
+ <ActionMenu
213
+ defaultIsOpen
214
+ menuToggleElement={<MenuToggleButton>Test Menu</MenuToggleButton>}
215
+ >
216
+ <MenuContent>
217
+ <MenuGroup id="group">
218
+ <MenuItem id="item-1">Item 1</MenuItem>
219
+ </MenuGroup>
220
+ </MenuContent>
221
+ </ActionMenu>
222
+ );
223
+
224
+ // role="group" must live on the inner <ul>, not the outer <li>.
225
+ const group = screen.getByRole("group");
226
+ expect(group.tagName).toBe("UL");
227
+ });
228
+
210
229
  it("when no title is provided, it should not render a title", () => {
211
230
  render(
212
231
  <SingleSelectMenu
@@ -39,6 +39,26 @@ const MenuItem = ({
39
39
 
40
40
  const highlighted = highlightedIndex === item?.index;
41
41
 
42
+ const downshiftItemProps =
43
+ getItemProps?.({
44
+ ...(isListbox ? {} : { role: "menuitem" }),
45
+ item,
46
+ index: item.index,
47
+ // The disabled prop no longer supported in downshift. Use isItemDisabled instead.
48
+ // disabled,
49
+ onClick,
50
+ onKeyDown,
51
+ ref: innerRef,
52
+ }) ?? {};
53
+
54
+ // downshift's useSelect adds aria-selected to every item, but aria-selected
55
+ // is only valid on role="option" (listbox). For menuitem / menuitemcheckbox /
56
+ // menuitemradio it fails axe's aria-allowed-attr rule — menu items use
57
+ // aria-checked for state, not aria-selected.
58
+ if (!isListbox && "aria-selected" in downshiftItemProps) {
59
+ delete downshiftItemProps["aria-selected"];
60
+ }
61
+
42
62
  return hiddenItemsMap[item.id] ? (
43
63
  <></>
44
64
  ) : (
@@ -47,16 +67,7 @@ const MenuItem = ({
47
67
  id={id}
48
68
  $highlighted={highlighted}
49
69
  $active={active}
50
- {...getItemProps?.({
51
- ...(isListbox ? {} : { role: "menuitem" }),
52
- item,
53
- index: item.index,
54
- // The disabled prop no longer supported in downshift. Use isItemDisabled instead.
55
- // disabled,
56
- onClick,
57
- onKeyDown,
58
- ref: innerRef,
59
- })}
70
+ {...downshiftItemProps}
60
71
  // use anchor tag if href is provided and button for menuitem
61
72
  as={isListbox ? undefined : href ? "a" : "button"}
62
73
  type={href ? undefined : "button"}
@@ -251,6 +251,25 @@ describe("MenuItem when not isListbox", () => {
251
251
  expect(menuItem).toContainElement(span);
252
252
  });
253
253
 
254
+ it("does not set aria-selected on role=menuitem (axe aria-allowed-attr)", () => {
255
+ render(
256
+ <ActionMenu
257
+ menuToggleElement={testMenuToggleElement}
258
+ isOpen
259
+ id="no-aria-selected"
260
+ >
261
+ <MenuContent>
262
+ <MenuItem {...menuItemProps} />
263
+ </MenuContent>
264
+ </ActionMenu>
265
+ );
266
+
267
+ // downshift's useSelect auto-adds aria-selected; it must be stripped
268
+ // for menuitems because aria-selected is only valid on role=option.
269
+ const menuItem = screen.getByRole("menuitem");
270
+ expect(menuItem).not.toHaveAttribute("aria-selected");
271
+ });
272
+
254
273
  it("accepts box & system Props", () => {
255
274
  render(
256
275
  <ActionMenu
@@ -25,6 +25,7 @@ export interface TypeMenuSearchInputProps extends TypeInputProps {
25
25
  export const MenuSearchInput = ({
26
26
  getIsItemVisible,
27
27
  inputProps,
28
+ onChange,
28
29
  ...restInputProps
29
30
  }: TypeMenuSearchInputProps) => {
30
31
  const {
@@ -63,6 +64,7 @@ export const MenuSearchInput = ({
63
64
  <Input
64
65
  onChange={(e, value) => {
65
66
  updateFilteredItems({ inputValue: value });
67
+ onChange?.(e, value);
66
68
  }}
67
69
  // apply onKeyDown logic for downshift keyboard accessibility support
68
70
  onKeyDown={handleKeyDown}
@@ -153,5 +153,28 @@ describe("MenuSearchInput", () => {
153
153
 
154
154
  expect(customFilter).toHaveBeenCalled();
155
155
  });
156
+
157
+ it("should call the passed onChange prop and still filter items", async () => {
158
+ const handleChange = jest.fn();
159
+ const { user } = render(
160
+ <Component {...defaultProps({ onChange: handleChange })} />
161
+ );
162
+
163
+ await user.click(screen.getByRole("button", { name: "Open Menu" }));
164
+
165
+ const inputElement = screen.getByRole("searchbox") as HTMLInputElement;
166
+ await act(async () => {
167
+ await user.click(inputElement);
168
+ await user.type(inputElement, "Item 0");
169
+ });
170
+
171
+ // onChange prop was forwarded
172
+ await waitFor(() => expect(handleChange).toHaveBeenCalled());
173
+
174
+ // updateFilteredItems still ran — only the matching item is visible
175
+ await waitFor(() =>
176
+ expect(screen.queryAllByRole(menuItemRole).length).toBe(1)
177
+ );
178
+ });
156
179
  });
157
180
  });