@vvedantb/ui 0.1.4 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/ui",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "license": "MIT",
5
5
  "description": "Shared React 19 UI primitives for vmem, vibot and Verve.",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -16,6 +16,8 @@ export * from "./ui/collapsible";
16
16
  export * from "./ui/command";
17
17
  export * from "./ui/context-menu";
18
18
  export * from "./ui/dialog";
19
+ export * from "./ui/alert-dialog";
20
+ export * from "./ui/confirm-dialog";
19
21
  export * from "./ui/dropdown-menu";
20
22
  export * from "./ui/hover-card";
21
23
  export * from "./ui/input";
@@ -0,0 +1,83 @@
1
+ import * as React from "react";
2
+ import { cn } from "../utils/cn";
3
+ import { Button, type ButtonProps } from "./button";
4
+ import {
5
+ Dialog,
6
+ DialogClose,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogOverlay,
12
+ DialogPortal,
13
+ DialogTitle,
14
+ DialogTrigger,
15
+ } from "./dialog";
16
+
17
+ const AlertDialog = Dialog;
18
+ const AlertDialogTrigger = DialogTrigger;
19
+ const AlertDialogPortal = DialogPortal;
20
+ const AlertDialogOverlay = DialogOverlay;
21
+
22
+ const AlertDialogContent = React.forwardRef<
23
+ React.ComponentRef<typeof DialogContent>,
24
+ React.ComponentPropsWithoutRef<typeof DialogContent>
25
+ >(({ hideCloseButton = true, onPointerDownOutside, onInteractOutside, ...props }, ref) => (
26
+ <DialogContent
27
+ ref={ref}
28
+ role="alertdialog"
29
+ hideCloseButton={hideCloseButton}
30
+ onPointerDownOutside={(event) => {
31
+ event.preventDefault();
32
+ onPointerDownOutside?.(event);
33
+ }}
34
+ onInteractOutside={(event) => {
35
+ event.preventDefault();
36
+ onInteractOutside?.(event);
37
+ }}
38
+ {...props}
39
+ />
40
+ ));
41
+ AlertDialogContent.displayName = "AlertDialogContent";
42
+
43
+ const AlertDialogHeader = DialogHeader;
44
+ const AlertDialogFooter = DialogFooter;
45
+ const AlertDialogTitle = DialogTitle;
46
+ const AlertDialogDescription = DialogDescription;
47
+
48
+ const AlertDialogAction = React.forwardRef<HTMLButtonElement, ButtonProps>(
49
+ ({ className, ...props }, ref) => (
50
+ <Button ref={ref} className={cn("max-sm:min-h-11", className)} {...props} />
51
+ ),
52
+ );
53
+ AlertDialogAction.displayName = "AlertDialogAction";
54
+
55
+ const AlertDialogCancel = React.forwardRef<HTMLButtonElement, ButtonProps>(
56
+ ({ className, variant = "outline", ...props }, ref) => (
57
+ <DialogClose asChild>
58
+ <Button
59
+ ref={ref}
60
+ type="button"
61
+ variant={variant}
62
+ data-alert-cancel=""
63
+ className={cn("max-sm:min-h-11 mt-2 sm:mt-0", className)}
64
+ {...props}
65
+ />
66
+ </DialogClose>
67
+ ),
68
+ );
69
+ AlertDialogCancel.displayName = "AlertDialogCancel";
70
+
71
+ export {
72
+ AlertDialog,
73
+ AlertDialogTrigger,
74
+ AlertDialogPortal,
75
+ AlertDialogOverlay,
76
+ AlertDialogContent,
77
+ AlertDialogHeader,
78
+ AlertDialogFooter,
79
+ AlertDialogTitle,
80
+ AlertDialogDescription,
81
+ AlertDialogAction,
82
+ AlertDialogCancel,
83
+ };
package/src/ui/button.tsx CHANGED
@@ -10,7 +10,8 @@ const buttonVariants = cva(
10
10
  variant: {
11
11
  default:
12
12
  "bg-accent text-accent-foreground hover:-translate-y-0.5 hover:opacity-90",
13
- destructive: "bg-danger text-danger-foreground hover:bg-danger/90",
13
+ destructive:
14
+ "bg-[oklch(47.2%_0.195_25.7)] text-white hover:bg-[oklch(42.5%_0.185_25.7)]",
14
15
  outline:
15
16
  "border border-border bg-transparent text-foreground hover:bg-default",
16
17
  secondary: "bg-default text-default-foreground hover:bg-default/78",
@@ -0,0 +1,61 @@
1
+ import * as React from "react";
2
+ import {
3
+ AlertDialog,
4
+ AlertDialogAction,
5
+ AlertDialogCancel,
6
+ AlertDialogContent,
7
+ AlertDialogDescription,
8
+ AlertDialogFooter,
9
+ AlertDialogHeader,
10
+ AlertDialogTitle,
11
+ } from "./alert-dialog";
12
+
13
+ export type ConfirmDialogProps = React.ComponentProps<typeof AlertDialog> & {
14
+ title: React.ReactNode;
15
+ description?: React.ReactNode;
16
+ cancelLabel?: React.ReactNode;
17
+ confirmLabel?: React.ReactNode;
18
+ onCancel?: () => void;
19
+ onConfirm?: () => void;
20
+ variant?: "default" | "destructive";
21
+ confirmDisabled?: boolean;
22
+ };
23
+
24
+ function ConfirmDialog({
25
+ title,
26
+ description,
27
+ cancelLabel = "Cancel",
28
+ confirmLabel = "Confirm",
29
+ onCancel,
30
+ onConfirm,
31
+ variant = "default",
32
+ confirmDisabled,
33
+ children,
34
+ ...props
35
+ }: ConfirmDialogProps) {
36
+ return (
37
+ <AlertDialog {...props}>
38
+ {children}
39
+ <AlertDialogContent>
40
+ <AlertDialogHeader>
41
+ <AlertDialogTitle>{title}</AlertDialogTitle>
42
+ {description != null ? (
43
+ <AlertDialogDescription>{description}</AlertDialogDescription>
44
+ ) : null}
45
+ </AlertDialogHeader>
46
+ <AlertDialogFooter>
47
+ <AlertDialogCancel onClick={onCancel}>{cancelLabel}</AlertDialogCancel>
48
+ <AlertDialogAction
49
+ variant={variant}
50
+ disabled={confirmDisabled}
51
+ onClick={onConfirm}
52
+ >
53
+ {confirmLabel}
54
+ </AlertDialogAction>
55
+ </AlertDialogFooter>
56
+ </AlertDialogContent>
57
+ </AlertDialog>
58
+ );
59
+ }
60
+
61
+ export { ConfirmDialog };
package/src/ui/dialog.tsx CHANGED
@@ -4,12 +4,22 @@ import { IconX } from "@tabler/icons-react";
4
4
  import { cn } from "../utils/cn";
5
5
  import { assignRef } from "../utils/ref";
6
6
  import { connectModalSurface } from "./modalTransition";
7
+ import {
8
+ connectBodyPointerEventsFix,
9
+ connectKeyboardScroll,
10
+ connectOverlayViewport,
11
+ treeHasDialogDescription,
12
+ useCompactOverlay,
13
+ } from "./overlay-a11y";
7
14
 
8
15
  const Dialog = DialogPrimitive.Root;
9
16
  const DialogTrigger = DialogPrimitive.Trigger;
10
17
  const DialogPortal = DialogPrimitive.Portal;
11
18
  const DialogClose = DialogPrimitive.Close;
12
19
 
20
+ const overlayMotionClass =
21
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 motion-reduce:animate-none motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none";
22
+
13
23
  const DialogOverlay = React.forwardRef<
14
24
  React.ComponentRef<typeof DialogPrimitive.Overlay>,
15
25
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
@@ -17,7 +27,8 @@ const DialogOverlay = React.forwardRef<
17
27
  <DialogPrimitive.Overlay
18
28
  ref={ref}
19
29
  className={cn(
20
- "fixed inset-0 z-50 bg-backdrop backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
30
+ "fixed inset-0 z-50 bg-backdrop backdrop-blur-md",
31
+ overlayMotionClass,
21
32
  className,
22
33
  )}
23
34
  {...props}
@@ -25,59 +36,154 @@ const DialogOverlay = React.forwardRef<
25
36
  ));
26
37
  DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
27
38
 
39
+ const DEFAULT_ALERT_DESCRIPTION = "This action needs confirmation.";
40
+
28
41
  const DialogContent = React.forwardRef<
29
42
  React.ComponentRef<typeof DialogPrimitive.Content>,
30
43
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
31
44
  hideCloseButton?: boolean;
32
45
  }
33
- >(({ className, children, hideCloseButton, ...props }, ref) => {
34
- const surfaceCleanupRef = React.useRef<(() => void) | null>(null);
46
+ >(
47
+ (
48
+ {
49
+ className,
50
+ children,
51
+ hideCloseButton,
52
+ role,
53
+ onOpenAutoFocus,
54
+ "aria-describedby": ariaDescribedBy,
55
+ ...props
56
+ },
57
+ ref,
58
+ ) => {
59
+ const compact = useCompactOverlay();
60
+ const isAlert = role === "alertdialog";
61
+ const contentNodeRef = React.useRef<HTMLDivElement | null>(null);
62
+ const surfaceCleanupRef = React.useRef<(() => void) | null>(null);
63
+ const overlayCleanupRef = React.useRef<(() => void) | null>(null);
64
+ const generatedDescriptionId = React.useId();
65
+ const hasDescription = treeHasDialogDescription(children, DialogDescription);
66
+ const missingDescription =
67
+ isAlert && ariaDescribedBy == null && !hasDescription;
68
+
69
+ React.useEffect(() => {
70
+ if (!missingDescription) return;
71
+ const nodeEnv = (globalThis as { process?: { env?: { NODE_ENV?: string } } })
72
+ .process?.env?.NODE_ENV;
73
+ if (nodeEnv === "production") return;
74
+ console.warn(
75
+ '[Dialog] role="alertdialog" requires a description. Add DialogDescription or aria-describedby.',
76
+ );
77
+ }, [missingDescription]);
35
78
 
36
- const mergedRef = React.useCallback(
37
- (node: HTMLDivElement | null) => {
38
- if (surfaceCleanupRef.current) {
39
- surfaceCleanupRef.current();
79
+ const mergedRef = React.useCallback(
80
+ (node: HTMLDivElement | null) => {
81
+ surfaceCleanupRef.current?.();
40
82
  surfaceCleanupRef.current = null;
41
- }
83
+ overlayCleanupRef.current?.();
84
+ overlayCleanupRef.current = null;
42
85
 
43
- if (node) {
44
- surfaceCleanupRef.current = connectModalSurface(node);
45
- }
86
+ contentNodeRef.current = node;
87
+ if (node) {
88
+ if (!compact) {
89
+ surfaceCleanupRef.current = connectModalSurface(node);
90
+ }
91
+ const viewport = connectOverlayViewport(node);
92
+ const keyboard = connectKeyboardScroll(node);
93
+ const pointerEvents = connectBodyPointerEventsFix();
94
+ overlayCleanupRef.current = () => {
95
+ viewport();
96
+ keyboard();
97
+ pointerEvents();
98
+ };
99
+ }
46
100
 
47
- assignRef(ref, node);
48
- },
49
- [ref],
50
- );
101
+ assignRef(ref, node);
102
+ },
103
+ [ref, compact],
104
+ );
51
105
 
52
- React.useLayoutEffect(() => {
53
- return () => {
54
- surfaceCleanupRef.current?.();
55
- surfaceCleanupRef.current = null;
106
+ React.useLayoutEffect(() => {
107
+ return () => {
108
+ surfaceCleanupRef.current?.();
109
+ surfaceCleanupRef.current = null;
110
+ overlayCleanupRef.current?.();
111
+ overlayCleanupRef.current = null;
112
+ };
113
+ }, []);
114
+
115
+ const handleOpenAutoFocus = (event: Event) => {
116
+ onOpenAutoFocus?.(event);
117
+ if (!isAlert || event.defaultPrevented) return;
118
+ event.preventDefault();
119
+ const root = contentNodeRef.current;
120
+ const cancel = root?.querySelector<HTMLElement>("[data-alert-cancel]");
121
+ (cancel ?? root)?.focus();
56
122
  };
57
- }, []);
58
-
59
- return (
60
- <DialogPortal>
61
- <DialogOverlay />
62
- <DialogPrimitive.Content
63
- ref={mergedRef}
64
- className={cn(
65
- "glass-panel-strong t-modal fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg max-sm:w-[calc(100vw-2rem)] max-h-[90dvh] gap-4 overflow-y-auto rounded-lg p-6 text-overlay-foreground smooth-shadow-ring-xl",
66
- className,
67
- )}
68
- {...props}
69
- >
70
- {children}
71
- {!hideCloseButton && (
72
- <DialogPrimitive.Close className="absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted transition-[background-color,transform] hover:bg-surface-tertiary/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:pointer-events-none before:absolute before:inset-[-4px] before:content-['']">
73
- <IconX className="h-4 w-4" />
74
- <span className="sr-only">Close</span>
75
- </DialogPrimitive.Close>
76
- )}
77
- </DialogPrimitive.Content>
78
- </DialogPortal>
79
- );
80
- });
123
+
124
+ return (
125
+ <DialogPortal>
126
+ <DialogOverlay />
127
+ <DialogPrimitive.Content
128
+ ref={mergedRef}
129
+ {...(role ? { role } : {})}
130
+ {...(missingDescription
131
+ ? { "aria-describedby": generatedDescriptionId }
132
+ : ariaDescribedBy != null
133
+ ? { "aria-describedby": ariaDescribedBy }
134
+ : {})}
135
+ onOpenAutoFocus={handleOpenAutoFocus}
136
+ data-compact={compact ? "true" : undefined}
137
+ className={cn(
138
+ "glass-panel-strong z-50 flex w-full flex-col gap-4 p-6 text-overlay-foreground smooth-shadow-ring-xl",
139
+ compact
140
+ ? cn(
141
+ "overflow-y-auto fixed inset-x-0 top-auto w-full max-w-none rounded-t-2xl rounded-b-none pt-3",
142
+ "max-h-[var(--overlay-vv-height,100dvh)]",
143
+ "bottom-[var(--overlay-vv-bottom,0px)]",
144
+ "pb-[max(1.5rem,env(safe-area-inset-bottom,0px))]",
145
+ "data-[compact=true]:[&_button]:min-h-11 data-[compact=true]:[&_[data-slot=dialog-footer]]:flex-col-reverse data-[compact=true]:[&_[data-slot=dialog-footer]]:sm:flex-col-reverse",
146
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
147
+ "motion-reduce:animate-none motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none",
148
+ )
149
+ : "t-modal fixed left-1/2 top-1/2 max-h-[90dvh] max-w-lg max-sm:w-[calc(100vw-2rem)] overflow-y-auto rounded-lg",
150
+ className,
151
+ )}
152
+ {...props}
153
+ >
154
+ {compact ? (
155
+ <div
156
+ aria-hidden
157
+ className="mx-auto mb-1 flex min-h-11 w-full shrink-0 items-center justify-center"
158
+ >
159
+ <span className="h-1 w-12 rounded-full bg-surface-tertiary" />
160
+ </div>
161
+ ) : null}
162
+ {missingDescription ? (
163
+ <DialogPrimitive.Description
164
+ id={generatedDescriptionId}
165
+ className="sr-only"
166
+ >
167
+ {DEFAULT_ALERT_DESCRIPTION}
168
+ </DialogPrimitive.Description>
169
+ ) : null}
170
+ {children}
171
+ {!hideCloseButton && (
172
+ <DialogPrimitive.Close
173
+ className={cn(
174
+ "absolute right-2 top-2 inline-flex h-11 w-11 items-center justify-center rounded-lg text-overlay-foreground/70 transition-[background-color,transform] hover:bg-surface-tertiary/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:pointer-events-none",
175
+ "motion-reduce:transition-none",
176
+ )}
177
+ >
178
+ <IconX className="h-4 w-4" />
179
+ <span className="sr-only">Close</span>
180
+ </DialogPrimitive.Close>
181
+ )}
182
+ </DialogPrimitive.Content>
183
+ </DialogPortal>
184
+ );
185
+ },
186
+ );
81
187
  DialogContent.displayName = DialogPrimitive.Content.displayName;
82
188
 
83
189
  const DialogHeader = ({
@@ -85,19 +191,36 @@ const DialogHeader = ({
85
191
  ...props
86
192
  }: React.HTMLAttributes<HTMLDivElement>) => (
87
193
  <div
88
- className={cn("flex flex-col gap-1.5 text-center sm:text-left", className)}
194
+ className={cn("flex flex-col gap-1.5 pr-10 text-left", className)}
89
195
  {...props}
90
196
  />
91
197
  );
92
198
  DialogHeader.displayName = "DialogHeader";
93
199
 
200
+ const DialogBody = ({
201
+ className,
202
+ ...props
203
+ }: React.HTMLAttributes<HTMLDivElement>) => (
204
+ <div
205
+ className={cn(
206
+ "min-h-11 flex-1 overflow-y-auto overscroll-contain",
207
+ className,
208
+ )}
209
+ {...props}
210
+ />
211
+ );
212
+ DialogBody.displayName = "DialogBody";
213
+
94
214
  const DialogFooter = ({
95
215
  className,
96
216
  ...props
97
217
  }: React.HTMLAttributes<HTMLDivElement>) => (
98
218
  <div
219
+ data-slot="dialog-footer"
99
220
  className={cn(
100
- "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
221
+ "mt-auto flex flex-col-reverse gap-2 bg-overlay pt-2 sm:flex-row sm:justify-end",
222
+ "pb-[max(0px,env(safe-area-inset-bottom,0px))]",
223
+ "[&_button]:max-sm:min-h-11",
101
224
  className,
102
225
  )}
103
226
  {...props}
@@ -126,7 +249,11 @@ const DialogDescription = React.forwardRef<
126
249
  >(({ className, ...props }, ref) => (
127
250
  <DialogPrimitive.Description
128
251
  ref={ref}
129
- className={cn("text-sm leading-relaxed text-muted", className)}
252
+ className={cn(
253
+ // `--muted` is ~3.5:1 on overlay; mix toward overlay-foreground for 4.5:1.
254
+ "text-sm leading-relaxed text-[color-mix(in_oklch,var(--overlay-foreground)_72%,var(--overlay))]",
255
+ className,
256
+ )}
130
257
  {...props}
131
258
  />
132
259
  ));
@@ -143,6 +270,7 @@ export {
143
270
  DialogContent,
144
271
  DialogRawContent,
145
272
  DialogHeader,
273
+ DialogBody,
146
274
  DialogFooter,
147
275
  DialogTitle,
148
276
  DialogDescription,
@@ -1,3 +1,5 @@
1
+ import { prefersReducedMotion } from "./overlay-a11y";
2
+
1
3
  const closeTimers = new WeakMap<HTMLElement, ReturnType<typeof setTimeout>>();
2
4
 
3
5
  function readModalCloseMs(): number {
@@ -27,6 +29,10 @@ function openModalSurface(el: HTMLElement): void {
27
29
  function closeModalSurface(el: HTMLElement): void {
28
30
  clearCloseTimer(el);
29
31
  el.classList.remove("is-open");
32
+ if (prefersReducedMotion()) {
33
+ el.classList.remove("is-closing");
34
+ return;
35
+ }
30
36
  el.classList.add("is-closing");
31
37
  const closeMs = readModalCloseMs();
32
38
  const timer = setTimeout(() => {
@@ -40,6 +46,10 @@ function closeModalSurface(el: HTMLElement): void {
40
46
  function primeModalSurface(el: HTMLElement): void {
41
47
  clearCloseTimer(el);
42
48
  el.classList.remove("is-open", "is-closing");
49
+ if (prefersReducedMotion()) {
50
+ openModalSurface(el);
51
+ return;
52
+ }
43
53
  requestAnimationFrame(() => {
44
54
  openModalSurface(el);
45
55
  });
@@ -0,0 +1,135 @@
1
+ import * as React from "react";
2
+
3
+ /** Phones, plus short landscape viewports such as 844×390. */
4
+ export const COMPACT_OVERLAY_QUERY = "(max-width: 639px), (max-height: 500px)";
5
+
6
+ export function prefersReducedMotion(): boolean {
7
+ return (
8
+ typeof window !== "undefined" &&
9
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
10
+ );
11
+ }
12
+
13
+ export function useCompactOverlay(): boolean {
14
+ const [compact, setCompact] = React.useState(false);
15
+
16
+ React.useLayoutEffect(() => {
17
+ const mql = window.matchMedia(COMPACT_OVERLAY_QUERY);
18
+ const sync = () => setCompact(mql.matches);
19
+ sync();
20
+ mql.addEventListener("change", sync);
21
+ return () => mql.removeEventListener("change", sync);
22
+ }, []);
23
+
24
+ return compact;
25
+ }
26
+
27
+ /** Radix dropdowns leave `pointer-events: none` on body when a dialog opens. */
28
+ export function restoreBodyPointerEvents(): void {
29
+ if (typeof document === "undefined") return;
30
+ if (document.body.style.pointerEvents === "none") {
31
+ document.body.style.removeProperty("pointer-events");
32
+ }
33
+ }
34
+
35
+ export function connectBodyPointerEventsFix(): () => void {
36
+ restoreBodyPointerEvents();
37
+ const observer = new MutationObserver(() => restoreBodyPointerEvents());
38
+ observer.observe(document.body, {
39
+ attributes: true,
40
+ attributeFilter: ["style"],
41
+ });
42
+ const t0 = window.setTimeout(restoreBodyPointerEvents, 0);
43
+ const t1 = window.setTimeout(restoreBodyPointerEvents, 50);
44
+ return () => {
45
+ observer.disconnect();
46
+ window.clearTimeout(t0);
47
+ window.clearTimeout(t1);
48
+ };
49
+ }
50
+
51
+ export function connectOverlayViewport(el: HTMLElement): () => void {
52
+ const apply = () => {
53
+ const vv = window.visualViewport;
54
+ const height = vv?.height ?? window.innerHeight;
55
+ const offsetTop = vv?.offsetTop ?? 0;
56
+ const bottom = Math.max(0, window.innerHeight - height - offsetTop);
57
+ el.style.setProperty("--overlay-vv-height", `${height}px`);
58
+ el.style.setProperty("--overlay-vv-bottom", `${bottom}px`);
59
+ if (el.getAttribute("data-compact") === "true") {
60
+ el.style.height = `${Math.min(el.scrollHeight, height)}px`;
61
+ } else {
62
+ el.style.removeProperty("height");
63
+ }
64
+ };
65
+
66
+ apply();
67
+ const vv = window.visualViewport;
68
+ vv?.addEventListener("resize", apply);
69
+ vv?.addEventListener("scroll", apply);
70
+ window.addEventListener("resize", apply);
71
+ return () => {
72
+ vv?.removeEventListener("resize", apply);
73
+ vv?.removeEventListener("scroll", apply);
74
+ window.removeEventListener("resize", apply);
75
+ el.style.removeProperty("--overlay-vv-height");
76
+ el.style.removeProperty("--overlay-vv-bottom");
77
+ };
78
+ }
79
+
80
+ function isEditable(el: EventTarget | null): el is HTMLElement {
81
+ if (!(el instanceof HTMLElement)) return false;
82
+ const tag = el.tagName;
83
+ return (
84
+ tag === "INPUT" ||
85
+ tag === "TEXTAREA" ||
86
+ tag === "SELECT" ||
87
+ el.isContentEditable
88
+ );
89
+ }
90
+
91
+ export function connectKeyboardScroll(root: HTMLElement): () => void {
92
+ const scrollFocused = () => {
93
+ const run = () => {
94
+ const target = document.activeElement;
95
+ if (!isEditable(target) || !root.contains(target)) return;
96
+ target.scrollIntoView({
97
+ block: "center",
98
+ behavior: prefersReducedMotion() ? "auto" : "smooth",
99
+ });
100
+ };
101
+ requestAnimationFrame(() => requestAnimationFrame(run));
102
+ };
103
+
104
+ const onFocusIn = (event: FocusEvent) => {
105
+ if (!isEditable(event.target)) return;
106
+ requestAnimationFrame(scrollFocused);
107
+ };
108
+
109
+ root.addEventListener("focusin", onFocusIn);
110
+ window.visualViewport?.addEventListener("resize", scrollFocused);
111
+ window.addEventListener("resize", scrollFocused);
112
+ return () => {
113
+ root.removeEventListener("focusin", onFocusIn);
114
+ window.visualViewport?.removeEventListener("resize", scrollFocused);
115
+ window.removeEventListener("resize", scrollFocused);
116
+ };
117
+ }
118
+
119
+ export function treeHasDialogDescription(
120
+ node: React.ReactNode,
121
+ descriptionType: React.ElementType,
122
+ ): boolean {
123
+ return React.Children.toArray(node).some((child) => {
124
+ if (!React.isValidElement(child)) return false;
125
+ const type = child.type;
126
+ if (type === descriptionType) return true;
127
+ const displayName =
128
+ typeof type === "object" && type !== null
129
+ ? (type as { displayName?: string }).displayName
130
+ : undefined;
131
+ if (displayName === "DialogDescription") return true;
132
+ const props = child.props as { children?: React.ReactNode };
133
+ return treeHasDialogDescription(props.children, descriptionType);
134
+ });
135
+ }
package/src/ui/sheet.tsx CHANGED
@@ -3,6 +3,12 @@
3
3
  import * as React from "react";
4
4
  import { Drawer as DrawerPrimitive } from "vaul";
5
5
  import { cn } from "../utils/cn";
6
+ import { assignRef } from "../utils/ref";
7
+ import {
8
+ connectBodyPointerEventsFix,
9
+ connectKeyboardScroll,
10
+ connectOverlayViewport,
11
+ } from "./overlay-a11y";
6
12
 
7
13
  type SheetSide = "top" | "bottom" | "left" | "right";
8
14
 
@@ -33,6 +39,9 @@ const SheetHandle = DrawerPrimitive.Handle;
33
39
  const SheetTitle = DrawerPrimitive.Title;
34
40
  const SheetDescription = DrawerPrimitive.Description;
35
41
 
42
+ const overlayMotionClass =
43
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 motion-reduce:animate-none motion-reduce:data-[state=open]:animate-none motion-reduce:data-[state=closed]:animate-none";
44
+
36
45
  const SheetOverlay = React.forwardRef<
37
46
  React.ComponentRef<typeof DrawerPrimitive.Overlay>,
38
47
  React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
@@ -40,7 +49,8 @@ const SheetOverlay = React.forwardRef<
40
49
  <DrawerPrimitive.Overlay
41
50
  ref={ref}
42
51
  className={cn(
43
- "fixed inset-0 z-50 bg-backdrop backdrop-blur-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
52
+ "fixed inset-0 z-50 bg-backdrop backdrop-blur-md",
53
+ overlayMotionClass,
44
54
  className,
45
55
  )}
46
56
  {...props}
@@ -49,8 +59,9 @@ const SheetOverlay = React.forwardRef<
49
59
  SheetOverlay.displayName = "SheetOverlay";
50
60
 
51
61
  const sheetSideClass: Record<SheetSide, string> = {
52
- bottom: "inset-x-0 bottom-0 mt-24 flex h-auto flex-col rounded-t-2xl",
53
- top: "inset-x-0 top-0 mb-24 flex h-auto flex-col rounded-b-2xl",
62
+ bottom:
63
+ "inset-x-0 bottom-[var(--overlay-vv-bottom,0px)] mt-24 flex max-h-[min(96dvh,calc(var(--overlay-vv-height,100dvh)-1.5rem))] h-auto flex-col rounded-t-2xl pb-[max(0.75rem,env(safe-area-inset-bottom,0px))]",
64
+ top: "inset-x-0 top-0 mb-24 flex h-auto max-h-[var(--overlay-vv-height,100dvh)] flex-col rounded-b-2xl pt-[max(0.75rem,env(safe-area-inset-top,0px))]",
54
65
  left: "inset-y-2 left-2 right-auto flex h-auto w-80 max-w-[88vw] flex-col rounded-2xl",
55
66
  right:
56
67
  "inset-y-2 left-auto right-2 flex h-auto w-80 max-w-[88vw] flex-col rounded-2xl",
@@ -65,20 +76,50 @@ const SheetContent = React.forwardRef<
65
76
  >(({ className, children, side: sideProp, ...props }, ref) => {
66
77
  const direction = React.useContext(SheetDirectionContext);
67
78
  const side = sideProp ?? direction;
79
+ const overlayCleanupRef = React.useRef<(() => void) | null>(null);
80
+
81
+ const mergedRef = React.useCallback(
82
+ (node: HTMLDivElement | null) => {
83
+ overlayCleanupRef.current?.();
84
+ overlayCleanupRef.current = null;
85
+ if (node) {
86
+ const viewport = connectOverlayViewport(node);
87
+ const keyboard = connectKeyboardScroll(node);
88
+ const pointerEvents = connectBodyPointerEventsFix();
89
+ overlayCleanupRef.current = () => {
90
+ viewport();
91
+ keyboard();
92
+ pointerEvents();
93
+ };
94
+ }
95
+ assignRef(ref, node);
96
+ },
97
+ [ref],
98
+ );
99
+
100
+ React.useLayoutEffect(() => {
101
+ return () => {
102
+ overlayCleanupRef.current?.();
103
+ overlayCleanupRef.current = null;
104
+ };
105
+ }, []);
106
+
68
107
  return (
69
108
  <SheetPortal>
70
109
  <SheetOverlay />
71
110
  <DrawerPrimitive.Content
72
- ref={ref}
111
+ ref={mergedRef}
73
112
  className={cn(
74
- "fixed z-50 bg-overlay/95 text-overlay-foreground smooth-shadow-ring-xl outline-hidden backdrop-blur-xl",
113
+ "fixed z-50 overflow-y-auto bg-overlay/95 text-overlay-foreground smooth-shadow-ring-xl outline-hidden backdrop-blur-xl motion-reduce:transition-none",
75
114
  sheetSideClass[side],
76
115
  className,
77
116
  )}
78
117
  {...props}
79
118
  >
80
119
  {side === "bottom" || side === "top" ? (
81
- <SheetHandle className="mx-auto mt-3 mb-1 h-1 w-12 shrink-0 rounded-full bg-surface-tertiary" />
120
+ <div className="flex min-h-11 shrink-0 items-center justify-center">
121
+ <SheetHandle className="relative mx-auto h-1 w-12 shrink-0 rounded-full bg-surface-tertiary after:absolute after:inset-x-[-16px] after:inset-y-[-20px] after:content-['']" />
122
+ </div>
82
123
  ) : null}
83
124
  {children}
84
125
  </DrawerPrimitive.Content>
@@ -87,6 +128,30 @@ const SheetContent = React.forwardRef<
87
128
  });
88
129
  SheetContent.displayName = "SheetContent";
89
130
 
131
+ const SheetHeader = ({
132
+ className,
133
+ ...props
134
+ }: React.HTMLAttributes<HTMLDivElement>) => (
135
+ <div className={cn("flex flex-col gap-1.5 p-4 text-left", className)} {...props} />
136
+ );
137
+ SheetHeader.displayName = "SheetHeader";
138
+
139
+ const SheetFooter = ({
140
+ className,
141
+ ...props
142
+ }: React.HTMLAttributes<HTMLDivElement>) => (
143
+ <div
144
+ className={cn(
145
+ "sticky bottom-0 mt-auto flex flex-col-reverse gap-2 bg-overlay/95 p-4 sm:flex-row sm:justify-end",
146
+ "pb-[max(1rem,env(safe-area-inset-bottom,0px))]",
147
+ "[&_button]:max-sm:min-h-11",
148
+ className,
149
+ )}
150
+ {...props}
151
+ />
152
+ );
153
+ SheetFooter.displayName = "SheetFooter";
154
+
90
155
  export {
91
156
  Sheet,
92
157
  SheetPortal,
@@ -97,4 +162,6 @@ export {
97
162
  SheetHandle,
98
163
  SheetTitle,
99
164
  SheetDescription,
165
+ SheetHeader,
166
+ SheetFooter,
100
167
  };