mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(studio): panel tabs take the decided Current design
Replaces the pill, underline and close treatment with the Current design: panels are cards on the shell with one border each, the shown tab is a fill in the card colour that slides to the next tab, only the shown tab carries its type icon and close glyph, strip actions sit on the active group only, and narrow strips scroll with an edge fade.
This commit is contained in:
@@ -286,9 +286,7 @@ function EditorShellBody({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
// Shell canvas is a step LIGHTER than the near-black panel cards so the
|
className="flex flex-col flex-1 min-h-0"
|
||||||
// gaps between panels read as visible seams (CapCut-style).
|
|
||||||
className="flex flex-col flex-1 min-h-0 bg-panel-surface"
|
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ interface StudioLeftPanelsProps {
|
|||||||
|
|
||||||
function PanelColumn({ footer, children }: { footer: ReactNode; children: ReactNode }) {
|
function PanelColumn({ footer, children }: { footer: ReactNode; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-950">
|
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
} from "dockview-react";
|
} from "dockview-react";
|
||||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||||
import { installDockAccessibility } from "./dockAccessibility";
|
import { installDockAccessibility } from "./dockAccessibility";
|
||||||
|
import { DockStripActions } from "./DockStripActions";
|
||||||
|
import { DockTab } from "./DockTab";
|
||||||
|
import { installTabFill } from "./dockTabFill";
|
||||||
import { addRegisteredPanel, applySideMinimums, buildEditLayout } from "./dockLayout";
|
import { addRegisteredPanel, applySideMinimums, buildEditLayout } from "./dockLayout";
|
||||||
import { DOCK_PANEL_COMPONENT } from "./dockLayoutSchema";
|
import { DOCK_PANEL_COMPONENT } from "./dockLayoutSchema";
|
||||||
import { useDockLayoutStore, type DockController, type DockSnapshot } from "./dockLayoutStore";
|
import { useDockLayoutStore, type DockController, type DockSnapshot } from "./dockLayoutStore";
|
||||||
@@ -150,6 +153,7 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
|
|||||||
applySideMinimums(api);
|
applySideMinimums(api);
|
||||||
const root = api.groups[0]?.element.closest<HTMLElement>(".hf-dock");
|
const root = api.groups[0]?.element.closest<HTMLElement>(".hf-dock");
|
||||||
const disposeAccessibility = root ? installDockAccessibility(api, root) : () => {};
|
const disposeAccessibility = root ? installDockAccessibility(api, root) : () => {};
|
||||||
|
const disposeTabFill = root ? installTabFill(api, root) : () => {};
|
||||||
// The dock spans the window (buildEditLayout sizes against it too); its own box lags a resize.
|
// The dock spans the window (buildEditLayout sizes against it too); its own box lags a resize.
|
||||||
const resizeObserver = new ResizeObserver(() => applySideMinimums(api, window.innerWidth));
|
const resizeObserver = new ResizeObserver(() => applySideMinimums(api, window.innerWidth));
|
||||||
if (root) resizeObserver.observe(root);
|
if (root) resizeObserver.observe(root);
|
||||||
@@ -189,6 +193,7 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
for (const subscription of subscriptions) subscription.dispose();
|
for (const subscription of subscriptions) subscription.dispose();
|
||||||
disposeAccessibility();
|
disposeAccessibility();
|
||||||
|
disposeTabFill();
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
useDockLayoutStore.getState().detach();
|
useDockLayoutStore.getState().detach();
|
||||||
};
|
};
|
||||||
@@ -202,8 +207,11 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
|
|||||||
key={projectId ?? ""}
|
key={projectId ?? ""}
|
||||||
className="hf-dock flex-1 min-h-0"
|
className="hf-dock flex-1 min-h-0"
|
||||||
components={COMPONENTS}
|
components={COMPONENTS}
|
||||||
|
defaultTabComponent={DockTab}
|
||||||
|
rightHeaderActionsComponent={DockStripActions}
|
||||||
defaultRenderer="always"
|
defaultRenderer="always"
|
||||||
disableFloatingGroups
|
disableFloatingGroups
|
||||||
|
disableTabsOverflowList
|
||||||
onReady={onReady}
|
onReady={onReady}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { CornersIn, CornersOut, DotsThree, X } from "@phosphor-icons/react";
|
||||||
|
import type { IDockviewHeaderActionsProps } from "dockview-react";
|
||||||
|
import { IconButton, Menu, MenuCheckboxItem, MenuItem, MenuSeparator } from "../ui";
|
||||||
|
import { useDockLayoutStore } from "./dockLayoutStore";
|
||||||
|
import { PANEL_DEFINITIONS, isPanelId, panelsInZone } from "./panelRegistry";
|
||||||
|
|
||||||
|
/** Panel menu, maximise and close group, drawn on the active group's strip only. */
|
||||||
|
export function DockStripActions({
|
||||||
|
api,
|
||||||
|
containerApi,
|
||||||
|
panels,
|
||||||
|
isGroupActive,
|
||||||
|
}: IDockviewHeaderActionsProps) {
|
||||||
|
const [maximized, setMaximized] = useState(() => api.isMaximized());
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = containerApi.onDidMaximizedGroupChange(() =>
|
||||||
|
setMaximized(api.isMaximized()),
|
||||||
|
);
|
||||||
|
return () => subscription.dispose();
|
||||||
|
}, [api, containerApi]);
|
||||||
|
const openPanels = useDockLayoutStore((state) => state.openPanels);
|
||||||
|
const togglePanel = useDockLayoutStore((state) => state.togglePanel);
|
||||||
|
const resetLayout = useDockLayoutStore((state) => state.resetLayout);
|
||||||
|
if (!isGroupActive) return null;
|
||||||
|
|
||||||
|
const first = panels.find((panel) => isPanelId(panel.id))?.id;
|
||||||
|
const menuPanels = isPanelId(first) ? panelsInZone(PANEL_DEFINITIONS[first].zone) : [];
|
||||||
|
return (
|
||||||
|
<div className="hf-dock-strip-actions">
|
||||||
|
<Menu
|
||||||
|
align="end"
|
||||||
|
aria-label="Panel menu"
|
||||||
|
trigger={<IconButton size="sm" aria-label="Panel menu" icon={<DotsThree size={16} />} />}
|
||||||
|
>
|
||||||
|
{menuPanels.map((id) => (
|
||||||
|
<MenuCheckboxItem
|
||||||
|
key={id}
|
||||||
|
checked={openPanels.has(id)}
|
||||||
|
onCheckedChange={() => togglePanel(id)}
|
||||||
|
>
|
||||||
|
{PANEL_DEFINITIONS[id].title}
|
||||||
|
</MenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem onClick={resetLayout}>Reset layout</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
aria-label={maximized ? "Restore panel" : "Maximize panel"}
|
||||||
|
icon={maximized ? <CornersIn size={16} /> : <CornersOut size={16} />}
|
||||||
|
onClick={() => (maximized ? api.exitMaximized() : api.maximize())}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
aria-label="Close group"
|
||||||
|
icon={<X size={16} />}
|
||||||
|
onClick={() => api.close()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
BracketsCurly,
|
||||||
|
ChartBarHorizontal,
|
||||||
|
Code,
|
||||||
|
FilmSlate,
|
||||||
|
Image,
|
||||||
|
Layout,
|
||||||
|
Monitor,
|
||||||
|
Presentation,
|
||||||
|
SlidersHorizontal,
|
||||||
|
SquaresFour,
|
||||||
|
Stack,
|
||||||
|
X,
|
||||||
|
type Icon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import type { IDockviewPanelHeaderProps } from "dockview-react";
|
||||||
|
import { isPanelId, type PanelId } from "./panelRegistry";
|
||||||
|
|
||||||
|
const TAB_ICONS: Record<PanelId, Icon> = {
|
||||||
|
preview: Monitor,
|
||||||
|
timeline: ChartBarHorizontal,
|
||||||
|
compositions: Layout,
|
||||||
|
assets: Image,
|
||||||
|
code: Code,
|
||||||
|
catalog: SquaresFour,
|
||||||
|
design: SlidersHorizontal,
|
||||||
|
layers: Stack,
|
||||||
|
renders: FilmSlate,
|
||||||
|
variables: BracketsCurly,
|
||||||
|
slideshow: Presentation,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A dock tab. Only the shown tab carries its type icon and close glyph; the rest are labels. */
|
||||||
|
export function DockTab({ api }: IDockviewPanelHeaderProps) {
|
||||||
|
const [title, setTitle] = useState(api.title ?? "");
|
||||||
|
const [shown, setShown] = useState(api.isVisible);
|
||||||
|
useEffect(() => {
|
||||||
|
const subscriptions = [
|
||||||
|
api.onDidTitleChange((event) => setTitle(event.title)),
|
||||||
|
api.onDidVisibilityChange((event) => setShown(event.isVisible)),
|
||||||
|
];
|
||||||
|
return () => {
|
||||||
|
for (const subscription of subscriptions) subscription.dispose();
|
||||||
|
};
|
||||||
|
}, [api]);
|
||||||
|
const TypeIcon = shown && isPanelId(api.id) ? TAB_ICONS[api.id] : null;
|
||||||
|
return (
|
||||||
|
<div className="hf-dock-tab">
|
||||||
|
{TypeIcon ? <TypeIcon className="hf-dock-tab-icon" size={14} aria-hidden /> : null}
|
||||||
|
<span className="hf-dock-tab-label">{title}</span>
|
||||||
|
{shown ? (
|
||||||
|
// Same shape as dockview's own close control: a tab cannot hold a focusable button.
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={`Close ${title}`}
|
||||||
|
className="hf-dock-tab-close"
|
||||||
|
onPointerDown={(event) => event.stopPropagation()}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
api.close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={12} aria-hidden />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Dock } from "./Dock";
|
||||||
|
import { useDockLayoutStore } from "./dockLayoutStore";
|
||||||
|
import { PANEL_IDS, type PanelId } from "./panelRegistry";
|
||||||
|
|
||||||
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
class ResizeObserverStub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub;
|
||||||
|
|
||||||
|
// happy-dom has no layout: a tab sits at 100px per position and is 90px wide.
|
||||||
|
const TAB_STEP = 100;
|
||||||
|
const TAB_WIDTH = 90;
|
||||||
|
const tabIndex = (element: HTMLElement) =>
|
||||||
|
element.classList.contains("dv-tab")
|
||||||
|
? [...(element.parentElement?.querySelectorAll(":scope > .dv-tab") ?? [])].indexOf(element)
|
||||||
|
: -1;
|
||||||
|
const layout = {
|
||||||
|
offsetLeft: {
|
||||||
|
get(this: HTMLElement) {
|
||||||
|
return Math.max(tabIndex(this), 0) * TAB_STEP;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
offsetWidth: {
|
||||||
|
get(this: HTMLElement) {
|
||||||
|
return tabIndex(this) >= 0 ? TAB_WIDTH : 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const original = {
|
||||||
|
offsetLeft: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetLeft"),
|
||||||
|
offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"),
|
||||||
|
};
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperties(HTMLElement.prototype, {
|
||||||
|
offsetLeft: { ...layout.offsetLeft, configurable: true },
|
||||||
|
offsetWidth: { ...layout.offsetWidth, configurable: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
for (const [key, descriptor] of Object.entries(original)) {
|
||||||
|
if (descriptor) Object.defineProperty(HTMLElement.prototype, key, descriptor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: Root | null = null;
|
||||||
|
let host: HTMLElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
root = createRoot(host);
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
<Dock.Root projectId="p1">
|
||||||
|
{PANEL_IDS.map((id) => (
|
||||||
|
<Dock.Panel key={id} id={id}>
|
||||||
|
<div>{id}</div>
|
||||||
|
</Dock.Panel>
|
||||||
|
))}
|
||||||
|
</Dock.Root>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root?.unmount());
|
||||||
|
root = null;
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const tab = (id: PanelId) => host.querySelector<HTMLElement>(`[data-tab-panel-id="${id}"]`);
|
||||||
|
const stripOf = (id: PanelId) => tab(id)?.closest<HTMLElement>(".dv-tabs-container");
|
||||||
|
const groupOf = (id: PanelId) => tab(id)?.closest<HTMLElement>(".dv-groupview");
|
||||||
|
|
||||||
|
async function activate(id: PanelId) {
|
||||||
|
// dockview settles the active panel on a microtask; `act(async)` waits for it.
|
||||||
|
await act(async () => useDockLayoutStore.getState().activatePanel(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("dock tabs", () => {
|
||||||
|
it("draw the type icon and close glyph on the shown tab only", () => {
|
||||||
|
for (const id of ["design", "compositions"] as const) {
|
||||||
|
expect(tab(id)?.querySelector(".hf-dock-tab-icon")).not.toBeNull();
|
||||||
|
expect(tab(id)?.querySelector(".hf-dock-tab-close")).not.toBeNull();
|
||||||
|
}
|
||||||
|
for (const id of ["layers", "renders", "variables", "assets", "code", "catalog"] as const) {
|
||||||
|
expect(tab(id)?.querySelector(".hf-dock-tab-icon")).toBeNull();
|
||||||
|
expect(tab(id)?.querySelector(".hf-dock-tab-close")).toBeNull();
|
||||||
|
expect(tab(id)?.textContent).toBe(tab(id)?.getAttribute("aria-label"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("move the icon to the newly shown tab and drop it from the old one", async () => {
|
||||||
|
await activate("layers");
|
||||||
|
expect(tab("layers")?.querySelector(".hf-dock-tab-icon")).not.toBeNull();
|
||||||
|
expect(tab("design")?.querySelector(".hf-dock-tab-icon")).toBeNull();
|
||||||
|
expect(tab("design")?.querySelector(".hf-dock-tab-close")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("name the close control after the panel, and close only that panel", async () => {
|
||||||
|
const close = tab("design")?.querySelector<HTMLElement>(".hf-dock-tab-close");
|
||||||
|
expect(close?.getAttribute("aria-label")).toBe("Close Design");
|
||||||
|
await act(async () => close?.click());
|
||||||
|
expect(useDockLayoutStore.getState().openPanels.has("design")).toBe(false);
|
||||||
|
expect(useDockLayoutStore.getState().openPanels.has("layers")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dock strip actions", () => {
|
||||||
|
const groupsWithActions = () =>
|
||||||
|
[...host.querySelectorAll(".hf-dock-strip-actions")].map((actions) =>
|
||||||
|
actions.closest(".dv-groupview"),
|
||||||
|
);
|
||||||
|
|
||||||
|
it("sit on the active group's strip only, and follow the active group", async () => {
|
||||||
|
await activate("design");
|
||||||
|
expect(groupsWithActions()).toEqual([groupOf("design")]);
|
||||||
|
const labels = [...host.querySelectorAll(".hf-dock-strip-actions button")].map((button) =>
|
||||||
|
button.getAttribute("aria-label"),
|
||||||
|
);
|
||||||
|
expect(labels).toEqual(["Panel menu", "Maximize panel", "Close group"]);
|
||||||
|
await activate("assets");
|
||||||
|
expect(groupsWithActions()).toEqual([groupOf("assets")]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dock tab fill", () => {
|
||||||
|
it("is one element per strip, under the shown tab, and follows a switch", async () => {
|
||||||
|
for (const strip of host.querySelectorAll(".dv-tabs-container")) {
|
||||||
|
expect(strip.querySelectorAll(".hf-dock-tab-fill")).toHaveLength(1);
|
||||||
|
}
|
||||||
|
const fill = () => stripOf("design")?.querySelector<HTMLElement>(".hf-dock-tab-fill");
|
||||||
|
expect(fill()?.style.transform).toBe("translateX(0px)");
|
||||||
|
expect(fill()?.style.width).toBe(`${TAB_WIDTH}px`);
|
||||||
|
await activate("renders");
|
||||||
|
expect(stripOf("design")?.querySelectorAll(".hf-dock-tab-fill")).toHaveLength(1);
|
||||||
|
expect(fill()?.style.transform).toBe(`translateX(${2 * TAB_STEP}px)`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,27 +1,29 @@
|
|||||||
@import "dockview-react/dist/styles/dockview.css";
|
@import "dockview-react/dist/styles/dockview.css";
|
||||||
|
|
||||||
.hf-dock {
|
.hf-dock {
|
||||||
--dv-group-view-background-color: var(--color-panel-bg);
|
--dv-group-view-background-color: var(--panel-card-bg);
|
||||||
--dv-tabs-and-actions-container-background-color: var(--panel-strip-bg);
|
--dv-tabs-and-actions-container-background-color: var(--panel-strip-bg);
|
||||||
--dv-tabs-and-actions-container-height: 36px;
|
--dv-tabs-and-actions-container-height: 36px;
|
||||||
--dv-activegroup-visiblepanel-tab-background-color: var(--panel-tab-bg-active);
|
/* The fill paints the shown tab, so every tab state is transparent. */
|
||||||
|
--dv-activegroup-visiblepanel-tab-background-color: transparent;
|
||||||
--dv-activegroup-hiddenpanel-tab-background-color: transparent;
|
--dv-activegroup-hiddenpanel-tab-background-color: transparent;
|
||||||
--dv-inactivegroup-visiblepanel-tab-background-color: var(--panel-tab-bg-active-muted);
|
--dv-inactivegroup-visiblepanel-tab-background-color: transparent;
|
||||||
--dv-inactivegroup-hiddenpanel-tab-background-color: transparent;
|
--dv-inactivegroup-hiddenpanel-tab-background-color: transparent;
|
||||||
--dv-activegroup-visiblepanel-tab-color: var(--panel-tab-text-active);
|
--dv-activegroup-visiblepanel-tab-color: var(--color-text-1);
|
||||||
--dv-activegroup-hiddenpanel-tab-color: var(--panel-tab-text);
|
--dv-activegroup-hiddenpanel-tab-color: var(--color-text-2);
|
||||||
--dv-inactivegroup-visiblepanel-tab-color: var(--color-panel-text-2);
|
--dv-inactivegroup-visiblepanel-tab-color: var(--color-text-1);
|
||||||
--dv-inactivegroup-hiddenpanel-tab-color: var(--panel-tab-text);
|
--dv-inactivegroup-hiddenpanel-tab-color: var(--color-text-2);
|
||||||
--dv-tab-divider-color: var(--color-panel-border);
|
--dv-tab-divider-color: transparent;
|
||||||
--dv-separator-border: var(--color-panel-border);
|
--dv-tab-margin: 0;
|
||||||
--dv-paneview-header-border-color: var(--color-panel-border);
|
--dv-separator-border: transparent;
|
||||||
--dv-sash-color: var(--color-panel-hover);
|
--dv-paneview-header-border-color: transparent;
|
||||||
|
--dv-sash-color: transparent;
|
||||||
--dv-active-sash-color: var(--color-panel-accent);
|
--dv-active-sash-color: var(--color-panel-accent);
|
||||||
--dv-drag-over-background-color: color-mix(in srgb, var(--color-panel-accent) 12%, transparent);
|
--dv-drag-over-background-color: color-mix(in srgb, var(--color-panel-accent) 12%, transparent);
|
||||||
--dv-drag-over-border-color: var(--color-panel-accent);
|
--dv-drag-over-border-color: var(--color-panel-accent);
|
||||||
--dv-tab-font-size: 12px;
|
--dv-tab-font-size: 12.5px;
|
||||||
--dv-tabs-and-actions-container-font-size: 12px;
|
--dv-tabs-and-actions-container-font-size: 12.5px;
|
||||||
--dv-tab-margin: 0;
|
background-color: var(--panel-shell-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hf-dock .dv-sash:focus-visible {
|
.hf-dock .dv-sash:focus-visible {
|
||||||
@@ -29,162 +31,160 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hf-dock .dv-dockview {
|
||||||
|
background-color: var(--panel-shell-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards on the shell: 3px around each group's view makes a 6px gutter. The card owns the only border. */
|
||||||
|
.hf-dock .dv-view:has(> .dv-groupview) {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hf-dock .dv-groupview {
|
||||||
|
border: 1px solid var(--panel-card-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.hf-dock .dv-tabs-and-actions-container {
|
.hf-dock .dv-tabs-and-actions-container {
|
||||||
align-items: center;
|
align-items: flex-end;
|
||||||
height: 36px;
|
padding: 6px 0 0 6px;
|
||||||
padding: 4px 6px;
|
|
||||||
background-color: var(--panel-strip-bg);
|
|
||||||
border-bottom: 1px solid var(--panel-strip-border);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hf-dock .dv-tabs-container {
|
.hf-dock .dv-scrollable {
|
||||||
align-items: center;
|
height: 30px;
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dockview draws a 1px rule from --dv-tab-divider-color on every tab but the
|
|
||||||
first. The token also strokes menus, so the rule is removed here only. */
|
|
||||||
.hf-dock .dv-tabs-container .dv-tab::before {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hf-dock .dv-tab {
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
line-height: 1;
|
|
||||||
transition:
|
|
||||||
background-color 120ms ease-out,
|
|
||||||
color 120ms ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A lone preview or timeline tab is a pill, not a bar stretched across the header. */
|
|
||||||
.hf-dock
|
|
||||||
.dv-tabs-and-actions-container.dv-single-tab.dv-full-width-single-tab
|
|
||||||
.dv-tabs-container
|
|
||||||
.dv-tab {
|
|
||||||
flex-grow: 0;
|
|
||||||
width: auto;
|
|
||||||
padding: 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hf-dock .dv-tab:has(.dv-default-tab-action) {
|
|
||||||
padding-right: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hf-dock
|
|
||||||
.dv-groupview.dv-active-group
|
|
||||||
> .dv-tabs-and-actions-container
|
|
||||||
.dv-tabs-container
|
|
||||||
> .dv-tab.dv-inactive-tab:hover,
|
|
||||||
.hf-dock
|
|
||||||
.dv-groupview.dv-inactive-group
|
|
||||||
> .dv-tabs-and-actions-container
|
|
||||||
.dv-tabs-container
|
|
||||||
> .dv-tab.dv-inactive-tab:hover {
|
|
||||||
background-color: var(--panel-tab-bg-hover);
|
|
||||||
color: var(--color-panel-text-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hf-dock
|
|
||||||
.dv-groupview.dv-active-group
|
|
||||||
> .dv-tabs-and-actions-container
|
|
||||||
.dv-tabs-container
|
|
||||||
> .dv-tab.dv-active-tab {
|
|
||||||
background-image: linear-gradient(var(--panel-tab-indicator), var(--panel-tab-indicator));
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: center bottom;
|
|
||||||
background-size: 16px 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hf-dock
|
|
||||||
.dv-groupview.dv-inactive-group
|
|
||||||
> .dv-tabs-and-actions-container
|
|
||||||
.dv-tabs-container
|
|
||||||
> .dv-tab.dv-active-tab {
|
|
||||||
background-image: linear-gradient(
|
|
||||||
color-mix(in srgb, var(--panel-tab-indicator) 50%, transparent),
|
|
||||||
color-mix(in srgb, var(--panel-tab-indicator) 50%, transparent)
|
|
||||||
);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-position: center bottom;
|
|
||||||
background-size: 16px 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dockview's own focus ring is `outline: 1px solid var(--dv-tab-divider-color) !important`. */
|
|
||||||
.hf-dock .dv-tab:focus-visible::after,
|
|
||||||
.hf-dock .dv-tab:has(:focus-visible)::after {
|
|
||||||
outline: 1.5px solid var(--panel-tab-ring) !important;
|
|
||||||
outline-offset: -1.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hf-dock .dv-tab .dv-default-tab {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hf-dock .dv-tab .dv-default-tab .dv-default-tab-content {
|
|
||||||
flex-grow: 0;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 140px;
|
|
||||||
margin-right: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hf-dock .dv-tab .dv-default-tab .dv-default-tab-action {
|
/* dockview's own scrollbar lane; the strip scrolls by wheel and keeps the shown tab in view. */
|
||||||
position: relative;
|
.hf-dock .dv-scrollbar {
|
||||||
visibility: visible;
|
|
||||||
opacity: 0;
|
|
||||||
flex: 0 0 20px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hf-dock .dv-tab:hover .dv-default-tab .dv-default-tab-action,
|
|
||||||
.hf-dock .dv-tab:focus-within .dv-default-tab .dv-default-tab-action,
|
|
||||||
.hf-dock .dv-tab.dv-active-tab .dv-default-tab .dv-default-tab-action {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hf-dock .dv-tab .dv-default-tab .dv-default-tab-action:hover {
|
|
||||||
opacity: 1;
|
|
||||||
border-radius: 6px;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: none) {
|
|
||||||
.hf-dock .dv-tab .dv-default-tab .dv-default-tab-action {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Filled 11px glyph, replaced by a 12px stroke centered in the 20px target. */
|
|
||||||
.hf-dock .dv-default-tab-action .dv-svg {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hf-dock .dv-default-tab-action::before,
|
.hf-dock .dv-tabs-container {
|
||||||
.hf-dock .dv-default-tab-action::after {
|
gap: 2px;
|
||||||
content: "";
|
--hf-fade-start: 0px;
|
||||||
|
--hf-fade-end: 0px;
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
black var(--hf-fade-start),
|
||||||
|
black calc(100% - var(--hf-fade-end)),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hf-dock .dv-tabs-container[data-clipped~="start"] {
|
||||||
|
--hf-fade-start: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hf-dock .dv-tabs-container[data-clipped~="end"] {
|
||||||
|
--hf-fade-end: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hf-dock .dv-tab {
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* dockview's own state rule paints tabs from these variables, so hover sets them. */
|
||||||
|
.hf-dock .dv-tab.dv-inactive-tab:hover {
|
||||||
|
--dv-activegroup-hiddenpanel-tab-background-color: var(--panel-tab-bg-hover);
|
||||||
|
--dv-inactivegroup-hiddenpanel-tab-background-color: var(--panel-tab-bg-hover);
|
||||||
|
--dv-activegroup-hiddenpanel-tab-color: var(--color-text-1);
|
||||||
|
--dv-inactivegroup-hiddenpanel-tab-color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hf-dock .dv-tab:focus-visible {
|
||||||
|
outline: 1.5px solid var(--color-accent);
|
||||||
|
outline-offset: -1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hf-dock .dv-tab:focus-visible::after,
|
||||||
|
.hf-dock .dv-tab:has(:focus-visible)::after {
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hf-dock-tab-fill {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 4px;
|
bottom: 0;
|
||||||
top: 9.25px;
|
left: 0;
|
||||||
width: 12px;
|
height: 30px;
|
||||||
height: 1.5px;
|
border-radius: 6px 6px 0 0;
|
||||||
border-radius: 1px;
|
background-color: var(--panel-card-bg);
|
||||||
background: currentColor;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hf-dock .dv-default-tab-action::before {
|
.hf-dock-tab-fill[data-ready] {
|
||||||
transform: rotate(45deg);
|
transition:
|
||||||
|
transform 140ms ease-out,
|
||||||
|
width 140ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hf-dock .dv-default-tab-action::after {
|
.hf-dock-tab {
|
||||||
transform: rotate(-45deg);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hf-dock-tab-icon {
|
||||||
|
flex: none;
|
||||||
|
animation: hf-dock-tab-icon-in 80ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hf-dock-tab-icon-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Takes its space whenever the tab is shown, so hover never moves the label. */
|
||||||
|
.hf-dock-tab-close {
|
||||||
|
display: flex;
|
||||||
|
margin-left: 2px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-tab:hover .hf-dock-tab-close,
|
||||||
|
.dv-tab:focus-visible .hf-dock-tab-close {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reserved on every strip so tabs never slide under the actions when focus changes group. */
|
||||||
|
.hf-dock .dv-right-actions-container {
|
||||||
|
flex: none;
|
||||||
|
align-self: flex-start;
|
||||||
|
width: 72px;
|
||||||
|
height: 24px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hf-dock-strip-actions {
|
||||||
|
display: flex;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dv-groupview:hover .hf-dock-strip-actions {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hf-dock-strip-actions:hover,
|
||||||
|
.hf-dock-strip-actions:focus-within {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hf-dock-tab-fill[data-ready] {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hf-dock-tab-icon {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,21 +163,4 @@ describe("dock tab strips", () => {
|
|||||||
press(tabs[3] as HTMLElement, "Home");
|
press(tabs[3] as HTMLElement, "Home");
|
||||||
expect(api.getPanel("design")?.group.activePanel?.id).toBe("design");
|
expect(api.getPanel("design")?.group.activePanel?.id).toBe("design");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("names each closable tab's close control after that panel", () => {
|
|
||||||
const tabs = tabsOf("design");
|
|
||||||
const labels = tabs.map((tab) =>
|
|
||||||
tab.querySelector(".dv-default-tab-action")?.getAttribute("aria-label"),
|
|
||||||
);
|
|
||||||
expect(labels).toEqual(["Close Design", "Close Layers", "Close Renders", "Close Variables"]);
|
|
||||||
expect(tabs[0]?.getAttribute("aria-selected")).toBe("true");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renames the close control when the panel title changes", () => {
|
|
||||||
api.getPanel("renders")?.api.setTitle("Renders (2)");
|
|
||||||
const renders = tabsOf("design").find((tab) => tab.dataset.tabPanelId === "renders");
|
|
||||||
expect(renders?.querySelector(".dv-default-tab-action")?.getAttribute("aria-label")).toBe(
|
|
||||||
"Close Renders (2)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,49 +74,16 @@ function onTabKeyDown(event: KeyboardEvent, tab: HTMLElement, api: DockviewApi)
|
|||||||
target.focus();
|
target.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dockview's default close control says "Close tab". Name the panel. */
|
|
||||||
function labelCloseButtons(root: HTMLElement) {
|
|
||||||
for (const button of root.querySelectorAll<HTMLButtonElement>(".dv-default-tab-action")) {
|
|
||||||
const title = button
|
|
||||||
.closest(".dv-default-tab")
|
|
||||||
?.querySelector(".dv-default-tab-content")
|
|
||||||
?.textContent?.trim();
|
|
||||||
if (!title) continue;
|
|
||||||
const label = `Close ${title}`;
|
|
||||||
if (button.getAttribute("aria-label") !== label) button.setAttribute("aria-label", label);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keyboard and ARIA support dockview 8.3 lacks for sashes and tab arrows. Returns a disposer. */
|
/** Keyboard and ARIA support dockview 8.3 lacks for sashes and tab arrows. Returns a disposer. */
|
||||||
export function installDockAccessibility(api: DockviewApi, root: HTMLElement): () => void {
|
export function installDockAccessibility(api: DockviewApi, root: HTMLElement): () => void {
|
||||||
const titleDisposers = new Map<string, { dispose: () => void }>();
|
const decorate = () => decorateSashes(root);
|
||||||
const watchTitles = () => {
|
decorate();
|
||||||
const live = new Set(api.panels.map((panel) => panel.id));
|
|
||||||
for (const [id, disposer] of titleDisposers) {
|
|
||||||
if (live.has(id)) continue;
|
|
||||||
disposer.dispose();
|
|
||||||
titleDisposers.delete(id);
|
|
||||||
}
|
|
||||||
for (const panel of api.panels) {
|
|
||||||
if (titleDisposers.has(panel.id)) continue;
|
|
||||||
titleDisposers.set(
|
|
||||||
panel.id,
|
|
||||||
panel.api.onDidTitleChange(() => labelCloseButtons(root)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const decorate = () => {
|
|
||||||
decorateSashes(root);
|
|
||||||
labelCloseButtons(root);
|
|
||||||
watchTitles();
|
|
||||||
};
|
|
||||||
const subscriptions = [
|
const subscriptions = [
|
||||||
api.onDidLayoutChange(decorate),
|
api.onDidLayoutChange(decorate),
|
||||||
api.onDidLayoutFromJSON(decorate),
|
api.onDidLayoutFromJSON(decorate),
|
||||||
api.onDidAddPanel(decorate),
|
api.onDidAddPanel(decorate),
|
||||||
api.onDidRemovePanel(decorate),
|
api.onDidRemovePanel(decorate),
|
||||||
];
|
];
|
||||||
decorate();
|
|
||||||
// Capture phase so the tab handler runs before dockview's own focus-only arrows.
|
// Capture phase so the tab handler runs before dockview's own focus-only arrows.
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (!(event.target instanceof HTMLElement)) return;
|
if (!(event.target instanceof HTMLElement)) return;
|
||||||
@@ -127,7 +94,5 @@ export function installDockAccessibility(api: DockviewApi, root: HTMLElement): (
|
|||||||
return () => {
|
return () => {
|
||||||
root.removeEventListener("keydown", onKeyDown, true);
|
root.removeEventListener("keydown", onKeyDown, true);
|
||||||
for (const subscription of subscriptions) subscription.dispose();
|
for (const subscription of subscriptions) subscription.dispose();
|
||||||
for (const disposer of titleDisposers.values()) disposer.dispose();
|
|
||||||
titleDisposers.clear();
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { DockviewApi } from "dockview-react";
|
||||||
|
|
||||||
|
const TAB_FILL_CLASS = "hf-dock-tab-fill";
|
||||||
|
|
||||||
|
/** The strip's one fill, created on first use. Dockview inserts tabs before tabs, so it stays first. */
|
||||||
|
function fillOf(list: HTMLElement): HTMLElement {
|
||||||
|
const existing = list.querySelector<HTMLElement>(`:scope > .${TAB_FILL_CLASS}`);
|
||||||
|
if (existing) return existing;
|
||||||
|
const fill = document.createElement("span");
|
||||||
|
fill.className = TAB_FILL_CLASS;
|
||||||
|
fill.setAttribute("aria-hidden", "true");
|
||||||
|
list.prepend(fill);
|
||||||
|
// Placed once without motion; only later moves slide.
|
||||||
|
requestAnimationFrame(() => fill.setAttribute("data-ready", ""));
|
||||||
|
return fill;
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeFill(list: HTMLElement) {
|
||||||
|
const fill = fillOf(list);
|
||||||
|
const tab = list.querySelector<HTMLElement>(":scope > .dv-active-tab");
|
||||||
|
fill.hidden = !tab;
|
||||||
|
if (!tab) return;
|
||||||
|
fill.style.width = `${tab.offsetWidth}px`;
|
||||||
|
fill.style.transform = `translateX(${tab.offsetLeft}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks which edges of a scrolled strip are clipped, for the CSS edge fade. */
|
||||||
|
function markClippedEdges(list: HTMLElement) {
|
||||||
|
const edges = [];
|
||||||
|
if (list.scrollLeft > 0) edges.push("start");
|
||||||
|
if (list.scrollLeft + list.clientWidth < list.scrollWidth - 1) edges.push("end");
|
||||||
|
const value = edges.join(" ");
|
||||||
|
if (value) list.setAttribute("data-clipped", value);
|
||||||
|
else list.removeAttribute("data-clipped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slides one fill per tab strip under its shown tab, and keeps the clipped-edge marks current.
|
||||||
|
* Returns a disposer.
|
||||||
|
*/
|
||||||
|
export function installTabFill(api: DockviewApi, root: HTMLElement): () => void {
|
||||||
|
const lists = () => root.querySelectorAll<HTMLElement>(".dv-tabs-container");
|
||||||
|
// Tab widths change when a tab gains or loses its icon, after React renders it.
|
||||||
|
const resizeObserver = new ResizeObserver(() => placeAll());
|
||||||
|
function placeAll() {
|
||||||
|
for (const list of lists()) {
|
||||||
|
placeFill(list);
|
||||||
|
markClippedEdges(list);
|
||||||
|
resizeObserver.observe(list);
|
||||||
|
for (const tab of list.querySelectorAll(":scope > .dv-tab")) resizeObserver.observe(tab);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onScroll = (event: Event) => {
|
||||||
|
const list = event.target;
|
||||||
|
if (list instanceof HTMLElement && list.classList.contains("dv-tabs-container")) {
|
||||||
|
markClippedEdges(list);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const subscriptions = [
|
||||||
|
api.onDidActivePanelChange(placeAll),
|
||||||
|
api.onDidAddPanel(placeAll),
|
||||||
|
api.onDidRemovePanel(placeAll),
|
||||||
|
api.onDidMovePanel(placeAll),
|
||||||
|
api.onDidLayoutChange(placeAll),
|
||||||
|
api.onDidLayoutFromJSON(placeAll),
|
||||||
|
];
|
||||||
|
// Scroll does not bubble; a capturing listener still sees every strip's.
|
||||||
|
root.addEventListener("scroll", onScroll, true);
|
||||||
|
placeAll();
|
||||||
|
return () => {
|
||||||
|
root.removeEventListener("scroll", onScroll, true);
|
||||||
|
for (const subscription of subscriptions) subscription.dispose();
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,26 +2,73 @@ import { readFileSync } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const dockCss = readFileSync(path.join(import.meta.dirname, "dock.css"), "utf8");
|
const dockCss = readFileSync(path.join(import.meta.dirname, "dock.css"), "utf8").replace(
|
||||||
const themeCss = readFileSync(path.join(import.meta.dirname, "../../styles/theme.css"), "utf8");
|
/@import[^;]*;/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
describe("panel tab contract", () => {
|
/** Each top-level rule's selector and body, with the reduced-motion block kept whole. */
|
||||||
it("paints selection, hover and focus from the panel-tab tokens", () => {
|
function rules(css: string): { selector: string; body: string }[] {
|
||||||
expect(dockCss).toContain(
|
const found: { selector: string; body: string }[] = [];
|
||||||
"--dv-activegroup-visiblepanel-tab-background-color: var(--panel-tab-bg-active)",
|
let depth = 0;
|
||||||
);
|
let start = 0;
|
||||||
expect(dockCss).toContain("background-color: var(--panel-tab-bg-hover)");
|
let selector = "";
|
||||||
expect(dockCss).toContain("outline: 1.5px solid var(--panel-tab-ring)");
|
for (let index = 0; index < css.length; index++) {
|
||||||
expect(dockCss).toContain("background-size: 16px 2px");
|
if (css[index] === "{") {
|
||||||
expect(dockCss).not.toMatch(/#[0-9a-f]{3,8}|rgba?\(/i);
|
if (depth === 0) {
|
||||||
|
selector = css
|
||||||
|
.slice(start, index)
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||||
|
.trim();
|
||||||
|
start = index + 1;
|
||||||
|
}
|
||||||
|
depth++;
|
||||||
|
} else if (css[index] === "}") {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) {
|
||||||
|
found.push({ selector, body: css.slice(start, index) });
|
||||||
|
start = index + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyOf = (selector: string) =>
|
||||||
|
rules(dockCss).find((rule) => rule.selector === selector)?.body;
|
||||||
|
|
||||||
|
describe("dock panel strip contract", () => {
|
||||||
|
it("draws one border per surface: the card's, and no divider, separator or tab outline", () => {
|
||||||
|
const bordered = rules(dockCss)
|
||||||
|
.filter((rule) => /(^|[\s;{])border(-(top|right|bottom|left))?\s*:/.test(rule.body))
|
||||||
|
.map((rule) => rule.selector);
|
||||||
|
expect(bordered).toEqual([".hf-dock .dv-groupview"]);
|
||||||
|
const dock = bodyOf(".hf-dock");
|
||||||
|
expect(dock).toContain("--dv-tab-divider-color: transparent");
|
||||||
|
expect(dock).toContain("--dv-separator-border: transparent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps one dark value per tab token and documents the paper host override", () => {
|
it("slides only transform and width, 140ms ease-out, and reveals the icon in 80ms", () => {
|
||||||
expect(themeCss).toContain("--panel-tab-bg-active: rgba(255, 255, 255, 0.08)");
|
expect(bodyOf(".hf-dock-tab-fill[data-ready]")?.replace(/\s+/g, " ").trim()).toBe(
|
||||||
expect(themeCss).toContain("--panel-tab-bg-active-muted: rgba(255, 255, 255, 0.04)");
|
"transition: transform 140ms ease-out, width 140ms ease-out;",
|
||||||
expect(themeCss).toContain("active rgba(0, 0, 0, 0.06)");
|
);
|
||||||
expect(themeCss).toContain("hover rgba(0, 0, 0, 0.035)");
|
expect(bodyOf(".hf-dock-tab-icon")).toContain("animation: hf-dock-tab-icon-in 80ms ease-out");
|
||||||
expect(themeCss).not.toContain("[data-theme");
|
const animated = rules(dockCss).filter(
|
||||||
expect(themeCss).not.toContain("[data-chrome");
|
(rule) => !rule.selector.startsWith("@") && /transition|animation/.test(rule.body),
|
||||||
|
);
|
||||||
|
expect(animated.map((rule) => rule.selector)).toEqual([
|
||||||
|
".hf-dock-tab-fill[data-ready]",
|
||||||
|
".hf-dock-tab-icon",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns the slide and the icon reveal off under reduced motion", () => {
|
||||||
|
const reduced = bodyOf("@media (prefers-reduced-motion: reduce)")?.replace(/\s+/g, " ");
|
||||||
|
expect(reduced).toContain(".hf-dock-tab-fill[data-ready] { transition: none; }");
|
||||||
|
expect(reduced).toContain(".hf-dock-tab-icon { animation: none; }");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes every colour from a token", () => {
|
||||||
|
expect(dockCss).not.toMatch(/#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(|oklch\(/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -115,10 +115,10 @@ export function PreviewPane({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
// Panel chrome (rounded border) is dropped in fullscreen so the preview
|
// A recessed well inset in its dock card; dropped in fullscreen so the
|
||||||
// fills the screen edge-to-edge.
|
// preview fills the screen edge-to-edge.
|
||||||
className={`flex-1 min-h-0 flex flex-col overflow-hidden bg-[var(--studio-preview-bg,var(--color-neutral-950))] ${
|
className={`flex-1 min-h-0 flex flex-col overflow-hidden bg-[var(--studio-preview-bg,var(--color-neutral-950))] ${
|
||||||
isFullscreen ? "" : "rounded-lg border border-neutral-800/50"
|
isFullscreen ? "" : "m-3 rounded-md border border-neutral-800/50"
|
||||||
}`}
|
}`}
|
||||||
data-studio-fullscreen-target=""
|
data-studio-fullscreen-target=""
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ export function TimelinePane({
|
|||||||
aria-disabled={timelineDisabled || undefined}
|
aria-disabled={timelineDisabled || undefined}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex flex-col flex-1 min-h-0 overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
|
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
||||||
if (timelineDisabled) return;
|
if (timelineDisabled) return;
|
||||||
|
|||||||
@@ -220,6 +220,22 @@ export function MenuRadioItem({ className, children, ...props }: MenuRadioItemPr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An on/off row; the tick renders only while `checked`. */
|
||||||
|
export function MenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: StyledProps<typeof BaseMenu.CheckboxItem>) {
|
||||||
|
return (
|
||||||
|
<BaseMenu.CheckboxItem className={cn(itemBase, className)} {...props}>
|
||||||
|
<span className="truncate">{children}</span>
|
||||||
|
<span className="flex size-3 shrink-0 items-center justify-center text-accent">
|
||||||
|
<BaseMenu.CheckboxItemIndicator>✓</BaseMenu.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
</BaseMenu.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** A hairline between two groups of items. */
|
/** A hairline between two groups of items. */
|
||||||
export function MenuSeparator({ className, ...props }: StyledProps<typeof BaseMenu.Separator>) {
|
export function MenuSeparator({ className, ...props }: StyledProps<typeof BaseMenu.Separator>) {
|
||||||
return <BaseMenu.Separator className={cn("my-1 h-px bg-hairline", className)} {...props} />;
|
return <BaseMenu.Separator className={cn("my-1 h-px bg-hairline", className)} {...props} />;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export { Tooltip } from "./Tooltip";
|
|||||||
export {
|
export {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
Menu,
|
Menu,
|
||||||
|
MenuCheckboxItem,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
MenuRadioGroup,
|
MenuRadioGroup,
|
||||||
MenuRadioItem,
|
MenuRadioItem,
|
||||||
|
|||||||
@@ -270,18 +270,13 @@
|
|||||||
--color-panel-media: var(--color-media);
|
--color-panel-media: var(--color-media);
|
||||||
--color-panel-container: var(--color-container);
|
--color-panel-container: var(--color-container);
|
||||||
|
|
||||||
/* panel tabs. Dark values. A paper host overrides these same names:
|
/* Dock panels are cards on the shell. The card owns the only border; its tab
|
||||||
strip #f5f5f4, border #18181b14, active rgba(0, 0, 0, 0.06),
|
* strip sits below the card body and the shown tab is a fill in the body colour. */
|
||||||
muted rgba(0, 0, 0, 0.03), hover rgba(0, 0, 0, 0.035), text #18181b / #71717a. */
|
--panel-shell-bg: var(--color-bg-1);
|
||||||
--panel-strip-bg: var(--color-panel-surface);
|
--panel-card-bg: var(--color-surface);
|
||||||
--panel-strip-border: var(--color-panel-border);
|
--panel-card-border: #303034;
|
||||||
--panel-tab-bg-active: rgba(255, 255, 255, 0.08);
|
--panel-strip-bg: var(--color-bg-0);
|
||||||
--panel-tab-bg-active-muted: rgba(255, 255, 255, 0.04);
|
--panel-tab-bg-hover: #202023;
|
||||||
--panel-tab-bg-hover: rgba(255, 255, 255, 0.04);
|
|
||||||
--panel-tab-text-active: var(--color-text-1);
|
|
||||||
--panel-tab-text: var(--color-text-3);
|
|
||||||
--panel-tab-indicator: var(--color-accent);
|
|
||||||
--panel-tab-ring: var(--color-accent);
|
|
||||||
|
|
||||||
/* Names Studio's markup invented that no config ever defined, so the classes
|
/* Names Studio's markup invented that no config ever defined, so the classes
|
||||||
* compiled to nothing. Aliased to the token that was meant, rather than
|
* compiled to nothing. Aliased to the token that was meant, rather than
|
||||||
|
|||||||
@@ -270,6 +270,39 @@
|
|||||||
],
|
],
|
||||||
"opacity": 0.64,
|
"opacity": 0.64,
|
||||||
"format": "rgb-channels"
|
"format": "rgb-channels"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dock-tab-label",
|
||||||
|
"foreground": "--color-text-2",
|
||||||
|
"background": ["--panel-strip-bg"],
|
||||||
|
"role": "text",
|
||||||
|
"minimum": 4.5,
|
||||||
|
"sources": [
|
||||||
|
"packages/studio/src/components/dock/dock.css:13",
|
||||||
|
"packages/studio/src/components/dock/dock.css:15"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dock-shown-tab-label",
|
||||||
|
"foreground": "--color-text-1",
|
||||||
|
"background": ["--panel-card-bg"],
|
||||||
|
"role": "text",
|
||||||
|
"minimum": 4.5,
|
||||||
|
"sources": [
|
||||||
|
"packages/studio/src/components/dock/dock.css:12",
|
||||||
|
"packages/studio/src/components/dock/dock.css:14"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dock-hovered-tab-label",
|
||||||
|
"foreground": "--color-text-1",
|
||||||
|
"background": ["--panel-strip-bg", "--panel-tab-bg-hover"],
|
||||||
|
"role": "text",
|
||||||
|
"minimum": 4.5,
|
||||||
|
"sources": [
|
||||||
|
"packages/studio/src/components/dock/dock.css:97",
|
||||||
|
"packages/studio/src/components/dock/dock.css:99"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user