mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 00:01:56 +00:00
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
This commit is contained in:
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { useAppStore } from "@/stores";
|
||||
import { sanitizeUniboxRailHidden } from "@/stores/slices/uiSlice";
|
||||
import { applyRailOrder, sanitizeUniboxRailHidden, sanitizeUniboxRailOrder } from "@/stores/slices/uiSlice";
|
||||
import { ScopeRail, type UniboxScope } from "./ScopeRail";
|
||||
|
||||
const overview = vi.hoisted(() => ({
|
||||
@@ -70,7 +70,12 @@ function mountRail(scope: UniboxScope = { kind: "all" }) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAppStore.setState({ uniboxRailFolded: {}, uniboxRailHidden: [] });
|
||||
useAppStore.setState({
|
||||
uniboxRailFolded: {},
|
||||
uniboxRailHidden: [],
|
||||
uniboxRailOrder: {},
|
||||
uniboxRailSectionOrder: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -363,3 +368,268 @@ describe("sanitizeUniboxRailHidden", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const MAIL_DEFAULT = [
|
||||
"all", "folder:inbox", "unread", "awaiting", "agent_drafts", "snoozed",
|
||||
"folder:drafts", "folder:sent", "scheduled", "folder:archive", "folder:spam", "folder:trash",
|
||||
];
|
||||
|
||||
// The rail row element around a label, as the keyboard sees it.
|
||||
const rowOf = (label: string) => screen.getByText(label).closest("[data-rail-row]") as HTMLElement;
|
||||
|
||||
// Headers in the order they are on screen.
|
||||
const headerOrder = () =>
|
||||
screen.getAllByRole("button", { expanded: true }).map((b) => b.textContent?.replace(/\d+$/, ""));
|
||||
|
||||
describe("row order", () => {
|
||||
it("moves a row with the grip's arrow keys while editing, and says where it went", () => {
|
||||
mountRail();
|
||||
startEditing("Mail");
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: /^Move Spam/ }), { key: "ArrowUp" });
|
||||
|
||||
const order = useAppStore.getState().uniboxRailOrder.mail;
|
||||
expect(order.indexOf("folder:spam")).toBe(order.indexOf("folder:archive") - 1);
|
||||
expect(screen.getByText("Spam moved to position 10 of 12")).toBeTruthy();
|
||||
|
||||
// Checkboxes follow the new order.
|
||||
const names = screen.getAllByRole("checkbox").map((c) => c.textContent);
|
||||
expect(names.indexOf("Spam")).toBeLessThan(names.indexOf("Archive"));
|
||||
});
|
||||
|
||||
it("moves a row with Alt+arrow outside edit mode, stepping over a hidden neighbour", () => {
|
||||
useAppStore.setState({ uniboxRailHidden: ["unread"] });
|
||||
mountRail();
|
||||
fireEvent.keyDown(rowOf("Inbox"), { key: "ArrowDown", altKey: true });
|
||||
expect(useAppStore.getState().uniboxRailOrder.mail.slice(0, 4)).toEqual([
|
||||
"all", "unread", "awaiting", "folder:inbox",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stores nothing once a row is moved back to where it started", () => {
|
||||
mountRail();
|
||||
fireEvent.keyDown(rowOf("Inbox"), { key: "ArrowDown", altKey: true });
|
||||
expect(useAppStore.getState().uniboxRailOrder.mail).toBeDefined();
|
||||
fireEvent.keyDown(rowOf("Inbox"), { key: "ArrowUp", altKey: true });
|
||||
expect(useAppStore.getState().uniboxRailOrder.mail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders a stored order", () => {
|
||||
useAppStore.setState({ uniboxRailOrder: { mail: ["folder:trash", ...MAIL_DEFAULT.slice(0, -1)] } });
|
||||
mountRail();
|
||||
const rows = Array.from(document.querySelectorAll("[data-rail-row]")).map((r) => r.textContent);
|
||||
expect(rows[0]).toContain("Trash");
|
||||
});
|
||||
|
||||
it("resets order and hidden rows from the edit footer", () => {
|
||||
useAppStore.setState({
|
||||
uniboxRailHidden: ["folder:spam", "view:hot"],
|
||||
uniboxRailOrder: { mail: ["folder:trash", ...MAIL_DEFAULT.slice(0, -1)] },
|
||||
});
|
||||
mountRail();
|
||||
startEditing("Mail");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reset" }));
|
||||
expect(useAppStore.getState().uniboxRailOrder.mail).toBeUndefined();
|
||||
// Only this section's rows come back.
|
||||
expect(useAppStore.getState().uniboxRailHidden).toEqual(["view:hot"]);
|
||||
expect((screen.getByRole("button", { name: "Reset" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("row menu", () => {
|
||||
it("hides a row from its menu", () => {
|
||||
mountRail();
|
||||
fireEvent.click(screen.getByLabelText("Spam folder actions"));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Hide from rail" }));
|
||||
expect(useAppStore.getState().uniboxRailHidden).toEqual(["folder:spam"]);
|
||||
expect(screen.queryByText("Spam")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the same menu on right-click, without opening the scope", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ScopeRail scope={{ kind: "all" }} onChange={onChange} />);
|
||||
fireEvent.contextMenu(rowOf("Unread"), { clientX: 40, clientY: 80 });
|
||||
expect(screen.getByRole("menuitem", { name: /Move up/ })).toBeTruthy();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables moves the row cannot make", () => {
|
||||
mountRail();
|
||||
fireEvent.click(screen.getByLabelText("All mail actions"));
|
||||
expect((screen.getByRole("menuitem", { name: /Move up/ }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((screen.getByRole("menuitem", { name: /Move down/ }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("walks its items with the arrow keys", async () => {
|
||||
mountRail();
|
||||
fireEvent.click(screen.getByLabelText("Inbox folder actions"));
|
||||
const menu = screen.getByRole("menu");
|
||||
fireEvent.keyDown(menu, { key: "ArrowDown" });
|
||||
expect(document.activeElement?.textContent).toBe("Mark all as read");
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: "ArrowDown" });
|
||||
expect(document.activeElement?.textContent).toContain("Move up");
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
|
||||
expect(document.activeElement?.textContent).toContain("Edit Mail rows");
|
||||
});
|
||||
});
|
||||
|
||||
describe("section menu", () => {
|
||||
it("moves a section and remembers it", () => {
|
||||
mountRail();
|
||||
expect(headerOrder().slice(0, 2)).toEqual(["Mail", "Views"]);
|
||||
fireEvent.click(screen.getByLabelText("Mail section options"));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Move section down" }));
|
||||
expect(useAppStore.getState().uniboxRailSectionOrder.slice(0, 2)).toEqual(["views", "mail"]);
|
||||
expect(headerOrder().slice(0, 2)).toEqual(["Views", "Mail"]);
|
||||
});
|
||||
|
||||
it("folds every other section", () => {
|
||||
mountRail();
|
||||
fireEvent.click(screen.getByLabelText("Views section options"));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Fold other sections" }));
|
||||
const folded = useAppStore.getState().uniboxRailFolded;
|
||||
expect(folded).toMatchObject({ mail: true, views: false, mailboxes: true, labels: true });
|
||||
expect(screen.getByText("Hot leads")).toBeTruthy();
|
||||
expect(screen.queryByText("Inbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers to show the hidden rows", () => {
|
||||
useAppStore.setState({ uniboxRailHidden: ["folder:spam", "folder:trash"] });
|
||||
mountRail();
|
||||
fireEvent.click(screen.getByLabelText("Mail section options"));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Show 2 hidden rows" }));
|
||||
expect(useAppStore.getState().uniboxRailHidden).toEqual([]);
|
||||
expect(screen.getByText("Spam")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("starts editing from the menu", () => {
|
||||
mountRail();
|
||||
fireEvent.click(screen.getByLabelText("Views section options"));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Edit rows…" }));
|
||||
expect(screen.getByRole("checkbox", { name: "Hot leads" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("edit mode ends", () => {
|
||||
it("on a click outside the section", () => {
|
||||
mountRail();
|
||||
startEditing("Mail");
|
||||
fireEvent.mouseDown(screen.getByText("me@example.com"));
|
||||
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("not on a click inside it", () => {
|
||||
mountRail();
|
||||
startEditing("Mail");
|
||||
fireEvent.mouseDown(screen.getByRole("checkbox", { name: "Spam" }));
|
||||
expect(screen.getAllByRole("checkbox").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("arrow keys between rows", () => {
|
||||
it("move focus down the rail and across sections", () => {
|
||||
mountRail();
|
||||
rowOf("All mail").focus();
|
||||
fireEvent.keyDown(rowOf("All mail"), { key: "ArrowDown" });
|
||||
expect(document.activeElement).toBe(rowOf("Inbox"));
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
|
||||
expect(document.activeElement?.textContent).toContain("Interested");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyRailOrder", () => {
|
||||
it("keeps the stored order, drops keys that are gone, and slots new ones after their default neighbour", () => {
|
||||
expect(applyRailOrder(["a", "b", "c"], ["c", "a", "b"])).toEqual(["c", "a", "b"]);
|
||||
expect(applyRailOrder(["a", "b"], ["b", "gone", "a"])).toEqual(["b", "a"]);
|
||||
expect(applyRailOrder(["a", "b", "new", "c"], ["c", "b", "a"])).toEqual(["c", "b", "new", "a"]);
|
||||
expect(applyRailOrder(["first", "a"], ["a"])).toEqual(["first", "a"]);
|
||||
expect(applyRailOrder(["a"], undefined)).toEqual(["a"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeUniboxRailOrder", () => {
|
||||
it("keeps string lists only, deduplicated", () => {
|
||||
expect(sanitizeUniboxRailOrder({ mail: ["a", "a", 3, "b"], views: "x", tags: [] })).toEqual({
|
||||
mail: ["a", "b"],
|
||||
tags: [],
|
||||
});
|
||||
expect(sanitizeUniboxRailOrder(["mail"])).toEqual({});
|
||||
});
|
||||
|
||||
it("is persisted with the section order", () => {
|
||||
useAppStore.setState({ uniboxRailOrder: { mail: ["unread"] }, uniboxRailSectionOrder: ["views"] });
|
||||
const persisted = useAppStore.persist.getOptions().partialize?.(useAppStore.getState()) as {
|
||||
uniboxRailOrder?: unknown;
|
||||
uniboxRailSectionOrder?: unknown;
|
||||
};
|
||||
expect(persisted.uniboxRailOrder).toEqual({ mail: ["unread"] });
|
||||
expect(persisted.uniboxRailSectionOrder).toEqual(["views"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard moves keep their place", () => {
|
||||
// Browsers blur a focused node that is moved in the DOM (React restores it); jsdom does not, so mimic it.
|
||||
const insertBefore = Node.prototype.insertBefore;
|
||||
beforeEach(() => {
|
||||
Node.prototype.insertBefore = function <T extends Node>(this: Node, node: T, child: Node | null): T {
|
||||
const focused = document.activeElement;
|
||||
if (node.isConnected && focused && node.contains(focused)) (focused as HTMLElement).blur();
|
||||
return insertBefore.call(this, node, child) as T;
|
||||
};
|
||||
});
|
||||
afterEach(() => {
|
||||
Node.prototype.insertBefore = insertBefore;
|
||||
});
|
||||
|
||||
it("keeps focus on the row it moved", () => {
|
||||
mountRail();
|
||||
rowOf("Inbox").focus();
|
||||
fireEvent.keyDown(rowOf("Inbox"), { key: "ArrowDown", altKey: true });
|
||||
expect(document.activeElement).toBe(rowOf("Inbox"));
|
||||
fireEvent.keyDown(rowOf("Inbox"), { key: "ArrowDown", altKey: true });
|
||||
expect(useAppStore.getState().uniboxRailOrder.mail.slice(0, 4)).toEqual([
|
||||
"all", "unread", "awaiting", "folder:inbox",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps focus on the grip it moved", () => {
|
||||
mountRail();
|
||||
startEditing("Mail");
|
||||
const grip = () => screen.getByRole("button", { name: /^Move Inbox/ });
|
||||
grip().focus();
|
||||
fireEvent.keyDown(grip(), { key: "ArrowDown" });
|
||||
expect(document.activeElement).toBe(grip());
|
||||
});
|
||||
|
||||
it("offers no moves in a folded section", () => {
|
||||
useAppStore.setState({ uniboxRailFolded: { mail: true } });
|
||||
mountRail({ kind: "unread" });
|
||||
fireEvent.click(screen.getByLabelText("Unread actions"));
|
||||
expect((screen.getByRole("menuitem", { name: /Move up/ }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((screen.getByRole("menuitem", { name: /Move down/ }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropdown keys", () => {
|
||||
it("jump to the first matching item from the panel, and never reach global shortcuts", () => {
|
||||
const globalKeys = vi.fn();
|
||||
window.addEventListener("keydown", globalKeys);
|
||||
try {
|
||||
mountRail();
|
||||
fireEvent.click(screen.getByLabelText("Mail section options"));
|
||||
fireEvent.keyDown(screen.getByRole("menu"), { key: "f" });
|
||||
expect(document.activeElement?.textContent).toBe("Fold section");
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: "e" });
|
||||
expect(document.activeElement?.textContent).toBe("Edit rows…");
|
||||
expect(globalKeys).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
window.removeEventListener("keydown", globalKeys);
|
||||
}
|
||||
});
|
||||
|
||||
it("leave Tab alone", () => {
|
||||
mountRail();
|
||||
fireEvent.click(screen.getByLabelText("Mail section options"));
|
||||
fireEvent.keyDown(screen.getByRole("menu"), { key: "Tab" });
|
||||
expect(screen.getByRole("menu")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
// The checkbox square of the app's pickers (category, tag and column
|
||||
// choosers): a filled slate square with a white check when on, a bordered
|
||||
// white square when off. Purely visual; the row it sits in is the control.
|
||||
// choosers): a filled square with a white check when on, a bordered white
|
||||
// square when off. Purely visual; the row it sits in is the control.
|
||||
// Tones match Checkbox: slate for option toggles, sky for choosing what shows.
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function CheckSquare({ checked }: { checked: boolean }) {
|
||||
const TONE = {
|
||||
slate: "border-slate-900 bg-slate-900",
|
||||
sky: "border-sky-600 bg-sky-600",
|
||||
} as const;
|
||||
|
||||
export function CheckSquare({ checked, tone = "slate" }: { checked: boolean; tone?: keyof typeof TONE }) {
|
||||
return (
|
||||
<span
|
||||
className={`size-3.5 rounded border flex items-center justify-center transition-colors shrink-0 ${
|
||||
checked ? "border-slate-900 bg-slate-900" : "border-slate-300 bg-white"
|
||||
}`}
|
||||
className={cn(
|
||||
"size-3.5 rounded border flex items-center justify-center transition-colors shrink-0",
|
||||
checked ? TONE[tone] : "border-slate-300 bg-white",
|
||||
)}
|
||||
>
|
||||
{checked && <CheckIcon className="w-2 h-2 text-white" />}
|
||||
{checked && <CheckIcon className="w-2 h-2 text-white" strokeWidth={3.5} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ interface MenuCtx {
|
||||
side: "bottom" | "top";
|
||||
align: "start" | "end" | "center";
|
||||
sideOffset: number;
|
||||
anchorPoint: { x: number; y: number } | null;
|
||||
}
|
||||
|
||||
const Ctx = createContext<MenuCtx | null>(null);
|
||||
@@ -70,6 +71,7 @@ export function PopoverMenu({
|
||||
sideOffset = 6,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
anchorPoint = null,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
side?: "bottom" | "top";
|
||||
@@ -77,6 +79,8 @@ export function PopoverMenu({
|
||||
sideOffset?: number;
|
||||
open?: boolean;
|
||||
onOpenChange?: (o: boolean) => void;
|
||||
/** Open at a viewport point instead of under the trigger (a context menu). */
|
||||
anchorPoint?: { x: number; y: number } | null;
|
||||
}) {
|
||||
const id = useId();
|
||||
const triggerRef = useRef<HTMLElement>(null);
|
||||
@@ -90,7 +94,7 @@ export function PopoverMenu({
|
||||
[controlledOpen, onOpenChange],
|
||||
);
|
||||
return (
|
||||
<Ctx.Provider value={{ id, open, setOpen, triggerRef, side, align, sideOffset }}>
|
||||
<Ctx.Provider value={{ id, open, setOpen, triggerRef, side, align, sideOffset, anchorPoint }}>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
);
|
||||
@@ -148,8 +152,10 @@ export function PopoverMenuContent({
|
||||
/** Pin the panel to the trigger's measured width (for full-width selects). */
|
||||
matchTriggerWidth?: boolean;
|
||||
}) {
|
||||
const { open, setOpen, triggerRef, side, align, sideOffset } = useMenu();
|
||||
const { open, setOpen, triggerRef, side, align, sideOffset, anchorPoint } = useMenu();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
// Where focus was when the menu opened, so closing gives it back.
|
||||
const returnFocus = useRef<HTMLElement | null>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number; width?: number } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -160,8 +166,10 @@ export function PopoverMenuContent({
|
||||
const compute = () => {
|
||||
const t = triggerRef.current;
|
||||
const c = ref.current;
|
||||
if (!t || !c) return;
|
||||
const r = t.getBoundingClientRect();
|
||||
if ((!t && !anchorPoint) || !c) return;
|
||||
const r = anchorPoint
|
||||
? new DOMRect(anchorPoint.x, anchorPoint.y, 0, 0)
|
||||
: t!.getBoundingClientRect();
|
||||
const cw = c.offsetWidth;
|
||||
const ch = c.offsetHeight;
|
||||
let top: number;
|
||||
@@ -185,8 +193,13 @@ export function PopoverMenuContent({
|
||||
setPos({ top, left, width: r.width });
|
||||
};
|
||||
compute();
|
||||
// A context menu has no element to follow, so a scroll elsewhere closes it.
|
||||
const onScroll = (e: Event) => {
|
||||
if (anchorPoint && !ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
else compute();
|
||||
};
|
||||
window.addEventListener("resize", compute);
|
||||
window.addEventListener("scroll", compute, true);
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
|
||||
// Recompute when the popover's own content changes size — e.g.
|
||||
// swapping a preset list for a datetime picker inside. Without
|
||||
@@ -200,35 +213,66 @@ export function PopoverMenuContent({
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", compute);
|
||||
window.removeEventListener("scroll", compute, true);
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [open, side, align, sideOffset, triggerRef]);
|
||||
}, [open, side, align, sideOffset, triggerRef, anchorPoint, setOpen]);
|
||||
|
||||
// Keyboard: focus lands on the panel (never over an autofocused input), and
|
||||
// closing hands it back to whatever held it before, if it is still there.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (ref.current?.contains(t)) return;
|
||||
if (triggerRef.current?.contains(t)) return;
|
||||
// A click inside another portaled floating layer this menu opened (a
|
||||
// date-picker calendar, a nested SelectMenu) must not close this menu.
|
||||
const el = t as Element | null;
|
||||
if (el && typeof el.closest === "function" && el.closest("[data-floating]")) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
// Capture phase: dialogs stop mousedown propagation on their card so the
|
||||
// backdrop does not close them, which would otherwise swallow this too.
|
||||
document.addEventListener("mousedown", onClick, true);
|
||||
document.addEventListener("keydown", onKey);
|
||||
const panel = ref.current;
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (!panel?.contains(active)) returnFocus.current = active;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (panel && !panel.contains(document.activeElement)) panel.focus({ preventScroll: true });
|
||||
});
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onClick, true);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
cancelAnimationFrame(frame);
|
||||
const back = returnFocus.current;
|
||||
returnFocus.current = null;
|
||||
const now = document.activeElement;
|
||||
const inside = !now || now === document.body || !!panel?.contains(now);
|
||||
if (back?.isConnected && inside) back.focus({ preventScroll: true });
|
||||
};
|
||||
}, [open, setOpen, triggerRef]);
|
||||
}, [open]);
|
||||
|
||||
// Arrow keys, Home/End and typeahead over this panel's own items. Keys it
|
||||
// handles stop here, so a nested menu's keys never reach this one and a
|
||||
// letter never also runs a global shortcut.
|
||||
const onMenuKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!ref.current?.contains(target)) return;
|
||||
if (target.matches("input, textarea, select, [contenteditable='true']")) return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
const items = Array.from(
|
||||
ref.current.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)'),
|
||||
);
|
||||
const typeahead = e.key.length === 1 && /\S/.test(e.key);
|
||||
if (!items.length || (!typeahead && !["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key))) return;
|
||||
e.stopPropagation();
|
||||
const at = items.indexOf(target);
|
||||
let next = -1;
|
||||
if (e.key === "ArrowDown") next = at < 0 ? 0 : (at + 1) % items.length;
|
||||
else if (e.key === "ArrowUp") next = at < 0 ? items.length - 1 : (at - 1 + items.length) % items.length;
|
||||
else if (e.key === "Home") next = 0;
|
||||
else if (e.key === "End") next = items.length - 1;
|
||||
else {
|
||||
// The next item after the focused one whose label starts with the key.
|
||||
const k = e.key.toLowerCase();
|
||||
for (let i = 1; i <= items.length; i++) {
|
||||
const idx = (at + i + items.length) % items.length;
|
||||
if (items[idx].textContent?.trim().toLowerCase().startsWith(k)) {
|
||||
next = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (next < 0) return;
|
||||
e.preventDefault();
|
||||
items[next].focus();
|
||||
};
|
||||
|
||||
// Anchor the animation origin to the side the menu opens from so
|
||||
// the scale + lift feels like it's growing out of the trigger
|
||||
@@ -259,7 +303,9 @@ export function PopoverMenuContent({
|
||||
ref={ref}
|
||||
key="popover"
|
||||
role="menu"
|
||||
tabIndex={-1}
|
||||
data-floating="true"
|
||||
onKeyDown={onMenuKeyDown}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.96, y: enterY }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
@@ -292,7 +338,7 @@ export function PopoverMenuContent({
|
||||
willChange: "transform, opacity",
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-md border border-slate-200 bg-white shadow-[0_4px_12px_-2px_rgba(15,23,42,0.08),0_2px_4px_rgba(15,23,42,0.04)] overflow-hidden py-1",
|
||||
"outline-none rounded-md border border-slate-200 bg-white shadow-[0_4px_12px_-2px_rgba(15,23,42,0.08),0_2px_4px_rgba(15,23,42,0.04)] overflow-hidden py-1",
|
||||
// Default viewport cap so tall menus scroll instead of
|
||||
// clipping off a phone screen; callers with a tighter
|
||||
// max-h still win via the cn merge below.
|
||||
@@ -351,10 +397,10 @@ export function PopoverMenuItem({
|
||||
if (closeOnSelect) setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full h-7 px-3 flex items-center gap-2 text-[12.5px] text-left transition-colors",
|
||||
"w-full h-7 px-3 flex items-center gap-2 text-[12.5px] text-left transition-colors outline-none",
|
||||
danger
|
||||
? "text-red-600 hover:bg-red-50"
|
||||
: "text-slate-700 hover:bg-slate-50 hover:text-slate-900",
|
||||
? "text-red-600 hover:bg-red-50 focus-visible:bg-red-50"
|
||||
: "text-slate-700 hover:bg-slate-50 hover:text-slate-900 focus-visible:bg-slate-50 focus-visible:text-slate-900",
|
||||
selected && !danger && "text-slate-900 font-medium",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
|
||||
@@ -37,6 +37,9 @@ export interface UISlice {
|
||||
// and the rows the user hid, as the rail's scopeKey values.
|
||||
uniboxRailFolded: Record<string, boolean>
|
||||
uniboxRailHidden: string[]
|
||||
// Row order per section and the order of the sections; absent means default.
|
||||
uniboxRailOrder: Record<string, string[]>
|
||||
uniboxRailSectionOrder: string[]
|
||||
|
||||
// Theme
|
||||
theme: Theme
|
||||
@@ -67,6 +70,10 @@ export interface UISlice {
|
||||
// Actions - Unibox scope rail
|
||||
toggleUniboxRailSection: (id: string) => void
|
||||
toggleUniboxRailRow: (key: string) => void
|
||||
setUniboxRailFolded: (folded: Record<string, boolean>) => void
|
||||
setUniboxRailRowsHidden: (keys: string[], hidden: boolean) => void
|
||||
setUniboxRailOrder: (section: string, keys: string[] | null) => void
|
||||
setUniboxRailSectionOrder: (ids: string[]) => void
|
||||
|
||||
// Actions - Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
@@ -106,6 +113,30 @@ export const sanitizeUniboxRailHidden = (v: unknown): string[] => {
|
||||
return [...new Set(v.filter((k): k is string => typeof k === 'string'))]
|
||||
}
|
||||
|
||||
// Row orders rehydrate as a map of string lists, each deduplicated.
|
||||
export const sanitizeUniboxRailOrder = (v: unknown): Record<string, string[]> => {
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(v).flatMap(([id, keys]) => (Array.isArray(keys) ? [[id, sanitizeUniboxRailHidden(keys)]] : [])),
|
||||
)
|
||||
}
|
||||
|
||||
// A stored order over keys that come and go: known keys keep the stored order,
|
||||
// and a key the stored order never saw lands right after its default predecessor.
|
||||
export const applyRailOrder = (defaults: string[], stored: string[] | undefined): string[] => {
|
||||
if (!stored?.length) return defaults
|
||||
const known = new Set(defaults)
|
||||
const out = stored.filter((k) => known.has(k))
|
||||
const placed = new Set(out)
|
||||
defaults.forEach((k, i) => {
|
||||
if (placed.has(k)) return
|
||||
const prev = i > 0 ? out.indexOf(defaults[i - 1]) : -1
|
||||
out.splice(prev + 1, 0, k)
|
||||
placed.add(k)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// The dashboard is light-only today: every surface is styled on white, so a
|
||||
// resolved dark theme would flip only the CSS-variable components (command
|
||||
// palette, toasts) and look broken. 'dark'/'system' are accepted but resolve
|
||||
@@ -121,6 +152,8 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
|
||||
navCollapsedSections: {},
|
||||
uniboxRailFolded: {},
|
||||
uniboxRailHidden: [],
|
||||
uniboxRailOrder: {},
|
||||
uniboxRailSectionOrder: [],
|
||||
|
||||
// Theme
|
||||
theme: getInitialTheme(),
|
||||
@@ -161,6 +194,21 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
|
||||
? state.uniboxRailHidden.filter((k) => k !== key)
|
||||
: [...state.uniboxRailHidden, key],
|
||||
})),
|
||||
setUniboxRailFolded: (folded) =>
|
||||
set((state) => ({ uniboxRailFolded: { ...state.uniboxRailFolded, ...folded } })),
|
||||
setUniboxRailRowsHidden: (keys, hidden) =>
|
||||
set((state) => {
|
||||
const rest = state.uniboxRailHidden.filter((k) => !keys.includes(k))
|
||||
return { uniboxRailHidden: hidden ? [...rest, ...keys] : rest }
|
||||
}),
|
||||
setUniboxRailOrder: (section, keys) =>
|
||||
set((state) => {
|
||||
const next = { ...state.uniboxRailOrder }
|
||||
if (keys) next[section] = keys
|
||||
else delete next[section]
|
||||
return { uniboxRailOrder: next }
|
||||
}),
|
||||
setUniboxRailSectionOrder: (ids) => set({ uniboxRailSectionOrder: ids }),
|
||||
|
||||
// Actions - Theme
|
||||
setTheme: (theme) => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { devtools, persist } from 'zustand/middleware'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { createUserSlice, type UserSlice } from './slices/userSlice'
|
||||
import { createOrganizationSlice, type OrganizationSlice } from './slices/organizationSlice'
|
||||
import { createUISlice, clampUniboxListWidth, sanitizeNavCollapsedSections, sanitizeUniboxRailHidden, type UISlice } from './slices/uiSlice'
|
||||
import { createUISlice, clampUniboxListWidth, sanitizeNavCollapsedSections, sanitizeUniboxRailHidden, sanitizeUniboxRailOrder, type UISlice } from './slices/uiSlice'
|
||||
import { createShortcutSlice, type ShortcutSlice } from './slices/shortcutSlice'
|
||||
import { createDataSlice, type DataSlice } from './slices/dataSlice'
|
||||
import { createRealtimeSlice, type RealtimeSlice } from './slices/realtimeSlice'
|
||||
@@ -50,6 +50,8 @@ export const useAppStore = create<AppStore>()(
|
||||
navCollapsedSections: sanitizeNavCollapsedSections(p.navCollapsedSections),
|
||||
uniboxRailFolded: sanitizeNavCollapsedSections(p.uniboxRailFolded),
|
||||
uniboxRailHidden: sanitizeUniboxRailHidden(p.uniboxRailHidden),
|
||||
uniboxRailOrder: sanitizeUniboxRailOrder(p.uniboxRailOrder),
|
||||
uniboxRailSectionOrder: sanitizeUniboxRailHidden(p.uniboxRailSectionOrder),
|
||||
}
|
||||
},
|
||||
partialize: (state) => ({
|
||||
@@ -65,9 +67,11 @@ export const useAppStore = create<AppStore>()(
|
||||
// Unibox layout (list column width + CRM rail default)
|
||||
uniboxListWidth: state.uniboxListWidth,
|
||||
uniboxContactRailOpen: state.uniboxContactRailOpen,
|
||||
// Unibox scope rail (folded sections + rows the user hid)
|
||||
// Unibox scope rail (folds, hidden rows, row and section order)
|
||||
uniboxRailFolded: state.uniboxRailFolded,
|
||||
uniboxRailHidden: state.uniboxRailHidden,
|
||||
uniboxRailOrder: state.uniboxRailOrder,
|
||||
uniboxRailSectionOrder: state.uniboxRailSectionOrder,
|
||||
// Persist current organization selection
|
||||
currentOrganization: state.currentOrganization,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user