Skip to content

Commit d4eb604

Browse files
committed
fix(webapp): favorites and sidebar customization polish
Move the favorite star next to the page title, brighten it on hover, and use a favorite's custom name in its tooltip. Favorite links now carry a marker param so only the favorite highlights as active (with its page's icon color), never its identical main menu item. Fix removing a favorite from its hover menu not persisting: the mutation fetcher now lives in the side menu, since the item unmounts optimistically and an unmounting owner's request gets aborted. Side menu labels fade out at the right edge when they overflow instead of hard-clipping. Section headers keep their hover state while hovering the header ellipsis, the More menu gains a Customize sidebar entry, and popover menu items use the larger side menu size. In the Customize sidebar modal: favorite name fields match the row text size, blank names show an error and block Confirm, Reset keeps custom names while restoring layout, the footer divider spans the full modal width, and the scrollbar sits at the modal edge. Built-in metric dashboards now save with their own icons and names when favorited.
1 parent ca66dd7 commit d4eb604

9 files changed

Lines changed: 302 additions & 123 deletions

File tree

apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import ReactGridLayout, { type Layout, useContainerWidth } from "react-grid-layo
66
import { cn } from "~/utils/cn";
77
import { Button } from "../primitives/Buttons";
88
import { DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog";
9+
import { FormError } from "../primitives/FormError";
910
import { Header3 } from "../primitives/Headers";
1011
import { Icon, type RenderIcon } from "../primitives/Icon";
1112
import { Input } from "../primitives/Input";
@@ -141,7 +142,15 @@ export function CustomizeSidebarDialog({
141142
setState((current) => ({ ...current, labels: { ...current.labels, [itemId]: label } }));
142143
};
143144

144-
const reset = () => setState(buildState(sections, undefined));
145+
// Reset restores the default layout (positions + visibility) but never touches favorite names
146+
const reset = () =>
147+
setState((current) => ({ ...buildState(sections, undefined), labels: current.labels }));
148+
149+
const hasBlankLabels = sections.some((section) =>
150+
section.items.some(
151+
(item) => item.isFavorite && (state.labels[item.id] ?? item.name).trim().length === 0
152+
)
153+
);
145154

146155
const confirm = () => {
147156
const defaults = buildState(sections, undefined);
@@ -193,7 +202,9 @@ export function CustomizeSidebarDialog({
193202
return (
194203
<DialogContent className="sm:max-w-md">
195204
<DialogHeader>Customize sidebar</DialogHeader>
196-
<div className="max-h-[60vh] space-y-6 overflow-y-auto pr-2 pt-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
205+
{/* Bleeds through the container's right padding (-mr-4/pr-4) so the scrollbar sits at the
206+
modal edge while the content keeps its visual inset */}
207+
<div className="-mr-4 max-h-[60vh] space-y-6 overflow-y-auto pr-4 pt-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
197208
{orderedSections.map((section, index) => (
198209
<div key={section.id}>
199210
<div className="flex items-center justify-between border-b border-grid-dimmed pb-1.5">
@@ -227,7 +238,8 @@ export function CustomizeSidebarDialog({
227238
</div>
228239
))}
229240
</div>
230-
<DialogFooter>
241+
{/* Negative margins stretch the top divider across the modal's full width */}
242+
<DialogFooter className="-mx-4 px-4">
231243
<div className="flex items-center gap-2">
232244
<DialogClose asChild>
233245
<Button variant="secondary/medium">Cancel</Button>
@@ -236,7 +248,7 @@ export function CustomizeSidebarDialog({
236248
Reset
237249
</Button>
238250
</div>
239-
<Button variant="primary/medium" onClick={confirm}>
251+
<Button variant="primary/medium" onClick={confirm} disabled={hasBlankLabels}>
240252
Confirm
241253
</Button>
242254
</DialogFooter>
@@ -367,14 +379,19 @@ function ModalItemRow({
367379
>
368380
<Icon icon={item.icon} className="size-5 shrink-0 text-text-dimmed" />
369381
{item.isFavorite ? (
370-
<Input
371-
value={label ?? item.name}
372-
onChange={(e) => onLabelChange(e.target.value)}
373-
variant="small"
374-
maxLength={64}
375-
containerClassName="max-w-60"
376-
aria-label={`Rename ${item.name}`}
377-
/>
382+
<>
383+
<Input
384+
value={label ?? item.name}
385+
onChange={(e) => onLabelChange(e.target.value)}
386+
variant="medium"
387+
maxLength={64}
388+
containerClassName="max-w-60"
389+
aria-label={`Rename ${item.name}`}
390+
/>
391+
{(label ?? item.name).trim().length === 0 && (
392+
<FormError className="shrink-0">Name can't be blank</FormError>
393+
)}
394+
</>
378395
) : (
379396
<span className="truncate text-sm text-text-bright">{item.name}</span>
380397
)}

apps/webapp/app/components/navigation/FavoritePageButton.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
buildFavoriteLabel,
1313
FAVORITES_ACTION_PATH,
1414
resolvePageMeta,
15+
stripFavoriteSearchParam,
1516
useFavorites,
1617
} from "./favoritePages";
1718

@@ -27,10 +28,13 @@ export function FavoritePageButton({ pageTitle }: { pageTitle?: string }) {
2728
const fetcher = useFetcher();
2829
const { areShortcutsEnabled } = useShortcuts();
2930

30-
const url = location.pathname + location.search;
31+
// The favorite marker param is presentation-only, so it never counts toward URL identity
32+
const url = location.pathname + stripFavoriteSearchParam(location.search);
3133
const existing = favorites.find((favorite) => favorite.url === url);
3234
const isFavorited = existing !== undefined;
33-
const pageName = pageTitle?.trim() || resolvePageMeta(location.pathname).name;
35+
// A renamed favorite keeps its custom name in the tooltip
36+
const pageName =
37+
existing?.label ?? (pageTitle?.trim() || resolvePageMeta(location.pathname).name);
3438

3539
const toggle = () => {
3640
if (existing) {
@@ -119,7 +123,7 @@ export function FavoritePageButton({ pageTitle }: { pageTitle?: string }) {
119123
isFavorited ? (
120124
<StarIconSolid className="size-4 text-yellow-500" />
121125
) : (
122-
<StarIconOutline className="size-4 text-text-dimmed" />
126+
<StarIconOutline className="size-4 text-text-dimmed transition-colors group-hover/button:text-text-bright" />
123127
)
124128
}
125129
/>

apps/webapp/app/components/navigation/FavoritesSection.tsx

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { EllipsisHorizontalIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid";
2-
import { useFetcher, useLocation, useNavigation } from "@remix-run/react";
2+
import { useLocation, useNavigation } from "@remix-run/react";
33
import { useEffect, useRef, useState } from "react";
44
import { type FavoritePage } from "~/services/dashboardPreferences.server";
55
import { cn } from "~/utils/cn";
@@ -10,23 +10,35 @@ import {
1010
PopoverCustomTrigger,
1111
PopoverMenuItem,
1212
} from "../primitives/Popover";
13-
import { favoritePageIcon, FAVORITES_ACTION_PATH } from "./favoritePages";
13+
import {
14+
favoriteLinkTo,
15+
favoritePageActiveColor,
16+
favoritePageIcon,
17+
isFavoriteActive,
18+
} from "./favoritePages";
1419
import { SideMenuItem } from "./SideMenuItem";
20+
import { SIDE_MENU_POPOVER_ITEM_ICON, SIDE_MENU_POPOVER_ITEM_LABEL } from "./sideMenuTypes";
1521

1622
/**
1723
* A favorited page in the side menu. Renders like a normal menu item, with an ellipsis menu
1824
* (Rename/Remove) that appears on hover, and an inline-editable label while renaming.
25+
*
26+
* Mutations are submitted by the SideMenu (not here): removing a favorite unmounts this item
27+
* optimistically, and a fetcher owned by an unmounting component gets its request aborted.
1928
*/
2029
export function FavoriteMenuItem({
2130
favorite,
2231
isCollapsed,
32+
onRemove,
33+
onRename,
2334
}: {
2435
favorite: FavoritePage;
2536
isCollapsed: boolean;
37+
onRemove: (id: string) => void;
38+
onRename: (id: string, label: string) => void;
2639
}) {
2740
const location = useLocation();
2841
const navigation = useNavigation();
29-
const fetcher = useFetcher();
3042
const [isEditing, setIsEditing] = useState(false);
3143
const [isMenuOpen, setMenuOpen] = useState(false);
3244

@@ -35,24 +47,14 @@ export function FavoriteMenuItem({
3547
}, [navigation.location?.pathname]);
3648

3749
const icon = favoritePageIcon(favorite.icon);
38-
const isActive = location.pathname + location.search === favorite.url;
50+
const isActive = isFavoriteActive(favorite, location.pathname, location.search);
3951

4052
const submitRename = (value: string) => {
4153
setIsEditing(false);
4254
const label = value.trim();
4355
// An empty or unchanged submit reverts to the saved label
4456
if (label.length === 0 || label === favorite.label) return;
45-
fetcher.submit(
46-
{ intent: "rename", id: favorite.id, label },
47-
{ method: "POST", action: FAVORITES_ACTION_PATH }
48-
);
49-
};
50-
51-
const remove = () => {
52-
fetcher.submit(
53-
{ intent: "remove", id: favorite.id },
54-
{ method: "POST", action: FAVORITES_ACTION_PATH }
55-
);
57+
onRename(favorite.id, label);
5658
};
5759

5860
if (isEditing && !isCollapsed) {
@@ -70,9 +72,9 @@ export function FavoriteMenuItem({
7072
<SideMenuItem
7173
name={favorite.label}
7274
icon={icon}
73-
activeIconColor="text-text-bright"
75+
activeIconColor={favoritePageActiveColor(favorite.icon)}
7476
inactiveIconColor="text-text-dimmed"
75-
to={favorite.url}
77+
to={favoriteLinkTo(favorite)}
7678
isCollapsed={isCollapsed}
7779
isActive={isActive}
7880
data-action="favorite"
@@ -99,7 +101,8 @@ export function FavoriteMenuItem({
99101
<PopoverMenuItem
100102
icon={PencilSquareIcon}
101103
title="Rename"
102-
leadingIconClassName="size-4 text-text-dimmed"
104+
leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
105+
className={SIDE_MENU_POPOVER_ITEM_LABEL}
103106
onClick={() => {
104107
setMenuOpen(false);
105108
setIsEditing(true);
@@ -109,10 +112,11 @@ export function FavoriteMenuItem({
109112
icon={TrashIcon}
110113
title="Remove"
111114
danger
112-
leadingIconClassName="size-4"
115+
leadingIconClassName="h-5 w-5"
116+
className={SIDE_MENU_POPOVER_ITEM_LABEL}
113117
onClick={() => {
114118
setMenuOpen(false);
115-
remove();
119+
onRemove(favorite.id);
116120
}}
117121
/>
118122
</div>

0 commit comments

Comments
 (0)