@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,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
+ }
@@ -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);