@reactberry/system 2.0.0-beta
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/README.md +48 -0
- package/package.json +74 -0
- package/src/blocks/Accordion/index.tsx +158 -0
- package/src/blocks/AnimatedCarousel/index.tsx +188 -0
- package/src/blocks/AppleGlow/index.tsx +144 -0
- package/src/blocks/Avatar/index.tsx +167 -0
- package/src/blocks/Await/index.tsx +45 -0
- package/src/blocks/Cards/AnimatedCard/index.tsx +175 -0
- package/src/blocks/Cards/FluorescentCard/index.tsx +180 -0
- package/src/blocks/Cards/InfoCard/index.tsx +206 -0
- package/src/blocks/Cards/TickerCard/index.tsx +125 -0
- package/src/blocks/Carousel/index.tsx +216 -0
- package/src/blocks/Checkbox/index.tsx +101 -0
- package/src/blocks/Collection/index.tsx +59 -0
- package/src/blocks/Container/index.tsx +55 -0
- package/src/blocks/Controls/Control.tsx +67 -0
- package/src/blocks/Controls/index.tsx +11 -0
- package/src/blocks/CyclingNumber/index.tsx +78 -0
- package/src/blocks/DisplaySet/index.tsx +42 -0
- package/src/blocks/Divider/index.tsx +14 -0
- package/src/blocks/Draggable/index.tsx +266 -0
- package/src/blocks/Drawer/index.tsx +136 -0
- package/src/blocks/DynamicIsland/DynamicIsland.tsx +89 -0
- package/src/blocks/DynamicIsland/index.tsx +2 -0
- package/src/blocks/Fader/index.tsx +145 -0
- package/src/blocks/FamilyDrawer/README.md +116 -0
- package/src/blocks/FamilyDrawer/example.tsx +108 -0
- package/src/blocks/FamilyDrawer/index.tsx +119 -0
- package/src/blocks/FamilyDrawer/views/DefaultView.tsx +93 -0
- package/src/blocks/FamilyDrawer/views/KeyView.tsx +129 -0
- package/src/blocks/FamilyDrawer/views/PhraseView.tsx +129 -0
- package/src/blocks/FamilyDrawer/views/RemoveView.tsx +81 -0
- package/src/blocks/FieldSet/index.tsx +173 -0
- package/src/blocks/Filesystem/index.tsx +198 -0
- package/src/blocks/Gallery/Carousel/index.tsx +257 -0
- package/src/blocks/Gallery/Modal/index.tsx +83 -0
- package/src/blocks/Gallery/index.tsx +57 -0
- package/src/blocks/Gallery/utils/animationVariants.ts +18 -0
- package/src/blocks/Gallery/utils/aspectRatio.ts +14 -0
- package/src/blocks/Gallery/utils/downloadPhoto.ts +24 -0
- package/src/blocks/Gallery/utils/range.ts +11 -0
- package/src/blocks/GradientMesh/index.tsx +106 -0
- package/src/blocks/Group/index.tsx +152 -0
- package/src/blocks/Heading/index.tsx +111 -0
- package/src/blocks/HorizontalScroller/index.tsx +135 -0
- package/src/blocks/Icon/index.tsx +45 -0
- package/src/blocks/Indicator/index.tsx +27 -0
- package/src/blocks/InlineEditor/index.tsx +216 -0
- package/src/blocks/List/index.tsx +657 -0
- package/src/blocks/Main/index.tsx +17 -0
- package/src/blocks/Marquee/index.tsx +116 -0
- package/src/blocks/MaskedField/index.tsx +199 -0
- package/src/blocks/Menu/MenuContent.tsx +246 -0
- package/src/blocks/Menu/MenuContext.tsx +34 -0
- package/src/blocks/Menu/MenuItem.tsx +104 -0
- package/src/blocks/Menu/index.tsx +60 -0
- package/src/blocks/Modal/index.tsx +268 -0
- package/src/blocks/MorphingPopover/index.tsx +294 -0
- package/src/blocks/Overlay/Backdrop.tsx +48 -0
- package/src/blocks/Overlay/OverscrollGuard.tsx +36 -0
- package/src/blocks/Overlay/index.ts +2 -0
- package/src/blocks/Parallax/index.tsx +117 -0
- package/src/blocks/ParallaxSection/index.tsx +61 -0
- package/src/blocks/Placeholder/index.tsx +48 -0
- package/src/blocks/Popover/index.tsx +402 -0
- package/src/blocks/Progress/getProgressColor.ts +61 -0
- package/src/blocks/Progress/index.tsx +179 -0
- package/src/blocks/ProgressiveBlur/index.tsx +75 -0
- package/src/blocks/README.md +15 -0
- package/src/blocks/RenderAsset/index.tsx +18 -0
- package/src/blocks/ScrollContainer/index.tsx +93 -0
- package/src/blocks/ShinyText/index.tsx +72 -0
- package/src/blocks/Skeleton/index.tsx +71 -0
- package/src/blocks/Slider/SliderControls.tsx +119 -0
- package/src/blocks/Slider/index.tsx +140 -0
- package/src/blocks/Slider/useSlider.ts +126 -0
- package/src/blocks/Slideshow/index.tsx +177 -0
- package/src/blocks/Spotlight/index.tsx +144 -0
- package/src/blocks/Steps/StepIndicator.tsx +149 -0
- package/src/blocks/Steps/StepProgress.tsx +164 -0
- package/src/blocks/Steps/Steps.tsx +197 -0
- package/src/blocks/Steps/StepsNav.tsx +30 -0
- package/src/blocks/Steps/StepsTracker.tsx +80 -0
- package/src/blocks/Steps/hooks.ts +71 -0
- package/src/blocks/Steps/index.tsx +16 -0
- package/src/blocks/Steps/types.ts +71 -0
- package/src/blocks/StickySectionStack/index.tsx +136 -0
- package/src/blocks/Switch/index.tsx +85 -0
- package/src/blocks/SystemNotice/index.tsx +81 -0
- package/src/blocks/Table/README.md +251 -0
- package/src/blocks/Table/Table.tsx +207 -0
- package/src/blocks/Table/TablePagination.tsx +189 -0
- package/src/blocks/Table/index.ts +33 -0
- package/src/blocks/Table/useTableControls.ts +331 -0
- package/src/blocks/Tag/index.tsx +27 -0
- package/src/blocks/TextBreak/index.tsx +96 -0
- package/src/blocks/TextReveal/index.tsx +104 -0
- package/src/blocks/Thumbnail/index.tsx +26 -0
- package/src/blocks/Ticker/index.tsx +112 -0
- package/src/blocks/Toast/index.tsx +77 -0
- package/src/blocks/Tooltip/index.tsx +174 -0
- package/src/blocks/Underlay/index.tsx +104 -0
- package/src/blocks/Upload/Dropzone.tsx +92 -0
- package/src/blocks/Upload/UploadBtn.tsx +38 -0
- package/src/blocks/Upload/index.tsx +61 -0
- package/src/blocks/Upload/types.ts +37 -0
- package/src/blocks/VideoMarquee/index.tsx +511 -0
- package/src/blocks/index.ts +119 -0
- package/src/blocks/pagination/Pagination.tsx +148 -0
- package/src/blocks/pagination/PaginationList.tsx +41 -0
- package/src/blocks/pagination/index.ts +2 -0
- package/src/charts/BarChart.tsx +63 -0
- package/src/charts/PieChart.tsx +39 -0
- package/src/charts/index.ts +3 -0
- package/src/charts/utils.ts +103 -0
- package/src/docs/README.md +373 -0
- package/src/docs/reference/README.md +299 -0
- package/src/elements/box.ts +163 -0
- package/src/elements/button.ts +49 -0
- package/src/elements/field.ts +129 -0
- package/src/elements/index.ts +8 -0
- package/src/elements/text.ts +47 -0
- package/src/elements/utils.js +97 -0
- package/src/hooks/use-copy-to-clipboard.tsx +33 -0
- package/src/hooks/use-enter-submit.tsx +23 -0
- package/src/hooks/use-local-storage.ts +42 -0
- package/src/hooks/use-sidebar.tsx +109 -0
- package/src/hooks/useAnimatedText.ts +32 -0
- package/src/hooks/useAutosizeTextArea.ts +45 -0
- package/src/hooks/useBreakpoint.tsx +123 -0
- package/src/hooks/useClickOutside.tsx +38 -0
- package/src/hooks/useHover.tsx +33 -0
- package/src/hooks/useHoverList.tsx +17 -0
- package/src/hooks/useKeyboardShortcuts.ts +91 -0
- package/src/hooks/useKeypress.ts +27 -0
- package/src/hooks/useOverlay.ts +32 -0
- package/src/hooks/useReducedMotion.ts +25 -0
- package/src/hooks/useStandaloneMode.ts +35 -0
- package/src/hooks/useTouchDevice.ts +34 -0
- package/src/icons/index.tsx +129 -0
- package/src/index.ts +12 -0
- package/src/providers/DesignSystemProvider.tsx +35 -0
- package/src/providers/StyledComponentsRegistry.tsx +30 -0
- package/src/providers/index.ts +2 -0
- package/src/themes/README.md +30 -0
- package/src/themes/default/assets/badge-avatar.tsx +45 -0
- package/src/themes/default/assets/logo.tsx +42 -0
- package/src/themes/default/global.ts +138 -0
- package/src/themes/default/modes/dark/config.js +49 -0
- package/src/themes/default/modes/dark/skins.js +631 -0
- package/src/themes/default/modes/dark/theme.js +87 -0
- package/src/themes/default/modes/light/config.js +48 -0
- package/src/themes/default/modes/light/skins.js +1026 -0
- package/src/themes/default/modes/light/theme.js +74 -0
- package/src/themes/default/tokens/controls.js +53 -0
- package/src/themes/default/tokens/shadows.js +63 -0
- package/src/themes/default/tokens/shapes.js +37 -0
- package/src/themes/default/tokens/space.js +143 -0
- package/src/themes/default/tokens/spectre.js +16 -0
- package/src/themes/default/utils.js +523 -0
- package/src/themes/index.ts +11 -0
- package/src/types.ts +394 -0
- package/src/utils/overlayTheme.ts +61 -0
- package/src/utils/pickColor.ts +15 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Box } from "@/design-system/elements";
|
|
4
|
+
import { MenuProvider, useMenu } from "./MenuContext";
|
|
5
|
+
import { MenuItem } from "./MenuItem";
|
|
6
|
+
import { MenuContent } from "./MenuContent";
|
|
7
|
+
import { AnimatePresence } from "motion/react";
|
|
8
|
+
|
|
9
|
+
interface MenuProps {
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Add an interface for the child props
|
|
14
|
+
interface MenuChildProps {
|
|
15
|
+
id: string | number;
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MenuInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
20
|
+
const { setSelected, selected, setDir } = useMenu();
|
|
21
|
+
|
|
22
|
+
const selectedContent = React.Children.toArray(children).find(
|
|
23
|
+
(child) =>
|
|
24
|
+
React.isValidElement(child) &&
|
|
25
|
+
(child.props as MenuChildProps).id === selected,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Box display="flex" alignItems="center" justifyContent="center">
|
|
30
|
+
<Box
|
|
31
|
+
position="relative"
|
|
32
|
+
display="flex"
|
|
33
|
+
alignItems="center"
|
|
34
|
+
onMouseLeave={() => {
|
|
35
|
+
setDir(null);
|
|
36
|
+
setSelected(null);
|
|
37
|
+
}}
|
|
38
|
+
zIndex={9999}
|
|
39
|
+
>
|
|
40
|
+
{children}
|
|
41
|
+
|
|
42
|
+
<AnimatePresence mode="sync">
|
|
43
|
+
{selected !== null &&
|
|
44
|
+
React.isValidElement(selectedContent) &&
|
|
45
|
+
(selectedContent.props as MenuChildProps).children}
|
|
46
|
+
</AnimatePresence>
|
|
47
|
+
</Box>
|
|
48
|
+
</Box>
|
|
49
|
+
);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const Menu: React.FC<MenuProps> = ({ children }) => {
|
|
53
|
+
return (
|
|
54
|
+
<MenuProvider>
|
|
55
|
+
<MenuInner>{children}</MenuInner>
|
|
56
|
+
</MenuProvider>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export { MenuItem, MenuContent };
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Enhanced Modal component
|
|
5
|
+
*
|
|
6
|
+
* Improvements:
|
|
7
|
+
* - Adds robust fallback navigation logic when closing (overlay click or programmatic).
|
|
8
|
+
* - Supports an optional `fallbackHref` prop for deterministic navigation when history is shallow.
|
|
9
|
+
* - Keeps support for user-supplied `onClose` (which always takes precedence).
|
|
10
|
+
* - Derives a parent path for common resource detail routes (/tasks/[id], /activity/[id]) if no fallback supplied.
|
|
11
|
+
* - Adds `closeOnOverlayClick` prop to allow disabling close when clicking the backdrop overlay.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* <Modal onClose={navigateBack} fallbackHref="/tasks" closeOnOverlayClick={false}>
|
|
15
|
+
* ...
|
|
16
|
+
* </Modal>
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
20
|
+
import { Box } from "@/design-system/elements";
|
|
21
|
+
import { useKeypress } from "@/design-system/hooks/useKeypress";
|
|
22
|
+
import { useOverlay } from "@/design-system/hooks/useOverlay";
|
|
23
|
+
import { Backdrop } from "@/design-system/blocks/Overlay";
|
|
24
|
+
import { usePathname, useRouter } from "next/navigation";
|
|
25
|
+
import {
|
|
26
|
+
createContext,
|
|
27
|
+
useCallback,
|
|
28
|
+
useContext,
|
|
29
|
+
useEffect,
|
|
30
|
+
useRef,
|
|
31
|
+
useState,
|
|
32
|
+
type ReactNode,
|
|
33
|
+
} from "react";
|
|
34
|
+
import { createPortal } from "react-dom";
|
|
35
|
+
|
|
36
|
+
// Re-export for consumers that import these directly from Modal
|
|
37
|
+
export { pushThemeColor, popThemeColor } from "@/design-system/utils/overlayTheme";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Context that exposes the Modal's close function to children.
|
|
41
|
+
* When `animated` is true, this triggers the exit animation before unmounting.
|
|
42
|
+
* When `animated` is false, this calls the close handler directly.
|
|
43
|
+
*/
|
|
44
|
+
const ModalCloseContext = createContext<(() => void) | null>(null);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Hook to access the parent Modal's close function.
|
|
48
|
+
* Returns null if used outside a Modal.
|
|
49
|
+
*/
|
|
50
|
+
export function useModalClose() {
|
|
51
|
+
return useContext(ModalCloseContext);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ModalProps {
|
|
55
|
+
children: ReactNode;
|
|
56
|
+
onClose?: () => void;
|
|
57
|
+
portal?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Explicit fallback route used when there is no browser history to return to.
|
|
60
|
+
* Example: "/tasks"
|
|
61
|
+
*/
|
|
62
|
+
fallbackHref?: string;
|
|
63
|
+
/**
|
|
64
|
+
* (Optional) Disable using browser history even if available and always use fallback / derived path.
|
|
65
|
+
*/
|
|
66
|
+
forceFallback?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Whether clicking the backdrop overlay should close the modal.
|
|
69
|
+
* Defaults to true for current behavior. Set to false to prevent overlay clicks from closing.
|
|
70
|
+
*/
|
|
71
|
+
closeOnOverlayClick?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Whether to show the tinted backdrop overlay behind the modal.
|
|
74
|
+
* Defaults to true. Set to false for a transparent backdrop.
|
|
75
|
+
*/
|
|
76
|
+
backdrop?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Whether to animate the modal open/close with a scale + fade
|
|
79
|
+
* animation matching the sign-in card style. Defaults to false.
|
|
80
|
+
*/
|
|
81
|
+
animated?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* Additional style/semantic props forwarded to container.
|
|
84
|
+
*/
|
|
85
|
+
[key: string]: any;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default function Modal({
|
|
89
|
+
children,
|
|
90
|
+
onClose,
|
|
91
|
+
portal = true,
|
|
92
|
+
fallbackHref,
|
|
93
|
+
forceFallback = false,
|
|
94
|
+
closeOnOverlayClick = true,
|
|
95
|
+
backdrop = true,
|
|
96
|
+
animated = false,
|
|
97
|
+
...rest
|
|
98
|
+
}: ModalProps) {
|
|
99
|
+
const router = useRouter();
|
|
100
|
+
const pathname = usePathname();
|
|
101
|
+
const { mounted, isStandalone } = useOverlay(true);
|
|
102
|
+
|
|
103
|
+
// Track if we (likely) have a prior history entry.
|
|
104
|
+
// NOTE: history.length heuristic is imperfect but acceptable for typical SPA usage.
|
|
105
|
+
const canGoBackRef = useRef(false);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (typeof window !== "undefined") {
|
|
109
|
+
canGoBackRef.current = window.history.length > 1;
|
|
110
|
+
}
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Derive a reasonable parent path if no explicit fallback was provided.
|
|
115
|
+
* Extend this logic as new modalized resource routes are introduced.
|
|
116
|
+
*/
|
|
117
|
+
const deriveParentPath = useCallback((p: string): string => {
|
|
118
|
+
if (!p) return "/";
|
|
119
|
+
const patterns: Array<{ regex: RegExp; parent: string }> = [
|
|
120
|
+
{ regex: /\/tasks\/[^/]+$/, parent: "/tasks" },
|
|
121
|
+
{ regex: /\/activity\/[^/]+$/, parent: "/activity" },
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
for (const { regex, parent } of patterns) {
|
|
125
|
+
if (regex.test(p)) return parent;
|
|
126
|
+
}
|
|
127
|
+
return "/"; // Default root
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
const resolvedFallback =
|
|
131
|
+
fallbackHref || (pathname ? deriveParentPath(pathname) : "/") || "/";
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Close handler:
|
|
135
|
+
* 1. If user supplied onClose -> call it.
|
|
136
|
+
* 2. Else if not forcing fallback and history seems viable -> router.back().
|
|
137
|
+
* 3. Else -> router.push(resolvedFallback).
|
|
138
|
+
*/
|
|
139
|
+
const handleClose = useCallback(() => {
|
|
140
|
+
if (onClose) {
|
|
141
|
+
onClose();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!forceFallback && canGoBackRef.current) {
|
|
146
|
+
router.back();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
router.push(resolvedFallback);
|
|
151
|
+
}, [onClose, forceFallback, router, resolvedFallback]);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Stop propagation inside main dialog so clicks there don't trigger overlay close.
|
|
155
|
+
*/
|
|
156
|
+
const stopPropagation = useCallback(
|
|
157
|
+
(e: React.MouseEvent) => e.stopPropagation(),
|
|
158
|
+
[]
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// For animated modals, manage a closing state to allow exit animations
|
|
162
|
+
// before the portal is removed.
|
|
163
|
+
const [isClosing, setIsClosing] = useState(false);
|
|
164
|
+
|
|
165
|
+
const handleAnimatedClose = useCallback(() => {
|
|
166
|
+
setIsClosing(true);
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
const onExitComplete = useCallback(() => {
|
|
170
|
+
setIsClosing(false);
|
|
171
|
+
handleClose();
|
|
172
|
+
}, [handleClose]);
|
|
173
|
+
|
|
174
|
+
const effectiveClose = animated ? handleAnimatedClose : handleClose;
|
|
175
|
+
const showContent = animated ? !isClosing : true;
|
|
176
|
+
|
|
177
|
+
// Close on Escape key. Uses effectiveClose so animated modals play exit animation.
|
|
178
|
+
useKeypress("Escape", effectiveClose);
|
|
179
|
+
|
|
180
|
+
const animatedTransition = {
|
|
181
|
+
type: "spring" as const,
|
|
182
|
+
stiffness: 500,
|
|
183
|
+
damping: 30,
|
|
184
|
+
mass: 0.8,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const modalContent = (
|
|
188
|
+
<Box
|
|
189
|
+
position="fixed"
|
|
190
|
+
top="0"
|
|
191
|
+
left="0"
|
|
192
|
+
width="100%"
|
|
193
|
+
height="100%"
|
|
194
|
+
display={"flex"}
|
|
195
|
+
justifyContent={"center"}
|
|
196
|
+
alignItems={"center"}
|
|
197
|
+
zIndex="100002"
|
|
198
|
+
pb="m"
|
|
199
|
+
px="xs"
|
|
200
|
+
style={{
|
|
201
|
+
paddingTop: isStandalone
|
|
202
|
+
? "calc(1rem + env(safe-area-inset-top, 0px))"
|
|
203
|
+
: "1rem",
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
<AnimatePresence onExitComplete={animated ? onExitComplete : undefined}>
|
|
207
|
+
{showContent && (
|
|
208
|
+
<>
|
|
209
|
+
{/* Dialog Panel */}
|
|
210
|
+
<Box
|
|
211
|
+
as={animated ? motion.div : undefined}
|
|
212
|
+
key="modal-dialog"
|
|
213
|
+
{...(animated
|
|
214
|
+
? {
|
|
215
|
+
initial: { opacity: 0, y: "2rem", scale: 0.85 },
|
|
216
|
+
animate: { opacity: 1, y: 0, scale: 1 },
|
|
217
|
+
exit: { opacity: 0, y: "2rem", scale: 0.85 },
|
|
218
|
+
transition: animatedTransition,
|
|
219
|
+
}
|
|
220
|
+
: {})}
|
|
221
|
+
role="dialog"
|
|
222
|
+
aria-modal="true"
|
|
223
|
+
position="relative"
|
|
224
|
+
maxWidth="inherit"
|
|
225
|
+
width="100%"
|
|
226
|
+
zIndex="100001"
|
|
227
|
+
shape="rounded"
|
|
228
|
+
$shadow="medium"
|
|
229
|
+
overflow="hidden"
|
|
230
|
+
height="100%"
|
|
231
|
+
skin="surface"
|
|
232
|
+
onClick={stopPropagation}
|
|
233
|
+
{...rest}
|
|
234
|
+
>
|
|
235
|
+
<Box
|
|
236
|
+
height="100%"
|
|
237
|
+
width="100%"
|
|
238
|
+
overflow="hidden"
|
|
239
|
+
display="flex"
|
|
240
|
+
flexDirection="column"
|
|
241
|
+
>
|
|
242
|
+
<ModalCloseContext.Provider value={effectiveClose}>
|
|
243
|
+
{children}
|
|
244
|
+
</ModalCloseContext.Provider>
|
|
245
|
+
</Box>
|
|
246
|
+
</Box>
|
|
247
|
+
|
|
248
|
+
{/* Clickable overlay (behind dialog) */}
|
|
249
|
+
<Backdrop
|
|
250
|
+
key="modal-overlay"
|
|
251
|
+
animated={animated}
|
|
252
|
+
transition={animated ? animatedTransition : { duration: 0.2 }}
|
|
253
|
+
bg={backdrop ? "transparent.light.9" : "transparent"}
|
|
254
|
+
onClick={
|
|
255
|
+
closeOnOverlayClick ? effectiveClose : undefined
|
|
256
|
+
}
|
|
257
|
+
/>
|
|
258
|
+
</>
|
|
259
|
+
)}
|
|
260
|
+
</AnimatePresence>
|
|
261
|
+
</Box>
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// Avoid rendering portal content during SSR (Next.js) until mounted.
|
|
265
|
+
if (!mounted) return null;
|
|
266
|
+
|
|
267
|
+
return portal ? createPortal(modalContent, document.body) : modalContent;
|
|
268
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { Box } from "@/design-system/elements";
|
|
5
|
+
import { createPortal } from "react-dom";
|
|
6
|
+
import { useClickOutside } from "@/design-system/hooks/useClickOutside";
|
|
7
|
+
import {
|
|
8
|
+
AnimatePresence,
|
|
9
|
+
MotionConfig,
|
|
10
|
+
motion,
|
|
11
|
+
type Transition,
|
|
12
|
+
type Variants,
|
|
13
|
+
} from "motion/react";
|
|
14
|
+
import Underlay from "../Underlay";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TRANSITION: Transition = {
|
|
17
|
+
type: "spring",
|
|
18
|
+
bounce: 0.1,
|
|
19
|
+
duration: 0.4,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const DEFAULT_VARIANTS: Variants = {
|
|
23
|
+
initial: { opacity: 0, borderRadius: 9999 },
|
|
24
|
+
animate: { opacity: 1, borderRadius: 16 },
|
|
25
|
+
exit: { opacity: 0, borderRadius: 9999 },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type MorphingPopoverRenderTriggerProps = {
|
|
29
|
+
ref: (node: HTMLElement | null) => void;
|
|
30
|
+
onClick?: () => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type MorphingPopoverProps = {
|
|
34
|
+
/** Trigger element shown when the popover is closed. */
|
|
35
|
+
trigger?: React.ReactNode;
|
|
36
|
+
/** Props forwarded to the trigger wrapper when not using renderTrigger. */
|
|
37
|
+
triggerProps?: Record<string, any>;
|
|
38
|
+
/** Optional placement hint (kept for API compatibility; currently ignored). */
|
|
39
|
+
placement?: string;
|
|
40
|
+
/** Props forwarded to the morphing panel container. */
|
|
41
|
+
panelProps?: Record<string, any>;
|
|
42
|
+
/** Props forwarded to the outer container Box. */
|
|
43
|
+
containerProps?: Record<string, any>;
|
|
44
|
+
/**
|
|
45
|
+
* Custom trigger renderer for full control over the trigger element.
|
|
46
|
+
* Signature mirrors the standard Popover API.
|
|
47
|
+
*/
|
|
48
|
+
renderTrigger?: (props: MorphingPopoverRenderTriggerProps) => React.ReactNode;
|
|
49
|
+
/**
|
|
50
|
+
* Popover content. If a function, receives a close() helper.
|
|
51
|
+
*/
|
|
52
|
+
children?:
|
|
53
|
+
| React.ReactNode
|
|
54
|
+
| ((context: { close: () => void }) => React.ReactNode);
|
|
55
|
+
/** Motion transition applied via MotionConfig. */
|
|
56
|
+
transition?: Transition;
|
|
57
|
+
/** Framer Motion variants for the morphing panel. */
|
|
58
|
+
variants?: Variants;
|
|
59
|
+
/** Uncontrolled initial open state. */
|
|
60
|
+
defaultOpen?: boolean;
|
|
61
|
+
/** Controlled open state. */
|
|
62
|
+
open?: boolean;
|
|
63
|
+
/** Callback when open state changes. */
|
|
64
|
+
onOpenChange?: (open: boolean) => void;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export default function MorphingPopover({
|
|
68
|
+
trigger,
|
|
69
|
+
triggerProps,
|
|
70
|
+
placement: _placement = "bottom end",
|
|
71
|
+
panelProps,
|
|
72
|
+
containerProps,
|
|
73
|
+
renderTrigger,
|
|
74
|
+
children,
|
|
75
|
+
transition = DEFAULT_TRANSITION,
|
|
76
|
+
variants = DEFAULT_VARIANTS,
|
|
77
|
+
defaultOpen = false,
|
|
78
|
+
open: controlledOpen,
|
|
79
|
+
onOpenChange,
|
|
80
|
+
}: MorphingPopoverProps) {
|
|
81
|
+
const uniqueId = useId();
|
|
82
|
+
const layoutId = `morphing-popover-${uniqueId}`;
|
|
83
|
+
|
|
84
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
85
|
+
const isControlled = controlledOpen !== undefined;
|
|
86
|
+
const isOpen = isControlled ? !!controlledOpen : uncontrolledOpen;
|
|
87
|
+
|
|
88
|
+
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
89
|
+
|
|
90
|
+
const setOpen = useCallback(
|
|
91
|
+
(next: boolean) => {
|
|
92
|
+
if (!isControlled) {
|
|
93
|
+
setUncontrolledOpen(next);
|
|
94
|
+
}
|
|
95
|
+
onOpenChange?.(next);
|
|
96
|
+
},
|
|
97
|
+
[isControlled, onOpenChange]
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
const handleOpen = useCallback(() => {
|
|
101
|
+
setOpen(true);
|
|
102
|
+
}, [setOpen]);
|
|
103
|
+
|
|
104
|
+
const handleClose = useCallback(() => {
|
|
105
|
+
setOpen(false);
|
|
106
|
+
}, [setOpen]);
|
|
107
|
+
|
|
108
|
+
// Close on click outside of the panel when open.
|
|
109
|
+
useClickOutside(panelRef, () => handleClose(), isOpen);
|
|
110
|
+
|
|
111
|
+
// Close on Escape key.
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!isOpen) return;
|
|
114
|
+
|
|
115
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
116
|
+
if (event.key === "Escape") {
|
|
117
|
+
handleClose();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
122
|
+
return () => {
|
|
123
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
124
|
+
};
|
|
125
|
+
}, [isOpen, handleClose]);
|
|
126
|
+
|
|
127
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
128
|
+
|
|
129
|
+
const handleTriggerRef = useCallback((node: HTMLElement | null) => {
|
|
130
|
+
triggerRef.current = node;
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
const renderTriggerNode = () => {
|
|
134
|
+
// If a custom trigger renderer is provided, mirror the Popover API
|
|
135
|
+
// and let the returned element control opening via the provided onClick.
|
|
136
|
+
if (renderTrigger) {
|
|
137
|
+
const rendered = renderTrigger({
|
|
138
|
+
ref: handleTriggerRef,
|
|
139
|
+
onClick: handleOpen,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (!rendered) return null;
|
|
143
|
+
|
|
144
|
+
if (React.isValidElement(rendered)) {
|
|
145
|
+
const MotionComponent = motion.create(
|
|
146
|
+
rendered.type as React.ComponentType<any>
|
|
147
|
+
);
|
|
148
|
+
const childProps = rendered.props as Record<string, any>;
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<MotionComponent
|
|
152
|
+
{...childProps}
|
|
153
|
+
layoutId={layoutId}
|
|
154
|
+
onClick={(event: React.MouseEvent<any>) => {
|
|
155
|
+
if (typeof childProps.onClick === "function") {
|
|
156
|
+
childProps.onClick(event);
|
|
157
|
+
}
|
|
158
|
+
// Do not call handleOpen here; we expect the child's onClick
|
|
159
|
+
// (wired via props.onClick) to manage open/close so we avoid
|
|
160
|
+
// double-calling it.
|
|
161
|
+
}}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Fallback: wrap non-element custom triggers.
|
|
167
|
+
return (
|
|
168
|
+
<Box
|
|
169
|
+
as={motion.div}
|
|
170
|
+
layoutId={layoutId}
|
|
171
|
+
onClick={handleOpen}
|
|
172
|
+
{...triggerProps}
|
|
173
|
+
>
|
|
174
|
+
{rendered}
|
|
175
|
+
</Box>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Default trigger path: use the provided trigger node and augment its
|
|
180
|
+
// onClick to also open the popover.
|
|
181
|
+
const rendered = trigger;
|
|
182
|
+
|
|
183
|
+
if (!rendered) return null;
|
|
184
|
+
|
|
185
|
+
if (React.isValidElement(rendered)) {
|
|
186
|
+
const MotionComponent = motion.create(
|
|
187
|
+
rendered.type as React.ComponentType<any>
|
|
188
|
+
);
|
|
189
|
+
const childProps = rendered.props as Record<string, any>;
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<MotionComponent
|
|
193
|
+
{...childProps}
|
|
194
|
+
layoutId={layoutId}
|
|
195
|
+
onClick={(event: React.MouseEvent<any>) => {
|
|
196
|
+
if (typeof childProps.onClick === "function") {
|
|
197
|
+
childProps.onClick(event);
|
|
198
|
+
}
|
|
199
|
+
if (!event.defaultPrevented) {
|
|
200
|
+
handleOpen();
|
|
201
|
+
}
|
|
202
|
+
}}
|
|
203
|
+
/>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Fallback: wrap non-element triggers.
|
|
208
|
+
return (
|
|
209
|
+
<Box
|
|
210
|
+
as={motion.div}
|
|
211
|
+
layoutId={layoutId}
|
|
212
|
+
onClick={handleOpen}
|
|
213
|
+
{...triggerProps}
|
|
214
|
+
>
|
|
215
|
+
{rendered}
|
|
216
|
+
</Box>
|
|
217
|
+
);
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const { style: panelStyleFromProps, ...restPanelProps } = panelProps ?? {};
|
|
221
|
+
|
|
222
|
+
const content =
|
|
223
|
+
typeof children === "function"
|
|
224
|
+
? children({ close: handleClose })
|
|
225
|
+
: children;
|
|
226
|
+
|
|
227
|
+
const overlay = (
|
|
228
|
+
<AnimatePresence>
|
|
229
|
+
{isOpen && (
|
|
230
|
+
<Box
|
|
231
|
+
as={motion.div}
|
|
232
|
+
key={`${layoutId}-overlay`}
|
|
233
|
+
position="fixed"
|
|
234
|
+
top={0}
|
|
235
|
+
left={0}
|
|
236
|
+
right={0}
|
|
237
|
+
bottom={0}
|
|
238
|
+
zIndex="999999"
|
|
239
|
+
display="flex"
|
|
240
|
+
alignItems="center"
|
|
241
|
+
justifyContent="center"
|
|
242
|
+
>
|
|
243
|
+
<Box
|
|
244
|
+
as={motion.div}
|
|
245
|
+
ref={panelRef}
|
|
246
|
+
key={layoutId}
|
|
247
|
+
layoutId={layoutId}
|
|
248
|
+
role="dialog"
|
|
249
|
+
aria-modal="true"
|
|
250
|
+
id={`morphing-popover-panel-${uniqueId}`}
|
|
251
|
+
shape="rounded"
|
|
252
|
+
$shadow="medium"
|
|
253
|
+
skin="surface"
|
|
254
|
+
p="xsmall"
|
|
255
|
+
style={panelStyleFromProps || {}}
|
|
256
|
+
initial="initial"
|
|
257
|
+
animate="animate"
|
|
258
|
+
exit="exit"
|
|
259
|
+
position={"relative"}
|
|
260
|
+
zIndex={"1"}
|
|
261
|
+
variants={variants}
|
|
262
|
+
{...restPanelProps}
|
|
263
|
+
>
|
|
264
|
+
{content}
|
|
265
|
+
</Box>
|
|
266
|
+
<Underlay
|
|
267
|
+
as={motion.div}
|
|
268
|
+
width="100dvw"
|
|
269
|
+
height="100dvh"
|
|
270
|
+
color="transparent"
|
|
271
|
+
top="0"
|
|
272
|
+
left="0"
|
|
273
|
+
position="fixed"
|
|
274
|
+
zIndex="-1"
|
|
275
|
+
initial="initial"
|
|
276
|
+
animate="animate"
|
|
277
|
+
exit="exit"
|
|
278
|
+
/>
|
|
279
|
+
</Box>
|
|
280
|
+
)}
|
|
281
|
+
</AnimatePresence>
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<MotionConfig transition={transition}>
|
|
286
|
+
<Box position="relative" display="inline-flex" {...containerProps}>
|
|
287
|
+
{renderTriggerNode()}
|
|
288
|
+
{typeof window === "undefined"
|
|
289
|
+
? overlay
|
|
290
|
+
: createPortal(overlay, document.body)}
|
|
291
|
+
</Box>
|
|
292
|
+
</MotionConfig>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion } from "motion/react";
|
|
4
|
+
import { Box } from "@/design-system/elements";
|
|
5
|
+
|
|
6
|
+
interface BackdropProps {
|
|
7
|
+
onClick?: () => void;
|
|
8
|
+
bg?: string;
|
|
9
|
+
zIndex?: number | string;
|
|
10
|
+
/** When true, the backdrop fades in from opacity 0. Otherwise it appears instantly. */
|
|
11
|
+
animated?: boolean;
|
|
12
|
+
/** Custom motion transition. Falls back to a simple 200ms fade. */
|
|
13
|
+
transition?: object;
|
|
14
|
+
[key: string]: any;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Animated full-screen backdrop overlay used by Modal, Drawer, etc.
|
|
19
|
+
* Must be rendered inside an `<AnimatePresence>` for exit animations.
|
|
20
|
+
*/
|
|
21
|
+
export function Backdrop({
|
|
22
|
+
onClick,
|
|
23
|
+
bg = "rgba(0, 0, 0, 0.5)",
|
|
24
|
+
zIndex,
|
|
25
|
+
animated = false,
|
|
26
|
+
transition,
|
|
27
|
+
...rest
|
|
28
|
+
}: BackdropProps) {
|
|
29
|
+
return (
|
|
30
|
+
<Box
|
|
31
|
+
as={motion.div}
|
|
32
|
+
initial={{ opacity: animated ? 0 : 1 }}
|
|
33
|
+
animate={{ opacity: 1 }}
|
|
34
|
+
exit={{ opacity: 0 }}
|
|
35
|
+
transition={transition ?? { duration: 0.2 }}
|
|
36
|
+
position="fixed"
|
|
37
|
+
top="0"
|
|
38
|
+
left="0"
|
|
39
|
+
width="100%"
|
|
40
|
+
height="100%"
|
|
41
|
+
bg={bg}
|
|
42
|
+
zIndex={zIndex}
|
|
43
|
+
onClick={onClick}
|
|
44
|
+
aria-hidden="true"
|
|
45
|
+
{...rest}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { motion } from "motion/react";
|
|
4
|
+
import { Box } from "@/design-system/elements";
|
|
5
|
+
|
|
6
|
+
interface OverscrollGuardProps {
|
|
7
|
+
zIndex?: number | string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Covers the safe-area gap at the bottom and extends well past the viewport
|
|
12
|
+
* so iOS rubber-band overscroll never reveals the page behind an overlay.
|
|
13
|
+
* The 200px buffer exceeds the maximum rubber-band distance on iOS.
|
|
14
|
+
*
|
|
15
|
+
* Must be rendered inside an `<AnimatePresence>` for exit animations.
|
|
16
|
+
*/
|
|
17
|
+
export function OverscrollGuard({ zIndex = 10008 }: OverscrollGuardProps) {
|
|
18
|
+
return (
|
|
19
|
+
<Box
|
|
20
|
+
as={motion.div}
|
|
21
|
+
initial={{ opacity: 0 }}
|
|
22
|
+
animate={{ opacity: 1 }}
|
|
23
|
+
exit={{ opacity: 0 }}
|
|
24
|
+
position="fixed"
|
|
25
|
+
left="0"
|
|
26
|
+
right="0"
|
|
27
|
+
zIndex={zIndex}
|
|
28
|
+
bg="surface"
|
|
29
|
+
skin="translucent"
|
|
30
|
+
style={{
|
|
31
|
+
bottom: "-200px",
|
|
32
|
+
height: "calc(env(safe-area-inset-bottom, 0px) + 200px)",
|
|
33
|
+
}}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
}
|