Reading pane layouts: Side, Bottom (under the list) and Off (single panel)
Choose in Settings > Appearance, or the list's ⋯ menu > Mailbox display (a popover with three preview cards; picking one applies it and closes it). Stored per device, like the theme; phones keep their own single panel. - Side: the existing three columns. - Bottom: the list above, the reading pane below, 45/55 until the 3px divider is dragged (height remembered). The pane's top bar takes the list filter bar's colour; the empty-pane picture shrinks to fit. - Off: one panel. The list, until a conversation opens in its place with a Back button; bulk actions move to a bar above the list. This reuses the phone's list/pane switching with desktop styling. - In Bottom and Off the side-panel toggle sits beside the search box (Off also keeps it in the conversation toolbar, since the list is hidden there). - An expanded composer covers the reading pane in every arrangement: the lower pane under the list, the whole panel with the pane off. Resizer gains a horizontal orientation; layoutDock takes the pane height. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,24 @@
|
||||
// A vertical drag handle for adjusting the width of the panel to its left.
|
||||
export function Resizer({ onDelta }: { onDelta: (deltaX: number) => void }) {
|
||||
// A drag handle between two panels. Vertical (the default) sits between panels
|
||||
// side by side and reports horizontal movement; horizontal sits between panels
|
||||
// stacked one above the other (the "reading pane at the bottom" layout) and
|
||||
// reports vertical movement.
|
||||
export function Resizer({
|
||||
onDelta,
|
||||
orientation = "vertical",
|
||||
}: {
|
||||
onDelta: (delta: number) => void;
|
||||
orientation?: "vertical" | "horizontal";
|
||||
}) {
|
||||
const vertical = orientation === "vertical";
|
||||
const cursor = vertical ? "col-resize" : "row-resize";
|
||||
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
let lastX = e.clientX;
|
||||
let last = vertical ? e.clientX : e.clientY;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
onDelta(ev.clientX - lastX);
|
||||
lastX = ev.clientX;
|
||||
const now = vertical ? ev.clientX : ev.clientY;
|
||||
onDelta(now - last);
|
||||
last = now;
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
@@ -16,9 +29,27 @@ export function Resizer({ onDelta }: { onDelta: (deltaX: number) => void }) {
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.cursor = cursor;
|
||||
};
|
||||
|
||||
if (!vertical) {
|
||||
return (
|
||||
// The divider itself: a solid 3px line between the list and the reading
|
||||
// pane, accent-coloured on hover.
|
||||
<div
|
||||
onMouseDown={onMouseDown}
|
||||
className="group relative z-20 h-[3px] w-full shrink-0 cursor-row-resize bg-app-border hover:bg-app-accent"
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
>
|
||||
{/* A taller invisible strip, so 3px is not all there is to grab. */}
|
||||
<div className="absolute inset-x-0 -inset-y-1" />
|
||||
{/* Small pill grab handle in the middle for easier grabbing. */}
|
||||
<div className="absolute left-1/2 top-1/2 h-[7px] w-8 -translate-x-1/2 -translate-y-1/2 rounded-full border border-app-border bg-app-elevated group-hover:border-app-accent group-hover:bg-app-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseDown={onMouseDown}
|
||||
|
||||
@@ -39,8 +39,9 @@ export function ComposeDock({
|
||||
fullScreen = false,
|
||||
}: {
|
||||
windows: ComposeWin[];
|
||||
/** Live geometry of the reading pane; an expanded window is laid over it. */
|
||||
pane: { width: number; right: number };
|
||||
/** Live geometry of the reading pane; an expanded window is laid over it.
|
||||
* `height` only when the pane does not run the full height of the screen. */
|
||||
pane: { width: number; right: number; height?: number };
|
||||
onMinimize: (id: string) => void;
|
||||
onRestore: (id: string) => void;
|
||||
onToggleExpand: (id: string) => void;
|
||||
@@ -88,7 +89,7 @@ export function ComposeDock({
|
||||
);
|
||||
}
|
||||
|
||||
const { items, overflow } = layoutDock(vw, vh, windows, pane.width);
|
||||
const { items, overflow } = layoutDock(vw, vh, windows, pane.width, pane.height ?? 0);
|
||||
|
||||
const title = (w: ComposeWin) =>
|
||||
(titles[w.id] ?? w.init.subject ?? "").trim() || t("compose.newMessage");
|
||||
|
||||
@@ -143,12 +143,15 @@ export interface DockLayout {
|
||||
* @param paneW Live width of the reading pane. An expanded window matches it,
|
||||
* so expanding tracks the sidebar/list resizers and the viewport
|
||||
* instead of standing at one hard-coded width.
|
||||
* @param paneH The pane's height when it does not span the screen (the reading
|
||||
* pane under the list); 0 means full height.
|
||||
*/
|
||||
export function layoutDock(
|
||||
vw: number,
|
||||
vh: number,
|
||||
windows: ComposeWin[],
|
||||
paneW = 0,
|
||||
paneH = 0,
|
||||
): DockLayout {
|
||||
const budget = Math.max(0, vw - GUTTER);
|
||||
const wantOpen = windows.filter((w) => !w.minimized);
|
||||
@@ -193,8 +196,11 @@ export function layoutDock(
|
||||
const barW =
|
||||
barsShown > 0 ? Math.min(BAR_MAX, Math.floor((avail - GAP * (barsShown - 1)) / barsShown)) : 0;
|
||||
|
||||
// Expanded, a window is as tall as the pane it covers: the full height beside
|
||||
// the list, or only the lower pane when the reading pane sits under the list.
|
||||
const expandedH = paneH > 0 ? paneH : vh;
|
||||
const heightOf = (w: ComposeWin) =>
|
||||
Math.max(0, Math.min(w.expanded ? vh : WINDOW_H, vh - EDGE));
|
||||
Math.max(0, Math.min(w.expanded ? expandedH : WINDOW_H, vh - EDGE));
|
||||
|
||||
return {
|
||||
items: windows.map((w) => ({
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { ReadingPanePicker } from "./ReadingPanePicker";
|
||||
|
||||
/**
|
||||
* "Mailbox display": the reading-pane choice, opened from the list's ⋯ menu.
|
||||
* Anchored below that menu. Picking a layout applies it and closes the popover;
|
||||
* ✕, Escape or a click elsewhere closes it without a change.
|
||||
*/
|
||||
export function MailboxDisplay({ onClose }: { onClose: () => void }) {
|
||||
const t = useT();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-label={t("display.title")}
|
||||
className="absolute right-0 top-full z-30 mt-1 w-[360px] rounded-xl border border-app-border bg-app-elevated p-4 shadow-2xl"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-app-fg">{t("display.title")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title={t("common.close")}
|
||||
aria-label={t("common.close")}
|
||||
className="rounded p-1 text-app-muted hover:bg-app-hover hover:text-app-fg"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden>
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-2 text-[13px] font-medium text-app-fg">{t("display.readingPane")}</p>
|
||||
<ReadingPanePicker onPick={onClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useT } from "../../i18n";
|
||||
import { READING_PANES, type ReadingPane, setReadingPane, useReadingPane } from "../../lib/readingPane";
|
||||
|
||||
// One list row in a preview: an avatar dot and two lines of text.
|
||||
function Row({ wide = false }: { wide?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-app-faint" />
|
||||
<span className="flex flex-1 flex-col gap-[3px]">
|
||||
<span className={`h-[3px] rounded-full bg-app-faint ${wide ? "w-full" : "w-4/5"}`} />
|
||||
<span className={`h-[3px] rounded-full bg-app-border ${wide ? "w-3/4" : "w-3/5"}`} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A miniature of the mail view in each arrangement.
|
||||
function Preview({ pane }: { pane: ReadingPane }) {
|
||||
const box = "h-[54px] w-full overflow-hidden rounded-md border border-app-border bg-app-bg p-1";
|
||||
if (pane === "side") {
|
||||
return (
|
||||
<div className={`${box} flex gap-1`}>
|
||||
<div className="flex w-[38%] flex-col gap-1.5 pt-0.5">
|
||||
<Row />
|
||||
<Row />
|
||||
</div>
|
||||
<div className="flex-1 rounded-sm bg-app-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (pane === "bottom") {
|
||||
return (
|
||||
<div className={`${box} flex flex-col gap-1`}>
|
||||
<div className="flex flex-col gap-1.5 pt-0.5">
|
||||
<Row wide />
|
||||
</div>
|
||||
<div className="flex-1 rounded-sm bg-app-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`${box} flex flex-col gap-1.5 pt-1.5`}>
|
||||
<Row wide />
|
||||
<Row wide />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The three reading-pane arrangements as selectable cards (Side / Bottom / Off).
|
||||
* `onPick` runs after a choice — the ⋯ menu's popover uses it to close itself. */
|
||||
export function ReadingPanePicker({ onPick }: { onPick?: (pane: ReadingPane) => void }) {
|
||||
const t = useT();
|
||||
const current = useReadingPane();
|
||||
return (
|
||||
<div role="radiogroup" aria-label={t("display.readingPane")} className="grid grid-cols-3 gap-2">
|
||||
{READING_PANES.map((pane) => {
|
||||
const on = pane === current;
|
||||
return (
|
||||
<button
|
||||
key={pane}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={on}
|
||||
onClick={() => {
|
||||
setReadingPane(pane);
|
||||
onPick?.(pane);
|
||||
}}
|
||||
className={`flex flex-col gap-2 rounded-lg border p-2 text-left transition-colors ${
|
||||
on
|
||||
? "border-app-accent bg-app-accent/10"
|
||||
: "border-app-border hover:bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
<Preview pane={pane} />
|
||||
<span className="flex items-center gap-2 px-0.5 text-[14px] text-app-fg">
|
||||
<span
|
||||
aria-hidden
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 ${
|
||||
on ? "border-app-accent" : "border-app-faint"
|
||||
}`}
|
||||
>
|
||||
{on && <span className="h-2 w-2 rounded-full bg-app-accent" />}
|
||||
</span>
|
||||
{t(`display.${pane}` as const)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user