Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"types": "tsgo --noEmit --skipLibCheck"
},
"dependencies": {
"@base-ui/react": "1.7.0",
"@chopin/dialect": "workspace:*",
"@chopin/diagrams": "workspace:*",
"@chopin/draft": "workspace:*",
Expand Down
95 changes: 95 additions & 0 deletions apps/web/src/action-sheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from "react";
import { Drawer } from "@base-ui/react/drawer";

import "./phone-sheets.css";

import type { IconProps } from "@chopin/icons";
import type { ReactNode } from "react";

export type ActionSheetItem<Action extends string> = {
action: Action;
label: string;
icon?: (props: IconProps) => ReactNode;
/** Only for what cannot be undone; an archive has Undo, so it reads as ordinary. */
destructive?: boolean;
};

/**
* A phone's menu: a grouped list rising from the bottom edge with a separate Cancel, the way iOS
* presents a choice of actions. It renders the same item list a desktop popover does.
*
* Copying runs on the tap itself so the clipboard keeps the gesture. Every other choice waits
* for the sheet to leave, so focus, dialogs and inline fields never contend with its focus trap.
*/
export function ActionSheet<Action extends string>(
{
immediate = [],
items,
label,
onClose,
onSelect,
title,
}: {
immediate?: Action[];
items: ActionSheetItem<Action>[];
label: string;
onClose: () => void;
onSelect: (action: Action) => void;
title?: string;
},
) {
let [open, setOpen] = useState(false);
let chosen = useRef<Action | undefined>(undefined);
useEffect(() => {
let frame = requestAnimationFrame(() => setOpen(true));
return () => cancelAnimationFrame(frame);
}, []);
let choose = (action: Action) => {
if (immediate.includes(action)) onSelect(action);
else chosen.current = action;
setOpen(false);
};

return (
<Drawer.Root
onOpenChange={setOpen}
onOpenChangeComplete={next => {
if (next) return;
onClose();
// Kept rather than cleared: Base UI may restore focus after this callback.
if (chosen.current) onSelect(chosen.current);
}}
open={open}
>
<Drawer.Portal>
<Drawer.Backdrop className="phone-sheet-backdrop" />
<Drawer.Viewport className="phone-sheet-viewport">
<Drawer.Popup
aria-label={label}
className="action-sheet"
data-action-sheet=""
finalFocus={() => chosen.current === undefined}
>
<Drawer.Title className="sr-only">{label}</Drawer.Title>
<div className="action-sheet-group">
{title && <p className="action-sheet-title">{title}</p>}
{items.map(item => (
<button
className="action-sheet-item"
data-tone={item.destructive ? "danger" : "normal"}
key={item.action}
onClick={() => choose(item.action)}
type="button"
>
{item.icon && <item.icon aria-hidden="true" size={18} />}
{item.label}
</button>
))}
</div>
<Drawer.Close className="action-sheet-cancel">Cancel</Drawer.Close>
</Drawer.Popup>
</Drawer.Viewport>
</Drawer.Portal>
</Drawer.Root>
);
}
8 changes: 6 additions & 2 deletions apps/web/src/copy-link.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import type { NoticeOptions } from "./navigation-notice";

export async function copyLink(href: string, notify: (notice: NoticeOptions) => void) {
/**
* Reports a clipboard write. The caller starts the write itself, inside the tap or click,
* because iOS Safari refuses one that begins after an asynchronous module load.
*/
export async function copyLink(written: Promise<void>, notify: (notice: NoticeOptions) => void) {
try {
await navigator.clipboard.writeText(href);
await written;
notify({ message: "Link copied" });
} catch {
notify({ message: "Could not copy link" });
Expand Down
63 changes: 57 additions & 6 deletions apps/web/src/document-actions-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { createPortal } from "react-dom";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import {
lazy,
Suspense,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { usePhone } from "@chopin/editor/pointer";
import { useTransitionPresence } from "@chopin/editor/transition-presence";
import { ArchiveIcon, ArrowUpIcon, CloseIcon, LinkIcon, PencilIcon } from "@chopin/icons";

Expand All @@ -11,7 +21,14 @@ import { IconButton } from "./icon-button";

import type { IconProps } from "@chopin/icons";
import type * as Api from "./api";
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
import type { ComponentProps, ComponentType, CSSProperties, KeyboardEvent, ReactNode } from "react";

// Once prefetched, the sheet renders directly: lazy() would still suspend its first render.
let actionSheet: typeof import("./action-sheet") | undefined;
export let loadActionSheet = () => import("./action-sheet").then(module => (actionSheet = module));
let LazyActionSheet = lazy(() =>
loadActionSheet().then(module => ({ default: module.ActionSheet }))
);

export type DocumentAction = "copy-link" | "rename" | "archive" | "restore" | "delete";

Expand Down Expand Up @@ -67,7 +84,14 @@ export function DocumentActionsMenu(
let button = useRef<HTMLButtonElement>(null);
let panel = useRef<HTMLDivElement>(null);
let initialItem = useRef(0);
let phone = usePhone();
// Chosen when opened, so a load finishing later never remounts an open sheet.
let sheet = useRef<
ComponentType<ComponentProps<typeof LazyActionSheet>>
>(LazyActionSheet);
let Sheet = sheet.current;
let [open, setOpen] = useState(false);
let [sheetOpen, setSheetOpen] = useState(false);
let [position, setPosition] = useState<CSSProperties>();
let items = documentMenuItems(channel);
let immediately = motionImmediately();
Expand Down Expand Up @@ -181,8 +205,22 @@ export function DocumentActionsMenu(
: (current - 1 + controls.length) % controls.length;
controls[next]?.focus();
};
let toggleMenu = () => open ? closeMenu() : openAt(0);
let toggleMenu = () => {
if (phone) {
sheet.current = actionSheet?.ActionSheet ?? LazyActionSheet;
setSheetOpen(true);
} else if (open) closeMenu();
else openAt(0);
};
// A phone's first tap should open the sheet, not wait for its code.
let preload = phone
? () => {
void loadActionSheet();
void import("./copy-link");
}
: undefined;
let triggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (phone) return;
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
openAt(event.key === "ArrowUp" ? items.length - 1 : 0);
Expand All @@ -194,7 +232,7 @@ export function DocumentActionsMenu(
? (
<IconButton
aria-controls={open ? id : undefined}
aria-expanded={open}
aria-expanded={open || sheetOpen}
aria-haspopup="menu"
aria-label={`Actions for ${channel.title}`}
data-tooltip="Document actions"
Expand All @@ -211,19 +249,32 @@ export function DocumentActionsMenu(
: (
<button
aria-controls={open ? id : undefined}
aria-expanded={open}
aria-haspopup="menu"
aria-expanded={open || sheetOpen}
aria-haspopup={phone ? "dialog" : "menu"}
aria-label={`Actions for ${channel.title}`}
className={className}
data-tooltip="Document actions"
onClick={toggleMenu}
onKeyDown={triggerKeyDown}
onPointerDown={preload}
ref={button}
type="button"
>
{trigger}
</button>
)}
{sheetOpen && (
<Suspense fallback={null}>
<Sheet
immediate={["copy-link"]}
items={items}
label={`Actions for ${channel.title}`}
onClose={() => setSheetOpen(false)}
onSelect={action => onAction(action as DocumentAction)}
title={channel.title}
/>
</Suspense>
)}
{mounted && createPortal(
<div
aria-hidden={active ? undefined : "true"}
Expand Down
74 changes: 74 additions & 0 deletions apps/web/src/documents-sheet-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from "bun:test";

import {
documentGroups,
filterDocumentGroups,
readRecentIds,
recentDocuments,
rememberOpened,
} from "./documents-sheet-model";

import type * as Api from "./api";

describe("documents sheet model", () => {
let channel = (id: string, title: string, updatedAt: string, parentChannelId?: string) =>
({
id,
title,
updatedAt,
parentChannelId,
repositoryId: "R",
repositoryOwner: "o",
repositoryName: "r",
slug: id,
createdBy: "u",
revision: 1,
createdAt: updatedAt,
descriptionRevision: 0,
}) satisfies Api.Channel;

let offline = channel("a", "Offline sync", "2026-10-11T10:00:00Z");
let release = channel("b", "Release checklist", "2026-10-10T10:00:00Z");
let eviction = channel("c", "IndexedDB eviction", "2026-10-09T10:00:00Z", "a");
let groups = documentGroups([offline, release, eviction], false);

it("filters titles case-insensitively and keeps parents for matching children", () => {
expect(filterDocumentGroups(groups, " ")).toBe(groups);
expect(filterDocumentGroups(groups, "RELEASE")).toEqual([{ parent: release, children: [] }]);
expect(filterDocumentGroups(groups, "eviction")).toEqual([
{ parent: offline, children: [eviction] },
]);
expect(filterDocumentGroups(groups, "offline")).toEqual([
{ parent: offline, children: [eviction] },
]);
expect(filterDocumentGroups(groups, "nothing")).toEqual([]);
});

it("remembers opened documents per project and shows two or more besides the current", () => {
let values = new Map<string, string>();
let storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => void values.set(key, value),
};
for (let id of ["a", "b", "a", "c"]) rememberOpened(storage, "U", "R", id);
rememberOpened(storage, "U", "Other", "z");
expect(readRecentIds(storage, "U", "R")).toEqual(["c", "a", "b"]);
expect(readRecentIds(storage, "U", "Other")).toEqual(["z"]);
expect(readRecentIds(storage, "V", "R")).toEqual([]);
let archived = { ...release, archivedAt: "2026-10-11T00:00:00Z" };
expect(recentDocuments(["c", "a", "b"], [offline, archived, eviction], "c")).toEqual([]);
expect(recentDocuments(["c", "a", "b"], [offline, release, eviction], "c")).toEqual([
offline,
release,
]);
expect(recentDocuments(["gone", "a"], [offline], undefined)).toEqual([]);
let broken = {
getItem: () => "{not json",
setItem: () => {
throw new Error("full");
},
};
expect(readRecentIds(broken, "U", "R")).toEqual([]);
expect(() => rememberOpened(broken, "U", "R", "a")).not.toThrow();
});
});
Loading
Loading