mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
fix(ui): add standalone PWA browser controls
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
96feaa331a
commit
7ce96e36a0
5 changed files with 216 additions and 0 deletions
|
|
@ -17,6 +17,7 @@ import { ToastViewport } from "./ToastViewport";
|
||||||
import { MobileBottomNav } from "./MobileBottomNav";
|
import { MobileBottomNav } from "./MobileBottomNav";
|
||||||
import { WorktreeBanner } from "./WorktreeBanner";
|
import { WorktreeBanner } from "./WorktreeBanner";
|
||||||
import { DevRestartBanner } from "./DevRestartBanner";
|
import { DevRestartBanner } from "./DevRestartBanner";
|
||||||
|
import { StandaloneBrowserControls } from "./StandaloneBrowserControls";
|
||||||
import { ResizableSidebarPane } from "./ResizableSidebarPane";
|
import { ResizableSidebarPane } from "./ResizableSidebarPane";
|
||||||
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
import { SidebarAccountMenu } from "./SidebarAccountMenu";
|
||||||
import { useDialogActions } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
|
|
@ -424,6 +425,7 @@ export function Layout() {
|
||||||
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
|
isMobile && "sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/85",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<StandaloneBrowserControls mobile={isMobile} />
|
||||||
<BreadcrumbBar />
|
<BreadcrumbBar />
|
||||||
{isMobile && isCompanySettingsRoute ? (
|
{isMobile && isCompanySettingsRoute ? (
|
||||||
<div className="border-b border-border px-4 pb-3">
|
<div className="border-b border-border px-4 pb-3">
|
||||||
|
|
|
||||||
65
ui/src/components/StandaloneBrowserControls.test.tsx
Normal file
65
ui/src/components/StandaloneBrowserControls.test.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { ToastProvider } from "../context/ToastContext";
|
||||||
|
import { StandaloneBrowserControls } from "./StandaloneBrowserControls";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
async function act(callback: () => void | Promise<void>) {
|
||||||
|
let result: void | Promise<void> = undefined;
|
||||||
|
flushSync(() => {
|
||||||
|
result = callback();
|
||||||
|
});
|
||||||
|
await result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushReact() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("StandaloneBrowserControls", () => {
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
Object.defineProperty(navigator, "standalone", { configurable: true, value: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete (navigator as Navigator & { standalone?: boolean }).standalone;
|
||||||
|
container.remove();
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows refresh, share, and open-in-browser controls in mobile standalone mode", async () => {
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<StandaloneBrowserControls mobile />
|
||||||
|
</ToastProvider>
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
expect(container.querySelector('[aria-label="Refresh"]')).not.toBeNull();
|
||||||
|
expect(container.querySelector('[aria-label="Share"]')).not.toBeNull();
|
||||||
|
expect(container.querySelector('[aria-label="Open in Browser"]')).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
103
ui/src/components/StandaloneBrowserControls.tsx
Normal file
103
ui/src/components/StandaloneBrowserControls.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { ExternalLink, RefreshCw, Share2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useOptionalToastActions } from "../context/ToastContext";
|
||||||
|
import { isChromelessDisplayMode } from "../lib/pwa-display-mode";
|
||||||
|
|
||||||
|
function ControlButton({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
onClick: () => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="size-8 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={label}
|
||||||
|
onClick={() => void onClick()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StandaloneBrowserControls({ mobile }: { mobile: boolean }) {
|
||||||
|
const [chromeless, setChromeless] = useState(false);
|
||||||
|
const toastActions = useOptionalToastActions();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mobile || typeof window === "undefined") {
|
||||||
|
setChromeless(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = () => setChromeless(isChromelessDisplayMode());
|
||||||
|
|
||||||
|
update();
|
||||||
|
if (typeof window.matchMedia !== "function") return;
|
||||||
|
|
||||||
|
const media = window.matchMedia("(display-mode: standalone)");
|
||||||
|
if (typeof media.addEventListener === "function") {
|
||||||
|
media.addEventListener("change", update);
|
||||||
|
return () => media.removeEventListener("change", update);
|
||||||
|
}
|
||||||
|
|
||||||
|
media.addListener(update);
|
||||||
|
return () => media.removeListener(update);
|
||||||
|
}, [mobile]);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const share = useCallback(async () => {
|
||||||
|
const url = window.location.href;
|
||||||
|
try {
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({ title: document.title || "Paperclip", url });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
toastActions?.pushToast({ title: "Link copied", tone: "success" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toastActions?.pushToast({ title: "Sharing is unavailable", body: url, tone: "warn" });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||||
|
toastActions?.pushToast({ title: "Share failed", body: "Try opening the page in your browser.", tone: "error" });
|
||||||
|
}
|
||||||
|
}, [toastActions]);
|
||||||
|
|
||||||
|
const openInBrowser = useCallback(() => {
|
||||||
|
window.open(window.location.href, "_blank", "noopener,noreferrer");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mobile || !chromeless) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-10 items-center justify-end gap-1 border-b border-border bg-background/95 px-3 backdrop-blur supports-[backdrop-filter]:bg-background/85">
|
||||||
|
<ControlButton label="Refresh" onClick={refresh}>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</ControlButton>
|
||||||
|
<ControlButton label="Share" onClick={share}>
|
||||||
|
<Share2 className="h-4 w-4" />
|
||||||
|
</ControlButton>
|
||||||
|
<ControlButton label="Open in Browser" onClick={openInBrowser}>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</ControlButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
ui/src/lib/pwa-display-mode.test.ts
Normal file
20
ui/src/lib/pwa-display-mode.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { isChromelessDisplayMode } from "./pwa-display-mode";
|
||||||
|
|
||||||
|
function matchMode(activeMode: string | null) {
|
||||||
|
return (query: string) => ({ matches: query === `(display-mode: ${activeMode})` });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isChromelessDisplayMode", () => {
|
||||||
|
it("detects standalone display mode from media queries", () => {
|
||||||
|
expect(isChromelessDisplayMode(matchMode("standalone"), false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects iOS home-screen standalone launches", () => {
|
||||||
|
expect(isChromelessDisplayMode(matchMode(null), true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores normal browser launches", () => {
|
||||||
|
expect(isChromelessDisplayMode(matchMode("browser"), false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
26
ui/src/lib/pwa-display-mode.ts
Normal file
26
ui/src/lib/pwa-display-mode.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
export const CHROMELESS_DISPLAY_MODES = ["standalone", "fullscreen", "window-controls-overlay"] as const;
|
||||||
|
|
||||||
|
type DisplayMode = (typeof CHROMELESS_DISPLAY_MODES)[number];
|
||||||
|
type MatchDisplayMode = (query: string) => Pick<MediaQueryList, "matches">;
|
||||||
|
|
||||||
|
function displayModeQuery(mode: DisplayMode) {
|
||||||
|
return `(display-mode: ${mode})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultMatchMedia(): MatchDisplayMode | undefined {
|
||||||
|
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return undefined;
|
||||||
|
return window.matchMedia.bind(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isChromelessDisplayMode(
|
||||||
|
matchMedia: MatchDisplayMode | undefined = defaultMatchMedia(),
|
||||||
|
iosStandalone: boolean | undefined =
|
||||||
|
typeof navigator === "undefined"
|
||||||
|
? undefined
|
||||||
|
: (navigator as Navigator & { standalone?: boolean }).standalone,
|
||||||
|
) {
|
||||||
|
if (iosStandalone === true) return true;
|
||||||
|
if (!matchMedia) return false;
|
||||||
|
|
||||||
|
return CHROMELESS_DISPLAY_MODES.some((mode) => matchMedia(displayModeQuery(mode)).matches);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue