@vvedantb/ui 0.1.4 → 0.2.0
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 +24 -0
- package/app/src/auth/useStableAuth.ts +69 -0
- package/app/src/cn.ts +6 -0
- package/app/src/confirm/ConfirmSkipHint.tsx +35 -0
- package/app/src/confirm/confirmSkip.ts +26 -0
- package/app/src/confirm/useAltHeld.ts +128 -0
- package/app/src/confirm/useRequestConfirm.ts +25 -0
- package/app/src/index.ts +43 -0
- package/app/src/settings/SettingsEmptyState.tsx +38 -0
- package/app/src/settings/SettingsField.tsx +42 -0
- package/app/src/settings/SettingsPage.tsx +88 -0
- package/app/src/settings/SettingsSection.tsx +92 -0
- package/app/src/settings/SettingsStack.tsx +17 -0
- package/app/src/settings/SettingsToggleRow.tsx +51 -0
- package/app/src/update/deployFingerprint.ts +27 -0
- package/app/src/update/updateWatcher.ts +100 -0
- package/package.json +65 -9
- package/shell/src/PillTabs.tsx +86 -0
- package/shell/src/SidebarHeader.tsx +21 -0
- package/shell/src/cn.ts +5 -0
- package/shell/src/index.ts +6 -0
- package/src/ai-elements/conversation.tsx +1 -1
- package/src/ai-elements/suggestion.tsx +1 -1
- package/src/index.ts +20 -18
- package/src/ui/{_menu-classes.vibot.ts → _menu-classes.alt.ts} +2 -2
- package/src/ui/_menu-classes.ts +2 -2
- package/src/ui/alert-dialog.tsx +83 -0
- package/src/ui/button.tsx +2 -1
- package/src/ui/confirm-dialog.tsx +61 -0
- package/src/ui/dialog.tsx +174 -46
- package/src/ui/modalTransition.ts +10 -0
- package/src/ui/overlay-a11y.ts +135 -0
- package/src/ui/sheet.tsx +73 -6
- package/tokens/examples/migration.md +303 -0
- package/tokens/src/css/animations.css +55 -0
- package/tokens/src/css/base.css +51 -0
- package/tokens/src/css/dialog.css +13 -0
- package/tokens/src/css/index.css +19 -0
- package/tokens/src/css/modal.css +44 -0
- package/tokens/src/css/scrollbar-thin.css +40 -0
- package/tokens/src/css/tabs-pills.css +58 -0
- package/tokens/src/css/tabs.css +57 -0
- package/tokens/src/css/utilities.css +34 -0
- package/tokens/src/native.ts +39 -0
- package/tokens/src/tailwind-preset.d.mts +25 -0
- package/tokens/src/tailwind-preset.mjs +177 -0
- package/tokens/src/tokens.css +192 -0
- /package/src/motion/{presets.vibot.ts → presets.alt.ts} +0 -0
- /package/src/ui/{accordion.verve.tsx → accordion.alt.tsx} +0 -0
- /package/src/ui/{clear-input.vibot.tsx → clear-input.alt.tsx} +0 -0
- /package/src/ui/{input-group.verve.tsx → input-group.alt.tsx} +0 -0
- /package/src/ui/{scroll-area.verve.tsx → scroll-area.alt.tsx} +0 -0
|
@@ -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
|
|
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
|
-
>(
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
101
|
+
assignRef(ref, node);
|
|
102
|
+
},
|
|
103
|
+
[ref, compact],
|
|
104
|
+
);
|
|
51
105
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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(
|
|
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
|
|
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:
|
|
53
|
-
|
|
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={
|
|
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
|
-
<
|
|
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
|
};
|