1"use client";
2
3import type { DesktopPreviewColorScheme } from "@t3tools/contracts";
4import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react";
5
6import { Button } from "~/components/ui/button";
7import {
8 Menu,
9 MenuItem,
10 MenuPopup,
11 MenuRadioGroup,
12 MenuRadioItem,
13 MenuGroup,
14 MenuGroupLabel,
15 MenuSeparator,
16 MenuSub,
17 MenuSubPopup,
18 MenuSubTrigger,
19 MenuTrigger,
20} from "~/components/ui/menu";
21import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
22
23const COLOR_SCHEME_OPTIONS: ReadonlyArray<{
24 value: DesktopPreviewColorScheme;
25 label: string;
26}> = [
27 { value: "system", label: "System" },
28 { value: "light", label: "Light" },
29 { value: "dark", label: "Dark" },
30];
31
32/**
33 * What the menu's items do for the active tab. Desktop tabs run them through
34 * the desktop bridge; server tabs, whether streamed or rendered natively by
35 * the desktop app, run them through the environment server.
36 */
37export interface PreviewMoreMenuActions {
38 readonly hardReload: () => void;
39 readonly setColorScheme: (colorScheme: DesktopPreviewColorScheme) => void;
40 readonly zoomIn: () => void;
41 readonly zoomOut: () => void;
42 readonly resetZoom: () => void;
43 readonly clearCookies: () => void;
44 readonly clearCache: () => void;
45 /** Absent where there is no inspector to open, such as a streamed server tab. */
46 readonly openDevTools?: () => void;
47 /** Absent where the tab cannot get its own window. */
48 readonly toggleNativePictureInPicture?: () => void;
49}
50
51interface Props {
52 /** False until the tab can take these actions, such as before its page attaches. */
53 enabled: boolean;
54 actions: PreviewMoreMenuActions;
55 /** Current zoom factor as a number (1.0 = 100%). */
56 zoomFactor: number;
57 /** Emulated `prefers-color-scheme` for the page. */
58 colorScheme: DesktopPreviewColorScheme;
59 /** Fixed viewport modes expose the device toolbar and resize rails. */
60 deviceToolbarVisible: boolean;
61 /** Switches between fill-panel mode and a fixed responsive viewport. */
62 onToggleDeviceToolbar: () => void;
63 /** Whether the separate native always-on-top preview window is open. */
64 nativePictureInPicture: boolean;
65 /** Profile display name, shown so the menu says which data is being cleared. */
66 profileName: string | undefined;
67 /** Reopens the tab in the other browser it can run in. */
68 move?: { readonly label: string; readonly onMove: () => void };
69}
70
71const MenuTriggerButton = () => (
72 <Tooltip>
73 <TooltipTrigger
74 render={
75 <MenuTrigger
76 render={<Button variant="ghost" size="icon-xs" type="button" aria-label="Preview menu" />}
77 />
78 }
79 >
80 <MoreVertical />
81 </TooltipTrigger>
82 <TooltipPopup>More</TooltipPopup>
83 </Tooltip>
84);
85
86/** Three-dot menu in the chrome row; the same items for every kind of tab. */
87export function PreviewMoreMenu({
88 enabled,
89 actions,
90 zoomFactor,
91 colorScheme,
92 deviceToolbarVisible,
93 onToggleDeviceToolbar,
94 nativePictureInPicture,
95 profileName,
96 move,
97}: Props) {
98 const zoomLabel = `${Math.round(zoomFactor * 100)}%`;
99 const disabled = !enabled;
100 return (
101 <Menu>
102 <MenuTriggerButton />
103 <MenuPopup align="end" sideOffset={6}>
104 <MenuItem onClick={actions.hardReload} disabled={disabled}>
105 Hard reload
106 </MenuItem>
107 {actions.openDevTools ? (
108 <MenuItem onClick={actions.openDevTools} disabled={disabled}>
109 Open DevTools
110 </MenuItem>
111 ) : null}
112 {actions.toggleNativePictureInPicture ? (
113 <MenuItem onClick={actions.toggleNativePictureInPicture} disabled={disabled}>
114 {nativePictureInPicture
115 ? "Close separate preview window"
116 : "Open separate preview window"}
117 </MenuItem>
118 ) : null}
119 {move ? <MenuItem onClick={move.onMove}>{move.label}</MenuItem> : null}
120 <MenuItem onClick={onToggleDeviceToolbar} disabled={disabled}>
121 {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"}
122 </MenuItem>
123 <MenuSub>
124 <MenuSubTrigger disabled={disabled}>Appearance</MenuSubTrigger>
125 <MenuSubPopup>
126 <MenuRadioGroup
127 value={colorScheme}
128 onValueChange={(value) => actions.setColorScheme(value as DesktopPreviewColorScheme)}
129 >
130 {COLOR_SCHEME_OPTIONS.map((option) => (
131 <MenuRadioItem key={option.value} value={option.value}>
132 {option.label}
133 </MenuRadioItem>
134 ))}
135 </MenuRadioGroup>
136 </MenuSubPopup>
137 </MenuSub>
138 <MenuSeparator />
139 {/*
140 Zoom row: label + inline control cluster. `closeOnClick=false`
141 keeps the menu open while the user clicks the +/− buttons.
142 */}
143 <MenuItem
144 closeOnClick={false}
145 onClick={(event: React.MouseEvent) => event.preventDefault()}
146 className="justify-between"
147 disabled={disabled}
148 >
149 <span>Zoom</span>
150 <span className="flex items-center gap-1">
151 <Button
152 variant="outline"
153 size="icon-xs"
154 type="button"
155 onClick={actions.zoomOut}
156 aria-label="Zoom out"
157 disabled={disabled}
158 >
159 <Minus />
160 </Button>
161 <span className="min-w-12 text-center text-xs tabular-nums text-muted-foreground">
162 {zoomLabel}
163 </span>
164 <Button
165 variant="outline"
166 size="icon-xs"
167 type="button"
168 onClick={actions.zoomIn}
169 aria-label="Zoom in"
170 disabled={disabled}
171 >
172 <PlusIcon />
173 </Button>
174 <Button
175 variant="ghost"
176 size="icon-xs"
177 type="button"
178 onClick={actions.resetZoom}
179 aria-label="Reset zoom"
180 disabled={disabled}
181 >
182 <RotateCcw />
183 </Button>
184 </span>
185 </MenuItem>
186 <MenuSeparator />
187 {/*
188 Grouped so the heading has a `MenuGroup` ancestor — `MenuGroupLabel`
189 reads its context and throws without one. The heading also answers
190 which profile the tab is in, which is otherwise invisible: it is fixed
191 at open and nothing else in the chrome shows it.
192 */}
193 <MenuGroup>
194 {profileName ? (
195 // Truncation needs a block box: `text-overflow` on an inline child
196 // never applies and a long name would push the popup past its width.
197 <MenuGroupLabel className="max-w-64">
198 <span className="block truncate">Profile: {profileName}</span>
199 </MenuGroupLabel>
200 ) : null}
201 <MenuItem onClick={actions.clearCookies}>Clear cookies</MenuItem>
202 <MenuItem onClick={actions.clearCache}>Clear cache</MenuItem>
203 </MenuGroup>
204 </MenuPopup>
205 </Menu>
206 );
207}