@sproutsocial/seeds-react-menu 1.11.9 → 1.12.1

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.11.9",
3
+ "version": "1.12.1",
4
4
  "description": "Seeds React Menu",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -26,7 +26,7 @@ import { mapMenuItems } from "../hooks/useMenuChildren";
26
26
  */
27
27
 
28
28
  const MenuGroup = <I extends TypeMenuItemProps = TypeMenuItemProps>({
29
- titleAs = "h3",
29
+ titleAs = "div",
30
30
  children: childrenProp,
31
31
  title,
32
32
  color,
@@ -68,7 +68,7 @@ const MenuGroup = <I extends TypeMenuItemProps = TypeMenuItemProps>({
68
68
  {...rest}
69
69
  >
70
70
  {title && (
71
- <StyledTitle role="heading" as={titleAs} id={titleId}>
71
+ <StyledTitle as={titleAs} id={titleId}>
72
72
  {title}
73
73
  </StyledTitle>
74
74
  )}
@@ -199,12 +199,12 @@ describe("MenuGroup", () => {
199
199
  </SingleSelectMenu>
200
200
  );
201
201
 
202
- // Find associated label for the group. (Menu Group uses aria-labelledby to point to heading element)
202
+ // The group is labelled via aria-labelledby pointing to the title div.
203
+ // The title must not carry role="heading" since heading is not an allowed
204
+ // descendant of listbox/menu (axe: aria-allowed-attr).
203
205
  const menuGroup = screen.getByRole("group", { name: menuTitle });
204
206
  expect(menuGroup).toBeInTheDocument();
205
- expect(
206
- screen.queryByRole("heading", { name: menuTitle })
207
- ).toBeInTheDocument();
207
+ expect(screen.queryByRole("heading")).not.toBeInTheDocument();
208
208
  });
209
209
 
210
210
  it("does not place role=group on an <li> (axe aria-allowed-role)", () => {
@@ -26,6 +26,7 @@ export const MenuSearchInput = ({
26
26
  getIsItemVisible,
27
27
  inputProps,
28
28
  onChange,
29
+ "aria-label": ariaLabel,
29
30
  ...restInputProps
30
31
  }: TypeMenuSearchInputProps) => {
31
32
  const {
@@ -69,8 +70,9 @@ export const MenuSearchInput = ({
69
70
  // apply onKeyDown logic for downshift keyboard accessibility support
70
71
  onKeyDown={handleKeyDown}
71
72
  autoComplete={false}
72
- aria-autocomplete="list"
73
+ ariaLabel={ariaLabel}
73
74
  inputProps={{
75
+ ["aria-autocomplete"]: "list",
74
76
  ["aria-activedescendant"]: toggleButtonProps["aria-activedescendant"],
75
77
  ["aria-controls"]: toggleButtonProps["aria-controls"],
76
78
  ...inputProps,
@@ -275,6 +275,66 @@ export const SingleSelectMenuWithCustomEmpty: Story = {
275
275
  },
276
276
  };
277
277
 
278
+ const russianBooks = TEST_BOOKS.filter(({ author }) =>
279
+ [
280
+ "Leo Tolstoy",
281
+ "Fyodor Dostoyevsy",
282
+ "Fyodor Dostoevsky",
283
+ "Lev Tolstoy",
284
+ ].includes(author)
285
+ );
286
+ const worldBooks = TEST_BOOKS.filter(
287
+ ({ author }) =>
288
+ ![
289
+ "Leo Tolstoy",
290
+ "Fyodor Dostoyevsy",
291
+ "Fyodor Dostoevsky",
292
+ "Lev Tolstoy",
293
+ ].includes(author)
294
+ );
295
+
296
+ export const SingleSelectMenuWithSearchAndGroups: Story = {
297
+ name: "Single Select Menu with Search and Groups",
298
+ render: ({ inputType, ...args }) => {
299
+ return (
300
+ <SingleSelectMenu
301
+ itemToString={(item) => {
302
+ if (!item) return "";
303
+ return TEST_BOOKS.find(({ id }) => id === item.id)?.title ?? "";
304
+ }}
305
+ menuToggleElement={<StorybookMenuToggleButton />}
306
+ onStateChange={(changes) => action("onStateChange")(changes)}
307
+ {...(args as object)}
308
+ >
309
+ <MenuHeader title="Select Book">
310
+ <MenuSearchInput
311
+ id="single-select-search-groups"
312
+ name="search"
313
+ type="search"
314
+ aria-label="Search Books"
315
+ />
316
+ </MenuHeader>
317
+ <MenuContent>
318
+ <MenuGroup id="russian-lit" title="Russian Literature">
319
+ {russianBooks.map(({ id, title }) => (
320
+ <MenuItem key={id} id={id} inputType={inputType}>
321
+ {title}
322
+ </MenuItem>
323
+ ))}
324
+ </MenuGroup>
325
+ <MenuGroup id="world-lit" title="World Literature">
326
+ {worldBooks.map(({ id, title }) => (
327
+ <MenuItem key={id} id={id} inputType={inputType}>
328
+ {title}
329
+ </MenuItem>
330
+ ))}
331
+ </MenuGroup>
332
+ </MenuContent>
333
+ </SingleSelectMenu>
334
+ );
335
+ },
336
+ };
337
+
278
338
  export const SingleSelectMenuSmallSize: Story = {
279
339
  name: "Small Size",
280
340
  render: ({ inputType, ...args }) => {
@@ -99,6 +99,7 @@ export const Controlled: Story = {
99
99
  name: "Controlled",
100
100
  render: () => {
101
101
  const [selectedIds, setSelectedIds] = React.useState<string[]>(["book-2"]);
102
+ const [inputValue, setInputValue] = React.useState("");
102
103
  const selected = books.filter((b) => selectedIds.includes(b.id));
103
104
  return (
104
105
  <div
@@ -119,9 +120,14 @@ export const Controlled: Story = {
119
120
  placeholder="Search books..."
120
121
  selectedItemIds={selectedIds}
121
122
  onSelectedItemIdsChange={(ids: string[]) => setSelectedIds(ids)}
123
+ inputValue={inputValue}
124
+ onInputValueChange={setInputValue}
122
125
  />
123
126
  )}
124
127
  </FormField>
128
+ <div style={{ fontSize: 13 }}>
129
+ Input: <strong>"{inputValue}"</strong>
130
+ </div>
125
131
  <div style={{ fontSize: 13 }}>
126
132
  Selected:{" "}
127
133
  <strong>
@@ -130,11 +136,14 @@ export const Controlled: Story = {
130
136
  : "none"}
131
137
  </strong>
132
138
  </div>
133
- <div style={{ display: "flex", gap: 8 }}>
139
+ <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
134
140
  <button onClick={() => setSelectedIds([])}>Clear all</button>
135
141
  <button onClick={() => setSelectedIds(["book-5", "book-7"])}>
136
142
  Select 1984 &amp; Meditations
137
143
  </button>
144
+ <button onClick={() => setInputValue("War")}>
145
+ Set input to "War"
146
+ </button>
138
147
  </div>
139
148
  </div>
140
149
  );
@@ -182,6 +191,27 @@ export const SelectableHeadings: Story = {
182
191
 
183
192
  export const SelectAll: Story = {
184
193
  name: "Select all",
194
+ render: () => (
195
+ <div style={{ width: "600px" }}>
196
+ <FormField label="Pick books">
197
+ {({ id }) => (
198
+ <MultiCombobox
199
+ id={id}
200
+ data={books}
201
+ itemToString={itemToString}
202
+ renderItem={renderItem}
203
+ placeholder="Search books..."
204
+ selectHeadings
205
+ selectAll
206
+ />
207
+ )}
208
+ </FormField>
209
+ </div>
210
+ ),
211
+ };
212
+
213
+ export const SelectAllWithGrouping: Story = {
214
+ name: "Select all with grouping",
185
215
  render: () => (
186
216
  <div style={{ width: "600px" }}>
187
217
  <FormField label="Pick books">
@@ -202,6 +232,26 @@ export const SelectAll: Story = {
202
232
  ),
203
233
  };
204
234
 
235
+ export const SelectAllLocalized: Story = {
236
+ name: "Select all (localized label)",
237
+ render: () => (
238
+ <div style={{ width: "600px" }}>
239
+ <FormField label="Pick books">
240
+ {({ id }) => (
241
+ <MultiCombobox
242
+ id={id}
243
+ data={books}
244
+ itemToString={itemToString}
245
+ renderItem={renderItem}
246
+ placeholder="Search books..."
247
+ selectAll="Tout sélectionner"
248
+ />
249
+ )}
250
+ </FormField>
251
+ </div>
252
+ ),
253
+ };
254
+
205
255
  export const Virtualized: Story = {
206
256
  name: "Virtualized",
207
257
  render: () => (
@@ -66,6 +66,8 @@ interface MultiComboboxBaseProps {
66
66
  loadingText?: string;
67
67
  disabled?: boolean;
68
68
  filter?: ((item: unknown, query: string) => boolean) | null;
69
+ inputValue?: string;
70
+ onInputValueChange?: (value: string) => void;
69
71
  }
70
72
 
71
73
  interface MultiComboboxDataProps<T extends DataWithId>
@@ -82,7 +84,7 @@ interface MultiComboboxDataProps<T extends DataWithId>
82
84
  groupBy?: (item: T) => string;
83
85
  renderGroupHeading?: (label: string) => React.ReactNode;
84
86
  selectHeadings?: boolean;
85
- selectAll?: boolean;
87
+ selectAll?: boolean | string;
86
88
  isVirtualized?: boolean;
87
89
  removeSelectedItems?: boolean;
88
90
  maxTokens?: number;
@@ -127,6 +129,8 @@ export function MultiCombobox<T extends DataWithId>(
127
129
  isLoading = false,
128
130
  loadingText = "Loading...",
129
131
  filter,
132
+ inputValue,
133
+ onInputValueChange,
130
134
  } = props;
131
135
 
132
136
  const isChildrenMode = "children" in props && props.children != null;
@@ -166,9 +170,12 @@ export function MultiCombobox<T extends DataWithId>(
166
170
  const selectHeadings = isChildrenMode
167
171
  ? false
168
172
  : (props as MultiComboboxDataProps<T>).selectHeadings ?? false;
169
- const selectAll = isChildrenMode
173
+ const selectAllProp = isChildrenMode
170
174
  ? false
171
175
  : (props as MultiComboboxDataProps<T>).selectAll ?? false;
176
+ const selectAll = Boolean(selectAllProp);
177
+ const selectAllLabel =
178
+ typeof selectAllProp === "string" ? selectAllProp : "Select all";
172
179
  const isVirtualized = isChildrenMode
173
180
  ? false
174
181
  : (props as MultiComboboxDataProps<T>).isVirtualized ?? false;
@@ -226,15 +233,20 @@ export function MultiCombobox<T extends DataWithId>(
226
233
  type FlatRow = T | GroupHeaderSentinel | SelectAllSentinel;
227
234
 
228
235
  const flatItems = React.useMemo<FlatRow[] | null>(() => {
229
- if (!groups || (!selectHeadings && !selectAll)) return null;
236
+ if (!selectHeadings && !selectAll) return null;
237
+ if (!groups && !selectAll) return null;
230
238
  const rows: FlatRow[] = [];
231
239
  if (selectAll) rows.push(SELECT_ALL_SENTINEL);
232
- for (const group of groups) {
233
- rows.push(makeGroupHeaderSentinel(group.value));
234
- rows.push(...group.items);
240
+ if (groups) {
241
+ for (const group of groups) {
242
+ rows.push(makeGroupHeaderSentinel(group.value));
243
+ rows.push(...group.items);
244
+ }
245
+ } else {
246
+ rows.push(...visibleData);
235
247
  }
236
248
  return rows;
237
- }, [groups, selectHeadings, selectAll]);
249
+ }, [groups, visibleData, selectHeadings, selectAll]);
238
250
 
239
251
  function setSelectedItems(items: T[]) {
240
252
  if (!isControlled) setInternalValue(items);
@@ -308,7 +320,7 @@ export function MultiCombobox<T extends DataWithId>(
308
320
  return (
309
321
  <ComboboxSelectAllItem key="__selectAll" value={row}>
310
322
  <ComboboxGroupHeaderCheckbox $state={state} id="__selectAll" />
311
- Select all
323
+ {selectAllLabel}
312
324
  </ComboboxSelectAllItem>
313
325
  );
314
326
  }
@@ -379,8 +391,13 @@ export function MultiCombobox<T extends DataWithId>(
379
391
  virtualized={isVirtualized}
380
392
  open={open}
381
393
  disabled={props.disabled}
382
- onOpenChange={setOpen}
394
+ onOpenChange={(nextOpen, eventDetails) => {
395
+ if (!nextOpen && eventDetails.reason === "item-press") return;
396
+ setOpen(nextOpen);
397
+ }}
383
398
  filter={filter}
399
+ inputValue={inputValue}
400
+ onInputValueChange={onInputValueChange}
384
401
  onItemHighlighted={
385
402
  isVirtualized
386
403
  ? (item, { reason, index }) => {