feat(shell): run the docked strips the full height and make them the only fold

The docked panels' strips now run from the top edge of the window to the
bottom, and the top bar ends left of them. The panel header no longer
carries a fold button in docked mode: the strip opens and closes the day
and, on /coach, the conversations; opening moves focus to the panel's
heading and closing returns it to the strip. The sheets keep a close
button, the day's now an X at the end of its header.

The command palette's door is a magnifier at every width, named Search,
with the shortcut in its tooltip. g p opens or closes the day like a
click on its strip, and below the docking width opens the sheet on the
last day or closes it; the shortcuts list shows it under Day.
This commit is contained in:
Marc-André Bombeck committed 2026-10-10 20:04:19 +02:00
1 parent 023115b05c
commit 39fc4e4da2
26 files changed
+561 -229

No files matched your search

@@ -17,47 +17,23 @@ import { openCommandPalette, preloadCommandPalette } from "./palette-store";
/**
* The way into the command palette, at the trailing end of the top bar on
* every signed-in page. Where the bar has room it looks like a search field
* ("Search…" and the key that opens it); where it has not (a phone, or a
* desktop bar narrowed by the sidebar and a docked panel) it is a magnifier
* with a tooltip. The switch reads the bar's own width (`@container/topbar`
* on the header), not the window's, because a docked panel takes the room
* without the window changing.
* every signed-in page: a magnifier at every width, named "Search", with a
* tooltip that gives the key that opens it as well ("Search (⌘K)", or
* "Search (Ctrl K)" off Apple platforms). 44 px under a coarse pointer, 36 px
* beside a fine one. Docked panels' strips stand right of the top bar, so the
* magnifier always sits just left of them and nothing covers it.
*/
export function CommandPaletteTrigger() {
const { t } = useTranslations();
// The key hint depends on the platform, which the server cannot know.
const mounted = useMounted();
const apple = mounted && isApplePlatform();
const label = t("palette.openLabel");
const label = t("palette.title");
const keyHint = apple ? "⌘K" : "Ctrl K";
const shortcut = apple ? "Meta+K" : "Control+K";
return (
<div className="ml-auto flex shrink-0 items-center">
<button
type="button"
data-slot="command-palette-trigger"
data-variant="field"
aria-label={label}
aria-keyshortcuts={mounted ? shortcut : undefined}
aria-haspopup="dialog"
onClick={openCommandPalette}
onPointerEnter={preloadCommandPalette}
onFocus={preloadCommandPalette}
className="border-border bg-background text-muted-foreground hover:text-foreground hover:bg-accent focus-visible:ring-ring/50 hidden h-9 w-56 items-center gap-2 rounded-md border px-3 text-sm transition-colors outline-none focus-visible:ring-[3px] @2xl/topbar:flex"
>
<Search className="size-4 shrink-0" aria-hidden="true" />
<span className="flex-1 truncate text-left">{t("palette.open")}</span>
{mounted ? (
<kbd
aria-hidden="true"
className="border-border bg-muted text-foreground rounded border px-1.5 font-mono text-xs"
>
{keyHint}
</kbd>
) : null}
</button>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
@@ -66,19 +42,20 @@ export function CommandPaletteTrigger() {
variant="ghost"
size="icon"
data-slot="command-palette-trigger"
data-variant="icon"
aria-label={label}
aria-keyshortcuts={mounted ? shortcut : undefined}
aria-haspopup="dialog"
onClick={openCommandPalette}
onPointerEnter={preloadCommandPalette}
onFocus={preloadCommandPalette}
className="text-muted-foreground hover:text-foreground min-h-11 min-w-11 @2xl/topbar:hidden"
className="text-muted-foreground hover:text-foreground size-11 pointer-fine:size-9"
>
<Search className="size-5" aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
<TooltipContent side="bottom" data-slot="command-palette-tooltip">
{mounted ? `${label} (${keyHint})` : label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
@@ -243,16 +243,19 @@ describe("<DayView>", () => {
expect(leave).not.toContain("FAMILY");
});
it("collapses the docked day and closes a sheet, and says which", async () => {
it("has no close button docked, where the strip folds it, and a clear one in a sheet", async () => {
const docked = await render();
const hide = docked.match(/<button[^>]*data-slot="day-close"[^>]*>/)?.[0];
expect(hide).toContain('aria-label="Hide day"');
expect(hide).toContain('aria-expanded="true"');
expect(docked).not.toContain('data-slot="day-close"');
for (const shell of ["sheet", "bottom"] as const) {
const sheet = await render({ shell });
const close = sheet.match(/<button[^>]*data-slot="day-close"[^>]*>/)?.[0];
expect(close).toContain('aria-label="Close day"');
expect(close).not.toContain("aria-expanded");
// An X, at the end of the header row, after the next-day arrow.
expect(sheet.indexOf('data-slot="day-close"')).toBeGreaterThan(
sheet.indexOf('data-slot="day-next"'),
);
expect(sheet).toContain("lucide-x");
}
});
@@ -185,6 +185,27 @@ export function jumpDay(next: DateKey): void {
* Coach's conversations below 1600 px): focus then stays with the control
* that asked for the room instead of moving to the day's edge.
*/
let dayToggle: (() => void) | null = null;
/**
* The mounted layer's open-or-close, the same as a click on the day's strip
* (a sheet with the strip's day below the docking width). The layer
* registers it; `g p` calls it through `toggleDay`.
*/
export function registerDayToggle(toggle: () => void): () => void {
dayToggle = toggle;
return () => {
if (dayToggle === toggle) dayToggle = null;
};
}
/** Open or close the day. False when no day layer is mounted. */
export function toggleDay(): boolean {
if (dayToggle === null) return false;
dayToggle();
return true;
}
let yielding = false;
/** Collapse the docked day to its edge for a neighbouring panel. */
+33 -13
View File
@@ -50,6 +50,7 @@ import {
openDay,
peekDayTriggerAt,
publishOpenDay,
registerDayToggle,
stepDay,
takeDayTrigger,
takeDayYield,
@@ -255,8 +256,8 @@ function DayLayer() {
}, []);
const panelRef = useRef<HTMLElement>(null);
const stripRef = useRef<HTMLButtonElement>(null);
// Set by a fold (the header's control, the strip): focus then lands on the
// strip, which brings the day back from the same place.
// Set by a fold (the strip, its shortcut): focus then lands on the strip,
// which brings the day back from the same place.
const collapsing = useRef(false);
// The remembered day, while the docked day is shut, if it is still a day
@@ -353,6 +354,33 @@ function DayLayer() {
[date, today],
);
// What the strip holds: the open day, else the remembered one, else today.
// Below the docking width the same day is the one `g p` opens.
const stripDay = stripDayOf(date, lastDay, today);
const dockedOpen = dockedDay !== null;
// The strip's click, and `g p` at every width: docked, the column opens
// or folds; below, the sheet opens on the strip's day or closes.
const toggle = useCallback(() => {
if (shell === "docked") {
if (dockedOpen) {
collapse();
return;
}
openDay(stripDay, { trigger: stripRef.current ?? undefined });
return;
}
if (date !== null) {
closeByPerson();
return;
}
openDay(stripDay);
}, [shell, dockedOpen, collapse, stripDay, date, closeByPerson]);
const latestToggle = useRef(toggle);
useEffect(() => {
latestToggle.current = toggle;
});
useEffect(() => registerDayToggle(() => latestToggle.current()), []);
const onPick = useCallback(
(next: DateKey) => {
if (next > today) return;
@@ -418,7 +446,8 @@ function DayLayer() {
today={today}
focus={focus}
shell={shell}
onClose={shell === "docked" ? collapse : closeByPerson}
// Only the sheets show a close button; docked, the strip folds the day.
onClose={closeByPerson}
onStep={onStep}
onPick={onPick}
Title={Title}
@@ -429,16 +458,7 @@ function DayLayer() {
);
if (shell === "docked") {
// What the strip holds: the open day, else the remembered one, else today.
const stripDay = stripDayOf(date, railDay, today);
const open = dockedDay !== null;
const toggle = () => {
if (open) {
collapse();
return;
}
openDay(stripDay, { trigger: stripRef.current ?? undefined });
};
const open = dockedOpen;
return (
<>
{live}
+25 -37
View File
@@ -8,9 +8,8 @@ import {
ChevronRight,
History,
MessageCircle,
PanelBottomClose,
PanelRightClose,
Plus,
X,
} from "lucide-react";
import {
@@ -84,15 +83,12 @@ const ICON_BUTTON =
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-9";
/**
* The collapse control is the Coach panel's own toggle, in the same place:
* the panel's top-left corner, the panel icon mirrored so it points the way
* the panel goes. Same size and glyph as `PANEL_HEADER_BUTTON` there (kept as
* a copy so the day layer does not pull the Coach's panel into the shell).
* The collapsed edge's control in `day-layer.tsx` takes the same pair.
* The sheets' close control, at the trailing end of the header like the
* Coach sheet's own. The docked day has none: its strip at the right edge of
* the window (`shell-dock.tsx`) is the one control that opens and closes it.
*/
export const PANEL_TOGGLE =
const CLOSE_BUTTON =
"text-muted-foreground hover:text-foreground size-11 shrink-0 pointer-fine:size-7";
export const PANEL_TOGGLE_ICON = "size-5 pointer-fine:size-4";
export function DayView({
date,
@@ -157,7 +153,7 @@ export function DayView({
? Object.values(data.sections).some((s) => s?.reason === "not_shared")
: false;
const compact = shell === "bottom";
const closeLabel = shell === "docked" ? t("day.hidePanel") : t("day.close");
const docked = shell === "docked";
return (
<div
@@ -170,41 +166,19 @@ export function DayView({
data-slot="day-header"
className={cn(
"flex shrink-0 items-center gap-1",
compact ? "pt-0.5 pr-2 pb-2 pl-2" : "border-border border-b px-3",
// The date starts on the body's reading edge (`px-4` / `px-6`).
compact
? "pt-0.5 pr-2 pb-2 pl-4"
: "border-border border-b pr-3 pl-6",
headerClassName,
)}
>
{/* Top left, where the Coach panel keeps its toggle: the way out.
Docked, it collapses the day to the edge; a sheet closes. */}
<Button
type="button"
variant="ghost"
size="icon"
data-slot="day-close"
aria-label={closeLabel}
aria-expanded={shell === "docked" ? true : undefined}
title={closeLabel}
onClick={onClose}
className={PANEL_TOGGLE}
>
{compact ? (
<PanelBottomClose
className={PANEL_TOGGLE_ICON}
aria-hidden="true"
/>
) : (
<PanelRightClose
className={cn(PANEL_TOGGLE_ICON, "-scale-x-100")}
aria-hidden="true"
/>
)}
</Button>
{/* One line, centred in the band: the date and nothing under it. */}
<Title
id={titleId}
ref={titleRef}
tabIndex={-1}
className="min-w-0 flex-1 truncate px-1 text-base leading-snug font-semibold focus-visible:outline-none"
className="min-w-0 flex-1 truncate pr-1 text-base leading-snug font-semibold focus-visible:outline-none"
>
{/* The date is the way to any other day: a calendar opens from
it, so the header needs no icon of its own. */}
@@ -262,6 +236,20 @@ export function DayView({
aria-hidden="true"
/>
</Button>
{docked ? null : (
<Button
type="button"
variant="ghost"
size="icon"
data-slot="day-close"
aria-label={t("day.close")}
title={t("day.close")}
onClick={onClose}
className={CLOSE_BUTTON}
>
<X className="size-5 pointer-fine:size-4" aria-hidden="true" />
</Button>
)}
</div>
<div
@@ -58,8 +58,9 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
* the two bottom borders draw one line. Its strip stays at the right edge of
* the window, left of the day's (`shell-dock.tsx`), open or not: a click
* on the strip opens the panel left of the strips and a second click shuts
* it; the header's first control shuts it too. The panel slides open and
* shut (a 200 ms width transition, none under reduced motion).
* it. The strip is the docked panel's only fold control; the header carries
* none. The panel slides open and shut (a 200 ms width transition, none
* under reduced motion).
* The header carries the title, a link to Plans, the settings gear and, in
* the sheet, a close button. New conversation is not here: it floats in the
* conversation itself (`NewChatFab`).
@@ -75,7 +76,7 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
* Keyboard: Escape folds the docked panel when focus is inside it and
* nothing inside (a row menu, the rename field, the settings popover) has
* already claimed the key; focus moves to the strip, and opening from the
* strip lands on the fold control. In the sheet Radix owns Escape, the
* strip lands on the panel's heading. In the sheet Radix owns Escape, the
* focus trap and the return.
*/
export const COACH_PANEL_ID = "coach-conversations-panel";
@@ -286,17 +287,17 @@ export function ConversationsPanel({
}
const expanded = docked ? dockedOpen : sheetOpen;
const collapseRef = useRef<HTMLButtonElement>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const expandRef = useRef<HTMLButtonElement>(null);
// The list keeps painting while it slides shut.
const shownOpen = useLingering(docked && dockedOpen ? true : null) ?? false;
// Where focus goes once the docked panel has folded or opened.
const focusAfter = useRef<"collapse" | "expand" | null>(null);
const focusAfter = useRef<"panel" | "strip" | null>(null);
useEffect(() => {
const target = focusAfter.current;
focusAfter.current = null;
if (target === "collapse") collapseRef.current?.focus();
if (target === "expand") expandRef.current?.focus();
if (target === "panel") headingRef.current?.focus({ preventScroll: true });
if (target === "strip") expandRef.current?.focus();
}, [dockedOpen]);
function toggleSheet() {
@@ -304,7 +305,7 @@ export function ConversationsPanel({
}
function expandDocked() {
focusAfter.current = "collapse";
focusAfter.current = "panel";
setYielded(false);
setRevealed(false);
setOpen(true);
@@ -313,7 +314,7 @@ export function ConversationsPanel({
}
function collapseDocked() {
focusAfter.current = "expand";
focusAfter.current = "strip";
setYielded(false);
setRevealed(false);
setOpen(false);
@@ -344,37 +345,19 @@ export function ConversationsPanel({
: cn("border-sidebar-border px-3", SHELL_HEADER_BAND),
)}
>
{inSheet ? null : (
// A title, not a tooltip: focus lands here when the panel opens
// from its edge, and a tooltip opened by that focus would take the
// next Escape for itself.
<Button
ref={collapseRef}
type="button"
variant="ghost"
size="icon"
onClick={collapseDocked}
data-slot="coach-panel-collapse"
aria-controls={COACH_PANEL_ID}
aria-expanded={true}
aria-label={t("insights.coach.frame.hidePanel")}
title={t("insights.coach.frame.hidePanel")}
className={PANEL_HEADER_BUTTON}
>
{/* The day's own fold control and glyph, mirrored so it points
the way the panel goes. */}
<PanelRightClose
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
aria-hidden="true"
/>
</Button>
)}
{inSheet ? (
<SheetTitle className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
{t("insights.coach.historyTitle")}
</SheetTitle>
) : (
<h2 className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
// Docked, the strip is the panel's only fold control; focus lands on
// the heading when the panel opens from it.
<h2
ref={headingRef}
tabIndex={-1}
data-slot="coach-panel-heading"
className="min-w-0 flex-1 truncate pl-1 text-lg leading-tight font-semibold focus-visible:outline-none"
>
{t("insights.coach.historyTitle")}
</h2>
)}
@@ -15,7 +15,7 @@ import { DOCK_SLIDE, DockStrip } from "../shell-dock";
* The docked panels' strips (v1.42). Each docked panel keeps its own strip
* at the right edge; the strip is a toggle for its panel, never moves, and
* below 1600 px only one panel is open. The click paths, focus and the
* geometry run in `e2e/dock-strips.spec.ts`.
* geometry run in `e2e/day-view.spec.ts` and `e2e/coach-layout.spec.ts`.
*/
function strip(expanded: boolean) {
return renderToStaticMarkup(
@@ -56,14 +56,14 @@ describe("DockStrip", () => {
expect(html).toContain('aria-label="Hide day"');
});
it("keeps the same shape open and closed, below the top bar's band", () => {
it("keeps the same shape open and closed, the full height of the window", () => {
const shape = (html: string) => html.match(/^<div[^>]*class="([^"]*)"/)![1];
expect(shape(strip(true))).toBe(shape(strip(false)));
expect(shape(strip(false))).toContain("w-10");
// The first cell is the band (`SHELL_HEADER_BAND`), empty and hidden.
expect(strip(false)).toMatch(
/^<div[^>]*><div aria-hidden="true" class="h-16 border-b/,
);
expect(shape(strip(false))).toContain("h-full");
// The button is the strip's first and only cell: no band above it.
expect(strip(false)).toMatch(/^<div[^>]*><button/);
expect(strip(false)).not.toContain("h-16");
});
it("orders the strips like their panels: conversations, then the day", () => {
+10 -3
View File
@@ -9,7 +9,11 @@ import {
useSyncExternalStore,
} from "react";
import { stepDay, useOpenDay } from "@/components/day/day-layer-controller";
import {
stepDay,
toggleDay,
useOpenDay,
} from "@/components/day/day-layer-controller";
import { useTodayKey } from "@/components/day/use-today-key";
import {
CAPTURE_KIND_ORDER,
@@ -38,8 +42,8 @@ import {
* (never on the sign-in pages or in the setup flow, which the shell renders
* without it).
*
* `g` + a key opens a page, `n` the add menu, `[` / `]` step the open day,
* `?` the list of all of them. Which key does what, and when a key is not
* `g` + a key opens a page, `g p` opens or closes the day, `n` the add menu,
* `[` / `]` step the open day, `?` the list of all of them. Which key does what, and when a key is not
* ours, is decided in `src/lib/keyboard/global-shortcuts.ts`; this component
* reads the page's state and carries the action out. A destination comes from
* the same lists the navigation renders, so `g t` with the timeline module
@@ -196,6 +200,9 @@ export function GlobalShortcuts() {
stepDay(date, action.delta);
return;
}
case "day-toggle":
if (toggleDay()) event.preventDefault();
return;
}
};
document.addEventListener("keydown", onKeyDown);
@@ -92,6 +92,7 @@ export function KeyboardShortcutsDialog({
];
const day: Row[] = [
{ id: "day-toggle", label: t("shortcuts.toggleDay"), sequence: ["g", "p"] },
{ id: "day-previous", label: t("shortcuts.previousDay"), sequence: ["["] },
{ id: "day-next", label: t("shortcuts.nextDay"), sequence: ["]"] },
];
+9 -11
View File
@@ -5,7 +5,6 @@ import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { SHELL_HEADER_BAND } from "./shell-metrics";
import { createShellSlot } from "./shell-slot";
/**
@@ -17,9 +16,12 @@ import { createShellSlot } from "./shell-slot";
* panels: conversations (`order-1`), then the day (`order-2`). A panel opens
* to the left of the strips; the strips never move or change shape.
*
* Each strip starts below the top bar's band. The band above them is empty
* and draws the top bar's bottom line on through, so nothing in the strips
* meets the command palette at the end of the top bar.
* Each strip runs the full height of the shell row, from the top of the
* window to the bottom. The strip column sits beside the content column, so
* the top bar (and the command palette's magnifier at its trailing end) ends
* at the strips' left edge and nothing in a strip can cover it. Docked, the
* strip is the panel's only open and close control: the panel's header
* carries no fold button of its own.
*/
const strips = createShellSlot("shell-strips");
@@ -89,9 +91,9 @@ export interface DockStripProps {
}
/**
* One strip: an empty cell of the top bar's band, then a button the rest of
* the window's height that opens and closes its panel. The label reads
* top to bottom; the strip is marked while its panel is open.
* One strip: a button the full height of the window that opens and closes
* its panel. The label reads top to bottom; the strip is marked while its
* panel is open.
*/
export function DockStrip({
ref,
@@ -116,10 +118,6 @@ export function DockStrip({
order === 1 ? "order-1" : "order-2",
)}
>
<div
aria-hidden="true"
className={cn(SHELL_HEADER_BAND, "border-sidebar-border shrink-0")}
/>
<button
ref={ref}
type="button"
+3 -5
View File
@@ -90,9 +90,7 @@ export function TopBar() {
// sidebar logo band reads too, so the two borders draw one
// continuous line. Never restate a height here.
className={cn(
// `@container/topbar`: the search control sizes to the bar's own
// width, which a docked side panel narrows without the window moving.
"bg-card/80 border-border @container/topbar sticky top-0 z-40 flex items-center justify-between px-4 backdrop-blur-md md:px-6",
"bg-card/80 border-border sticky top-0 z-40 flex items-center justify-between px-4 backdrop-blur-md md:px-6",
SHELL_HEADER_BAND,
)}
// The status-bar inset of the installed app is the shell's
@@ -114,8 +112,8 @@ export function TopBar() {
<TopBarContextOutlet className="shell-desktop:flex hidden min-w-0 flex-1 items-center" />
{/* v1.42 — the command palette's door, on every signed-in page: a
search-field button where the bar has room, a magnifier where it
has not (and on a phone). `ml-auto` keeps it at the trailing end. */}
magnifier at every width. `ml-auto` keeps it at the trailing end,
just left of any docked panel's strip. */}
<CommandPaletteTrigger />
{/* Page-owned actions (the Coach's conversations toggle). `ml-auto`
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import { visibleNavDestinations } from "@/components/layout/nav-model";
import {
DAY_TOGGLE_KEY,
GO_TO_SHORTCUTS,
SEQUENCE_TIMEOUT_MS,
blockedByModifier,
createShortcutReader,
@@ -181,6 +183,40 @@ describe("sequence timing", () => {
});
});
describe("g p, the day", () => {
it("opens or closes the day from the page", () => {
const reader = createShortcutReader();
expect(reader.read(key("g"), "page", 0)).toBe("pending");
expect(reader.read(key("p"), "page", 10)).toEqual({ type: "day-toggle" });
});
it("collides with no destination", () => {
expect(DAY_TOGGLE_KEY).toBe("p");
const keys = GO_TO_SHORTCUTS.map((s) => s.key as string);
expect(keys).not.toContain(DAY_TOGGLE_KEY);
expect(new Set(keys).size).toBe(keys.length);
});
it("needs the g, within the window", () => {
const reader = createShortcutReader();
expect(reader.read(key("p"), "page", 0)).toBeNull();
reader.read(key("g"), "page", 0);
expect(reader.read(key("p"), "page", SEQUENCE_TIMEOUT_MS + 1)).toBeNull();
});
it("is not read from a field, under a modifier or over another dialog", () => {
const reader = createShortcutReader();
reader.read(key("g"), "page", 0);
expect(reader.read(key("p", { target: input }), "page", 10)).toBeNull();
reader.read(key("g"), "page", 20);
expect(reader.read(key("p", { metaKey: true }), "page", 30)).toBeNull();
reader.read(key("g"), "page", 40);
expect(reader.read(key("p"), "off", 50)).toBeNull();
expect(reader.read(key("g"), "off", 60)).toBeNull();
expect(reader.read(key("p"), "page", 70)).toBeNull();
});
});
describe("scope", () => {
it("an open dialog, menu or popover takes every key", () => {
const reader = createShortcutReader();
@@ -191,7 +227,7 @@ describe("scope", () => {
expect(reader.read(key("d"), "off", 10)).toBeNull();
});
it("over the day sheet only [ and ] act", () => {
it("over the day sheet only [, ] and g p act", () => {
const reader = createShortcutReader();
expect(reader.read(key("["), "day-sheet", 0)).toEqual({
type: "day",
@@ -202,7 +238,14 @@ describe("scope", () => {
delta: 1,
});
expect(reader.read(key("n"), "day-sheet", 0)).toBeNull();
expect(reader.read(key("g"), "day-sheet", 0)).toBeNull();
expect(reader.read(key("?"), "day-sheet", 0)).toBeNull();
// g waits, but only p completes it there: no page leaves under a sheet.
expect(reader.read(key("g"), "day-sheet", 0)).toBe("pending");
expect(reader.read(key("d"), "day-sheet", 10)).toBeNull();
reader.read(key("g"), "day-sheet", 20);
expect(reader.read(key("p"), "day-sheet", 30)).toEqual({
type: "day-toggle",
});
});
it("Escape is never ours", () => {
+27 -7
View File
@@ -15,7 +15,8 @@
* Option+5 on a German Mac, AltGr+8 on a German PC) still counts, because
* for that person there is no other way to press it;
* - while a dialog, sheet, menu or popover is open the keys are its own,
* except over the day sheet, where `[` and `]` step the day it shows.
* except over the day sheet, where `[` and `]` step the day it shows and
* `g p` closes it.
*
* `g` starts a two-key sequence; the second key has to follow within
* `SEQUENCE_TIMEOUT_MS`. Escape is never handled here: every surface closes
@@ -43,17 +44,25 @@ export const GO_TO_SHORTCUTS = [
export type GoToKey = (typeof GO_TO_SHORTCUTS)[number]["key"];
/**
* `g` + `p` opens or closes the day, the way a click on the day's strip does
* (a sheet below the docking width). Not a destination, and no `g` key above.
*/
export const DAY_TOGGLE_KEY = "p";
export type ShortcutAction =
| { type: "go"; key: GoToKey }
| { type: "palette" }
| { type: "capture" }
| { type: "help" }
| { type: "day"; delta: -1 | 1 };
| { type: "day"; delta: -1 | 1 }
| { type: "day-toggle" };
/**
* Where the keyboard is:
* - `page`: nothing modal is open, every shortcut applies;
* - `day-sheet`: the day sheet is the only modal surface, `[` / `]` apply;
* - `day-sheet`: the day sheet is the only modal surface, `[` / `]` and
* `g p` apply;
* - `off`: another dialog, sheet, menu or popover has the keys.
*/
export type ShortcutScope = "page" | "day-sheet" | "off";
@@ -205,12 +214,23 @@ export function createShortcutReader(timeoutMs = SEQUENCE_TIMEOUT_MS) {
if (key === "[" || key === "]") {
return { type: "day", delta: key === "[" ? -1 : 1 };
}
if (scope !== "page") return null;
if (pending !== null && now - pending <= timeoutMs) {
// The second key of a sequence: a destination, or nothing at all, so
// a mistyped `g n` does not open the add menu.
const inSequence = pending !== null && now - pending <= timeoutMs;
if (scope !== "page") {
// Over the day sheet only `g p` (which closes it) is a sequence.
if (inSequence && key === DAY_TOGGLE_KEY) return { type: "day-toggle" };
if (key === "g") {
pendingSince = now;
return "pending";
}
return null;
}
if (inSequence) {
// The second key of a sequence: a destination, the day, or nothing at
// all, so a mistyped `g n` does not open the add menu.
if (isGoToKey(key)) return { type: "go", key };
if (key === DAY_TOGGLE_KEY) return { type: "day-toggle" };
if (key !== "g") return null;
}
if (key === "g") {