From fd8b9ac84412eaf0672c77072c2b540ccbbf2111 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Tue, 29 Sep 2026 10:30:03 -0700 Subject: [PATCH] feat: animate the unibox scope rail (row folds, sliding active highlight, edit-mode swap), let people drag or keyboard-reorder Mail and Views rows and move whole sections, add row and section menus with right-click, hide-with-undo and reset, and give the shared PopoverMenu pointer anchoring, arrow-key and typeahead navigation and focus return --- docs/content/docs/guides/unibox.mdx | 2 +- web/src/components/app/unibox/ScopeRail.tsx | 1389 ++++++++++++----- .../components/app/unibox/scopeRail.test.tsx | 274 +++- web/src/components/ui/check-square.tsx | 22 +- web/src/components/ui/popover-menu.tsx | 108 +- web/src/stores/slices/uiSlice.ts | 48 + web/src/stores/useAppStore.ts | 8 +- 7 files changed, 1384 insertions(+), 467 deletions(-) diff --git a/docs/content/docs/guides/unibox.mdx b/docs/content/docs/guides/unibox.mdx index 6e286229e..9b244e712 100644 --- a/docs/content/docs/guides/unibox.mdx +++ b/docs/content/docs/guides/unibox.mdx @@ -16,7 +16,7 @@ The columns are yours to size. Every choice here is remembered in the browser yo | The divider between the list and the thread | Drag it to widen the conversation list so long subjects fit, or to give the space back to the thread. It also takes the keyboard once it has focus: arrow keys nudge it (hold `Shift` for a bigger step), `Home` and `End` go to the narrowest and widest the window allows, `Enter` (or a double-click) puts it back to the default. | | **Collapse** at the bottom of the left navigation, or `b` | Shrinks Warmbly's own navigation to an icon rail and gives the width to the page. Row labels become tooltips; unread mail keeps its count badge and an open [Advisor](/guides/advisor/) finding shows as a coloured dot on the icon. The same control expands it again. | | The section headers in the left navigation (Email, CRM, Resources) | Click one to fold its section away, and again to bring it back. Like the other layout choices, it is remembered in this browser. A folded section still shows the page you are on, and a coloured dot beside its header flags an open [Advisor](/guides/advisor/) finding on one of the pages it hides. The icon rail follows the same choice. | -| The section headers in the scope rail (Mail, Views, Mailboxes, Labels, Tags) | Click one to fold its section away, and again to bring it back. It is remembered in this browser. A folded section still shows the scope you are looking at, and a small blue dot beside its header means a row it folds away has a highlighted count (rows you hid never raise it). The pencil on **Mail** and **Views** lets you choose which rows appear: untick a row to hide it, then press **Done** (or `Escape`). A small "2 hidden" beside the pencil tells you rows are off the rail. Hiding a row only takes it off the rail, so its shortcuts and links keep working, and the scope you are in always stays visible. | +| The section headers in the scope rail (Mail, Views, Mailboxes, Labels, Tags) | Click one to fold its section away, and again to bring it back. A folded section still shows the scope you are looking at, and a small blue dot beside its header means a row it folds away has a highlighted count (rows you hid never raise it). The **…** beside a header (or a right-click on it) folds every other section, unfolds them all, or moves the section up or down the rail. The pencil on **Mail** and **Views** puts the rows in edit mode: untick a row to hide it, drag it by its handle (or focus the handle and use the arrow keys) to reorder, and press **Done**, `Escape`, or click anywhere else. **Reset** puts that section back the way it shipped. Each row also has its own **…** menu and right-click menu to move it, hide it (with an undo), or mark a folder read, and `Alt` with an arrow key moves the focused row. A small "2 hidden" beside the pencil tells you rows are off the rail. Hiding a row only takes it off the rail, so its shortcuts and links keep working, and the scope you are in always stays visible. All of it is remembered in this browser. | | The contact button in the thread header | Shows or hides the contact panel on the right. It starts closed when no preference is saved. Opening or closing it sticks across conversations, and existing preferences are preserved. | How wide the list can get depends on the window: the thread always keeps enough room to read a message, and the contact panel counts toward that when it is open, so the widest setting is narrower on a laptop than on a large monitor. diff --git a/web/src/components/app/unibox/ScopeRail.tsx b/web/src/components/app/unibox/ScopeRail.tsx index 0df027589..6af3a7caa 100644 --- a/web/src/components/app/unibox/ScopeRail.tsx +++ b/web/src/components/app/unibox/ScopeRail.tsx @@ -9,20 +9,36 @@ // Views (premade, over the automatic labels) // Mailboxes / Labels / Tags (collapsible, searchable past 8 items) // -// Every section header folds, and the fold is remembered. Mail and Views also -// have a pencil to hide rows from the rail, Apple-Mail style. -// Hiding is only visual; the scope, its URL and its shortcuts keep working. +// Every section folds and can be moved; Mail and Views rows can be hidden and +// reordered. All of it is a per-browser preference: hiding is only visual, so +// a scope's URL and shortcuts keep working. // // Today and This week are not rows: the filter sheet's date range covers // them, and the rail is for the places mail lives, not for every slice of it. import React from "react"; +import { + AnimatePresence, + LayoutGroup, + MotionConfig, + Reorder, + motion, + useDragControls, + useReducedMotion, + type DragControls, +} from "framer-motion"; +import toast from "react-hot-toast"; import { ArchiveIcon, - CheckIcon, + ArrowDownIcon, + ArrowUpIcon, ChevronDownIcon, - ChevronRightIcon, + ChevronsDownUpIcon, + ChevronsUpDownIcon, + EyeIcon, + EyeOffIcon, FileTextIcon, + GripVerticalIcon, InboxIcon, LayersIcon, MailIcon, @@ -32,6 +48,7 @@ import { PencilIcon, OctagonAlertIcon, ReplyIcon, + RotateCcwIcon, SearchIcon, SendIcon, SparklesIcon, @@ -42,18 +59,23 @@ import { BotIcon, BanIcon, TriangleAlertIcon, + XIcon, } from "lucide-react"; import { useAppStore } from "@/stores"; +import { applyRailOrder } from "@/stores/slices/uiSlice"; import useUniboxOverview from "@/lib/api/hooks/app/unibox/useUniboxOverview"; import useMarkSeen from "@/lib/api/hooks/app/unibox/useMarkSeen"; import AnimatedNumber from "@/components/ui/AnimatedNumber"; import ComposeDraftsItem from "@/components/app/unibox/compose/ComposeDraftsItem"; import { cn } from "@/lib/utils"; import { DitherMeter } from "@/components/ui/dither"; +import { CheckSquare } from "@/components/ui/check-square"; import { PopoverMenu, PopoverMenuContent, PopoverMenuItem, + PopoverMenuKbd, + PopoverMenuSeparator, PopoverMenuTrigger, } from "@/components/ui/popover-menu"; import type { UniboxFolder } from "@/lib/api/models/app/unibox/UniboxSearch"; @@ -61,6 +83,7 @@ import { TagMeaningTooltip } from "@/components/ui/tag-meaning-tooltip"; import { isAutomaticTag } from "@/lib/unibox/tagMeanings"; import { UNIBOX_VIEWS, viewCategories, type UniboxViewId } from "@/lib/unibox/views"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { shortcutLabel } from "@/components/ui/shortcut-tooltip"; export type UniboxScope = | { kind: "all" } @@ -120,15 +143,61 @@ const VIEW_ICONS: Record = { const COLLAPSE_THRESHOLD = 8; const COLLAPSED_VISIBLE = 6; +const SECTION_IDS = ["mail", "views", "mailboxes", "labels", "tags"]; + +// The sidebar's fold easing, so both navigators move alike. +const EASE = [0.2, 0, 0, 1] as const; + +function useRailTransition() { + const reduce = useReducedMotion(); + return reduce ? { duration: 0 } : { duration: 0.22, ease: EASE }; +} + +// Each row folds its own height, so its neighbours close in instead of snapping. +const FOLD = { + initial: { height: 0, opacity: 0, overflow: "hidden" }, + animate: { height: "auto", opacity: 1, transitionEnd: { overflow: "visible" } }, + exit: { height: 0, opacity: 0, overflow: "hidden" }, +} as const; + +const sameOrder = (a: string[], b: string[]) => + a.length === b.length && a.every((k, i) => k === b[i]); + +// Puts `key` just before or after `neighbour`, the rest keeping their order. +function moveNextTo(order: string[], key: string, neighbour: string, delta: -1 | 1) { + const out = order.filter((k) => k !== key); + const n = out.indexOf(neighbour); + out.splice(delta < 0 ? n : n + 1, 0, key); + return out; +} + interface ScopeRailProps { scope: UniboxScope; onChange: (s: UniboxScope) => void; } +// What a section needs to fold its siblings and move itself among them. +interface SectionControls { + isFirst: boolean; + isLast: boolean; + move: (delta: -1 | 1) => void; + canFoldOthers: boolean; + anyFolded: boolean; + foldOthers: () => void; + unfoldAll: () => void; +} + export function ScopeRail({ scope, onChange }: ScopeRailProps) { const overview = useUniboxOverview(); const data = overview.data; const markSeen = useMarkSeen(); + // The page and the mobile sheet both mount a rail; each slides its own highlight. + const layoutGroup = React.useId(); + const transition = useRailTransition(); + const sectionOrder = useAppStore((s) => s.uniboxRailSectionOrder); + const setSectionOrder = useAppStore((s) => s.setUniboxRailSectionOrder); + const folded = useAppStore((s) => s.uniboxRailFolded); + const setFolded = useAppStore((s) => s.setUniboxRailFolded); const active = scopeKey(scope); const folderCounts = React.useMemo(() => { @@ -139,60 +208,27 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) { return m; }, [data?.folders]); - // One descriptor per row so a section can fold, hide and edit them without - // knowing what each one is. `key` is the scopeKey, which is also what is - // stored when a row is hidden. const scopeRow = ( target: UniboxScope, label: string, icon: React.ReactNode, count: number | undefined, { accent = false }: { accent?: boolean } = {}, - ): RailRow => { - const key = scopeKey(target); - return { - key, - label, - icon, - accent, - node: ( - onChange(target)} - /> - ), - }; - }; + ): RailRow => ({ key: scopeKey(target), label, icon, count, accent, onOpen: () => onChange(target) }); - const folderRow = ( - folder: UniboxFolder, - label: string, - icon: React.ReactNode, - ): RailRow => { + const folderRow = (folder: UniboxFolder, label: string, icon: React.ReactNode): RailRow => { const counts = folderCounts.get(folder); // Drafts reads better as a total; everywhere else the number is unread. const count = folder === "drafts" ? counts?.total : counts?.unread; - const accent = folder !== "drafts" && !!count; return { key: `folder:${folder}`, label, icon, - accent, - node: ( - onChange({ kind: "folder", folder })} - onMarkAllRead={() => markSeen.mutate({ folder, seen: true })} - /> - ), + count: count || undefined, + accent: folder !== "drafts" && !!count, + noun: "folder", + onOpen: () => onChange({ kind: "folder", folder }), + onMarkAllRead: () => markSeen.mutate({ folder, seen: true }), }; }; @@ -239,135 +275,182 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) { key: `view:${v.id}`, label: v.label, icon: VIEW_ICONS[v.id], + count: unread || undefined, accent: unread > 0, - node: ( - - -
- 0} - active={active === `view:${v.id}`} - onClick={() => onChange({ kind: "view", view: v.id })} - /> -
-
- - {v.meaning} - -
- ), + noun: "view", + tooltip: v.meaning, + onOpen: () => onChange({ kind: "view", view: v.id }), }; }) : []; - return ( - + // Arrow keys walk the rows like a list; Tab still visits every control. + const onRailKeyDown = (e: React.KeyboardEvent) => { + if (e.altKey || e.metaKey || e.ctrlKey || e.shiftKey) return; + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return; + const target = e.target as HTMLElement; + if (!target.matches("[data-rail-row]")) return; + const rows = Array.from(e.currentTarget.querySelectorAll("[data-rail-row]")); + const at = rows.indexOf(target); + const next = + e.key === "Home" ? 0 : e.key === "End" ? rows.length - 1 : at + (e.key === "ArrowDown" ? 1 : -1); + if (next < 0 || next >= rows.length) return; + e.preventDefault(); + e.stopPropagation(); + rows[next].focus(); + }; + + return ( + + + + {/* Collapses when there are no drafts. */} +
+ +
+ {present.map((id) => ( + + {section(id)} + + ))} +
+
+
); } @@ -383,21 +466,46 @@ function Dot({ color }: { color?: string }) { ); } -// One rail row as a section sees it. `key` is the row's scopeKey; `node` is -// the row as it renders normally, while `icon`/`label` are all the edit mode -// needs to draw its checkbox version. +// One Mail or Views row. `key` is its scopeKey, which is what hiding and +// ordering store. interface RailRow { key: string; label: string; icon: React.ReactNode; + count?: number; accent?: boolean; - node: React.ReactNode; + noun?: "folder" | "view"; + tooltip?: string; + onOpen: () => void; + onMarkAllRead?: () => void; } -// The header every rail section shares, so the five of them look and behave -// alike: a fold toggle, an optional "highlighted count folded away" dot, and on the -// right either a count or (Mail, Views) a pencil, with how many rows are -// hidden beside it; the pencil becomes Done while rows are being edited. +// A menu that opens from its "…" button or, on right-click, at the pointer. +function useAnchoredMenu() { + const [open, setOpen] = React.useState(false); + const [point, setPoint] = React.useState<{ x: number; y: number } | null>(null); + const onContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + // A keyboard context-menu key reports 0,0: open under the element instead. + const r = e.currentTarget.getBoundingClientRect(); + setPoint(e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : { x: r.left + 12, y: r.bottom }); + setOpen(true); + }; + const menuProps = { + open, + anchorPoint: point, + onOpenChange: (o: boolean) => { + if (o) setPoint(null); + setOpen(o); + }, + }; + return { open, onContextMenu, menuProps }; +} + +// The header every rail section shares: a fold toggle, a dot when a folded +// section hides a highlighted count, and on the right a count or the row +// editor's pencil, then the section's own menu. function SectionHeader({ label, panelId, @@ -408,7 +516,7 @@ function SectionHeader({ editing, onEdit, hiddenCount, - optionsRef, + menu, }: { label: string; panelId: string; @@ -419,142 +527,345 @@ function SectionHeader({ editing?: boolean; onEdit?: () => void; hiddenCount?: number; - optionsRef?: React.Ref; + menu: React.ReactNode; }) { const hiddenId = React.useId(); + const transition = useRailTransition(); + const sectionMenu = useAnchoredMenu(); + const pop = { + initial: { opacity: 0, scale: 0.6 }, + animate: { opacity: 1, scale: 1 }, + exit: { opacity: 0, scale: 0.6 }, + transition, + }; return ( -
+
- {/* Non-interactive: a reminder that rows are missing, so a rail that - looks short is not mistaken for a bug. Slate-500 (4.7:1 on white), - because it is new information, unlike the quieter slate-300 totals. - It also describes the pencil, so tabbing to it hears the number. */} - {!!hiddenCount && ( - - {hiddenCount} hidden - + {/* Says rows are off the rail, and describes the pencil to a screen reader. */} + + {!!hiddenCount && ( + + {hiddenCount} hidden + + )} + + {onEdit && ( + + {editing ? ( + + Done + + ) : ( + // Always visible: it is the way in to hiding and ordering rows. + + + + )} + + )} + {!editing && ( + + + + + {menu} + )} - {onEdit && - (editing ? ( - - ) : ( - // Always visible, unlike the folder menu: this is the only way in to - // hiding rows, so it cannot wait for a hover to be found. At rest it - // is as quiet as the section label and darkens on hover, like the - // rest of the header. - - ))}
); } -// RailSection: a fixed list of rows (Mail, Views) that folds and that the user -// can trim. Hidden rows stay out of the rail but not out of the app: the scope, -// its URL and its shortcuts all still work, so the row you are standing on is -// never hidden from under you. +const MENU_ICON = "w-3 h-3"; + +// What every section's menu starts with: folding and moving the section. +function SectionMenuItems({ + open, + onToggle, + controls, + children, +}: { + open: boolean; + onToggle: () => void; + controls: SectionControls; + children?: React.ReactNode; +}) { + return ( + <> + : } + onSelect={onToggle} + > + {open ? "Fold section" : "Unfold section"} + + } + disabled={!controls.canFoldOthers} + onSelect={controls.foldOthers} + > + Fold other sections + + } + disabled={!controls.anyFolded} + onSelect={controls.unfoldAll} + > + Unfold all sections + + + } + disabled={controls.isFirst} + onSelect={() => controls.move(-1)} + > + Move section up + + } + disabled={controls.isLast} + onSelect={() => controls.move(1)} + > + Move section down + + {children && ( + <> + + {children} + + )} + + ); +} + +// RailSection: a fixed list of rows (Mail, Views) that folds, and whose rows +// the user can hide and reorder. The row you are on is never hidden. function RailSection({ id, label, rows, activeKey, + controls, footer, }: { id: string; label: string; rows: RailRow[]; activeKey: string; + controls: SectionControls; footer?: React.ReactNode; }) { const panelId = React.useId(); + const transition = useRailTransition(); const folded = useAppStore((s) => s.uniboxRailFolded[id] ?? false); const toggleSection = useAppStore((s) => s.toggleUniboxRailSection); const hiddenKeys = useAppStore((s) => s.uniboxRailHidden); const toggleRow = useAppStore((s) => s.toggleUniboxRailRow); + const setRowsHidden = useAppStore((s) => s.setUniboxRailRowsHidden); + const storedOrder = useAppStore((s) => s.uniboxRailOrder[id]); + const setOrder = useAppStore((s) => s.setUniboxRailOrder); // Local on purpose: editing is a moment, not a preference. const [editing, setEditing] = React.useState(false); - // Both ends of editing swap the focused control for another one (the menu - // trigger becomes Done, the checkboxes become rows), so focus is moved on - // purpose: to the first checkbox going in, back to the options button out. - // The options button is found through the header, because the menu trigger - // puts its own ref on it. - const headerRef = React.useRef(null); + // The order while a drag is in flight; committed once, on the drop. + const [draft, setDraft] = React.useState(null); + // Mirrors `draft` so a drop that lands before the next render still commits it. + const draftRef = React.useRef(null); + const updateDraft = (keys: string[] | null) => { + draftRef.current = keys; + setDraft(keys); + }; + + const [announcement, setAnnouncement] = React.useState(""); + // Rows cross-fade between modes only after a toggle, never on first paint. + const [swapped, setSwapped] = React.useState(false); + const sectionRef = React.useRef(null); const firstEditRowRef = React.useRef(null); const wasEditing = React.useRef(false); + + const setEditMode = (on: boolean) => { + setSwapped(true); + updateDraft(null); + setEditing(on); + }; + + // Focus follows the swap: into the checkboxes, then back to the pencil, + // unless the user has already put it somewhere else. React.useEffect(() => { if (editing === wasEditing.current) return; wasEditing.current = editing; - const target = editing - ? firstEditRowRef.current - : headerRef.current?.querySelector("[data-rail-options]"); - target?.focus(); + if (editing) { + firstEditRowRef.current?.focus(); + return; + } + const focused = document.activeElement; + if (focused && focused !== document.body && !sectionRef.current?.contains(focused)) return; + sectionRef.current?.querySelector("[data-rail-options]")?.focus(); }, [editing]); - const wanted = rows.filter((r) => r.key === activeKey || !hiddenKeys.includes(r.key)); - // Folded, a section keeps only the row you are on. Editing needs every row, - // hidden ones included, so it also unfolds the section while it lasts. + // A click anywhere else ends editing, as Done would. + React.useEffect(() => { + if (!editing) return; + const onDown = (e: MouseEvent) => { + const t = e.target as Element | null; + if (!t || sectionRef.current?.contains(t) || t.closest?.("[data-floating]")) return; + setEditing(false); + draftRef.current = null; + setDraft(null); + }; + document.addEventListener("mousedown", onDown, true); + return () => document.removeEventListener("mousedown", onDown, true); + }, [editing]); + + const defaults = rows.map((r) => r.key); + const orderKeys = draft ?? applyRailOrder(defaults, storedOrder); + const byKey = new Map(rows.map((r) => [r.key, r])); + const ordered = orderKeys.flatMap((k) => byKey.get(k) ?? []); + const isHidden = (k: string) => hiddenKeys.includes(k); + const wanted = ordered.filter((r) => r.key === activeKey || !isHidden(r.key)); const open = editing || !folded; - const shown = editing ? rows : open ? wanted : wanted.filter((r) => r.key === activeKey); + const shown = editing ? ordered : open ? wanted : wanted.filter((r) => r.key === activeKey); const dot = !open && wanted.some((r) => r.key !== activeKey && r.accent); + const hiddenHere = defaults.filter(isHidden); + const isDefault = hiddenHere.length === 0 && sameOrder(applyRailOrder(defaults, storedOrder), defaults); + + const commit = (keys: string[]) => setOrder(id, sameOrder(keys, defaults) ? null : keys); + + // Steps past the neighbour the user can see, so a hidden row never swallows a + // move; a folded section shows no neighbours, so nothing moves there. + const visibleForMove = editing ? ordered : open ? wanted : []; + const canMove = (key: string, delta: -1 | 1) => { + const at = visibleForMove.findIndex((r) => r.key === key); + return at >= 0 && !!visibleForMove[at + delta]; + }; + const move = (key: string, delta: -1 | 1) => { + const at = visibleForMove.findIndex((r) => r.key === key); + const neighbour = visibleForMove[at + delta]; + if (at < 0 || !neighbour) return; + commit(moveNextTo(orderKeys, key, neighbour.key, delta)); + setAnnouncement(`${byKey.get(key)?.label} moved to position ${at + delta + 1} of ${visibleForMove.length}`); + }; + + const hideWithUndo = (row: RailRow) => { + setRowsHidden([row.key], true); + const when = row.key === activeKey ? " once you leave it" : ""; + toast( + (t) => ( + + + {row.label} is hidden from the rail{when}. + + + + ), + { id: `rail-hidden:${row.key}` }, + ); + }; + + const reset = () => { + setRowsHidden(defaults, false); + setOrder(id, null); + updateDraft(null); + setAnnouncement(`${label} rows are back to the default`); + }; + + const menu = ( + toggleSection(id)} controls={controls}> + } onSelect={() => setEditMode(true)}> + Edit rows… + + {hiddenHere.length > 0 && ( + } onSelect={() => setRowsHidden(hiddenHere, false)}> + Show {hiddenHere.length} hidden {hiddenHere.length === 1 ? "row" : "rows"} + + )} + } disabled={isDefault} onSelect={reset}> + Reset to default + + + ); return (
{ if (editing && e.key === "Escape") { e.stopPropagation(); - setEditing(false); + setEditMode(false); } }} > @@ -565,35 +876,190 @@ function RailSection({ onToggle={() => toggleSection(id)} dot={dot} editing={editing} - onEdit={() => setEditing((v) => !v)} - hiddenCount={editing ? 0 : rows.filter((r) => hiddenKeys.includes(r.key)).length} - optionsRef={headerRef} + onEdit={() => setEditMode(!editing)} + hiddenCount={editing ? 0 : hiddenHere.length} + menu={menu} /> -
- {shown.map((r, i) => ( - - {editing ? ( - - ))} - {open && footer} -
+ r.key)} + onReorder={(keys: string[]) => editing && updateDraft(keys)} + className="px-2 space-y-px" + > + + {shown.map((r, i) => ( + + + + {editing && ( + +
+ Drag to reorder, untick to hide + +
+
+ )} + {open && !editing && footer ? ( + + {footer} + + ) : null} +
+ + {announcement} +
); } +// One Mail or Views row, as it folds, reorders and swaps into its edit form. +function RailRowItem({ + row, + sectionLabel, + active, + editing, + swapped, + hidden, + buttonRef, + canMoveUp, + canMoveDown, + onMove, + onToggleHidden, + onHide, + onEdit, + onDragEnd, +}: { + row: RailRow; + sectionLabel: string; + active: boolean; + editing: boolean; + swapped: boolean; + hidden: boolean; + buttonRef?: React.Ref; + canMoveUp: boolean; + canMoveDown: boolean; + onMove: (delta: -1 | 1) => void; + onToggleHidden: () => void; + onHide: () => void; + onEdit: () => void; + onDragEnd: () => void; +}) { + const transition = useRailTransition(); + const drag = useDragControls(); + return ( + + + {editing ? ( + + + ); +} + function CollapsibleSection({ id, label, + controls, items, isActive, hasAccent, @@ -604,6 +1070,7 @@ function CollapsibleSection({ }: { id: string; label: string; + controls: SectionControls; items: T[]; isActive: (item: T) => boolean; hasAccent: (item: T) => boolean; @@ -613,6 +1080,7 @@ function CollapsibleSection({ renderItem: (item: T) => React.ReactNode; }) { const panelId = React.useId(); + const transition = useRailTransition(); const [search, setSearch] = React.useState(""); const [expanded, setExpanded] = React.useState(false); const sectionOpen = useAppStore((s) => !(s.uniboxRailFolded[id] ?? false)); @@ -629,10 +1097,16 @@ function CollapsibleSection({ const visible = showCollapse && !expanded ? filtered.slice(0, COLLAPSED_VISIBLE) : filtered; const hidden = filtered.length - visible.length; - // Folded, only the row you are on stays, even if the list would have tucked - // it behind "Show all"; a highlighted count folded away leaves a dot instead. - const foldedVisible = sectionOpen ? [] : items.filter(isActive); + // Folded, only the row you are on stays, even one "Show all" would have tucked away. + const rows = sectionOpen ? visible : items.filter(isActive); const dot = !sectionOpen && items.some((it) => !isActive(it) && hasAccent(it)); + const message = !sectionOpen + ? null + : items.length === 0 + ? emptyText + : filtered.length === 0 + ? "No matches." + : null; return (
@@ -643,72 +1117,89 @@ function CollapsibleSection({ onToggle={() => toggleSection(id)} dot={dot} count={items.length} + menu={ toggleSection(id)} controls={controls} />} /> +
+ + {sectionOpen && showSearch && ( + +
+ + setSearch(e.target.value)} + onKeyDown={(e) => { + if (e.key !== "Escape") return; + e.stopPropagation(); + if (search) setSearch(""); + else e.currentTarget.blur(); + }} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + className="flex-1 min-w-0 h-5 bg-transparent text-[11.5px] text-slate-900 placeholder:text-slate-400 outline-none" + /> + + {search && ( + setSearch("")} + className="size-4 rounded inline-flex items-center justify-center text-slate-400 hover:text-slate-700 hover:bg-slate-100 shrink-0" + aria-label="Clear filter" + initial={{ opacity: 0, scale: 0.6 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.6 }} + transition={transition} + > + + + )} + +
+
+ )} + {message && ( + +
{message}
+
+ )} +
- {/* Rendered even when empty, so the header's aria-controls always has - a target. */} - {!sectionOpen && ( -
- {foldedVisible.map(renderItem)} +
+ + {rows.map((it) => ( + + {renderItem(it)} + + ))} +
- )} - {sectionOpen && ( -
- {showSearch && ( -
- - setSearch(e.target.value)} - placeholder={searchPlaceholder} - className="flex-1 min-w-0 h-5 bg-transparent text-[11.5px] text-slate-900 placeholder:text-slate-400 outline-none" - /> - {search && ( - - )} -
+ + {sectionOpen && hidden > 0 && ( + + + )} - - {items.length === 0 ? ( -
- {emptyText} -
- ) : filtered.length === 0 ? ( -
- No matches. -
- ) : ( -
{visible.map(renderItem)}
+ {sectionOpen && expanded && filtered.length > COLLAPSED_VISIBLE && ( + + + )} - - {hidden > 0 && ( - - )} - {expanded && filtered.length > COLLAPSED_VISIBLE && ( - - )} -
- )} + +
); } @@ -750,10 +1241,24 @@ function ScheduledMeter({ used, cap }: { used: number; cap: number }) { } const ROW = - "w-full h-7 pl-2 pr-2 rounded-md flex items-center gap-2.5 transition-colors text-left"; -const ROW_ACTIVE = "bg-slate-100 text-slate-900"; + "w-full h-7 pl-2 pr-2 rounded-md flex items-center gap-2.5 transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200"; +// The active row's background is the sliding ActiveMark, not a class. +const ROW_ACTIVE = "text-slate-900"; const ROW_IDLE = "text-slate-600 hover:bg-slate-50 hover:text-slate-900"; +// The highlight behind the row you are on; it slides to the next one you pick. +function ActiveMark() { + const reduce = useReducedMotion(); + return ( + + ); +} + function Count({ value, accent, active }: { value: number; accent?: boolean; active?: boolean }) { return ( void; - onMarkAllRead: () => void; + row: RailRow; + active: boolean; + onMove: (delta: -1 | 1) => void; + menu: React.ReactNode; }) { - return ( -
{ - // Only the row itself activates: keydown from the nested menu trigger - // bubbles here, and Enter on the three-dot button must open its menu. - if (e.target !== e.currentTarget) return; - if (e.key === "Enter" || e.key === " ") { + const rowMenu = useAnchoredMenu(); + const body = ( +
+ + - - } - onSelect={onMarkAllRead} - > - Mark all as read - - + {menu}
); + if (!row.tooltip) return body; + return ( + + +
{body}
+
+ + {row.tooltip} + +
+ ); } -// EditRow: a row while its section is being edited. A checkbox (checked means -// shown) instead of a link, so a click changes visibility rather than scope. +// EditRow: a row while its section is edited. The grip drags it (or moves it +// with the arrow keys); the rest is a checkbox, checked meaning shown. function EditRow({ - buttonRef, - icon, - label, + row, hidden, + drag, + buttonRef, onToggle, + onMove, }: { - buttonRef?: React.Ref; - icon: React.ReactNode; - label: string; + row: RailRow; hidden: boolean; + drag: DragControls; + buttonRef?: React.Ref; onToggle: () => void; + onMove: (delta: -1 | 1) => void; }) { + const moveKey = (e: React.KeyboardEvent, from: "row" | "grip") => { + if (from === "row" && !e.altKey) return; + if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; + e.preventDefault(); + e.stopPropagation(); + onMove(e.key === "ArrowUp" ? -1 : 1); + }; return ( - + + + +
); } @@ -905,13 +1444,15 @@ function Item({ return (