chore: add a quiet button variant and one ring-free focus for kit
buttons The calendar shipped its own "quiet control" grammar as a page-local set of class strings: 28px tall, 13px text, muted ink that darkens under a light hover wash, ring-free outline focus, and the accent fill reserved for a pressed or current state. The same look was hand-rolled elsewhere with drifting values (/65 ink, /50 washes, ring focus) as className overrides on the kit's Button, against the kit policy. The grammar now lives in the kit as `variant="quiet"`, with the accent fill keyed off `aria-pressed`, `aria-current` and `data-popup-open`, so a control that is on, current, or holding a popup open fills itself. A `size="icon-compact"` names the 28px square the sidebar already used for its compose control; `sm` already had the height for labelled chips. Kit buttons had no focus treatment of their own, so sites that needed one bolted `focus-visible:ring-*` overrides onto the Button. The base now carries a 2px inset outline from a new `ui/focus.ts` constant that raw controls (calendar grid cells, the reaction chip) import too, and the calendar's controls.ts goes away. With both in place the hand-rolled sites collapse onto the variant: the calendar's header, month picker and day panel; the statistics month navigator; the sidebar's section actions and compose and mobile menu controls; the memo card's trailing actions and its action menu, which no longer takes a className; the share-image dialog's footer; the memo outline; and the inactive underline tab. The month title fills while its picker is open and the outline's current heading fills like every other current thing. Tests follow the classes they assert.
This commit is contained in:
parent
ef410846c4
commit
c031b77c83
29 changed files with 118 additions and 209 deletions
|
|
@ -65,27 +65,13 @@ import {
|
|||
import TagsSection from "./TagsSection";
|
||||
import ViewsSection from "./ViewsSection";
|
||||
|
||||
const SIDEBAR_HEADER_PRIMARY_ACTION_CLASSES =
|
||||
"size-7 shrink-0 text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50";
|
||||
|
||||
const NewMemoAction = ({ onClick }: { onClick: () => void }) => {
|
||||
const t = useTranslate();
|
||||
const label = t("editor.new-memo");
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className={SIDEBAR_HEADER_PRIMARY_ACTION_CLASSES}
|
||||
onClick={onClick}
|
||||
aria-label={label}
|
||||
data-new-memo-trigger
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TooltipTrigger render={<Button variant="outline" size="icon-compact" onClick={onClick} aria-label={label} data-new-memo-trigger />}>
|
||||
<SquarePenIcon className="size-4" strokeWidth={1.8} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
||||
|
|
@ -628,14 +614,7 @@ export const MobileAppHeader = () => {
|
|||
const { setMobileOpen } = useAppSidebar();
|
||||
return (
|
||||
<header className="sticky top-0 z-20 flex h-12 w-full items-center justify-start gap-1 border-b border-border/70 bg-background/90 px-2 backdrop-blur-md md:hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-8 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
aria-label="Open navigation"
|
||||
data-mobile-navigation-trigger
|
||||
>
|
||||
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)} aria-label="Open navigation" data-mobile-navigation-trigger>
|
||||
<MenuIcon className="size-[18px]" />
|
||||
</Button>
|
||||
<SidebarBrand className="max-w-[12rem]" size="md" />
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ import SidebarSectionHeader from "./SidebarSectionHeader";
|
|||
|
||||
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_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";
|
||||
// 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";
|
||||
// Section actions are the kit's quiet `icon-sm` buttons; only their glyph is section-specific.
|
||||
export const SIDEBAR_SECTION_ACTION_ICON_CLASSES = "size-3.5";
|
||||
|
||||
interface Props {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
sidebarRowStateAttributes,
|
||||
sidebarRowStateClasses,
|
||||
} from "./SidebarRow";
|
||||
import SidebarSection, { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "./SidebarSection";
|
||||
import SidebarSection, { SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "./SidebarSection";
|
||||
|
||||
interface Props {
|
||||
tagCount: Record<string, number>;
|
||||
|
|
@ -110,16 +110,7 @@ const TagsSection = ({ tagCount, onSelect, scope }: Props) => {
|
|||
<TooltipTrigger render={<span className="inline-flex" />}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={`${t("common.tags")}: ${t("common.more")}`}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
|
||||
"data-popup-open:bg-sidebar-accent data-popup-open:text-foreground",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
render={<Button variant="quiet" size="icon-sm" />}
|
||||
>
|
||||
<MoreHorizontalIcon className={SIDEBAR_SECTION_ACTION_ICON_CLASSES} strokeWidth={1.8} />
|
||||
</DropdownMenuTrigger>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import SidebarRow, {
|
|||
sidebarRowStateAttributes,
|
||||
sidebarRowStateClasses,
|
||||
} from "./SidebarRow";
|
||||
import SidebarSection, { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "./SidebarSection";
|
||||
import SidebarSection, { SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "./SidebarSection";
|
||||
|
||||
/** The row's ⋯ menu: a trailing slot control that stays hidden until the row is engaged. */
|
||||
const VIEW_MENU_TRIGGER_CLASSES = cn(
|
||||
|
|
@ -77,13 +77,7 @@ const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => {
|
|||
!manageActive && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<MemoDisplaySettingMenu />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={SIDEBAR_SECTION_ACTION_BUTTON_CLASSES}
|
||||
onClick={handleCreate}
|
||||
aria-label={t("common.create")}
|
||||
>
|
||||
<Button variant="quiet" size="icon-sm" onClick={handleCreate} aria-label={t("common.create")}>
|
||||
<PlusIcon className={SIDEBAR_SECTION_ACTION_ICON_CLASSES} strokeWidth={1.8} />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { memo } from "react";
|
||||
import { type CalendarDayCell as CalendarDayCellData, getTooltipText } from "@/components/ActivityCalendar";
|
||||
import { FOCUS_VISIBLE_OUTLINE_CLASSES } from "@/components/ui/focus";
|
||||
import type { MemoTimeBasis } from "@/contexts/ViewContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { CalendarLink } from "./CalendarLink";
|
||||
import { CALENDAR_FOCUS_CLASSES } from "./controls";
|
||||
import type { CalendarDaySummary } from "./dayModel";
|
||||
import { buildCalendarPath, getMonthOfDate } from "./paths";
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ export const CalendarDayCell = memo(
|
|||
!isLastColumn && "border-e",
|
||||
!isLastRow && "border-b",
|
||||
corner && CORNER_CLASSES[corner],
|
||||
CALENDAR_FOCUS_CLASSES,
|
||||
FOCUS_VISIBLE_OUTLINE_CLASSES,
|
||||
// The open day is the place you are, so it takes the fill the sidebar gives a current row.
|
||||
day.isSelected ? "bg-accent" : day.isCurrentMonth ? "bg-card hover:bg-muted/40" : "bg-muted/25 hover:bg-muted/45",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { addMonths } from "@/lib/calendar-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { CalendarLink } from "./CalendarLink";
|
||||
import { CALENDAR_CONTROL_ACTIVE_CLASSES, CALENDAR_ICON_CONTROL_CLASSES, CALENDAR_TEXT_CONTROL_CLASSES } from "./controls";
|
||||
import { MonthPicker } from "./MonthPicker";
|
||||
import { buildCalendarPath, getMonthOfDate } from "./paths";
|
||||
|
||||
|
|
@ -35,6 +35,7 @@ export const CalendarHeader = ({ month, monthLabel, today, activeDate, closable
|
|||
const navigate = useNavigate();
|
||||
const { search } = useLocation();
|
||||
const todayOpen = activeDate === today;
|
||||
const iconControlClassName = cn(buttonVariants({ variant: "quiet", size: "icon-compact" }));
|
||||
|
||||
return (
|
||||
// The title's text starts on the grid's text axis (border + cell padding); the month
|
||||
|
|
@ -42,25 +43,18 @@ export const CalendarHeader = ({ month, monthLabel, today, activeDate, closable
|
|||
<header className="flex h-9 shrink-0 items-center ps-px">
|
||||
<MonthPicker month={month} monthLabel={monthLabel} today={today} />
|
||||
<div className="ms-auto flex items-center gap-0.5">
|
||||
<CalendarLink
|
||||
to={buildCalendarPath(addMonths(month, -1))}
|
||||
aria-label={t("common.previous-month")}
|
||||
className={CALENDAR_ICON_CONTROL_CLASSES}
|
||||
>
|
||||
<CalendarLink to={buildCalendarPath(addMonths(month, -1))} aria-label={t("common.previous-month")} className={iconControlClassName}>
|
||||
<ChevronLeftIcon className="rtl:rotate-180" strokeWidth={1.75} />
|
||||
</CalendarLink>
|
||||
<CalendarLink
|
||||
to={buildCalendarPath(addMonths(month, 1))}
|
||||
aria-label={t("common.next-month")}
|
||||
className={CALENDAR_ICON_CONTROL_CLASSES}
|
||||
>
|
||||
<CalendarLink to={buildCalendarPath(addMonths(month, 1))} aria-label={t("common.next-month")} className={iconControlClassName}>
|
||||
<ChevronRightIcon className="rtl:rotate-180" strokeWidth={1.75} />
|
||||
</CalendarLink>
|
||||
{/* Today is a toggle, so it is a button that navigates; the search keeps the filter query. */}
|
||||
{/* Today is a toggle, so it is a button that navigates; the search keeps the filter query.
|
||||
Its pressed state is the quiet variant's accent fill, keyed off aria-pressed. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={todayOpen}
|
||||
className={cn(CALENDAR_TEXT_CONTROL_CLASSES, "ms-1.5", todayOpen && CALENDAR_CONTROL_ACTIVE_CLASSES)}
|
||||
className={cn(buttonVariants({ variant: "quiet", size: "sm" }), "ms-1.5")}
|
||||
onClick={() => navigate({ pathname: getTodayPath(month, activeDate, today, closable), search })}
|
||||
>
|
||||
{t("common.today")}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { MentionResolutionProvider } from "@/components/MemoContent/MentionResol
|
|||
import MemoEditor from "@/components/MemoEditor";
|
||||
import { deriveDefaultCreateTimeFromDate } from "@/components/MemoEditor/utils/deriveDefaultCreateTime";
|
||||
import MemoView from "@/components/MemoView";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { NewMemoProvider } from "@/contexts/NewMemoContext";
|
||||
import { useSpaceContext } from "@/contexts/SpaceContext";
|
||||
|
|
@ -13,7 +14,6 @@ import { parseLocalDate } from "@/lib/calendar-utils";
|
|||
import { cn } from "@/lib/utils";
|
||||
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { CALENDAR_ICON_CONTROL_CLASSES, CALENDAR_TEXT_CONTROL_CLASSES } from "./controls";
|
||||
|
||||
export interface DayPanelProps {
|
||||
/** `YYYY-MM-DD` */
|
||||
|
|
@ -58,7 +58,12 @@ export const DayPanel = ({ date, memos, onClose }: DayPanelProps) => {
|
|||
<header className="mb-3 flex items-center gap-0.5 border-b border-border/70 pb-3">
|
||||
<h2 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight text-foreground">{dateLabel}</h2>
|
||||
{onClose && (
|
||||
<button type="button" aria-label={t("calendar.close-day")} className={CALENDAR_ICON_CONTROL_CLASSES} onClick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("calendar.close-day")}
|
||||
className={cn(buttonVariants({ variant: "quiet", size: "icon-compact" }))}
|
||||
onClick={onClose}
|
||||
>
|
||||
<XIcon strokeWidth={1.8} />
|
||||
</button>
|
||||
)}
|
||||
|
|
@ -92,7 +97,7 @@ export const DayPanel = ({ date, memos, onClose }: DayPanelProps) => {
|
|||
<button
|
||||
type="button"
|
||||
// The cards' own bottom margin sets the gap; the chip's edge lines up with theirs.
|
||||
className={cn(CALENDAR_TEXT_CONTROL_CLASSES, "self-start")}
|
||||
className={cn(buttonVariants({ variant: "quiet", size: "sm" }), "self-start")}
|
||||
onClick={() => setComposingFor(date)}
|
||||
>
|
||||
<PlusIcon strokeWidth={1.8} />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { CalendarLink } from "./CalendarLink";
|
||||
import { CALENDAR_CONTROL_ACTIVE_CLASSES, CALENDAR_ICON_CONTROL_CLASSES, CALENDAR_TEXT_CONTROL_CLASSES } from "./controls";
|
||||
import { buildCalendarPath, buildMonthKey, getMonthOfDate } from "./paths";
|
||||
|
||||
export interface MonthPickerProps {
|
||||
|
|
@ -18,10 +18,13 @@ export interface MonthPickerProps {
|
|||
|
||||
const MONTHS = Array.from({ length: 12 }, (_, index) => index + 1);
|
||||
|
||||
const TEXT_CONTROL_CLASSES = cn(buttonVariants({ variant: "quiet", size: "sm" }));
|
||||
const ICON_CONTROL_CLASSES = cn(buttonVariants({ variant: "quiet", size: "icon-compact" }));
|
||||
|
||||
/**
|
||||
* The month title is the trigger; the popup is a year stepper over the twelve months, the
|
||||
* shown month filled and the current month marked. Months are links so the browser owns
|
||||
* history and middle-click.
|
||||
* shown month filled (it is the `aria-current` month) and the current month marked. Months
|
||||
* are links so the browser owns history and middle-click.
|
||||
*/
|
||||
export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => {
|
||||
const t = useTranslate();
|
||||
|
|
@ -45,7 +48,7 @@ export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => {
|
|||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
aria-label={t("calendar.select-month")}
|
||||
className={cn(CALENDAR_TEXT_CONTROL_CLASSES, "text-base font-semibold tracking-tight text-foreground")}
|
||||
className={cn(TEXT_CONTROL_CLASSES, "text-base font-semibold tracking-tight text-foreground")}
|
||||
>
|
||||
{monthLabel}
|
||||
</PopoverTrigger>
|
||||
|
|
@ -54,7 +57,7 @@ export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => {
|
|||
<button
|
||||
type="button"
|
||||
aria-label={t("calendar.previous-year")}
|
||||
className={CALENDAR_ICON_CONTROL_CLASSES}
|
||||
className={ICON_CONTROL_CLASSES}
|
||||
onClick={() => setYear((value) => value - 1)}
|
||||
>
|
||||
<ChevronLeftIcon className="rtl:rotate-180" strokeWidth={1.75} />
|
||||
|
|
@ -63,7 +66,7 @@ export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => {
|
|||
<button
|
||||
type="button"
|
||||
aria-label={t("calendar.next-year")}
|
||||
className={CALENDAR_ICON_CONTROL_CLASSES}
|
||||
className={ICON_CONTROL_CLASSES}
|
||||
onClick={() => setYear((value) => value + 1)}
|
||||
>
|
||||
<ChevronRightIcon className="rtl:rotate-180" strokeWidth={1.75} />
|
||||
|
|
@ -79,11 +82,7 @@ export const MonthPicker = ({ month, monthLabel, today }: MonthPickerProps) => {
|
|||
key={key}
|
||||
to={buildCalendarPath(key)}
|
||||
aria-current={isShown ? "page" : undefined}
|
||||
className={cn(
|
||||
CALENDAR_TEXT_CONTROL_CLASSES,
|
||||
"relative justify-center text-foreground",
|
||||
isShown && ["font-medium", CALENDAR_CONTROL_ACTIVE_CLASSES],
|
||||
)}
|
||||
className={cn(TEXT_CONTROL_CLASSES, "relative text-foreground", isShown && "font-medium")}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{monthShortLabels[value - 1]}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* One quiet control grammar for the calendar, in the spirit of Notion and Linear: 28px tall,
|
||||
* 13px text, muted ink that darkens on hover under a light wash, even padding, and the accent
|
||||
* fill reserved for a pressed or current state. Raw elements carry these classes so the kit's
|
||||
* variants never fight them.
|
||||
*/
|
||||
/** The ring-free focus treatment every calendar control and grid cell shares. */
|
||||
export const CALENDAR_FOCUS_CLASSES =
|
||||
"focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring/60";
|
||||
|
||||
const CALENDAR_CONTROL_CLASSES = cn(
|
||||
"inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md text-ui text-muted-foreground/70 no-underline transition-colors hover:bg-muted/60 hover:text-foreground [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
CALENDAR_FOCUS_CLASSES,
|
||||
);
|
||||
|
||||
/** A control that is only a glyph: a 28px square. */
|
||||
export const CALENDAR_ICON_CONTROL_CLASSES = cn(CALENDAR_CONTROL_CLASSES, "w-7 justify-center");
|
||||
|
||||
/** A control with a label: 8px of side padding, so the hover wash is a chip around the words. */
|
||||
export const CALENDAR_TEXT_CONTROL_CLASSES = cn(CALENDAR_CONTROL_CLASSES, "px-2");
|
||||
|
||||
/** The one state that takes a fill: the place you are, or the thing that is on. */
|
||||
export const CALENDAR_CONTROL_ACTIVE_CLASSES = "bg-accent text-accent-foreground hover:bg-accent";
|
||||
|
|
@ -25,7 +25,6 @@ import {
|
|||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import { useMemoActionHandlers } from "./hooks";
|
||||
|
|
@ -64,19 +63,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
|
|||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("common.more")}
|
||||
className={cn(
|
||||
"text-muted-foreground/70 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50 data-popup-open:bg-accent data-popup-open:text-accent-foreground",
|
||||
props.className,
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DropdownMenuTrigger render={<Button variant="quiet" size="icon-sm" aria-label={t("common.more")} />}>
|
||||
<MoreVerticalIcon className="size-4" strokeWidth={1.8} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={2}>
|
||||
|
|
|
|||
|
|
@ -108,22 +108,12 @@ const MemoShareImageDialog = ({ open, onOpenChange }: MemoShareImageDialogProps)
|
|||
|
||||
<DialogFooter className="shrink-0 border-t border-border/60 px-4 py-3 sm:px-5">
|
||||
{supportsNativeShare && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={handleNativeShare}
|
||||
disabled={isRendering}
|
||||
>
|
||||
<Button variant="quiet" onClick={handleNativeShare} disabled={isRendering}>
|
||||
{isRendering ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : <Share2Icon className="mr-2 h-4 w-4" />}
|
||||
{t("memo.share.image-share")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-border/70 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
onClick={handleDownload}
|
||||
disabled={isRendering}
|
||||
>
|
||||
<Button variant="outline" onClick={handleDownload} disabled={isRendering}>
|
||||
{isRendering ? <Loader2Icon className="mr-2 h-4 w-4 animate-spin" /> : <DownloadIcon className="mr-2 h-4 w-4" />}
|
||||
{t("memo.share.image-download")}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,5 @@ export interface MemoActionMenuProps {
|
|||
memo: Memo;
|
||||
parentScope: MemoOriginScope;
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
onEdit?: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { SIDEBAR_ROW_BOX_CLASSES } from "@/components/AppSidebar/SidebarRow";
|
||||
import type { HeadingItem } from "@/components/MemoContent/pipeline";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { findAnchorTarget, findMemoContentRoot } from "@/utils/markdown-manipulation";
|
||||
|
||||
|
|
@ -12,6 +12,12 @@ interface MemoOutlineProps {
|
|||
/** Distance from the viewport top of the "reading line" used to decide the active section. */
|
||||
const READING_LINE_OFFSET = 100;
|
||||
|
||||
/**
|
||||
* Each heading is a quiet row: the kit's `quiet` treatment at the sidebar's 28px row height,
|
||||
* so the section you are reading takes the accent fill through `aria-current`.
|
||||
*/
|
||||
const OUTLINE_ROW_CLASSES = cn(buttonVariants({ variant: "quiet", size: "sm" }), "relative w-full justify-start");
|
||||
|
||||
/** Outline navigation for memo headings (h1–h4) with active-section tracking. */
|
||||
const MemoOutline = ({ headings, memoName }: MemoOutlineProps) => {
|
||||
const [activeSlug, setActiveSlug] = useState<string | null>(null);
|
||||
|
|
@ -72,12 +78,7 @@ const MemoOutline = ({ headings, memoName }: MemoOutlineProps) => {
|
|||
href={`#${heading.slug}`}
|
||||
onClick={(e) => handleClick(e, heading.slug)}
|
||||
aria-current={active ? "location" : undefined}
|
||||
className={cn(
|
||||
SIDEBAR_ROW_BOX_CLASSES,
|
||||
"relative",
|
||||
heading.level === minLevel && "font-medium",
|
||||
active ? "text-foreground" : "text-muted-foreground/70 hover:bg-sidebar-accent/65 hover:text-foreground",
|
||||
)}
|
||||
className={cn(OUTLINE_ROW_CLASSES, heading.level === minLevel && "font-medium")}
|
||||
style={{ paddingInlineStart: 8 + (heading.level - minLevel) * 12 }}
|
||||
>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useDirection } from "@base-ui/react/direction-provider";
|
||||
import { Columns2Icon, Columns3Icon, InfinityIcon, type LucideIcon, Rows3Icon, SlidersHorizontalIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection";
|
||||
import { SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -193,12 +194,7 @@ function MemoDisplaySettingMenu({ className }: Props) {
|
|||
<TooltipTrigger render={<span className="inline-flex" />}>
|
||||
<PopoverTrigger
|
||||
aria-label={t("memo.view-options")}
|
||||
className={cn(
|
||||
"flex items-center justify-center",
|
||||
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
|
||||
"data-popup-open:bg-sidebar-accent data-popup-open:text-foreground",
|
||||
className,
|
||||
)}
|
||||
className={cn(buttonVariants({ variant: "quiet", size: "icon-sm" }), className)}
|
||||
>
|
||||
<SlidersHorizontalIcon className={SIDEBAR_SECTION_ACTION_ICON_CLASSES} strokeWidth={1.8} />
|
||||
</PopoverTrigger>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { SmilePlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { FOCUS_VISIBLE_OUTLINE_CLASSES } from "@/components/ui/focus";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { useInstance } from "@/contexts/InstanceContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -37,7 +38,8 @@ const ReactionSelector = (props: Props) => {
|
|||
type="button"
|
||||
aria-label={t("setting.memo.add-reaction")}
|
||||
className={cn(
|
||||
"flex size-7 cursor-pointer items-center justify-center rounded-full border text-muted-foreground transition-all hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
"flex size-7 cursor-pointer items-center justify-center rounded-full border text-muted-foreground transition-all hover:opacity-80",
|
||||
FOCUS_VISIBLE_OUTLINE_CLASSES,
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { BookmarkIcon } from "lucide-react";
|
|||
import { useCallback, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import RelativeTime from "@/components/RelativeTime";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useNewMemo } from "@/contexts/NewMemoContext";
|
||||
import useNavigateTo from "@/hooks/useNavigateTo";
|
||||
|
|
@ -22,8 +22,8 @@ import { createMemoNavigationState } from "../navigation";
|
|||
import type { MemoHeaderProps } from "../types";
|
||||
import MemoSpaceBadge from "./MemoSpaceBadge";
|
||||
|
||||
const MEMO_HEADER_ACTION_CLASSES =
|
||||
"size-6 shrink-0 rounded-md border-none bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/50 data-popup-open:bg-accent data-popup-open:text-foreground";
|
||||
/** The card's trailing actions are the kit's quiet 24px squares, whether or not they are kit buttons. */
|
||||
const MEMO_HEADER_ACTION_CLASSES = cn(buttonVariants({ variant: "quiet", size: "icon-sm" }));
|
||||
|
||||
const MemoHeader: React.FC<MemoHeaderProps> = ({ timeDisplay = "relative", showCreator, showVisibility, showPinned, showSpace }) => {
|
||||
const t = useTranslate();
|
||||
|
|
@ -93,6 +93,8 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ timeDisplay = "relative", showC
|
|||
<ReactionSelector
|
||||
className={cn(
|
||||
MEMO_HEADER_ACTION_CLASSES,
|
||||
// The chip's own round bordered look gives way to the header's quiet square.
|
||||
"border-none hover:opacity-100",
|
||||
reactionSelectorOpen && "sm:flex!",
|
||||
"flex sm:hidden sm:group-hover:flex sm:group-focus-within:flex",
|
||||
)}
|
||||
|
|
@ -113,16 +115,11 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ timeDisplay = "relative", showC
|
|||
{showPinned && memo.pinned && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
{/* The pinned mark keeps its primary ink; that custom look lives on the raw trigger, not a kit button. */}
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("common.unpin")}
|
||||
className={cn(MEMO_HEADER_ACTION_CLASSES, "text-primary hover:text-primary data-popup-open:text-primary")}
|
||||
onClick={unpinMemo}
|
||||
/>
|
||||
}
|
||||
aria-label={t("common.unpin")}
|
||||
className={cn(MEMO_HEADER_ACTION_CLASSES, "text-primary hover:text-primary")}
|
||||
onClick={unpinMemo}
|
||||
>
|
||||
<BookmarkIcon className="size-4" strokeWidth={1.8} />
|
||||
</TooltipTrigger>
|
||||
|
|
@ -133,13 +130,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ timeDisplay = "relative", showC
|
|||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<MemoActionMenu
|
||||
memo={memo}
|
||||
parentScope={parentScope}
|
||||
readonly={readonly}
|
||||
className={MEMO_HEADER_ACTION_CLASSES}
|
||||
onEdit={openEditor}
|
||||
/>
|
||||
<MemoActionMenu memo={memo} parentScope={parentScope} readonly={readonly} onEdit={openEditor} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,23 +18,11 @@ export const MonthNavigator = memo(({ visibleMonth, onMonthChange }: MonthNaviga
|
|||
<h2 className="min-w-0 truncate font-medium tracking-[-0.015em] text-foreground/90 select-none">{monthLabel}</h2>
|
||||
|
||||
<nav className="flex shrink-0 items-center gap-0.5" aria-label={t("common.month-navigation")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handlePrevMonth}
|
||||
aria-label={t("common.previous-month")}
|
||||
className="size-6 rounded text-muted-foreground/65 hover:bg-muted/50 hover:text-foreground/90"
|
||||
>
|
||||
<Button variant="quiet" size="icon-sm" onClick={handlePrevMonth} aria-label={t("common.previous-month")}>
|
||||
<ChevronLeftIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleNextMonth}
|
||||
aria-label={t("common.next-month")}
|
||||
className="size-6 rounded text-muted-foreground/65 hover:bg-muted/50 hover:text-foreground/90"
|
||||
>
|
||||
<Button variant="quiet" size="icon-sm" onClick={handleNextMonth} aria-label={t("common.next-month")}>
|
||||
<ChevronRightIcon className="size-4 rtl:rotate-180" strokeWidth={1.75} />
|
||||
</Button>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ Color comes from the semantic OKLCH tokens documented in
|
|||
|
||||
| Component | variant | size / shape | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| **Button** | default · destructive · outline · secondary · ghost · link | size: default · sm · lg · icon · **icon-sm** | `icon-sm` (`size-6`) is local — for dense/compact icon buttons. Auto-sizes unsized `svg` children to `size-4`. |
|
||||
| **Button** | default · destructive · outline · secondary · ghost · link · **quiet** | size: default · sm · lg · icon · **icon-compact** · **icon-sm** | Every variant carries the kit's ring-free keyboard focus (`FOCUS_VISIBLE_OUTLINE_CLASSES` from `ui/focus.ts`; raw controls import the same constant). `quiet` is local: 13px muted ink that darkens under a `bg-muted/60` hover wash, and the accent fill only for `aria-pressed`, `aria-current` or `data-popup-open`. Use it via `buttonVariants({ variant: "quiet" })` on raw `<button>`/`<a>` elements too (calendar controls, memo outline, card actions). `icon-compact` (`size-7`) is the 28px square matching `sm`'s height; `icon-sm` (`size-6`) is for dense icon buttons. Auto-sizes unsized `svg` children to `size-4`. |
|
||||
| **Badge** | default · secondary · destructive · outline · **warning** | shape: default · **pill** | `warning` + `shape` are local additions. `pill` = `rounded-full`. |
|
||||
| **Tabs** | — | variant: **segmented** · **underline** | Local component (not upstream shadcn). Context-based `Tabs`/`TabsList`/`TabsTrigger`, no Radix. `underline` relies on the consumer's own divider. |
|
||||
| **Dialog** | — | size: sm · default · lg · xl · 2xl · full | Local `size` variants + auto-focus prevention on open. |
|
||||
|
|
|
|||
|
|
@ -2,9 +2,13 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
|||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FOCUS_VISIBLE_OUTLINE_CLASSES } from "./focus";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
|
||||
cn(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
|
||||
FOCUS_VISIBLE_OUTLINE_CLASSES,
|
||||
),
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
@ -14,15 +18,26 @@ const buttonVariants = cva(
|
|||
secondary: "bg-secondary text-secondary-foreground hover:shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
// A quiet control in the spirit of Notion and Linear: 13px muted ink that darkens under
|
||||
// a light hover wash, and the accent fill reserved for the one state that is "on" —
|
||||
// pressed, current, or holding a popup open.
|
||||
quiet:
|
||||
"text-ui font-normal text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground aria-pressed:bg-accent aria-pressed:text-accent-foreground aria-[current]:bg-accent aria-[current]:text-accent-foreground data-popup-open:bg-accent data-popup-open:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 px-3",
|
||||
sm: "h-7 rounded-md gap-1 px-2 has-[>svg]:px-2",
|
||||
lg: "h-9 rounded-md px-4",
|
||||
icon: "size-8",
|
||||
// The 28px square: the height of `sm`, and the sidebar's compose-control square.
|
||||
"icon-compact": "size-7",
|
||||
"icon-sm": "size-6",
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// A quiet chip gives its glyph and label a little more air than the kit's tight `sm`.
|
||||
{ variant: "quiet", size: "sm", className: "gap-1.5" },
|
||||
],
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
|
|
|
|||
8
web/src/components/ui/focus.ts
Normal file
8
web/src/components/ui/focus.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* The kit's one focus treatment: a 2px inset outline in the ring color, drawn only for
|
||||
* keyboard focus. Outline, not `ring-*`, so it never adds to the box or reads as a border.
|
||||
* Kit buttons carry it from their base; raw elements that act as controls (calendar grid
|
||||
* cells, the reaction chip) take it from here.
|
||||
*/
|
||||
export const FOCUS_VISIBLE_OUTLINE_CLASSES =
|
||||
"focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-ring/60";
|
||||
|
|
@ -47,7 +47,8 @@ const tabsTriggerVariants = cva(
|
|||
{
|
||||
variant: "underline",
|
||||
active: false,
|
||||
className: "border-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
// The inactive tab is a quiet control: the same resting ink and hover wash as the button kit's `quiet` variant.
|
||||
className: "border-transparent text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground",
|
||||
},
|
||||
],
|
||||
defaultVariants: { variant: "segmented", active: false },
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { fireEvent, screen, render as testingLibraryRender, within } from "@test
|
|||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AppSidebar, { MobileAppHeader, MobileAppSidebar } from "@/components/AppSidebar";
|
||||
import { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection";
|
||||
import { SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection";
|
||||
import { type MemoFilter, parseFilterQuery } from "@/contexts/MemoFilterContext";
|
||||
|
||||
const authState = vi.hoisted(() => ({
|
||||
|
|
@ -553,7 +553,7 @@ describe("App sidebar logo", () => {
|
|||
const viewOptions = screen.getByRole("button", { name: "memo.view-options" });
|
||||
const createView = screen.getByRole("button", { name: "common.create" });
|
||||
expect(viewOptions.compareDocumentPosition(createView) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(createView).toHaveClass(...SIDEBAR_SECTION_ACTION_BUTTON_CLASSES.split(" "));
|
||||
expect(createView).toHaveClass("size-6", "rounded-md", "text-muted-foreground/70", "hover:bg-muted/60", "hover:text-foreground");
|
||||
expect(createView.querySelector("svg")).toHaveClass(SIDEBAR_SECTION_ACTION_ICON_CLASSES);
|
||||
const tasksView = screen.getByRole("button", { name: "common.tasks" });
|
||||
expect(tasksView).toHaveTextContent("common.tasks");
|
||||
|
|
|
|||
|
|
@ -30,12 +30,13 @@ vi.mock("@/utils/i18n", () => ({
|
|||
}));
|
||||
|
||||
describe("MemoActionMenu", () => {
|
||||
it("uses the standard compact action surface and preserves its open state", async () => {
|
||||
it("is a quiet compact control that takes the accent fill while open", async () => {
|
||||
const memo = create(MemoSchema, { name: "memos/1", state: State.NORMAL, pinned: false });
|
||||
render(<MemoActionMenu memo={memo} parentScope="preserve" />);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "common.more" });
|
||||
expect(trigger).toHaveClass("size-6", "rounded-md", "hover:bg-accent", "focus-visible:ring-2", "data-popup-open:bg-accent");
|
||||
expect(trigger).toHaveClass("size-6", "rounded-md", "text-muted-foreground/70", "hover:bg-muted/60", "data-popup-open:bg-accent");
|
||||
expect(trigger.className).not.toMatch(/ring-/);
|
||||
expect(trigger).toHaveAttribute("aria-haspopup", "menu");
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(trigger.querySelector(".lucide-ellipsis-vertical")).toHaveClass("size-4");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { DirectionProvider } from "@base-ui/react/direction-provider";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection";
|
||||
import { SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection";
|
||||
import MemoDisplaySettingMenu from "@/components/MemoDisplaySettingMenu";
|
||||
import { ViewProvider } from "@/contexts/ViewContext";
|
||||
|
||||
|
|
@ -44,7 +44,7 @@ describe("MemoDisplaySettingMenu", () => {
|
|||
);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "View options" });
|
||||
expect(trigger).toHaveClass(...SIDEBAR_SECTION_ACTION_BUTTON_CLASSES.split(" "));
|
||||
expect(trigger).toHaveClass("size-6", "rounded-md", "text-muted-foreground/70", "hover:bg-muted/60", "hover:text-foreground");
|
||||
expect(trigger.querySelector("svg")).toHaveClass(SIDEBAR_SECTION_ACTION_ICON_CLASSES);
|
||||
|
||||
fireEvent.click(trigger);
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ vi.mock("@/components/RelativeTime", () => ({
|
|||
default: () => <>time</>,
|
||||
}));
|
||||
|
||||
// The menu owns its trigger's look now (see memo-action-menu.test.tsx); the header only places it.
|
||||
vi.mock("@/components/MemoActionMenu", () => ({
|
||||
default: ({ className }: { className?: string }) => (
|
||||
<button type="button" aria-label="memo-actions" className={className}>
|
||||
default: () => (
|
||||
<button type="button" aria-label="memo-actions">
|
||||
Memo actions
|
||||
</button>
|
||||
),
|
||||
|
|
@ -109,16 +110,19 @@ describe("MemoHeader navigation", () => {
|
|||
|
||||
expect(actionRail).toHaveClass("items-center", "gap-1");
|
||||
expect(reaction).toHaveClass("sm:group-focus-within:flex");
|
||||
for (const action of [reaction, actions]) {
|
||||
expect(action).toHaveClass(
|
||||
"size-6",
|
||||
"rounded-md",
|
||||
"hover:bg-accent",
|
||||
"hover:text-foreground",
|
||||
"focus-visible:ring-2",
|
||||
"data-popup-open:bg-accent",
|
||||
);
|
||||
}
|
||||
// The reaction chip gives up its round bordered look for the header's quiet square.
|
||||
expect(reaction).toHaveClass(
|
||||
"size-6",
|
||||
"rounded-md",
|
||||
"border-none",
|
||||
"text-muted-foreground/70",
|
||||
"hover:bg-muted/60",
|
||||
"hover:text-foreground",
|
||||
"focus-visible:outline-2",
|
||||
"data-popup-open:bg-accent",
|
||||
);
|
||||
expect(reaction).not.toHaveClass("rounded-full");
|
||||
expect(reaction.className).not.toMatch(/ring-/);
|
||||
});
|
||||
|
||||
it.each([false, true])("uses a keyboard-operable timestamp and preserves origin when showCreator=%s", (showCreator) => {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ describe("ReactionSelector", () => {
|
|||
render(<ReactionSelector memo={memo} />);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "setting.memo.add-reaction" });
|
||||
expect(trigger).toHaveClass("size-7", "rounded-full", "focus-visible:ring-2");
|
||||
expect(trigger).toHaveClass("size-7", "rounded-full", "focus-visible:outline-2");
|
||||
expect(trigger.className).not.toMatch(/ring-/);
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
|
|
|
|||
|
|
@ -7,10 +7,7 @@ import SidebarRow, {
|
|||
SIDEBAR_ROW_SLOT_BUTTON_CLASSES,
|
||||
SIDEBAR_ROW_SLOT_CLASSES,
|
||||
} from "@/components/AppSidebar/SidebarRow";
|
||||
import SidebarSection, {
|
||||
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
|
||||
SIDEBAR_SECTION_CONTENT_CLASSES,
|
||||
} from "@/components/AppSidebar/SidebarSection";
|
||||
import SidebarSection, { SIDEBAR_SECTION_CONTENT_CLASSES } from "@/components/AppSidebar/SidebarSection";
|
||||
import TagTree from "@/components/TagTree";
|
||||
|
||||
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
|
||||
|
|
@ -53,7 +50,6 @@ describe("sidebar row grammar", () => {
|
|||
expect(SIDEBAR_ROW_SLOT_BUTTON_CLASSES).toContain("after:-inset-0.5");
|
||||
expect(SIDEBAR_ROW_SLOT_BUTTON_CLASSES).toContain("after:content-['']");
|
||||
expect(SIDEBAR_ROW_SLOT_BUTTON_CLASSES).not.toContain("-mx-1");
|
||||
expect(SIDEBAR_SECTION_ACTION_BUTTON_CLASSES.split(" ")).toContain("size-6");
|
||||
});
|
||||
|
||||
it("gives tag tree rows the same box as a nav row", () => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ 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 { AppSidebarProvider } from "@/contexts/AppSidebarContext";
|
||||
|
|
@ -59,10 +58,6 @@ describe("sidebar selected grammar", () => {
|
|||
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 }) => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SIDEBAR_ROW_BOX_CLASSES, SIDEBAR_ROW_COUNT_RAIL_CLASSES, SIDEBAR_ROW_SLOT_CLASSES } from "@/components/AppSidebar/SidebarRow";
|
||||
import { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection";
|
||||
import { SIDEBAR_SECTION_ACTION_ICON_CLASSES } from "@/components/AppSidebar/SidebarSection";
|
||||
import TagsSection from "@/components/AppSidebar/TagsSection";
|
||||
import { MemoFilterProvider } from "@/contexts/MemoFilterContext";
|
||||
|
||||
|
|
@ -26,7 +26,7 @@ describe("TagsSection", () => {
|
|||
expect(heading.parentElement).toHaveTextContent(/^common.tags$/);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "common.tags: common.more" });
|
||||
expect(trigger).toHaveClass(...SIDEBAR_SECTION_ACTION_BUTTON_CLASSES.split(" "));
|
||||
expect(trigger).toHaveClass("size-6", "rounded-md", "text-muted-foreground/70", "hover:bg-muted/60", "hover:text-foreground");
|
||||
expect(trigger.querySelector("svg")).toHaveClass(SIDEBAR_SECTION_ACTION_ICON_CLASSES);
|
||||
expect(screen.queryByRole("button", { name: "common.tags: memo.layout-list" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitemcheckbox")).not.toBeInTheDocument();
|
||||
|
|
|
|||
Loading…
Reference in a new issue