@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
package/src/popover.tsx
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import React, { type ReactNode } from "react";
|
|
2
|
+
import type * as RadixPopover from "@radix-ui/react-popover";
|
|
3
|
+
|
|
4
|
+
import { PopoverBack } from "./popover-back";
|
|
5
|
+
import { PopoverButton } from "./popover-button";
|
|
6
|
+
import { type PopoverAction,PopoverContent } from "./popover-content";
|
|
7
|
+
import { PopoverFlow } from "./popover-flow";
|
|
8
|
+
import { PopoverFooter } from "./popover-footer";
|
|
9
|
+
import { PopoverNext } from "./popover-next";
|
|
10
|
+
import { PopoverStandalone } from "./popover-standalone";
|
|
11
|
+
import { PopoverTrigger } from "./popover-trigger";
|
|
12
|
+
import { PopoverWalkthrough } from "./popover-walkthrough";
|
|
13
|
+
|
|
14
|
+
export type { PopoverAction };
|
|
15
|
+
|
|
16
|
+
// Base props shared by both variants
|
|
17
|
+
type BasePopoverProps = RadixPopover.PopoverProps & {
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
className?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Standalone variant props
|
|
23
|
+
type StandalonePopoverProps = BasePopoverProps & {
|
|
24
|
+
multistep?: false;
|
|
25
|
+
step?: never;
|
|
26
|
+
disableClickOutside?: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Walkthrough variant props
|
|
30
|
+
type WalkthroughPopoverProps = BasePopoverProps & {
|
|
31
|
+
multistep: true;
|
|
32
|
+
step: number;
|
|
33
|
+
disableClickOutside?: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type PopoverProps = StandalonePopoverProps | WalkthroughPopoverProps;
|
|
37
|
+
|
|
38
|
+
type PopoverCmp<P> = React.FunctionComponent<P> & {
|
|
39
|
+
Back: typeof PopoverBack;
|
|
40
|
+
Button: typeof PopoverButton;
|
|
41
|
+
Content: typeof PopoverContent;
|
|
42
|
+
Flow: typeof PopoverFlow;
|
|
43
|
+
Footer: typeof PopoverFooter;
|
|
44
|
+
Next: typeof PopoverNext;
|
|
45
|
+
Trigger: typeof PopoverTrigger;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const Popover: PopoverCmp<PopoverProps> = (props) => {
|
|
49
|
+
const { multistep = false, disableClickOutside, ...restProps } = props;
|
|
50
|
+
|
|
51
|
+
if (multistep) {
|
|
52
|
+
return (
|
|
53
|
+
<PopoverWalkthrough
|
|
54
|
+
{...(restProps as WalkthroughPopoverProps)}
|
|
55
|
+
disableClickOutside={disableClickOutside ?? true}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<PopoverStandalone
|
|
62
|
+
{...(restProps as StandalonePopoverProps)}
|
|
63
|
+
disableClickOutside={disableClickOutside}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
Popover.Back = PopoverBack;
|
|
69
|
+
Popover.Button = PopoverButton;
|
|
70
|
+
Popover.Content = PopoverContent;
|
|
71
|
+
Popover.Flow = PopoverFlow;
|
|
72
|
+
Popover.Footer = PopoverFooter;
|
|
73
|
+
Popover.Next = PopoverNext;
|
|
74
|
+
Popover.Trigger = PopoverTrigger;
|
|
75
|
+
|
|
76
|
+
Popover.displayName = "Popover";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import { purpurBreakpointMd } from "@purpurds/tokens";
|
|
3
|
+
|
|
4
|
+
const DEBOUNCE_DELAY = 150;
|
|
5
|
+
|
|
6
|
+
export const SCREEN_MEDIA_QUERY = {
|
|
7
|
+
MAX_MD: `(max-width: ${purpurBreakpointMd})`,
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export const useScreenSize = () => {
|
|
11
|
+
const maxMediumScreen = window.matchMedia(SCREEN_MEDIA_QUERY.MAX_MD);
|
|
12
|
+
function updateSize() {
|
|
13
|
+
setIsMdOrSmaller(maxMediumScreen.matches);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const [isMdOrSmaller, setIsMdOrSmaller] = useState(() => {
|
|
17
|
+
if (typeof window === "undefined") return false;
|
|
18
|
+
return window.matchMedia(SCREEN_MEDIA_QUERY.MAX_MD).matches;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const handleResize = useCallback(() => {
|
|
22
|
+
let timeoutId: number;
|
|
23
|
+
|
|
24
|
+
return () => {
|
|
25
|
+
clearTimeout(timeoutId);
|
|
26
|
+
timeoutId = setTimeout(() => updateSize(), DEBOUNCE_DELAY);
|
|
27
|
+
};
|
|
28
|
+
}, [updateSize]);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (typeof window === "undefined") return;
|
|
32
|
+
|
|
33
|
+
window.addEventListener("resize", handleResize);
|
|
34
|
+
|
|
35
|
+
return () => window.removeEventListener("resize", handleResize);
|
|
36
|
+
}, [handleResize]);
|
|
37
|
+
|
|
38
|
+
return { isMdOrSmaller };
|
|
39
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
export const useSmoothScroll = (onComplete?: () => void) => {
|
|
4
|
+
const scrollAnimationId = useRef<number | null>(null);
|
|
5
|
+
|
|
6
|
+
const cancelScroll = useCallback(() => {
|
|
7
|
+
if (scrollAnimationId.current) {
|
|
8
|
+
cancelAnimationFrame(scrollAnimationId.current);
|
|
9
|
+
scrollAnimationId.current = null;
|
|
10
|
+
}
|
|
11
|
+
}, []);
|
|
12
|
+
|
|
13
|
+
const scrollToElement = useCallback(
|
|
14
|
+
(element: HTMLElement, duration: number = 600) => {
|
|
15
|
+
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
16
|
+
const rect = element.getBoundingClientRect();
|
|
17
|
+
const absoluteTop = rect.top + window.pageYOffset;
|
|
18
|
+
const elementHeight = rect.height;
|
|
19
|
+
const viewportHeight = window.innerHeight;
|
|
20
|
+
|
|
21
|
+
let targetY = absoluteTop - viewportHeight / 2 + elementHeight / 2;
|
|
22
|
+
const documentHeight = document.documentElement.scrollHeight;
|
|
23
|
+
const maxScroll = Math.max(0, documentHeight - viewportHeight);
|
|
24
|
+
targetY = Math.max(0, Math.min(targetY, maxScroll));
|
|
25
|
+
|
|
26
|
+
if (prefersReducedMotion) {
|
|
27
|
+
window.scrollTo(0, targetY);
|
|
28
|
+
onComplete?.();
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const startY = window.pageYOffset;
|
|
33
|
+
const distance = targetY - startY;
|
|
34
|
+
const startTime = performance.now();
|
|
35
|
+
|
|
36
|
+
const animateScroll = (currentTime: number) => {
|
|
37
|
+
const elapsed = currentTime - startTime;
|
|
38
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
39
|
+
|
|
40
|
+
const easeInOut =
|
|
41
|
+
progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2;
|
|
42
|
+
|
|
43
|
+
const currentY = startY + distance * easeInOut;
|
|
44
|
+
window.scrollTo(0, currentY);
|
|
45
|
+
|
|
46
|
+
if (progress < 1) {
|
|
47
|
+
scrollAnimationId.current = requestAnimationFrame(animateScroll);
|
|
48
|
+
} else {
|
|
49
|
+
scrollAnimationId.current = null;
|
|
50
|
+
onComplete?.();
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
cancelScroll();
|
|
55
|
+
|
|
56
|
+
scrollAnimationId.current = requestAnimationFrame(animateScroll);
|
|
57
|
+
},
|
|
58
|
+
[onComplete, cancelScroll]
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return { scrollToElement, cancelScroll };
|
|
62
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
import { useOptionalPopoverFlow } from "./popover-flow";
|
|
4
|
+
import { usePopoverInternal } from "./popover-internal-context";
|
|
5
|
+
import { useSmoothScroll } from "./use-smooth-scroll";
|
|
6
|
+
|
|
7
|
+
export function usePopoverTrigger(ref: React.Ref<HTMLButtonElement>) {
|
|
8
|
+
const triggerRef = useRef<HTMLButtonElement>(null);
|
|
9
|
+
const context = usePopoverInternal();
|
|
10
|
+
const flow = useOptionalPopoverFlow();
|
|
11
|
+
const hasScrolled = useRef(false);
|
|
12
|
+
const { scrollToElement, cancelScroll } = useSmoothScroll(context?.onScrollComplete);
|
|
13
|
+
|
|
14
|
+
// Forward ref
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (typeof ref === "function") {
|
|
17
|
+
ref(triggerRef.current);
|
|
18
|
+
} else if (ref) {
|
|
19
|
+
(ref as React.MutableRefObject<HTMLButtonElement | null>).current = triggerRef.current;
|
|
20
|
+
}
|
|
21
|
+
}, [ref]);
|
|
22
|
+
|
|
23
|
+
// Walkthrough scroll logic
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const isActiveStep =
|
|
26
|
+
flow &&
|
|
27
|
+
context?.walkthroughStep !== undefined &&
|
|
28
|
+
flow.currentStep === context.walkthroughStep &&
|
|
29
|
+
triggerRef.current &&
|
|
30
|
+
!hasScrolled.current;
|
|
31
|
+
|
|
32
|
+
if (isActiveStep) {
|
|
33
|
+
const element = triggerRef.current!;
|
|
34
|
+
const rect = element.getBoundingClientRect();
|
|
35
|
+
const buffer = 100;
|
|
36
|
+
const isVisible =
|
|
37
|
+
rect.top >= -buffer &&
|
|
38
|
+
rect.bottom <= window.innerHeight + buffer &&
|
|
39
|
+
rect.left >= -buffer &&
|
|
40
|
+
rect.right <= window.innerWidth + buffer;
|
|
41
|
+
|
|
42
|
+
if (isVisible) {
|
|
43
|
+
context?.onScrollComplete?.();
|
|
44
|
+
} else {
|
|
45
|
+
context?.onScrollStart?.();
|
|
46
|
+
scrollToElement(element);
|
|
47
|
+
}
|
|
48
|
+
hasScrolled.current = true;
|
|
49
|
+
} else if (flow?.currentStep !== context?.walkthroughStep) {
|
|
50
|
+
hasScrolled.current = false;
|
|
51
|
+
cancelScroll();
|
|
52
|
+
}
|
|
53
|
+
}, [flow?.currentStep, context?.walkthroughStep, flow, scrollToElement, cancelScroll, context]);
|
|
54
|
+
|
|
55
|
+
// Cancel scroll on unmount
|
|
56
|
+
useEffect(() => cancelScroll, [cancelScroll]);
|
|
57
|
+
|
|
58
|
+
return { triggerRef, context };
|
|
59
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { useOptionalPopoverFlow } from "./popover-flow";
|
|
4
|
+
|
|
5
|
+
export function usePopoverWalkthrough(step: number) {
|
|
6
|
+
const flow = useOptionalPopoverFlow();
|
|
7
|
+
const [actuallyOpen, setActuallyOpen] = useState(false);
|
|
8
|
+
const [waitingForScroll, setWaitingForScroll] = useState(false);
|
|
9
|
+
const timeoutRef = useRef<number | null>(null);
|
|
10
|
+
const openedAtRef = useRef<number>(0);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (flow) {
|
|
14
|
+
flow.registerStep(step);
|
|
15
|
+
return () => flow.unregisterStep(step);
|
|
16
|
+
}
|
|
17
|
+
return () => {};
|
|
18
|
+
}, [flow, step]);
|
|
19
|
+
|
|
20
|
+
const shouldBeOpen = flow?.currentStep === step;
|
|
21
|
+
|
|
22
|
+
const onScrollStart = useCallback(() => {
|
|
23
|
+
setWaitingForScroll(true);
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
const onScrollComplete = useCallback(() => {
|
|
27
|
+
setWaitingForScroll(false);
|
|
28
|
+
if (shouldBeOpen) {
|
|
29
|
+
const delay = flow.openDelay;
|
|
30
|
+
timeoutRef.current = window.setTimeout(() => {
|
|
31
|
+
setActuallyOpen(true);
|
|
32
|
+
openedAtRef.current = Date.now();
|
|
33
|
+
}, delay);
|
|
34
|
+
}
|
|
35
|
+
}, [shouldBeOpen, flow]);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (timeoutRef.current) {
|
|
39
|
+
clearTimeout(timeoutRef.current);
|
|
40
|
+
timeoutRef.current = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (shouldBeOpen) {
|
|
44
|
+
if (!waitingForScroll) {
|
|
45
|
+
const delay = flow.openDelay;
|
|
46
|
+
timeoutRef.current = window.setTimeout(() => {
|
|
47
|
+
setActuallyOpen(true);
|
|
48
|
+
openedAtRef.current = Date.now();
|
|
49
|
+
}, delay);
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
setActuallyOpen(false);
|
|
53
|
+
setWaitingForScroll(false);
|
|
54
|
+
openedAtRef.current = 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return () => {
|
|
58
|
+
if (timeoutRef.current) {
|
|
59
|
+
clearTimeout(timeoutRef.current);
|
|
60
|
+
timeoutRef.current = null;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}, [shouldBeOpen, waitingForScroll, flow]);
|
|
64
|
+
|
|
65
|
+
const handleOpenChange = useCallback(
|
|
66
|
+
(open: boolean, consumerOnOpenChange?: (open: boolean) => void) => {
|
|
67
|
+
consumerOnOpenChange?.(open);
|
|
68
|
+
if (!open && actuallyOpen && flow && flow.currentStep === step) {
|
|
69
|
+
const timeSinceOpen = Date.now() - openedAtRef.current;
|
|
70
|
+
if (timeSinceOpen < 10 || timeoutRef.current !== null) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
flow.dismiss();
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
[actuallyOpen, flow, step]
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
actuallyOpen,
|
|
81
|
+
onScrollStart,
|
|
82
|
+
onScrollComplete,
|
|
83
|
+
handleOpenChange,
|
|
84
|
+
};
|
|
85
|
+
}
|
package/vitest.setup.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as matchers from "@testing-library/jest-dom/matchers";
|
|
2
|
+
import { expect, vi } from "vitest";
|
|
3
|
+
import * as axeMatchers from "vitest-axe/matchers";
|
|
4
|
+
|
|
5
|
+
import "vitest-canvas-mock";
|
|
6
|
+
import "vitest-axe/extend-expect";
|
|
7
|
+
|
|
8
|
+
expect.extend(matchers);
|
|
9
|
+
expect.extend(axeMatchers);
|
|
10
|
+
|
|
11
|
+
const matchMedia = vi.fn(function (query) {
|
|
12
|
+
return {
|
|
13
|
+
matches: true,
|
|
14
|
+
media: query,
|
|
15
|
+
onchange: null,
|
|
16
|
+
addEventListener: vi.fn(),
|
|
17
|
+
removeEventListener: vi.fn(),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
vi.stubGlobal("matchMedia", matchMedia);
|
|
22
|
+
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
const ResizeObserverMock = vi.fn(function (this: any) {
|
|
25
|
+
this.disconnect = vi.fn();
|
|
26
|
+
this.observe = vi.fn();
|
|
27
|
+
this.takeRecords = vi.fn();
|
|
28
|
+
this.unobserve = vi.fn();
|
|
29
|
+
});
|
|
30
|
+
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
|