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 { useDirection } from "@base-ui/react/direction-provider";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
|
|
@ -16,20 +15,15 @@ import {
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
MapIcon,
|
MapIcon,
|
||||||
MenuIcon,
|
MenuIcon,
|
||||||
MoreHorizontalIcon,
|
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
PlusIcon,
|
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SquarePenIcon,
|
SquarePenIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
UserRoundIcon,
|
UserRoundIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type ReactNode, useEffect, useState } from "react";
|
import { type ReactNode, useEffect } from "react";
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { Link, matchPath, useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
import { Link, matchPath, useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
|
||||||
import { MemoDetailSidebar } from "@/components/MemoDetailSidebar";
|
import { MemoDetailSidebar } from "@/components/MemoDetailSidebar";
|
||||||
import MemoDisplaySettingMenu from "@/components/MemoDisplaySettingMenu";
|
|
||||||
import { DEFAULT_SETTING_SECTION, SETTINGS_SECTIONS } from "@/components/Settings/settingSections";
|
import { DEFAULT_SETTING_SECTION, SETTINGS_SECTIONS } from "@/components/Settings/settingSections";
|
||||||
import StatisticsView from "@/components/StatisticsView";
|
import StatisticsView from "@/components/StatisticsView";
|
||||||
import UserMenu from "@/components/UserMenu";
|
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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { memoViewServiceClient } from "@/connect";
|
|
||||||
import { type AttachmentSection, type InboxFilter, useAppSidebar } from "@/contexts/AppSidebarContext";
|
import { type AttachmentSection, type InboxFilter, useAppSidebar } from "@/contexts/AppSidebarContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext";
|
import { useGlobalMemoEditor } from "@/contexts/GlobalMemoEditorContext";
|
||||||
|
|
@ -48,32 +41,20 @@ import { useAttachmentLibraryStats } from "@/hooks/useAttachmentLibrary";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import { type MemoStatsContext, useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
|
import { type MemoStatsContext, useFilteredMemoStats } from "@/hooks/useFilteredMemoStats";
|
||||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||||
import { useMemoViews, useNotifications, userKeys, useUser } from "@/hooks/useUserQueries";
|
import { useNotifications, useUser } from "@/hooks/useUserQueries";
|
||||||
import { handleError } from "@/lib/error";
|
import { getMemoScopePath, isMemoScopeRoute, type PrimaryMemoScope, resolveMemoScope } from "@/lib/memo-views";
|
||||||
import {
|
|
||||||
BUILTIN_TASKS_VIEW_ID,
|
|
||||||
getMemoScopePath,
|
|
||||||
getMemoViewId,
|
|
||||||
isMemoScopeRoute,
|
|
||||||
type PrimaryMemoScope,
|
|
||||||
resolveMemoScope,
|
|
||||||
} from "@/lib/memo-views";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
import { State } from "@/types/proto/api/v1/common_pb";
|
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 { User_Role, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import MemosLogo from "../MemosLogo";
|
import MemosLogo from "../MemosLogo";
|
||||||
import { getSidebarRouteKind, routeSupportsCollectionScope } from "./routes";
|
import { getSidebarRouteKind, routeSupportsCollectionScope } from "./routes";
|
||||||
import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_FOCUS_CLASSES, SIDEBAR_ROW_ICON_CLASSES, sidebarRowStateClasses } from "./SidebarRow";
|
import SidebarRow, { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_FOCUS_CLASSES, SidebarRowIconSlot, sidebarRowStateClasses } from "./SidebarRow";
|
||||||
import SidebarSection, {
|
import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "./SidebarSection";
|
||||||
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
|
|
||||||
SIDEBAR_SECTION_ACTION_ICON_CLASSES,
|
|
||||||
SIDEBAR_SECTION_STACK_CLASSES,
|
|
||||||
} from "./SidebarSection";
|
|
||||||
import SpaceSwitcher from "./SpaceSwitcher";
|
import SpaceSwitcher from "./SpaceSwitcher";
|
||||||
import TagsSection from "./TagsSection";
|
import TagsSection from "./TagsSection";
|
||||||
|
import ViewsSection from "./ViewsSection";
|
||||||
|
|
||||||
const SIDEBAR_HORIZONTAL_PADDING = "px-3";
|
const SIDEBAR_HORIZONTAL_PADDING = "px-3";
|
||||||
const SIDEBAR_HEADER_ACTION_CLASSES = "size-7 shrink-0 rounded-md text-muted-foreground hover:text-foreground";
|
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 ProfileMode = () => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -389,7 +251,7 @@ const SettingsSidebarContent = () => {
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(currentSection === section.key))}
|
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>
|
<span className="truncate">{t(section.labelKey)}</span>
|
||||||
</Link>
|
</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_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_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. */
|
/** Idle and selected colouring for a row box, kept in one place so lists cannot drift apart. */
|
||||||
export const sidebarRowStateClasses = (active?: boolean) =>
|
export const sidebarRowStateClasses = (active?: boolean) =>
|
||||||
|
|
@ -41,9 +69,9 @@ const SidebarRow = ({ active, icon: Icon, label, count, onClick, trailing }: Pro
|
||||||
aria-pressed={active || undefined}
|
aria-pressed={active || undefined}
|
||||||
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(active))}
|
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(active))}
|
||||||
>
|
>
|
||||||
{Icon && <Icon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />}
|
{Icon && <SidebarRowIconSlot icon={Icon} />}
|
||||||
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
<span className="min-w-0 flex-1 truncate text-start">{label}</span>
|
||||||
{count != null && count > 0 && <span className={SIDEBAR_ROW_COUNT_CLASSES}>{count}</span>}
|
{count != null && count > 0 && <span className={SIDEBAR_ROW_COUNT_RAIL_CLASSES}>{count}</span>}
|
||||||
{trailing}
|
{trailing}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import { replaceFiltersByFactor, stringifyFilters, useMemoFilterContext } from "
|
||||||
import { useLocalStorage, useOverflowTitle } from "@/hooks";
|
import { useLocalStorage, useOverflowTitle } from "@/hooks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import TagTree from "../TagTree";
|
import TagTree, { tagRowAriaLabel } from "../TagTree";
|
||||||
import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_COUNT_CLASSES, SIDEBAR_ROW_ICON_CLASSES, sidebarRowStateClasses } from "./SidebarRow";
|
import { SIDEBAR_ROW_CLASSES, SIDEBAR_ROW_COUNT_RAIL_CLASSES, SidebarRowIconSlot, sidebarRowStateClasses } from "./SidebarRow";
|
||||||
import SidebarSection, {
|
import SidebarSection, {
|
||||||
SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES,
|
SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES,
|
||||||
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
|
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
|
||||||
|
|
@ -27,7 +27,7 @@ const TagPath = forwardRef<HTMLSpanElement, { tag: string }>(({ tag }, ref) => {
|
||||||
const segments = tag.split("/");
|
const segments = tag.split("/");
|
||||||
|
|
||||||
return (
|
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) => (
|
{segments.map((segment, index) => (
|
||||||
<span key={`${segment}-${index}`}>
|
<span key={`${segment}-${index}`}>
|
||||||
{index > 0 && <span className="px-0.5 text-muted-foreground/40">/</span>}
|
{index > 0 && <span className="px-0.5 text-muted-foreground/40">/</span>}
|
||||||
|
|
@ -43,23 +43,27 @@ interface FlatTagRowProps {
|
||||||
tag: string;
|
tag: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
/** Computed by the parent, which already holds the translator — rows stay subscription-free. */
|
||||||
|
ariaLabel: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FlatTagRow = ({ tag, amount, active, onClick }: FlatTagRowProps) => {
|
const FlatTagRow = ({ tag, amount, active, ariaLabel, onClick }: FlatTagRowProps) => {
|
||||||
const { ref, title } = useOverflowTitle<HTMLSpanElement>(`#${tag}`);
|
const { ref, title } = useOverflowTitle<HTMLSpanElement>(`#${tag}`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={ariaLabel}
|
||||||
aria-pressed={active || undefined}
|
aria-pressed={active || undefined}
|
||||||
title={title}
|
title={title}
|
||||||
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(active))}
|
className={cn(SIDEBAR_ROW_CLASSES, sidebarRowStateClasses(active))}
|
||||||
onClick={onClick}
|
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} />
|
<TagPath ref={ref} tag={tag} />
|
||||||
<span className={SIDEBAR_ROW_COUNT_CLASSES}>{amount}</span>
|
<span className={SIDEBAR_ROW_COUNT_RAIL_CLASSES}>{amount}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -128,7 +132,14 @@ const TagsSection = ({ tagCount, onSelect, navigationTarget, scope }: Props) =>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{tags.map(([tag, amount]) => (
|
{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 { useMemo, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { Link } from "react-router-dom";
|
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 SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "@/components/AppSidebar/SidebarSection";
|
||||||
import { extractHeadings } from "@/components/MemoContent/pipeline";
|
import { extractHeadings } from "@/components/MemoContent/pipeline";
|
||||||
import { getRelationBuckets, getRelationMemo } from "@/components/MemoMetadata/Relation/relationHelpers";
|
import { getRelationBuckets, getRelationMemo } from "@/components/MemoMetadata/Relation/relationHelpers";
|
||||||
|
|
@ -56,8 +56,8 @@ const BacklinkRow = ({
|
||||||
title={title}
|
title={title}
|
||||||
viewTransition
|
viewTransition
|
||||||
>
|
>
|
||||||
<LinkIcon className={SIDEBAR_ROW_ICON_CLASSES} strokeWidth={1.8} />
|
<SidebarRowIconSlot icon={LinkIcon} />
|
||||||
<span ref={ref} className="min-w-0 flex-1 truncate text-left">
|
<span ref={ref} className="min-w-0 flex-1 truncate text-start">
|
||||||
{snippet}
|
{snippet}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</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",
|
"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} />
|
<SidebarRowIconSlot icon={Share2Icon} />
|
||||||
<span className="min-w-0 flex-1 truncate text-left">{t("common.share")}</span>
|
<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} />
|
<ChevronDownIcon className="size-3.5 shrink-0 opacity-55" strokeWidth={1.8} />
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="start" sideOffset={4} className="w-48">
|
<DropdownMenuContent align="start" sideOffset={4} className="w-48">
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@ import { ChevronRightIcon, HashIcon } from "lucide-react";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
SIDEBAR_ROW_BOX_CLASSES,
|
SIDEBAR_ROW_BOX_CLASSES,
|
||||||
SIDEBAR_ROW_COUNT_CLASSES,
|
SIDEBAR_ROW_COUNT_RAIL_CLASSES,
|
||||||
SIDEBAR_ROW_FOCUS_CLASSES,
|
|
||||||
SIDEBAR_ROW_ICON_CLASSES,
|
SIDEBAR_ROW_ICON_CLASSES,
|
||||||
|
SIDEBAR_ROW_LABEL_CLASSES,
|
||||||
|
SIDEBAR_ROW_SLOT_BUTTON_CLASSES,
|
||||||
|
SIDEBAR_ROW_SLOT_CLASSES,
|
||||||
|
SidebarRowIconSlot,
|
||||||
sidebarRowStateClasses,
|
sidebarRowStateClasses,
|
||||||
} from "@/components/AppSidebar/SidebarRow";
|
} from "@/components/AppSidebar/SidebarRow";
|
||||||
import { useLocalStorage, useOverflowTitle } from "@/hooks";
|
import { useLocalStorage, useOverflowTitle } from "@/hooks";
|
||||||
|
|
@ -37,6 +40,13 @@ interface TagTreeExpansion {
|
||||||
|
|
||||||
const EMPTY_EXPANSION: TagTreeExpansion = { expanded: [] };
|
const EMPTY_EXPANSION: TagTreeExpansion = { expanded: [] };
|
||||||
|
|
||||||
|
// A structural row toggles like any other, so it hovers like one too — just quieter at rest.
|
||||||
|
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][]) => {
|
export const buildTagTree = (tagAmounts: [tag: string, amount: number][]) => {
|
||||||
const root: TagTreeNode = {
|
const root: TagTreeNode = {
|
||||||
key: "",
|
key: "",
|
||||||
|
|
@ -77,12 +87,14 @@ const parentPathsOf = (tag: string): string[] => {
|
||||||
return segments.slice(0, -1).map((_, index) => segments.slice(0, index + 1).join("/"));
|
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
|
<ChevronRightIcon
|
||||||
aria-hidden="true"
|
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}
|
strokeWidth={1.8}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -104,6 +116,7 @@ const TagItem = ({ tag, depth, activeTag, expanded, onTagClick, onToggle }: TagI
|
||||||
const hasSubTags = tag.subTags.length > 0;
|
const hasSubTags = tag.subTags.length > 0;
|
||||||
const open = hasSubTags && expanded.has(tag.text);
|
const open = hasSubTags && expanded.has(tag.text);
|
||||||
const { ref: labelRef, title } = useOverflowTitle<HTMLSpanElement>(isTag ? `#${tag.text}` : tag.text);
|
const { ref: labelRef, title } = useOverflowTitle<HTMLSpanElement>(isTag ? `#${tag.text}` : tag.text);
|
||||||
|
const tagLabel = tag.amount !== undefined ? tagRowAriaLabel(t, tag.text, tag.amount) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-w-0">
|
<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}
|
aria-expanded={hasSubTags ? open : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
SIDEBAR_ROW_BOX_CLASSES,
|
SIDEBAR_ROW_BOX_CLASSES,
|
||||||
isTag ? sidebarRowStateClasses(isActive) : "font-medium text-muted-foreground/65",
|
isTag ? sidebarRowStateClasses(isActive) : STRUCTURAL_ROW_CLASSES,
|
||||||
isAncestorOfActiveTag && !isActive && "text-foreground/75",
|
isAncestorOfActiveTag && !isActive && "text-foreground/75",
|
||||||
)}
|
)}
|
||||||
// Overrides the start half of the box's `px-2`, leaving the trailing 8px intact.
|
// Overrides the start half of the box's `px-2`, leaving the trailing 8px intact.
|
||||||
// Same 12px step the memo outline indents by.
|
// Same 12px step the memo outline indents by.
|
||||||
style={{ paddingInlineStart: 8 + depth * INDENT_STEP }}
|
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 && (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`${open ? t("common.collapse") : t("common.expand")} #${tag.text}`}
|
aria-label={`${open ? t("common.collapse") : t("common.expand")} #${tag.text}`}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className={cn(
|
className={SIDEBAR_ROW_SLOT_BUTTON_CLASSES}
|
||||||
"-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,
|
|
||||||
)}
|
|
||||||
onClick={() => onToggle(tag.text)}
|
onClick={() => onToggle(tag.text)}
|
||||||
>
|
>
|
||||||
<Chevron open={open} />
|
<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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { HashIcon } from "lucide-react";
|
import { HashIcon } from "lucide-react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
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 SidebarSection, { SIDEBAR_SECTION_CONTENT_CLASSES } from "@/components/AppSidebar/SidebarSection";
|
||||||
import TagTree from "@/components/TagTree";
|
import TagTree from "@/components/TagTree";
|
||||||
|
|
||||||
|
|
@ -27,9 +31,14 @@ describe("sidebar row grammar", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("gives a nav row the shared row box", () => {
|
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", () => {
|
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SIDEBAR_ROW_COUNT_RAIL_CLASSES } from "@/components/AppSidebar/SidebarRow";
|
||||||
import TagTree from "@/components/TagTree";
|
import TagTree from "@/components/TagTree";
|
||||||
|
|
||||||
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
|
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
|
||||||
|
|
@ -74,12 +75,58 @@ describe("TagTree rendering", () => {
|
||||||
expect(screen.queryByText("b")).not.toBeInTheDocument();
|
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", () => {
|
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()} />);
|
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.
|
// The row is the disclosure, so it is the only tab stop on that line.
|
||||||
const personalDisclosure = screen.getByLabelText(/common\.(expand|collapse) personal$/);
|
const personalDisclosure = screen.getByLabelText(/common\.(expand|collapse) personal$/);
|
||||||
expect(personalDisclosure).toHaveAttribute("aria-expanded", "false");
|
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);
|
fireEvent.click(personalDisclosure);
|
||||||
expect(screen.getByText("travel")).toBeVisible();
|
expect(screen.getByText("travel")).toBeVisible();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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 {
|
import {
|
||||||
SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES,
|
SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES,
|
||||||
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
|
SIDEBAR_SECTION_ACTION_BUTTON_CLASSES,
|
||||||
|
|
@ -45,4 +46,26 @@ describe("TagsSection", () => {
|
||||||
expect(listButton).not.toHaveClass(...SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES.split(" "));
|
expect(listButton).not.toHaveClass(...SIDEBAR_SECTION_ACTION_ACTIVE_CLASSES.split(" "));
|
||||||
expect(listButton).toHaveClass("text-muted-foreground/65");
|
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