1import { XIcon } from "lucide-react";
2import { useRef, useState, type CSSProperties, type ReactNode } from "react";
3
4import { useNativeTitlebarHeight } from "../lib/windowControlsOverlay";
5import { useCloseAppModal } from "./sidebar/mainAppLocation";
6import { useThemeEditorStore } from "./settings/themeEditorStore";
7import { Button } from "./ui/button";
8import { Dialog, DialogClose, DialogPopup } from "./ui/dialog";
9import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
10
11/** Settings and Usage own their shortcuts inside `[data-app-modal]` and return to the opener. */
12export function AppRouteModal({
13 label,
14 children,
15 variant = "app",
16}: {
17 label: string;
18 children: ReactNode;
19 variant?: "app" | "app-panel";
20}) {
21 const closeAppModal = useCloseAppModal();
22 const themeEditorOpen = useThemeEditorStore((store) => store.session !== null);
23 const [open, setOpen] = useState(true);
24 // Text selection that ends on the backdrop is not a dismissal.
25 const backdropPressRef = useRef(false);
26 // Focus the surface, not its first control: no stray focus ring, no phone keyboard.
27 const popupRef = useRef<HTMLDivElement>(null);
28 // Clear the native window controls (macOS traffic lights, the Windows and Linux caption
29 // buttons). Below sm the modal fills the window and leaves that strip empty; up to lg its
30 // 1.5rem inset is shorter than the strip.
31 const nativeTitlebarHeight = useNativeTitlebarHeight();
32
33 return (
34 <Dialog
35 open={open}
36 // The floating editor lives outside this route and needs focus while editing.
37 modal={!themeEditorOpen}
38 // Portaled layers outside this tree (toasts, Clerk, confirm dialogs) are not backdrop presses.
39 disablePointerDismissal
40 onOpenChange={(nextOpen) => {
41 if (nextOpen) return;
42 // Closing unmounts a focused field without a blur, so commit-on-blur rows would
43 // drop what was typed. Blur first so they commit.
44 const focused = document.activeElement;
45 if (focused instanceof HTMLElement && popupRef.current?.contains(focused)) focused.blur();
46 setOpen(false);
47 }}
48 onOpenChangeComplete={(nextOpen) => {
49 if (!nextOpen) void closeAppModal();
50 }}
51 >
52 <DialogPopup
53 variant={variant}
54 showCloseButton={false}
55 bottomStickOnMobile={false}
56 ref={popupRef}
57 initialFocus={popupRef}
58 aria-label={label}
59 data-app-modal=""
60 viewportProps={{
61 style: {
62 "--app-modal-titlebar-height": `${nativeTitlebarHeight}px`,
63 "--app-modal-top-inset": `max(env(safe-area-inset-top), ${nativeTitlebarHeight}px)`,
64 "--app-modal-sm-top": `max(1.5rem, ${nativeTitlebarHeight}px)`,
65 } as CSSProperties,
66 onPointerDown: (event) => {
67 backdropPressRef.current = event.target === event.currentTarget;
68 },
69 onClick: (event) => {
70 if (backdropPressRef.current && event.target === event.currentTarget) setOpen(false);
71 backdropPressRef.current = false;
72 },
73 }}
74 >
75 {/* The emptied strip still moves the window, as the titlebar it replaces does. */}
76 <div
77 aria-hidden
78 className="absolute inset-x-0 top-0 hidden h-(--app-modal-titlebar-height) [-webkit-app-region:drag] max-sm:block"
79 />
80 {children}
81 </DialogPopup>
82 </Dialog>
83 );
84}
85
86export function AppRouteModalClose() {
87 return (
88 <Tooltip>
89 <TooltipTrigger
90 render={
91 <DialogClose aria-label="Close" render={<Button size="icon-sm" variant="ghost" />} />
92 }
93 >
94 <XIcon />
95 </TooltipTrigger>
96 <TooltipPopup side="bottom">Close (Esc)</TooltipPopup>
97 </Tooltip>
98 );
99}