Secret Scan / scan (push) Successful in 5s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 3s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m44s
Build App (Preview) / build-windows (pull_request) Successful in 4m55s
Build App (Preview) / build-linux (pull_request) Successful in 5m29s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
The dock was showing `NotesPanel`, which is a master/detail layout: a column of titles beside an editor. The previous commit made that survive dock width; it did not make it right. At 352px the layout still spends roughly 356px of height on chrome — dock header, panel header, title strip, a button row that wraps, and a paragraph of help — before the body gets a pixel. So the dock now shows one note. The title field names what is open and the chevron beside it switches; New and Delete move into the overflow menu; the help text goes. Chrome drops to about 112px and the body takes the rest. The two surfaces are now different components, which contradicts a docstring I wrote — "shared so the two cannot drift into different behaviour". That claim was about behaviour, and behaviour was never in the layout: it is in `useNotes` for the cache and its write ordering, and now in `useNoteDraft`, extracted here so when a keystroke becomes a save is defined in exactly one place. Only the layout diverges. `NotesPanel.shared.test.tsx` gets stronger for it — it now mounts the dock panel and the tab panel together, which is what the app actually does, instead of the same component twice. `NoteSwitcher` is not `OverflowMenu` despite the shape being close: that keys items by label, and notes are addressed by id, so two untitled notes — the ordinary case — would collapse into one row. It is also not a `combobox`; an input plus a listbox button is two honest controls, where the role would owe active-descendant tracking and filtering that nothing here needs. `SendToAgentButton` picks up `useUnavailable` from #49, which is what its `disabled` plus explanatory `title` was already asking for. Four tests moved from `toBeDisabled()` to the new contract, and one of them — "does nothing for an empty note" — turned out never to have asserted that it does nothing. It does now, for click and for Enter, which is the guard the swap needs. It also gains `dropUp`, and that is load-bearing rather than cosmetic: the dock clips its own overflow, so a session menu opening downward from a button on the bottom edge is drawn outside the panel and never seen. 744 tests pass, 62 files. As before, jsdom has no layout engine: that the dock now reads as compact is not something the suite can tell you. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
113 lines
4.2 KiB
TypeScript
113 lines
4.2 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import type { Note } from "../../lib/types";
|
|
|
|
export const UNTITLED = "Untitled note";
|
|
|
|
interface Props {
|
|
notes: Note[];
|
|
selectedId: string;
|
|
title: string;
|
|
onTitleChange: (value: string) => void;
|
|
onCommit: () => void;
|
|
onSelect: (id: string) => void;
|
|
}
|
|
|
|
/**
|
|
* One row that both names the current note and switches to another.
|
|
*
|
|
* The dock has no room for a permanent list of titles, so the title field
|
|
* doubles as the label of what is open and the chevron beside it holds the
|
|
* rest. Renaming therefore needs no separate affordance.
|
|
*
|
|
* Two honest controls rather than one `role="combobox"`: a text field and a
|
|
* button that opens a listbox. A real combobox owes its listbox keyboard
|
|
* navigation, active-descendant tracking and an input that filters — none of
|
|
* which this needs, and half of which is worse than not claiming the role.
|
|
*
|
|
* `OverflowMenu` is deliberately not reused here despite the shape being
|
|
* close. It keys its items by label, and notes are addressed by id: two
|
|
* untitled notes are the ordinary case and would collapse into one row.
|
|
*/
|
|
export default function NoteSwitcher({
|
|
notes,
|
|
selectedId,
|
|
title,
|
|
onTitleChange,
|
|
onCommit,
|
|
onSelect,
|
|
}: Props) {
|
|
const [open, setOpen] = useState(false);
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Same dismissal contract as `OverflowMenu`, so the two feel identical.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onDocClick = (e: MouseEvent) => {
|
|
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
|
|
};
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") setOpen(false);
|
|
};
|
|
document.addEventListener("mousedown", onDocClick);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onDocClick);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [open]);
|
|
|
|
return (
|
|
<div ref={rootRef} className="relative flex items-center gap-1 min-w-0">
|
|
<input
|
|
value={title}
|
|
onChange={(e) => onTitleChange(e.target.value)}
|
|
onBlur={onCommit}
|
|
placeholder="Note title"
|
|
aria-label="Note title"
|
|
className="flex-1 min-w-0 px-2 h-7 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] transition-colors"
|
|
/>
|
|
<button
|
|
type="button"
|
|
aria-label="Switch note"
|
|
aria-haspopup="listbox"
|
|
aria-expanded={open}
|
|
onClick={() => setOpen((o) => !o)}
|
|
className="inline-flex items-center justify-center h-7 w-6 flex-shrink-0 rounded-[var(--radius-control)] border border-[var(--border-color)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--border-color)] transition-colors"
|
|
>
|
|
<span aria-hidden="true" className="leading-none text-[10px]">▾</span>
|
|
</button>
|
|
{open && (
|
|
<div
|
|
role="listbox"
|
|
aria-label="Notes"
|
|
className="absolute right-0 top-full mt-1 z-40 w-full max-h-64 overflow-y-auto py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)]"
|
|
style={{ boxShadow: "var(--shadow-overlay)" }}
|
|
>
|
|
{/* Buttons directly inside the listbox: wrapping each in an `<li>`
|
|
would put an implicit `listitem` between the listbox and its
|
|
options, which is not a child role a listbox owns. */}
|
|
{notes.map((n) => (
|
|
<button
|
|
key={n.id}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={n.id === selectedId}
|
|
onClick={() => {
|
|
onSelect(n.id);
|
|
setOpen(false);
|
|
}}
|
|
className={`block w-full text-left px-3 py-1.5 text-xs truncate transition-colors hover:bg-[var(--bg-tertiary)] ${
|
|
n.id === selectedId
|
|
? "text-[var(--text-primary)] bg-[var(--bg-tertiary)]"
|
|
: "text-[var(--text-secondary)]"
|
|
}`}
|
|
>
|
|
{n.title.trim() || UNTITLED}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|