From 69002e6416661d16246dc61648d4f2f8845919ad Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 24 Aug 2026 19:36:10 +0800 Subject: [PATCH] feat: add semantic motion system (#1320) * feat: add semantic motion system * feat: animate session workspace transitions * feat: refine motion accessibility and transfer feedback --- src/ui/AppShell.tsx | 5 +- src/ui/components/button.tsx | 2 +- .../connection/ConnectionScreen.tsx | 35 ++- src/ui/components/switch.tsx | 2 +- .../components/DownloadProgressToast.tsx | 38 +-- .../components/TransferProgressBar.tsx | 42 +++ .../components/TransferProgressToast.tsx | 59 ++-- src/ui/index.css | 265 ++++++++++++++++++ src/ui/shell/TabBar.tsx | 25 +- .../CredentialItem/CredentialItem.tsx | 6 +- src/ui/sidebar/tree/HostItem/HostItem.tsx | 28 +- src/ui/sidebar/tree/SidebarTree.tsx | 2 +- .../connection/ConnectionScreen.test.tsx | 6 + .../file-manager/TransferProgressBar.test.tsx | 61 ++++ src/ui/tests/shell/TabBar.test.tsx | 83 ++++++ 15 files changed, 569 insertions(+), 90 deletions(-) create mode 100644 src/ui/features/file-manager/components/TransferProgressBar.tsx create mode 100644 src/ui/tests/features/file-manager/TransferProgressBar.test.tsx create mode 100644 src/ui/tests/shell/TabBar.test.tsx diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index fbb6c6b3..7e8aee51 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -2295,6 +2295,7 @@ export function AppShell({ node.style.pointerEvents = activeInline ? "auto" : "none"; node.style.zIndex = activeInline ? "1" : "0"; } else { + node.classList.toggle("motion-workspace-enter", activeInline); node.style.visibility = ""; node.style.pointerEvents = ""; node.style.zIndex = activeInline ? "2" : ""; @@ -2824,7 +2825,7 @@ export function AppShell({
{!isMobile && !sidebarOpen && (
- {showIndeterminate ? ( - - ) : ( - - )} +
{detailLeft ?? ""} void; }) { const { t } = useTranslation(); + const reduceMotion = useReducedMotion(); const [open, setOpen] = useState(true); const [dragTabId, setDragTabId] = useState(null); const [dragTargetIndex, setDragTargetIndex] = useState(null); @@ -214,7 +216,7 @@ export function TabBar({ return (
+ {active && ( + + )} {/* Focused-pane indicator: brand accent bottom border overlay */} {showFocusIndicator && ( diff --git a/src/ui/sidebar/credential-tree/CredentialItem/CredentialItem.tsx b/src/ui/sidebar/credential-tree/CredentialItem/CredentialItem.tsx index 5f093b01..49508d40 100644 --- a/src/ui/sidebar/credential-tree/CredentialItem/CredentialItem.tsx +++ b/src/ui/sidebar/credential-tree/CredentialItem/CredentialItem.tsx @@ -200,10 +200,10 @@ export function CredentialItem({ const trayCollapsed = `max-h-0 opacity-0 ${isCompact ? "" : "-mt-1"}`; const trayVisibilityClass = alwaysShowTray || actionsOnly - ? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[60px] opacity-100" : trayCollapsed}` + ? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[60px] opacity-100" : trayCollapsed}` : shouldUseClickTray - ? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState ? "max-h-[60px] opacity-100" : trayCollapsed}` - : `overflow-hidden transition-all duration-150 ease-out ${trayCollapsed} group-hover:max-h-[60px] group-hover:opacity-100 ${isCompact ? "" : "group-hover:mt-0"} ${isMenuOpen ? `!max-h-[60px] !opacity-100 ${isCompact ? "" : "!mt-0"}` : ""}`; + ? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState ? "max-h-[60px] opacity-100" : trayCollapsed}` + : `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayCollapsed} group-hover:max-h-[60px] group-hover:opacity-100 ${isCompact ? "" : "group-hover:mt-0"} ${isMenuOpen ? `!max-h-[60px] !opacity-100 ${isCompact ? "" : "!mt-0"}` : ""}`; return (
{ + const justCameOnline = + previousAvailability.current !== "online" && availability === "online"; + previousAvailability.current = availability; + if (!justCameOnline) return; + + setStatusLocking(true); + const timeout = window.setTimeout(() => setStatusLocking(false), 400); + return () => window.clearTimeout(timeout); + }, [availability]); const isTouchOnly = typeof window !== "undefined" && window.matchMedia("(hover: none)").matches; const alwaysShowTray = trayTrigger === "always"; @@ -947,9 +960,9 @@ export function HostItem({ const trayCollapsedClass = `max-h-0 opacity-0 ${isCompact ? "" : "-mt-[3.5px]"}`; const trayVisibilityClass = alwaysShowTray || actionsOnly - ? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[130px] opacity-100" : trayCollapsedClass}` + ? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[130px] opacity-100" : trayCollapsedClass}` : shouldUseClickTray - ? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState ? "max-h-[130px] opacity-100" : trayCollapsedClass}` + ? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState ? "max-h-[130px] opacity-100" : trayCollapsedClass}` : // No transition in hover mode: the row's height is set by the // virtualizer and snaps in a single frame, so animating the tray // against it leaves the open tray overflowing its shortened row for @@ -1053,7 +1066,7 @@ export function HostItem({ setContextMenuPosition({ x: event.clientX, y: event.clientY }); onMenuOpenChange?.(true); }} - className={`group relative flex items-stretch select-none transition-colors hover:bg-muted/50 ${ + className={`group relative flex items-stretch select-none transition-colors motion-interactive hover:bg-muted/50 ${ canDrag ? "cursor-grab active:cursor-grabbing" : "cursor-pointer" } ${ selected @@ -1096,7 +1109,8 @@ export function HostItem({ {/* Status stripe */} {showStatusStripes && (
)} @@ -1262,7 +1276,7 @@ export function HostItem({
80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`} + className={`motion-meter h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`} style={{ width: `${host.cpu}%` }} />
@@ -1276,7 +1290,7 @@ export function HostItem({
80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`} + className={`motion-meter h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`} style={{ width: `${host.ram}%` }} />
diff --git a/src/ui/sidebar/tree/SidebarTree.tsx b/src/ui/sidebar/tree/SidebarTree.tsx index a027ee5d..c82ae1a4 100644 --- a/src/ui/sidebar/tree/SidebarTree.tsx +++ b/src/ui/sidebar/tree/SidebarTree.tsx @@ -1101,7 +1101,7 @@ export function SidebarTree({ {/* Floating selection bar */} {selectionMode && ( -
+
{t("hosts.nSelected", { count: selectedHostIds.size })} diff --git a/src/ui/tests/components/connection/ConnectionScreen.test.tsx b/src/ui/tests/components/connection/ConnectionScreen.test.tsx index feb59e01..f4760683 100644 --- a/src/ui/tests/components/connection/ConnectionScreen.test.tsx +++ b/src/ui/tests/components/connection/ConnectionScreen.test.tsx @@ -19,6 +19,9 @@ describe("ConnectionScreen", () => { ).not.toThrow(); expect(screen.getByText("common.loading")).toBeTruthy(); + const status = screen.getByRole("status"); + expect(status.getAttribute("data-status")).toBe("connecting"); + expect(status.querySelector('[data-active="true"]')).toBeTruthy(); }); it("renders the disconnected state without a provider", () => { @@ -32,6 +35,9 @@ describe("ConnectionScreen", () => { ).not.toThrow(); expect(screen.getByText("guacamole.hostNotFound")).toBeTruthy(); + expect(screen.getByRole("status").getAttribute("data-status")).toBe( + "disconnected", + ); }); it("still shows the connection log when a provider is present", () => { diff --git a/src/ui/tests/features/file-manager/TransferProgressBar.test.tsx b/src/ui/tests/features/file-manager/TransferProgressBar.test.tsx new file mode 100644 index 00000000..dcb06959 --- /dev/null +++ b/src/ui/tests/features/file-manager/TransferProgressBar.test.tsx @@ -0,0 +1,61 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { TransferProgressBar } from "../../../features/file-manager/components/TransferProgressBar"; + +afterEach(cleanup); + +describe("TransferProgressBar", () => { + it("exposes determinate progress and drives the fill with a transform", () => { + const { container } = render( + , + ); + + const progress = screen.getByRole("progressbar", { + name: "Uploading archive", + }); + expect(progress.getAttribute("aria-valuenow")).toBe("42"); + expect(progress.getAttribute("aria-busy")).toBe("false"); + expect( + container + .querySelector(".transfer-progress-fill") + ?.style.getPropertyValue("--transfer-progress"), + ).toBe("0.42"); + }); + + it("clamps invalid percentages before exposing them", () => { + const { rerender } = render( + , + ); + + expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe( + "100", + ); + + rerender(); + expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe( + "0", + ); + }); + + it("marks unknown and stalled progress without inventing a percentage", () => { + const { container } = render( + , + ); + + const progress = screen.getByRole("progressbar"); + expect(progress.hasAttribute("aria-valuenow")).toBe(false); + expect(progress.getAttribute("aria-busy")).toBe("true"); + expect(progress.getAttribute("data-stalled")).toBe("true"); + expect( + container + .querySelector(".transfer-progress-fill") + ?.classList.contains("transfer-progress-indeterminate"), + ).toBe(true); + expect( + container + .querySelector(".transfer-progress-fill") + ?.classList.contains("transfer-progress-stalled"), + ).toBe(true); + }); +}); diff --git a/src/ui/tests/shell/TabBar.test.tsx b/src/ui/tests/shell/TabBar.test.tsx new file mode 100644 index 00000000..ec9fd6f6 --- /dev/null +++ b/src/ui/tests/shell/TabBar.test.tsx @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import type { Tab } from "@/types/ui-types"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/lib/electron", () => ({ isElectron: () => true })); + +import { TabBar } from "@/shell/TabBar"; + +afterEach(cleanup); + +const tabs = [ + { id: "dashboard", type: "dashboard", label: "Dashboard" }, + { id: "terminal-1", type: "terminal", label: "web-01" }, +] as Tab[]; + +function renderTabBar(activeTabId: string, onSetActiveTab = vi.fn()) { + return render( + {}} + onRefreshTab={() => {}} + onReorderTabs={() => {}} + onSplitTab={() => {}} + onAddToSplit={() => {}} + onRemoveFromSplit={() => {}} + isAppFullscreen={false} + onToggleAppFullscreen={() => {}} + />, + ); +} + +describe("TabBar workspace continuity", () => { + it("keeps one shared indicator on the active workspace", () => { + const { container, rerender } = renderTabBar("dashboard"); + expect( + container.querySelector('[data-workspace-indicator="dashboard"]'), + ).toBeTruthy(); + + rerender( + {}} + onCloseTab={() => {}} + onRefreshTab={() => {}} + onReorderTabs={() => {}} + onSplitTab={() => {}} + onAddToSplit={() => {}} + onRemoveFromSplit={() => {}} + isAppFullscreen={false} + onToggleAppFullscreen={() => {}} + />, + ); + + expect( + container.querySelectorAll("[data-workspace-indicator]"), + ).toHaveLength(1); + expect( + container.querySelector('[data-workspace-indicator="terminal-1"]'), + ).toBeTruthy(); + }); + + it("activates a workspace immediately when its tab is clicked", () => { + const onSetActiveTab = vi.fn(); + const { getByText } = renderTabBar("dashboard", onSetActiveTab); + + fireEvent.click(getByText("web-01")); + + expect(onSetActiveTab).toHaveBeenCalledWith("terminal-1"); + }); +});