diff --git a/clients/web/src/components/elements/ScreenStage/ScreenStage.test.tsx b/clients/web/src/components/elements/ScreenStage/ScreenStage.test.tsx new file mode 100644 index 00000000..e21b7596 --- /dev/null +++ b/clients/web/src/components/elements/ScreenStage/ScreenStage.test.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { ScreenStage } from "./ScreenStage"; + +describe("ScreenStage", () => { + it("renders its screen when active", () => { + renderWithMantine( + +
active screen
+
, + ); + expect(screen.getByText("active screen")).toBeInTheDocument(); + }); + + it("renders nothing when inactive (outgoing screen unmounts)", () => { + renderWithMantine( + +
inactive screen
+
, + ); + expect(screen.queryByText("inactive screen")).toBeNull(); + }); + + it("still renders its screen in the fill variant", () => { + renderWithMantine( + +
filled screen
+
, + ); + expect(screen.getByText("filled screen")).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx b/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx new file mode 100644 index 00000000..23ac4d14 --- /dev/null +++ b/clients/web/src/components/elements/ScreenStage/ScreenStage.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from "react"; +import { Box, Transition } from "@mantine/core"; + +/** Screen enter / exit durations for the shared `fade-up` stage transition. */ +export const SCREEN_ENTER_MS = 350; +export const SCREEN_EXIT_MS = 250; + +export interface ScreenStageProps { + /** True when this stage's screen is the active one. */ + active: boolean; + children: ReactNode; + /** + * Stretch the stage to fill its relative-positioned parent (adds `bottom: 0`). + * Needed where the screen relies on the parent for height (e.g. an inner + * ScrollArea in the monitoring column). Off by default so callers whose + * screens size themselves keep the top/left/right anchoring. + */ + fill?: boolean; +} + +/** + * Wraps a screen in a Mantine `fade-up` Transition so that, on switch, the + * incoming screen slides up and fades in while the outgoing one fades down and + * out — both mounted at once via absolute positioning. With Transition's default + * (`keepMounted={false}`) the outgoing screen unmounts after its exit animation, + * resetting any local screen state (search filters, scroll, expanded sections). + * + * Shared by the primary InspectorView pane and the pinned monitoring column so + * both use identical enter/exit motion (#1639-follow-up). Must be rendered + * inside a `position: relative` container. + */ +export function ScreenStage({ + active, + children, + fill = false, +}: ScreenStageProps) { + return ( + + {(styles) => ( + // `style={styles}` is the runtime transition state from Mantine's + // Transition API — interpolated values, not static styling. + + {children} + + )} + + ); +} diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx index 577502e0..1667382e 100644 --- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { Divider, Stack } from "@mantine/core"; import { MonitoringControls } from "../MonitoringControls/MonitoringControls"; +import { ScreenStage } from "../../elements/ScreenStage/ScreenStage"; export interface MonitoringScreenProps { /** Available monitor tabs (Logs/Protocol/Network, filtered by capability). */ @@ -26,7 +27,10 @@ const ColumnLayout = Stack.withProps({ gap: 0, }); +// Relative-positioned host so each tab's `ScreenStage` (absolutely positioned) +// can cross-fade over it. `mih: 0` lets the inner ScrollArea bound its height. const ScreenSlot = Stack.withProps({ + pos: "relative", flex: 1, mih: 0, gap: 0, @@ -57,7 +61,13 @@ export function MonitoringScreen({ onClose={onClose} /> - {screens[value]} + + {tabs.map((tab) => ( + + {screens[tab]} + + ))} + ); } diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index b2a655d1..68264af4 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -8,7 +8,6 @@ import { } from "react"; import { AppShell, - Box, Flex, Group, Stack, @@ -84,6 +83,7 @@ import { type ConsoleUiState, } from "../../screens/ConsoleScreen/ConsoleScreen"; import type { SortDirection } from "../../elements/SortToggle/SortToggle"; +import { ScreenStage } from "../../elements/ScreenStage/ScreenStage"; import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen"; import { ResizeHandle } from "../../elements/ResizeHandle/ResizeHandle"; import { getServerType } from "@inspector/core/mcp/config.js"; @@ -241,9 +241,6 @@ function serializeMonitorWidth(value: number): string { return String(value); } -const SCREEN_ENTER_MS = 350; -const SCREEN_EXIT_MS = 250; - // Relative-positioned wrapper for the absolutely-positioned `ScreenStage` // children. `mih: "100%"` requires `AppShell.Main` to provide a definite // height for the stack itself to fill — the absolute children render @@ -306,36 +303,6 @@ const MonitorColumnGroup = Group.withProps({ flex: "0 0 auto", }); -// Wraps each screen in a Mantine Transition. With Transition's default -// (`keepMounted={false}`), the outgoing screen unmounts after its exit -// animation — any local screen state (search filters, scroll position, -// expanded sections) is reset on tab switch. -function ScreenStage({ - active, - children, -}: { - active: boolean; - children: ReactNode; -}) { - return ( - - {(styles) => ( - // `style={styles}` is the runtime transition state from Mantine's - // Transition API — these are interpolated values, not static styling. - - {children} - - )} - - ); -} - export interface InspectorViewProps { // Server list (static config; runtime connection state comes from the // separate fields below and is merged into each card by this component).