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:
2026-10-03 18:13:43 +02:00
co-authored by Claude Opus 5.5
parent befa1c2404
commit b1bdae6e59
9 changed files with 362 additions and 33 deletions
+37 -6
View File
@@ -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");
+7 -1
View File
@@ -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>
);
}