diff --git a/packages/studio/src/components/dock/DockTab.tsx b/packages/studio/src/components/dock/DockTab.tsx index 6b46cc014f..2de514c929 100644 --- a/packages/studio/src/components/dock/DockTab.tsx +++ b/packages/studio/src/components/dock/DockTab.tsx @@ -31,40 +31,38 @@ const TAB_ICONS: Record = { slideshow: Presentation, }; -/** A dock tab. Only the shown tab carries its type icon and close glyph; the rest are labels. */ +/** + * A dock tab. dock.css shows the type icon and close glyph on the shown tab only, keyed on + * dockview's own tab class, so they swap in the frame the tab changes rather than a render later. + */ export function DockTab({ api }: IDockviewPanelHeaderProps) { const subscribe = useCallback( (onChange: () => void) => { - const subscriptions = [api.onDidTitleChange(onChange), api.onDidVisibilityChange(onChange)]; - return () => { - for (const subscription of subscriptions) subscription.dispose(); - }; + const subscription = api.onDidTitleChange(onChange); + return () => subscription.dispose(); }, [api], ); const title = useSyncExternalStore(subscribe, () => api.title ?? ""); - const shown = useSyncExternalStore(subscribe, () => api.isVisible); - const TypeIcon = shown && isPanelId(api.id) ? TAB_ICONS[api.id] : null; + const TypeIcon = isPanelId(api.id) ? TAB_ICONS[api.id] : null; return (
{TypeIcon ? : null} {title} - {shown ? ( - // Same shape as dockview's own close control: a tab cannot hold a focusable button. -
event.stopPropagation()} - onClick={(event) => { - event.stopPropagation(); - api.close(); - }} - > - -
- ) : null} + {/* Same shape as dockview's own close control: a tab cannot hold a focusable button. */} +
event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + api.close(); + }} + > + +
); } diff --git a/packages/studio/src/components/dock/DockTabs.test.tsx b/packages/studio/src/components/dock/DockTabs.test.tsx index 3c98d3c5eb..0a9ae5cd51 100644 --- a/packages/studio/src/components/dock/DockTabs.test.tsx +++ b/packages/studio/src/components/dock/DockTabs.test.tsx @@ -1,5 +1,7 @@ // @vitest-environment happy-dom +import { readFileSync } from "node:fs"; +import path from "node:path"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -40,6 +42,13 @@ const original = { offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"), }; beforeAll(() => { + // The shown-tab-only rule lives in dock.css; apply it so computed display is what a user sees. + const style = document.createElement("style"); + style.textContent = readFileSync(path.join(import.meta.dirname, "dock.css"), "utf8").replace( + /@import[^;]*;/, + "", + ); + document.head.append(style); Object.defineProperties(HTMLElement.prototype, { offsetLeft: { ...layout.offsetLeft, configurable: true }, offsetWidth: { ...layout.offsetWidth, configurable: true }, @@ -83,6 +92,10 @@ afterEach(() => { const tab = (id: PanelId) => host.querySelector(`[data-tab-panel-id="${id}"]`); const stripOf = (id: PanelId) => tab(id)?.closest(".dv-tabs-container"); const groupOf = (id: PanelId) => tab(id)?.closest(".dv-groupview"); +const shows = (id: PanelId, part: "icon" | "close") => { + const element = tab(id)?.querySelector(`.hf-dock-tab-${part}`); + return element ? getComputedStyle(element).display !== "none" : false; +}; async function activate(id: PanelId) { // dockview settles the active panel on a microtask; `act(async)` waits for it. @@ -92,21 +105,20 @@ async function activate(id: PanelId) { 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(); + expect(shows(id, "icon")).toBe(true); + expect(shows(id, "close")).toBe(true); } 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")); + expect(shows(id, "icon")).toBe(false); + expect(shows(id, "close")).toBe(false); } }); 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(); + expect(shows("layers", "icon")).toBe(true); + expect(shows("design", "icon")).toBe(false); + expect(shows("design", "close")).toBe(false); }); it("name the close control after the panel, and close only that panel", async () => { diff --git a/packages/studio/src/components/dock/dock.css b/packages/studio/src/components/dock/dock.css index f6ee3e4ef6..04ee18b97c 100644 --- a/packages/studio/src/components/dock/dock.css +++ b/packages/studio/src/components/dock/dock.css @@ -136,6 +136,11 @@ gap: 6px; } +.hf-dock .dv-inactive-tab .hf-dock-tab-icon, +.hf-dock .dv-inactive-tab .hf-dock-tab-close { + display: none; +} + .hf-dock-tab-icon { flex: none; animation: hf-dock-tab-icon-in 80ms ease-out; diff --git a/packages/studio/src/components/dock/dockTabFill.ts b/packages/studio/src/components/dock/dockTabFill.ts index b1f6d82927..ebb1e0e03f 100644 --- a/packages/studio/src/components/dock/dockTabFill.ts +++ b/packages/studio/src/components/dock/dockTabFill.ts @@ -44,7 +44,7 @@ function markClippedEdges(list: HTMLElement) { */ export function installTabFill(api: DockviewApi, root: HTMLElement): () => void { const lists = () => root.querySelectorAll(".dv-tabs-container"); - // Tab widths change when a tab gains or loses its icon, after React renders it. + // Tab widths also change with no dockview event: a renamed title, a late web font. const resizeObserver = new ResizeObserver(() => placeAll()); const observed = new Set(); function observe(elements: Iterable) {