diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx index 64ec21aa4f..f0380125b9 100644 --- a/packages/studio/src/components/EditorShell.tsx +++ b/packages/studio/src/components/EditorShell.tsx @@ -286,9 +286,7 @@ function EditorShellBody({ return (
diff --git a/packages/studio/src/components/StudioLeftPanels.tsx b/packages/studio/src/components/StudioLeftPanels.tsx index 96d80b356a..f1a55d0cc8 100644 --- a/packages/studio/src/components/StudioLeftPanels.tsx +++ b/packages/studio/src/components/StudioLeftPanels.tsx @@ -28,7 +28,7 @@ interface StudioLeftPanelsProps { function PanelColumn({ footer, children }: { footer: ReactNode; children: ReactNode }) { return ( -
+
{children}
{footer}
diff --git a/packages/studio/src/components/dock/Dock.tsx b/packages/studio/src/components/dock/Dock.tsx index 022ae80af3..842045a62e 100644 --- a/packages/studio/src/components/dock/Dock.tsx +++ b/packages/studio/src/components/dock/Dock.tsx @@ -16,6 +16,9 @@ import { } from "dockview-react"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; import { installDockAccessibility } from "./dockAccessibility"; +import { DockStripActions } from "./DockStripActions"; +import { DockTab } from "./DockTab"; +import { installTabFill } from "./dockTabFill"; import { addRegisteredPanel, applySideMinimums, buildEditLayout } from "./dockLayout"; import { DOCK_PANEL_COMPONENT } from "./dockLayoutSchema"; import { useDockLayoutStore, type DockController, type DockSnapshot } from "./dockLayoutStore"; @@ -150,6 +153,7 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea applySideMinimums(api); const root = api.groups[0]?.element.closest(".hf-dock"); 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. const resizeObserver = new ResizeObserver(() => applySideMinimums(api, window.innerWidth)); if (root) resizeObserver.observe(root); @@ -189,6 +193,7 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea clearTimeout(timer); for (const subscription of subscriptions) subscription.dispose(); disposeAccessibility(); + disposeTabFill(); resizeObserver.disconnect(); useDockLayoutStore.getState().detach(); }; @@ -202,8 +207,11 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea key={projectId ?? ""} className="hf-dock flex-1 min-h-0" components={COMPONENTS} + defaultTabComponent={DockTab} + rightHeaderActionsComponent={DockStripActions} defaultRenderer="always" disableFloatingGroups + disableTabsOverflowList onReady={onReady} /> {children} diff --git a/packages/studio/src/components/dock/DockStripActions.tsx b/packages/studio/src/components/dock/DockStripActions.tsx new file mode 100644 index 0000000000..f3d2401291 --- /dev/null +++ b/packages/studio/src/components/dock/DockStripActions.tsx @@ -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 ( +
+ } />} + > + {menuPanels.map((id) => ( + togglePanel(id)} + > + {PANEL_DEFINITIONS[id].title} + + ))} + + Reset layout + + : } + onClick={() => (maximized ? api.exitMaximized() : api.maximize())} + /> + } + onClick={() => api.close()} + /> +
+ ); +} diff --git a/packages/studio/src/components/dock/DockTab.tsx b/packages/studio/src/components/dock/DockTab.tsx new file mode 100644 index 0000000000..439086a387 --- /dev/null +++ b/packages/studio/src/components/dock/DockTab.tsx @@ -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 = { + 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 ( +
+ {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} +
+ ); +} diff --git a/packages/studio/src/components/dock/DockTabs.test.tsx b/packages/studio/src/components/dock/DockTabs.test.tsx new file mode 100644 index 0000000000..3c98d3c5eb --- /dev/null +++ b/packages/studio/src/components/dock/DockTabs.test.tsx @@ -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( + + {PANEL_IDS.map((id) => ( + +
{id}
+
+ ))} +
, + ); + }); +}); + +afterEach(() => { + act(() => root?.unmount()); + root = null; + document.body.innerHTML = ""; + vi.useRealTimers(); +}); + +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"); + +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(".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(".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)`); + }); +}); diff --git a/packages/studio/src/components/dock/dock.css b/packages/studio/src/components/dock/dock.css index eeb334ecff..092a755051 100644 --- a/packages/studio/src/components/dock/dock.css +++ b/packages/studio/src/components/dock/dock.css @@ -1,27 +1,29 @@ @import "dockview-react/dist/styles/dockview.css"; .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-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-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-activegroup-visiblepanel-tab-color: var(--panel-tab-text-active); - --dv-activegroup-hiddenpanel-tab-color: var(--panel-tab-text); - --dv-inactivegroup-visiblepanel-tab-color: var(--color-panel-text-2); - --dv-inactivegroup-hiddenpanel-tab-color: var(--panel-tab-text); - --dv-tab-divider-color: var(--color-panel-border); - --dv-separator-border: var(--color-panel-border); - --dv-paneview-header-border-color: var(--color-panel-border); - --dv-sash-color: var(--color-panel-hover); + --dv-activegroup-visiblepanel-tab-color: var(--color-text-1); + --dv-activegroup-hiddenpanel-tab-color: var(--color-text-2); + --dv-inactivegroup-visiblepanel-tab-color: var(--color-text-1); + --dv-inactivegroup-hiddenpanel-tab-color: var(--color-text-2); + --dv-tab-divider-color: transparent; + --dv-tab-margin: 0; + --dv-separator-border: transparent; + --dv-paneview-header-border-color: transparent; + --dv-sash-color: transparent; --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-border-color: var(--color-panel-accent); - --dv-tab-font-size: 12px; - --dv-tabs-and-actions-container-font-size: 12px; - --dv-tab-margin: 0; + --dv-tab-font-size: 12.5px; + --dv-tabs-and-actions-container-font-size: 12.5px; + background-color: var(--panel-shell-bg); } .hf-dock .dv-sash:focus-visible { @@ -29,162 +31,160 @@ 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 { - align-items: center; - height: 36px; - padding: 4px 6px; - background-color: var(--panel-strip-bg); - border-bottom: 1px solid var(--panel-strip-border); + align-items: flex-end; + padding: 6px 0 0 6px; } -.hf-dock .dv-tabs-container { - align-items: center; - 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; +.hf-dock .dv-scrollable { + height: 30px; 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 { - position: relative; - 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 { +/* dockview's own scrollbar lane; the strip scrolls by wheel and keeps the shown tab in view. */ +.hf-dock .dv-scrollbar { display: none; } -.hf-dock .dv-default-tab-action::before, -.hf-dock .dv-default-tab-action::after { - content: ""; +.hf-dock .dv-tabs-container { + gap: 2px; + --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; - left: 4px; - top: 9.25px; - width: 12px; - height: 1.5px; - border-radius: 1px; - background: currentColor; + bottom: 0; + left: 0; + height: 30px; + border-radius: 6px 6px 0 0; + background-color: var(--panel-card-bg); + pointer-events: none; } -.hf-dock .dv-default-tab-action::before { - transform: rotate(45deg); +.hf-dock-tab-fill[data-ready] { + transition: + transform 140ms ease-out, + width 140ms ease-out; } -.hf-dock .dv-default-tab-action::after { - transform: rotate(-45deg); +.hf-dock-tab { + 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; + } } diff --git a/packages/studio/src/components/dock/dockAccessibility.test.ts b/packages/studio/src/components/dock/dockAccessibility.test.ts index 0225d886aa..7e96b58d8f 100644 --- a/packages/studio/src/components/dock/dockAccessibility.test.ts +++ b/packages/studio/src/components/dock/dockAccessibility.test.ts @@ -163,21 +163,4 @@ describe("dock tab strips", () => { press(tabs[3] as HTMLElement, "Home"); 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)", - ); - }); }); diff --git a/packages/studio/src/components/dock/dockAccessibility.ts b/packages/studio/src/components/dock/dockAccessibility.ts index e11e62960e..31160e6182 100644 --- a/packages/studio/src/components/dock/dockAccessibility.ts +++ b/packages/studio/src/components/dock/dockAccessibility.ts @@ -74,49 +74,16 @@ function onTabKeyDown(event: KeyboardEvent, tab: HTMLElement, api: DockviewApi) target.focus(); } -/** Dockview's default close control says "Close tab". Name the panel. */ -function labelCloseButtons(root: HTMLElement) { - for (const button of root.querySelectorAll(".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. */ export function installDockAccessibility(api: DockviewApi, root: HTMLElement): () => void { - const titleDisposers = new Map void }>(); - const watchTitles = () => { - 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 decorate = () => decorateSashes(root); + decorate(); const subscriptions = [ api.onDidLayoutChange(decorate), api.onDidLayoutFromJSON(decorate), api.onDidAddPanel(decorate), api.onDidRemovePanel(decorate), ]; - decorate(); // Capture phase so the tab handler runs before dockview's own focus-only arrows. const onKeyDown = (event: KeyboardEvent) => { if (!(event.target instanceof HTMLElement)) return; @@ -127,7 +94,5 @@ export function installDockAccessibility(api: DockviewApi, root: HTMLElement): ( return () => { root.removeEventListener("keydown", onKeyDown, true); for (const subscription of subscriptions) subscription.dispose(); - for (const disposer of titleDisposers.values()) disposer.dispose(); - titleDisposers.clear(); }; } diff --git a/packages/studio/src/components/dock/dockTabFill.ts b/packages/studio/src/components/dock/dockTabFill.ts new file mode 100644 index 0000000000..9071e8e97b --- /dev/null +++ b/packages/studio/src/components/dock/dockTabFill.ts @@ -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(`: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(":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(".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(); + }; +} diff --git a/packages/studio/src/components/dock/dockTabs.contract.test.ts b/packages/studio/src/components/dock/dockTabs.contract.test.ts index 7fd0ed0198..4e2d4ffe0c 100644 --- a/packages/studio/src/components/dock/dockTabs.contract.test.ts +++ b/packages/studio/src/components/dock/dockTabs.contract.test.ts @@ -2,26 +2,73 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; -const dockCss = readFileSync(path.join(import.meta.dirname, "dock.css"), "utf8"); -const themeCss = readFileSync(path.join(import.meta.dirname, "../../styles/theme.css"), "utf8"); +const dockCss = readFileSync(path.join(import.meta.dirname, "dock.css"), "utf8").replace( + /@import[^;]*;/, + "", +); -describe("panel tab contract", () => { - it("paints selection, hover and focus from the panel-tab tokens", () => { - expect(dockCss).toContain( - "--dv-activegroup-visiblepanel-tab-background-color: var(--panel-tab-bg-active)", - ); - expect(dockCss).toContain("background-color: var(--panel-tab-bg-hover)"); - expect(dockCss).toContain("outline: 1.5px solid var(--panel-tab-ring)"); - expect(dockCss).toContain("background-size: 16px 2px"); - expect(dockCss).not.toMatch(/#[0-9a-f]{3,8}|rgba?\(/i); +/** Each top-level rule's selector and body, with the reduced-motion block kept whole. */ +function rules(css: string): { selector: string; body: string }[] { + const found: { selector: string; body: string }[] = []; + let depth = 0; + let start = 0; + let selector = ""; + for (let index = 0; index < css.length; index++) { + if (css[index] === "{") { + 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", () => { - expect(themeCss).toContain("--panel-tab-bg-active: rgba(255, 255, 255, 0.08)"); - expect(themeCss).toContain("--panel-tab-bg-active-muted: rgba(255, 255, 255, 0.04)"); - expect(themeCss).toContain("active rgba(0, 0, 0, 0.06)"); - expect(themeCss).toContain("hover rgba(0, 0, 0, 0.035)"); - expect(themeCss).not.toContain("[data-theme"); - expect(themeCss).not.toContain("[data-chrome"); + it("slides only transform and width, 140ms ease-out, and reveals the icon in 80ms", () => { + expect(bodyOf(".hf-dock-tab-fill[data-ready]")?.replace(/\s+/g, " ").trim()).toBe( + "transition: transform 140ms ease-out, width 140ms ease-out;", + ); + expect(bodyOf(".hf-dock-tab-icon")).toContain("animation: hf-dock-tab-icon-in 80ms ease-out"); + const animated = rules(dockCss).filter( + (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); }); }); diff --git a/packages/studio/src/components/nle/PreviewPane.tsx b/packages/studio/src/components/nle/PreviewPane.tsx index 6f8bd7c10b..775a508081 100644 --- a/packages/studio/src/components/nle/PreviewPane.tsx +++ b/packages/studio/src/components/nle/PreviewPane.tsx @@ -115,10 +115,10 @@ export function PreviewPane({ return (
diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx index 727fef32fb..39131066e6 100644 --- a/packages/studio/src/components/nle/TimelinePane.tsx +++ b/packages/studio/src/components/nle/TimelinePane.tsx @@ -262,7 +262,7 @@ export function TimelinePane({ aria-disabled={timelineDisabled || undefined} >
{ if ((e.target as HTMLElement).closest("[data-clip]")) return; if (timelineDisabled) return; diff --git a/packages/studio/src/components/ui/Menu.tsx b/packages/studio/src/components/ui/Menu.tsx index a0b36d54b9..b4e22b6007 100644 --- a/packages/studio/src/components/ui/Menu.tsx +++ b/packages/studio/src/components/ui/Menu.tsx @@ -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) { + return ( + + {children} + + ✓ + + + ); +} + /** A hairline between two groups of items. */ export function MenuSeparator({ className, ...props }: StyledProps) { return ; diff --git a/packages/studio/src/components/ui/index.ts b/packages/studio/src/components/ui/index.ts index e9f4936e8e..aac1aa360f 100644 --- a/packages/studio/src/components/ui/index.ts +++ b/packages/studio/src/components/ui/index.ts @@ -11,6 +11,7 @@ export { Tooltip } from "./Tooltip"; export { ContextMenu, Menu, + MenuCheckboxItem, MenuItem, MenuRadioGroup, MenuRadioItem, diff --git a/packages/studio/src/styles/theme.css b/packages/studio/src/styles/theme.css index 3b44430f6f..a4af83a5f9 100644 --- a/packages/studio/src/styles/theme.css +++ b/packages/studio/src/styles/theme.css @@ -270,18 +270,13 @@ --color-panel-media: var(--color-media); --color-panel-container: var(--color-container); - /* panel tabs. Dark values. A paper host overrides these same names: - strip #f5f5f4, border #18181b14, active rgba(0, 0, 0, 0.06), - muted rgba(0, 0, 0, 0.03), hover rgba(0, 0, 0, 0.035), text #18181b / #71717a. */ - --panel-strip-bg: var(--color-panel-surface); - --panel-strip-border: var(--color-panel-border); - --panel-tab-bg-active: rgba(255, 255, 255, 0.08); - --panel-tab-bg-active-muted: rgba(255, 255, 255, 0.04); - --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); + /* Dock panels are cards on the shell. The card owns the only border; its tab + * strip sits below the card body and the shown tab is a fill in the body colour. */ + --panel-shell-bg: var(--color-bg-1); + --panel-card-bg: var(--color-surface); + --panel-card-border: #303034; + --panel-strip-bg: var(--color-bg-0); + --panel-tab-bg-hover: #202023; /* 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 diff --git a/scripts/contrast-pairs.json b/scripts/contrast-pairs.json index c695cb7015..5f393dccb5 100644 --- a/scripts/contrast-pairs.json +++ b/scripts/contrast-pairs.json @@ -270,6 +270,39 @@ ], "opacity": 0.64, "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" + ] } ] },