aplance-ui 1.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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/api.d.ts +175 -0
- package/dist/api.js +1954 -0
- package/dist/api.js.map +1 -0
- package/dist/frameworks/astro/astro.config.snippet.md +23 -0
- package/dist/frameworks/astro/postcss.config.mjs.template +20 -0
- package/dist/frameworks/astro/vite-inline.snippet.md +22 -0
- package/dist/frameworks/electron-vite/vite.config.snippet.md +44 -0
- package/dist/frameworks/next/layout-import.snippet.md +19 -0
- package/dist/frameworks/next/next.config.snippet.md +15 -0
- package/dist/frameworks/next/postcss.config.js.template +16 -0
- package/dist/frameworks/tanstack-start/postcss.config.ts.template +17 -0
- package/dist/frameworks/tanstack-start/vite-inline.snippet.md +22 -0
- package/dist/frameworks/vite-react/postcss.config.ts.template +16 -0
- package/dist/frameworks/vite-react/vite-inline.snippet.md +20 -0
- package/dist/frameworks/vite-react/vite.config.ts.template +48 -0
- package/dist/index.js +2047 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/registry/components/primitives/icon/icon.module.css +63 -0
- package/registry/components/primitives/icon/icon.tsx +53 -0
- package/registry/components/primitives/modal/modal-context.ts +19 -0
- package/registry/components/primitives/modal/modal.module.css +415 -0
- package/registry/components/primitives/modal/modal.tsx +550 -0
- package/registry/components/primitives/popover/popover-context.ts +28 -0
- package/registry/components/primitives/popover/popover.module.css +28 -0
- package/registry/components/primitives/popover/popover.tsx +465 -0
- package/registry/components/ui/accordion/accordion.module.css +208 -0
- package/registry/components/ui/accordion/accordion.tsx +327 -0
- package/registry/components/ui/avatar/avatar.module.css +32 -0
- package/registry/components/ui/avatar/avatar.tsx +37 -0
- package/registry/components/ui/badge/badge.module.css +103 -0
- package/registry/components/ui/badge/badge.tsx +29 -0
- package/registry/components/ui/button/button.module.css +216 -0
- package/registry/components/ui/button/button.tsx +133 -0
- package/registry/components/ui/chart/chart.module.css +168 -0
- package/registry/components/ui/chart/chart.tsx +1293 -0
- package/registry/components/ui/chart/types.ts +210 -0
- package/registry/components/ui/chart/utils.ts +59 -0
- package/registry/components/ui/copy-button/copy-button.module.css +21 -0
- package/registry/components/ui/copy-button/copy-button.tsx +123 -0
- package/registry/components/ui/countdown/countdown.module.css +5 -0
- package/registry/components/ui/countdown/countdown.tsx +152 -0
- package/registry/components/ui/data-table/checkbox-column.tsx +59 -0
- package/registry/components/ui/data-table/data-table.module.css +594 -0
- package/registry/components/ui/data-table/data-table.tsx +1109 -0
- package/registry/components/ui/data-table/filter-bar.tsx +33 -0
- package/registry/components/ui/data-table/pagination-bar.tsx +125 -0
- package/registry/components/ui/data-table/types.ts +594 -0
- package/registry/components/ui/data-table/useDataTable.tsx +573 -0
- package/registry/components/ui/data-table/utils.ts +445 -0
- package/registry/components/ui/empty-state/empty-state.module.css +26 -0
- package/registry/components/ui/empty-state/empty-state.tsx +32 -0
- package/registry/components/ui/input-fields/color-picker/color-palette.module.css +102 -0
- package/registry/components/ui/input-fields/color-picker/color-palette.tsx +92 -0
- package/registry/components/ui/input-fields/color-picker/color-picker.module.css +29 -0
- package/registry/components/ui/input-fields/color-picker/color-picker.tsx +168 -0
- package/registry/components/ui/input-fields/common/field-message/field-message.module.css +28 -0
- package/registry/components/ui/input-fields/common/field-message/field-message.tsx +39 -0
- package/registry/components/ui/input-fields/common/input-field-common.module.css +318 -0
- package/registry/components/ui/input-fields/common/input-field-native.module.css +267 -0
- package/registry/components/ui/input-fields/common/picker-popover-common.module.css +40 -0
- package/registry/components/ui/input-fields/common/select-button/select-button.module.css +9 -0
- package/registry/components/ui/input-fields/common/select-button/select-button.tsx +171 -0
- package/registry/components/ui/input-fields/common/types.ts +38 -0
- package/registry/components/ui/input-fields/custom-select/custom-select.module.css +56 -0
- package/registry/components/ui/input-fields/custom-select/custom-select.tsx +403 -0
- package/registry/components/ui/input-fields/date-picker/calendar.module.css +311 -0
- package/registry/components/ui/input-fields/date-picker/calendar.tsx +186 -0
- package/registry/components/ui/input-fields/date-picker/date-picker.module.css +57 -0
- package/registry/components/ui/input-fields/date-picker/date-picker.tsx +945 -0
- package/registry/components/ui/input-fields/date-picker/types.ts +102 -0
- package/registry/components/ui/input-fields/date-picker/utils.ts +236 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-palette.module.css +90 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-palette.tsx +168 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-picker.module.css +8 -0
- package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +102 -0
- package/registry/components/ui/input-fields/emoji-picker/utils.ts +505 -0
- package/registry/components/ui/input-fields/field-group/field-group.module.css +86 -0
- package/registry/components/ui/input-fields/field-group/field-group.tsx +86 -0
- package/registry/components/ui/input-fields/input/input.tsx +319 -0
- package/registry/components/ui/input-fields/media-input/kind-icons.tsx +62 -0
- package/registry/components/ui/input-fields/media-input/media-input.module.css +294 -0
- package/registry/components/ui/input-fields/media-input/media-input.tsx +327 -0
- package/registry/components/ui/input-fields/media-input/media-preview.tsx +101 -0
- package/registry/components/ui/input-fields/media-input/types.ts +129 -0
- package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +302 -0
- package/registry/components/ui/input-fields/media-input/utils.ts +325 -0
- package/registry/components/ui/input-fields/otp-input/otp-input.module.css +24 -0
- package/registry/components/ui/input-fields/otp-input/otp-input.tsx +291 -0
- package/registry/components/ui/input-fields/select/select.tsx +118 -0
- package/registry/components/ui/input-fields/switch/switch.module.css +70 -0
- package/registry/components/ui/input-fields/switch/switch.tsx +59 -0
- package/registry/components/ui/input-fields/textarea/textarea.tsx +133 -0
- package/registry/components/ui/input-fields/time-picker/time-picker.module.css +57 -0
- package/registry/components/ui/input-fields/time-picker/time-picker.tsx +437 -0
- package/registry/components/ui/loader/loader.module.css +156 -0
- package/registry/components/ui/loader/loader.tsx +102 -0
- package/registry/components/ui/stepper/stepper.module.css +294 -0
- package/registry/components/ui/stepper/stepper.tsx +197 -0
- package/registry/components/ui/tabs/tabs.module.css +150 -0
- package/registry/components/ui/tabs/tabs.tsx +134 -0
- package/registry/components/ui/toast/toast.module.css +159 -0
- package/registry/components/ui/toast/toast.tsx +104 -0
- package/registry/components/ui/tooltip/tooltip.module.css +82 -0
- package/registry/components/ui/tooltip/tooltip.tsx +72 -0
- package/registry/global.d.ts +4 -0
- package/registry/hooks/useFocusOutside.tsx +33 -0
- package/registry/hooks/useResizeObserver.tsx +32 -0
- package/registry/lib/dom.ts +25 -0
- package/registry/lib/string.ts +23 -0
- package/registry/registry.json +719 -0
- package/registry/styles/app.css +240 -0
- package/registry/styles/components.css +78 -0
- package/registry/styles/fonts/geist-regular.woff2 +0 -0
- package/registry/styles/globals.css +7 -0
- package/registry/styles/media.css +25 -0
- package/registry/styles/utilities.css +90 -0
- package/registry/themes/colors/blue.css +37 -0
- package/registry/themes/colors/green.css +37 -0
- package/registry/themes/colors/rose.css +37 -0
- package/registry/themes/colors/slate.css +37 -0
- package/registry/themes/colors/stone.css +37 -0
- package/registry/themes/colors/violet.css +37 -0
- package/registry/themes/colors/zinc.css +37 -0
- package/registry/themes/radius/pill.css +8 -0
- package/registry/themes/radius/rounded.css +8 -0
- package/registry/themes/radius/sharp.css +8 -0
- package/registry/themes/spacing/compact.css +17 -0
- package/registry/themes/spacing/spacious.css +17 -0
- package/registry/tsconfig.json +25 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
useState,
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useCallback,
|
|
7
|
+
useRef,
|
|
8
|
+
useId,
|
|
9
|
+
isValidElement,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
type FC,
|
|
12
|
+
type ComponentProps,
|
|
13
|
+
type CSSProperties,
|
|
14
|
+
} from "react";
|
|
15
|
+
import { createPortal } from "react-dom";
|
|
16
|
+
import clsx from "clsx";
|
|
17
|
+
import { IconX } from "@tabler/icons-react";
|
|
18
|
+
import {
|
|
19
|
+
Button,
|
|
20
|
+
type ButtonVariant,
|
|
21
|
+
type ButtonSize,
|
|
22
|
+
type ButtonLayout,
|
|
23
|
+
} from "../../ui/button/button";
|
|
24
|
+
import { useFocusOutside } from "../../../hooks/useFocusOutside";
|
|
25
|
+
import { ModalContext, useModal } from "./modal-context";
|
|
26
|
+
import s from "./modal.module.css";
|
|
27
|
+
import { Icon } from "../icon/icon";
|
|
28
|
+
|
|
29
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
export type ModalSize = "sm" | "md" | "lg" | "xl" | "full";
|
|
32
|
+
export type ModalPlacement =
|
|
33
|
+
| "center"
|
|
34
|
+
| "top"
|
|
35
|
+
| "bottom"
|
|
36
|
+
| "left"
|
|
37
|
+
| "right"
|
|
38
|
+
| "full";
|
|
39
|
+
export type ModalAnimation =
|
|
40
|
+
| "scale"
|
|
41
|
+
| "fade"
|
|
42
|
+
| "slide-top"
|
|
43
|
+
| "slide-bottom"
|
|
44
|
+
| "slide-left"
|
|
45
|
+
| "slide-right"
|
|
46
|
+
| "none";
|
|
47
|
+
export type ModalScrollBehavior = "inside" | "outside";
|
|
48
|
+
|
|
49
|
+
// Default animation for a given placement - center uses scale, edges slide
|
|
50
|
+
// in from their own side, full fades. Overridden by an explicit `animation`.
|
|
51
|
+
const defaultAnimationFor = (placement: ModalPlacement): ModalAnimation => {
|
|
52
|
+
switch (placement) {
|
|
53
|
+
case "top":
|
|
54
|
+
return "slide-top";
|
|
55
|
+
case "bottom":
|
|
56
|
+
return "slide-bottom";
|
|
57
|
+
case "left":
|
|
58
|
+
return "slide-left";
|
|
59
|
+
case "right":
|
|
60
|
+
return "slide-right";
|
|
61
|
+
case "full":
|
|
62
|
+
return "fade";
|
|
63
|
+
case "center":
|
|
64
|
+
default:
|
|
65
|
+
return "scale";
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// Module-level scroll-lock counter - safe for nested/stacked modals.
|
|
70
|
+
// A module-scoped counter prevents the common bug where closing the first
|
|
71
|
+
// of two overlapping modals restores overflow while the second is still open.
|
|
72
|
+
let _scrollLockCount = 0;
|
|
73
|
+
let _prevBodyOverflow = "";
|
|
74
|
+
const acquireScrollLock = () => {
|
|
75
|
+
if (_scrollLockCount === 0) {
|
|
76
|
+
_prevBodyOverflow = document.body.style.overflow;
|
|
77
|
+
document.body.style.overflow = "hidden";
|
|
78
|
+
}
|
|
79
|
+
_scrollLockCount++;
|
|
80
|
+
};
|
|
81
|
+
const releaseScrollLock = () => {
|
|
82
|
+
if (_scrollLockCount === 0) return;
|
|
83
|
+
_scrollLockCount--;
|
|
84
|
+
if (_scrollLockCount === 0) {
|
|
85
|
+
document.body.style.overflow = _prevBodyOverflow;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ─── Shared button passthrough ────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
type ButtonPassthroughProps = {
|
|
92
|
+
variant?: ButtonVariant;
|
|
93
|
+
size?: ButtonSize;
|
|
94
|
+
layout?: ButtonLayout;
|
|
95
|
+
disabled?: boolean;
|
|
96
|
+
className?: string;
|
|
97
|
+
style?: CSSProperties;
|
|
98
|
+
children?: ReactNode;
|
|
99
|
+
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// ─── Modal ────────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
type ModalProps = {
|
|
105
|
+
/**
|
|
106
|
+
* Dialog content plus an optional <ModalTrigger>. ModalTrigger is hoisted
|
|
107
|
+
* out of the dialog panel so it stays visible while the modal is closed.
|
|
108
|
+
* Everything else renders inside the dialog.
|
|
109
|
+
*/
|
|
110
|
+
children: ReactNode;
|
|
111
|
+
/** Controlled open state */
|
|
112
|
+
isOpen?: boolean;
|
|
113
|
+
/** Controlled change handler */
|
|
114
|
+
onOpenChange?: (open: boolean) => void;
|
|
115
|
+
/** Uncontrolled default */
|
|
116
|
+
defaultOpen?: boolean;
|
|
117
|
+
size?: ModalSize;
|
|
118
|
+
/**
|
|
119
|
+
* Where the dialog sits inside the viewport.
|
|
120
|
+
* - `center` (default): centered modal
|
|
121
|
+
* - `top` / `bottom`: sheet pinned to an edge
|
|
122
|
+
* - `left` / `right`: side drawer
|
|
123
|
+
* - `full`: fills the viewport
|
|
124
|
+
*/
|
|
125
|
+
placement?: ModalPlacement;
|
|
126
|
+
/**
|
|
127
|
+
* Placement override below the `--md` breakpoint (≤ 900px).
|
|
128
|
+
* Defaults to `bottom` so modals become bottom sheets on phones.
|
|
129
|
+
* Pass the same value as `placement` (or any placement) to opt out.
|
|
130
|
+
*/
|
|
131
|
+
mobilePlacement?: ModalPlacement;
|
|
132
|
+
/**
|
|
133
|
+
* Entrance/exit animation. Defaults based on `placement`:
|
|
134
|
+
* center→scale, top/bottom/left/right→slide from that edge, full→fade.
|
|
135
|
+
*/
|
|
136
|
+
animation?: ModalAnimation;
|
|
137
|
+
scrollBehavior?: ModalScrollBehavior;
|
|
138
|
+
/** Backdrop blur (default: true) */
|
|
139
|
+
blur?: boolean;
|
|
140
|
+
/** Close on backdrop click (default: true) */
|
|
141
|
+
closeOnOverlayClick?: boolean;
|
|
142
|
+
/** Close on Escape key (default: true) */
|
|
143
|
+
closeOnEsc?: boolean;
|
|
144
|
+
/** Show built-in X close button (default: true) */
|
|
145
|
+
showCloseButton?: boolean;
|
|
146
|
+
/** Render dialog in a portal appended to document.body (default: true) */
|
|
147
|
+
renderOnPortal?: boolean;
|
|
148
|
+
/** Animation duration ms (default: 200) */
|
|
149
|
+
animationDuration?: number;
|
|
150
|
+
/** Called after close animation completes */
|
|
151
|
+
onClose?: () => void;
|
|
152
|
+
/** Called when opened */
|
|
153
|
+
onOpen?: () => void;
|
|
154
|
+
/** Extra className forwarded to the dialog panel */
|
|
155
|
+
className?: string;
|
|
156
|
+
style?: CSSProperties;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const Modal: FC<ModalProps> = ({
|
|
160
|
+
children,
|
|
161
|
+
isOpen: controlledIsOpen,
|
|
162
|
+
onOpenChange,
|
|
163
|
+
defaultOpen = false,
|
|
164
|
+
size = "md",
|
|
165
|
+
placement = "center",
|
|
166
|
+
mobilePlacement = "bottom",
|
|
167
|
+
animation,
|
|
168
|
+
scrollBehavior = "inside",
|
|
169
|
+
blur = true,
|
|
170
|
+
closeOnOverlayClick = true,
|
|
171
|
+
closeOnEsc = true,
|
|
172
|
+
showCloseButton = true,
|
|
173
|
+
renderOnPortal = true,
|
|
174
|
+
animationDuration = 200,
|
|
175
|
+
onClose,
|
|
176
|
+
onOpen,
|
|
177
|
+
className,
|
|
178
|
+
style,
|
|
179
|
+
}) => {
|
|
180
|
+
const isControlled = controlledIsOpen !== undefined;
|
|
181
|
+
const [internalIsOpen, setInternalIsOpen] = useState(defaultOpen);
|
|
182
|
+
const isOpen = isControlled ? controlledIsOpen! : internalIsOpen;
|
|
183
|
+
|
|
184
|
+
// shouldRender: mounts/unmounts DOM node - kept alive during close animation
|
|
185
|
+
const [shouldRender, setShouldRender] = useState(isOpen);
|
|
186
|
+
// isVisible: drives CSS transition - false = closed/initial, true = open
|
|
187
|
+
// Always starts false so the element is in the DOM before the transition fires
|
|
188
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
189
|
+
|
|
190
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
191
|
+
const modalId = useId();
|
|
192
|
+
|
|
193
|
+
// Split children: <ModalTrigger> siblings render outside the dialog so they
|
|
194
|
+
// stay visible while closed; everything else is dialog content. Order is
|
|
195
|
+
// preserved within each bucket, which is all consumers care about.
|
|
196
|
+
const nestedTriggers: ReactNode[] = [];
|
|
197
|
+
const dialogChildren: ReactNode[] = [];
|
|
198
|
+
Children.forEach(children, (child) => {
|
|
199
|
+
if (isValidElement(child) && child.type === ModalTrigger) {
|
|
200
|
+
nestedTriggers.push(child);
|
|
201
|
+
} else {
|
|
202
|
+
dialogChildren.push(child);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Freeze dialog content during close animation so conditional content
|
|
207
|
+
// (e.g. {editAgent && <form>}) doesn't collapse before the animation ends.
|
|
208
|
+
const childrenSnapshot = useRef<ReactNode>(dialogChildren);
|
|
209
|
+
if (isOpen) childrenSnapshot.current = dialogChildren;
|
|
210
|
+
|
|
211
|
+
// Stash callbacks in refs so identity changes on the parent don't retrigger
|
|
212
|
+
// the open/close effect (which would leak timers and re-fire onOpen).
|
|
213
|
+
const onCloseRef = useRef(onClose);
|
|
214
|
+
const onOpenRef = useRef(onOpen);
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
onCloseRef.current = onClose;
|
|
217
|
+
onOpenRef.current = onOpen;
|
|
218
|
+
}, [onClose, onOpen]);
|
|
219
|
+
|
|
220
|
+
const setIsOpen = useCallback(
|
|
221
|
+
(next: boolean) => {
|
|
222
|
+
if (!isControlled) setInternalIsOpen(next);
|
|
223
|
+
onOpenChange?.(next);
|
|
224
|
+
},
|
|
225
|
+
[isControlled, onOpenChange],
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const open = useCallback(() => setIsOpen(true), [setIsOpen]);
|
|
229
|
+
const close = useCallback(() => setIsOpen(false), [setIsOpen]);
|
|
230
|
+
const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen, setIsOpen]);
|
|
231
|
+
|
|
232
|
+
// Open: setShouldRender(true) → node mounts → useLayoutEffect below fires → isVisible=true → open transition
|
|
233
|
+
// Close: setIsVisible(false) immediately → close transition plays → setTimeout unmounts after duration
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (isOpen) {
|
|
236
|
+
setShouldRender(true);
|
|
237
|
+
} else {
|
|
238
|
+
setIsVisible(false);
|
|
239
|
+
const t = setTimeout(() => {
|
|
240
|
+
setShouldRender(false);
|
|
241
|
+
onCloseRef.current?.();
|
|
242
|
+
}, animationDuration);
|
|
243
|
+
return () => clearTimeout(t);
|
|
244
|
+
}
|
|
245
|
+
}, [isOpen, animationDuration]);
|
|
246
|
+
|
|
247
|
+
// Fires synchronously after the node is painted for the first time.
|
|
248
|
+
// Force-reads offsetHeight to flush the browser's style recalc so it
|
|
249
|
+
// registers the closed CSS state before we flip to open - same trick
|
|
250
|
+
// Popover uses with isMeasured + ResizeObserver.
|
|
251
|
+
useLayoutEffect(() => {
|
|
252
|
+
if (shouldRender && contentRef.current) {
|
|
253
|
+
void contentRef.current.offsetHeight; // flush reflow
|
|
254
|
+
setIsVisible(isOpen);
|
|
255
|
+
}
|
|
256
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
257
|
+
}, [shouldRender]); // only run when node mounts, not on every isOpen change
|
|
258
|
+
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
if (isOpen) onOpenRef.current?.();
|
|
261
|
+
}, [isOpen]);
|
|
262
|
+
|
|
263
|
+
// Body scroll lock - reference-counted across stacked modals
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (!isOpen) return;
|
|
266
|
+
acquireScrollLock();
|
|
267
|
+
return releaseScrollLock;
|
|
268
|
+
}, [isOpen]);
|
|
269
|
+
|
|
270
|
+
// ESC key
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (!closeOnEsc || !isOpen) return;
|
|
273
|
+
const handler = (e: globalThis.KeyboardEvent) => {
|
|
274
|
+
if (e.key === "Escape") close();
|
|
275
|
+
};
|
|
276
|
+
window.addEventListener("keydown", handler);
|
|
277
|
+
return () => window.removeEventListener("keydown", handler);
|
|
278
|
+
}, [isOpen, closeOnEsc, close]);
|
|
279
|
+
|
|
280
|
+
// Focus trap
|
|
281
|
+
useLayoutEffect(() => {
|
|
282
|
+
if (!isOpen || !contentRef.current) return;
|
|
283
|
+
const el = contentRef.current;
|
|
284
|
+
const focusable = el.querySelectorAll<HTMLElement>(
|
|
285
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
286
|
+
);
|
|
287
|
+
const first = focusable[0];
|
|
288
|
+
const last = focusable[focusable.length - 1];
|
|
289
|
+
(first ?? el).focus();
|
|
290
|
+
|
|
291
|
+
const trap = (e: globalThis.KeyboardEvent) => {
|
|
292
|
+
if (e.key !== "Tab") return;
|
|
293
|
+
if (focusable.length === 0) {
|
|
294
|
+
e.preventDefault();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (e.shiftKey) {
|
|
298
|
+
if (document.activeElement === first) {
|
|
299
|
+
e.preventDefault();
|
|
300
|
+
last.focus();
|
|
301
|
+
}
|
|
302
|
+
} else {
|
|
303
|
+
if (document.activeElement === last) {
|
|
304
|
+
e.preventDefault();
|
|
305
|
+
first.focus();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
el.addEventListener("keydown", trap);
|
|
310
|
+
return () => el.removeEventListener("keydown", trap);
|
|
311
|
+
}, [isOpen]);
|
|
312
|
+
|
|
313
|
+
// Close when a click lands outside the dialog panel - mirrors Popover's
|
|
314
|
+
// outside-click pattern so the component family stays consistent.
|
|
315
|
+
useFocusOutside([contentRef], close, {
|
|
316
|
+
enabled: closeOnOverlayClick && isOpen,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// ── Dialog panel ──────────────────────────────────────────────────────────
|
|
320
|
+
const dialog = shouldRender ? (
|
|
321
|
+
<div
|
|
322
|
+
className={s.overlay}
|
|
323
|
+
data-state={isVisible ? "open" : "closed"}
|
|
324
|
+
data-placement={placement}
|
|
325
|
+
data-mobile-placement={mobilePlacement}
|
|
326
|
+
data-scroll-behavior={scrollBehavior}
|
|
327
|
+
style={
|
|
328
|
+
{
|
|
329
|
+
"--modal-animation-duration": `${animationDuration}ms`,
|
|
330
|
+
...(!blur && {
|
|
331
|
+
backdropFilter: "none",
|
|
332
|
+
WebkitBackdropFilter: "none",
|
|
333
|
+
}),
|
|
334
|
+
} as CSSProperties
|
|
335
|
+
}
|
|
336
|
+
>
|
|
337
|
+
<div
|
|
338
|
+
ref={contentRef}
|
|
339
|
+
id={modalId}
|
|
340
|
+
role="dialog"
|
|
341
|
+
aria-modal="true"
|
|
342
|
+
tabIndex={-1}
|
|
343
|
+
className={clsx(
|
|
344
|
+
s.content,
|
|
345
|
+
s[`size_${size}`],
|
|
346
|
+
s[`anim_${animation ?? defaultAnimationFor(placement)}`],
|
|
347
|
+
scrollBehavior === "inside" && s.scroll_inside,
|
|
348
|
+
className,
|
|
349
|
+
)}
|
|
350
|
+
data-state={isVisible ? "open" : "closed"}
|
|
351
|
+
data-placement={placement}
|
|
352
|
+
data-mobile-placement={mobilePlacement}
|
|
353
|
+
style={style}
|
|
354
|
+
>
|
|
355
|
+
{/* children render ONLY here - never outside the dialog */}
|
|
356
|
+
{childrenSnapshot.current}
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
) : null;
|
|
360
|
+
|
|
361
|
+
const portal =
|
|
362
|
+
renderOnPortal && dialog ? createPortal(dialog, document.body) : dialog;
|
|
363
|
+
|
|
364
|
+
// Hoisted triggers sit outside the portal so they stay visible while closed;
|
|
365
|
+
// dialog content lives inside the portal.
|
|
366
|
+
return (
|
|
367
|
+
<ModalContext.Provider
|
|
368
|
+
value={{ isOpen, open, close, toggle, showCloseButton }}
|
|
369
|
+
>
|
|
370
|
+
{nestedTriggers}
|
|
371
|
+
{portal}
|
|
372
|
+
</ModalContext.Provider>
|
|
373
|
+
);
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// ─── ModalTrigger ─────────────────────────────────────────────────────────────
|
|
377
|
+
// Nest inside <Modal> - Modal hoists it out of the dialog automatically so
|
|
378
|
+
// the trigger stays visible while the modal is closed. For fully controlled
|
|
379
|
+
// modals the trigger can live elsewhere; call open() from useModal() instead.
|
|
380
|
+
|
|
381
|
+
type ModalTriggerProps = ButtonPassthroughProps & {
|
|
382
|
+
asChild?: boolean;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
export const ModalTrigger: FC<ModalTriggerProps> = ({
|
|
386
|
+
asChild,
|
|
387
|
+
children,
|
|
388
|
+
onClick,
|
|
389
|
+
variant,
|
|
390
|
+
size,
|
|
391
|
+
layout,
|
|
392
|
+
disabled,
|
|
393
|
+
className,
|
|
394
|
+
style,
|
|
395
|
+
}) => {
|
|
396
|
+
const { open } = useModal();
|
|
397
|
+
|
|
398
|
+
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
|
399
|
+
open();
|
|
400
|
+
onClick?.(e);
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
if (asChild && isValidElement(children)) {
|
|
404
|
+
const child = children as React.ReactElement<Record<string, unknown>>;
|
|
405
|
+
return (
|
|
406
|
+
<child.type
|
|
407
|
+
{...child.props}
|
|
408
|
+
onClick={(e: React.MouseEvent<HTMLElement>) => {
|
|
409
|
+
open();
|
|
410
|
+
(
|
|
411
|
+
child.props.onClick as
|
|
412
|
+
| React.MouseEventHandler<HTMLElement>
|
|
413
|
+
| undefined
|
|
414
|
+
)?.(e);
|
|
415
|
+
(onClick as React.MouseEventHandler<HTMLElement> | undefined)?.(e);
|
|
416
|
+
}}
|
|
417
|
+
/>
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return (
|
|
422
|
+
<Button
|
|
423
|
+
as="button"
|
|
424
|
+
variant={variant}
|
|
425
|
+
size={size}
|
|
426
|
+
layout={layout}
|
|
427
|
+
disabled={disabled}
|
|
428
|
+
className={className}
|
|
429
|
+
style={style}
|
|
430
|
+
onClick={handleClick}
|
|
431
|
+
>
|
|
432
|
+
{children}
|
|
433
|
+
</Button>
|
|
434
|
+
);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
// ─── ModalClose ───────────────────────────────────────────────────────────────
|
|
438
|
+
// Explicit close button inside footer/body (e.g. "Cancel").
|
|
439
|
+
// The automatic X button is controlled by showCloseButton prop on Modal.
|
|
440
|
+
|
|
441
|
+
type ModalCloseProps = ButtonPassthroughProps & {
|
|
442
|
+
asChild?: boolean;
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
export const ModalClose: FC<ModalCloseProps> = ({
|
|
446
|
+
asChild,
|
|
447
|
+
children,
|
|
448
|
+
onClick,
|
|
449
|
+
variant = "secondary",
|
|
450
|
+
size,
|
|
451
|
+
layout,
|
|
452
|
+
disabled,
|
|
453
|
+
className,
|
|
454
|
+
style,
|
|
455
|
+
}) => {
|
|
456
|
+
const { close } = useModal();
|
|
457
|
+
|
|
458
|
+
const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
|
459
|
+
close();
|
|
460
|
+
onClick?.(e);
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
if (asChild && isValidElement(children)) {
|
|
464
|
+
const child = children as React.ReactElement<Record<string, unknown>>;
|
|
465
|
+
return (
|
|
466
|
+
<child.type
|
|
467
|
+
{...child.props}
|
|
468
|
+
onClick={(e: React.MouseEvent<HTMLElement>) => {
|
|
469
|
+
close();
|
|
470
|
+
(
|
|
471
|
+
child.props.onClick as
|
|
472
|
+
| React.MouseEventHandler<HTMLElement>
|
|
473
|
+
| undefined
|
|
474
|
+
)?.(e);
|
|
475
|
+
(onClick as React.MouseEventHandler<HTMLElement> | undefined)?.(e);
|
|
476
|
+
}}
|
|
477
|
+
/>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return (
|
|
482
|
+
<Button
|
|
483
|
+
as="button"
|
|
484
|
+
variant={variant}
|
|
485
|
+
size={size}
|
|
486
|
+
layout={layout}
|
|
487
|
+
disabled={disabled}
|
|
488
|
+
className={className}
|
|
489
|
+
style={style}
|
|
490
|
+
onClick={handleClick}
|
|
491
|
+
>
|
|
492
|
+
{children}
|
|
493
|
+
</Button>
|
|
494
|
+
);
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
// ─── Composition helpers ──────────────────────────────────────────────────────
|
|
498
|
+
|
|
499
|
+
export const ModalHeader: FC<ComponentProps<"div">> = ({
|
|
500
|
+
className,
|
|
501
|
+
children,
|
|
502
|
+
...props
|
|
503
|
+
}) => {
|
|
504
|
+
const { close, showCloseButton } = useModal();
|
|
505
|
+
return (
|
|
506
|
+
<div className={clsx(s.header, className)} {...props}>
|
|
507
|
+
<div className={s.header_content}>{children}</div>
|
|
508
|
+
{showCloseButton && (
|
|
509
|
+
<Button
|
|
510
|
+
as="button"
|
|
511
|
+
variant="ghost"
|
|
512
|
+
layout="box"
|
|
513
|
+
size="sm"
|
|
514
|
+
className={s.close_button}
|
|
515
|
+
onClick={close}
|
|
516
|
+
aria-label="Close modal"
|
|
517
|
+
>
|
|
518
|
+
<Icon clickable size="sm">
|
|
519
|
+
<IconX strokeWidth={2} />
|
|
520
|
+
</Icon>
|
|
521
|
+
</Button>
|
|
522
|
+
)}
|
|
523
|
+
</div>
|
|
524
|
+
);
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
export const ModalTitle: FC<ComponentProps<"h2">> = ({
|
|
528
|
+
className,
|
|
529
|
+
...props
|
|
530
|
+
}) => <h2 className={clsx(s.title, className)} {...props} />;
|
|
531
|
+
|
|
532
|
+
export const ModalDescription: FC<ComponentProps<"p">> = ({
|
|
533
|
+
className,
|
|
534
|
+
...props
|
|
535
|
+
}) => <p className={clsx(s.description, className)} {...props} />;
|
|
536
|
+
|
|
537
|
+
export const ModalBody: FC<ComponentProps<"div">> = ({
|
|
538
|
+
className,
|
|
539
|
+
...props
|
|
540
|
+
}) => <div className={clsx(s.body, className)} {...props} />;
|
|
541
|
+
|
|
542
|
+
export const ModalFooter: FC<ComponentProps<"div">> = ({
|
|
543
|
+
className,
|
|
544
|
+
...props
|
|
545
|
+
}) => <div className={clsx(s.footer, className)} {...props} />;
|
|
546
|
+
|
|
547
|
+
export const ModalDivider: FC<ComponentProps<"hr">> = ({
|
|
548
|
+
className,
|
|
549
|
+
...props
|
|
550
|
+
}) => <hr className={clsx(s.divider, className)} {...props} />;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createContext, useContext, type RefObject } from "react";
|
|
2
|
+
import type { Coords, Position, Trigger } from "./popover";
|
|
3
|
+
|
|
4
|
+
export type PopoverContextType = {
|
|
5
|
+
isOpen: boolean;
|
|
6
|
+
setIsOpen: (open: boolean) => void;
|
|
7
|
+
popoverRef: RefObject<HTMLDivElement | null>;
|
|
8
|
+
menuRef: RefObject<HTMLDivElement | null>;
|
|
9
|
+
coords: Coords;
|
|
10
|
+
isMeasured: boolean;
|
|
11
|
+
shouldRender: boolean;
|
|
12
|
+
animationDuration?: number;
|
|
13
|
+
triggerWidth: number;
|
|
14
|
+
trigger: Trigger;
|
|
15
|
+
position: Position;
|
|
16
|
+
hoverTimeoutRef: RefObject<ReturnType<typeof setTimeout> | null>;
|
|
17
|
+
renderOnPortal: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const PopoverContext = createContext<PopoverContextType | undefined>(
|
|
21
|
+
undefined,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export const usePopover = () => {
|
|
25
|
+
const ctx = useContext(PopoverContext);
|
|
26
|
+
if (!ctx) throw new Error("usePopover must be used within Popover");
|
|
27
|
+
return ctx;
|
|
28
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
|
|
2
|
+
.container {
|
|
3
|
+
/* its width can be managed from parent or user component container */
|
|
4
|
+
/* e.g. using with custom-select: .container can be anything and this will match it */
|
|
5
|
+
width: fit-content;
|
|
6
|
+
position: relative;
|
|
7
|
+
display: flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
justify-content: center;
|
|
10
|
+
|
|
11
|
+
.trigger {
|
|
12
|
+
width: 100%;
|
|
13
|
+
min-width: fit-content;
|
|
14
|
+
display: flex;
|
|
15
|
+
align-items: center;
|
|
16
|
+
/* add other styles from props */
|
|
17
|
+
|
|
18
|
+
&[data-disabled="true"] {
|
|
19
|
+
pointer-events: none;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.menu_wrapper {
|
|
24
|
+
/* style as needed at usage */
|
|
25
|
+
min-width: fit-content;
|
|
26
|
+
/* max-width: 100% !important; can be updated with !important */
|
|
27
|
+
}
|
|
28
|
+
}
|