feat: keep favorites starred during a Favorites edit, re-highlight in Favorites after a scope is reached by shortcut or Back, name the target of a renamed view on hover, word the hide toast by where the highlight is, route the edit-mode star through the menu's add, cap favorite names by code point, and memoize the mailbox, label and tag rail rows

This commit is contained in:
Matthew Meszaros
2026-09-30 04:57:00 -07:00
parent e3c9ddde01
commit b4312d7ec1
3 changed files with 100 additions and 29 deletions
+42 -27
View File
@@ -243,6 +243,12 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) {
);
const active = scopeKey(scope);
// A scope reached any other way (shortcut, Back) highlights in Favorites again.
const [pickedFor, setPickedFor] = React.useState(active);
if (pickedFor !== active) {
setPickedFor(active);
if (homePick !== null && homePick !== active) setHomePick(null);
}
const folderCounts = React.useMemo(() => {
const m = new Map<string, { unread: number; total: number }>();
for (const f of data?.folders ?? []) {
@@ -327,7 +333,10 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) {
})
: [];
const mailboxRows: RailRow[] = (data?.mailboxes ?? []).map((m) => ({
const mailboxes = data?.mailboxes;
const categories = data?.categories;
const tags = data?.tags;
const mailboxRows = React.useMemo<RailRow[]>(() => (mailboxes ?? []).map((m) => ({
key: `mailbox:${m.id}`,
label: m.email,
icon: <MailOpenIcon className={ICON} />,
@@ -336,9 +345,9 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) {
noun: "mailbox",
search: `${m.email} ${m.name}`,
onOpen: () => onChange({ kind: "mailbox", mailboxId: m.id }),
}));
})), [mailboxes, onChange]);
const labelRows: RailRow[] = (data?.categories ?? []).map((c) => ({
const labelRows = React.useMemo<RailRow[]>(() => (categories ?? []).map((c) => ({
key: `category:${c.id}`,
label: c.title,
icon: <Dot color={c.color} />,
@@ -347,9 +356,9 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) {
noun: "label",
tooltip: tagMeaning(c.title) || undefined,
onOpen: () => onChange({ kind: "category", categoryId: c.id }),
}));
})), [categories, onChange]);
const tagRows: RailRow[] = (data?.tags ?? []).map((t) => ({
const tagRows = React.useMemo<RailRow[]>(() => (tags ?? []).map((t) => ({
key: `tag:${t.id}`,
label: t.title,
icon: <Dot color={t.color} />,
@@ -357,13 +366,16 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) {
accent: t.unread > 0,
noun: "tag",
onOpen: () => onChange({ kind: "tag", tagId: t.id }),
}));
})), [tags, onChange]);
// Favorites resolve against every other section, so a row always carries its live count.
const everyRow = new Map(
[...mailRows, ...viewRows, ...mailboxRows, ...labelRows, ...tagRows].map((r) => [r.key, r]),
);
const favoriteList = favoritesDraft ?? favorites;
// A favorite added while editing (a keyboard star elsewhere) joins the draft at the end.
const favoriteList = favoritesDraft
? [...favoritesDraft, ...favorites.filter((f) => !favoritesDraft.some((d) => d.key === f.key))]
: favorites;
const favoriteKeys = new Set(favorites.map((f) => f.key));
const favoriteRows: RailRow[] = favoriteList.flatMap((f) => {
const r = everyRow.get(f.key);
@@ -373,6 +385,7 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) {
...r,
label: f.name ?? r.label,
original: f.name ? r.label : undefined,
tooltip: f.name && r.tooltip ? `${r.label}: ${r.tooltip}` : r.tooltip,
noun: "favorite",
onOpen: () => {
setHomePick(null);
@@ -385,14 +398,20 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) {
// One row carries the highlight: the favorite, unless the scope was opened from its own section.
const inFavorites = favoriteKeys.has(active) && homePick !== active && favoriteRows.some((r) => r.key === active);
const homeActive = inFavorites ? "" : active;
const home = (rows: RailRow[]) =>
rows.map((r) => ({
...r,
onOpen: () => {
setHomePick(r.key);
r.onOpen();
},
}));
const home = React.useCallback(
(rows: RailRow[]) =>
rows.map((r) => ({
...r,
onOpen: () => {
setHomePick(r.key);
r.onOpen();
},
})),
[],
);
const homeMailboxRows = React.useMemo(() => home(mailboxRows), [home, mailboxRows]);
const homeLabelRows = React.useMemo(() => home(labelRows), [home, labelRows]);
const homeTagRows = React.useMemo(() => home(tagRows), [home, tagRows]);
// Writes the favorites; while editing, the draft keeps unticked ones in place.
const writeFavorites = (next: UniboxRailFavorite[], kept = favoriteKeys) => {
@@ -451,13 +470,13 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) {
<FavoriteToggle
on={favoriteKeys.has(row.key)}
label={row.label}
onToggle={() => (favoriteKeys.has(row.key) ? removeFavorite(row.key, row.label) : toggleFavorite(row.key))}
onToggle={() => (favoriteKeys.has(row.key) ? removeFavorite(row.key, row.label) : addFavorite(row))}
/>
);
const hideWithUndo = (row: RailRow) => {
setRowsHidden([row.key], true);
const when = row.key === active ? " once you leave it" : "";
const when = row.key === homeActive ? " once you leave it" : "";
undoToast(`rail-hidden:${row.key}`, `${row.label} is hidden from the rail${when}.`, () =>
setRowsHidden([row.key], false),
);
@@ -602,7 +621,7 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) {
id="mailboxes"
label="Mailboxes"
controls={controls("mailboxes")}
rows={home(mailboxRows)}
rows={homeMailboxRows}
activeKey={homeActive}
rowMenu={favoriteItem}
emptyText={overview.isPending ? "Loading…" : "No mailboxes connected."}
@@ -615,7 +634,7 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) {
id="labels"
label="Labels"
controls={controls("labels")}
rows={home(labelRows)}
rows={homeLabelRows}
activeKey={homeActive}
rowMenu={favoriteItem}
emptyText="No labels yet."
@@ -628,7 +647,7 @@ export function ScopeRail({ scope, onChange }: ScopeRailProps) {
id="tags"
label="Tags"
controls={controls("tags")}
rows={home(tagRows)}
rows={homeTagRows}
activeKey={homeActive}
rowMenu={favoriteItem}
emptyText="No tags yet."
@@ -976,8 +995,7 @@ function MoveItems({ row, ctx }: { row: RailRow; ctx: RowContext }) {
);
}
// FixedSection: Mail and Views, a shipped list of rows the user can hide,
// reorder and star into Favorites.
// FixedSection: Mail and Views, whose shipped rows can be hidden, reordered and starred.
function FixedSection({
id,
label,
@@ -1061,9 +1079,7 @@ function FixedSection({
);
}
// RailSection: an ordered list of rows that folds and has an edit mode, where
// each row can be ticked off (hidden, or out of Favorites) and reordered. The
// row you are on is never ticked out of sight.
// RailSection: ordered rows that fold, reorder and tick off in edit mode; the active row always shows.
function RailSection({
id,
label,
@@ -1796,8 +1812,7 @@ function FavoriteToggle({ on, label, onToggle }: { on: boolean; label: string; o
);
}
// RenameRow: a favorite's name, edited in place. Enter or leaving the field
// keeps it, Escape drops the edit, and an empty name goes back to the row's own.
// RenameRow: Enter or blur keeps the name, Escape drops it, empty restores the row's own.
function RenameRow({
row,
onDone,
@@ -738,6 +738,30 @@ describe("Favorites", () => {
expect(favoritesPanel().contains(current[0])).toBe(true);
});
it("highlights in Favorites again once the scope is left and reached some other way", () => {
useAppStore.setState({ uniboxRailFavorites: [{ key: "folder:inbox" }] });
const onChange = vi.fn();
const { rerender } = render(<ScopeRail scope={{ kind: "folder", folder: "inbox" }} onChange={onChange} />);
const homeInbox = () => screen.getAllByText("Inbox").map((n) => n.closest("[data-rail-row]") as HTMLElement)[1];
fireEvent.click(homeInbox());
rerender(<ScopeRail scope={{ kind: "folder", folder: "inbox" }} onChange={onChange} />);
expect(favoritesPanel().querySelector("[aria-current]")).toBeNull();
// A shortcut away and back, with no click in the rail.
rerender(<ScopeRail scope={{ kind: "unread" }} onChange={onChange} />);
rerender(<ScopeRail scope={{ kind: "folder", folder: "inbox" }} onChange={onChange} />);
expect(favoritesPanel().querySelector("[aria-current]")).toBeTruthy();
expect(document.querySelectorAll("[aria-current]")).toHaveLength(1);
});
it("names what a renamed view points at on hover", () => {
useAppStore.setState({ uniboxRailFavorites: [{ key: "view:needs_reply", name: "Follow-ups" }] });
mountRail();
expect(favoriteLabels()).toEqual(["Follow-ups"]);
fireEvent.focus(favoritesPanel().querySelector("[data-rail-row]") as HTMLElement);
expect(screen.getAllByText(/^Needs a reply: /).length).toBeGreaterThan(0);
});
it("folds, keeps the favorite you are on, and remembers the fold", () => {
useAppStore.setState({ uniboxRailFavorites: [{ key: "folder:inbox" }, { key: "unread" }] });
mountRail({ kind: "unread" });
@@ -872,6 +896,17 @@ describe("Favorites edit mode", () => {
expect(favoriteLabels()).toEqual(["Work", "Hot leads"]);
});
it("keeps a favorite starred elsewhere while editing when the section next writes", () => {
useAppStore.setState({ uniboxRailFavorites: [{ key: "unread" }, { key: "view:hot" }] });
mountRail();
fireEvent.click(screen.getByRole("button", { name: "Edit favorites" }));
// Starred from another row's menu by keyboard, which leaves edit mode open.
act(() => useAppStore.getState().toggleUniboxRailFavorite("folder:sent"));
fireEvent.click(screen.getByRole("checkbox", { name: "Unread" }));
expect(useAppStore.getState().uniboxRailFavorites.map((f) => f.key)).toEqual(["view:hot", "folder:sent"]);
expect(screen.getByRole("checkbox", { name: "Sent" }).getAttribute("aria-checked")).toBe("true");
});
it("stays on the rail while every favorite is unticked, and leaves it on Done", () => {
useAppStore.setState({ uniboxRailFavorites: [{ key: "unread" }] });
mountRail();
@@ -929,6 +964,23 @@ describe("Favorites edit mode", () => {
});
});
describe("Favorites and hiding", () => {
it("says a row hides at once when the scope you are on is highlighted in Favorites", () => {
useAppStore.setState({ uniboxRailFavorites: [{ key: "folder:inbox" }] });
render(
<>
<ScopeRail scope={{ kind: "folder", folder: "inbox" }} onChange={() => {}} />
<Toaster />
</>,
);
fireEvent.click(screen.getByLabelText("Inbox folder actions"));
fireEvent.click(screen.getByRole("menuitem", { name: "Hide from rail" }));
expect(screen.getByText("Inbox is hidden from the rail.")).toBeTruthy();
// Gone from Mail, still in Favorites.
expect(screen.getAllByText("Inbox")).toHaveLength(1);
});
});
describe("Favorites undo", () => {
it("puts a removed favorite back where it was, with its name", () => {
useAppStore.setState({
@@ -992,6 +1044,10 @@ describe("sanitizeUniboxRailFavorites", () => {
expect(cleanUniboxRailFavoriteName("a\n\tb")).toBe("a b");
expect(cleanUniboxRailFavoriteName("x".repeat(80))).toHaveLength(40);
expect(cleanUniboxRailFavoriteName("")).toBeUndefined();
// An emoji straddling the cap is kept whole or dropped, never split.
const capped = cleanUniboxRailFavoriteName(`${"x".repeat(39)}🔥🔥`) as string;
expect(Array.from(capped)).toHaveLength(40);
expect(capped.endsWith("🔥")).toBe(true);
});
it("runs on rehydration", async () => {
+2 -2
View File
@@ -134,10 +134,10 @@ export const sanitizeUniboxRailOrder = (v: unknown): Record<string, string[]> =>
)
}
// A blank name means "use the row's own label"; anything else is trimmed and capped.
// A blank name means "use the row's own label"; anything else is trimmed and capped by code point.
export const cleanUniboxRailFavoriteName = (v: unknown): string | undefined => {
if (typeof v !== 'string') return undefined
const name = v.replace(/\s+/g, ' ').trim().slice(0, UNIBOX_RAIL_FAVORITE_NAME_MAX).trim()
const name = Array.from(v.replace(/\s+/g, ' ').trim()).slice(0, UNIBOX_RAIL_FAVORITE_NAME_MAX).join('').trim()
return name || undefined
}