fix(studio): swap the tab icon in the same frame as the tab

This commit is contained in:
Miguel Ángel
2026-09-23 00:49:07 -04:00
parent 4130277321
commit eefaa839e4
4 changed files with 47 additions and 32 deletions
+21 -23
View File
@@ -31,40 +31,38 @@ const TAB_ICONS: Record<PanelId, Icon> = {
slideshow: Presentation, 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) { export function DockTab({ api }: IDockviewPanelHeaderProps) {
const subscribe = useCallback( const subscribe = useCallback(
(onChange: () => void) => { (onChange: () => void) => {
const subscriptions = [api.onDidTitleChange(onChange), api.onDidVisibilityChange(onChange)]; const subscription = api.onDidTitleChange(onChange);
return () => { return () => subscription.dispose();
for (const subscription of subscriptions) subscription.dispose();
};
}, },
[api], [api],
); );
const title = useSyncExternalStore(subscribe, () => api.title ?? ""); const title = useSyncExternalStore(subscribe, () => api.title ?? "");
const shown = useSyncExternalStore(subscribe, () => api.isVisible); const TypeIcon = isPanelId(api.id) ? TAB_ICONS[api.id] : null;
const TypeIcon = shown && isPanelId(api.id) ? TAB_ICONS[api.id] : null;
return ( return (
<div className="hf-dock-tab"> <div className="hf-dock-tab">
{TypeIcon ? <TypeIcon className="hf-dock-tab-icon" size={14} aria-hidden /> : null} {TypeIcon ? <TypeIcon className="hf-dock-tab-icon" size={14} aria-hidden /> : null}
<span className="hf-dock-tab-label">{title}</span> <span className="hf-dock-tab-label">{title}</span>
{shown ? ( {/* Same shape as dockview's own close control: a tab cannot hold a focusable button. */}
// Same shape as dockview's own close control: a tab cannot hold a focusable button. <div
<div role="button"
role="button" tabIndex={-1}
tabIndex={-1} aria-label={`Close ${title}`}
aria-label={`Close ${title}`} className="hf-dock-tab-close"
className="hf-dock-tab-close" onPointerDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => {
onClick={(event) => { event.stopPropagation();
event.stopPropagation(); api.close();
api.close(); }}
}} >
> <X size={12} aria-hidden />
<X size={12} aria-hidden /> </div>
</div>
) : null}
</div> </div>
); );
} }
@@ -1,5 +1,7 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import path from "node:path";
import { act } from "react"; import { act } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
@@ -40,6 +42,13 @@ const original = {
offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"), offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"),
}; };
beforeAll(() => { 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, { Object.defineProperties(HTMLElement.prototype, {
offsetLeft: { ...layout.offsetLeft, configurable: true }, offsetLeft: { ...layout.offsetLeft, configurable: true },
offsetWidth: { ...layout.offsetWidth, configurable: true }, offsetWidth: { ...layout.offsetWidth, configurable: true },
@@ -83,6 +92,10 @@ afterEach(() => {
const tab = (id: PanelId) => host.querySelector<HTMLElement>(`[data-tab-panel-id="${id}"]`); const tab = (id: PanelId) => host.querySelector<HTMLElement>(`[data-tab-panel-id="${id}"]`);
const stripOf = (id: PanelId) => tab(id)?.closest<HTMLElement>(".dv-tabs-container"); const stripOf = (id: PanelId) => tab(id)?.closest<HTMLElement>(".dv-tabs-container");
const groupOf = (id: PanelId) => tab(id)?.closest<HTMLElement>(".dv-groupview"); const groupOf = (id: PanelId) => tab(id)?.closest<HTMLElement>(".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) { async function activate(id: PanelId) {
// dockview settles the active panel on a microtask; `act(async)` waits for it. // 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", () => { describe("dock tabs", () => {
it("draw the type icon and close glyph on the shown tab only", () => { it("draw the type icon and close glyph on the shown tab only", () => {
for (const id of ["design", "compositions"] as const) { for (const id of ["design", "compositions"] as const) {
expect(tab(id)?.querySelector(".hf-dock-tab-icon")).not.toBeNull(); expect(shows(id, "icon")).toBe(true);
expect(tab(id)?.querySelector(".hf-dock-tab-close")).not.toBeNull(); expect(shows(id, "close")).toBe(true);
} }
for (const id of ["layers", "renders", "variables", "assets", "code", "catalog"] as const) { for (const id of ["layers", "renders", "variables", "assets", "code", "catalog"] as const) {
expect(tab(id)?.querySelector(".hf-dock-tab-icon")).toBeNull(); expect(shows(id, "icon")).toBe(false);
expect(tab(id)?.querySelector(".hf-dock-tab-close")).toBeNull(); expect(shows(id, "close")).toBe(false);
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 () => { it("move the icon to the newly shown tab and drop it from the old one", async () => {
await activate("layers"); await activate("layers");
expect(tab("layers")?.querySelector(".hf-dock-tab-icon")).not.toBeNull(); expect(shows("layers", "icon")).toBe(true);
expect(tab("design")?.querySelector(".hf-dock-tab-icon")).toBeNull(); expect(shows("design", "icon")).toBe(false);
expect(tab("design")?.querySelector(".hf-dock-tab-close")).toBeNull(); expect(shows("design", "close")).toBe(false);
}); });
it("name the close control after the panel, and close only that panel", async () => { it("name the close control after the panel, and close only that panel", async () => {
@@ -136,6 +136,11 @@
gap: 6px; 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 { .hf-dock-tab-icon {
flex: none; flex: none;
animation: hf-dock-tab-icon-in 80ms ease-out; animation: hf-dock-tab-icon-in 80ms ease-out;
@@ -44,7 +44,7 @@ function markClippedEdges(list: HTMLElement) {
*/ */
export function installTabFill(api: DockviewApi, root: HTMLElement): () => void { export function installTabFill(api: DockviewApi, root: HTMLElement): () => void {
const lists = () => root.querySelectorAll<HTMLElement>(".dv-tabs-container"); const lists = () => root.querySelectorAll<HTMLElement>(".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 resizeObserver = new ResizeObserver(() => placeAll());
const observed = new Set<Element>(); const observed = new Set<Element>();
function observe(elements: Iterable<Element>) { function observe(elements: Iterable<Element>) {