feat(sidebar): separate checked filters from current rows

The rail used one fill for two meanings: the page you are on and a
filter that is on. A view, a tag and a calendar day could light three
rows like three current pages, while the view itself was never echoed
above the list.

- Split sidebarRowStateClasses into idle / current / checked. Nav pills,
  settings sections and list scopes stay filled; views, tags (flat and
  tree) and the selected calendar day take the checked look: foreground
  text, accent icon and count, a 2px accent mark in the rail inset, no
  surface.
- Echo the active view as a filter chip like a tag, and format the day
  chip as a date.
- Make the tags list/tree toggle surface-free so only rows carry a fill.
- Tighten rows from 32px to 28px; nav pills keep their 32px square.
This commit is contained in:
johnnyjoygh 2026-09-02 22:50:29 +08:00
parent ce0dd23d2f
commit aba1f7384d
14 changed files with 251 additions and 57 deletions

View file

@ -24,7 +24,8 @@ export const getCellIntensityClass = (day: CalendarDayCell, maxCount: number): s
}; };
export const getCalendarCellStateClass = (day: Pick<CalendarDayCell, "isSelected">): string => export const getCalendarCellStateClass = (day: Pick<CalendarDayCell, "isSelected">): string =>
day.isSelected ? "z-10 ring-2 ring-blue-500/70 ring-inset" : ""; // A picked day is a checked filter like a view or tag row: it takes the accent, not a ring.
day.isSelected ? "z-10 bg-primary font-medium text-primary-foreground" : "";
export const generateMonthsForYear = (year: number): string[] => { export const generateMonthsForYear = (year: number): string[] => {
return Array.from({ length: MONTHS_IN_YEAR }, (_, i) => dayjs(`${year}-01-01`).add(i, "month").format("YYYY-MM")); return Array.from({ length: MONTHS_IN_YEAR }, (_, i) => dayjs(`${year}-01-01`).add(i, "month").format("YYYY-MM"));

View file

@ -108,8 +108,13 @@ const ProfileMode = () => {
return ( return (
<SidebarSection label={t("common.profile")}> <SidebarSection label={t("common.profile")}>
<SidebarRow active={active === "memos"} icon={LayoutListIcon} label={t("common.memos")} onClick={() => setMode("memos")} /> <SidebarRow
<SidebarRow active={active === "map"} icon={MapIcon} label={t("common.map")} onClick={() => setMode("map")} /> state={active === "memos" ? "current" : "idle"}
icon={LayoutListIcon}
label={t("common.memos")}
onClick={() => setMode("memos")}
/>
<SidebarRow state={active === "map" ? "current" : "idle"} icon={MapIcon} label={t("common.map")} onClick={() => setMode("map")} />
</SidebarSection> </SidebarSection>
); );
}; };
@ -191,7 +196,7 @@ const AttachmentsSidebarContent = () => {
{rows.map((row) => ( {rows.map((row) => (
<SidebarRow <SidebarRow
key={row.value} key={row.value}
active={attachmentSection === row.value} state={attachmentSection === row.value ? "current" : "idle"}
icon={row.icon} icon={row.icon}
label={row.label} label={row.label}
count={row.count} count={row.count}
@ -229,7 +234,7 @@ const InboxSidebarContent = () => {
{rows.map((row) => ( {rows.map((row) => (
<SidebarRow <SidebarRow
key={row.value} key={row.value}
active={inboxFilter === row.value} state={inboxFilter === row.value ? "current" : "idle"}
icon={row.icon} icon={row.icon}
label={row.label} label={row.label}
count={row.count} count={row.count}
@ -258,7 +263,7 @@ const SettingsSidebarContent = () => {
key={section.key} key={section.key}
to={`${ROUTES.SETTING}#${section.key}`} to={`${ROUTES.SETTING}#${section.key}`}
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(currentSection === section.key))} className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(currentSection === section.key ? "current" : "idle"))}
> >
<SidebarRowIconSlot icon={section.icon} /> <SidebarRowIconSlot icon={section.icon} />
<span className="truncate">{t(section.labelKey)}</span> <span className="truncate">{t(section.labelKey)}</span>
@ -324,7 +329,7 @@ interface GlobalNavItem {
* label only opens the text track, so the artwork and surface never jump. * label only opens the text track, so the artwork and surface never jump.
*/ */
const navPillClasses = (active: boolean) => const navPillClasses = (active: boolean) =>
cn(sidebarSurfaceVariants({ role: "navPill" }), SIDEBAR_ROW_FOCUS_CLASSES, sidebarRowStateClasses(active)); cn(sidebarSurfaceVariants({ role: "navPill" }), SIDEBAR_ROW_FOCUS_CLASSES, sidebarRowStateClasses(active ? "current" : "idle"));
const NavPillLabel = ({ expanded, label, children }: { expanded: boolean; label: ReactNode; children?: ReactNode }) => ( const NavPillLabel = ({ expanded, label, children }: { expanded: boolean; label: ReactNode; children?: ReactNode }) => (
<span <span

View file

@ -36,7 +36,7 @@ const CommonSidebarContent = () => {
to={ROUTES.ABOUT} to={ROUTES.ABOUT}
aria-current={aboutActive ? "page" : undefined} aria-current={aboutActive ? "page" : undefined}
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(aboutActive))} className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(aboutActive ? "current" : "idle"))}
> >
<SidebarRowIconSlot icon={InfoIcon} /> <SidebarRowIconSlot icon={InfoIcon} />
<span className="min-w-0 flex-1 truncate">{t("common.about")}</span> <span className="min-w-0 flex-1 truncate">{t("common.about")}</span>

View file

@ -18,8 +18,8 @@ export const SIDEBAR_ROW_FOCUS_CLASSES =
export const SIDEBAR_ROW_CLASSES = `${SIDEBAR_ROW_BOX_CLASSES} ${SIDEBAR_ROW_FOCUS_CLASSES}`; export const SIDEBAR_ROW_CLASSES = `${SIDEBAR_ROW_BOX_CLASSES} ${SIDEBAR_ROW_FOCUS_CLASSES}`;
export const SIDEBAR_ROW_ICON_CLASSES = "me-auto size-4 shrink-0 opacity-75"; export const SIDEBAR_ROW_ICON_CLASSES = "me-auto size-4 shrink-0 opacity-75 group-data-checked:text-primary group-data-checked:opacity-100";
const SIDEBAR_ROW_COUNT_CLASSES = "text-2xs tabular-nums text-muted-foreground/60"; const SIDEBAR_ROW_COUNT_CLASSES = "text-2xs tabular-nums text-muted-foreground/60 group-data-checked:text-primary";
/** /**
* The focusable body of a split row — rows whose box is a wrapper carrying other controls * The focusable body of a split row — rows whose box is a wrapper carrying other controls
@ -48,14 +48,32 @@ export const SidebarRowIconSlot = ({ icon: Icon }: { icon: LucideIcon }) => (
</span> </span>
); );
/** Idle and selected colouring for a row box, kept in one place so lists cannot drift apart. */ /**
export const sidebarRowStateClasses = (active?: boolean) => * One selected look per meaning, kept together so lists cannot drift apart.
active * `current` is the place you are (nav pills, settings sections, list scopes) and fills the
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" * row. `checked` is a filter that is on (a view, a tag) and must never read as a page: no
: "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground"; * surface, foreground text, an accent mark on the rail edge, accent icon and count.
*/
export type SidebarRowState = "idle" | "current" | "checked";
const SIDEBAR_ROW_HOVER_CLASSES = "hover:bg-sidebar-accent/65 hover:text-foreground";
export const sidebarRowStateClasses = (state: SidebarRowState = "idle") => {
if (state === "current") return "bg-sidebar-accent font-medium text-sidebar-accent-foreground";
if (state === "checked") {
// The mark hangs in the rail's 12px inset so the label stays on its rail.
return `relative font-medium text-foreground ${SIDEBAR_ROW_HOVER_CLASSES} before:absolute before:inset-y-1.5 before:-start-3 before:w-0.5 before:rounded-e-full before:bg-primary before:content-['']`;
}
return `text-muted-foreground ${SIDEBAR_ROW_HOVER_CLASSES}`;
};
/** Goes on the row box so the icon slot and count rail inside it can take the checked colour. */
export const sidebarRowStateAttributes = (state: SidebarRowState) => ({
"data-checked": state === "checked" ? "" : undefined,
});
interface Props { interface Props {
active?: boolean; state?: SidebarRowState;
icon?: LucideIcon; icon?: LucideIcon;
label: ReactNode; label: ReactNode;
count?: number; count?: number;
@ -63,12 +81,13 @@ interface Props {
trailing?: ReactNode; trailing?: ReactNode;
} }
const SidebarRow = ({ active, icon: Icon, label, count, onClick, trailing }: Props) => ( const SidebarRow = ({ state = "idle", icon: Icon, label, count, onClick, trailing }: Props) => (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
aria-pressed={active || undefined} aria-pressed={state !== "idle" || undefined}
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(active))} {...sidebarRowStateAttributes(state)}
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(state))}
> >
{Icon && <SidebarRowIconSlot icon={Icon} />} {Icon && <SidebarRowIconSlot icon={Icon} />}
<span data-sidebar-label className="min-w-0 flex-1 truncate text-start"> <span data-sidebar-label className="min-w-0 flex-1 truncate text-start">

View file

@ -5,7 +5,8 @@ export const SIDEBAR_SECTION_STACK_CLASSES = "flex flex-col gap-3";
export const SIDEBAR_SECTION_CONTENT_CLASSES = "flex flex-col gap-0.5"; export const SIDEBAR_SECTION_CONTENT_CLASSES = "flex flex-col gap-0.5";
export const SIDEBAR_SECTION_ACTION_BUTTON_CLASSES = export const SIDEBAR_SECTION_ACTION_BUTTON_CLASSES =
"size-6 rounded text-muted-foreground/65 transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"; "size-6 rounded text-muted-foreground/65 transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50";
export const SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES = "bg-sidebar-accent text-foreground"; // Surface-free on purpose: in the rail a fill means a selected row, and a mode toggle is not one.
export const SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES = "text-foreground";
export const SIDEBAR_SECTION_ACTION_ICON_CLASSES = "size-3.5"; export const SIDEBAR_SECTION_ACTION_ICON_CLASSES = "size-3.5";
interface Props { interface Props {

View file

@ -7,7 +7,13 @@ import { useLocalStorage, useOverflowTitle } from "@/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import TagTree, { tagRowAriaLabel } from "../TagTree"; import TagTree, { tagRowAriaLabel } from "../TagTree";
import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_COUNT_RAIL_CLASSES, SidebarRowIconSlot, sidebarRowStateClasses } from "./SidebarRow"; import {
SIDEBAR_ROW_CLASSES,
SIDEBAR_ROW_COUNT_RAIL_CLASSES,
SidebarRowIconSlot,
sidebarRowStateAttributes,
sidebarRowStateClasses,
} from "./SidebarRow";
import SidebarSection, { import SidebarSection, {
SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES, SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES,
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
@ -50,6 +56,7 @@ interface FlatTagRowProps {
const FlatTagRow = ({ tag, amount, active, ariaLabel, onClick }: FlatTagRowProps) => { const FlatTagRow = ({ tag, amount, active, ariaLabel, onClick }: FlatTagRowProps) => {
const { ref, title } = useOverflowTitle<HTMLSpanElement>(`#${tag}`); const { ref, title } = useOverflowTitle<HTMLSpanElement>(`#${tag}`);
const state = active ? "checked" : "idle";
return ( return (
<button <button
@ -57,7 +64,8 @@ const FlatTagRow = ({ tag, amount, active, ariaLabel, onClick }: FlatTagRowProps
aria-label={ariaLabel} aria-label={ariaLabel}
aria-pressed={active || undefined} aria-pressed={active || undefined}
title={title} title={title}
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(active))} {...sidebarRowStateAttributes(state)}
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(state))}
onClick={onClick} onClick={onClick}
> >
{/* Same leading slot as the tree, so the # marks hold their line when switching modes. */} {/* Same leading slot as the tree, so the # marks hold their line when switching modes. */}

View file

@ -23,6 +23,7 @@ import SidebarRow, {
SIDEBAR_ROW_LABEL_CLASSES, SIDEBAR_ROW_LABEL_CLASSES,
SIDEBAR_ROW_SLOT_BUTTON_CLASSES, SIDEBAR_ROW_SLOT_BUTTON_CLASSES,
SidebarRowIconSlot, SidebarRowIconSlot,
sidebarRowStateAttributes,
sidebarRowStateClasses, sidebarRowStateClasses,
} from "./SidebarRow"; } from "./SidebarRow";
import SidebarSection, { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "./SidebarSection"; import SidebarSection, { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "./SidebarSection";
@ -90,7 +91,7 @@ const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => {
} }
> >
<SidebarRow <SidebarRow
active={!manageActive && selectedMemoView === BUILTIN_TASKS_VIEW_ID} state={!manageActive && selectedMemoView === BUILTIN_TASKS_VIEW_ID ? "checked" : "idle"}
icon={SquareCheckIcon} icon={SquareCheckIcon}
label={t("common.tasks")} label={t("common.tasks")}
onClick={() => handleView(BUILTIN_TASKS_VIEW_ID)} onClick={() => handleView(BUILTIN_TASKS_VIEW_ID)}
@ -98,8 +99,13 @@ const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => {
{memoViews.map((memoView) => { {memoViews.map((memoView) => {
const id = getMemoViewId(memoView.name); const id = getMemoViewId(memoView.name);
const active = !manageActive && selectedMemoView === id; const active = !manageActive && selectedMemoView === id;
const state = active ? "checked" : "idle";
return ( return (
<div key={memoView.name} className={cn(SIDEBAR_ROW_BOX_CLASSES, "group/view", sidebarRowStateClasses(active))}> <div
key={memoView.name}
{...sidebarRowStateAttributes(state)}
className={cn(SIDEBAR_ROW_BOX_CLASSES, "group/view", sidebarRowStateClasses(state))}
>
<button type="button" onClick={() => handleView(id)} aria-pressed={active || undefined} className={SIDEBAR_ROW_LABEL_CLASSES}> <button type="button" onClick={() => handleView(id)} aria-pressed={active || undefined} className={SIDEBAR_ROW_LABEL_CLASSES}>
<SidebarRowIconSlot icon={ParenthesesIcon} /> <SidebarRowIconSlot icon={ParenthesesIcon} />
<span className="min-w-0 flex-1 truncate">{memoView.title}</span> <span className="min-w-0 flex-1 truncate">{memoView.title}</span>
@ -135,7 +141,7 @@ const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => {
</div> </div>
); );
})} })}
{manageActive && <SidebarRow active icon={MoreHorizontalIcon} label={t("common.manage")} />} {manageActive && <SidebarRow state="current" icon={MoreHorizontalIcon} label={t("common.manage")} />}
<ConfirmDialog <ConfirmDialog
open={!!deleteTarget} open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(undefined)} onOpenChange={(open) => !open && setDeleteTarget(undefined)}

View file

@ -20,7 +20,7 @@ export const SIDEBAR_NAV_LEADING_SLOT_CLASSES = "flex size-4 shrink-0 items-cent
export const sidebarSurfaceVariants = cva("min-w-0 items-center", { export const sidebarSurfaceVariants = cva("min-w-0 items-center", {
variants: { variants: {
role: { role: {
row: "flex h-8 w-full gap-1 rounded-md px-2 text-ui", row: "flex h-7 w-full gap-1 rounded-md px-2 text-ui",
navPill: "relative flex h-8 rounded-md px-2", navPill: "relative flex h-8 rounded-md px-2",
headerBrand: "flex h-9 max-w-full gap-1 rounded-md px-2", headerBrand: "flex h-9 max-w-full gap-1 rounded-md px-2",
mobileBrand: "flex h-9 max-w-full gap-1.5 rounded-md px-1", mobileBrand: "flex h-9 max-w-full gap-1.5 rounded-md px-1",

View file

@ -1,3 +1,4 @@
import dayjs from "dayjs";
import { isEqual } from "lodash-es"; import { isEqual } from "lodash-es";
import { import {
BookmarkIcon, BookmarkIcon,
@ -7,16 +8,24 @@ import {
EyeIcon, EyeIcon,
HashIcon, HashIcon,
LinkIcon, LinkIcon,
LucideIcon, type LucideIcon,
MapPinIcon, MapPinIcon,
ParenthesesIcon,
SearchIcon, SearchIcon,
SquareCheckIcon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { FilterFactor, getMemoFilterKey, MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext"; import { type FilterFactor, getMemoFilterKey, type MemoFilter, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useMemoViews } from "@/hooks/useUserQueries";
import { BUILTIN_TASKS_VIEW_ID, getMemoViewId, isMemoScopeRoute } from "@/lib/memo-views";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
const DATE_FILTER_FORMAT = "MMM D, YYYY";
interface FilterConfig { interface FilterConfig {
icon: LucideIcon; icon: LucideIcon;
getLabel: (value: string, t: ReturnType<typeof useTranslate>) => string; getLabel: (value: string, t: ReturnType<typeof useTranslate>) => string;
@ -37,7 +46,10 @@ const FILTER_CONFIGS: Record<FilterFactor, FilterConfig> = {
}, },
displayTime: { displayTime: {
icon: CalendarIcon, icon: CalendarIcon,
getLabel: (value) => value, getLabel: (value) => {
const date = dayjs(value);
return date.isValid() ? date.format(DATE_FILTER_FORMAT) : value;
},
}, },
pinned: { pinned: {
icon: BookmarkIcon, icon: BookmarkIcon,
@ -61,9 +73,33 @@ const FILTER_CONFIGS: Record<FilterFactor, FilterConfig> = {
}, },
}; };
interface FilterChipProps {
icon?: LucideIcon;
label: string;
onRemove: () => void;
}
/** One chip for anything narrowing the list, so a view, a tag and a day are announced the same way. */
const FilterChip = ({ icon: Icon, label, onRemove }: FilterChipProps) => (
<div className="group inline-flex items-center gap-1.5 h-7 px-2.5 bg-accent/50 hover:bg-accent border border-border/50 rounded-full text-sm transition-all duration-200 hover:shadow-sm">
{Icon && <Icon className="w-3.5 h-3.5 text-muted-foreground shrink-0" />}
<span className="text-foreground/80 font-medium max-w-32 truncate">{label}</span>
<span className="ml-0.5 -mr-1">
<Button variant="ghost" size="icon-sm" onClick={onRemove} aria-label="Remove filter">
<XIcon className="w-3 h-3" />
</Button>
</span>
</div>
);
const MemoFilters = ({ className }: { className?: string }) => { const MemoFilters = ({ className }: { className?: string }) => {
const t = useTranslate(); const t = useTranslate();
const { filters, removeFilter } = useMemoFilterContext(); const location = useLocation();
const currentUser = useCurrentUser();
const { filters, memoView, removeFilter, setMemoView } = useMemoFilterContext();
// A remembered view only narrows the collection routes; elsewhere it is dormant and must not be echoed.
const viewApplies = memoView !== undefined && isMemoScopeRoute(location.pathname);
const { data: memoViews = [] } = useMemoViews(viewApplies ? currentUser?.name : undefined);
const handleRemoveFilter = (filter: MemoFilter) => { const handleRemoveFilter = (filter: MemoFilter) => {
removeFilter((f: MemoFilter) => isEqual(f, filter)); removeFilter((f: MemoFilter) => isEqual(f, filter));
@ -77,31 +113,28 @@ const MemoFilters = ({ className }: { className?: string }) => {
return config.getLabel(filter.value, t); return config.getLabel(filter.value, t);
}; };
if (filters.length === 0) { const viewChip = (() => {
if (!viewApplies) return null;
if (memoView === BUILTIN_TASKS_VIEW_ID) return { icon: SquareCheckIcon, label: t("common.tasks") };
const title = memoViews.find((item) => getMemoViewId(item.name) === memoView)?.title;
return title ? { icon: ParenthesesIcon, label: title } : null;
})();
if (filters.length === 0 && !viewChip) {
return null; return null;
} }
return ( return (
<div className={cn("w-full flex flex-row justify-start items-center flex-wrap gap-2", className)}> <div className={cn("w-full flex flex-row justify-start items-center flex-wrap gap-2", className)}>
{filters.map((filter) => { {viewChip && <FilterChip icon={viewChip.icon} label={viewChip.label} onRemove={() => setMemoView(undefined)} />}
const config = FILTER_CONFIGS[filter.factor]; {filters.map((filter) => (
const Icon = config?.icon; <FilterChip
key={getMemoFilterKey(filter)}
return ( icon={FILTER_CONFIGS[filter.factor]?.icon}
<div label={getFilterDisplayText(filter)}
key={getMemoFilterKey(filter)} onRemove={() => handleRemoveFilter(filter)}
className="group inline-flex items-center gap-1.5 h-7 px-2.5 bg-accent/50 hover:bg-accent border border-border/50 rounded-full text-sm transition-all duration-200 hover:shadow-sm" />
> ))}
{Icon && <Icon className="w-3.5 h-3.5 text-muted-foreground shrink-0" />}
<span className="text-foreground/80 font-medium max-w-32 truncate">{getFilterDisplayText(filter)}</span>
<span className="ml-0.5 -mr-1">
<Button variant="ghost" size="icon-sm" onClick={() => handleRemoveFilter(filter)} aria-label="Remove filter">
<XIcon className="w-3 h-3" />
</Button>
</span>
</div>
);
})}
</div> </div>
); );
}; };

View file

@ -116,7 +116,7 @@ function useAutoFetchWhenNotScrollable({
const PagedMemoList = (props: Props) => { const PagedMemoList = (props: Props) => {
const t = useTranslate(); const t = useTranslate();
const { isUserSettingsInitialized } = useAuth(); const { isUserSettingsInitialized } = useAuth();
const { filters } = useMemoFilterContext(); const { filters, memoView } = useMemoFilterContext();
const { maxColumns, compactMode } = useView(); const { maxColumns, compactMode } = useView();
// maxColumns is a ceiling: 1 = single reading column, 0 = as many as fit. The single // maxColumns is a ceiling: 1 = single reading column, 0 = as many as fit. The single
// column renders in normal document flow; anything wider becomes the packed grid. // column renders in normal document flow; anything wider becomes the packed grid.
@ -227,7 +227,7 @@ const PagedMemoList = (props: Props) => {
// empty state follows them. The newest memo also lands directly beneath them (priorityKey // empty state follows them. The newest memo also lands directly beneath them (priorityKey
// above). Every vertical seam inside the stack uses GRID_GAP so y-spacing matches the // above). Every vertical seam inside the stack uses GRID_GAP so y-spacing matches the
// grid's x-spacing exactly. // grid's x-spacing exactly.
const hasFilters = filters.length > 0; const hasFilters = filters.length > 0 || memoView !== undefined;
const gridLeading = const gridLeading =
leadingContent || hasFilters || initialLoader || emptyPlaceholder ? ( leadingContent || hasFilters || initialLoader || emptyPlaceholder ? (
<div className="flex w-full flex-col" style={{ gap: GRID_GAP }}> <div className="flex w-full flex-col" style={{ gap: GRID_GAP }}>

View file

@ -8,6 +8,7 @@ import {
SIDEBAR_ROW_SLOT_BUTTON_CLASSES, SIDEBAR_ROW_SLOT_BUTTON_CLASSES,
SIDEBAR_ROW_SLOT_CLASSES, SIDEBAR_ROW_SLOT_CLASSES,
SidebarRowIconSlot, SidebarRowIconSlot,
sidebarRowStateAttributes,
sidebarRowStateClasses, sidebarRowStateClasses,
} from "@/components/AppSidebar/SidebarRow"; } from "@/components/AppSidebar/SidebarRow";
import { useLocalStorage, useOverflowTitle } from "@/hooks"; import { useLocalStorage, useOverflowTitle } from "@/hooks";
@ -41,7 +42,7 @@ interface TagTreeExpansion {
const EMPTY_EXPANSION: TagTreeExpansion = { expanded: [] }; const EMPTY_EXPANSION: TagTreeExpansion = { expanded: [] };
// A structural row toggles like any other, so it hovers like one too — just quieter at rest. // A structural row toggles like any other, so it hovers like one too — just quieter at rest.
const STRUCTURAL_ROW_CLASSES = cn(sidebarRowStateClasses(false), "font-medium text-muted-foreground/65"); const STRUCTURAL_ROW_CLASSES = cn(sidebarRowStateClasses(), "font-medium text-muted-foreground/65");
/** One announcement for a tag row in either layout, so tree and flat mode never drift apart. */ /** One announcement for a tag row in either layout, so tree and flat mode never drift apart. */
export const tagRowAriaLabel = (t: ReturnType<typeof useTranslate>, tag: string, amount: number) => export const tagRowAriaLabel = (t: ReturnType<typeof useTranslate>, tag: string, amount: number) =>
@ -121,6 +122,7 @@ const TagItem = ({ tag, depth, activeTag, expanded, onTagClick, onToggle }: TagI
const open = hasSubTags && expanded.has(tag.text); const open = hasSubTags && expanded.has(tag.text);
const { ref: labelRef, title } = useOverflowTitle<HTMLSpanElement>(isTag ? `#${tag.text}` : tag.text); const { ref: labelRef, title } = useOverflowTitle<HTMLSpanElement>(isTag ? `#${tag.text}` : tag.text);
const tagLabel = tag.amount !== undefined ? tagRowAriaLabel(t, tag.text, tag.amount) : undefined; const tagLabel = tag.amount !== undefined ? tagRowAriaLabel(t, tag.text, tag.amount) : undefined;
const state = isActive ? "checked" : "idle";
return ( return (
<div className="w-full min-w-0"> <div className="w-full min-w-0">
@ -129,9 +131,10 @@ const TagItem = ({ tag, depth, activeTag, expanded, onTagClick, onToggle }: TagI
aria-level={depth + 1} aria-level={depth + 1}
aria-selected={isActive || undefined} aria-selected={isActive || undefined}
aria-expanded={hasSubTags ? open : undefined} aria-expanded={hasSubTags ? open : undefined}
{...sidebarRowStateAttributes(state)}
className={cn( className={cn(
SIDEBAR_ROW_BOX_CLASSES, SIDEBAR_ROW_BOX_CLASSES,
isTag ? sidebarRowStateClasses(isActive) : STRUCTURAL_ROW_CLASSES, isTag ? sidebarRowStateClasses(state) : STRUCTURAL_ROW_CLASSES,
isAncestorOfActiveTag && !isActive && "text-foreground/75", isAncestorOfActiveTag && !isActive && "text-foreground/75",
)} )}
// Overrides the start half of the box's `px-2`, leaving the trailing 8px intact. // Overrides the start half of the box's `px-2`, leaving the trailing 8px intact.

View file

@ -55,14 +55,24 @@ describe("CalendarCell empty-day clickability", () => {
expect(button.querySelector('[aria-hidden="true"]')).toHaveClass("rounded-full"); expect(button.querySelector('[aria-hidden="true"]')).toHaveClass("rounded-full");
}); });
it("uses an inset ring for selection without changing the numeral weight", () => { it("fills a selected day with the accent like a checked filter row, keeping the numeral weight", () => {
render(<CalendarCell day={makeDay({ isSelected: true })} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />); render(<CalendarCell day={makeDay({ isSelected: true })} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />);
const button = screen.getByRole("button", { name: /selected/ }); const button = screen.getByRole("button", { name: /selected/ });
expect(chipOf(button)).toHaveClass("ring-2", "ring-inset"); expect(chipOf(button)).toHaveClass("bg-primary", "text-primary-foreground", "font-medium");
expect(chipOf(button)).not.toHaveClass("ring-2", "ring-inset");
expect(chipOf(button)).not.toHaveClass("font-semibold", "font-bold"); expect(chipOf(button)).not.toHaveClass("font-semibold", "font-bold");
}); });
it("keeps the accent fill on a selected empty day under hover", () => {
render(<CalendarCell day={makeDay({ isSelected: true })} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />);
const chip = chipOf(screen.getByRole("button", { name: /selected/ }));
// The empty-cell hover tint would replace bg-primary on hover and strand the light numeral.
expect(chip).not.toHaveClass("group-hover/day:bg-muted/40", "bg-transparent");
expect(chip).toHaveClass("bg-primary");
});
it("caps the chip so a wider container buys hit area, not calendar height", () => { it("caps the chip so a wider container buys hit area, not calendar height", () => {
render(<CalendarCell day={makeDay()} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />); render(<CalendarCell day={makeDay()} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />);

View file

@ -39,7 +39,7 @@ describe("sidebar row grammar", () => {
const row = screen.getByRole("button", { name: "Tasks3" }); const row = screen.getByRole("button", { name: "Tasks3" });
expect(row).toHaveClass(...boxClasses); expect(row).toHaveClass(...boxClasses);
expect(row).toHaveClass("h-8", "w-full", "gap-1", "rounded-md", "px-2"); expect(row).toHaveClass("h-7", "w-full", "gap-1", "rounded-md", "px-2");
expect(row).not.toHaveClass("-mx-1"); expect(row).not.toHaveClass("-mx-1");
// Icon in the shared slot and count in the shared rail, so every list — nav rows, // Icon in the shared slot and count in the shared rail, so every list — nav rows,
// views, tags in both modes — keeps its icons and digits on the same vertical lines. // views, tags in both modes — keeps its icons and digits on the same vertical lines.

View file

@ -0,0 +1,108 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { HashIcon } from "lucide-react";
import { useEffect } from "react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import SidebarRow, { sidebarRowStateClasses } from "@/components/AppSidebar/SidebarRow";
import { SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES } from "@/components/AppSidebar/SidebarSection";
import TagsSection from "@/components/AppSidebar/TagsSection";
import MemoFilters from "@/components/MemoFilters";
import { MemoFilterProvider, useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { BUILTIN_TASKS_VIEW_ID } from "@/lib/memo-views";
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
vi.mock("@/hooks/useCurrentUser", () => ({ default: () => ({ name: "users/1" }) }));
vi.mock("@/hooks/useUserQueries", () => ({
useMemoViews: () => ({ data: [{ name: "users/1/memoViews/abc", title: "Last week", filter: "pinned" }] }),
}));
const tokens = (classes: string) => classes.split(" ");
/**
* The rail has two selected meanings and each gets its own look: a filled row is the page
* you are on, a checked row is a filter that is on. Views, tags and days are filters, so
* they must never wear the fill, and every filter that is on must be echoed above the list.
*/
describe("sidebar selected grammar", () => {
it("fills only a current row", () => {
expect(tokens(sidebarRowStateClasses("current"))).toContain("bg-sidebar-accent");
expect(tokens(sidebarRowStateClasses("checked"))).not.toContain("bg-sidebar-accent");
expect(tokens(sidebarRowStateClasses("checked"))).toContain("before:bg-primary");
expect(tokens(sidebarRowStateClasses())).not.toContain("font-medium");
});
it("marks a checked row so its icon and count take the accent", () => {
render(<SidebarRow state="checked" icon={HashIcon} label="work" count={4} />);
const row = screen.getByRole("button", { name: "work4" });
expect(row).toHaveAttribute("data-checked");
expect(row).toHaveAttribute("aria-pressed", "true");
expect(row).not.toHaveClass("bg-sidebar-accent");
expect(row.querySelector("svg")).toHaveClass("group-data-checked:text-primary");
expect(screen.getByText("4")).toHaveClass("group-data-checked:text-primary");
});
it("checks a tag row instead of filling it", () => {
render(
<MemoryRouter>
<MemoFilterProvider>
<TagsSection tagCount={{ work: 2 }} scope="home" />
</MemoFilterProvider>
</MemoryRouter>,
);
const row = screen.getByRole("button", { name: "#work, setting.tags.used-count" });
expect(row).not.toHaveAttribute("data-checked");
fireEvent.click(row);
expect(row).toHaveAttribute("data-checked");
expect(row).not.toHaveClass("bg-sidebar-accent");
});
it("keeps section mode toggles surface-free", () => {
expect(tokens(SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES)).not.toContain("bg-sidebar-accent");
});
});
const SelectView = ({ id }: { id: string }) => {
const { setMemoView } = useMemoFilterContext();
useEffect(() => setMemoView(id), [id, setMemoView]);
return null;
};
const renderChips = (path: string, viewId?: string) =>
render(
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter initialEntries={[path]}>
<MemoFilterProvider>
{viewId && <SelectView id={viewId} />}
<MemoFilters />
</MemoFilterProvider>
</MemoryRouter>
</QueryClientProvider>,
);
describe("MemoFilters", () => {
it("echoes the active view like any other filter, and clears it from the chip", () => {
renderChips("/", BUILTIN_TASKS_VIEW_ID);
expect(screen.getByText("common.tasks")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Remove filter" }));
expect(screen.queryByText("common.tasks")).not.toBeInTheDocument();
});
it("names a saved view by its title", () => {
renderChips("/", "abc");
expect(screen.getByText("Last week")).toBeInTheDocument();
});
it("stays quiet about a view off the collection routes, where it does not apply", () => {
renderChips("/u/alice", BUILTIN_TASKS_VIEW_ID);
expect(screen.queryByText("common.tasks")).not.toBeInTheDocument();
});
it("formats a day filter as a date rather than the raw value", () => {
renderChips("/?filter=displayTime:2026-09-02");
expect(screen.getByText("Sep 2, 2026")).toBeInTheDocument();
});
});