@sproutsocial/seeds-react-menu 1.8.6 → 1.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutsocial/seeds-react-menu",
3
- "version": "1.8.6",
3
+ "version": "1.10.0",
4
4
  "description": "Seeds React Menu",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -31,18 +31,18 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@radix-ui/react-popover": "^1.1.2",
34
- "@sproutsocial/seeds-react-box": "^1.1.13",
35
- "@sproutsocial/seeds-react-button": "^1.3.18",
36
- "@sproutsocial/seeds-react-checkbox": "^1.3.22",
37
- "@sproutsocial/seeds-react-icon": "^2.2.4",
38
- "@sproutsocial/seeds-react-input": "^1.5.4",
39
- "@sproutsocial/seeds-react-label": "^1.0.13",
40
- "@sproutsocial/seeds-react-popout": "^2.4.25",
41
- "@sproutsocial/seeds-react-radio": "^1.3.12",
42
- "@sproutsocial/seeds-react-switch": "^1.2.21",
34
+ "@sproutsocial/seeds-react-box": "^1.1.14",
35
+ "@sproutsocial/seeds-react-button": "^1.4.0",
36
+ "@sproutsocial/seeds-react-checkbox": "^1.3.24",
37
+ "@sproutsocial/seeds-react-icon": "^2.2.5",
38
+ "@sproutsocial/seeds-react-input": "^1.5.7",
39
+ "@sproutsocial/seeds-react-label": "^1.0.14",
40
+ "@sproutsocial/seeds-react-popout": "^2.4.28",
41
+ "@sproutsocial/seeds-react-radio": "^1.3.14",
42
+ "@sproutsocial/seeds-react-switch": "^1.2.23",
43
43
  "@sproutsocial/seeds-react-system-props": "^3.0.2",
44
- "@sproutsocial/seeds-react-theme": "^3.5.1",
45
- "@sproutsocial/seeds-react-token-input": "^1.4.25",
44
+ "@sproutsocial/seeds-react-theme": "^3.6.0",
45
+ "@sproutsocial/seeds-react-token-input": "^1.4.28",
46
46
  "@sproutsocial/seeds-react-utilities": "^4.3.0",
47
47
  "@tanstack/react-virtual": "^3.13.12",
48
48
  "downshift": "9.0.4",
@@ -51,7 +51,7 @@
51
51
  "styled-system": "^5.1.5"
52
52
  },
53
53
  "devDependencies": {
54
- "@sproutsocial/seeds-react-tooltip": "^1.1.7",
54
+ "@sproutsocial/seeds-react-tooltip": "^1.1.10",
55
55
  "@sproutsocial/eslint-config-seeds": "*",
56
56
  "@sproutsocial/seeds-react-testing-library": "*",
57
57
  "@sproutsocial/seeds-testing": "*",
@@ -9,11 +9,49 @@ import { useMenuToggleContext } from "./MenuContext";
9
9
  export interface TypeListboxToggleButtonProps
10
10
  extends TypeMenuToggleButtonProps {
11
11
  invalid?: boolean;
12
+ size?: "small" | "default" | "large";
12
13
  }
13
14
 
14
15
  export const StyledListboxToggleButton = styled(
15
16
  MenuToggleButton
16
17
  )<TypeListboxToggleButtonProps>`
18
+ background-color: ${(props) => props.theme.colors.form.background.base};
19
+ border: 1px solid ${(props) => props.theme.colors.form.border.base};
20
+ border-radius: ${(props) => props.theme.radii[500]};
21
+ transition: border-color ${(props) => props.theme.duration.fast}
22
+ ${(props) => props.theme.easing.ease_in},
23
+ box-shadow ${(props) => props.theme.duration.fast}
24
+ ${(props) => props.theme.easing.ease_in};
25
+ font-weight: ${(props) => props.theme.fontWeights.normal};
26
+ &:active {
27
+ transform: none;
28
+ }
29
+
30
+ padding: ${(props) => {
31
+ switch (props.size) {
32
+ case "large":
33
+ return `${props.theme.space[400]} ${props.theme.space[400]} ${props.theme.space[400]} ${props.theme.space[400]}`;
34
+
35
+ case "small":
36
+ return `${props.theme.space[200]} ${props.theme.space[200]} ${props.theme.space[200]} ${props.theme.space[200]}`;
37
+
38
+ case "default":
39
+ default:
40
+ return `${props.theme.space[300]} ${props.theme.space[300]} ${props.theme.space[300]} ${props.theme.space[300]}`;
41
+ }
42
+ }};
43
+ font-size: ${(props) => {
44
+ switch (props.size) {
45
+ case "large":
46
+ return props.theme.typography[300].fontSize;
47
+
48
+ case "small":
49
+ case "default":
50
+ default:
51
+ return props.theme.typography[200].fontSize;
52
+ }
53
+ }};
54
+
17
55
  ${(props) =>
18
56
  props.invalid &&
19
57
  css`
@@ -49,11 +87,16 @@ const StyledInnerButton = styled.div`
49
87
 
50
88
  export const ListboxToggleButton = ({
51
89
  children,
90
+ size = "default",
52
91
  ...buttonProps
53
92
  }: TypeListboxToggleButtonProps) => {
54
93
  const { isOpen } = useMenuToggleContext();
55
94
  return (
56
- <StyledListboxToggleButton appearance="secondary" {...buttonProps}>
95
+ <StyledListboxToggleButton
96
+ appearance="unstyled"
97
+ size={size}
98
+ {...buttonProps}
99
+ >
57
100
  <StyledInnerButton>
58
101
  {children}
59
102
  <StyledChevron $isOpen={isOpen} invalid={buttonProps.invalid}>
@@ -274,3 +274,53 @@ export const SingleSelectMenuWithCustomEmpty: Story = {
274
274
  );
275
275
  },
276
276
  };
277
+
278
+ export const SingleSelectMenuSmallSize: Story = {
279
+ name: "Small Size",
280
+ render: ({ inputType, ...args }) => {
281
+ return (
282
+ <SingleSelectMenu
283
+ itemToString={(item) => (item ? TEST_BOOKS[item.index].title : "")}
284
+ menuToggleElement={
285
+ <StorybookMenuToggleButton buttonProps={{ size: "small" }} />
286
+ }
287
+ {...(args as object)}
288
+ >
289
+ <MenuContent>
290
+ <MenuGroup id="books">
291
+ {TEST_BOOKS.map(({ id, title }) => (
292
+ <MenuItem key={id} id={id} inputType={inputType}>
293
+ {title}
294
+ </MenuItem>
295
+ ))}
296
+ </MenuGroup>
297
+ </MenuContent>
298
+ </SingleSelectMenu>
299
+ );
300
+ },
301
+ };
302
+
303
+ export const SingleSelectMenuLargeSize: Story = {
304
+ name: "Large Size",
305
+ render: ({ inputType, ...args }) => {
306
+ return (
307
+ <SingleSelectMenu
308
+ itemToString={(item) => (item ? TEST_BOOKS[item.index].title : "")}
309
+ menuToggleElement={
310
+ <StorybookMenuToggleButton buttonProps={{ size: "large" }} />
311
+ }
312
+ {...(args as object)}
313
+ >
314
+ <MenuContent>
315
+ <MenuGroup id="books">
316
+ {TEST_BOOKS.map(({ id, title }) => (
317
+ <MenuItem key={id} id={id} inputType={inputType}>
318
+ {title}
319
+ </MenuItem>
320
+ ))}
321
+ </MenuGroup>
322
+ </MenuContent>
323
+ </SingleSelectMenu>
324
+ );
325
+ },
326
+ };
@@ -345,6 +345,8 @@ const TestVirtualizedMulti = () => {
345
345
  itemToSearchString={itemToSearchString}
346
346
  renderItem={renderItem}
347
347
  isVirtualized={true}
348
+ groupBy={(book) => book.author}
349
+ selectHeadings={true}
348
350
  EmptyState={defaultEmptyState}
349
351
  placeholder="Search for books"
350
352
  menuProps={{
@@ -607,3 +609,37 @@ export const CustomSearchString: Story = {
607
609
  return <TestCustomSearchString />;
608
610
  },
609
611
  };
612
+
613
+ // Popout Placement Top
614
+ // Positioned at the bottom of the viewport to demonstrate that the popout
615
+ // opens upward rather than downward when popoutPlacement="top" is set.
616
+ const TestPopoutPlacementTop = () => {
617
+ return (
618
+ <FormField label="Pick some books" width="100%">
619
+ {({ id }) => (
620
+ <MultiAutocomplete
621
+ id={id}
622
+ data={books}
623
+ itemToString={itemToString}
624
+ renderItem={renderItem}
625
+ onSelectedItemIdsChange={(itemIds) => console.log(itemIds)}
626
+ EmptyState={defaultEmptyState}
627
+ placeholder="Search for books"
628
+ popoutPlacement="top"
629
+ />
630
+ )}
631
+ </FormField>
632
+ );
633
+ };
634
+
635
+ export const PopoutPlacementTop: Story = {
636
+ name: "Popout Placement Top",
637
+ decorators: [
638
+ (Story) => (
639
+ <Box display="flex" height="100vh" alignItems="flex-end">
640
+ <Story />
641
+ </Box>
642
+ ),
643
+ ],
644
+ render: () => <TestPopoutPlacementTop />,
645
+ };
@@ -1,4 +1,4 @@
1
- import { useState, useMemo } from "react";
1
+ import { useState, useMemo, useRef } from "react";
2
2
  import { useCombobox, useMultipleSelection } from "downshift";
3
3
  import { TokenInput } from "@sproutsocial/seeds-react-token-input";
4
4
  // This will eventually be imported from seeds-react-popout
@@ -74,7 +74,10 @@ export function MultiAutocomplete<T extends DataWithId>({
74
74
  inputType = "checkbox",
75
75
  onPointerDownOutside,
76
76
  onInteractOutside,
77
+ popoutPlacement,
77
78
  }: MultiAutocompleteProps<T>) {
79
+ const mousedownInPopoverRef = useRef(false);
80
+
78
81
  const [uncontrolledInputValue, setUncontrolledInputValue] =
79
82
  useState<string>("");
80
83
  const [uncontrolledSelectedItemIds, setUncontrolledSelectedItemIds] =
@@ -118,6 +121,9 @@ export function MultiAutocomplete<T extends DataWithId>({
118
121
  };
119
122
 
120
123
  const updateIsOpen = (newIsOpen: boolean) => {
124
+ if (!newIsOpen) {
125
+ updateInputValue("");
126
+ }
121
127
  if (!isIsOpenControlled) {
122
128
  setUncontrolledIsOpen(newIsOpen);
123
129
  }
@@ -360,6 +366,13 @@ export function MultiAutocomplete<T extends DataWithId>({
360
366
  if (inputProps.onBlur) {
361
367
  inputProps.onBlur(e);
362
368
  }
369
+ // Don't close if the blur was caused by a mousedown inside the popover (e.g. clicking the scrollbar)
370
+ // Re-focus the input so future blur events can still close the menu
371
+ if (mousedownInPopoverRef.current) {
372
+ mousedownInPopoverRef.current = false;
373
+ e.target.focus();
374
+ return;
375
+ }
363
376
  // Need to figure why onblur is getting called when selecting select all item
364
377
  updateIsOpen(false);
365
378
  };
@@ -373,6 +386,7 @@ export function MultiAutocomplete<T extends DataWithId>({
373
386
  <Popout
374
387
  open={comboboxIsOpen}
375
388
  onOpenChange={updateIsOpen}
389
+ side={popoutPlacement}
376
390
  onOpenAutoFocus={(e) => {
377
391
  // Prevent default auto-focus behavior
378
392
  e.preventDefault();
@@ -384,6 +398,12 @@ export function MultiAutocomplete<T extends DataWithId>({
384
398
  onPointerDownOutside={onPointerDownOutside}
385
399
  onInteractOutside={onInteractOutside}
386
400
  animationConfig={menuAnimationConfig}
401
+ onMouseDown={() => {
402
+ mousedownInPopoverRef.current = true;
403
+ }}
404
+ onMouseUp={() => {
405
+ mousedownInPopoverRef.current = false;
406
+ }}
387
407
  content={
388
408
  <ComboboxContent
389
409
  items={items}
@@ -67,6 +67,7 @@ export function SingleAutocomplete<T extends DataWithId>({
67
67
  includePlaceholderItem = false,
68
68
  onPointerDownOutside,
69
69
  onInteractOutside,
70
+ popoutPlacement,
70
71
  }: SingleAutocompleteProps<T>) {
71
72
  const [uncontrolledInputValue, setUncontrolledInputValue] =
72
73
  useState<string>("");
@@ -293,6 +294,7 @@ export function SingleAutocomplete<T extends DataWithId>({
293
294
  <Popout
294
295
  open={comboboxIsOpen}
295
296
  onOpenChange={updateIsOpen}
297
+ side={popoutPlacement}
296
298
  animationConfig={menuAnimationConfig}
297
299
  onOpenAutoFocus={(e) => {
298
300
  // Prevent default auto-focus behavior
@@ -86,6 +86,7 @@ export const VirtualizedComboboxContent = <T extends DataWithId>({
86
86
  style={{
87
87
  height: `${rowVirtualizer.getTotalSize()}px`,
88
88
  position: "relative",
89
+ overflow: "hidden",
89
90
  }}
90
91
  >
91
92
  <div
@@ -77,12 +77,28 @@ export function Popout({
77
77
  // At the moment, if the popout content is the combobox the onclick gets overridden by the popout
78
78
  return (
79
79
  <Popover.Root open={popoverOpen} onOpenChange={popoverOnOpenChange}>
80
- <Popover.Trigger ref={radixRef} asChild>
81
- {children}
82
- </Popover.Trigger>
83
- <Popover.Anchor>
84
- <div style={{ height: "1px" }}>&nbsp;</div>
85
- </Popover.Anchor>
80
+ {/*
81
+ The Anchor provides the positioning reference for the popout content.
82
+ We can't rely on Popover.Trigger's ref for positioning because the children
83
+ (e.g. Input) are class components that don't support forwardRef — Radix's
84
+ asChild would connect the ref to the class instance rather than the DOM node.
85
+ The Anchor is absolutely positioned at the top or bottom edge of the wrapper
86
+ so the popout content appears correctly above or below the trigger.
87
+ */}
88
+ <div style={{ position: "relative" }}>
89
+ <Popover.Trigger ref={radixRef} asChild>
90
+ {children}
91
+ </Popover.Trigger>
92
+ <Popover.Anchor
93
+ style={{
94
+ position: "absolute",
95
+ top: side === "top" ? 0 : "100%",
96
+ left: 0,
97
+ right: 0,
98
+ height: "1px",
99
+ }}
100
+ />
101
+ </div>
86
102
  <Popover.Portal>
87
103
  <StyledContent
88
104
  side={side}
@@ -22,6 +22,11 @@ export interface BaseSelectProps<T extends DataWithId> {
22
22
  event: CustomEvent<{ originalEvent: PointerEvent }>
23
23
  ) => void;
24
24
  onInteractOutside?: (event: CustomEvent<{ originalEvent: Event }>) => void;
25
+ /**
26
+ * Controls which side the popout appears relative to the trigger.
27
+ * @default "bottom"
28
+ */
29
+ popoutPlacement?: "top" | "bottom";
25
30
  }
26
31
  /**
27
32
  * Props common to all single-select components
@@ -78,6 +78,7 @@ export function SearchableMultiSelect<T extends DataWithId>({
78
78
  triggerButtonProps,
79
79
  onPointerDownOutside,
80
80
  onInteractOutside,
81
+ popoutPlacement,
81
82
  }: SearchableMultiSelectProps<T>) {
82
83
  const [uncontrolledInputValue, setUncontrolledInputValue] =
83
84
  useState<string>("");
@@ -367,6 +368,7 @@ export function SearchableMultiSelect<T extends DataWithId>({
367
368
  <Popout
368
369
  open={comboboxIsOpen}
369
370
  onOpenChange={updateIsOpen}
371
+ side={popoutPlacement}
370
372
  animationConfig={menuAnimationConfig}
371
373
  onOpenAutoFocus={(e) => {
372
374
  // Prevent default auto-focus behavior - we'll focus the input manually
@@ -74,6 +74,7 @@ export function SearchableSelect<T extends DataWithId>({
74
74
  triggerButtonProps,
75
75
  onPointerDownOutside,
76
76
  onInteractOutside,
77
+ popoutPlacement,
77
78
  }: SearchableSelectProps<T>) {
78
79
  const [uncontrolledInputValue, setUncontrolledInputValue] =
79
80
  useState<string>("");
@@ -257,6 +258,7 @@ export function SearchableSelect<T extends DataWithId>({
257
258
  <Popout
258
259
  open={comboboxIsOpen}
259
260
  onOpenChange={updateIsOpen}
261
+ side={popoutPlacement}
260
262
  animationConfig={menuAnimationConfig}
261
263
  onOpenAutoFocus={(e) => {
262
264
  // Prevent default auto-focus behavior - we'll focus the input manually
@@ -79,6 +79,7 @@ export function MultiSelect<T extends DataWithId>({
79
79
  shouldOverflow,
80
80
  onPointerDownOutside,
81
81
  onInteractOutside,
82
+ popoutPlacement,
82
83
  }: MultiSelectProps<T>) {
83
84
  const [uncontrolledSelectedItemIds, setUncontrolledSelectedItemIds] =
84
85
  useState<T["id"][]>([]);
@@ -335,6 +336,7 @@ export function MultiSelect<T extends DataWithId>({
335
336
  <Popout
336
337
  open={selectIsOpen}
337
338
  onOpenChange={updateIsOpen}
339
+ side={popoutPlacement}
338
340
  onOpenAutoFocus={(e) => {
339
341
  // Prevent default auto-focus behavior
340
342
  e.preventDefault();
@@ -66,6 +66,7 @@ export function SingleSelect<T extends DataWithId>({
66
66
  removePlaceholderItem = false,
67
67
  onPointerDownOutside,
68
68
  onInteractOutside,
69
+ popoutPlacement,
69
70
  }: SingleSelectProps<T>) {
70
71
  const [uncontrolledSelectedItemId, setUncontrolledSelectedItemId] = useState<
71
72
  T["id"] | null
@@ -214,6 +215,7 @@ export function SingleSelect<T extends DataWithId>({
214
215
  <Popout
215
216
  open={selectIsOpen}
216
217
  onOpenChange={updateIsOpen}
218
+ side={popoutPlacement}
217
219
  animationConfig={menuAnimationConfig}
218
220
  onOpenAutoFocus={(e) => {
219
221
  // Prevent default auto-focus behavior