@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,277 @@
1
+ import React, { forwardRef, type ReactNode, useRef } from "react";
2
+ import { Button } from "@purpurds/button";
3
+ import { IconClose } from "@purpurds/icon/close";
4
+ import { Paragraph } from "@purpurds/paragraph";
5
+ import * as RadixPopover from "@radix-ui/react-popover";
6
+ import c from "classnames/bind";
7
+
8
+ import styles from "./popover.module.scss";
9
+ import { PopoverBack } from "./popover-back";
10
+ import { PopoverButton } from "./popover-button";
11
+ import { useOptionalPopoverFlow } from "./popover-flow";
12
+ import { PopoverFooter } from "./popover-footer";
13
+ import { PopoverHeader } from "./popover-header";
14
+ import { PopoverNegativeContext, usePopoverInternal } from "./popover-internal-context";
15
+ import { PopoverNext } from "./popover-next";
16
+
17
+ const cx = c.bind(styles);
18
+
19
+ export type PopoverAction = {
20
+ type: "next" | "back" | "finish" | "dismiss";
21
+ step?: number;
22
+ };
23
+
24
+ export type PopoverContentProps = RadixPopover.PopoverContentProps & {
25
+ /**
26
+ * Position of the arrow/beak pointing to the trigger element.
27
+ * Set to "none" to hide the arrow.
28
+ * @default "down"
29
+ */
30
+ beakPosition?: "up" | "right" | "down" | "left" | "none";
31
+ /**
32
+ * Whether to use negative (light) styling for the popover content.
33
+ * @default false
34
+ */
35
+ negative?: boolean;
36
+ /**
37
+ * Accessible label for the close button icon.
38
+ */
39
+ closeIconAriaLabel: string;
40
+ /**
41
+ * Main title text displayed in the popover header.
42
+ */
43
+ title: string;
44
+ /**
45
+ * Optional icon displayed in the popover header next to the title.
46
+ */
47
+ icon?: ReactNode;
48
+ /**
49
+ * Main body text content of the popover.
50
+ */
51
+ body: string;
52
+ /**
53
+ * Custom footer content. If not provided and used within PopoverFlow,
54
+ * default navigation buttons will be rendered.
55
+ */
56
+ children?: ReactNode;
57
+ /**
58
+ * Callback fired when user interacts with navigation buttons (next, back, finish, dismiss).
59
+ */
60
+ onAction?: (action: PopoverAction) => void;
61
+ /**
62
+ * CSS z-index value for the popover content.
63
+ * @default 210
64
+ */
65
+ zIndex?: number;
66
+ };
67
+
68
+ export const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(
69
+ (
70
+ {
71
+ children,
72
+ className,
73
+ beakPosition = "down",
74
+ align = "center",
75
+ alignOffset = 0,
76
+ sideOffset = 5, // How close or far the popover is from the trigger
77
+ negative = false,
78
+ closeIconAriaLabel,
79
+ title,
80
+ icon,
81
+ body,
82
+ onAction,
83
+ zIndex = 210,
84
+ ...props
85
+ },
86
+ ref
87
+ ) => {
88
+ const flow = useOptionalPopoverFlow();
89
+ const context = usePopoverInternal();
90
+ const headerRef = useRef<HTMLDivElement>(null);
91
+
92
+ // Map beakPosition to Radix side values
93
+ const beakPositionMap: Record<
94
+ "up" | "right" | "down" | "left" | "none",
95
+ "bottom" | "left" | "top" | "right"
96
+ > = {
97
+ up: "bottom",
98
+ right: "left",
99
+ down: "top",
100
+ left: "right",
101
+ none: "bottom", // Default position when no arrow
102
+ };
103
+
104
+ const radixSide = beakPositionMap[beakPosition];
105
+ const shouldShowArrow = beakPosition !== "none";
106
+
107
+ let footerContent = children;
108
+
109
+ if (flow && !children) {
110
+ // Use walkthroughStep from context (0 for standalone, actual step for walkthrough)
111
+ const currentStep = context?.walkthroughStep || flow.currentStep;
112
+ const isFirstStep = currentStep === 1;
113
+ const isLastStep = currentStep === flow.totalSteps;
114
+
115
+ footerContent = (
116
+ <PopoverFooter>
117
+ {!isFirstStep && (
118
+ <PopoverBack onClick={() => onAction?.({ type: "back", step: currentStep })}>
119
+ {flow.backLabel}
120
+ </PopoverBack>
121
+ )}
122
+ {isLastStep ? (
123
+ <PopoverButton
124
+ onClick={() => onAction?.({ type: "finish", step: currentStep })}
125
+ dismiss
126
+ >
127
+ {flow.finishLabel}
128
+ </PopoverButton>
129
+ ) : (
130
+ <PopoverNext onClick={() => onAction?.({ type: "next", step: currentStep })}>
131
+ {flow.nextLabel}
132
+ </PopoverNext>
133
+ )}
134
+ </PopoverFooter>
135
+ );
136
+ }
137
+
138
+ const handleOpenAutoFocus = (event: Event) => {
139
+ event.preventDefault();
140
+
141
+ if (flow) {
142
+ const contentElement = event.currentTarget as HTMLElement;
143
+ // Focus the content element for better accessibility
144
+ // Use setTimeout to ensure the DOM has fully rendered
145
+ setTimeout(() => {
146
+ contentElement.setAttribute("tabindex", "-1");
147
+ contentElement.focus();
148
+ }, 0);
149
+ }
150
+
151
+ props.onOpenAutoFocus?.(event);
152
+ };
153
+
154
+ const handleCloseAutoFocus = (event: Event) => {
155
+ // Prevent focus from returning to trigger in walkthrough flow
156
+ // This allows the next popover to manage focus properly
157
+ if (flow) {
158
+ event.preventDefault();
159
+ }
160
+
161
+ props.onCloseAutoFocus?.(event);
162
+ };
163
+
164
+ const handleClose = (event: React.MouseEvent) => {
165
+ const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
166
+
167
+ if (flow && !prefersReducedMotion) {
168
+ event.preventDefault();
169
+ setTimeout(() => {
170
+ onAction?.({ type: "dismiss", step: flow.currentStep });
171
+ flow.dismiss();
172
+ }, 200);
173
+ } else {
174
+ onAction?.({ type: "dismiss", step: flow?.currentStep });
175
+ flow?.dismiss();
176
+ }
177
+ };
178
+
179
+ const handleEscapeKey = (event: KeyboardEvent) => {
180
+ const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
181
+
182
+ if (flow && !prefersReducedMotion) {
183
+ event.preventDefault();
184
+ setTimeout(() => {
185
+ onAction?.({ type: "dismiss", step: flow.currentStep });
186
+ flow.dismiss();
187
+ }, 200);
188
+ } else {
189
+ onAction?.({ type: "dismiss", step: flow?.currentStep });
190
+ flow?.dismiss();
191
+ }
192
+ };
193
+
194
+ const handleInteractOutside = (event: Event) => {
195
+ if (context?.disableClickOutside) {
196
+ event.preventDefault();
197
+ return;
198
+ }
199
+
200
+ if (flow) {
201
+ const target = event.target as HTMLElement;
202
+ const isPopoverInteraction =
203
+ target.closest("[role='dialog']") ||
204
+ target.closest(".purpur-popover__trigger") ||
205
+ target.closest("button[aria-haspopup='dialog']");
206
+
207
+ if (!isPopoverInteraction) {
208
+ onAction?.({ type: "dismiss", step: flow.currentStep });
209
+ setTimeout(() => {
210
+ flow.dismiss();
211
+ }, 0);
212
+ }
213
+ }
214
+ };
215
+
216
+ return (
217
+ <RadixPopover.Portal>
218
+ <RadixPopover.Content
219
+ data-testid="popover-content"
220
+ role="dialog"
221
+ aria-modal="true"
222
+ aria-labelledby={props["aria-label"] ? undefined : "popover-heading"}
223
+ ref={ref}
224
+ side={radixSide}
225
+ sideOffset={sideOffset}
226
+ align={align}
227
+ alignOffset={alignOffset}
228
+ style={
229
+ {
230
+ "--popover-z-index": zIndex,
231
+ } as React.CSSProperties
232
+ }
233
+ className={cx(
234
+ "purpur-popover__content",
235
+ { "purpur-popover__content--negative": negative },
236
+ className
237
+ )}
238
+ onOpenAutoFocus={handleOpenAutoFocus}
239
+ onCloseAutoFocus={handleCloseAutoFocus}
240
+ onEscapeKeyDown={handleEscapeKey}
241
+ onInteractOutside={handleInteractOutside}
242
+ {...props}
243
+ >
244
+ <PopoverNegativeContext.Provider value={{ negative }}>
245
+ <div className={cx("purpur-popover__inner")}>
246
+ <PopoverHeader ref={headerRef} title={title} icon={icon} />
247
+ <Paragraph className={cx("purpur-popover__body")}>{body}</Paragraph>
248
+ </div>
249
+ {footerContent}
250
+
251
+ <RadixPopover.Close asChild onClick={handleClose}>
252
+ <Button
253
+ variant={negative ? "text" : "tertiary-purple"}
254
+ size="sm"
255
+ aria-label={closeIconAriaLabel}
256
+ className={cx("purpur-popover__close")}
257
+ negative={!negative}
258
+ iconOnly
259
+ >
260
+ <IconClose size="xs" className={cx("purpur-popover__icon")} />
261
+ </Button>
262
+ </RadixPopover.Close>
263
+ </PopoverNegativeContext.Provider>
264
+ {shouldShowArrow && (
265
+ <RadixPopover.Arrow
266
+ className={cx("purpur-popover__arrow")}
267
+ aria-hidden="true"
268
+ tabIndex={-1}
269
+ />
270
+ )}
271
+ </RadixPopover.Content>
272
+ </RadixPopover.Portal>
273
+ );
274
+ }
275
+ );
276
+
277
+ PopoverContent.displayName = "PopoverContent";
@@ -0,0 +1,170 @@
1
+ import React, {
2
+ createContext,
3
+ type ReactNode,
4
+ useCallback,
5
+ useContext,
6
+ useMemo,
7
+ useState,
8
+ } from "react";
9
+
10
+ type PopoverFlowContextType = {
11
+ currentStep: number;
12
+ totalSteps: number;
13
+ next: () => void;
14
+ back: () => void;
15
+ dismiss: () => void;
16
+ registerStep: (step: number) => void;
17
+ unregisterStep: (step: number) => void;
18
+ separatorText: string;
19
+ stepText: string;
20
+ backLabel: string;
21
+ nextLabel: string;
22
+ finishLabel: string;
23
+ openDelay: number;
24
+ };
25
+
26
+ const PopoverFlowContext = createContext<PopoverFlowContextType | null>(null);
27
+
28
+ export const usePopoverFlow = () => {
29
+ const context = useContext(PopoverFlowContext);
30
+ if (!context) {
31
+ throw new Error("usePopoverFlow must be used within a PopoverFlow provider");
32
+ }
33
+ return context;
34
+ };
35
+
36
+ export const useOptionalPopoverFlow = (): PopoverFlowContextType | null => {
37
+ return useContext(PopoverFlowContext);
38
+ };
39
+
40
+ export type PopoverFlowProps = {
41
+ /**
42
+ * Child components, typically multiple Popover components with step numbers.
43
+ */
44
+ children: ReactNode;
45
+ /**
46
+ * The step number to show when the flow starts.
47
+ * @default 1
48
+ */
49
+ initialStep?: number;
50
+ /**
51
+ * Callback fired when the user completes the last step of the flow.
52
+ */
53
+ onComplete?: () => void;
54
+ /**
55
+ * Callback fired when the user dismisses the flow before completing it.
56
+ */
57
+ onDismiss?: () => void;
58
+ /**
59
+ * Text used as separator in the step indicator (e.g., "of" in "1 of 3").
60
+ */
61
+ separatorText: string;
62
+ /**
63
+ * Text label for "Step" in the step indicator.
64
+ */
65
+ stepText: string;
66
+ /**
67
+ * Label for the back navigation button.
68
+ */
69
+ backLabel: string;
70
+ /**
71
+ * Label for the next navigation button.
72
+ */
73
+ nextLabel: string;
74
+ /**
75
+ * Label for the finish button on the last step.
76
+ */
77
+ finishLabel: string;
78
+ /**
79
+ * Delay in milliseconds before opening the next popover in the flow.
80
+ * @default 0
81
+ */
82
+ openDelay?: number;
83
+ };
84
+
85
+ export const PopoverFlow = ({
86
+ children,
87
+ initialStep = 1,
88
+ onComplete,
89
+ onDismiss,
90
+ separatorText,
91
+ stepText,
92
+ backLabel,
93
+ nextLabel,
94
+ finishLabel,
95
+ openDelay = 0,
96
+ }: PopoverFlowProps) => {
97
+ const [currentStep, setCurrentStep] = useState(initialStep);
98
+ const [registeredSteps, setRegisteredSteps] = useState<Set<number>>(new Set());
99
+
100
+ const registerStep = useCallback((step: number) => {
101
+ setRegisteredSteps((prev) => {
102
+ const next = new Set(prev);
103
+ next.add(step);
104
+ return next;
105
+ });
106
+ }, []);
107
+
108
+ const unregisterStep = useCallback((step: number) => {
109
+ setRegisteredSteps((prev) => {
110
+ const next = new Set(prev);
111
+ next.delete(step);
112
+ return next;
113
+ });
114
+ }, []);
115
+
116
+ const next = useCallback(() => {
117
+ setCurrentStep((prev) => {
118
+ const nextStep = prev + 1;
119
+ if (nextStep > registeredSteps.size) {
120
+ onComplete?.();
121
+ return prev;
122
+ }
123
+ return nextStep;
124
+ });
125
+ }, [registeredSteps.size, onComplete]);
126
+
127
+ const back = useCallback(() => {
128
+ setCurrentStep((prev) => Math.max(1, prev - 1));
129
+ }, []);
130
+
131
+ const dismiss = useCallback(() => {
132
+ setCurrentStep(0);
133
+ onDismiss?.();
134
+ }, [onDismiss]);
135
+
136
+ const value = useMemo(
137
+ () => ({
138
+ currentStep,
139
+ totalSteps: registeredSteps.size,
140
+ next,
141
+ back,
142
+ dismiss,
143
+ registerStep,
144
+ unregisterStep,
145
+ separatorText,
146
+ stepText,
147
+ backLabel,
148
+ nextLabel,
149
+ finishLabel,
150
+ openDelay,
151
+ }),
152
+ [
153
+ currentStep,
154
+ registeredSteps.size,
155
+ next,
156
+ back,
157
+ dismiss,
158
+ registerStep,
159
+ unregisterStep,
160
+ separatorText,
161
+ stepText,
162
+ backLabel,
163
+ nextLabel,
164
+ finishLabel,
165
+ openDelay,
166
+ ]
167
+ );
168
+
169
+ return <PopoverFlowContext.Provider value={value}>{children}</PopoverFlowContext.Provider>;
170
+ };
@@ -0,0 +1,21 @@
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { describe, expect,it } from "vitest";
4
+ import { axe } from "vitest-axe";
5
+
6
+ import { PopoverFooter } from "./popover-footer";
7
+
8
+ describe("PopoverFooter", () => {
9
+ it("renders children", () => {
10
+ render(<PopoverFooter>Footer content</PopoverFooter>);
11
+ expect(screen.getByText("Footer content")).toBeInTheDocument();
12
+ });
13
+
14
+ it("should be accessible", async () => {
15
+ const { container } = render(<PopoverFooter>Footer content</PopoverFooter>);
16
+ const results = await axe(container);
17
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
18
+ // @ts-ignore
19
+ expect(results).toHaveNoViolations();
20
+ });
21
+ });
@@ -0,0 +1,32 @@
1
+ import React, { type ReactNode } from "react";
2
+ import c from "classnames/bind";
3
+
4
+ import styles from "./popover.module.scss";
5
+ import { useOptionalPopoverFlow } from "./popover-flow";
6
+ import { PopoverSteps } from "./popover-steps";
7
+
8
+ const cx = c.bind(styles);
9
+
10
+ export type PopoverFooterProps = {
11
+ /**
12
+ * Footer content, typically navigation buttons like PopoverButton, PopoverNext, or PopoverBack.
13
+ */
14
+ children: ReactNode;
15
+ /**
16
+ * Optional CSS class name for custom styling.
17
+ */
18
+ className?: string;
19
+ };
20
+
21
+ export const PopoverFooter = ({ children, className }: PopoverFooterProps) => {
22
+ const flow = useOptionalPopoverFlow();
23
+
24
+ return (
25
+ <div className={cx("purpur-popover__footer", className)}>
26
+ {flow && <PopoverSteps />}
27
+ <div className={cx("purpur-popover__footer-button-group")}>{children}</div>
28
+ </div>
29
+ );
30
+ };
31
+
32
+ PopoverFooter.displayName = "PopoverFooter";
@@ -0,0 +1,22 @@
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { describe, expect,it } from "vitest";
4
+ import { axe } from "vitest-axe";
5
+
6
+ import { PopoverHeader } from "./popover-header";
7
+
8
+ describe("PopoverHeader", () => {
9
+ it("renders title and icon", () => {
10
+ render(<PopoverHeader title="Header title" icon={<span>icon</span>} />);
11
+ expect(screen.getByText("Header title")).toBeInTheDocument();
12
+ expect(screen.getByText("icon")).toBeInTheDocument();
13
+ });
14
+
15
+ it("should be accessible", async () => {
16
+ const { container } = render(<PopoverHeader title="Header title" icon={<span>icon</span>} />);
17
+ const results = await axe(container);
18
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
19
+ // @ts-ignore
20
+ expect(results).toHaveNoViolations();
21
+ });
22
+ });
@@ -0,0 +1,32 @@
1
+ import React, { forwardRef, type ReactNode } from "react";
2
+ import { Heading } from "@purpurds/heading";
3
+ import c from "classnames/bind";
4
+
5
+ import styles from "./popover.module.scss";
6
+
7
+ const cx = c.bind(styles);
8
+
9
+ export type PopoverHeaderProps = {
10
+ title: string;
11
+ icon?: ReactNode;
12
+ };
13
+
14
+ export const PopoverHeader = forwardRef<HTMLDivElement, PopoverHeaderProps>(
15
+ ({ title, icon }, ref) => {
16
+ return (
17
+ <div ref={ref} className={cx("purpur-popover__header-content")}>
18
+ {icon && <span className={cx("purpur-popover__icon")}>{icon}</span>}
19
+ <Heading
20
+ tag="h2"
21
+ variant="title-100"
22
+ className={cx("purpur-popover__title")}
23
+ id="popover-heading"
24
+ >
25
+ {title}
26
+ </Heading>
27
+ </div>
28
+ );
29
+ }
30
+ );
31
+
32
+ PopoverHeader.displayName = "PopoverHeader";
@@ -0,0 +1,28 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ export type PopoverInternalContextValue = {
4
+ isOpen: boolean;
5
+ walkthroughStep: number;
6
+ onScrollComplete?: () => void;
7
+ onScrollStart?: () => void;
8
+ disableClickOutside?: boolean;
9
+ };
10
+
11
+ export const PopoverInternalContext = createContext<PopoverInternalContextValue | null>(null);
12
+
13
+ export const usePopoverInternal = () => {
14
+ const context = useContext(PopoverInternalContext);
15
+ return context;
16
+ };
17
+
18
+ // Separate context for UI-specific props that are set at the Content level
19
+ export type PopoverNegativeContextValue = {
20
+ negative: boolean;
21
+ };
22
+
23
+ export const PopoverNegativeContext = createContext<PopoverNegativeContextValue>({ negative: false });
24
+
25
+ export const usePopoverNegative = () => {
26
+ const context = useContext(PopoverNegativeContext);
27
+ return context;
28
+ };
@@ -0,0 +1,61 @@
1
+ import React from "react";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { axe } from "vitest-axe";
5
+
6
+ import { PopoverFlow } from "./popover-flow";
7
+ import { PopoverInternalContext } from "./popover-internal-context";
8
+ import { PopoverNext } from "./popover-next";
9
+
10
+ describe("PopoverNext", () => {
11
+ it("renders children and handles click", () => {
12
+ const onClick = vi.fn();
13
+ render(
14
+ <PopoverFlow
15
+ separatorText="of"
16
+ stepText="Step"
17
+ backLabel="Back"
18
+ nextLabel="Next"
19
+ finishLabel="Finish"
20
+ >
21
+ <PopoverInternalContext.Provider
22
+ value={{
23
+ isOpen: false,
24
+ walkthroughStep: 0,
25
+ negative: false,
26
+ }}
27
+ >
28
+ <PopoverNext onClick={onClick}>Next</PopoverNext>
29
+ </PopoverInternalContext.Provider>
30
+ </PopoverFlow>
31
+ );
32
+ fireEvent.click(screen.getByText("Next"));
33
+ expect(onClick).toHaveBeenCalled();
34
+ });
35
+
36
+ it("should be accessible", async () => {
37
+ const { container } = render(
38
+ <PopoverFlow
39
+ separatorText="of"
40
+ stepText="Step"
41
+ backLabel="Back"
42
+ nextLabel="Next"
43
+ finishLabel="Finish"
44
+ >
45
+ <PopoverInternalContext.Provider
46
+ value={{
47
+ isOpen: false,
48
+ walkthroughStep: 0,
49
+ negative: false,
50
+ }}
51
+ >
52
+ <PopoverNext>Next</PopoverNext>
53
+ </PopoverInternalContext.Provider>
54
+ </PopoverFlow>
55
+ );
56
+ const results = await axe(container);
57
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
58
+ // @ts-ignore
59
+ expect(results).toHaveNoViolations();
60
+ });
61
+ });
@@ -0,0 +1,40 @@
1
+ import React, { type ReactNode } from "react";
2
+ import { Button } from "@purpurds/button";
3
+
4
+ import { usePopoverFlow } from "./popover-flow";
5
+ import { usePopoverNegative } from "./popover-internal-context";
6
+ import { useScreenSize } from "./use-screen-size.hook";
7
+
8
+ export const PopoverNext = ({
9
+ children,
10
+ onClick,
11
+ }: {
12
+ children: ReactNode;
13
+ onClick?: () => void;
14
+ }) => {
15
+ const { next } = usePopoverFlow();
16
+ const { negative: isNegative } = usePopoverNegative();
17
+ const { isMdOrSmaller } = useScreenSize();
18
+
19
+ // Invert negative: if popover is negative (light), buttons should be normal (dark)
20
+ const negative = isNegative ? false : true;
21
+
22
+ const handleClick = () => {
23
+ onClick?.();
24
+ next();
25
+ };
26
+
27
+ return (
28
+ <Button
29
+ size="sm"
30
+ variant="primary"
31
+ onClick={handleClick}
32
+ negative={negative}
33
+ fullWidth={isMdOrSmaller}
34
+ >
35
+ {children}
36
+ </Button>
37
+ );
38
+ };
39
+
40
+ PopoverNext.displayName = "PopoverNext";