@sproutsocial/seeds-react-menu 1.18.2 → 1.18.4

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.18.2",
3
+ "version": "1.18.4",
4
4
  "description": "Seeds React Menu",
5
5
  "author": "Sprout Social, Inc.",
6
6
  "license": "MIT",
@@ -35,18 +35,18 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@radix-ui/react-popover": "^1.1.2",
38
- "@sproutsocial/seeds-react-box": "^1.1.32",
39
- "@sproutsocial/seeds-react-button": "^2.3.9",
40
- "@sproutsocial/seeds-react-checkbox": "^1.3.55",
41
- "@sproutsocial/seeds-react-icon": "^2.5.2",
42
- "@sproutsocial/seeds-react-input": "^1.5.43",
43
- "@sproutsocial/seeds-react-label": "^1.0.34",
44
- "@sproutsocial/seeds-react-popout": "^2.5.24",
45
- "@sproutsocial/seeds-react-radio": "^1.3.35",
46
- "@sproutsocial/seeds-react-switch": "^1.2.52",
38
+ "@sproutsocial/seeds-react-box": "^1.1.34",
39
+ "@sproutsocial/seeds-react-button": "^2.3.11",
40
+ "@sproutsocial/seeds-react-checkbox": "^1.3.57",
41
+ "@sproutsocial/seeds-react-icon": "^2.5.4",
42
+ "@sproutsocial/seeds-react-input": "^1.5.45",
43
+ "@sproutsocial/seeds-react-label": "^1.0.36",
44
+ "@sproutsocial/seeds-react-popout": "^2.5.26",
45
+ "@sproutsocial/seeds-react-radio": "^1.3.37",
46
+ "@sproutsocial/seeds-react-switch": "^1.2.54",
47
47
  "@sproutsocial/seeds-react-system-props": "^3.2.1",
48
- "@sproutsocial/seeds-react-theme": "^4.5.1",
49
- "@sproutsocial/seeds-react-token-input": "^1.4.65",
48
+ "@sproutsocial/seeds-react-theme": "^4.7.0",
49
+ "@sproutsocial/seeds-react-token-input": "^1.4.67",
50
50
  "@sproutsocial/seeds-react-utilities": "^4.3.0",
51
51
  "@base-ui/react": "^1.3.0",
52
52
  "@tanstack/react-virtual": "^3.13.12",
@@ -56,7 +56,7 @@
56
56
  "styled-system": "^5.1.5"
57
57
  },
58
58
  "devDependencies": {
59
- "@sproutsocial/seeds-react-tooltip": "^1.1.39",
59
+ "@sproutsocial/seeds-react-tooltip": "^1.1.41",
60
60
  "@sproutsocial/eslint-config-seeds": "*",
61
61
  "@sproutsocial/seeds-react-testing-library": "*",
62
62
  "@sproutsocial/seeds-testing": "*",
@@ -1,4 +1,4 @@
1
- import { type RefObject } from "react";
1
+ import { type RefObject, useEffect, useRef } from "react";
2
2
  import { useNestedMenuContext } from "./NestedMenuContext";
3
3
  import {
4
4
  MenuPopout,
@@ -14,6 +14,13 @@ const StyledDiv = styled(motion.div)`
14
14
  overflow: hidden;
15
15
  `;
16
16
 
17
+ /** How long to wait after closing before returning focus to the toggle. */
18
+ const RETURN_FOCUS_DELAY = 200;
19
+ /** How long to wait before retrying an attempt that was undone. */
20
+ const RETURN_FOCUS_RETRY_DELAY = 50;
21
+ /** Upper bound on retrying, so an unfocusable toggle cannot retry forever. */
22
+ const RETURN_FOCUS_BUDGET = 1000;
23
+
17
24
  export const NestedMenuPopout = ({
18
25
  content,
19
26
  menuToggleElement,
@@ -29,6 +36,52 @@ export const NestedMenuPopout = ({
29
36
  const { activeMenuContext, rootMenuContextProps, resetSelectedMenuPath } =
30
37
  useNestedMenuContext();
31
38
 
39
+ const closeTimerRef = useRef<ReturnType<typeof setTimeout>>();
40
+ const retryTimerRef = useRef<ReturnType<typeof setTimeout>>();
41
+
42
+ useEffect(
43
+ () => () => {
44
+ clearTimeout(closeTimerRef.current);
45
+ clearTimeout(retryTimerRef.current);
46
+ },
47
+ []
48
+ );
49
+
50
+ /**
51
+ * Move focus back to the toggle, retrying until it takes effect.
52
+ *
53
+ * The popout's focus lock stays mounted for the length of the close
54
+ * animation, and while it is mounted it traps focus: a focus() call landing
55
+ * outside the menu is immediately pulled back in. On a slow machine the
56
+ * first attempt can fall inside that window, and once the lock unmounts
57
+ * focus is left on <body> with nothing to restore it. Retrying covers that
58
+ * instead of lengthening the initial delay, which can only ever guess at how
59
+ * long the animation took.
60
+ *
61
+ * An attempt that takes effect stops the retries, so this never keeps
62
+ * pulling focus back from somewhere the user deliberately moved it.
63
+ */
64
+ const attemptReturnFocus = (deadline: number) => {
65
+ const toggleElement = toggleElementRef.current;
66
+
67
+ if (toggleElement) {
68
+ if (document.activeElement === toggleElement) {
69
+ return;
70
+ }
71
+ toggleElement.focus();
72
+ if (document.activeElement === toggleElement) {
73
+ return;
74
+ }
75
+ }
76
+
77
+ if (Date.now() < deadline) {
78
+ retryTimerRef.current = setTimeout(
79
+ () => attemptReturnFocus(deadline),
80
+ RETURN_FOCUS_RETRY_DELAY
81
+ );
82
+ }
83
+ };
84
+
32
85
  return (
33
86
  <MenuPopout
34
87
  lockPlacement
@@ -36,10 +89,10 @@ export const NestedMenuPopout = ({
36
89
  onClose={() => {
37
90
  onClose?.();
38
91
  // Manually focus the toggle element and then reset state
39
- setTimeout(() => {
40
- toggleElementRef.current?.focus();
92
+ closeTimerRef.current = setTimeout(() => {
93
+ attemptReturnFocus(Date.now() + RETURN_FOCUS_BUDGET);
41
94
  resetSelectedMenuPath?.();
42
- }, 200);
95
+ }, RETURN_FOCUS_DELAY);
43
96
  }}
44
97
  isOpen={!!rootMenuContextProps.isOpen}
45
98
  openMenu={rootMenuContextProps.openMenu}
@@ -5,12 +5,7 @@ import { FormField } from "@sproutsocial/seeds-react-form-field";
5
5
  import { Button } from "@sproutsocial/seeds-react-button";
6
6
  import { Input } from "@sproutsocial/seeds-react-input";
7
7
  import { TokenInput } from "@sproutsocial/seeds-react-token-input";
8
- import {
9
- Modal as ModalV1,
10
- ModalV2,
11
- ModalBody,
12
- ModalFooter,
13
- } from "@sproutsocial/seeds-react-modal";
8
+ import { Modal, ModalBody, ModalFooter } from "@sproutsocial/seeds-react-modal";
14
9
  import {
15
10
  PeekIn,
16
11
  PeekInHeader,
@@ -146,40 +141,11 @@ type Story = StoryObj;
146
141
 
147
142
  // ─── Stories ──────────────────────────────────────────────────────────────────
148
143
 
149
- export const V1Modal: Story = {
150
- name: "V1 Modal",
151
- render: () => {
152
- const [isOpen, setIsOpen] = React.useState(false);
153
- return (
154
- <>
155
- <Button appearance="primary" onClick={() => setIsOpen(true)}>
156
- Open V1 Modal
157
- </Button>
158
- <ModalV1
159
- appElementSelector="#root"
160
- isOpen={isOpen}
161
- onClose={() => setIsOpen(false)}
162
- closeButtonLabel="Close"
163
- label="V1 Modal"
164
- >
165
- <ModalV1.Header title="" subtitle="" bordered>
166
- V1 modal
167
- </ModalV1.Header>
168
- <ModalV1.Content>
169
- <AllComponents />
170
- </ModalV1.Content>
171
- <ModalV1.Footer>Footer</ModalV1.Footer>
172
- </ModalV1>
173
- </>
174
- );
175
- },
176
- };
177
-
178
144
  export const V2Modal: Story = {
179
145
  name: "V2 Modal",
180
146
  render: () => (
181
147
  <>
182
- <ModalV2
148
+ <Modal
183
149
  title="Pick some books"
184
150
  modalTrigger={<Button appearance="primary">Open V2 Modal</Button>}
185
151
  closeButtonAriaLabel="Close"
@@ -188,7 +154,7 @@ export const V2Modal: Story = {
188
154
  <AllComponents />
189
155
  </ModalBody>
190
156
  <ModalFooter cancelButton={<Button>Cancel</Button>} />
191
- </ModalV2>
157
+ </Modal>
192
158
  <AllComponents />
193
159
  </>
194
160
  ),
@@ -196,92 +162,6 @@ export const V2Modal: Story = {
196
162
 
197
163
  // ─── Action Menu in Modal ─────────────────────────────────────────────────────
198
164
 
199
- export const ActionMenuInV1Modal: Story = {
200
- name: "Action Menu in V1 Modal",
201
- render: () => {
202
- const [isOpen, setIsOpen] = React.useState(false);
203
- return (
204
- <>
205
- <Button appearance="primary" onClick={() => setIsOpen(true)}>
206
- Open V1 Modal
207
- </Button>
208
- <ModalV1
209
- appElementSelector="#root"
210
- isOpen={isOpen}
211
- onClose={() => setIsOpen(false)}
212
- closeButtonLabel="Close"
213
- label="Action Menu in V1 Modal"
214
- >
215
- <ModalV1.Header title="" subtitle="" bordered>
216
- Actions
217
- </ModalV1.Header>
218
- <ModalV1.Content>
219
- <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
220
- <ActionMenu
221
- trigger={<Button appearance="secondary">Basic Menu</Button>}
222
- >
223
- <MenuItem onClick={action("Compose")}>Compose</MenuItem>
224
- <MenuItem onClick={action("View Messages")}>
225
- View Messages
226
- </MenuItem>
227
- <MenuItem onClick={action("Analyze post")}>
228
- Analyze post
229
- </MenuItem>
230
- </ActionMenu>
231
-
232
- <ActionMenu
233
- trigger={
234
- <Button appearance="secondary">Menu with Groups</Button>
235
- }
236
- >
237
- <MenuGroup label="Actions" showSeparator>
238
- <MenuItem onClick={action("Edit")}>Edit</MenuItem>
239
- <MenuItem onClick={action("Duplicate")}>Duplicate</MenuItem>
240
- </MenuGroup>
241
- <MenuGroup label="Danger">
242
- <MenuItem onClick={action("Delete")} disabled>
243
- Delete
244
- </MenuItem>
245
- </MenuGroup>
246
- </ActionMenu>
247
-
248
- <ActionMenu
249
- trigger={
250
- <Button appearance="secondary">Menu with Submenu</Button>
251
- }
252
- >
253
- <MenuItem onClick={action("Compose")}>Compose</MenuItem>
254
- <MenuSub trigger={<MenuSubTrigger>Share</MenuSubTrigger>}>
255
- <MenuItem onClick={action("Share to Twitter")}>
256
- Twitter
257
- </MenuItem>
258
- <MenuItem onClick={action("Share to LinkedIn")}>
259
- LinkedIn
260
- </MenuItem>
261
- <MenuSub
262
- trigger={<MenuSubTrigger>More platforms</MenuSubTrigger>}
263
- >
264
- <MenuItem onClick={action("Share to Facebook")}>
265
- Facebook
266
- </MenuItem>
267
- <MenuItem onClick={action("Share to Instagram")}>
268
- Instagram
269
- </MenuItem>
270
- </MenuSub>
271
- </MenuSub>
272
- <MenuItem onClick={action("Delete")} disabled>
273
- Delete
274
- </MenuItem>
275
- </ActionMenu>
276
- </div>
277
- </ModalV1.Content>
278
- <ModalV1.Footer>Footer</ModalV1.Footer>
279
- </ModalV1>
280
- </>
281
- );
282
- },
283
- };
284
-
285
165
  export const ActionMenuInPeekIn: Story = {
286
166
  name: "Action Menu in PeekIn",
287
167
  render: () => {
@@ -391,7 +271,7 @@ export const ActionMenuInPeekIn: Story = {
391
271
  export const ActionMenuInModal: Story = {
392
272
  name: "Action Menu in V2 Modal",
393
273
  render: () => (
394
- <ModalV2
274
+ <Modal
395
275
  title="Actions"
396
276
  modalTrigger={<Button appearance="primary">Open Modal</Button>}
397
277
  closeButtonAriaLabel="Close"
@@ -447,6 +327,6 @@ export const ActionMenuInModal: Story = {
447
327
  </div>
448
328
  </ModalBody>
449
329
  <ModalFooter cancelButton={<Button>Close</Button>} />
450
- </ModalV2>
330
+ </Modal>
451
331
  ),
452
332
  };
@@ -2,11 +2,7 @@ import React from "react";
2
2
  import type { Meta, StoryObj } from "@storybook/react";
3
3
  import { FormField } from "@sproutsocial/seeds-react-form-field";
4
4
  import { Button } from "@sproutsocial/seeds-react-button";
5
- import {
6
- ModalV2,
7
- ModalBody,
8
- ModalFooter,
9
- } from "@sproutsocial/seeds-react-modal";
5
+ import { Modal, ModalBody, ModalFooter } from "@sproutsocial/seeds-react-modal";
10
6
  import { MultiCombobox } from "./MultiCombobox";
11
7
  import {
12
8
  books,
@@ -570,7 +566,7 @@ export const Required: Story = {
570
566
  export const LargeBookSetWithMaxTokensInModal: Story = {
571
567
  name: "Large book set with max tokens (in modal)",
572
568
  render: () => (
573
- <ModalV2
569
+ <Modal
574
570
  title="Pick some books"
575
571
  modalTrigger={<Button appearance="primary">Open Modal</Button>}
576
572
  closeButtonAriaLabel="Close"
@@ -594,7 +590,7 @@ export const LargeBookSetWithMaxTokensInModal: Story = {
594
590
  </div>
595
591
  </ModalBody>
596
592
  <ModalFooter cancelButton={<Button>Cancel</Button>} />
597
- </ModalV2>
593
+ </Modal>
598
594
  ),
599
595
  };
600
596
 
@@ -605,7 +601,7 @@ export const LargeBookSetWithMaxTokensInModal: Story = {
605
601
  export const EscapeKeyInModal: Story = {
606
602
  name: "Escape Key in Modal (a11y)",
607
603
  render: () => (
608
- <ModalV2
604
+ <Modal
609
605
  title="Pick some books"
610
606
  modalTrigger={<Button appearance="primary">Open Modal</Button>}
611
607
  closeButtonAriaLabel="Close"
@@ -631,6 +627,6 @@ export const EscapeKeyInModal: Story = {
631
627
  </div>
632
628
  </ModalBody>
633
629
  <ModalFooter cancelButton={<Button>Cancel</Button>} />
634
- </ModalV2>
630
+ </Modal>
635
631
  ),
636
632
  };