mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
feat: add semantic motion system (#1320)
* feat: add semantic motion system * feat: animate session workspace transitions * feat: refine motion accessibility and transfer feedback
This commit is contained in:
+3
-2
@@ -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({
|
||||
<div
|
||||
inert={settingsFullscreen ? true : undefined}
|
||||
aria-hidden={settingsFullscreen || undefined}
|
||||
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-all duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
|
||||
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-[padding] duration-200 ${!isMobile && !sidebarOpen ? "pl-6" : ""}`}
|
||||
>
|
||||
{!isMobile && !sidebarOpen && (
|
||||
<button
|
||||
@@ -2864,7 +2865,7 @@ export function AppShell({
|
||||
{/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
|
||||
{!isMobile && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
className="motion-workspace-layout absolute inset-0"
|
||||
style={{
|
||||
display: isSplit ? "flex" : "none",
|
||||
flexDirection: "column",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Slot } from "radix-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-medium whitespace-nowrap transition-[color,background-color,border-color,opacity,box-shadow,transform] outline-none select-none focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -49,7 +49,13 @@ export function ConnectionScreen({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("absolute inset-0 z-[100] flex flex-col", className)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-status={status}
|
||||
className={cn(
|
||||
"motion-context-enter absolute inset-0 z-[100] flex flex-col",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: backgroundColor || "var(--bg-base)" }}
|
||||
>
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center">
|
||||
@@ -57,7 +63,15 @@ export function ConnectionScreen({
|
||||
emptyState
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 px-6 text-center">
|
||||
{showSpinner && <div className="simple-spinner" />}
|
||||
{showSpinner && (
|
||||
<div
|
||||
className="connection-signal"
|
||||
data-active="true"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="connection-signal-core" />
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<p className="text-sm text-foreground-secondary font-medium">
|
||||
{message}
|
||||
@@ -105,23 +119,6 @@ export function ConnectionScreen({
|
||||
position={logPosition}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style>
|
||||
{`
|
||||
@keyframes connection-screen-spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
.simple-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--border-base);
|
||||
border-top-color: var(--foreground);
|
||||
border-radius: 50%;
|
||||
animation: connection-screen-spin 0.8s linear infinite;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ function Switch({
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-[background-color,border-color,box-shadow,opacity] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { formatTransferMbPerSec } from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDownToLine } from "lucide-react";
|
||||
import { TransferProgressBar } from "./TransferProgressBar";
|
||||
|
||||
interface DownloadProgressToastProps {
|
||||
fileName: string;
|
||||
@@ -22,26 +24,6 @@ function formatBytes(bytes: number): string {
|
||||
return `${formattedSize} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function IndeterminateProgressBar() {
|
||||
return (
|
||||
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
|
||||
<div className="bg-primary/60 absolute inset-y-0 left-0 w-1/3 animate-pulse rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeterminateProgressBar({ value }: { value: number }) {
|
||||
const clamped = Math.min(100, Math.max(0, value));
|
||||
return (
|
||||
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-[width]"
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadProgressToast({
|
||||
fileName,
|
||||
loaded,
|
||||
@@ -58,14 +40,16 @@ export function DownloadProgressToast({
|
||||
|
||||
return (
|
||||
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
|
||||
<p className="text-sm font-medium leading-tight truncate">
|
||||
{t("fileManager.downloadingFile", { name: fileName })}
|
||||
<p className="flex items-center gap-2 text-sm font-medium leading-tight">
|
||||
<ArrowDownToLine className="size-3.5 shrink-0 text-accent-brand" />
|
||||
<span className="truncate">
|
||||
{t("fileManager.downloadingFile", { name: fileName })}
|
||||
</span>
|
||||
</p>
|
||||
{percent === undefined ? (
|
||||
<IndeterminateProgressBar />
|
||||
) : (
|
||||
<DeterminateProgressBar value={percent} />
|
||||
)}
|
||||
<TransferProgressBar
|
||||
value={percent}
|
||||
label={t("fileManager.downloadingFile", { name: fileName })}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate">
|
||||
{total !== undefined
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function TransferProgressBar({
|
||||
value,
|
||||
label,
|
||||
stalled = false,
|
||||
}: {
|
||||
value?: number;
|
||||
label: string;
|
||||
stalled?: boolean;
|
||||
}) {
|
||||
const clamped = Math.min(100, Math.max(0, value ?? 0));
|
||||
const indeterminate = value === undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={indeterminate ? undefined : clamped}
|
||||
aria-busy={indeterminate}
|
||||
data-stalled={stalled || undefined}
|
||||
className="transfer-progress-track"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"transfer-progress-fill",
|
||||
indeterminate && "transfer-progress-indeterminate",
|
||||
stalled && "transfer-progress-stalled",
|
||||
)}
|
||||
style={
|
||||
indeterminate
|
||||
? undefined
|
||||
: ({ "--transfer-progress": clamped / 100 } as CSSProperties)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,15 @@ import {
|
||||
type TransferProgressResponse,
|
||||
} from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ArrowRight,
|
||||
Gauge,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { TransferProgressBar } from "./TransferProgressBar";
|
||||
|
||||
interface TransferProgressToastProps {
|
||||
status: TransferProgressResponse;
|
||||
@@ -15,26 +24,6 @@ interface TransferProgressToastProps {
|
||||
cancelling?: boolean;
|
||||
}
|
||||
|
||||
function IndeterminateProgressBar() {
|
||||
return (
|
||||
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
|
||||
<div className="bg-primary/60 absolute inset-y-0 left-0 w-1/3 animate-pulse rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeterminateProgressBar({ value }: { value: number }) {
|
||||
const clamped = Math.min(100, Math.max(0, value));
|
||||
return (
|
||||
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-[width]"
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransferProgressToast({
|
||||
status,
|
||||
liveMbPerSec,
|
||||
@@ -103,11 +92,29 @@ export function TransferProgressToast({
|
||||
|
||||
const showIndeterminate =
|
||||
status.phase === "reconnecting" || percent === undefined;
|
||||
const PhaseIcon =
|
||||
status.phase === "compressing"
|
||||
? Archive
|
||||
: status.phase === "benchmarking"
|
||||
? Gauge
|
||||
: status.phase === "verifying"
|
||||
? ShieldCheck
|
||||
: status.phase === "extracting"
|
||||
? ArchiveRestore
|
||||
: status.phase === "reconnecting"
|
||||
? RefreshCw
|
||||
: ArrowRight;
|
||||
|
||||
return (
|
||||
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-tight">{title}</p>
|
||||
<p
|
||||
key={status.phase}
|
||||
className="motion-transfer-phase flex min-w-0 items-center gap-2 text-sm font-medium leading-tight"
|
||||
>
|
||||
<PhaseIcon className="size-3.5 shrink-0 text-accent-brand" />
|
||||
<span className="truncate">{title}</span>
|
||||
</p>
|
||||
{onCancel && status.status === "running" && status.transferId && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -123,11 +130,11 @@ export function TransferProgressToast({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showIndeterminate ? (
|
||||
<IndeterminateProgressBar />
|
||||
) : (
|
||||
<DeterminateProgressBar value={percent} />
|
||||
)}
|
||||
<TransferProgressBar
|
||||
value={showIndeterminate ? undefined : percent}
|
||||
label={title}
|
||||
stalled={stalled}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate">{detailLeft ?? ""}</span>
|
||||
<span
|
||||
|
||||
@@ -101,6 +101,11 @@
|
||||
|
||||
:root {
|
||||
--accent-brand: #f59145;
|
||||
--motion-duration-micro: 110ms;
|
||||
--motion-duration-panel: 220ms;
|
||||
--motion-duration-context: 340ms;
|
||||
--motion-ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--motion-ease-exit: cubic-bezier(0.4, 0, 1, 1);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.1802 0.0043 128.73);
|
||||
--card: oklch(1 0 0);
|
||||
@@ -537,3 +542,263 @@ html.fs-xl {
|
||||
.terminal-toolbar-collapsed-shell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Motion communicates state and hierarchy. Keep it off the terminal glyph layer. */
|
||||
.motion-interactive {
|
||||
transition-duration: var(--motion-duration-micro);
|
||||
transition-timing-function: var(--motion-ease-enter);
|
||||
}
|
||||
|
||||
.motion-meter {
|
||||
transition: width var(--motion-duration-panel) var(--motion-ease-enter);
|
||||
}
|
||||
|
||||
.motion-panel-enter {
|
||||
animation: motion-panel-enter var(--motion-duration-panel)
|
||||
var(--motion-ease-enter) both;
|
||||
}
|
||||
|
||||
.motion-context-enter {
|
||||
animation: motion-context-enter var(--motion-duration-context)
|
||||
var(--motion-ease-enter) both;
|
||||
}
|
||||
|
||||
.motion-workspace-enter {
|
||||
animation: motion-workspace-enter var(--motion-duration-panel)
|
||||
var(--motion-ease-enter) both;
|
||||
}
|
||||
|
||||
.motion-workspace-layout {
|
||||
animation: motion-workspace-layout var(--motion-duration-context)
|
||||
var(--motion-ease-enter) both;
|
||||
}
|
||||
|
||||
.motion-transfer-phase {
|
||||
animation: motion-transfer-phase var(--motion-duration-micro)
|
||||
var(--motion-ease-enter) both;
|
||||
}
|
||||
|
||||
.transfer-progress-track {
|
||||
position: relative;
|
||||
height: 5px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in oklab, var(--primary) 16%, transparent);
|
||||
}
|
||||
|
||||
.transfer-progress-fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--accent-brand);
|
||||
transform: scaleX(var(--transfer-progress, 0));
|
||||
transform-origin: left center;
|
||||
transition: transform var(--motion-duration-panel) var(--motion-ease-enter);
|
||||
}
|
||||
|
||||
.transfer-progress-indeterminate {
|
||||
width: 32%;
|
||||
transform: translateX(-110%);
|
||||
animation: transfer-progress-flow 1.1s var(--motion-ease-enter) infinite;
|
||||
}
|
||||
|
||||
.transfer-progress-stalled {
|
||||
background: var(--destructive);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.transfer-progress-indeterminate.transfer-progress-stalled {
|
||||
transform: translateX(105%);
|
||||
}
|
||||
|
||||
.connection-signal {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 48px;
|
||||
height: 24px;
|
||||
color: var(--accent-brand);
|
||||
}
|
||||
|
||||
.connection-signal::before,
|
||||
.connection-signal::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 1px;
|
||||
background: currentColor;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
.connection-signal::before {
|
||||
right: 50%;
|
||||
margin-right: 5px;
|
||||
transform-origin: right;
|
||||
}
|
||||
|
||||
.connection-signal::after {
|
||||
left: 50%;
|
||||
margin-left: 5px;
|
||||
transform-origin: left;
|
||||
}
|
||||
|
||||
.connection-signal-core {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border: 1px solid currentColor;
|
||||
background: color-mix(in oklab, currentColor 18%, transparent);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.connection-signal[data-active="true"]::before,
|
||||
.connection-signal[data-active="true"]::after {
|
||||
animation: connection-signal-flow 900ms var(--motion-ease-enter) infinite;
|
||||
}
|
||||
|
||||
.connection-signal[data-active="true"]::after {
|
||||
animation-delay: 120ms;
|
||||
}
|
||||
|
||||
.host-status-stripe {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.host-status-stripe[data-locking="true"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 -3px;
|
||||
z-index: -1;
|
||||
background: inherit;
|
||||
opacity: 0;
|
||||
transform: scaleX(1);
|
||||
animation: host-signal-lock var(--motion-duration-context)
|
||||
var(--motion-ease-enter) both;
|
||||
}
|
||||
|
||||
@keyframes motion-panel-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.99);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes motion-context-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.995);
|
||||
filter: saturate(0.65);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: saturate(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes motion-workspace-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(3px) scale(0.998);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes motion-workspace-layout {
|
||||
from {
|
||||
opacity: 0.72;
|
||||
transform: scale(0.996);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes motion-transfer-phase {
|
||||
from {
|
||||
opacity: 0.45;
|
||||
transform: translateX(-3px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes transfer-progress-flow {
|
||||
to {
|
||||
transform: translateX(420%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes connection-signal-flow {
|
||||
0% {
|
||||
opacity: 0.12;
|
||||
transform: scaleX(0.2);
|
||||
}
|
||||
45% {
|
||||
opacity: 0.72;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.22;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes host-signal-lock {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scaleX(3.2);
|
||||
}
|
||||
38% {
|
||||
opacity: 0.48;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--motion-duration-micro: 1ms;
|
||||
--motion-duration-panel: 1ms;
|
||||
--motion-duration-context: 1ms;
|
||||
}
|
||||
|
||||
.motion-panel-enter,
|
||||
.motion-context-enter,
|
||||
.motion-workspace-enter,
|
||||
.motion-workspace-layout,
|
||||
.motion-transfer-phase,
|
||||
.transfer-progress-indeterminate,
|
||||
.connection-signal[data-active="true"]::before,
|
||||
.connection-signal[data-active="true"]::after,
|
||||
.host-status-stripe[data-locking="true"]::after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.motion-meter {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.transfer-progress-indeterminate {
|
||||
transform: translateX(105%);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: 1ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
+22
-3
@@ -1,5 +1,6 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Separator } from "@/components/separator";
|
||||
import {
|
||||
@@ -77,6 +78,7 @@ export function TabBar({
|
||||
onToggleRightDock?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [dragTabId, setDragTabId] = useState<string | null>(null);
|
||||
const [dragTargetIndex, setDragTargetIndex] = useState<number | null>(null);
|
||||
@@ -214,7 +216,7 @@ export function TabBar({
|
||||
return (
|
||||
<div className="flex flex-col shrink-0 min-w-0">
|
||||
<div
|
||||
className={`flex items-end bg-sidebar min-w-0 transition-all duration-200 ${open ? "h-12.5 border-b border-border" : "h-0 overflow-hidden"}`}
|
||||
className={`flex items-end bg-sidebar min-w-0 transition-[height,border-color] duration-200 ${open ? "h-12.5 border-b border-border" : "h-0 overflow-hidden"}`}
|
||||
>
|
||||
<div
|
||||
ref={tabBarRef}
|
||||
@@ -318,10 +320,27 @@ export function TabBar({
|
||||
${index === 0 && tab.type !== "dashboard" ? "border-l border-border" : ""}
|
||||
${
|
||||
tab.type === "dashboard"
|
||||
? `px-2.5 md:px-3.5 ${active ? "border-b-2 border-b-accent-brand bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
|
||||
: `px-2.5 md:px-4 font-medium ${active ? "border-b-2 border-b-accent-brand bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
|
||||
? `px-2.5 md:px-3.5 ${active ? "bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
|
||||
: `px-2.5 md:px-4 font-medium ${active ? "bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
|
||||
}`}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="active-workspace-tab"
|
||||
data-workspace-indicator={tab.id}
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-0.5 bg-accent-brand z-10"
|
||||
transition={
|
||||
reduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
type: "spring",
|
||||
stiffness: 520,
|
||||
damping: 42,
|
||||
mass: 0.55,
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* Focused-pane indicator: brand accent bottom border overlay */}
|
||||
{showFocusIndicator && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent-brand/70 z-10" />
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useEffect, useState, type MouseEvent } from "react";
|
||||
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Box,
|
||||
@@ -319,6 +319,19 @@ export function HostItem({
|
||||
? "online"
|
||||
: "offline";
|
||||
const isOnline = availability === "online";
|
||||
const previousAvailability = useRef(availability);
|
||||
const [statusLocking, setStatusLocking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
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 && (
|
||||
<div
|
||||
className={`w-[3px] shrink-0 transition-colors ${getStatusClasses(availability, statusScheme, "stripe", statusLoading)}`}
|
||||
data-locking={statusLocking}
|
||||
className={`host-status-stripe w-[3px] shrink-0 transition-colors motion-interactive ${getStatusClasses(availability, statusScheme, "stripe", statusLoading)}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1262,7 +1276,7 @@ export function HostItem({
|
||||
<Cpu className="size-2.5 shrink-0 text-muted-foreground/40" />
|
||||
<div className="w-9 h-1 bg-muted-foreground/15 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${host.cpu > 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}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -1276,7 +1290,7 @@ export function HostItem({
|
||||
<MemoryStick className="size-2.5 shrink-0 text-muted-foreground/40" />
|
||||
<div className="w-9 h-1 bg-muted-foreground/15 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${host.ram > 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}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1101,7 +1101,7 @@ export function SidebarTree({
|
||||
|
||||
{/* Floating selection bar */}
|
||||
{selectionMode && (
|
||||
<div className="absolute bottom-4 inset-x-3 z-50">
|
||||
<div className="motion-panel-enter absolute bottom-4 inset-x-3 z-50">
|
||||
<div className="bg-popover border border-border shadow-xl px-2.5 py-2 flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs font-semibold tabular-nums shrink-0">
|
||||
{t("hosts.nSelected", { count: selectedHostIds.size })}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
<TransferProgressBar value={42} label="Uploading archive" />,
|
||||
);
|
||||
|
||||
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<HTMLElement>(".transfer-progress-fill")
|
||||
?.style.getPropertyValue("--transfer-progress"),
|
||||
).toBe("0.42");
|
||||
});
|
||||
|
||||
it("clamps invalid percentages before exposing them", () => {
|
||||
const { rerender } = render(
|
||||
<TransferProgressBar value={140} label="Uploading archive" />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
|
||||
"100",
|
||||
);
|
||||
|
||||
rerender(<TransferProgressBar value={-20} label="Uploading archive" />);
|
||||
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
|
||||
"0",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks unknown and stalled progress without inventing a percentage", () => {
|
||||
const { container } = render(
|
||||
<TransferProgressBar label="Reconnecting" stalled />,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTabId={activeTabId}
|
||||
splitMode="none"
|
||||
paneTabIds={[]}
|
||||
focusedPaneIndex={null}
|
||||
onSetActiveTab={onSetActiveTab}
|
||||
onCloseTab={() => {}}
|
||||
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(
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeTabId="terminal-1"
|
||||
splitMode="none"
|
||||
paneTabIds={[]}
|
||||
focusedPaneIndex={null}
|
||||
onSetActiveTab={() => {}}
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user