feat(canvas): improve collapsed sidebar scanability and branding
This commit is contained in:
77
components/canvas/__tests__/canvas-sidebar.test.tsx
Normal file
77
components/canvas/__tests__/canvas-sidebar.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import CanvasSidebar from "@/components/canvas/canvas-sidebar";
|
||||||
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-auth-query", () => ({
|
||||||
|
useAuthQuery: () => ({ name: "Demo Canvas" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/canvas/canvas-user-menu", () => ({
|
||||||
|
CanvasUserMenu: ({ compact }: { compact?: boolean }) => (
|
||||||
|
<div data-testid={compact ? "canvas-user-menu-compact" : "canvas-user-menu-full"} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/progressive-blur", () => ({
|
||||||
|
ProgressiveBlur: () => <div data-testid="progressive-blur" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/image", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ alt }: { alt?: string }) => <span role="img" aria-label={alt ?? ""} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
describe("CanvasSidebar", () => {
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
let root: Root | null = null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (root) {
|
||||||
|
await act(async () => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
root = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows divider-separated category sections with two-column grids in rail mode", async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<CanvasSidebar canvasId={"canvas-1" as Id<"canvases">} railMode />,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = container?.textContent ?? "";
|
||||||
|
expect(text).not.toContain("QUELLE");
|
||||||
|
expect(text).not.toContain("KI-AUSGABE");
|
||||||
|
|
||||||
|
const sourceGrid = container?.querySelector(
|
||||||
|
'[data-testid="sidebar-rail-category-source-grid"]',
|
||||||
|
);
|
||||||
|
expect(sourceGrid).not.toBeNull();
|
||||||
|
expect(sourceGrid?.className).toContain("grid-cols-2");
|
||||||
|
|
||||||
|
const aiOutputDivider = container?.querySelector(
|
||||||
|
'[data-testid="sidebar-rail-category-ai-output-divider"]',
|
||||||
|
);
|
||||||
|
expect(aiOutputDivider).not.toBeNull();
|
||||||
|
|
||||||
|
const allCategoryGrids = container?.querySelectorAll('[data-testid$="-grid"]');
|
||||||
|
expect(allCategoryGrids?.length ?? 0).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,8 +15,8 @@ import type { Id } from "@/convex/_generated/dataModel";
|
|||||||
const SIDEBAR_DEFAULT_SIZE = "18%";
|
const SIDEBAR_DEFAULT_SIZE = "18%";
|
||||||
const SIDEBAR_COLLAPSE_THRESHOLD = "10%";
|
const SIDEBAR_COLLAPSE_THRESHOLD = "10%";
|
||||||
const SIDEBAR_MAX_SIZE = "40%";
|
const SIDEBAR_MAX_SIZE = "40%";
|
||||||
const SIDEBAR_COLLAPSED_SIZE = "64px";
|
const SIDEBAR_COLLAPSED_SIZE = "84px";
|
||||||
const SIDEBAR_RAIL_MAX_WIDTH_PX = 112;
|
const SIDEBAR_RAIL_MAX_WIDTH_PX = 148;
|
||||||
const MAIN_PANEL_MIN_SIZE = "40%";
|
const MAIN_PANEL_MIN_SIZE = "40%";
|
||||||
|
|
||||||
type CanvasShellProps = {
|
type CanvasShellProps = {
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ import {
|
|||||||
|
|
||||||
import { CanvasUserMenu } from "@/components/canvas/canvas-user-menu";
|
import { CanvasUserMenu } from "@/components/canvas/canvas-user-menu";
|
||||||
import { ProgressiveBlur } from "@/components/ui/progressive-blur";
|
import { ProgressiveBlur } from "@/components/ui/progressive-blur";
|
||||||
import { useAuthQuery } from "@/hooks/use-auth-query";
|
|
||||||
import { api } from "@/convex/_generated/api";
|
|
||||||
import type { Id } from "@/convex/_generated/dataModel";
|
import type { Id } from "@/convex/_generated/dataModel";
|
||||||
import {
|
import {
|
||||||
NODE_CATEGORY_META,
|
NODE_CATEGORY_META,
|
||||||
@@ -107,14 +105,14 @@ function SidebarRow({
|
|||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border transition-colors",
|
"rounded-lg border transition-colors",
|
||||||
compact
|
compact
|
||||||
? "flex h-10 w-full items-center justify-center p-0"
|
? "flex aspect-square min-h-0 w-full items-center justify-center rounded-md p-0"
|
||||||
: "flex items-center gap-2 px-3 py-2 text-sm",
|
: "flex items-center gap-2 px-3 py-2 text-sm",
|
||||||
enabled
|
enabled
|
||||||
? "cursor-grab border-border/80 bg-card hover:bg-accent active:cursor-grabbing"
|
? "cursor-grab border-border/80 bg-card hover:bg-accent active:cursor-grabbing"
|
||||||
: "cursor-not-allowed border-transparent bg-muted/30 text-muted-foreground",
|
: "cursor-not-allowed border-transparent bg-muted/30 text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="size-4 shrink-0 opacity-80" />
|
<Icon className={cn("shrink-0 opacity-80", compact ? "size-[1.3rem]" : "size-4")} />
|
||||||
{!compact ? <span className="min-w-0 flex-1 truncate">{entry.label}</span> : null}
|
{!compact ? <span className="min-w-0 flex-1 truncate">{entry.label}</span> : null}
|
||||||
{!compact && entry.phase > 1 ? (
|
{!compact && entry.phase > 1 ? (
|
||||||
<span className="shrink-0 text-[10px] font-medium tabular-nums text-muted-foreground/80">
|
<span className="shrink-0 text-[10px] font-medium tabular-nums text-muted-foreground/80">
|
||||||
@@ -135,31 +133,39 @@ export default function CanvasSidebar({
|
|||||||
canvasId,
|
canvasId,
|
||||||
railMode = false,
|
railMode = false,
|
||||||
}: CanvasSidebarProps) {
|
}: CanvasSidebarProps) {
|
||||||
const canvas = useAuthQuery(api.canvases.get, { canvasId });
|
void canvasId;
|
||||||
const byCategory = catalogEntriesByCategory();
|
const byCategory = catalogEntriesByCategory();
|
||||||
const [collapsedByCategory, setCollapsedByCategory] = useState<
|
const [collapsedByCategory, setCollapsedByCategory] = useState<
|
||||||
Partial<Record<(typeof NODE_CATEGORIES_ORDERED)[number], boolean>>
|
Partial<Record<(typeof NODE_CATEGORIES_ORDERED)[number], boolean>>
|
||||||
>(() =>
|
>(() =>
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
NODE_CATEGORIES_ORDERED.map((categoryId) => [categoryId, categoryId !== "source"]),
|
NODE_CATEGORIES_ORDERED.map((categoryId) => [categoryId, false]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const railEntries = NODE_CATEGORIES_ORDERED.flatMap(
|
|
||||||
(categoryId) => byCategory.get(categoryId) ?? [],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-full w-full min-w-0 overflow-hidden flex-col border-r border-border/80 bg-background">
|
<aside className="flex h-full w-full min-w-0 overflow-hidden flex-col border-r border-border/80 bg-background">
|
||||||
{railMode ? (
|
{railMode ? (
|
||||||
<div className="border-b border-border/80 px-2 py-3">
|
<div className="border-b border-border/80 px-2 py-3">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<span
|
<div className="relative">
|
||||||
className="line-clamp-1 text-center text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
|
<NextImage
|
||||||
title={canvas?.name ?? "Canvas"}
|
src="/logos/lemonspace-logo-v2-black-rgb.svg"
|
||||||
>
|
alt="LemonSpace"
|
||||||
{canvas?.name?.slice(0, 2).toUpperCase() ?? "CV"}
|
width={74}
|
||||||
</span>
|
height={14}
|
||||||
|
className="h-auto w-[4.625rem] dark:hidden"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<NextImage
|
||||||
|
src="/logos/lemonspace-logo-v2-white-rgb.svg"
|
||||||
|
alt="LemonSpace"
|
||||||
|
width={74}
|
||||||
|
height={14}
|
||||||
|
className="hidden h-auto w-[4.625rem] dark:block"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -191,22 +197,41 @@ export default function CanvasSidebar({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full overflow-y-auto overscroll-contain",
|
"h-full overflow-y-auto overscroll-contain",
|
||||||
railMode ? "p-2 pb-20" : "p-3 pb-28",
|
railMode ? "px-2 py-2 pb-20" : "p-3 pb-28",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{railMode ? (
|
{railMode ? (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-3">
|
||||||
{railEntries.map((entry) => (
|
{NODE_CATEGORIES_ORDERED.map((categoryId, index) => {
|
||||||
|
const entries = byCategory.get(categoryId) ?? [];
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={categoryId}
|
||||||
|
className={cn("space-y-1.5", index === 0 ? "pt-0" : "border-t border-border/70 pt-2")}
|
||||||
|
>
|
||||||
|
{index > 0 ? (
|
||||||
|
<div data-testid={`sidebar-rail-category-${categoryId}-divider`} aria-hidden="true" />
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
data-testid={`sidebar-rail-category-${categoryId}-grid`}
|
||||||
|
className="grid grid-cols-2 gap-1"
|
||||||
|
>
|
||||||
|
{entries.map((entry) => (
|
||||||
<SidebarRow key={entry.type} entry={entry} compact />
|
<SidebarRow key={entry.type} entry={entry} compact />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{NODE_CATEGORIES_ORDERED.map((categoryId) => {
|
{NODE_CATEGORIES_ORDERED.map((categoryId) => {
|
||||||
const entries = byCategory.get(categoryId) ?? [];
|
const entries = byCategory.get(categoryId) ?? [];
|
||||||
if (entries.length === 0) return null;
|
if (entries.length === 0) return null;
|
||||||
const { label } = NODE_CATEGORY_META[categoryId];
|
const { label } = NODE_CATEGORY_META[categoryId];
|
||||||
const isCollapsed = collapsedByCategory[categoryId] ?? categoryId !== "source";
|
const isCollapsed = collapsedByCategory[categoryId] ?? false;
|
||||||
return (
|
return (
|
||||||
<div key={categoryId} className="mb-4 last:mb-0">
|
<div key={categoryId} className="mb-4 last:mb-0">
|
||||||
<button
|
<button
|
||||||
@@ -214,7 +239,7 @@ export default function CanvasSidebar({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
setCollapsedByCategory((prev) => ({
|
setCollapsedByCategory((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[categoryId]: !(prev[categoryId] ?? categoryId !== "source"),
|
[categoryId]: !(prev[categoryId] ?? false),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
className="mb-2 flex w-full items-center justify-between rounded-md px-0.5 py-1 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
|
className="mb-2 flex w-full items-center justify-between rounded-md px-0.5 py-1 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default defineConfig({
|
|||||||
"components/canvas/__tests__/use-node-local-data.test.tsx",
|
"components/canvas/__tests__/use-node-local-data.test.tsx",
|
||||||
"components/canvas/__tests__/use-canvas-sync-engine.test.ts",
|
"components/canvas/__tests__/use-canvas-sync-engine.test.ts",
|
||||||
"components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx",
|
"components/canvas/__tests__/use-canvas-sync-engine-hook.test.tsx",
|
||||||
|
"components/canvas/__tests__/canvas-sidebar.test.tsx",
|
||||||
"components/canvas/__tests__/asset-browser-panel.test.tsx",
|
"components/canvas/__tests__/asset-browser-panel.test.tsx",
|
||||||
"components/canvas/__tests__/video-browser-panel.test.tsx",
|
"components/canvas/__tests__/video-browser-panel.test.tsx",
|
||||||
"components/media/__tests__/media-preview-utils.test.ts",
|
"components/media/__tests__/media-preview-utils.test.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user