@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.
- package/dist/LICENSE.txt +905 -0
- package/dist/metadata.js +8 -0
- package/dist/popover-back.d.ts +9 -0
- package/dist/popover-back.d.ts.map +1 -0
- package/dist/popover-button.d.ts +37 -0
- package/dist/popover-button.d.ts.map +1 -0
- package/dist/popover-content.d.ts +93 -0
- package/dist/popover-content.d.ts.map +1 -0
- package/dist/popover-flow.d.ts +65 -0
- package/dist/popover-flow.d.ts.map +1 -0
- package/dist/popover-footer.d.ts +16 -0
- package/dist/popover-footer.d.ts.map +1 -0
- package/dist/popover-header.d.ts +7 -0
- package/dist/popover-header.d.ts.map +1 -0
- package/dist/popover-internal-context.d.ts +15 -0
- package/dist/popover-internal-context.d.ts.map +1 -0
- package/dist/popover-next.d.ts +9 -0
- package/dist/popover-next.d.ts.map +1 -0
- package/dist/popover-standalone.d.ts +12 -0
- package/dist/popover-standalone.d.ts.map +1 -0
- package/dist/popover-steps.d.ts +6 -0
- package/dist/popover-steps.d.ts.map +1 -0
- package/dist/popover-trigger.d.ts +27 -0
- package/dist/popover-trigger.d.ts.map +1 -0
- package/dist/popover-walkthrough.d.ts +13 -0
- package/dist/popover-walkthrough.d.ts.map +1 -0
- package/dist/popover.cjs.js +42 -0
- package/dist/popover.cjs.js.map +1 -0
- package/dist/popover.d.ts +36 -0
- package/dist/popover.d.ts.map +1 -0
- package/dist/popover.es.js +3849 -0
- package/dist/popover.es.js.map +1 -0
- package/dist/styles.css +1 -0
- package/dist/use-screen-size.hook.d.ts +7 -0
- package/dist/use-screen-size.hook.d.ts.map +1 -0
- package/dist/use-smooth-scroll.d.ts +5 -0
- package/dist/use-smooth-scroll.d.ts.map +1 -0
- package/dist/usePopoverTrigger.d.ts +5 -0
- package/dist/usePopoverTrigger.d.ts.map +1 -0
- package/dist/usePopoverWalkthrough.d.ts +7 -0
- package/dist/usePopoverWalkthrough.d.ts.map +1 -0
- package/eslint.config.mjs +2 -0
- package/package.json +82 -0
- package/src/global.d.ts +4 -0
- package/src/popover-back.test.tsx +63 -0
- package/src/popover-back.tsx +40 -0
- package/src/popover-button.test.tsx +51 -0
- package/src/popover-button.tsx +84 -0
- package/src/popover-content.test.tsx +1122 -0
- package/src/popover-content.tsx +277 -0
- package/src/popover-flow.tsx +170 -0
- package/src/popover-footer.test.tsx +21 -0
- package/src/popover-footer.tsx +32 -0
- package/src/popover-header.test.tsx +22 -0
- package/src/popover-header.tsx +32 -0
- package/src/popover-internal-context.tsx +28 -0
- package/src/popover-next.test.tsx +61 -0
- package/src/popover-next.tsx +40 -0
- package/src/popover-standalone.tsx +48 -0
- package/src/popover-steps.tsx +32 -0
- package/src/popover-trigger.tsx +71 -0
- package/src/popover-walkthrough.test.tsx +346 -0
- package/src/popover-walkthrough.tsx +45 -0
- package/src/popover.module.scss +315 -0
- package/src/popover.stories.tsx +1157 -0
- package/src/popover.test.tsx +642 -0
- package/src/popover.tsx +76 -0
- package/src/use-screen-size.hook.ts +39 -0
- package/src/use-smooth-scroll.ts +62 -0
- package/src/usePopoverTrigger.ts +59 -0
- package/src/usePopoverWalkthrough.ts +85 -0
- 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";
|