chore(web): unify sidebar rows on a shared row grammar
Align every sidebar list (tags in both modes, views, settings, memo detail) on shared slot and count-rail primitives so icons, labels, and digits sit on the same vertical lines. Tree branches show their # mark at rest and swap in the chevron on hover or keyboard focus; views rows gain glyphs and move onto the same grammar, with ViewsSection extracted to its own module.
This commit is contained in:
parent
5b2bd74bdb
commit
04be0d9ee6
9 changed files with 357 additions and 208 deletions
|
|
@ -1,5 +1,4 @@
|
|||
import { useDirection } from "@base-ui/react/direction-provider";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
ArrowRightIcon,
|
||||
|
|
@ -16,20 +15,15 @@ import {
|
|||
type LucideIcon,
|
||||
MapIcon,
|
||||
MenuIcon,
|
||||
MoreHorizontalIcon,
|
||||
PaperclipIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
SquarePenIcon,
|
||||
Trash2Icon,
|
||||
UserRoundIcon,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { Link, matchPath, useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import { MemoDetailSidebar } from "@/components/MemoDetailSidebar";
|
||||
import MemoDisplaySettingMenu from "@/components/MemoDisplaySettingMenu";
|
||||
import { DEFAULT_SETTING_SECTION, SETTINGS_SECTIONS } from "@/components/Settings/settingSections";
|
||||
import StatisticsView from "@/components/StatisticsView";
|
||||
import UserMenu from "@/components/UserMenu";
|
||||
|
|
@ -37,7 +31,6 @@ import { Button } from "@/components/ui/button";
|
|||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { memoViewServiceClient } from "@/connect";
|
||||
import { type AttachmentSection, type InboxFilter, useAppSidebar } from "@/contexts/AppSidebarContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext";
|
||||
|
|
@ -48,32 +41,20 @@ import { useAttachmentLibraryStats } from "@/hooks/useAttachmentLibrary";
|
|||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { type MemoStatsContext, useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import { useMemoViews, useNotifications, userKeys, useUser } from "@/hooks/useUserQueries";
|
||||
import { handleError } from "@/lib/error";
|
||||
import {
|
||||
BUILTIN_TASKS_VIEW_ID,
|
||||
getMemoScopePath,
|
||||
getMemoViewId,
|
||||
isMemoScopeRoute,
|
||||
type PrimaryMemoScope,
|
||||
resolveMemoScope,
|
||||
} from "@/lib/memo-views";
|
||||
import { useNotifications, useUser } from "@/hooks/useUserQueries";
|
||||
import { getMemoScopePath, isMemoScopeRoute, type PrimaryMemoScope, resolveMemoScope } from "@/lib/memo-views";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import { State } from "@/types/proto/api/v1/common_pb";
|
||||
import type { MemoView } from "@/types/proto/api/v1/memo_view_service_pb";
|
||||
import { User_Role, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import MemosLogo from "../MemosLogo";
|
||||
import { getSidebarRouteKind, routeSupportsCollectionScope } from "./routes";
|
||||
import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_FOCUS_CLASSES, SIDEBAR_ROW_ICON_CLASSES, sidebarRowStateClasses } from "./SidebarRow";
|
||||
import SidebarSection, {
|
||||
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
|
||||
SIDEBAR_SECTION_ACTION_ICON_CLASSES,
|
||||
SIDEBAR_SECTION_STACK_CLASSES,
|
||||
} from "./SidebarSection";
|
||||
import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_FOCUS_CLASSES, SidebarRowIconSlot, sidebarRowStateClasses } from "./SidebarRow";
|
||||
import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "./SidebarSection";
|
||||
import SpaceSwitcher from "./SpaceSwitcher";
|
||||
import TagsSection from "./TagsSection";
|
||||
import ViewsSection from "./ViewsSection";
|
||||
|
||||
const SIDEBAR_HORIZONTAL_PADDING = "px-3";
|
||||
const SIDEBAR_HEADER_ACTION_CLASSES = "size-7 shrink-0 rounded-md text-muted-foreground hover:text-foreground";
|
||||
|
|
@ -103,125 +84,6 @@ const NewMemoAction = ({ onClick }: { onClick: () => void }) => {
|
|||
);
|
||||
};
|
||||
|
||||
const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => {
|
||||
const t = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
const currentUser = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: memoViews = [] } = useMemoViews(currentUser?.name);
|
||||
const { memoView: selectedMemoView, setMemoView } = useMemoFilterContext();
|
||||
const { setMobileOpen } = useAppSidebar();
|
||||
const [deleteTarget, setDeleteTarget] = useState<MemoView>();
|
||||
const location = useLocation();
|
||||
|
||||
const handleView = (viewId: string) => {
|
||||
setMemoView(selectedMemoView === viewId ? undefined : viewId);
|
||||
if (!isMemoScopeRoute(location.pathname)) navigate(ROUTES.HOME);
|
||||
setMobileOpen(false);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate(ROUTES.VIEWS, { state: { openCreate: true } });
|
||||
setMobileOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await memoViewServiceClient.deleteMemoView({ name: deleteTarget.name });
|
||||
await queryClient.invalidateQueries({ queryKey: userKeys.memoViews(currentUser?.name) });
|
||||
if (selectedMemoView === getMemoViewId(deleteTarget.name)) setMemoView(undefined);
|
||||
toast.success(t("setting.memo-view.delete-success", { title: deleteTarget.title }));
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, { context: "Delete memo view" });
|
||||
} finally {
|
||||
setDeleteTarget(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarSection
|
||||
label={t("common.views")}
|
||||
action={
|
||||
!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")}
|
||||
>
|
||||
<PlusIcon className={SIDEBAR_SECTION_ACTION_ICON_CLASSES} strokeWidth={1.8} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<SidebarRow
|
||||
active={!manageActive && selectedMemoView === BUILTIN_TASKS_VIEW_ID}
|
||||
label={t("common.tasks")}
|
||||
onClick={() => handleView(BUILTIN_TASKS_VIEW_ID)}
|
||||
/>
|
||||
{memoViews.map((memoView) => {
|
||||
const id = getMemoViewId(memoView.name);
|
||||
const active = !manageActive && selectedMemoView === id;
|
||||
return (
|
||||
<div key={memoView.name} className={cn(SIDEBAR_ROW_CLASSES, "group/view", sidebarRowStateClasses(active))}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleView(id)}
|
||||
aria-pressed={active || undefined}
|
||||
className="flex h-full min-w-0 flex-1 items-center gap-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{memoView.title}</span>
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
nativeButton={false}
|
||||
render={
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${t("common.edit")} ${memoView.title}`}
|
||||
className="-mr-1 flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-opacity hover:bg-background/70 md:opacity-0 md:group-hover/view:opacity-100 md:focus-visible:opacity-100 data-popup-open:opacity-100"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={2} size="sm">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
navigate(ROUTES.VIEWS, { state: { memoView } });
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={() => setDeleteTarget(memoView)}>
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{manageActive && <SidebarRow active icon={MoreHorizontalIcon} label={t("common.manage")} />}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
|
||||
title={t("setting.memo-view.delete-confirm", { title: deleteTarget?.title ?? "" })}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={handleDelete}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</SidebarSection>
|
||||
);
|
||||
};
|
||||
|
||||
const ProfileMode = () => {
|
||||
const t = useTranslate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
|
@ -389,7 +251,7 @@ const SettingsSidebarContent = () => {
|
|||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(currentSection === section.key))}
|
||||
>
|
||||
<section.icon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
|
||||
<SidebarRowIconSlot icon={section.icon} />
|
||||
<span className="truncate">{t(section.labelKey)}</span>
|
||||
</Link>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -17,7 +17,35 @@ export const SIDEBAR_ROW_FOCUS_CLASSES = "focus-visible:outline-none focus-visib
|
|||
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-2xs tabular-nums text-muted-foreground/60";
|
||||
const SIDEBAR_ROW_COUNT_CLASSES = "text-2xs tabular-nums text-muted-foreground/60";
|
||||
|
||||
/**
|
||||
* The focusable body of a split row — rows whose box is a wrapper carrying other controls
|
||||
* put their label layout and focus ring here. The gap must stay equal to the box's own
|
||||
* `gap-2` or slot alignment breaks between one-control and split rows.
|
||||
*/
|
||||
export const SIDEBAR_ROW_LABEL_CLASSES = `flex h-full min-w-0 flex-1 items-center gap-2 text-start ${SIDEBAR_ROW_FOCUS_CLASSES}`;
|
||||
|
||||
/**
|
||||
* Fixed leading slot: icons and disclosures share one vertical line across every list and
|
||||
* mode. The box is 24px so a disclosure gets a real hit target, but the icon inside must
|
||||
* line up with the bare 15px icons of plain rows — the negative margins cancel the extra
|
||||
* width on both sides so slotted rows keep the same icon and label positions.
|
||||
*/
|
||||
export const SIDEBAR_ROW_SLOT_CLASSES = "-mx-1 flex size-6 shrink-0 items-center justify-center";
|
||||
|
||||
/** A slot that is itself a control (disclosure, row menu): same box plus the hover chip. */
|
||||
export const SIDEBAR_ROW_SLOT_BUTTON_CLASSES = `${SIDEBAR_ROW_SLOT_CLASSES} rounded hover:bg-sidebar-accent ${SIDEBAR_ROW_FOCUS_CLASSES}`;
|
||||
|
||||
/** Trailing rail for counts, wide enough that digits align down the list. */
|
||||
export const SIDEBAR_ROW_COUNT_RAIL_CLASSES = `${SIDEBAR_ROW_COUNT_CLASSES} min-w-[3ch] shrink-0 text-end`;
|
||||
|
||||
/** The standard icon-in-slot pairing, so every list renders its glyphs identically. */
|
||||
export const SidebarRowIconSlot = ({ icon: Icon }: { icon: LucideIcon }) => (
|
||||
<span className={SIDEBAR_ROW_SLOT_CLASSES} aria-hidden="true">
|
||||
<Icon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
|
||||
</span>
|
||||
);
|
||||
|
||||
/** Idle and selected colouring for a row box, kept in one place so lists cannot drift apart. */
|
||||
export const sidebarRowStateClasses = (active?: boolean) =>
|
||||
|
|
@ -41,9 +69,9 @@ const SidebarRow = ({ active, icon: Icon, label, count, onClick, trailing }: Pro
|
|||
aria-pressed={active || undefined}
|
||||
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(active))}
|
||||
>
|
||||
{Icon && <Icon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />}
|
||||
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
||||
{count != null && count > 0 && <span className={SIDEBAR_ROW_COUNT_CLASSES}>{count}</span>}
|
||||
{Icon && <SidebarRowIconSlot icon={Icon} />}
|
||||
<span className="min-w-0 flex-1 truncate text-start">{label}</span>
|
||||
{count != null && count > 0 && <span className={SIDEBAR_ROW_COUNT_RAIL_CLASSES}>{count}</span>}
|
||||
{trailing}
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { replaceFiltersByFactor, stringifyFilters, useMemoFilterContext } from "
|
|||
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 TagTree, { tagRowAriaLabel } from "../TagTree";
|
||||
import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_COUNT_RAIL_CLASSES, SidebarRowIconSlot, sidebarRowStateClasses } from "./SidebarRow";
|
||||
import SidebarSection, {
|
||||
SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES,
|
||||
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
|
||||
|
|
@ -27,7 +27,7 @@ const TagPath = forwardRef<HTMLSpanElement, { tag: string }>(({ tag }, ref) => {
|
|||
const segments = tag.split("/");
|
||||
|
||||
return (
|
||||
<span ref={ref} className="min-w-0 flex-1 truncate text-left">
|
||||
<span ref={ref} className="min-w-0 flex-1 truncate text-start">
|
||||
{segments.map((segment, index) => (
|
||||
<span key={`${segment}-${index}`}>
|
||||
{index > 0 && <span className="px-0.5 text-muted-foreground/40">/</span>}
|
||||
|
|
@ -43,23 +43,27 @@ interface FlatTagRowProps {
|
|||
tag: string;
|
||||
amount: number;
|
||||
active: boolean;
|
||||
/** Computed by the parent, which already holds the translator — rows stay subscription-free. */
|
||||
ariaLabel: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const FlatTagRow = ({ tag, amount, active, onClick }: FlatTagRowProps) => {
|
||||
const FlatTagRow = ({ tag, amount, active, ariaLabel, onClick }: FlatTagRowProps) => {
|
||||
const { ref, title } = useOverflowTitle<HTMLSpanElement>(`#${tag}`);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-pressed={active || undefined}
|
||||
title={title}
|
||||
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(active))}
|
||||
onClick={onClick}
|
||||
>
|
||||
<HashIcon aria-hidden="true" className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
|
||||
{/* Same leading slot as the tree, so the # marks hold their line when switching modes. */}
|
||||
<SidebarRowIconSlot icon={HashIcon} />
|
||||
<TagPath ref={ref} tag={tag} />
|
||||
<span className={SIDEBAR_ROW_COUNT_CLASSES}>{amount}</span>
|
||||
<span className={SIDEBAR_ROW_COUNT_RAIL_CLASSES}>{amount}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -128,7 +132,14 @@ const TagsSection = ({ tagCount, onSelect, navigationTarget, scope }: Props) =>
|
|||
) : (
|
||||
<>
|
||||
{tags.map(([tag, amount]) => (
|
||||
<FlatTagRow key={tag} tag={tag} amount={amount} active={activeTags.has(tag)} onClick={() => handleTagClick(tag)} />
|
||||
<FlatTagRow
|
||||
key={tag}
|
||||
tag={tag}
|
||||
amount={amount}
|
||||
active={activeTags.has(tag)}
|
||||
ariaLabel={tagRowAriaLabel(t, tag, amount)}
|
||||
onClick={() => handleTagClick(tag)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
152
web/src/components/AppSidebar/ViewsSection.tsx
Normal file
152
web/src/components/AppSidebar/ViewsSection.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { MoreHorizontalIcon, ParenthesesIcon, PlusIcon, SquareCheckIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import MemoDisplaySettingMenu from "@/components/MemoDisplaySettingMenu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { memoViewServiceClient } from "@/connect";
|
||||
import { useAppSidebar } from "@/contexts/AppSidebarContext";
|
||||
import { useMemoFilterContext } from "@/contexts/MemoFilterContext";
|
||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||
import { useMemoViews, userKeys } from "@/hooks/useUserQueries";
|
||||
import { handleError } from "@/lib/error";
|
||||
import { BUILTIN_TASKS_VIEW_ID, getMemoViewId, isMemoScopeRoute } from "@/lib/memo-views";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ROUTES } from "@/router/routes";
|
||||
import type { MemoView } from "@/types/proto/api/v1/memo_view_service_pb";
|
||||
import { useTranslate } from "@/utils/i18n";
|
||||
import SidebarRow, {
|
||||
SIDEBAR_ROW_BOX_CLASSES,
|
||||
SIDEBAR_ROW_LABEL_CLASSES,
|
||||
SIDEBAR_ROW_SLOT_BUTTON_CLASSES,
|
||||
SidebarRowIconSlot,
|
||||
sidebarRowStateClasses,
|
||||
} from "./SidebarRow";
|
||||
import SidebarSection, { SIDEBAR_SECTION_ACTION_BUTTON_CLASSES, 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(
|
||||
SIDEBAR_ROW_SLOT_BUTTON_CLASSES,
|
||||
"text-muted-foreground transition-opacity md:opacity-0 md:group-hover/view:opacity-100 md:focus-visible:opacity-100 data-popup-open:opacity-100",
|
||||
);
|
||||
|
||||
const ViewsSection = ({ manageActive = false }: { manageActive?: boolean }) => {
|
||||
const t = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
const currentUser = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: memoViews = [] } = useMemoViews(currentUser?.name);
|
||||
const { memoView: selectedMemoView, setMemoView } = useMemoFilterContext();
|
||||
const { setMobileOpen } = useAppSidebar();
|
||||
const [deleteTarget, setDeleteTarget] = useState<MemoView>();
|
||||
const location = useLocation();
|
||||
|
||||
const handleView = (viewId: string) => {
|
||||
setMemoView(selectedMemoView === viewId ? undefined : viewId);
|
||||
if (!isMemoScopeRoute(location.pathname)) navigate(ROUTES.HOME);
|
||||
setMobileOpen(false);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate(ROUTES.VIEWS, { state: { openCreate: true } });
|
||||
setMobileOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await memoViewServiceClient.deleteMemoView({ name: deleteTarget.name });
|
||||
await queryClient.invalidateQueries({ queryKey: userKeys.memoViews(currentUser?.name) });
|
||||
if (selectedMemoView === getMemoViewId(deleteTarget.name)) setMemoView(undefined);
|
||||
toast.success(t("setting.memo-view.delete-success", { title: deleteTarget.title }));
|
||||
} catch (error: unknown) {
|
||||
handleError(error, toast.error, { context: "Delete memo view" });
|
||||
} finally {
|
||||
setDeleteTarget(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarSection
|
||||
label={t("common.views")}
|
||||
action={
|
||||
!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")}
|
||||
>
|
||||
<PlusIcon className={SIDEBAR_SECTION_ACTION_ICON_CLASSES} strokeWidth={1.8} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<SidebarRow
|
||||
active={!manageActive && selectedMemoView === BUILTIN_TASKS_VIEW_ID}
|
||||
icon={SquareCheckIcon}
|
||||
label={t("common.tasks")}
|
||||
onClick={() => handleView(BUILTIN_TASKS_VIEW_ID)}
|
||||
/>
|
||||
{memoViews.map((memoView) => {
|
||||
const id = getMemoViewId(memoView.name);
|
||||
const active = !manageActive && selectedMemoView === id;
|
||||
return (
|
||||
<div key={memoView.name} className={cn(SIDEBAR_ROW_BOX_CLASSES, "group/view", sidebarRowStateClasses(active))}>
|
||||
<button type="button" onClick={() => handleView(id)} aria-pressed={active || undefined} className={SIDEBAR_ROW_LABEL_CLASSES}>
|
||||
<SidebarRowIconSlot icon={ParenthesesIcon} />
|
||||
<span className="min-w-0 flex-1 truncate">{memoView.title}</span>
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
nativeButton={false}
|
||||
render={
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${t("common.edit")} ${memoView.title}`}
|
||||
className={VIEW_MENU_TRIGGER_CLASSES}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={2} size="sm">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
navigate(ROUTES.VIEWS, { state: { memoView } });
|
||||
setMobileOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("common.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={() => setDeleteTarget(memoView)}>
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{manageActive && <SidebarRow active icon={MoreHorizontalIcon} label={t("common.manage")} />}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(undefined)}
|
||||
title={t("setting.memo-view.delete-confirm", { title: deleteTarget?.title ?? "" })}
|
||||
confirmLabel={t("common.delete")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
onConfirm={handleDelete}
|
||||
confirmVariant="destructive"
|
||||
/>
|
||||
</SidebarSection>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewsSection;
|
||||
|
|
@ -3,7 +3,7 @@ import { BookmarkCheckIcon, BookmarkIcon, ChevronDownIcon, ImageIcon, LinkIcon,
|
|||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { Link } from "react-router-dom";
|
||||
import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_ICON_CLASSES } from "@/components/AppSidebar/SidebarRow";
|
||||
import SidebarRow, { SIDEBAR_ROW_CLASSES, SidebarRowIconSlot } from "@/components/AppSidebar/SidebarRow";
|
||||
import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "@/components/AppSidebar/SidebarSection";
|
||||
import { extractHeadings } from "@/components/MemoContent/pipeline";
|
||||
import { getRelationBuckets, getRelationMemo } from "@/components/MemoMetadata/Relation/relationHelpers";
|
||||
|
|
@ -56,8 +56,8 @@ const BacklinkRow = ({
|
|||
title={title}
|
||||
viewTransition
|
||||
>
|
||||
<LinkIcon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
|
||||
<span ref={ref} className="min-w-0 flex-1 truncate text-left">
|
||||
<SidebarRowIconSlot icon={LinkIcon} />
|
||||
<span ref={ref} className="min-w-0 flex-1 truncate text-start">
|
||||
{snippet}
|
||||
</span>
|
||||
</Link>
|
||||
|
|
@ -125,8 +125,8 @@ const MemoDetailSidebar = ({ memo, parentPage, parentScope, className, onShareIm
|
|||
"text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground data-popup-open:bg-sidebar-accent/65 data-popup-open:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Share2Icon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
|
||||
<span className="min-w-0 flex-1 truncate text-left">{t("common.share")}</span>
|
||||
<SidebarRowIconSlot icon={Share2Icon} />
|
||||
<span className="min-w-0 flex-1 truncate text-start">{t("common.share")}</span>
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 opacity-55" strokeWidth={1.8} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={4} className="w-48">
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ import { ChevronRightIcon, HashIcon } from "lucide-react";
|
|||
import { useEffect, useMemo } from "react";
|
||||
import {
|
||||
SIDEBAR_ROW_BOX_CLASSES,
|
||||
SIDEBAR_ROW_COUNT_CLASSES,
|
||||
SIDEBAR_ROW_FOCUS_CLASSES,
|
||||
SIDEBAR_ROW_COUNT_RAIL_CLASSES,
|
||||
SIDEBAR_ROW_ICON_CLASSES,
|
||||
SIDEBAR_ROW_LABEL_CLASSES,
|
||||
SIDEBAR_ROW_SLOT_BUTTON_CLASSES,
|
||||
SIDEBAR_ROW_SLOT_CLASSES,
|
||||
SidebarRowIconSlot,
|
||||
sidebarRowStateClasses,
|
||||
} from "@/components/AppSidebar/SidebarRow";
|
||||
import { useLocalStorage, useOverflowTitle } from "@/hooks";
|
||||
|
|
@ -37,6 +40,13 @@ interface TagTreeExpansion {
|
|||
|
||||
const EMPTY_EXPANSION: TagTreeExpansion = { expanded: [] };
|
||||
|
||||
// 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");
|
||||
|
||||
/** 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) =>
|
||||
`#${tag}, ${t("setting.tags.used-count", { count: amount })}`;
|
||||
|
||||
export const buildTagTree = (tagAmounts: [tag: string, amount: number][]) => {
|
||||
const root: TagTreeNode = {
|
||||
key: "",
|
||||
|
|
@ -77,12 +87,14 @@ const parentPathsOf = (tag: string): string[] => {
|
|||
return segments.slice(0, -1).map((_, index) => segments.slice(0, index + 1).join("/"));
|
||||
};
|
||||
|
||||
const TagMark = () => <HashIcon aria-hidden="true" className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />;
|
||||
const TagMark = ({ className }: { className?: string }) => (
|
||||
<HashIcon aria-hidden="true" className={cn(SIDEBAR_ROW_ICON_CLASSES, className)} strokeWidth={1.8} />
|
||||
);
|
||||
|
||||
const Chevron = ({ open }: { open: boolean }) => (
|
||||
const Chevron = ({ open, className }: { open: boolean; className?: string }) => (
|
||||
<ChevronRightIcon
|
||||
aria-hidden="true"
|
||||
className={cn("size-3.5 shrink-0 text-muted-foreground/70 transition-transform", open ? "rotate-90" : "rtl:rotate-180")}
|
||||
className={cn("size-3.5 shrink-0 text-muted-foreground/70 transition-transform", open ? "rotate-90" : "rtl:rotate-180", className)}
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
);
|
||||
|
|
@ -104,6 +116,7 @@ const TagItem = ({ tag, depth, activeTag, expanded, onTagClick, onToggle }: TagI
|
|||
const hasSubTags = tag.subTags.length > 0;
|
||||
const open = hasSubTags && expanded.has(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;
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0">
|
||||
|
|
@ -114,57 +127,61 @@ const TagItem = ({ tag, depth, activeTag, expanded, onTagClick, onToggle }: TagI
|
|||
aria-expanded={hasSubTags ? open : undefined}
|
||||
className={cn(
|
||||
SIDEBAR_ROW_BOX_CLASSES,
|
||||
isTag ? sidebarRowStateClasses(isActive) : "font-medium text-muted-foreground/65",
|
||||
isTag ? sidebarRowStateClasses(isActive) : STRUCTURAL_ROW_CLASSES,
|
||||
isAncestorOfActiveTag && !isActive && "text-foreground/75",
|
||||
)}
|
||||
// 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={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={SIDEBAR_ROW_COUNT_CLASSES}>{tag.amount}</span>
|
||||
</button>
|
||||
) : (
|
||||
// A structural segment only exists to reach its children, so the whole row is the
|
||||
// disclosure — one control, rather than a row and a chevron doing the same thing.
|
||||
// Aligns with the tag rows below it: past the 15px mark plus the row's 8px gap.
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${open ? t("common.collapse") : t("common.expand")} ${tag.text}`}
|
||||
aria-expanded={open}
|
||||
className={cn("flex h-full min-w-0 flex-1 items-center gap-2 text-left", SIDEBAR_ROW_FOCUS_CLASSES)}
|
||||
onClick={() => onToggle(tag.text)}
|
||||
>
|
||||
<span ref={labelRef} className="min-w-0 flex-1 truncate ps-[23px]" title={title}>
|
||||
{tag.key}
|
||||
</span>
|
||||
<Chevron open={open} />
|
||||
</button>
|
||||
)}
|
||||
{isTag && hasSubTags && (
|
||||
// A tag row's label filters, so its disclosure needs a control of its own.
|
||||
// At rest the slot holds the row's # mark like any other tag; pointing at the row
|
||||
// (or tabbing into it) swaps in the chevron, so branches only look different while
|
||||
// the disclosure is actually reachable.
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${open ? t("common.collapse") : t("common.expand")} #${tag.text}`}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"-mr-1 flex size-6 shrink-0 items-center justify-center rounded text-muted-foreground/70 hover:bg-background/70 hover:text-foreground",
|
||||
SIDEBAR_ROW_FOCUS_CLASSES,
|
||||
)}
|
||||
className={SIDEBAR_ROW_SLOT_BUTTON_CLASSES}
|
||||
onClick={() => onToggle(tag.text)}
|
||||
>
|
||||
<TagMark className="group-hover:hidden group-has-[:focus-visible]:hidden" />
|
||||
<Chevron open={open} className="hidden group-hover:block group-has-[:focus-visible]:block" />
|
||||
</button>
|
||||
)}
|
||||
{isTag ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tagLabel}
|
||||
aria-pressed={isActive || undefined}
|
||||
title={title}
|
||||
className={SIDEBAR_ROW_LABEL_CLASSES}
|
||||
onClick={() => onTagClick(tag.text)}
|
||||
>
|
||||
{!hasSubTags && <SidebarRowIconSlot icon={HashIcon} />}
|
||||
<span ref={labelRef} className="min-w-0 flex-1 truncate">
|
||||
{tag.key}
|
||||
</span>
|
||||
<span className={SIDEBAR_ROW_COUNT_RAIL_CLASSES}>{tag.amount}</span>
|
||||
</button>
|
||||
) : (
|
||||
// A structural segment only exists to reach its children, so the whole row is the
|
||||
// disclosure — one control, rather than a row and a chevron doing the same thing.
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${open ? t("common.collapse") : t("common.expand")} ${tag.text}`}
|
||||
aria-expanded={open}
|
||||
className={SIDEBAR_ROW_LABEL_CLASSES}
|
||||
onClick={() => onToggle(tag.text)}
|
||||
>
|
||||
<span className={SIDEBAR_ROW_SLOT_CLASSES} aria-hidden="true">
|
||||
<Chevron open={open} />
|
||||
</span>
|
||||
<span ref={labelRef} className="min-w-0 flex-1 truncate" title={title}>
|
||||
{tag.key}
|
||||
</span>
|
||||
{/* Empty count rail keeps structural labels truncating at the same edge as tag labels. */}
|
||||
<span aria-hidden="true" className={SIDEBAR_ROW_COUNT_RAIL_CLASSES} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { HashIcon } from "lucide-react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import SidebarRow, { SIDEBAR_ROW_BOX_CLASSES } from "@/components/AppSidebar/SidebarRow";
|
||||
import SidebarRow, {
|
||||
SIDEBAR_ROW_BOX_CLASSES,
|
||||
SIDEBAR_ROW_COUNT_RAIL_CLASSES,
|
||||
SIDEBAR_ROW_SLOT_CLASSES,
|
||||
} from "@/components/AppSidebar/SidebarRow";
|
||||
import SidebarSection, { SIDEBAR_SECTION_CONTENT_CLASSES } from "@/components/AppSidebar/SidebarSection";
|
||||
import TagTree from "@/components/TagTree";
|
||||
|
||||
|
|
@ -27,9 +31,14 @@ describe("sidebar row grammar", () => {
|
|||
});
|
||||
|
||||
it("gives a nav row the shared row box", () => {
|
||||
render(<SidebarRow icon={HashIcon} label="Tasks" />);
|
||||
render(<SidebarRow icon={HashIcon} label="Tasks" count={3} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Tasks" })).toHaveClass(...boxClasses);
|
||||
const row = screen.getByRole("button", { name: "Tasks3" });
|
||||
expect(row).toHaveClass(...boxClasses);
|
||||
// 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.
|
||||
expect(row.firstElementChild).toHaveClass(...SIDEBAR_ROW_SLOT_CLASSES.split(" "));
|
||||
expect(screen.getByText("3")).toHaveClass(...SIDEBAR_ROW_COUNT_RAIL_CLASSES.split(" "));
|
||||
});
|
||||
|
||||
it("gives tag tree rows the same box as a nav row", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SIDEBAR_ROW_COUNT_RAIL_CLASSES } from "@/components/AppSidebar/SidebarRow";
|
||||
import TagTree from "@/components/TagTree";
|
||||
|
||||
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
|
||||
|
|
@ -74,12 +75,58 @@ describe("TagTree rendering", () => {
|
|||
expect(screen.queryByText("b")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("puts disclosures before labels and keeps counts in a fixed trailing rail", () => {
|
||||
const onTagClick = vi.fn();
|
||||
render(
|
||||
<TagTree
|
||||
tagAmounts={[
|
||||
["a", 12],
|
||||
["a/b", 1],
|
||||
["getting-started", 3],
|
||||
]}
|
||||
scope="home"
|
||||
onTagClick={onTagClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const branch = screen.getByText("a").closest('[role="treeitem"]') as HTMLElement;
|
||||
const branchLabelButton = screen.getByText("a").closest("button") as HTMLButtonElement;
|
||||
const disclosure = screen.getByLabelText("common.expand #a");
|
||||
const branchCount = within(branch).getByText("12");
|
||||
expect(disclosure.compareDocumentPosition(branchLabelButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(branchLabelButton).toHaveClass("text-start");
|
||||
expect(branchLabelButton.querySelector("svg")).not.toBeInTheDocument();
|
||||
|
||||
// A branch reads as a plain tag at rest: the slot holds the # mark and only swaps in the
|
||||
// chevron while the row is hovered or holds focus.
|
||||
const [restMark, hoverChevron] = Array.from(disclosure.querySelectorAll("svg"));
|
||||
expect(restMark).toHaveClass("group-hover:hidden", "group-has-[:focus-visible]:hidden");
|
||||
expect(hoverChevron).toHaveClass("hidden", "group-hover:block", "group-has-[:focus-visible]:block");
|
||||
|
||||
fireEvent.click(disclosure);
|
||||
expect(onTagClick).not.toHaveBeenCalled();
|
||||
fireEvent.click(branchCount);
|
||||
expect(onTagClick).toHaveBeenCalledWith("a");
|
||||
|
||||
const leaf = screen.getByText("getting-started").closest('[role="treeitem"]') as HTMLElement;
|
||||
const leafCount = within(leaf).getByText("3");
|
||||
expect(within(leaf).getAllByRole("button")).toHaveLength(1);
|
||||
expect(screen.getByText("getting-started").closest("button")?.querySelector("svg")).toBeInTheDocument();
|
||||
|
||||
for (const count of [branchCount, leafCount]) {
|
||||
expect(count).toHaveClass(...SIDEBAR_ROW_COUNT_RAIL_CLASSES.split(" "));
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a structural row one control that both labels and toggles it", () => {
|
||||
render(<TagTree tagAmounts={[["personal/travel/singapore", 1]]} scope="home" onTagClick={vi.fn()} />);
|
||||
|
||||
// The row is the disclosure, so it is the only tab stop on that line.
|
||||
const personalDisclosure = screen.getByLabelText(/common\.(expand|collapse) personal$/);
|
||||
expect(personalDisclosure).toHaveAttribute("aria-expanded", "false");
|
||||
const personalLabel = screen.getByText("personal");
|
||||
const chevron = personalDisclosure.querySelector("svg") as SVGElement;
|
||||
expect(chevron.compareDocumentPosition(personalLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
fireEvent.click(personalDisclosure);
|
||||
expect(screen.getByText("travel")).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { fireEvent, render, screen } 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_ACTIVE_CLASSES,
|
||||
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
|
||||
|
|
@ -45,4 +46,26 @@ describe("TagsSection", () => {
|
|||
expect(listButton).not.toHaveClass(...SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES.split(" "));
|
||||
expect(listButton).toHaveClass("text-muted-foreground/65");
|
||||
});
|
||||
|
||||
it("keeps flat rows on the shared row grammar with a trailing count rail", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MemoFilterProvider>
|
||||
<TagsSection tagCount={{ alpha: 2, "a/very-long-tag-path": 1 }} scope="home" />
|
||||
</MemoFilterProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
const alpha = screen.getByText("alpha");
|
||||
const alphaButton = alpha.closest("button") as HTMLButtonElement;
|
||||
const path = alpha.parentElement?.parentElement as HTMLSpanElement;
|
||||
const count = screen.getByText("2");
|
||||
|
||||
expect(alphaButton).toHaveClass(...SIDEBAR_ROW_BOX_CLASSES.split(" "));
|
||||
// The # sits in the same fixed slot the tree uses, so switching modes keeps it in place.
|
||||
expect(alphaButton.firstElementChild).toHaveClass(...SIDEBAR_ROW_SLOT_CLASSES.split(" "));
|
||||
expect(path).toHaveClass("truncate", "text-start");
|
||||
expect(path).not.toContainElement(count);
|
||||
expect(count).toHaveClass(...SIDEBAR_ROW_COUNT_RAIL_CLASSES.split(" "));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue