@purpurds/popover 0.0.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.
Files changed (72) hide show
  1. package/dist/LICENSE.txt +905 -0
  2. package/dist/metadata.js +8 -0
  3. package/dist/popover-back.d.ts +9 -0
  4. package/dist/popover-back.d.ts.map +1 -0
  5. package/dist/popover-button.d.ts +37 -0
  6. package/dist/popover-button.d.ts.map +1 -0
  7. package/dist/popover-content.d.ts +93 -0
  8. package/dist/popover-content.d.ts.map +1 -0
  9. package/dist/popover-flow.d.ts +65 -0
  10. package/dist/popover-flow.d.ts.map +1 -0
  11. package/dist/popover-footer.d.ts +16 -0
  12. package/dist/popover-footer.d.ts.map +1 -0
  13. package/dist/popover-header.d.ts +7 -0
  14. package/dist/popover-header.d.ts.map +1 -0
  15. package/dist/popover-internal-context.d.ts +15 -0
  16. package/dist/popover-internal-context.d.ts.map +1 -0
  17. package/dist/popover-next.d.ts +9 -0
  18. package/dist/popover-next.d.ts.map +1 -0
  19. package/dist/popover-standalone.d.ts +12 -0
  20. package/dist/popover-standalone.d.ts.map +1 -0
  21. package/dist/popover-steps.d.ts +6 -0
  22. package/dist/popover-steps.d.ts.map +1 -0
  23. package/dist/popover-trigger.d.ts +27 -0
  24. package/dist/popover-trigger.d.ts.map +1 -0
  25. package/dist/popover-walkthrough.d.ts +13 -0
  26. package/dist/popover-walkthrough.d.ts.map +1 -0
  27. package/dist/popover.cjs.js +42 -0
  28. package/dist/popover.cjs.js.map +1 -0
  29. package/dist/popover.d.ts +36 -0
  30. package/dist/popover.d.ts.map +1 -0
  31. package/dist/popover.es.js +3849 -0
  32. package/dist/popover.es.js.map +1 -0
  33. package/dist/styles.css +1 -0
  34. package/dist/use-screen-size.hook.d.ts +7 -0
  35. package/dist/use-screen-size.hook.d.ts.map +1 -0
  36. package/dist/use-smooth-scroll.d.ts +5 -0
  37. package/dist/use-smooth-scroll.d.ts.map +1 -0
  38. package/dist/usePopoverTrigger.d.ts +5 -0
  39. package/dist/usePopoverTrigger.d.ts.map +1 -0
  40. package/dist/usePopoverWalkthrough.d.ts +7 -0
  41. package/dist/usePopoverWalkthrough.d.ts.map +1 -0
  42. package/eslint.config.mjs +2 -0
  43. package/package.json +82 -0
  44. package/src/global.d.ts +4 -0
  45. package/src/popover-back.test.tsx +63 -0
  46. package/src/popover-back.tsx +40 -0
  47. package/src/popover-button.test.tsx +51 -0
  48. package/src/popover-button.tsx +84 -0
  49. package/src/popover-content.test.tsx +1122 -0
  50. package/src/popover-content.tsx +277 -0
  51. package/src/popover-flow.tsx +170 -0
  52. package/src/popover-footer.test.tsx +21 -0
  53. package/src/popover-footer.tsx +32 -0
  54. package/src/popover-header.test.tsx +22 -0
  55. package/src/popover-header.tsx +32 -0
  56. package/src/popover-internal-context.tsx +28 -0
  57. package/src/popover-next.test.tsx +61 -0
  58. package/src/popover-next.tsx +40 -0
  59. package/src/popover-standalone.tsx +48 -0
  60. package/src/popover-steps.tsx +32 -0
  61. package/src/popover-trigger.tsx +71 -0
  62. package/src/popover-walkthrough.test.tsx +346 -0
  63. package/src/popover-walkthrough.tsx +45 -0
  64. package/src/popover.module.scss +315 -0
  65. package/src/popover.stories.tsx +1157 -0
  66. package/src/popover.test.tsx +642 -0
  67. package/src/popover.tsx +76 -0
  68. package/src/use-screen-size.hook.ts +39 -0
  69. package/src/use-smooth-scroll.ts +62 -0
  70. package/src/usePopoverTrigger.ts +59 -0
  71. package/src/usePopoverWalkthrough.ts +85 -0
  72. package/vitest.setup.ts +30 -0
@@ -0,0 +1,48 @@
1
+ import React, { type ReactNode, useState } from "react";
2
+ import * as RadixPopover from "@radix-ui/react-popover";
3
+
4
+ import { PopoverInternalContext } from "./popover-internal-context";
5
+
6
+ export type PopoverStandaloneProps = RadixPopover.PopoverProps & {
7
+ children: ReactNode;
8
+ className?: string;
9
+ disableClickOutside?: boolean;
10
+ };
11
+
12
+ export const PopoverStandalone = ({
13
+ children,
14
+ open: controlledOpen,
15
+ defaultOpen = false,
16
+ onOpenChange,
17
+ disableClickOutside,
18
+ ...props
19
+ }: PopoverStandaloneProps) => {
20
+ // Internal state for uncontrolled mode
21
+ const [internalOpen, setInternalOpen] = useState(defaultOpen);
22
+
23
+ const isControlled = controlledOpen !== undefined;
24
+ const isOpen = isControlled ? controlledOpen : internalOpen;
25
+
26
+ const handleOpenChange = (newOpen: boolean) => {
27
+ if (!isControlled) {
28
+ setInternalOpen(newOpen);
29
+ }
30
+ onOpenChange?.(newOpen);
31
+ };
32
+
33
+ return (
34
+ <RadixPopover.Root open={isOpen} onOpenChange={handleOpenChange} {...props}>
35
+ <PopoverInternalContext.Provider
36
+ value={{
37
+ isOpen,
38
+ walkthroughStep: 0,
39
+ disableClickOutside,
40
+ }}
41
+ >
42
+ {children}
43
+ </PopoverInternalContext.Provider>
44
+ </RadixPopover.Root>
45
+ );
46
+ };
47
+
48
+ PopoverStandalone.displayName = "PopoverStandalone";
@@ -0,0 +1,32 @@
1
+ import React from "react";
2
+ import { Paragraph } from "@purpurds/paragraph";
3
+ import c from "classnames/bind";
4
+
5
+ import styles from "./popover.module.scss";
6
+ import { usePopoverFlow } from "./popover-flow";
7
+ import { usePopoverInternal } from "./popover-internal-context";
8
+
9
+ const cx = c.bind(styles);
10
+
11
+ export const PopoverSteps = () => {
12
+ const flow = usePopoverFlow();
13
+ const context = usePopoverInternal();
14
+
15
+ // Use walkthroughStep from context (each popover knows its own step number)
16
+ const currentStep = context?.walkthroughStep || flow.currentStep;
17
+ const { totalSteps, separatorText, stepText } = flow;
18
+ return (
19
+ <>
20
+ <Paragraph
21
+ className={cx("purpur-popover__steps")}
22
+ role="status"
23
+ aria-live="polite"
24
+ aria-atomic="true"
25
+ >
26
+ {stepText} {currentStep} {separatorText} {totalSteps}
27
+ </Paragraph>
28
+ </>
29
+ );
30
+ };
31
+
32
+ PopoverSteps.displayName = "PopoverSteps";
@@ -0,0 +1,71 @@
1
+ import React, { forwardRef } from "react";
2
+ import * as RadixPopover from "@radix-ui/react-popover";
3
+ import c from "classnames/bind";
4
+
5
+ import styles from "./popover.module.scss";
6
+ import { usePopoverTrigger } from "./usePopoverTrigger";
7
+
8
+ const cx = c.bind(styles);
9
+
10
+ export type PopoverTriggerProps = RadixPopover.PopoverTriggerProps & {
11
+ /**
12
+ * Whether to show visual highlight effect around the trigger when popover is open.
13
+ * @default true
14
+ */
15
+ highlight?: boolean;
16
+ /**
17
+ * Whether to use negative (light) styling for the highlight effect.
18
+ * @default false
19
+ */
20
+ negative?: boolean;
21
+ };
22
+
23
+ export const PopoverTrigger = forwardRef<HTMLButtonElement, PopoverTriggerProps>(
24
+ ({ children, className, highlight = true, negative = false, ...props }, ref) => {
25
+ const { triggerRef, context } = usePopoverTrigger(ref);
26
+
27
+ return (
28
+ <div
29
+ className={cx(
30
+ "purpur-popover__trigger",
31
+ { "purpur-popover__trigger--highlight": context?.isOpen && highlight },
32
+ className
33
+ )}
34
+ >
35
+ {context?.isOpen && highlight && (
36
+ <>
37
+ {/* State 1: Outer glow effect */}
38
+ <div
39
+ className={cx(
40
+ "purpur-popover__trigger-highlight",
41
+ "purpur-popover__trigger-highlight--state1",
42
+ { "purpur-popover__trigger-highlight--negative": negative }
43
+ )}
44
+ aria-hidden="true"
45
+ />
46
+ {/* State 2: Inner border only */}
47
+ <div
48
+ className={cx(
49
+ "purpur-popover__trigger-highlight",
50
+ "purpur-popover__trigger-highlight--state2",
51
+ { "purpur-popover__trigger-highlight--negative": negative }
52
+ )}
53
+ aria-hidden="true"
54
+ />
55
+ </>
56
+ )}
57
+ <RadixPopover.Trigger
58
+ ref={triggerRef}
59
+ asChild
60
+ aria-expanded={context?.isOpen ?? false}
61
+ aria-haspopup="dialog"
62
+ {...props}
63
+ >
64
+ {children}
65
+ </RadixPopover.Trigger>
66
+ </div>
67
+ );
68
+ }
69
+ );
70
+
71
+ PopoverTrigger.displayName = "PopoverTrigger";
@@ -0,0 +1,346 @@
1
+ import React from "react";
2
+ import { render, screen, waitFor } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { Popover } from "./popover";
7
+ import { type PopoverAction, PopoverContent } from "./popover-content";
8
+ import { PopoverFlow } from "./popover-flow";
9
+ import { PopoverTrigger } from "./popover-trigger";
10
+
11
+ describe("Popover Walkthrough Integration Tests", () => {
12
+ let user: ReturnType<typeof userEvent.setup>;
13
+
14
+ beforeEach(() => {
15
+ user = userEvent.setup();
16
+ // Mock scrollTo for tests
17
+ window.scrollTo = vi.fn();
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.clearAllMocks();
22
+ });
23
+
24
+ const ThreeStepWalkthrough = ({ onAction }: { onAction?: (action: PopoverAction) => void }) => {
25
+ return (
26
+ <PopoverFlow
27
+ separatorText="of"
28
+ stepText="Step"
29
+ backLabel="Back"
30
+ nextLabel="Next"
31
+ finishLabel="Finish"
32
+ >
33
+ <div data-testid="step-1-container">
34
+ <Popover multistep step={1}>
35
+ <PopoverTrigger>
36
+ <button>Step 1 Trigger</button>
37
+ </PopoverTrigger>
38
+ <PopoverContent
39
+ beakPosition="down"
40
+ closeIconAriaLabel="Close"
41
+ title="Step 1"
42
+ body="This is step 1"
43
+ onAction={onAction}
44
+ />
45
+ </Popover>
46
+ </div>
47
+
48
+ <div data-testid="step-2-container">
49
+ <Popover multistep step={2}>
50
+ <PopoverTrigger>
51
+ <button>Step 2 Trigger</button>
52
+ </PopoverTrigger>
53
+ <PopoverContent
54
+ beakPosition="down"
55
+ closeIconAriaLabel="Close"
56
+ title="Step 2"
57
+ body="This is step 2"
58
+ onAction={onAction}
59
+ />
60
+ </Popover>
61
+ </div>
62
+
63
+ <div data-testid="step-3-container">
64
+ <Popover multistep step={3}>
65
+ <PopoverTrigger>
66
+ <button>Step 3 Trigger</button>
67
+ </PopoverTrigger>
68
+ <PopoverContent
69
+ beakPosition="down"
70
+ closeIconAriaLabel="Close"
71
+ title="Step 3"
72
+ body="This is step 3"
73
+ onAction={onAction}
74
+ />
75
+ </Popover>
76
+ </div>
77
+ </PopoverFlow>
78
+ );
79
+ };
80
+
81
+ it("should auto-open the first step when mounted", async () => {
82
+ render(<ThreeStepWalkthrough />);
83
+
84
+ // Wait for first step to open
85
+ await waitFor(() => {
86
+ expect(screen.getByText("Step 1")).toBeInTheDocument();
87
+ });
88
+
89
+ expect(screen.getByText("This is step 1")).toBeInTheDocument();
90
+ expect(screen.getByRole("status")).toHaveTextContent("Step 1 of 3");
91
+ });
92
+
93
+ it("should navigate from step 1 to step 2 when clicking Next", async () => {
94
+ render(<ThreeStepWalkthrough />);
95
+
96
+ // Wait for first step to open
97
+ await waitFor(() => {
98
+ expect(screen.getByText("Step 1")).toBeInTheDocument();
99
+ });
100
+
101
+ // Click Next button
102
+ const nextButton = screen.getByRole("button", { name: /next/i });
103
+ await user.click(nextButton);
104
+
105
+ // Wait for step 2 to open
106
+ await waitFor(() => {
107
+ expect(screen.getByText("Step 2")).toBeInTheDocument();
108
+ });
109
+
110
+ expect(screen.getByText("This is step 2")).toBeInTheDocument();
111
+ expect(screen.getByRole("status")).toHaveTextContent("Step 2 of 3");
112
+ });
113
+
114
+ it("should navigate from step 2 to step 3 when clicking Next", async () => {
115
+ render(<ThreeStepWalkthrough />);
116
+
117
+ // Wait for first step
118
+ await waitFor(() => {
119
+ expect(screen.getByText("Step 1")).toBeInTheDocument();
120
+ });
121
+
122
+ // Navigate to step 2
123
+ await user.click(screen.getByRole("button", { name: /next/i }));
124
+
125
+ await waitFor(() => {
126
+ expect(screen.getByText("Step 2")).toBeInTheDocument();
127
+ });
128
+
129
+ // Navigate to step 3
130
+ await user.click(screen.getByRole("button", { name: /next/i }));
131
+
132
+ // Wait for step 3 to open
133
+ await waitFor(() => {
134
+ expect(screen.getByText("Step 3")).toBeInTheDocument();
135
+ });
136
+
137
+ expect(screen.getByText("This is step 3")).toBeInTheDocument();
138
+ expect(screen.getByRole("status")).toHaveTextContent("Step 3 of 3");
139
+ });
140
+
141
+ it("should keep step 3 open and show Finish button", async () => {
142
+ render(<ThreeStepWalkthrough />);
143
+
144
+ // Navigate through all steps
145
+ await waitFor(() => {
146
+ expect(screen.getByText("Step 1")).toBeInTheDocument();
147
+ });
148
+
149
+ await user.click(screen.getByRole("button", { name: /next/i }));
150
+
151
+ await waitFor(() => {
152
+ expect(screen.getByText("Step 2")).toBeInTheDocument();
153
+ });
154
+
155
+ await user.click(screen.getByRole("button", { name: /next/i }));
156
+
157
+ await waitFor(() => {
158
+ expect(screen.getByText("Step 3")).toBeInTheDocument();
159
+ });
160
+
161
+ // Step 3 should remain open
162
+ expect(screen.getByText("This is step 3")).toBeInTheDocument();
163
+
164
+ // Finish button should be visible
165
+ const finishButton = screen.getByRole("button", { name: /finish/i });
166
+ expect(finishButton).toBeInTheDocument();
167
+
168
+ // Wait a bit to ensure it doesn't disappear
169
+ await new Promise((resolve) => setTimeout(resolve, 500));
170
+
171
+ expect(screen.getByText("Step 3")).toBeInTheDocument();
172
+ expect(screen.getByText("This is step 3")).toBeInTheDocument();
173
+ });
174
+
175
+ it("should call onAction with 'finish' type when Finish button is clicked", async () => {
176
+ const onAction = vi.fn();
177
+ render(<ThreeStepWalkthrough onAction={onAction} />);
178
+
179
+ // Navigate through all steps
180
+ await waitFor(() => {
181
+ expect(screen.getByText("Step 1")).toBeInTheDocument();
182
+ });
183
+
184
+ await user.click(screen.getByRole("button", { name: /next/i }));
185
+
186
+ await waitFor(() => {
187
+ expect(screen.getByText("Step 2")).toBeInTheDocument();
188
+ });
189
+
190
+ await user.click(screen.getByRole("button", { name: /next/i }));
191
+
192
+ await waitFor(() => {
193
+ expect(screen.getByText("Step 3")).toBeInTheDocument();
194
+ });
195
+
196
+ // Click Finish button
197
+ const finishButton = screen.getByRole("button", { name: /finish/i });
198
+ await user.click(finishButton);
199
+
200
+ // Should have called onAction with finish type
201
+ expect(onAction).toHaveBeenCalledWith(
202
+ expect.objectContaining({
203
+ type: "finish",
204
+ step: 3,
205
+ })
206
+ );
207
+ });
208
+
209
+ it("should call onAction with 'dismiss' when close icon is clicked", async () => {
210
+ const onAction = vi.fn();
211
+ render(<ThreeStepWalkthrough onAction={onAction} />);
212
+
213
+ // Wait for first step
214
+ await waitFor(() => {
215
+ expect(screen.getByText("Step 1")).toBeInTheDocument();
216
+ });
217
+
218
+ // Click close button (X icon)
219
+ const closeButton = screen.getByLabelText(/close/i);
220
+ await user.click(closeButton);
221
+
222
+ // Should have called onAction with dismiss type
223
+ expect(onAction).toHaveBeenCalledWith(
224
+ expect.objectContaining({
225
+ type: "dismiss",
226
+ step: 1,
227
+ })
228
+ );
229
+ });
230
+
231
+ it("should navigate back from step 2 to step 1", async () => {
232
+ render(<ThreeStepWalkthrough />);
233
+
234
+ // Navigate to step 2
235
+ await waitFor(() => {
236
+ expect(screen.getByText("Step 1")).toBeInTheDocument();
237
+ });
238
+
239
+ await user.click(screen.getByRole("button", { name: /next/i }));
240
+
241
+ await waitFor(() => {
242
+ expect(screen.getByText("Step 2")).toBeInTheDocument();
243
+ });
244
+
245
+ // Click Back button
246
+ const backButton = screen.getByRole("button", { name: /back/i });
247
+ await user.click(backButton);
248
+
249
+ // Should return to step 1
250
+ await waitFor(() => {
251
+ expect(screen.getByText("Step 1")).toBeInTheDocument();
252
+ });
253
+
254
+ expect(screen.getByText("This is step 1")).toBeInTheDocument();
255
+ });
256
+
257
+ it("should not show Back button on first step", async () => {
258
+ render(<ThreeStepWalkthrough />);
259
+
260
+ await waitFor(() => {
261
+ expect(screen.getByText("Step 1")).toBeInTheDocument();
262
+ });
263
+
264
+ // Back button should not be present
265
+ expect(screen.queryByRole("button", { name: /back/i })).not.toBeInTheDocument();
266
+ });
267
+
268
+ it("should show both Back and Next buttons on middle step", async () => {
269
+ render(<ThreeStepWalkthrough />);
270
+
271
+ // Navigate to step 2
272
+ await waitFor(() => {
273
+ expect(screen.getByText("Step 1")).toBeInTheDocument();
274
+ });
275
+
276
+ await user.click(screen.getByRole("button", { name: /next/i }));
277
+
278
+ await waitFor(() => {
279
+ expect(screen.getByText("Step 2")).toBeInTheDocument();
280
+ });
281
+
282
+ // Both buttons should be present
283
+ expect(screen.getByRole("button", { name: /back/i })).toBeInTheDocument();
284
+ expect(screen.getByRole("button", { name: /next/i })).toBeInTheDocument();
285
+ });
286
+
287
+ it("should show Back and Finish buttons on last step", async () => {
288
+ render(<ThreeStepWalkthrough />);
289
+
290
+ // Navigate to step 3
291
+ await waitFor(() => {
292
+ expect(screen.getByText("Step 1")).toBeInTheDocument();
293
+ });
294
+
295
+ await user.click(screen.getByRole("button", { name: /next/i }));
296
+
297
+ await waitFor(() => {
298
+ expect(screen.getByText("Step 2")).toBeInTheDocument();
299
+ });
300
+
301
+ await user.click(screen.getByRole("button", { name: /next/i }));
302
+
303
+ await waitFor(() => {
304
+ expect(screen.getByText("Step 3")).toBeInTheDocument();
305
+ });
306
+
307
+ // Back and Finish buttons should be present
308
+ expect(screen.getByRole("button", { name: /back/i })).toBeInTheDocument();
309
+ expect(screen.getByRole("button", { name: /finish/i })).toBeInTheDocument();
310
+
311
+ // Next button should not be present
312
+ expect(screen.queryByRole("button", { name: /next/i })).not.toBeInTheDocument();
313
+ });
314
+
315
+ it("should properly sequence through all navigation events", async () => {
316
+ const onAction = vi.fn();
317
+ render(<ThreeStepWalkthrough onAction={onAction} />);
318
+
319
+ // Step 1 -> Step 2
320
+ await waitFor(() => {
321
+ expect(screen.getByText("Step 1")).toBeInTheDocument();
322
+ });
323
+ await user.click(screen.getByRole("button", { name: /next/i }));
324
+
325
+ expect(onAction).toHaveBeenCalledWith(expect.objectContaining({ type: "next", step: 1 }));
326
+
327
+ // Step 2 -> Step 3
328
+ await waitFor(() => {
329
+ expect(screen.getByText("Step 2")).toBeInTheDocument();
330
+ });
331
+ await user.click(screen.getByRole("button", { name: /next/i }));
332
+
333
+ expect(onAction).toHaveBeenCalledWith(expect.objectContaining({ type: "next", step: 2 }));
334
+
335
+ // Step 3 -> Finish
336
+ await waitFor(() => {
337
+ expect(screen.getByText("Step 3")).toBeInTheDocument();
338
+ });
339
+ await user.click(screen.getByRole("button", { name: /finish/i }));
340
+
341
+ expect(onAction).toHaveBeenCalledWith(expect.objectContaining({ type: "finish", step: 3 }));
342
+
343
+ // Should have been called 3 times total
344
+ expect(onAction).toHaveBeenCalledTimes(3);
345
+ });
346
+ });
@@ -0,0 +1,45 @@
1
+ import React, { type ReactNode } from "react";
2
+ import * as RadixPopover from "@radix-ui/react-popover";
3
+
4
+ import { PopoverInternalContext } from "./popover-internal-context";
5
+ import { usePopoverWalkthrough } from "./usePopoverWalkthrough";
6
+
7
+ export type PopoverWalkthroughProps = RadixPopover.PopoverProps & {
8
+ children: ReactNode;
9
+ step: number;
10
+ className?: string;
11
+ disableClickOutside?: boolean;
12
+ };
13
+
14
+ export const PopoverWalkthrough = ({
15
+ children,
16
+ step,
17
+ onOpenChange: consumerOnOpenChange,
18
+ disableClickOutside = true,
19
+ ...props
20
+ }: PopoverWalkthroughProps) => {
21
+ const { actuallyOpen, onScrollStart, onScrollComplete, handleOpenChange } =
22
+ usePopoverWalkthrough(step);
23
+
24
+ return (
25
+ <RadixPopover.Root
26
+ open={actuallyOpen}
27
+ onOpenChange={(open) => handleOpenChange(open, consumerOnOpenChange)}
28
+ {...props}
29
+ >
30
+ <PopoverInternalContext.Provider
31
+ value={{
32
+ isOpen: actuallyOpen,
33
+ walkthroughStep: step,
34
+ onScrollStart,
35
+ onScrollComplete,
36
+ disableClickOutside,
37
+ }}
38
+ >
39
+ {children}
40
+ </PopoverInternalContext.Provider>
41
+ </RadixPopover.Root>
42
+ );
43
+ };
44
+
45
+ PopoverWalkthrough.displayName = "PopoverWalkthrough";