fix(ui): refine compact navigation and memo actions

This commit is contained in:
johnnyjoygh 2026-09-02 22:22:13 +08:00
parent 5a65350400
commit ce0dd23d2f
10 changed files with 194 additions and 31 deletions

View file

@ -437,7 +437,7 @@ const GlobalNavigation = () => {
return (
<TooltipProvider>
<nav className={cn("flex h-9 items-center gap-1", SIDEBAR_RAIL_CLASSES)} aria-label="Primary">
<nav className={cn("flex h-8 items-center gap-1", SIDEBAR_RAIL_CLASSES)} aria-label="Primary">
{currentUser && (
<DropdownMenu
onOpenChange={(open, eventDetails) => {
@ -505,7 +505,7 @@ const GlobalNavigation = () => {
<span
data-sidebar-trailing
className={cn(
"absolute -end-0.5 -top-0.5 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-semibold leading-4 text-primary-foreground transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none",
"absolute -end-0.5 top-0 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[9px] font-semibold leading-4 text-primary-foreground transition-[opacity,scale] duration-200 ease-out motion-reduce:transition-none",
item.count > 0 ? "scale-100 opacity-100" : "scale-50 opacity-0",
)}
>

View file

@ -1,4 +1,4 @@
import { CheckIcon, ChevronDownIcon, LoaderCircleIcon, type LucideIcon, PlusIcon } from "lucide-react";
import { CheckIcon, ChevronsUpDownIcon, LoaderCircleIcon, type LucideIcon, PlusIcon } from "lucide-react";
import { type ReactNode, useRef, useState } from "react";
import CreateSpaceDialog from "@/components/CreateSpaceDialog";
import MemosLogo from "@/components/MemosLogo";
@ -92,7 +92,7 @@ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?:
aria-label={`${t("space.switch")}: ${currentContextLabel}`}
title={currentContextLabel}
className={cn(
"group text-start transition-colors hover:bg-sidebar-accent/65 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40",
"text-start transition-colors hover:bg-sidebar-accent/65 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40",
size === "header" ? sidebarSurfaceVariants({ role: "headerBrand" }) : sidebarSurfaceVariants({ role: "mobileBrand" }),
className,
)}
@ -127,10 +127,7 @@ function SpaceSwitcher({ className, size = "md" }: { className?: string; size?:
<MemosLogo compact size={brandSize} />
)}
</span>
<ChevronDownIcon
className="size-3.5 shrink-0 text-muted-foreground/70 transition-transform duration-150 group-data-[popup-open]:rotate-180 motion-reduce:transition-none"
strokeWidth={1.8}
/>
<ChevronsUpDownIcon aria-hidden="true" className="size-3.5 shrink-0 text-muted-foreground/70" strokeWidth={1.8} />
</DropdownMenuTrigger>
<DropdownMenuContent
size="sm"

View file

@ -25,6 +25,7 @@ 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";
@ -63,8 +64,20 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" className="size-4" />}>
<MoreVerticalIcon className="text-muted-foreground" />
<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,
)}
/>
}
>
<MoreVerticalIcon className="size-4" strokeWidth={1.8} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={2}>
{/* Edit actions (non-readonly, non-archived) */}

View file

@ -4,6 +4,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { useInstance } from "@/contexts/InstanceContext";
import { cn } from "@/lib/utils";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { useReactionActions } from "./hooks";
interface Props {
@ -14,6 +15,7 @@ interface Props {
const ReactionSelector = (props: Props) => {
const { memo, className, onOpenChange } = props;
const t = useTranslate();
const [open, setOpen] = useState(false);
const { memoRelatedSetting } = useInstance();
@ -30,17 +32,18 @@ const ReactionSelector = (props: Props) => {
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
nativeButton={false}
render={
<span
<button
type="button"
aria-label={t("setting.memo.add-reaction")}
className={cn(
"h-7 w-7 flex justify-center items-center rounded-full border cursor-pointer transition-all hover:opacity-80",
"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",
className,
)}
/>
}
>
<SmilePlusIcon className="w-4 h-4 mx-auto text-muted-foreground" />
<SmilePlusIcon className="mx-auto size-4" />
</PopoverTrigger>
<PopoverContent align="center" className="max-w-[90vw] sm:max-w-md">
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-1 max-h-64 overflow-y-auto">

View file

@ -2,6 +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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useNewMemo } from "@/contexts/NewMemoContext";
import useNavigateTo from "@/hooks/useNavigateTo";
@ -21,6 +22,9 @@ 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";
const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, showPinned, showSpace }) => {
const t = useTranslate();
const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false);
@ -82,10 +86,14 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
)}
</div>
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">
<div data-slot="memo-header-actions" className="flex shrink-0 select-none flex-row items-center justify-end gap-1">
{currentUser && !isArchived && (
<ReactionSelector
className={cn("border-none w-auto h-auto", reactionSelectorOpen && "block!", "block sm:hidden sm:group-hover:block")}
className={cn(
MEMO_HEADER_ACTION_CLASSES,
reactionSelectorOpen && "sm:flex!",
"flex sm:hidden sm:group-hover:flex sm:group-focus-within:flex",
)}
memo={memo}
onOpenChange={setReactionSelectorOpen}
/>
@ -93,10 +101,8 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
{showVisibility && memo.visibility !== Visibility.PRIVATE && (
<Tooltip>
<TooltipTrigger>
<span className="flex justify-center items-center rounded-md hover:opacity-80">
<VisibilityIcon visibility={memo.visibility} />
</span>
<TooltipTrigger aria-label={visibilityOption && t(visibilityOption.labelKey)} className={MEMO_HEADER_ACTION_CLASSES}>
<VisibilityIcon visibility={memo.visibility} className="text-current" />
</TooltipTrigger>
<TooltipContent>{visibilityOption && t(visibilityOption.labelKey)}</TooltipContent>
</Tooltip>
@ -105,8 +111,18 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
{showPinned && memo.pinned && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span className="cursor-pointer" />}>
<BookmarkIcon className="w-4 h-auto text-primary" onClick={unpinMemo} />
<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}
/>
}
>
<BookmarkIcon className="size-4" strokeWidth={1.8} />
</TooltipTrigger>
<TooltipContent>
<p>{t("common.unpin")}</p>
@ -115,7 +131,13 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
</TooltipProvider>
)}
<MemoActionMenu memo={memo} parentScope={parentScope} readonly={readonly} onEdit={openEditor} />
<MemoActionMenu
memo={memo}
parentScope={parentScope}
readonly={readonly}
className={MEMO_HEADER_ACTION_CLASSES}
onEdit={openEditor}
/>
</div>
</div>
);

View file

@ -222,7 +222,8 @@ describe("App sidebar logo", () => {
expect(switcher).not.toHaveClass("px-1");
expect(switcher.firstElementChild).not.toHaveClass("flex-1");
expect(within(switcher).getByText("Memos logo")).toHaveAttribute("data-logo-size", "header");
expect(switcher.querySelector(".lucide-chevron-down")).not.toBeNull();
expect(switcher.querySelector(".lucide-chevrons-up-down")).not.toBeNull();
expect(switcher.querySelector(".lucide-chevron-down")).toBeNull();
expect(compose).toHaveClass("size-7", "rounded-md", "border", "bg-background", "shadow-xs");
expect(compose).not.toHaveClass("rounded-full");
@ -398,7 +399,7 @@ describe("App sidebar logo", () => {
expect(screen.getByRole("link", { name: "Memos logo" })).toHaveAttribute("href", "/explore");
expect(screen.queryByRole("button", { name: /^space\.switch:/ })).not.toBeInTheDocument();
const primaryNavigation = screen.getByRole("navigation", { name: "Primary" });
expect(primaryNavigation).toHaveClass("h-9", "items-center", "gap-1", "px-3");
expect(primaryNavigation).toHaveClass("h-8", "items-center", "gap-1", "px-3");
expect(primaryNavigation).not.toHaveClass("flex-col");
const navigation = within(primaryNavigation);
expectActiveNavPill(navigation.getByRole("link", { name: "common.explore" }), "common.explore");
@ -523,6 +524,7 @@ describe("App sidebar logo", () => {
const scopeTrigger = screen.getByRole("button", { name: "common.home" });
expectActiveNavPill(scopeTrigger, "common.home");
expect(scopeTrigger.querySelector(".lucide-chevron-down")).toBeInTheDocument();
expect(scopeTrigger.querySelector(".lucide-chevrons-up-down")).not.toBeInTheDocument();
expectCollapsedNavPill(screen.getByRole("link", { name: "common.attachments" }), "common.attachments");
fireEvent.click(scopeTrigger);
expect(await screen.findByRole("menuitem", { name: "common.home" })).toBeInTheDocument();

View file

@ -0,0 +1,49 @@
import { create } from "@bufbuild/protobuf";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import MemoActionMenu from "@/components/MemoActionMenu";
import { State } from "@/types/proto/api/v1/common_pb";
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
const handlers = vi.hoisted(() => ({
handleTogglePinMemoBtnClick: vi.fn(),
handleEditMemoClick: vi.fn(),
handleToggleMemoStatusClick: vi.fn(),
handleCopyLink: vi.fn(),
handleCopyContent: vi.fn(),
handleCheckAllTaskListItemsClick: vi.fn(),
handleUncheckAllTaskListItemsClick: vi.fn(),
handleDeleteMemoClick: vi.fn(),
confirmDeleteMemo: vi.fn(),
}));
vi.mock("@/components/ConfirmDialog", () => ({
default: () => null,
}));
vi.mock("@/components/MemoActionMenu/hooks", () => ({
useMemoActionHandlers: () => handlers,
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
describe("MemoActionMenu", () => {
it("uses the standard compact action surface and preserves its open state", 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).toHaveAttribute("aria-haspopup", "menu");
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(trigger.querySelector(".lucide-ellipsis-vertical")).toHaveClass("size-4");
fireEvent.click(trigger);
await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "true"));
expect(trigger).toHaveAttribute("data-popup-open");
expect(await screen.findByRole("menuitem", { name: "common.pin" })).toBeInTheDocument();
});
});

View file

@ -5,6 +5,7 @@ import MemoHeader from "@/components/MemoView/components/MemoHeader";
const state = vi.hoisted(() => ({
creator: undefined as { username: string; displayName: string; avatarUrl: string } | undefined,
currentUser: undefined as { name: string } | undefined,
}));
vi.mock("@/components/RelativeTime", () => ({
@ -12,11 +13,19 @@ vi.mock("@/components/RelativeTime", () => ({
}));
vi.mock("@/components/MemoActionMenu", () => ({
default: () => null,
default: ({ className }: { className?: string }) => (
<button type="button" aria-label="memo-actions" className={className}>
Memo actions
</button>
),
}));
vi.mock("@/components/MemoReactionListView", () => ({
ReactionSelector: () => null,
ReactionSelector: ({ className }: { className?: string }) => (
<button type="button" aria-label="add-reaction" className={className}>
Add reaction
</button>
),
}));
vi.mock("@/components/UserAvatar", () => ({
@ -50,7 +59,7 @@ vi.mock("@/components/MemoView/MemoViewContext", () => ({
useMemoViewContext: () => ({
memo: { name: "memos/123", visibility: 1, pinned: false, space: "spaces/product" },
creator: state.creator,
currentUser: undefined,
currentUser: state.currentUser,
parentPage: "/explore?filter=tagSearch%3Awork",
parentScope: "preserve",
isArchived: false,
@ -82,6 +91,34 @@ const LocationProbe = () => {
describe("MemoHeader navigation", () => {
beforeEach(() => {
state.creator = undefined;
state.currentUser = undefined;
});
it("uses one compact interaction surface for memo header actions", () => {
state.currentUser = { name: "users/alice" };
render(
<MemoryRouter>
<MemoHeader />
</MemoryRouter>,
);
const reaction = screen.getByRole("button", { name: "add-reaction" });
const actions = screen.getByRole("button", { name: "memo-actions" });
const actionRail = actions.closest('[data-slot="memo-header-actions"]');
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",
);
}
});
it.each([false, true])("uses a keyboard-operable timestamp and preserves origin when showCreator=%s", (showCreator) => {

View file

@ -0,0 +1,36 @@
import { create } from "@bufbuild/protobuf";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import ReactionSelector from "@/components/MemoReactionListView/ReactionSelector";
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
vi.mock("@/components/MemoReactionListView/hooks", () => ({
useReactionActions: () => ({
hasReacted: () => false,
handleReactionClick: vi.fn(),
}),
}));
vi.mock("@/contexts/InstanceContext", () => ({
useInstance: () => ({ memoRelatedSetting: { reactions: ["👍"] } }),
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
describe("ReactionSelector", () => {
it("uses a named native trigger with a visible focus state", async () => {
const memo = create(MemoSchema, { name: "memos/1", reactions: [] });
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).toHaveAttribute("aria-expanded", "false");
fireEvent.click(trigger);
await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "true"));
expect(screen.getByRole("button", { name: "👍" })).toBeInTheDocument();
});
});

View file

@ -64,7 +64,11 @@ describe("SpaceSwitcher", () => {
it("lists Memos, every available Space, and the create entry", async () => {
render(<SpaceSwitcher />);
fireEvent.click(screen.getByRole("button", { name: "space.switch: common.memos" }));
const trigger = screen.getByRole("button", { name: "space.switch: common.memos" });
expect(trigger).toHaveAttribute("aria-haspopup", "menu");
expect(trigger).toHaveAttribute("aria-expanded", "false");
fireEvent.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(await screen.findByRole("menuitemradio", { name: "Memos" })).toHaveAttribute("aria-checked", "true");
const productRow = screen.getByRole("menuitemradio", { name: "Product" });
@ -157,8 +161,8 @@ describe("SpaceSwitcher", () => {
expect(trigger).not.toHaveClass("px-1");
expect(title).toHaveClass("text-[14px]", "font-semibold", "leading-5");
expect(mark).toHaveClass("size-5", "rounded-[5px]");
expect(trigger.querySelector(".lucide-chevron-down")).not.toBeNull();
expect(trigger.querySelector(".lucide-chevrons-up-down")).toBeNull();
expect(trigger.querySelector(".lucide-chevrons-up-down")).not.toBeNull();
expect(trigger.querySelector(".lucide-chevron-down")).toBeNull();
});
it("keeps duplicate identity in the header label but out of its geometry", () => {