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.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -0
  3. package/dist/api.d.ts +175 -0
  4. package/dist/api.js +1954 -0
  5. package/dist/api.js.map +1 -0
  6. package/dist/frameworks/astro/astro.config.snippet.md +23 -0
  7. package/dist/frameworks/astro/postcss.config.mjs.template +20 -0
  8. package/dist/frameworks/astro/vite-inline.snippet.md +22 -0
  9. package/dist/frameworks/electron-vite/vite.config.snippet.md +44 -0
  10. package/dist/frameworks/next/layout-import.snippet.md +19 -0
  11. package/dist/frameworks/next/next.config.snippet.md +15 -0
  12. package/dist/frameworks/next/postcss.config.js.template +16 -0
  13. package/dist/frameworks/tanstack-start/postcss.config.ts.template +17 -0
  14. package/dist/frameworks/tanstack-start/vite-inline.snippet.md +22 -0
  15. package/dist/frameworks/vite-react/postcss.config.ts.template +16 -0
  16. package/dist/frameworks/vite-react/vite-inline.snippet.md +20 -0
  17. package/dist/frameworks/vite-react/vite.config.ts.template +48 -0
  18. package/dist/index.js +2047 -0
  19. package/dist/index.js.map +1 -0
  20. package/package.json +63 -0
  21. package/registry/components/primitives/icon/icon.module.css +63 -0
  22. package/registry/components/primitives/icon/icon.tsx +53 -0
  23. package/registry/components/primitives/modal/modal-context.ts +19 -0
  24. package/registry/components/primitives/modal/modal.module.css +415 -0
  25. package/registry/components/primitives/modal/modal.tsx +550 -0
  26. package/registry/components/primitives/popover/popover-context.ts +28 -0
  27. package/registry/components/primitives/popover/popover.module.css +28 -0
  28. package/registry/components/primitives/popover/popover.tsx +465 -0
  29. package/registry/components/ui/accordion/accordion.module.css +208 -0
  30. package/registry/components/ui/accordion/accordion.tsx +327 -0
  31. package/registry/components/ui/avatar/avatar.module.css +32 -0
  32. package/registry/components/ui/avatar/avatar.tsx +37 -0
  33. package/registry/components/ui/badge/badge.module.css +103 -0
  34. package/registry/components/ui/badge/badge.tsx +29 -0
  35. package/registry/components/ui/button/button.module.css +216 -0
  36. package/registry/components/ui/button/button.tsx +133 -0
  37. package/registry/components/ui/chart/chart.module.css +168 -0
  38. package/registry/components/ui/chart/chart.tsx +1293 -0
  39. package/registry/components/ui/chart/types.ts +210 -0
  40. package/registry/components/ui/chart/utils.ts +59 -0
  41. package/registry/components/ui/copy-button/copy-button.module.css +21 -0
  42. package/registry/components/ui/copy-button/copy-button.tsx +123 -0
  43. package/registry/components/ui/countdown/countdown.module.css +5 -0
  44. package/registry/components/ui/countdown/countdown.tsx +152 -0
  45. package/registry/components/ui/data-table/checkbox-column.tsx +59 -0
  46. package/registry/components/ui/data-table/data-table.module.css +594 -0
  47. package/registry/components/ui/data-table/data-table.tsx +1109 -0
  48. package/registry/components/ui/data-table/filter-bar.tsx +33 -0
  49. package/registry/components/ui/data-table/pagination-bar.tsx +125 -0
  50. package/registry/components/ui/data-table/types.ts +594 -0
  51. package/registry/components/ui/data-table/useDataTable.tsx +573 -0
  52. package/registry/components/ui/data-table/utils.ts +445 -0
  53. package/registry/components/ui/empty-state/empty-state.module.css +26 -0
  54. package/registry/components/ui/empty-state/empty-state.tsx +32 -0
  55. package/registry/components/ui/input-fields/color-picker/color-palette.module.css +102 -0
  56. package/registry/components/ui/input-fields/color-picker/color-palette.tsx +92 -0
  57. package/registry/components/ui/input-fields/color-picker/color-picker.module.css +29 -0
  58. package/registry/components/ui/input-fields/color-picker/color-picker.tsx +168 -0
  59. package/registry/components/ui/input-fields/common/field-message/field-message.module.css +28 -0
  60. package/registry/components/ui/input-fields/common/field-message/field-message.tsx +39 -0
  61. package/registry/components/ui/input-fields/common/input-field-common.module.css +318 -0
  62. package/registry/components/ui/input-fields/common/input-field-native.module.css +267 -0
  63. package/registry/components/ui/input-fields/common/picker-popover-common.module.css +40 -0
  64. package/registry/components/ui/input-fields/common/select-button/select-button.module.css +9 -0
  65. package/registry/components/ui/input-fields/common/select-button/select-button.tsx +171 -0
  66. package/registry/components/ui/input-fields/common/types.ts +38 -0
  67. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +56 -0
  68. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +403 -0
  69. package/registry/components/ui/input-fields/date-picker/calendar.module.css +311 -0
  70. package/registry/components/ui/input-fields/date-picker/calendar.tsx +186 -0
  71. package/registry/components/ui/input-fields/date-picker/date-picker.module.css +57 -0
  72. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +945 -0
  73. package/registry/components/ui/input-fields/date-picker/types.ts +102 -0
  74. package/registry/components/ui/input-fields/date-picker/utils.ts +236 -0
  75. package/registry/components/ui/input-fields/emoji-picker/emoji-palette.module.css +90 -0
  76. package/registry/components/ui/input-fields/emoji-picker/emoji-palette.tsx +168 -0
  77. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.module.css +8 -0
  78. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +102 -0
  79. package/registry/components/ui/input-fields/emoji-picker/utils.ts +505 -0
  80. package/registry/components/ui/input-fields/field-group/field-group.module.css +86 -0
  81. package/registry/components/ui/input-fields/field-group/field-group.tsx +86 -0
  82. package/registry/components/ui/input-fields/input/input.tsx +319 -0
  83. package/registry/components/ui/input-fields/media-input/kind-icons.tsx +62 -0
  84. package/registry/components/ui/input-fields/media-input/media-input.module.css +294 -0
  85. package/registry/components/ui/input-fields/media-input/media-input.tsx +327 -0
  86. package/registry/components/ui/input-fields/media-input/media-preview.tsx +101 -0
  87. package/registry/components/ui/input-fields/media-input/types.ts +129 -0
  88. package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +302 -0
  89. package/registry/components/ui/input-fields/media-input/utils.ts +325 -0
  90. package/registry/components/ui/input-fields/otp-input/otp-input.module.css +24 -0
  91. package/registry/components/ui/input-fields/otp-input/otp-input.tsx +291 -0
  92. package/registry/components/ui/input-fields/select/select.tsx +118 -0
  93. package/registry/components/ui/input-fields/switch/switch.module.css +70 -0
  94. package/registry/components/ui/input-fields/switch/switch.tsx +59 -0
  95. package/registry/components/ui/input-fields/textarea/textarea.tsx +133 -0
  96. package/registry/components/ui/input-fields/time-picker/time-picker.module.css +57 -0
  97. package/registry/components/ui/input-fields/time-picker/time-picker.tsx +437 -0
  98. package/registry/components/ui/loader/loader.module.css +156 -0
  99. package/registry/components/ui/loader/loader.tsx +102 -0
  100. package/registry/components/ui/stepper/stepper.module.css +294 -0
  101. package/registry/components/ui/stepper/stepper.tsx +197 -0
  102. package/registry/components/ui/tabs/tabs.module.css +150 -0
  103. package/registry/components/ui/tabs/tabs.tsx +134 -0
  104. package/registry/components/ui/toast/toast.module.css +159 -0
  105. package/registry/components/ui/toast/toast.tsx +104 -0
  106. package/registry/components/ui/tooltip/tooltip.module.css +82 -0
  107. package/registry/components/ui/tooltip/tooltip.tsx +72 -0
  108. package/registry/global.d.ts +4 -0
  109. package/registry/hooks/useFocusOutside.tsx +33 -0
  110. package/registry/hooks/useResizeObserver.tsx +32 -0
  111. package/registry/lib/dom.ts +25 -0
  112. package/registry/lib/string.ts +23 -0
  113. package/registry/registry.json +719 -0
  114. package/registry/styles/app.css +240 -0
  115. package/registry/styles/components.css +78 -0
  116. package/registry/styles/fonts/geist-regular.woff2 +0 -0
  117. package/registry/styles/globals.css +7 -0
  118. package/registry/styles/media.css +25 -0
  119. package/registry/styles/utilities.css +90 -0
  120. package/registry/themes/colors/blue.css +37 -0
  121. package/registry/themes/colors/green.css +37 -0
  122. package/registry/themes/colors/rose.css +37 -0
  123. package/registry/themes/colors/slate.css +37 -0
  124. package/registry/themes/colors/stone.css +37 -0
  125. package/registry/themes/colors/violet.css +37 -0
  126. package/registry/themes/colors/zinc.css +37 -0
  127. package/registry/themes/radius/pill.css +8 -0
  128. package/registry/themes/radius/rounded.css +8 -0
  129. package/registry/themes/radius/sharp.css +8 -0
  130. package/registry/themes/spacing/compact.css +17 -0
  131. package/registry/themes/spacing/spacious.css +17 -0
  132. 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
+ }