Skip to content
Source
Sign inSign up

AppRouteModal.tsx

103 lines4.5 KB

1import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
2import { XIcon } from "lucide-react";
3import { useRef, useState, type CSSProperties, type ReactNode } from "react";
4​
5import { useNativeTitlebarHeight } from "../lib/windowControlsOverlay";
6import { useCloseAppModal } from "./sidebar/mainAppLocation";
7import { Button } from "./ui/button";
8import { DialogBackdrop } from "./ui/dialog";
9import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
10​
11/**
12 * Large modal for the Settings and Usage routes. Esc, the close button, or a press on the
13 * backdrop returns to the page that opened it. Keyboard guards that skip dialogs exempt
14 * `[data-app-modal]`, because these pages own their shortcuts.
15 */
16export function AppRouteModal({ label, children }: { label: string; children: ReactNode }) {
17 const closeAppModal = useCloseAppModal();
18 const [open, setOpen] = useState(true);
19 // Text selection that ends on the backdrop is not a dismissal.
20 const backdropPressRef = useRef(false);
21 // Focus the surface, not its first control: no stray focus ring, no phone keyboard.
22 // On close, leave focus alone; returning it to the opener paints a focus ring.
23 const popupRef = useRef<HTMLDivElement>(null);
24 // Clear the native window controls (macOS traffic lights, the Windows and Linux caption
25 // buttons). Below sm the modal fills the window and leaves that strip empty; up to lg its
26 // 1.5rem inset is shorter than the strip.
27 const nativeTitlebarHeight = useNativeTitlebarHeight();
28​
29 return (
30 <DialogPrimitive.Root
31 open={open}
32 // Portaled layers outside this tree (toasts, Clerk, confirm dialogs) are not backdrop presses.
33 disablePointerDismissal
34 onOpenChange={(nextOpen) => {
35 if (nextOpen) return;
36 // Closing unmounts a focused field without a blur, so commit-on-blur rows would
37 // drop what was typed. Blur first so they commit.
38 const focused = document.activeElement;
39 if (focused instanceof HTMLElement && popupRef.current?.contains(focused)) focused.blur();
40 setOpen(false);
41 }}
42 onOpenChangeComplete={(nextOpen) => {
43 if (!nextOpen) void closeAppModal();
44 }}
45 >
46 <DialogPrimitive.Portal>
47 <DialogBackdrop />
48 <DialogPrimitive.Viewport
49 style={
50 {
51 "--app-modal-titlebar-height": `${nativeTitlebarHeight}px`,
52 "--app-modal-top-inset": `max(env(safe-area-inset-top), ${nativeTitlebarHeight}px)`,
53 "--app-modal-sm-top": `max(1.5rem, ${nativeTitlebarHeight}px)`,
54 } as CSSProperties
55 }
56 className="fixed inset-0 z-50 flex items-center justify-center sm:p-6 sm:pt-(--app-modal-sm-top) lg:p-10"
57 onPointerDown={(event) => {
58 backdropPressRef.current = event.target === event.currentTarget;
59 }}
60 onClick={(event) => {
61 if (backdropPressRef.current && event.target === event.currentTarget) setOpen(false);
62 backdropPressRef.current = false;
63 }}
64 >
65 <DialogPrimitive.Popup
66 ref={popupRef}
67 initialFocus={popupRef}
68 finalFocus={false}
69 aria-label={label}
70 data-slot="dialog-popup"
71 data-app-modal=""
72 className="relative flex size-full min-h-0 min-w-0 flex-col overflow-hidden bg-background max-sm:pt-(--app-modal-top-inset) max-sm:pb-safe text-foreground outline-none transition-[opacity,scale] duration-150 ease-out [-webkit-app-region:no-drag] data-ending-style:scale-[0.985] data-ending-style:opacity-0 data-starting-style:scale-[0.985] data-starting-style:opacity-0 sm:max-h-[60rem] sm:max-w-[80rem] sm:rounded-2xl sm:border sm:shadow-2xl/40"
73 >
74 {/* The emptied strip still moves the window, as the titlebar it replaces does. */}
75 <div
76 aria-hidden
77 className="absolute inset-x-0 top-0 hidden h-(--app-modal-titlebar-height) [-webkit-app-region:drag] max-sm:block"
78 />
79 {children}
80 </DialogPrimitive.Popup>
81 </DialogPrimitive.Viewport>
82 </DialogPrimitive.Portal>
83 </DialogPrimitive.Root>
84 );
86​
87export function AppRouteModalClose() {
88 return (
89 <Tooltip>
90 <TooltipTrigger
91 render={
92 <DialogPrimitive.Close
93 aria-label="Close"
94 render={<Button size="icon-sm" variant="ghost" />}
95 />
96 }
97 >
98 <XIcon />
99 </TooltipTrigger>
100 <TooltipPopup side="bottom">Close (Esc)</TooltipPopup>
101 </Tooltip>
102 );