fix(sidebar): stop the month calendar growing with the rail

Calendar cells were `aspect-square w-full`, so cell height was a pure
function of rail width. Dragging the rail from 224px to its 400px max
took the calendar block from 231px to 382px — a 65% jump that pushed
Views and Tags out of view, left a 12px numeral floating in a 50px box,
and turned the selected day into a 50x50 slab against a rail whose every
other row is 30px.

Split the cell in two: the button spans its whole column and takes the
pointer, while a chip inside it carries the fill, the ring and the today
dot, and stops growing at `maxSize`. A chip is therefore
`min(column, 30px)` — narrow rails are untouched (224px still yields
25px cells, 170px of grid) and wider ones spend the surplus on hit area
instead of height, holding the grid at 200px from 258px up.

The today dot stops detaching as a side effect: it is positioned against
a chip that no longer grows, rather than against a cell floor that
drifted 19px below the numeral at full width.
This commit is contained in:
boojack 2026-08-06 22:59:21 +08:00
parent abe87d9440
commit d722608947
7 changed files with 172 additions and 74 deletions

View file

@ -24,31 +24,47 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
};
const sizeConfig = size === "small" ? SMALL_CELL_SIZE : DEFAULT_CELL_SIZE;
const smallExtraClasses = size === "small" ? `${SMALL_CELL_SIZE.dimensions} min-h-0` : "";
const baseClasses = cn(
"relative aspect-square w-full flex items-center justify-center text-center transition-[background-color,color,filter,box-shadow] duration-150 ease-out select-none",
// Two elements with two jobs: the cell spans its whole column and takes the pointer, the
// chip inside it is the square that carries the fill and sets the row's height.
const cellClasses = "group/day flex w-full items-center justify-center select-none";
const chipClasses = cn(
"relative flex aspect-square w-full items-center justify-center text-center transition-[background-color,color,filter,box-shadow] duration-150 ease-out",
sizeConfig.font,
sizeConfig.borderRadius,
smallExtraClasses,
sizeConfig.maxSize,
);
const isInteractive = Boolean(onClick);
const ariaLabel = day.isSelected ? `${tooltipText} (selected)` : tooltipText;
if (!day.isCurrentMonth) {
return <div className={cn(baseClasses, "text-muted-foreground/25 bg-transparent cursor-default")}>{day.label}</div>;
return (
<div className={cn(cellClasses, "cursor-default")}>
<span className={cn(chipClasses, "bg-transparent text-muted-foreground/25")}>{day.label}</span>
</div>
);
}
const intensityClass = getCellIntensityClass(day, maxCount);
const buttonClasses = cn(
"h-auto p-0",
baseClasses,
intensityClass,
getCalendarCellStateClass(day),
isInteractive
? "cursor-pointer hover:brightness-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-inset"
: "cursor-default",
const chip = (
<span
className={cn(
chipClasses,
intensityClass,
getCalendarCellStateClass(day),
isInteractive &&
"group-hover/day:brightness-[0.97] group-focus-visible/day:ring-2 group-focus-visible/day:ring-ring/40 group-focus-visible/day:ring-inset",
)}
>
{day.label}
{day.isToday && (
<span
aria-hidden="true"
className="absolute bottom-[3px] left-1/2 size-[3px] -translate-x-1/2 rounded-full bg-blue-600/80 dark:bg-blue-300/80"
/>
)}
</span>
);
const button = (
@ -59,15 +75,9 @@ export const CalendarCell = memo((props: CalendarCellProps) => {
aria-label={ariaLabel}
aria-current={day.isToday ? "date" : undefined}
aria-disabled={!isInteractive}
className={buttonClasses}
className={cn(cellClasses, "p-0 focus-visible:outline-none", isInteractive ? "cursor-pointer" : "cursor-default")}
>
{day.label}
{day.isToday && (
<span
aria-hidden="true"
className="absolute bottom-[3px] left-1/2 size-[3px] -translate-x-1/2 rounded-full bg-blue-600/80 dark:bg-blue-300/80"
/>
)}
{chip}
</button>
);

View file

@ -17,18 +17,26 @@ export const CELL_STYLES = {
MEDIUM: "bg-blue-400/45 text-foreground/80",
LOW: "bg-blue-400/30 text-foreground/80",
MINIMAL: "bg-blue-400/18 text-foreground/75",
EMPTY: "bg-transparent text-foreground/75 hover:bg-muted/40",
// Hover reads off the cell, not the chip, so the whole column responds to the pointer.
EMPTY: "bg-transparent text-foreground/75 group-hover/day:bg-muted/40",
} as const;
/**
* `maxSize` caps the square chip that carries a day's fill, so a chip measures
* `min(column, maxSize)`. Narrow containers are untouched — the 224px rail's ~25px columns
* stay below the cap — while wider ones spend the surplus on hit area rather than height,
* which is what stops a widened sidebar from doubling the calendar's height.
*/
export const SMALL_CELL_SIZE = {
font: "text-[10px] font-normal leading-none tracking-[-0.01em] tabular-nums",
dimensions: "w-full h-full",
maxSize: "max-w-[24px]",
borderRadius: "rounded-md",
gap: "gap-1",
} as const;
export const DEFAULT_CELL_SIZE = {
font: "text-xs font-normal leading-none tracking-[-0.01em] tabular-nums",
maxSize: "max-w-[30px]",
borderRadius: "rounded-md",
gap: "gap-1",
} as const;

View file

@ -2,12 +2,30 @@ import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export const SIDEBAR_ROW_CLASSES =
"group flex h-[30px] w-full min-w-0 items-center gap-2 rounded-md px-2 text-[13px] leading-[18px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
/**
* The rail's row rhythm — height, type scale, radius, padding, gap. Every list in the
* sidebar is built from this, so a row reads the same whether it is a view, a setting or
* a tag. Split from the focus treatment because a row is not always its own focus target:
* rows that carry a trailing control put the box on a wrapper and focus on the button
* inside it.
*/
export const SIDEBAR_ROW_BOX_CLASSES =
"group flex h-[30px] w-full min-w-0 items-center gap-2 rounded-md px-2 text-[13px] leading-[18px] transition-colors";
/** Goes on whichever element in a row actually takes focus. */
export const SIDEBAR_ROW_FOCUS_CLASSES = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50";
export const SIDEBAR_ROW_CLASSES = `${SIDEBAR_ROW_BOX_CLASSES} ${SIDEBAR_ROW_FOCUS_CLASSES}`;
export const SIDEBAR_ROW_ICON_CLASSES = "size-[15px] shrink-0 opacity-75";
export const SIDEBAR_ROW_COUNT_CLASSES = "text-[11px] tabular-nums text-muted-foreground/60";
/** Idle and selected colouring for a row box, kept in one place so lists cannot drift apart. */
export const sidebarRowStateClasses = (active?: boolean) =>
active
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
: "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground";
interface Props {
active?: boolean;
icon: LucideIcon;
@ -22,12 +40,7 @@ const SidebarRow = ({ active, icon: Icon, label, count, onClick, trailing }: Pro
type="button"
onClick={onClick}
aria-pressed={active || undefined}
className={cn(
SIDEBAR_ROW_CLASSES,
active
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
: "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground",
)}
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(active))}
>
<Icon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
<span className="min-w-0 flex-1 truncate text-left">{label}</span>

View file

@ -1,10 +1,12 @@
import { HashIcon, ListIcon, ListTreeIcon } from "lucide-react";
import { forwardRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
import { useLocalStorage, useOverflowTitle } from "@/hooks";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
import TagTree from "../TagTree";
import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_COUNT_CLASSES, SIDEBAR_ROW_ICON_CLASSES, sidebarRowStateClasses } from "./SidebarRow";
import SidebarSectionHeader from "./SidebarSectionHeader";
interface Props {
@ -16,7 +18,7 @@ const TagPath = forwardRef<HTMLSpanElement, { tag: string }>(({ tag }, ref) => {
const segments = tag.split("/");
return (
<span ref={ref} className="min-w-0 truncate text-left">
<span ref={ref} className="min-w-0 flex-1 truncate text-left">
{segments.map((segment, index) => (
<span key={`${segment}-${index}`}>
{index > 0 && <span className="px-0.5 text-muted-foreground/40">/</span>}
@ -43,17 +45,12 @@ const FlatTagRow = ({ tag, amount, active, onClick }: FlatTagRowProps) => {
type="button"
aria-pressed={active || undefined}
title={title}
className={cn(
"group grid h-[26px] w-full min-w-0 grid-cols-[12px_minmax(0,1fr)_auto] items-center gap-x-1.5 rounded-[5px] px-2 text-xs leading-4 text-muted-foreground transition-colors hover:bg-sidebar-accent/65 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring/40",
active && "bg-sidebar-accent font-medium text-sidebar-accent-foreground hover:bg-sidebar-accent",
)}
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(active))}
onClick={onClick}
>
<HashIcon aria-hidden="true" className="size-3 text-muted-foreground/65" strokeWidth={1.75} />
<HashIcon aria-hidden="true" className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
<TagPath ref={ref} tag={tag} />
<span className={cn("shrink-0 leading-none tabular-nums text-muted-foreground/50", active && "text-sidebar-accent-foreground/65")}>
{amount}
</span>
<span className={SIDEBAR_ROW_COUNT_CLASSES}>{amount}</span>
</button>
);
};
@ -86,30 +83,26 @@ const TagsSection = ({ tagCount, onSelect }: Props) => {
<SidebarSectionHeader
action={
<div className="flex items-center gap-0.5" role="group" aria-label={t("common.tags")}>
<button
type="button"
<Button
variant="ghost"
size="icon-sm"
aria-label={t("common.tags")}
aria-pressed={!treeMode}
className={cn(
"flex size-[22px] items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/40",
!treeMode && "bg-sidebar-accent text-foreground",
)}
className={cn("size-5 rounded text-muted-foreground", !treeMode && "bg-accent text-foreground")}
onClick={() => setTreeMode(false)}
>
<ListIcon className="size-3.5" strokeWidth={1.7} />
</button>
<button
type="button"
<ListIcon className="size-3.5" strokeWidth={1.8} />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`${t("common.tags")}: ${t("common.tree-mode")}`}
aria-pressed={treeMode}
className={cn(
"flex size-[22px] items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-sidebar-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/40",
treeMode && "bg-sidebar-accent text-foreground",
)}
className={cn("size-5 rounded text-muted-foreground", treeMode && "bg-accent text-foreground")}
onClick={() => setTreeMode(true)}
>
<ListTreeIcon className="size-3.5" strokeWidth={1.7} />
</button>
<ListTreeIcon className="size-3.5" strokeWidth={1.8} />
</Button>
</div>
}
>
@ -121,7 +114,7 @@ const TagsSection = ({ tagCount, onSelect }: Props) => {
{treeMode ? (
<TagTree tagAmounts={tags} activeTag={activeTag} onTagClick={handleTagClick} />
) : (
<div className="space-y-px">
<div className="space-y-0.5">
{tags.map(([tag, amount]) => (
<FlatTagRow key={tag} tag={tag} amount={amount} active={activeTags.has(tag)} onClick={() => handleTagClick(tag)} />
))}

View file

@ -1,9 +1,19 @@
import { HashIcon } from "lucide-react";
import { useMemo } from "react";
import {
SIDEBAR_ROW_BOX_CLASSES,
SIDEBAR_ROW_COUNT_CLASSES,
SIDEBAR_ROW_FOCUS_CLASSES,
SIDEBAR_ROW_ICON_CLASSES,
sidebarRowStateClasses,
} from "@/components/AppSidebar/SidebarRow";
import { useOverflowTitle } from "@/hooks";
import { cn } from "@/lib/utils";
import { useTranslate } from "@/utils/i18n";
/** Matches the memo outline's indent, the app's other nested rail list. */
const INDENT_STEP = 12;
interface TagTreeNode {
key: string;
text: string;
@ -52,7 +62,7 @@ export const buildTagTree = (tagAmounts: [tag: string, amount: number][]) => {
return root.subTags;
};
const TagMark = () => <HashIcon aria-hidden="true" className="size-3 text-muted-foreground/65" strokeWidth={1.75} />;
const TagMark = () => <HashIcon aria-hidden="true" className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />;
interface TagItemProps {
tag: TagTreeNode;
@ -75,40 +85,38 @@ const TagItem = ({ tag, depth, activeTag, onTagClick }: TagItemProps) => {
aria-level={depth + 1}
aria-selected={isActive || undefined}
className={cn(
"relative flex h-[26px] w-full min-w-0 items-center rounded-[5px] pr-2 text-xs leading-4 transition-colors",
isTag ? "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground" : "font-medium text-muted-foreground/65",
isActive && "bg-sidebar-accent font-medium text-sidebar-accent-foreground hover:bg-sidebar-accent",
SIDEBAR_ROW_BOX_CLASSES,
isTag ? sidebarRowStateClasses(isActive) : "font-medium text-muted-foreground/65",
isAncestorOfActiveTag && !isActive && "text-foreground/75",
)}
style={{ paddingInlineStart: 8 + depth * 14 }}
// Overrides the start half of the box's `px-2`, leaving the trailing 8px intact.
// Same 12px step the memo outline indents by.
style={{ paddingInlineStart: 8 + depth * INDENT_STEP }}
>
{isTag ? (
<button
type="button"
aria-pressed={isActive || undefined}
title={title}
className="grid h-full min-w-0 flex-1 grid-cols-[12px_minmax(0,1fr)_auto] items-center gap-x-1.5 rounded-sm text-left focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring/40"
className={cn("flex h-full min-w-0 flex-1 items-center gap-2 text-left", SIDEBAR_ROW_FOCUS_CLASSES)}
onClick={() => onTagClick(tag.text)}
>
<TagMark />
<span ref={labelRef} className="min-w-0 flex-1 truncate">
{tag.key}
</span>
<span
className={cn("shrink-0 leading-none tabular-nums text-muted-foreground/50", isActive && "text-sidebar-accent-foreground/65")}
>
{tag.amount}
</span>
<span className={SIDEBAR_ROW_COUNT_CLASSES}>{tag.amount}</span>
</button>
) : (
<span ref={labelRef} className="min-w-0 flex-1 truncate ps-[18px]" title={title}>
// Aligns with the tag rows below it: past the 15px mark plus the row's 8px gap.
<span ref={labelRef} className="min-w-0 flex-1 truncate ps-[23px]" title={title}>
{tag.key}
</span>
)}
</div>
{hasSubTags && (
<div className="mt-px flex w-full min-w-0 flex-col gap-px" role="group">
<div className="mt-0.5 flex w-full min-w-0 flex-col gap-0.5" role="group">
{tag.subTags.map((subTag) => (
<TagItem key={subTag.text} tag={subTag} depth={depth + 1} activeTag={activeTag} onTagClick={onTagClick} />
))}
@ -123,7 +131,7 @@ const TagTree = ({ tagAmounts, activeTag, onTagClick }: Props) => {
const tags = useMemo(() => buildTagTree(tagAmounts), [tagAmounts]);
return (
<div className="relative flex h-auto w-full flex-col items-stretch gap-px" role="tree" aria-label={t("common.tags")}>
<div className="relative flex h-auto w-full flex-col items-stretch gap-0.5" role="tree" aria-label={t("common.tags")}>
{tags.map((tag) => (
<TagItem key={tag.text} tag={tag} depth={0} activeTag={activeTag} onTagClick={onTagClick} />
))}

View file

@ -13,6 +13,9 @@ const makeDay = (overrides: Partial<CalendarDayCell> = {}): CalendarDayCell => (
...overrides,
});
/** The button spans its column; the square chip inside it carries every visual. */
const chipOf = (button: HTMLElement) => button.firstElementChild as HTMLElement;
describe("CalendarCell empty-day clickability", () => {
it("fires onClick for an in-month day with count=0", () => {
const onClick = vi.fn();
@ -31,7 +34,7 @@ describe("CalendarCell empty-day clickability", () => {
expect(button).toHaveAttribute("tabindex", "0");
expect(button).toHaveAttribute("aria-disabled", "false");
expect(button).not.toHaveAttribute("data-slot", "tooltip-trigger");
expect(button).toHaveClass("bg-transparent");
expect(chipOf(button)).toHaveClass("bg-transparent");
});
it("still renders a populated in-month day as interactive", () => {
@ -56,8 +59,18 @@ describe("CalendarCell empty-day clickability", () => {
render(<CalendarCell day={makeDay({ isSelected: true })} maxCount={5} tooltipText="May 1, 2025" onClick={() => {}} />);
const button = screen.getByRole("button", { name: /selected/ });
expect(button).toHaveClass("ring-2", "ring-inset");
expect(button).not.toHaveClass("font-semibold", "font-bold");
expect(chipOf(button)).toHaveClass("ring-2", "ring-inset");
expect(chipOf(button)).not.toHaveClass("font-semibold", "font-bold");
});
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={() => {}} />);
const button = screen.getByRole("button", { name: /May 1, 2025/ });
// The square lives on the capped chip. Putting it back on the button would make row
// height track the column width again, which is what made a widened rail so tall.
expect(button).not.toHaveClass("aspect-square");
expect(chipOf(button)).toHaveClass("aspect-square", "max-w-[30px]");
});
it("does not render out-of-month days as interactive (no role=button)", () => {

View file

@ -0,0 +1,53 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import SidebarRow, { SIDEBAR_ROW_BOX_CLASSES } from "@/components/AppSidebar/SidebarRow";
import TagTree from "@/components/TagTree";
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
/**
* Tag rows drifted off the rail's row grammar once already (they were 26px/12px against
* everything else's 30px/13px). These lock the shared box: the sizes must come from
* `SIDEBAR_ROW_BOX_CLASSES`, not from values copied into each list.
*/
const boxClasses = SIDEBAR_ROW_BOX_CLASSES.split(" ");
describe("sidebar row grammar", () => {
it("gives a nav row the shared row box", () => {
render(<SidebarRow icon={() => null} label="Tasks" />);
expect(screen.getByRole("button", { name: "Tasks" })).toHaveClass(...boxClasses);
});
it("gives tag tree rows the same box as a nav row", () => {
render(
<TagTree
tagAmounts={[
["a", 2],
["a/b", 1],
]}
onTagClick={vi.fn()}
/>,
);
for (const item of screen.getAllByRole("treeitem")) {
expect(item).toHaveClass(...boxClasses);
}
});
it("indents nested tags by the same step the memo outline uses", () => {
render(
<TagTree
tagAmounts={[
["a", 2],
["a/b", 1],
]}
onTagClick={vi.fn()}
/>,
);
const [root, child] = screen.getAllByRole("treeitem");
expect(root).toHaveStyle({ paddingInlineStart: "8px" });
expect(child).toHaveStyle({ paddingInlineStart: "20px" });
});
});