From e6a28653e6835a694830733d4b0438375520a2e9 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 4 Sep 2026 02:06:43 +0200 Subject: [PATCH 1/7] Keep the bottom toolbar clear of the side panels and viewport controls The bar stays centered on the canvas until a panel would cover it, then shifts to the free side with a margin. Where the space between the panels is too narrow for the full set of controls it collapses to the cursor, the diagnostics status and Play, expanding again while the pointer or the keyboard is on it. --- .changeset/bottom-bar-panel-clearance.md | 5 + .../petrinaut/docs/drawing-a-net.md | 7 + libs/@hashintel/petrinaut/docs/simulation.md | 4 + .../petrinaut/src/ui/constants/ui.ts | 6 + .../BottomBar/bottom-bar-placement.test.ts | 75 +++++ .../BottomBar/bottom-bar-placement.ts | 62 ++++ .../components/BottomBar/bottom-bar.tsx | 300 +++++++++++++----- .../components/BottomBar/collapse-context.ts | 34 ++ .../BottomBar/collapsible-group.tsx | 98 ++++++ .../BottomBar/simulation-controls.tsx | 60 ++-- .../components/BottomBar/toolbar-modes.tsx | 93 +++--- .../components/BottomBar/use-element-width.ts | 29 ++ .../SDCPN/components/viewport-controls.tsx | 11 +- 13 files changed, 623 insertions(+), 161 deletions(-) create mode 100644 .changeset/bottom-bar-panel-clearance.md create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapse-context.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-element-width.ts diff --git a/.changeset/bottom-bar-panel-clearance.md b/.changeset/bottom-bar-panel-clearance.md new file mode 100644 index 00000000000..a4004b84ea5 --- /dev/null +++ b/.changeset/bottom-bar-panel-clearance.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Keep the bottom toolbar clear of the side panels and the viewport controls: it stays centered on the canvas until a panel would cover it, then shifts aside, and collapses to its essential controls — expanding again on hover or focus — when the space between the panels is too narrow for the full set. diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md index fedd5733889..4c20c5eeabc 100644 --- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md +++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md @@ -11,6 +11,13 @@ The editor is organized around a central canvas where you build your net: - **Bottom panel** -- tabs for Diagnostics (code errors), Simulation Settings, and Timeline (during simulation). - **Bottom toolbar** -- editing mode buttons, simulation controls, the AI assistant toggle, and a show/hide button for the bottom panel. +The bottom toolbar sits centered on the canvas and steps aside rather than +slide under anything: open a panel wide enough to reach it and it shifts to the +free side, keeping clear of the left sidebar, the properties panel and the +viewport controls. Where even that leaves too little room it shrinks to the +cursor, the diagnostics status and Play; point at it, or tab into it, and the +rest comes back for as long as you stay on it. + full-editor ## Top bar diff --git a/libs/@hashintel/petrinaut/docs/simulation.md b/libs/@hashintel/petrinaut/docs/simulation.md index 9ee2336477b..864865b6b6a 100644 --- a/libs/@hashintel/petrinaut/docs/simulation.md +++ b/libs/@hashintel/petrinaut/docs/simulation.md @@ -98,6 +98,10 @@ The bottom toolbar provides playback controls: The frame counter shows the current frame number, total frames, and elapsed simulation time. +Playback widens the toolbar, so in a narrow window it keeps Play and folds the +scrubber, the frame counter and the playback settings away until you point at +it. + simulation-toolbar ### Speed diff --git a/libs/@hashintel/petrinaut/src/ui/constants/ui.ts b/libs/@hashintel/petrinaut/src/ui/constants/ui.ts index fe0a3c89429..c69f240cdc1 100644 --- a/libs/@hashintel/petrinaut/src/ui/constants/ui.ts +++ b/libs/@hashintel/petrinaut/src/ui/constants/ui.ts @@ -29,6 +29,12 @@ export const MAX_LEFT_SIDEBAR_WIDTH = 500; export const MIN_PROPERTIES_PANEL_WIDTH = 250; export const MAX_PROPERTIES_PANEL_WIDTH = 800; +// Viewport controls — the button column at the bottom right of the canvas. +// The width is one `xs` icon button; the bottom bar reserves both so it never +// runs under the column. +export const VIEWPORT_CONTROLS_OFFSET = 12; +export const VIEWPORT_CONTROLS_WIDTH = 24; + // Bottom Panel (DEFAULT_BOTTOM_PANEL_HEIGHT in react/state/panel-defaults) export const MIN_BOTTOM_PANEL_HEIGHT = 100; export const MAX_BOTTOM_PANEL_HEIGHT = 600; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.test.ts new file mode 100644 index 00000000000..af5f6772b47 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { + type BottomBarBounds, + fitsWithinBounds, + getAvailableWidth, + getBottomBarOffset, +} from "./bottom-bar-placement"; + +const bounds = (overrides: Partial = {}): BottomBarBounds => ({ + containerWidth: 1000, + leftInset: 0, + rightInset: 0, + margin: 12, + ...overrides, +}); + +describe("getBottomBarOffset", () => { + it("leaves a bar that clears both panels centred", () => { + expect( + getBottomBarOffset(bounds({ leftInset: 200, rightInset: 200 }), 400), + ).toBe(0); + }); + + it("pushes a bar clear of the left panel", () => { + // Centred, the 600px bar starts at 200px, 62px inside the panel and its margin. + expect(getBottomBarOffset(bounds({ leftInset: 250 }), 600)).toBe(62); + }); + + it("pushes a bar clear of the right panel", () => { + expect(getBottomBarOffset(bounds({ rightInset: 250 }), 600)).toBe(-62); + }); + + it("pushes clear of the wider side when both panels are open", () => { + const space = bounds({ + containerWidth: 1200, + leftInset: 100, + rightInset: 400, + }); + + expect(getBottomBarOffset(space, 500)).toBe(-62); + }); + + it("keeps the left edge when the bar is wider than the space between the panels", () => { + const space = bounds({ leftInset: 400, rightInset: 400 }); + const offset = getBottomBarOffset(space, 400); + + // 1000 - 400 = 600, and the left edge lands on the panel edge plus margin. + expect(offset).toBe(112); + expect((1000 - 400) / 2 + offset).toBe(412); + }); + + it("stays put until the container and the bar have been measured", () => { + expect(getBottomBarOffset(bounds({ containerWidth: 0 }), 400)).toBe(0); + expect(getBottomBarOffset(bounds(), 0)).toBe(0); + }); +}); + +describe("fitsWithinBounds", () => { + it("counts both insets and both margins against the container", () => { + expect(getAvailableWidth(bounds({ leftInset: 250, rightInset: 350 }))).toBe( + 376, + ); + expect( + fitsWithinBounds(bounds({ leftInset: 250, rightInset: 350 }), 376), + ).toBe(true); + expect( + fitsWithinBounds(bounds({ leftInset: 250, rightInset: 350 }), 377), + ).toBe(false); + }); + + it("imposes no limit before the container has been measured", () => { + expect(fitsWithinBounds(bounds({ containerWidth: 0 }), 800)).toBe(true); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.ts new file mode 100644 index 00000000000..6c2541688ea --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.ts @@ -0,0 +1,62 @@ +/** + * Where the bottom bar sits between the panels docked around the canvas, and + * whether everything it can show fits there. + * + * The bar is centered on the canvas rather than on the space left between the + * panels: opening a panel must not move a bar that still has room. A bar that + * would run under a panel is pushed aside, and only as far as it takes to + * clear it. + */ + +/** The space the bar has to sit in, in CSS pixels. */ +export interface BottomBarBounds { + /** Width of the canvas area the bar is centered on. */ + readonly containerWidth: number; + /** Width taken by whatever is docked on the left. */ + readonly leftInset: number; + /** Width taken by whatever is docked on the right, viewport controls included. */ + readonly rightInset: number; + /** Gap kept between the bar and anything it is pushed away from. */ + readonly margin: number; +} + +/** Width left for the bar once the insets and both margins are taken out. */ +export const getAvailableWidth = (bounds: BottomBarBounds): number => + bounds.containerWidth - + bounds.leftInset - + bounds.rightInset - + bounds.margin * 2; + +/** + * Whether a bar of `width` clears both insets while it stays centered, or can + * be pushed aside far enough to. An unmeasured container imposes no limit, so + * the bar shows everything until it has been measured. + */ +export const fitsWithinBounds = ( + bounds: BottomBarBounds, + width: number, +): boolean => bounds.containerWidth <= 0 || width <= getAvailableWidth(bounds); + +/** + * How far to shift a bar of `barWidth` from the centered position for it to + * clear both insets: positive to the right, negative to the left, zero while + * the centered bar already clears them. A bar wider than the space between the + * insets cannot clear both, and keeps its left edge. + */ +export const getBottomBarOffset = ( + bounds: BottomBarBounds, + barWidth: number, +): number => { + if (bounds.containerWidth <= 0 || barWidth <= 0) { + return 0; + } + + const centeredLeft = (bounds.containerWidth - barWidth) / 2; + const leftLimit = bounds.leftInset + bounds.margin; + const rightLimit = + bounds.containerWidth - bounds.rightInset - bounds.margin - barWidth; + + // `Math.max` last so the left limit wins when the two cross, which is what + // happens once the bar is wider than the space between the insets. + return Math.max(leftLimit, Math.min(centeredLeft, rightLimit)) - centeredLeft; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx index aa264351c90..53db9d40006 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx @@ -1,4 +1,4 @@ -import { use, useCallback, useEffect } from "react"; +import { use, useCallback, useEffect, useRef, useState } from "react"; import { Icon } from "@hashintel/ds-components"; import { css, cva } from "@hashintel/ds-helpers/css"; @@ -12,13 +12,27 @@ import { type EditorState, } from "../../../../../react/state/editor-context"; import { AiAssistantIcon } from "../../../../components/ai-assistant-icon"; +import { + VIEWPORT_CONTROLS_OFFSET, + VIEWPORT_CONTROLS_WIDTH, +} from "../../../../constants/ui"; +import { fitsWithinBounds, getBottomBarOffset } from "./bottom-bar-placement"; +import { + BottomBarCollapseContext, + type CollapsibleGroupWidth, +} from "./collapse-context"; +import { CollapsibleGroup } from "./collapsible-group"; import { DiagnosticsIndicator } from "./diagnostics-indicator"; import { SimulationControls } from "./simulation-controls"; import { ToolbarButton } from "./toolbar-button"; import { ToolbarDivider } from "./toolbar-divider"; -import { ToolbarModes } from "./toolbar-modes"; +import { CursorModeDropdown, EditionTools } from "./toolbar-modes"; +import { useElementWidth } from "./use-element-width"; import { useKeyboardShortcuts } from "./use-keyboard-shortcuts"; +/** Gap kept between the bar and a panel it has been pushed away from. */ +const BOTTOM_BAR_MARGIN = 12; + const glassPanelStyle = css({ padding: "1", backgroundColor: "white.a95", @@ -38,13 +52,26 @@ const toolbarContainerStyle = css({ gap: "1", }); -const bottomBarPositionStyle = css({ +// Spans the canvas so the bar centers on the canvas rather than on the space +// between the panels, and lets clicks through everywhere the bar itself is not. +const bottomBarLaneStyle = css({ position: "absolute", - left: "[50%]", - transform: "translateX(-50%)", + left: "[0]", + right: "[0]", + display: "flex", + justifyContent: "center", + pointerEvents: "none", zIndex: "[calc(var(--z-index-sticky) + 1)]", +}); + +const bottomBarStyle = css({ display: "flex", gap: "[20px]", + pointerEvents: "auto", + transition: "[transform 150ms ease-in-out]", + "@media (prefers-reduced-motion: reduce)": { + transition: "[none]", + }, }); const animatingStyle = cva({ @@ -86,6 +113,11 @@ export const BottomBar: React.FC = ({ bottomPanelHeight, isAiAssistantOpen, isPanelAnimating, + isLeftSidebarOpen, + isSearchOpen, + leftSidebarWidth, + hasSelection, + propertiesPanelWidth, toggleAiAssistant, } = use(EditorContext); @@ -115,6 +147,87 @@ export const BottomBar: React.FC = ({ // Setup keyboard shortcuts useKeyboardShortcuts(mode, onEditionModeChange, onCursorModeChange); + const laneRef = useRef(null); + const barRef = useRef(null); + const containerWidth = useElementWidth(laneRef); + const barWidth = useElementWidth(barRef); + + const [groupWidths, setGroupWidths] = useState< + ReadonlyMap + >(() => new Map()); + + const reportGroupWidth = ( + id: string, + width: CollapsibleGroupWidth | null, + ) => { + setGroupWidths((previous) => { + const current = previous.get(id); + if (width === null) { + if (!current) { + return previous; + } + const next = new Map(previous); + next.delete(id); + return next; + } + if ( + current && + current.natural === width.natural && + current.hidden === width.hidden + ) { + return previous; + } + return new Map(previous).set(id, width); + }); + }; + + const [isPointerOver, setIsPointerOver] = useState(false); + const [isFocusWithin, setIsFocusWithin] = useState(false); + const [hasActiveInteraction, setHasActiveInteraction] = useState(false); + + // A menu opened from the bar renders outside it, so the pointer leaving for + // the menu would otherwise collapse the bar out from under the click that + // opened it. A click inside the bar holds it expanded until one lands + // elsewhere. + useEffect(() => { + if (!hasActiveInteraction) { + return; + } + + const release = (event: PointerEvent) => { + const bar = barRef.current; + if (bar && event.target instanceof Node && bar.contains(event.target)) { + return; + } + setHasActiveInteraction(false); + }; + + document.addEventListener("pointerdown", release); + return () => document.removeEventListener("pointerdown", release); + }, [hasActiveInteraction]); + + const bounds = { + containerWidth, + leftInset: isLeftSidebarOpen || isSearchOpen ? leftSidebarWidth : 0, + // The viewport controls sit in the bar's row on the right of the canvas, + // so they bound it the same way a panel does. + rightInset: + (hasSelection ? propertiesPanelWidth : 0) + + (isActualMode ? 0 : VIEWPORT_CONTROLS_OFFSET + VIEWPORT_CONTROLS_WIDTH), + margin: BOTTOM_BAR_MARGIN, + }; + + let hiddenWidth = 0; + for (const width of groupWidths.values()) { + hiddenWidth += width.hidden; + } + + // What the bar measures now plus what it is already hiding: the width it + // would take with every control shown. + const isPeeking = isPointerOver || isFocusWithin || hasActiveInteraction; + const isCollapsed = + !isPeeking && !fitsWithinBounds(bounds, barWidth + hiddenWidth); + // Calculate bottom offset based on bottom panel visibility const bottomOffset = isBottomPanelOpen ? bottomPanelHeight + 24 // panel height + margin + spacing @@ -122,86 +235,117 @@ export const BottomBar: React.FC = ({ return (
- {/* Edition tools segment */} - -
- - {hasAiAssistant && !isActualMode && ( - <> - - - - - - )} -
-
- - {/* Playback segment */} - setIsPointerOver(true)} + onPointerLeave={() => setIsPointerOver(false)} + onPointerDown={() => setHasActiveInteraction(true)} + onFocus={() => setIsFocusWithin(true)} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + setIsFocusWithin(false); + } }} > -
- + {/* Edition tools segment */} + - {isBottomPanelOpen ? ( - - ) : ( - - )} - - {!isActualMode && ( - <> - - - + - - )} -
-
+ {!isActualMode && ( + + + {hasAiAssistant && ( + <> + + + + + + )} + + )} +
+ + + {/* Playback segment */} + +
+ + {isBottomPanelOpen ? ( + + ) : ( + + )} + + {!isActualMode && ( + <> + + + + + )} +
+
+ + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapse-context.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapse-context.ts new file mode 100644 index 00000000000..af483ec483c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapse-context.ts @@ -0,0 +1,34 @@ +import { createContext } from "react"; + +/** What one collapsible group of toolbar controls takes up, in CSS pixels. */ +export interface CollapsibleGroupWidth { + /** Width the group takes when the bar shows everything. */ + readonly natural: number; + /** Width currently clipped away — 0 while the group is expanded. */ + readonly hidden: number; +} + +export interface BottomBarCollapseValue { + /** True while the bar shows only its essential controls. */ + readonly isCollapsed: boolean; + /** + * Report what a group takes up, so the bar knows how wide it would be with + * everything shown. Pass `null` when the group unmounts. + */ + readonly reportGroupWidth: ( + id: string, + width: CollapsibleGroupWidth | null, + ) => void; +} + +/** + * Lets controls anywhere under the bottom bar mark themselves collapsible + * without the bar having to know what its segments are made of. + * + * The default keeps groups expanded, so the controls render normally outside + * the bar — in Storybook, say. + */ +export const BottomBarCollapseContext = createContext({ + isCollapsed: false, + reportGroupWidth: () => {}, +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx new file mode 100644 index 00000000000..e3d43868975 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx @@ -0,0 +1,98 @@ +import { use, useId, useLayoutEffect, useRef } from "react"; + +import { css, cva } from "@hashintel/ds-helpers/css"; + +import { BottomBarCollapseContext } from "./collapse-context"; + +/** + * The group collapses by animating its grid column to nothing while the + * content inside keeps its natural width. That keeps the content measurable + * whether it is shown or hidden, so the bar can tell how wide it would be with + * everything shown without laying the controls out twice. + */ +const groupStyle = cva({ + base: { + display: "grid", + gridTemplateColumns: "[1fr]", + transition: + "[grid-template-columns 150ms ease-in-out, opacity 150ms ease-in-out]", + "@media (prefers-reduced-motion: reduce)": { + transition: "[none]", + }, + }, + variants: { + collapsed: { + true: { + gridTemplateColumns: "[0fr]", + opacity: "[0]", + pointerEvents: "none", + }, + }, + }, +}); + +const clipStyle = css({ + overflow: "hidden", + minWidth: "[0]", +}); + +const contentStyle = css({ + display: "flex", + alignItems: "center", + gap: "1", + width: "[max-content]", +}); + +/** + * Toolbar controls the bottom bar hides when it runs out of room, and shows + * again while the pointer or the keyboard is on the bar. + */ +export const CollapsibleGroup: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const { isCollapsed, reportGroupWidth } = use(BottomBarCollapseContext); + const groupId = useId(); + const clipRef = useRef(null); + const contentRef = useRef(null); + + useLayoutEffect(() => { + const clip = clipRef.current; + const content = contentRef.current; + if (!clip || !content) { + return; + } + + const measure = () => { + const natural = content.getBoundingClientRect().width; + const rendered = clip.getBoundingClientRect().width; + reportGroupWidth(groupId, { + natural, + hidden: Math.max(natural - rendered, 0), + }); + }; + measure(); + + // Both boxes are watched: the content changes when a control appears, and + // the clip changes on every frame of the collapse. Reporting the pair from + // one observer keeps the two in step, so the width the bar derives from + // them is right mid-animation too. + const observer = new ResizeObserver(measure); + observer.observe(clip); + observer.observe(content); + + return () => { + observer.disconnect(); + reportGroupWidth(groupId, null); + }; + }, [groupId, reportGroupWidth]); + + return ( +
+
+
+ {children} +
+
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx index 49c9aefecb7..6f7abf7556d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/simulation-controls.tsx @@ -6,6 +6,7 @@ import { css } from "@hashintel/ds-helpers/css"; import { PlaybackContext } from "../../../../../react/playback/context"; import { SimulationContext } from "../../../../../react/simulation/context"; import { EditorContext } from "../../../../../react/state/editor-context"; +import { CollapsibleGroup } from "./collapsible-group"; import { PlaybackSettingsMenu } from "./playback-settings-menu"; import { ToolbarButton } from "./toolbar-button"; import { ToolbarDivider } from "./toolbar-divider"; @@ -173,7 +174,7 @@ export const SimulationControls: React.FC = ({ <> {/* Stop button - only visible when simulation exists */} {hasSimulation && ( - <> + = ({ - + )} {/* Play/Pause button - always visible */} @@ -200,35 +201,38 @@ export const SimulationControls: React.FC = ({ )} - {/* Frame controls - only visible when simulation exists */} - {hasSimulation && ( - <> -
-
Frame
-
- {frameIndex + 1} / {totalFrames} + {/* Frame controls - only visible when simulation exists - and the + playback settings, which the bar hides first when it runs short of + room: the scrubber is the widest thing on it. */} + + {hasSimulation && ( + <> +
+
Frame
+
+ {frameIndex + 1} / {totalFrames} +
+
{elapsedTime.toFixed(3)}s
-
{elapsedTime.toFixed(3)}s
-
- - - setCurrentViewedFrame(Number(event.target.value)) - } - className={sliderStyle} - /> - - - )} + + setCurrentViewedFrame(Number(event.target.value)) + } + className={sliderStyle} + /> + + + + )} - {/* Playback settings menu */} - + + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx index c6a2d2e7bf7..e6c8efddb36 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx @@ -65,7 +65,8 @@ const dropdownArrowStyle = css({ opacity: "[0.5]", }); -const CursorModeDropdown: React.FC<{ +/** Picks between the select and pan cursors, and returns to cursor mode. */ +export const CursorModeDropdown: React.FC<{ editionMode: EditorEditionMode; onEditionModeChange: (mode: EditorEditionMode) => void; cursorMode: CursorMode; @@ -162,20 +163,18 @@ const ComponentDropdown: React.FC<{ ); }; -interface ToolbarModesProps { +interface EditionToolsProps { editionMode: EditorEditionMode; onEditionModeChange: (mode: EditorEditionMode) => void; - cursorMode: CursorMode; - onCursorModeChange: (mode: CursorMode) => void; - showEditTools?: boolean; } -export const ToolbarModes: React.FC = ({ +/** + * The tools that add nodes to the net. Nothing to offer on a read-only net, + * where every one of them is refused. + */ +export const EditionTools: React.FC = ({ editionMode, onEditionModeChange, - cursorMode, - onCursorModeChange, - showEditTools = true, }) => { const isReadOnly = useIsReadOnly(); const { activeSubnetId } = use(ActiveNetContext); @@ -183,49 +182,43 @@ export const ToolbarModes: React.FC = ({ const { extensions } = use(SDCPNContext); const { enableNetComponents } = use(UserSettingsContext); + if (isReadOnly) { + return null; + } + return ( <> - - {showEditTools && !isReadOnly && ( - <> - - onEditionModeChange("add-place")} - isSelected={editionMode === "add-place"} - ariaLabel="Add place mode" - draggable - onDragStart={(event) => { - // eslint-disable-next-line no-param-reassign - event.dataTransfer.effectAllowed = "move"; - writeDraggedNodeKind(event.dataTransfer, "place"); - }} - > - - - onEditionModeChange("add-transition")} - isSelected={editionMode === "add-transition"} - ariaLabel="Add transition mode" - draggable - onDragStart={(event) => { - // eslint-disable-next-line no-param-reassign - event.dataTransfer.effectAllowed = "move"; - writeDraggedNodeKind(event.dataTransfer, "transition"); - }} - > - - - {isRootNet && extensions.subnets && enableNetComponents && ( - - )} - + + onEditionModeChange("add-place")} + isSelected={editionMode === "add-place"} + ariaLabel="Add place mode" + draggable + onDragStart={(event) => { + // eslint-disable-next-line no-param-reassign + event.dataTransfer.effectAllowed = "move"; + writeDraggedNodeKind(event.dataTransfer, "place"); + }} + > + + + onEditionModeChange("add-transition")} + isSelected={editionMode === "add-transition"} + ariaLabel="Add transition mode" + draggable + onDragStart={(event) => { + // eslint-disable-next-line no-param-reassign + event.dataTransfer.effectAllowed = "move"; + writeDraggedNodeKind(event.dataTransfer, "transition"); + }} + > + + + {isRootNet && extensions.subnets && enableNetComponents && ( + )} ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-element-width.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-element-width.ts new file mode 100644 index 00000000000..f21b2bf8d40 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-element-width.ts @@ -0,0 +1,29 @@ +import { useLayoutEffect, useState } from "react"; + +/** + * Tracks the rendered width of `ref`'s element in CSS pixels, 0 until it has + * been measured. Every change is reported, the frames of a CSS transition + * included, so layout derived from the width stays in step with it. + */ +export const useElementWidth = ( + ref: React.RefObject, +): number => { + const [width, setWidth] = useState(0); + + useLayoutEffect(() => { + const element = ref.current; + if (!element) { + return; + } + + const measure = () => setWidth(element.getBoundingClientRect().width); + measure(); + + const observer = new ResizeObserver(measure); + observer.observe(element); + + return () => observer.disconnect(); + }, [ref]); + + return width; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx index 4f32dac3d42..4c3873e5524 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx @@ -5,14 +5,15 @@ import { cx, css, cva } from "@hashintel/ds-helpers/css"; import { usePetrinautNavigation } from "../../../../react/navigation"; import { EditorContext } from "../../../../react/state/editor-context"; -import { PANEL_MARGIN } from "../../../constants/ui"; +import { + PANEL_MARGIN, + VIEWPORT_CONTROLS_OFFSET, +} from "../../../constants/ui"; import { useCanvasController } from "../canvas-renderer"; import { ViewportSettingsDialog } from "./viewport-settings-dialog"; import type { ViewportAction } from "../../../types/viewport-action"; -const BASE_OFFSET = 12; - const containerStyle = css({ position: "absolute", display: "flex", @@ -57,10 +58,10 @@ export const ViewportControls: React.FC<{ const isPropertiesPanelVisible = hasSelection; const rightOffset = - BASE_OFFSET + + VIEWPORT_CONTROLS_OFFSET + (isPropertiesPanelVisible ? propertiesPanelWidth + PANEL_MARGIN : 0); const bottomOffset = - BASE_OFFSET + (isBottomPanelOpen ? bottomPanelHeight + PANEL_MARGIN : 0); + VIEWPORT_CONTROLS_OFFSET + (isBottomPanelOpen ? bottomPanelHeight + PANEL_MARGIN : 0); return (
Date: Fri, 4 Sep 2026 02:22:02 +0200 Subject: [PATCH 2/7] Pin the collapsible-group width reporter's identity Each group measures from an effect keyed on the callback, so a fresh one per render would disconnect the observers and report a width in a loop. --- .../components/BottomBar/bottom-bar.tsx | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx index 53db9d40006..3cc4ab6ae8c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx @@ -156,30 +156,33 @@ export const BottomBar: React.FC = ({ ReadonlyMap >(() => new Map()); - const reportGroupWidth = ( - id: string, - width: CollapsibleGroupWidth | null, - ) => { - setGroupWidths((previous) => { - const current = previous.get(id); - if (width === null) { - if (!current) { + // Identity is load-bearing rather than a performance nicety: every group + // measures from an effect keyed on this callback, and a new one each render + // would tear the observers down and report a width in a loop. + const reportGroupWidth = useCallback( + (id: string, width: CollapsibleGroupWidth | null) => { + setGroupWidths((previous) => { + const current = previous.get(id); + if (width === null) { + if (!current) { + return previous; + } + const next = new Map(previous); + next.delete(id); + return next; + } + if ( + current && + current.natural === width.natural && + current.hidden === width.hidden + ) { return previous; } - const next = new Map(previous); - next.delete(id); - return next; - } - if ( - current && - current.natural === width.natural && - current.hidden === width.hidden - ) { - return previous; - } - return new Map(previous).set(id, width); - }); - }; + return new Map(previous).set(id, width); + }); + }, + [], + ); const [isPointerOver, setIsPointerOver] = useState(false); const [isFocusWithin, setIsFocusWithin] = useState(false); From 7adf7d87082328b9a4c67afcd0c31ffb55d452b1 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 4 Sep 2026 02:37:00 +0200 Subject: [PATCH 3/7] Stop the bottom toolbar drifting into place The segment's transition was `all 0.3s`, so a collapsing group animated the glass panel's width over twice the group's own 150ms, and the offset, which transitioned on a curve of its own, pulled the bar past its place and back. The transition now names the two properties the hover effect changes, and the bar only animates its offset while a panel opens or closes: a collapse keeps it glued to the width it is measured at, and a resize drag to the panel edge. Hover expansion settles in 174ms rather than 315ms, a collapse in 86ms rather than 201ms. --- .../components/BottomBar/bottom-bar.tsx | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx index 3cc4ab6ae8c..cc5f688322b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx @@ -39,7 +39,10 @@ const glassPanelStyle = css({ borderWidth: "thin", borderColor: "neutral.a50", boxShadow: "[0 3px 11px rgba(0, 0, 0, 0.1)]", - transition: "[all 0.3s ease]", + // Named rather than `all`: the segment's width changes when a group + // collapses, and `all` animated that over 0.3s on top of the group's own + // 150ms, which left the bar drifting past its place and back. + transition: "[background-color 0.3s ease, box-shadow 0.3s ease]", _hover: { backgroundColor: "white.a110", boxShadow: "[0 4px 13px rgba(0, 0, 0, 0.15)]", @@ -68,12 +71,14 @@ const bottomBarStyle = css({ display: "flex", gap: "[20px]", pointerEvents: "auto", - transition: "[transform 150ms ease-in-out]", - "@media (prefers-reduced-motion: reduce)": { - transition: "[none]", - }, }); +/** + * Only a panel opening or closing animates the bar into place. A collapse + * moves it too, but there the offset follows the width the bar is measured at, + * frame by frame, and a transition would race that with a curve of its own. + * A resize drag wants no transition either: the bar tracks the panel edge. + */ const animatingStyle = cva({ base: {}, variants: { @@ -85,6 +90,20 @@ const animatingStyle = cva({ }, }); +const barAnimatingStyle = cva({ + base: {}, + variants: { + animating: { + true: { + transition: "[transform 150ms ease-in-out]", + "@media (prefers-reduced-motion: reduce)": { + transition: "[none]", + }, + }, + }, + }, +}); + type EditorMode = EditorState["globalMode"]; type EditorEditionMode = EditorState["editionMode"]; @@ -226,10 +245,11 @@ export const BottomBar: React.FC = ({ } // What the bar measures now plus what it is already hiding: the width it - // would take with every control shown. + // would take with every control shown. Both terms move together while a + // group collapses, so the sum holds still throughout. + const naturalWidth = barWidth + hiddenWidth; const isPeeking = isPointerOver || isFocusWithin || hasActiveInteraction; - const isCollapsed = - !isPeeking && !fitsWithinBounds(bounds, barWidth + hiddenWidth); + const isCollapsed = !isPeeking && !fitsWithinBounds(bounds, naturalWidth); // Calculate bottom offset based on bottom panel visibility const bottomOffset = isBottomPanelOpen @@ -244,7 +264,7 @@ export const BottomBar: React.FC = ({ >
Date: Fri, 4 Sep 2026 13:44:34 +0200 Subject: [PATCH 4/7] Ride the bottom panel on one composited transform The bar rode above the bottom panel on its `bottom`, which the main thread animates, while the panel slides on a transform the compositor animates. The frames dropped while the panel's content mounts leave a layout-driven animation behind, so the bar arrived after the panel it is meant to sit on. Lane now holds a fixed inset and both axes ride one transform. Collapse decelerates over 120ms instead of easing in and out over 150ms: hovering moved 0.3% of the way in the first frame and now moves 22%, and the bar settles in 133ms rather than 174ms. --- .../components/BottomBar/bottom-bar.tsx | 32 ++++++++----------- .../BottomBar/collapsible-group.tsx | 5 ++- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx index cc5f688322b..f1603b077fe 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx @@ -33,6 +33,9 @@ import { useKeyboardShortcuts } from "./use-keyboard-shortcuts"; /** Gap kept between the bar and a panel it has been pushed away from. */ const BOTTOM_BAR_MARGIN = 12; +/** Gap between the bar and whatever is below it, canvas or bottom panel. */ +const BOTTOM_BAR_INSET = 24; + const glassPanelStyle = css({ padding: "1", backgroundColor: "white.a95", @@ -57,6 +60,8 @@ const toolbarContainerStyle = css({ // Spans the canvas so the bar centers on the canvas rather than on the space // between the panels, and lets clicks through everywhere the bar itself is not. +// The lane is anchored a fixed distance from the bottom, and the bar rides +// above the bottom panel on its transform. const bottomBarLaneStyle = css({ position: "absolute", left: "[0]", @@ -78,18 +83,12 @@ const bottomBarStyle = css({ * moves it too, but there the offset follows the width the bar is measured at, * frame by frame, and a transition would race that with a curve of its own. * A resize drag wants no transition either: the bar tracks the panel edge. + * + * Both axes ride one transform, which the compositor animates. The bottom + * panel slides on its own transform, and a main-thread property could not stay + * with it: the frames dropped while the panel's content mounts leave a + * layout-driven animation behind, and the bar arrives late. */ -const animatingStyle = cva({ - base: {}, - variants: { - animating: { - true: { - transition: "[bottom 150ms ease-in-out]", - }, - }, - }, -}); - const barAnimatingStyle = cva({ base: {}, variants: { @@ -251,22 +250,19 @@ export const BottomBar: React.FC = ({ const isPeeking = isPointerOver || isFocusWithin || hasActiveInteraction; const isCollapsed = !isPeeking && !fitsWithinBounds(bounds, naturalWidth); - // Calculate bottom offset based on bottom panel visibility - const bottomOffset = isBottomPanelOpen - ? bottomPanelHeight + 24 // panel height + margin + spacing - : 24; + const panelLift = isBottomPanelOpen ? bottomPanelHeight : 0; return (
setIsPointerOver(true)} onPointerLeave={() => setIsPointerOver(false)} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx index e3d43868975..af976d01e37 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx @@ -14,8 +14,11 @@ const groupStyle = cva({ base: { display: "grid", gridTemplateColumns: "[1fr]", + // Decelerating rather than eased at both ends, and shorter than a panel's + // 150ms: the controls have to be on their way out by the first frame after + // the pointer lands, or the bar reads as slow to answer. transition: - "[grid-template-columns 150ms ease-in-out, opacity 150ms ease-in-out]", + "[grid-template-columns 120ms ease-out, opacity 120ms ease-out]", "@media (prefers-reduced-motion: reduce)": { transition: "[none]", }, From 09661befa880a856c56781e162c1a41d546901be Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 4 Sep 2026 17:57:21 +0200 Subject: [PATCH 5/7] Answer the bottom-toolbar review Keeps the toolbar clear of the assistant panel, which paints over it: the panel's width moves from its own state into the editor's, and one derivation of the panel insets now serves both the toolbar and the viewport controls, which were hidden behind the assistant for the same reason. Reveals the hidden controls from CSS rather than mirrored state. A control that unmounts while focused fires no blur, so the focus flag could stay set and leave the toolbar stranded open; `:focus-within` cannot go stale. Only the click-hold stays in JavaScript, for the menus that render outside the bar. Takes the measurement and the fold policy out of the component into `useBottomBarLayout`, drops the forked width hook for the package's own `useElementSize`, and splits the toolbar's two dropdowns into their own files around the trigger they share. While a panel animates, the offset now follows the width the bar is heading for, so its transition is not restarted every frame. Folding eases in over 160ms and revealing decelerates over 120ms, since only one of the two answers a pointer. --- .../petrinaut/docs/drawing-a-net.md | 4 +- .../hooks/use-petrinaut-commands.test.tsx | 1 + .../hooks/use-petrinaut-mutations.test.tsx | 1 + .../src/react/state/editor-context.ts | 9 + .../src/react/state/editor-provider.tsx | 2 + .../src/react/state/panel-defaults.ts | 2 + .../petrinaut/src/ui/constants/ui.ts | 7 +- .../src/ui/hooks/use-canvas-insets.test.ts | 56 ++++++ .../src/ui/hooks/use-canvas-insets.ts | 51 +++++ .../BottomBar/bottom-bar-placement.test.ts | 46 ++++- .../BottomBar/bottom-bar-placement.ts | 4 +- .../components/BottomBar/bottom-bar.tsx | 189 +++++------------- .../components/BottomBar/collapse-context.ts | 8 +- .../BottomBar/collapsible-group.tsx | 41 ++-- .../BottomBar/cursor-mode-dropdown.tsx | 57 ++++++ .../{toolbar-modes.tsx => edition-tools.tsx} | 143 ++----------- .../BottomBar/toolbar-menu-trigger.tsx | 67 +++++++ .../BottomBar/use-bottom-bar-layout.ts | 154 ++++++++++++++ .../components/BottomBar/use-element-width.ts | 29 --- .../experiments-story-fixtures.tsx | 1 + .../Editor/panels/ai-assistant-panel.test.tsx | 1 + .../ai-assistant-contents.tsx | 11 +- .../SDCPN/components/viewport-controls.tsx | 27 +-- 23 files changed, 561 insertions(+), 350 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx rename libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/{toolbar-modes.tsx => edition-tools.tsx} (50%) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts delete mode 100644 libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-element-width.ts diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md index 4c20c5eeabc..0d1aa4315e2 100644 --- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md +++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md @@ -15,8 +15,8 @@ The bottom toolbar sits centered on the canvas and steps aside rather than slide under anything: open a panel wide enough to reach it and it shifts to the free side, keeping clear of the left sidebar, the properties panel and the viewport controls. Where even that leaves too little room it shrinks to the -cursor, the diagnostics status and Play; point at it, or tab into it, and the -rest comes back for as long as you stay on it. +cursor, the panel toggle, the diagnostics status and Play; point at it, or tab +into it, and the rest comes back for as long as you stay on it. full-editor diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx index 6ab6c248657..4693fb8e05d 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx @@ -46,6 +46,7 @@ const editorContextValue = ( setLeftSidebarOpen: () => {}, setLeftSidebarWidth: () => {}, setPropertiesPanelWidth: () => {}, + setAiAssistantWidth: () => {}, setBottomPanelOpen: () => {}, toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx index 9672cec8e14..b7fa73a7a46 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx @@ -49,6 +49,7 @@ const editorContextValue = ( setLeftSidebarOpen: () => {}, setLeftSidebarWidth: () => {}, setPropertiesPanelWidth: () => {}, + setAiAssistantWidth: () => {}, setBottomPanelOpen: () => {}, toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts index 54122e7d9f3..1936ff1a8c1 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts @@ -1,6 +1,7 @@ import { createContext, createRef } from "react"; import { + DEFAULT_AI_ASSISTANT_WIDTH, DEFAULT_BOTTOM_PANEL_HEIGHT, DEFAULT_LEFT_SIDEBAR_WIDTH, DEFAULT_PROPERTIES_PANEL_WIDTH, @@ -81,6 +82,11 @@ export type EditorState = { propertiesPanelWidth: number; isBottomPanelOpen: boolean; bottomPanelHeight: number; + /** + * Width of the AI assistant panel. Held here rather than inside the panel so + * the surfaces that have to keep clear of it can read it. + */ + aiAssistantWidth: number; activeBottomPanelTab: BottomPanelTab; componentSubnetId: string | null; selection: SelectionMap; @@ -127,6 +133,7 @@ export type EditorActions = { setLeftSidebarOpen: (isOpen: boolean) => void; setLeftSidebarWidth: (width: number) => void; setPropertiesPanelWidth: (width: number) => void; + setAiAssistantWidth: (width: number) => void; setBottomPanelOpen: (isOpen: boolean) => void; toggleBottomPanel: () => void; setBottomPanelHeight: (height: number) => void; @@ -189,6 +196,7 @@ export const initialEditorState: EditorState = { propertiesPanelWidth: DEFAULT_PROPERTIES_PANEL_WIDTH, isBottomPanelOpen: false, bottomPanelHeight: DEFAULT_BOTTOM_PANEL_HEIGHT, + aiAssistantWidth: DEFAULT_AI_ASSISTANT_WIDTH, activeBottomPanelTab: "diagnostics", componentSubnetId: null, selection: new Map(), @@ -215,6 +223,7 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = { setLeftSidebarOpen: () => {}, setLeftSidebarWidth: () => {}, setPropertiesPanelWidth: () => {}, + setAiAssistantWidth: () => {}, setBottomPanelOpen: () => {}, toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx index a9b5e170c60..6b43a1f678b 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx @@ -410,6 +410,8 @@ export const EditorProvider: React.FC = ({ children }) => { setState((prev) => ({ ...prev, leftSidebarWidth: width })), setPropertiesPanelWidth: (width) => setState((prev) => ({ ...prev, propertiesPanelWidth: width })), + setAiAssistantWidth: (width) => + setState((prev) => ({ ...prev, aiAssistantWidth: width })), setBottomPanelOpen: (isOpen) => { scheduleAnimationEnd(); setState((prev) => ({ diff --git a/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts b/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts index 472d222462d..ad68b8472cf 100644 --- a/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts +++ b/libs/@hashintel/petrinaut/src/react/state/panel-defaults.ts @@ -11,3 +11,5 @@ export const DEFAULT_LEFT_SIDEBAR_WIDTH = 320; export const DEFAULT_PROPERTIES_PANEL_WIDTH = 450; export const DEFAULT_BOTTOM_PANEL_HEIGHT = 180; + +export const DEFAULT_AI_ASSISTANT_WIDTH = 500; diff --git a/libs/@hashintel/petrinaut/src/ui/constants/ui.ts b/libs/@hashintel/petrinaut/src/ui/constants/ui.ts index c69f240cdc1..ba4ca9d0e8a 100644 --- a/libs/@hashintel/petrinaut/src/ui/constants/ui.ts +++ b/libs/@hashintel/petrinaut/src/ui/constants/ui.ts @@ -30,11 +30,14 @@ export const MIN_PROPERTIES_PANEL_WIDTH = 250; export const MAX_PROPERTIES_PANEL_WIDTH = 800; // Viewport controls — the button column at the bottom right of the canvas. -// The width is one `xs` icon button; the bottom bar reserves both so it never -// runs under the column. +// The width is one `xs` icon button, rounded up from its rendered 21px. export const VIEWPORT_CONTROLS_OFFSET = 12; export const VIEWPORT_CONTROLS_WIDTH = 24; +/** What a control sharing the column's row has to leave free of it. */ +export const VIEWPORT_CONTROLS_CLEARANCE = + VIEWPORT_CONTROLS_OFFSET + VIEWPORT_CONTROLS_WIDTH; + // Bottom Panel (DEFAULT_BOTTOM_PANEL_HEIGHT in react/state/panel-defaults) export const MIN_BOTTOM_PANEL_HEIGHT = 100; export const MAX_BOTTOM_PANEL_HEIGHT = 600; diff --git a/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts new file mode 100644 index 00000000000..73d82896401 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { getCanvasInsets, type PanelLayoutState } from "./use-canvas-insets"; + +const closed: PanelLayoutState = { + isLeftSidebarOpen: false, + isSearchOpen: false, + leftSidebarWidth: 320, + hasSelection: false, + propertiesPanelWidth: 450, + isAiAssistantOpen: false, + aiAssistantWidth: 500, + isBottomPanelOpen: false, + bottomPanelHeight: 180, +}; + +describe("getCanvasInsets", () => { + it("counts nothing while every panel is closed", () => { + expect(getCanvasInsets(closed)).toEqual({ left: 0, right: 0, bottom: 0 }); + }); + + it("counts the left sidebar whether the toggle or search opened it", () => { + expect(getCanvasInsets({ ...closed, isLeftSidebarOpen: true }).left).toBe( + 320, + ); + expect(getCanvasInsets({ ...closed, isSearchOpen: true }).left).toBe(320); + }); + + it("counts the properties panel only against a selection", () => { + expect(getCanvasInsets(closed).right).toBe(0); + expect(getCanvasInsets({ ...closed, hasSelection: true }).right).toBe(450); + }); + + it("stacks the assistant on the properties panel, which it docks beside", () => { + expect(getCanvasInsets({ ...closed, isAiAssistantOpen: true }).right).toBe( + 500, + ); + expect( + getCanvasInsets({ + ...closed, + hasSelection: true, + isAiAssistantOpen: true, + }).right, + ).toBe(950); + }); + + it("counts the bottom panel's height, not its open state alone", () => { + expect( + getCanvasInsets({ + ...closed, + isBottomPanelOpen: true, + bottomPanelHeight: 240, + }).bottom, + ).toBe(240); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts new file mode 100644 index 00000000000..503b4305152 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/hooks/use-canvas-insets.ts @@ -0,0 +1,51 @@ +import { use } from "react"; + +import { EditorContext } from "../../react/state/editor-context"; +import { PANEL_MARGIN } from "../constants/ui"; + +/** How much of the canvas each edge's panels cover, in CSS pixels. */ +export interface CanvasInsets { + readonly left: number; + readonly right: number; + readonly bottom: number; +} + +/** The editor state the insets are derived from. */ +export interface PanelLayoutState { + readonly isLeftSidebarOpen: boolean; + readonly isSearchOpen: boolean; + readonly leftSidebarWidth: number; + readonly hasSelection: boolean; + readonly propertiesPanelWidth: number; + readonly isAiAssistantOpen: boolean; + readonly aiAssistantWidth: number; + readonly isBottomPanelOpen: boolean; + readonly bottomPanelHeight: number; +} + +/** + * Each edge's rule is the one the panel on it renders by: search opens the + * left sidebar without the toggle, a selection opens the properties panel, and + * the assistant docks beside the properties panel rather than over it, so an + * open pair covers the sum of the two. + */ +export const getCanvasInsets = (state: PanelLayoutState): CanvasInsets => ({ + left: + state.isLeftSidebarOpen || state.isSearchOpen + ? state.leftSidebarWidth + PANEL_MARGIN + : 0, + right: + (state.hasSelection ? state.propertiesPanelWidth + PANEL_MARGIN : 0) + + (state.isAiAssistantOpen ? state.aiAssistantWidth : 0), + bottom: state.isBottomPanelOpen ? state.bottomPanelHeight + PANEL_MARGIN : 0, +}); + +/** + * What the docked panels take out of the canvas, for the controls that float + * over it and have to keep clear of them. + * + * The panels overlay the canvas rather than shrinking it, so a floating + * control cannot read this off its own layout. + */ +export const useCanvasInsets = (): CanvasInsets => + getCanvasInsets(use(EditorContext)); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.test.ts index af5f6772b47..0e0addd3290 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import { type BottomBarBounds, fitsWithinBounds, - getAvailableWidth, getBottomBarOffset, } from "./bottom-bar-placement"; @@ -50,6 +49,37 @@ describe("getBottomBarOffset", () => { expect((1000 - 400) / 2 + offset).toBe(412); }); + it("keeps the left edge when the panels are lopsided too", () => { + // The right inset alone leaves less than the bar needs, so neither edge + // can be cleared and the left one wins. A symmetric pair would pass this + // by arithmetic accident. + const space = bounds({ leftInset: 120, rightInset: 700 }); + const offset = getBottomBarOffset(space, 400); + + expect((1000 - 400) / 2 + offset).toBe(132); + }); + + it("clears both panels for every width it says fits", () => { + const space = bounds({ leftInset: 250, rightInset: 350 }); + /** Where the bar ends up, once the offset is applied. */ + const placed = (width: number) => { + const left = + (space.containerWidth - width) / 2 + getBottomBarOffset(space, width); + return { left, right: left + width }; + }; + + for (const width of [100, 300, 376]) { + expect(fitsWithinBounds(space, width)).toBe(true); + expect(placed(width).left).toBeGreaterThanOrEqual(262); + expect(placed(width).right).toBeLessThanOrEqual(638); + } + + // A pixel over, and no position clears both: the left edge is what the + // offset holds on to. + expect(fitsWithinBounds(space, 377)).toBe(false); + expect(placed(377).left).toBe(262); + }); + it("stays put until the container and the bar have been measured", () => { expect(getBottomBarOffset(bounds({ containerWidth: 0 }), 400)).toBe(0); expect(getBottomBarOffset(bounds(), 0)).toBe(0); @@ -58,15 +88,11 @@ describe("getBottomBarOffset", () => { describe("fitsWithinBounds", () => { it("counts both insets and both margins against the container", () => { - expect(getAvailableWidth(bounds({ leftInset: 250, rightInset: 350 }))).toBe( - 376, - ); - expect( - fitsWithinBounds(bounds({ leftInset: 250, rightInset: 350 }), 376), - ).toBe(true); - expect( - fitsWithinBounds(bounds({ leftInset: 250, rightInset: 350 }), 377), - ).toBe(false); + // 1000 - 250 - 350 - 2 x 12 + const space = bounds({ leftInset: 250, rightInset: 350 }); + + expect(fitsWithinBounds(space, 376)).toBe(true); + expect(fitsWithinBounds(space, 377)).toBe(false); }); it("imposes no limit before the container has been measured", () => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.ts index 6c2541688ea..d819c54a7c0 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar-placement.ts @@ -16,12 +16,12 @@ export interface BottomBarBounds { readonly leftInset: number; /** Width taken by whatever is docked on the right, viewport controls included. */ readonly rightInset: number; - /** Gap kept between the bar and anything it is pushed away from. */ + /** Gap kept clear inside each inset, so the bar never touches a panel. */ readonly margin: number; } /** Width left for the bar once the insets and both margins are taken out. */ -export const getAvailableWidth = (bounds: BottomBarBounds): number => +const getAvailableWidth = (bounds: BottomBarBounds): number => bounds.containerWidth - bounds.leftInset - bounds.rightInset - diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx index f1603b077fe..3d13e2d0de0 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx @@ -1,4 +1,4 @@ -import { use, useCallback, useEffect, useRef, useState } from "react"; +import { use, useEffect, useRef } from "react"; import { Icon } from "@hashintel/ds-components"; import { css, cva } from "@hashintel/ds-helpers/css"; @@ -11,30 +11,21 @@ import { EditorContext, type EditorState, } from "../../../../../react/state/editor-context"; +import { useIsReadOnly } from "../../../../../react/state/use-is-read-only"; import { AiAssistantIcon } from "../../../../components/ai-assistant-icon"; -import { - VIEWPORT_CONTROLS_OFFSET, - VIEWPORT_CONTROLS_WIDTH, -} from "../../../../constants/ui"; -import { fitsWithinBounds, getBottomBarOffset } from "./bottom-bar-placement"; -import { - BottomBarCollapseContext, - type CollapsibleGroupWidth, -} from "./collapse-context"; +import { BottomBarCollapseContext } from "./collapse-context"; import { CollapsibleGroup } from "./collapsible-group"; +import { CursorModeDropdown } from "./cursor-mode-dropdown"; import { DiagnosticsIndicator } from "./diagnostics-indicator"; +import { EditionTools } from "./edition-tools"; import { SimulationControls } from "./simulation-controls"; import { ToolbarButton } from "./toolbar-button"; import { ToolbarDivider } from "./toolbar-divider"; -import { CursorModeDropdown, EditionTools } from "./toolbar-modes"; -import { useElementWidth } from "./use-element-width"; +import { useBottomBarLayout } from "./use-bottom-bar-layout"; import { useKeyboardShortcuts } from "./use-keyboard-shortcuts"; -/** Gap kept between the bar and a panel it has been pushed away from. */ -const BOTTOM_BAR_MARGIN = 12; - /** Gap between the bar and whatever is below it, canvas or bottom panel. */ -const BOTTOM_BAR_INSET = 24; +const BOTTOM_BAR_GAP = 24; const glassPanelStyle = css({ padding: "1", @@ -42,9 +33,8 @@ const glassPanelStyle = css({ borderWidth: "thin", borderColor: "neutral.a50", boxShadow: "[0 3px 11px rgba(0, 0, 0, 0.1)]", - // Named rather than `all`: the segment's width changes when a group - // collapses, and `all` animated that over 0.3s on top of the group's own - // 150ms, which left the bar drifting past its place and back. + // Named rather than `all`, which would animate the width a folding group + // changes and take twice as long doing it as the group itself. transition: "[background-color 0.3s ease, box-shadow 0.3s ease]", _hover: { backgroundColor: "white.a110", @@ -58,10 +48,8 @@ const toolbarContainerStyle = css({ gap: "1", }); -// Spans the canvas so the bar centers on the canvas rather than on the space +// Spans the canvas so the bar centres on the canvas rather than on the space // between the panels, and lets clicks through everywhere the bar itself is not. -// The lane is anchored a fixed distance from the bottom, and the bar rides -// above the bottom panel on its transform. const bottomBarLaneStyle = css({ position: "absolute", left: "[0]", @@ -76,18 +64,24 @@ const bottomBarStyle = css({ display: "flex", gap: "[20px]", pointerEvents: "auto", + // A bar wider than the lane overflows rather than squashing its segments: + // revealing the hidden controls in a cramped window does exactly that. + flexShrink: 0, }); /** - * Only a panel opening or closing animates the bar into place. A collapse - * moves it too, but there the offset follows the width the bar is measured at, - * frame by frame, and a transition would race that with a curve of its own. - * A resize drag wants no transition either: the bar tracks the panel edge. + * Only a panel opening or closing animates the bar into place. Folding moves + * it too, but there the offset follows the width the bar is measured at, frame + * by frame, and a transition would race that with a curve of its own; a resize + * drag wants none either, so the bar tracks the edge under the pointer. * - * Both axes ride one transform, which the compositor animates. The bottom - * panel slides on its own transform, and a main-thread property could not stay - * with it: the frames dropped while the panel's content mounts leave a - * layout-driven animation behind, and the bar arrives late. + * Both axes ride one transform, which the compositor animates like the panel's + * own slide. A main-thread property could not stay with it: the frames dropped + * while a panel's content mounts leave a layout-driven animation behind. + * + * Reduced motion is deliberately not honoured here. This transition is not + * decoration, it is what keeps the bar attached to a panel that animates + * regardless of the setting, and stopping only the bar detaches it. */ const barAnimatingStyle = cva({ base: {}, @@ -95,9 +89,6 @@ const barAnimatingStyle = cva({ animating: { true: { transition: "[transform 150ms ease-in-out]", - "@media (prefers-reduced-motion: reduce)": { - transition: "[none]", - }, }, }, }, @@ -128,14 +119,8 @@ export const BottomBar: React.FC = ({ isBottomPanelOpen, setBottomPanelOpen, setActiveBottomPanelTab, - bottomPanelHeight, isAiAssistantOpen, isPanelAnimating, - isLeftSidebarOpen, - isSearchOpen, - leftSidebarWidth, - hasSelection, - propertiesPanelWidth, toggleAiAssistant, } = use(EditorContext); @@ -145,15 +130,12 @@ export const BottomBar: React.FC = ({ const hasDiagnostics = errorDiagnosticsCount > 0; const { activeSubnetId } = use(ActiveNetContext); const isInSubnet = activeSubnetId !== null; + const isReadOnly = useIsReadOnly(); - const showDiagnostics = useCallback(() => { + const showDiagnostics = () => { setBottomPanelOpen(true); setActiveBottomPanelTab("diagnostics"); - }, [setBottomPanelOpen, setActiveBottomPanelTab]); - - const toggleBottomPanel = useCallback(() => { - setBottomPanelOpen(!isBottomPanelOpen); - }, [setBottomPanelOpen, isBottomPanelOpen]); + }; // Fallback to cursor mode when switching away from edit while in a mutative mode. useEffect(() => { @@ -167,114 +149,37 @@ export const BottomBar: React.FC = ({ const laneRef = useRef(null); const barRef = useRef(null); - const containerWidth = useElementWidth(laneRef); - const barWidth = useElementWidth(barRef); - - const [groupWidths, setGroupWidths] = useState< - ReadonlyMap - >(() => new Map()); - - // Identity is load-bearing rather than a performance nicety: every group - // measures from an effect keyed on this callback, and a new one each render - // would tear the observers down and report a width in a loop. - const reportGroupWidth = useCallback( - (id: string, width: CollapsibleGroupWidth | null) => { - setGroupWidths((previous) => { - const current = previous.get(id); - if (width === null) { - if (!current) { - return previous; - } - const next = new Map(previous); - next.delete(id); - return next; - } - if ( - current && - current.natural === width.natural && - current.hidden === width.hidden - ) { - return previous; - } - return new Map(previous).set(id, width); - }); - }, - [], - ); - - const [isPointerOver, setIsPointerOver] = useState(false); - const [isFocusWithin, setIsFocusWithin] = useState(false); - const [hasActiveInteraction, setHasActiveInteraction] = useState(false); + const layout = useBottomBarLayout(laneRef, barRef, { + hasViewportControls: !isActualMode, + isAnimating: isPanelAnimating, + }); - // A menu opened from the bar renders outside it, so the pointer leaving for - // the menu would otherwise collapse the bar out from under the click that - // opened it. A click inside the bar holds it expanded until one lands - // elsewhere. - useEffect(() => { - if (!hasActiveInteraction) { - return; - } - - const release = (event: PointerEvent) => { - const bar = barRef.current; - if (bar && event.target instanceof Node && bar.contains(event.target)) { - return; - } - setHasActiveInteraction(false); - }; - - document.addEventListener("pointerdown", release); - return () => document.removeEventListener("pointerdown", release); - }, [hasActiveInteraction]); - - const bounds = { - containerWidth, - leftInset: isLeftSidebarOpen || isSearchOpen ? leftSidebarWidth : 0, - // The viewport controls sit in the bar's row on the right of the canvas, - // so they bound it the same way a panel does. - rightInset: - (hasSelection ? propertiesPanelWidth : 0) + - (isActualMode ? 0 : VIEWPORT_CONTROLS_OFFSET + VIEWPORT_CONTROLS_WIDTH), - margin: BOTTOM_BAR_MARGIN, - }; - - let hiddenWidth = 0; - for (const width of groupWidths.values()) { - hiddenWidth += width.hidden; - } - - // What the bar measures now plus what it is already hiding: the width it - // would take with every control shown. Both terms move together while a - // group collapses, so the sum holds still throughout. - const naturalWidth = barWidth + hiddenWidth; - const isPeeking = isPointerOver || isFocusWithin || hasActiveInteraction; - const isCollapsed = !isPeeking && !fitsWithinBounds(bounds, naturalWidth); - - const panelLift = isBottomPanelOpen ? bottomPanelHeight : 0; + // Edit tools are absent on a read-only net and outside edit mode, so the + // group would otherwise fold an empty box and leave its gap behind. + const hasEditionGroup = !isActualMode && (!isReadOnly || hasAiAssistant); return (
setIsPointerOver(true)} - onPointerLeave={() => setIsPointerOver(false)} - onPointerDown={() => setHasActiveInteraction(true)} - onFocus={() => setIsFocusWithin(true)} - onBlur={(event) => { - if (!event.currentTarget.contains(event.relatedTarget)) { - setIsFocusWithin(false); - } + transform: `translate(${layout.offsetX}px, ${-layout.liftY}px)`, }} + onPointerDown={layout.hold} > - + {/* Edition tools segment */} = ({ cursorMode={cursorMode} onCursorModeChange={onCursorModeChange} /> - {!isActualMode && ( + {hasEditionGroup && ( = ({
setBottomPanelOpen(!isBottomPanelOpen)} ariaLabel={isBottomPanelOpen ? "Hide panel" : "Show panel"} ariaExpanded={isBottomPanelOpen} > diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapse-context.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapse-context.ts index af483ec483c..9239cf424af 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapse-context.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapse-context.ts @@ -2,9 +2,9 @@ import { createContext } from "react"; /** What one collapsible group of toolbar controls takes up, in CSS pixels. */ export interface CollapsibleGroupWidth { - /** Width the group takes when the bar shows everything. */ + /** What this group takes when its controls are shown. */ readonly natural: number; - /** Width currently clipped away — 0 while the group is expanded. */ + /** How much of that is clipped away right now — 0 while it is shown. */ readonly hidden: number; } @@ -25,8 +25,8 @@ export interface BottomBarCollapseValue { * Lets controls anywhere under the bottom bar mark themselves collapsible * without the bar having to know what its segments are made of. * - * The default keeps groups expanded, so the controls render normally outside - * the bar — in Storybook, say. + * The default shows everything and measures nothing: away from the bar there + * is no space to run out of. */ export const BottomBarCollapseContext = createContext({ isCollapsed: false, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx index af976d01e37..27e632ea055 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/collapsible-group.tsx @@ -5,20 +5,21 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import { BottomBarCollapseContext } from "./collapse-context"; /** - * The group collapses by animating its grid column to nothing while the - * content inside keeps its natural width. That keeps the content measurable - * whether it is shown or hidden, so the bar can tell how wide it would be with - * everything shown without laying the controls out twice. + * The group folds by animating its grid column to nothing while the content + * inside keeps its natural width. That keeps the content measurable whether it + * is shown or hidden, so the bar can tell how wide it would be with everything + * shown without laying the controls out twice. + * + * The reveal is a selector on the bar rather than state passed back down: the + * browser maintains `:hover` and `:focus-within` itself, where mirroring them + * into React can strand the bar open — a control that unmounts while focused + * fires no blur. */ const groupStyle = cva({ base: { display: "grid", gridTemplateColumns: "[1fr]", - // Decelerating rather than eased at both ends, and shorter than a panel's - // 150ms: the controls have to be on their way out by the first frame after - // the pointer lands, or the bar reads as slow to answer. - transition: - "[grid-template-columns 120ms ease-out, opacity 120ms ease-out]", + transition: "[grid-template-columns 160ms ease-in, opacity 160ms ease-in]", "@media (prefers-reduced-motion: reduce)": { transition: "[none]", }, @@ -29,6 +30,18 @@ const groupStyle = cva({ gridTemplateColumns: "[0fr]", opacity: "[0]", pointerEvents: "none", + // Revealing answers the pointer, so it runs shorter and decelerates; + // folding is not a response to anything and eases in. The selector + // stays on one line: Panda writes the key into the class name, and a + // wrapped one stops matching the rule it generated. + '[data-bottom-bar]:hover &, [data-bottom-bar]:focus-within &, [data-bottom-bar][data-holding="true"] &': + { + gridTemplateColumns: "[1fr]", + opacity: "[1]", + pointerEvents: "auto", + transition: + "[grid-template-columns 120ms ease-out, opacity 120ms ease-out]", + }, }, }, }, @@ -49,6 +62,10 @@ const contentStyle = css({ /** * Toolbar controls the bottom bar hides when it runs out of room, and shows * again while the pointer or the keyboard is on the bar. + * + * A folded group keeps its controls focusable on purpose: focus is what + * reveals them, so making the subtree inert would leave a keyboard with no way + * in. */ export const CollapsibleGroup: React.FC<{ children: React.ReactNode }> = ({ children, @@ -76,9 +93,9 @@ export const CollapsibleGroup: React.FC<{ children: React.ReactNode }> = ({ measure(); // Both boxes are watched: the content changes when a control appears, and - // the clip changes on every frame of the collapse. Reporting the pair from - // one observer keeps the two in step, so the width the bar derives from - // them is right mid-animation too. + // the clip changes on every frame of the fold. Reporting the pair from one + // observer keeps the two in step, so the width the bar derives from them + // is right mid-animation too. const observer = new ResizeObserver(measure); observer.observe(clip); observer.observe(content); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx new file mode 100644 index 00000000000..1f65d503413 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx @@ -0,0 +1,57 @@ +import { Menu, type MenuItem } from "@hashintel/ds-components"; + +import { + type CursorMode, + type EditorState, +} from "../../../../../react/state/editor-context"; +import { ToolbarMenuTrigger } from "./toolbar-menu-trigger"; + +type EditorEditionMode = EditorState["editionMode"]; + +/** Picks between the select and pan cursors, and returns to cursor mode. */ +export const CursorModeDropdown: React.FC<{ + editionMode: EditorEditionMode; + onEditionModeChange: (mode: EditorEditionMode) => void; + cursorMode: CursorMode; + onCursorModeChange: (mode: CursorMode) => void; +}> = ({ editionMode, onEditionModeChange, cursorMode, onCursorModeChange }) => { + const handleCursorChange = (mode: CursorMode) => { + onCursorModeChange(mode); + onEditionModeChange("cursor"); + }; + + const items: MenuItem[] = [ + { + id: "select", + icon: "cursor", + text: "Select", + suffix: "V", + tone: cursorMode === "select" ? "brand" : "neutral", + selected: cursorMode === "select", + onClick: () => handleCursorChange("select"), + }, + { + id: "pan", + icon: "hand", + text: "Pan", + suffix: "H", + tone: cursorMode === "pan" ? "brand" : "neutral", + selected: cursorMode === "pan", + onClick: () => handleCursorChange("pan"), + }, + ]; + + return ( + + } + items={items} + position="top" + /> + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/edition-tools.tsx similarity index 50% rename from libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx rename to libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/edition-tools.tsx index e6c8efddb36..5b1979d8343 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-modes.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/edition-tools.tsx @@ -1,121 +1,23 @@ import { use } from "react"; -import { Icon, Menu, type MenuItem } from "@hashintel/ds-components"; -import { css, cva } from "@hashintel/ds-helpers/css"; +import { Icon, type MenuItem, Menu } from "@hashintel/ds-components"; import { ActiveNetContext } from "../../../../../react/state/active-net-context"; -import { EditorContext } from "../../../../../react/state/editor-context"; +import { + EditorContext, + type EditorState, +} from "../../../../../react/state/editor-context"; import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../react/state/use-is-read-only"; import { UserSettingsContext } from "../../../../../react/state/user-settings-context"; import { writeDraggedNodeKind } from "../../../shared/canvas-node-drag"; import { ToolbarButton } from "./toolbar-button"; import { ToolbarDivider } from "./toolbar-divider"; - -import type { - CursorMode, - EditorState, -} from "../../../../../react/state/editor-context"; +import { ToolbarMenuTrigger } from "./toolbar-menu-trigger"; type EditorEditionMode = EditorState["editionMode"]; -const cursorTriggerStyle = cva({ - base: { - display: "flex", - alignItems: "center", - justifyContent: "center", - gap: "[2px]", - border: "none", - borderRadius: "lg", - cursor: "pointer", - transition: "[all 0.2s ease]", - backgroundColor: "[transparent]", - color: "neutral.s110", - height: "8", - paddingX: "[6px]", - fontSize: "xl", - "& > *": { - transition: "[transform 0.2s ease]", - }, - _hover: { - color: "neutral.s120", - "& > *": { - transform: "[scale(1.05)]", - }, - }, - _active: { - "& > *": { - transform: "[scale(0.95)]", - }, - }, - }, - variants: { - isActive: { - true: { - color: "[#3b82f6]", - _hover: { - color: "[#2563eb]", - }, - }, - }, - }, -}); - -const dropdownArrowStyle = css({ - opacity: "[0.5]", -}); - -/** Picks between the select and pan cursors, and returns to cursor mode. */ -export const CursorModeDropdown: React.FC<{ - editionMode: EditorEditionMode; - onEditionModeChange: (mode: EditorEditionMode) => void; - cursorMode: CursorMode; - onCursorModeChange: (mode: CursorMode) => void; -}> = ({ editionMode, onEditionModeChange, cursorMode, onCursorModeChange }) => { - const handleCursorChange = (mode: CursorMode) => { - onCursorModeChange(mode); - onEditionModeChange("cursor"); - }; - - const items: MenuItem[] = [ - { - id: "select", - icon: "cursor", - text: "Select", - suffix: "V", - tone: cursorMode === "select" ? "brand" : "neutral", - selected: cursorMode === "select", - onClick: () => handleCursorChange("select"), - }, - { - id: "pan", - icon: "hand", - text: "Pan", - suffix: "H", - tone: cursorMode === "pan" ? "brand" : "neutral", - selected: cursorMode === "pan", - onClick: () => handleCursorChange("pan"), - }, - ]; - - return ( - - {cursorMode === "pan" ? : } - - - } - items={items} - position="top" - /> - ); -}; - +/** Picks which subnet the next component instance comes from. */ const ComponentDropdown: React.FC<{ editionMode: EditorEditionMode; }> = ({ editionMode }) => { @@ -142,20 +44,16 @@ const ComponentDropdown: React.FC<{ }); } - const isActive = - editionMode === "add-component" && componentSubnetId !== null; - return ( - - - + } items={items} position="top" @@ -163,19 +61,14 @@ const ComponentDropdown: React.FC<{ ); }; -interface EditionToolsProps { - editionMode: EditorEditionMode; - onEditionModeChange: (mode: EditorEditionMode) => void; -} - /** * The tools that add nodes to the net. Nothing to offer on a read-only net, * where every one of them is refused. */ -export const EditionTools: React.FC = ({ - editionMode, - onEditionModeChange, -}) => { +export const EditionTools: React.FC<{ + editionMode: EditorEditionMode; + onEditionModeChange: (mode: EditorEditionMode) => void; +}> = ({ editionMode, onEditionModeChange }) => { const isReadOnly = useIsReadOnly(); const { activeSubnetId } = use(ActiveNetContext); const isRootNet = activeSubnetId === null; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx new file mode 100644 index 00000000000..c65850c33bb --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx @@ -0,0 +1,67 @@ +import { Icon, type IconName } from "@hashintel/ds-components"; +import { css, cva } from "@hashintel/ds-helpers/css"; + +const triggerStyle = cva({ + base: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "[2px]", + border: "none", + borderRadius: "lg", + cursor: "pointer", + transition: "[all 0.2s ease]", + backgroundColor: "[transparent]", + color: "neutral.s110", + height: "8", + paddingX: "[6px]", + fontSize: "xl", + "& > *": { + transition: "[transform 0.2s ease]", + }, + _hover: { + color: "neutral.s120", + "& > *": { + transform: "[scale(1.05)]", + }, + }, + _active: { + "& > *": { + transform: "[scale(0.95)]", + }, + }, + }, + variants: { + isActive: { + true: { + color: "[#3b82f6]", + _hover: { + color: "[#2563eb]", + }, + }, + }, + }, +}); + +const chevronStyle = css({ + opacity: "[0.5]", +}); + +/** + * The button a toolbar dropdown opens from: the mode it would apply, and a + * chevron saying there is a choice behind it. + */ +export const ToolbarMenuTrigger: React.FC<{ + icon: IconName; + isActive: boolean; + ariaLabel: string; +}> = ({ icon, isActive, ariaLabel }) => ( + +); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts new file mode 100644 index 00000000000..680ed1a3209 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts @@ -0,0 +1,154 @@ +import { useCallback, useEffect, useState } from "react"; + +import { useElementSize } from "../../../../../react/hooks/use-element-size"; +import { VIEWPORT_CONTROLS_CLEARANCE } from "../../../../constants/ui"; +import { useCanvasInsets } from "../../../../hooks/use-canvas-insets"; +import { fitsWithinBounds, getBottomBarOffset } from "./bottom-bar-placement"; +import { type CollapsibleGroupWidth } from "./collapse-context"; + +/** Gap kept between the bar and a panel it has been pushed away from. */ +const BOTTOM_BAR_MARGIN = 12; + +export interface BottomBarLayout { + /** Shift from the centred position that clears the panels, in px. */ + readonly offsetX: number; + /** How far the bottom panel lifts the bar, in px. */ + readonly liftY: number; + /** True while the bar has room only for its essential controls. */ + readonly isCollapsed: boolean; + /** True while a click inside the bar holds the hidden controls out. */ + readonly isHolding: boolean; + /** Reports what one collapsible group takes up. See the collapse context. */ + readonly reportGroupWidth: ( + id: string, + width: CollapsibleGroupWidth | null, + ) => void; + /** Holds the hidden controls out until a click lands outside the bar. */ + readonly hold: () => void; +} + +/** + * Places the bottom bar between the docked panels and decides whether it has + * room for every control. + * + * The bar is measured rather than modelled: its own width is what the offset + * clamps, and each collapsible group reports both what it takes when shown and + * what it is currently hiding, so the decision never chases itself. + * + * Whether the hidden controls are on screen is not decided here: hover and + * keyboard focus reveal them in CSS, which cannot go stale the way mirrored + * state does. Only the click-hold needs JavaScript, because the menu a click + * opens renders outside the bar. + */ +export const useBottomBarLayout = ( + /** Spans the canvas; the bar is centred in it and measured against it. */ + laneRef: React.RefObject, + barRef: React.RefObject, + { + hasViewportControls, + isAnimating, + }: { + hasViewportControls: boolean; + /** True while a panel opens or closes, and the bar transitions with it. */ + isAnimating: boolean; + }, +): BottomBarLayout => { + const containerWidth = useElementSize(laneRef, { box: "border" })?.width ?? 0; + const barWidth = useElementSize(barRef, { box: "border" })?.width ?? 0; + + const [groupWidths, setGroupWidths] = useState< + ReadonlyMap + >(() => new Map()); + + // Identity is load-bearing rather than a performance nicety: every group + // measures from an effect keyed on this callback, and a new one each render + // would tear the observers down and report a width in a loop. + const reportGroupWidth = useCallback( + (id: string, width: CollapsibleGroupWidth | null) => { + setGroupWidths((previous) => { + const current = previous.get(id); + if (width === null) { + if (!current) { + return previous; + } + const next = new Map(previous); + next.delete(id); + return next; + } + if ( + current && + current.natural === width.natural && + current.hidden === width.hidden + ) { + return previous; + } + return new Map(previous).set(id, width); + }); + }, + [], + ); + + const [isHolding, setIsHolding] = useState(false); + + // The menu a control opens renders outside the bar, so the pointer leaving + // for the menu would collapse the bar out from under the click that opened + // it. A click inside holds the controls out until one lands elsewhere. + useEffect(() => { + if (!isHolding) { + return; + } + + const release = (event: PointerEvent) => { + const bar = barRef.current; + if (bar && event.target instanceof Node && bar.contains(event.target)) { + return; + } + setIsHolding(false); + }; + + document.addEventListener("pointerdown", release); + return () => document.removeEventListener("pointerdown", release); + }, [barRef, isHolding]); + + const insets = useCanvasInsets(); + const bounds = { + containerWidth, + leftInset: insets.left, + // The viewport controls sit in the bar's row on the right of the canvas, + // so they bound it the same way a panel does. They are absent in actual + // mode, where `SDCPNCanvas` does not render them. + rightInset: + insets.right + (hasViewportControls ? VIEWPORT_CONTROLS_CLEARANCE : 0), + margin: BOTTOM_BAR_MARGIN, + }; + + let hiddenWidth = 0; + let collapsibleWidth = 0; + for (const width of groupWidths.values()) { + hiddenWidth += width.hidden; + collapsibleWidth += width.natural; + } + + // What the bar would take with every control shown. Its two terms move + // together while a group folds, so the sum holds still throughout. + const expandedWidth = barWidth + hiddenWidth; + const isCollapsed = !isHolding && !fitsWithinBounds(bounds, expandedWidth); + + // While a panel animates, the bar's own transition carries it there, and an + // offset taken from a width that is itself animating would restart that + // transition every frame. The width the bar is heading for steps once + // instead. With no transition running, the measured width is what keeps the + // bar glued to its own collapse and to a panel edge being dragged. + const settledWidth = isCollapsed + ? expandedWidth - collapsibleWidth + : expandedWidth; + + return { + offsetX: getBottomBarOffset(bounds, isAnimating ? settledWidth : barWidth), + liftY: insets.bottom, + isCollapsed, + isHolding, + reportGroupWidth, + hold: () => setIsHolding(true), + }; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-element-width.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-element-width.ts deleted file mode 100644 index f21b2bf8d40..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-element-width.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useLayoutEffect, useState } from "react"; - -/** - * Tracks the rendered width of `ref`'s element in CSS pixels, 0 until it has - * been measured. Every change is reported, the frames of a CSS transition - * included, so layout derived from the width stays in step with it. - */ -export const useElementWidth = ( - ref: React.RefObject, -): number => { - const [width, setWidth] = useState(0); - - useLayoutEffect(() => { - const element = ref.current; - if (!element) { - return; - } - - const measure = () => setWidth(element.getBoundingClientRect().width); - measure(); - - const observer = new ResizeObserver(measure); - observer.observe(element); - - return () => observer.disconnect(); - }, [ref]); - - return width; -}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx index 6d33f3ec00f..fbca7653be2 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/experiments-story-fixtures.tsx @@ -655,6 +655,7 @@ export function FakeEditorProvider({ setLeftSidebarOpen: () => {}, setLeftSidebarWidth: () => {}, setPropertiesPanelWidth: () => {}, + setAiAssistantWidth: () => {}, setBottomPanelOpen: () => {}, toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx index b197c585cfd..4c39bb19062 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel.test.tsx @@ -84,6 +84,7 @@ const editorContextValue: EditorContextValue = { setLeftSidebarOpen: () => {}, setLeftSidebarWidth: () => {}, setPropertiesPanelWidth: () => {}, + setAiAssistantWidth: () => {}, setBottomPanelOpen: () => {}, toggleBottomPanel: () => {}, setBottomPanelHeight: () => {}, diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx index 42cd71bc79e..22c62fa18a5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/ai-assistant-contents.tsx @@ -15,6 +15,7 @@ import { Button, Icon } from "@hashintel/ds-components"; import { css, cva } from "@hashintel/ds-helpers/css"; import { NotificationsContext } from "../../../../../react/notifications/context"; +import { EditorContext } from "../../../../../react/state/editor-context"; import { VoiceSessionContext } from "../../../../../react/voice-session/context"; import { useVoiceSessionErrorMessage, @@ -76,8 +77,6 @@ export type AiAssistantContentsProps = { voiceModeAvailable?: boolean; }; -const defaultAssistantWidth = 500; - const shellStyle = cva({ base: { position: "absolute", @@ -94,7 +93,6 @@ const shellStyle = cva({ true: { top: "0", bottom: "0", - width: `[${defaultAssistantWidth}px]`, maxWidth: "[calc(100vw - 32px)]", padding: "2", _before: { @@ -672,7 +670,12 @@ export const AiAssistantContents = ({ (message) => revealedIds.has(message.id) && message.role === "user", ).length; - const [assistantWidth, setAssistantWidth] = useState(defaultAssistantWidth); + // Held in editor state, not here: the bottom toolbar and the viewport + // controls have to keep clear of this panel, and cannot read a local value. + const { + aiAssistantWidth: assistantWidth, + setAiAssistantWidth: setAssistantWidth, + } = use(EditorContext); const [chipsDismissed, setChipsDismissed] = useState(false); diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx index 4c3873e5524..d51aac5a2c3 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/viewport-controls.tsx @@ -5,10 +5,8 @@ import { cx, css, cva } from "@hashintel/ds-helpers/css"; import { usePetrinautNavigation } from "../../../../react/navigation"; import { EditorContext } from "../../../../react/state/editor-context"; -import { - PANEL_MARGIN, - VIEWPORT_CONTROLS_OFFSET, -} from "../../../constants/ui"; +import { VIEWPORT_CONTROLS_OFFSET } from "../../../constants/ui"; +import { useCanvasInsets } from "../../../hooks/use-canvas-insets"; import { useCanvasController } from "../canvas-renderer"; import { ViewportSettingsDialog } from "./viewport-settings-dialog"; @@ -47,21 +45,14 @@ export const ViewportControls: React.FC<{ ); }; const { zoomIn, zoomOut } = useCanvasController(); - const { - collapseAllPanels, - hasSelection, - propertiesPanelWidth, - isBottomPanelOpen, - bottomPanelHeight, - isPanelAnimating, - } = use(EditorContext); + const { collapseAllPanels, isPanelAnimating } = use(EditorContext); - const isPropertiesPanelVisible = hasSelection; - const rightOffset = - VIEWPORT_CONTROLS_OFFSET + - (isPropertiesPanelVisible ? propertiesPanelWidth + PANEL_MARGIN : 0); - const bottomOffset = - VIEWPORT_CONTROLS_OFFSET + (isBottomPanelOpen ? bottomPanelHeight + PANEL_MARGIN : 0); + // Shared with the bottom toolbar, so the two keep clear of the same panels + // by the same rules — the assistant panel included, which used to cover the + // column when it opened. + const insets = useCanvasInsets(); + const rightOffset = VIEWPORT_CONTROLS_OFFSET + insets.right; + const bottomOffset = VIEWPORT_CONTROLS_OFFSET + insets.bottom; return (
Date: Fri, 4 Sep 2026 18:12:49 +0200 Subject: [PATCH 6/7] Forward the menu trigger's injected props to its button `Menu` mounts its trigger through Ark's `asChild`, which clones the element to attach the click handling, the trigger ref and its ARIA. Wrapping the button in a component that consumed only its own props dropped all of that, so neither the cursor-mode nor the add-component dropdown opened. --- .../BottomBar/cursor-mode-dropdown.tsx | 2 +- .../components/BottomBar/edition-tools.tsx | 2 +- .../BottomBar/toolbar-menu-trigger.tsx | 20 +++++++++++-------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx index 1f65d503413..1b2d374cae5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/cursor-mode-dropdown.tsx @@ -47,7 +47,7 @@ export const CursorModeDropdown: React.FC<{ } items={items} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/edition-tools.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/edition-tools.tsx index 5b1979d8343..a478d1007ce 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/edition-tools.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/edition-tools.tsx @@ -52,7 +52,7 @@ const ComponentDropdown: React.FC<{ isActive={ editionMode === "add-component" && componentSubnetId !== null } - ariaLabel="Add component" + aria-label="Add component" /> } items={items} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx index c65850c33bb..dd526f2e195 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/toolbar-menu-trigger.tsx @@ -50,17 +50,21 @@ const chevronStyle = css({ /** * The button a toolbar dropdown opens from: the mode it would apply, and a * chevron saying there is a choice behind it. + * + * Every other prop reaches the `button`, ref included. `Menu` mounts its + * trigger through Ark's `asChild`, which clones this element to attach the + * click handling, the trigger ref and its ARIA — anything this component keeps + * to itself never reaches the DOM, and the menu then does not open. */ -export const ToolbarMenuTrigger: React.FC<{ +export const ToolbarMenuTrigger = ({ + icon, + isActive, + ...buttonProps +}: { icon: IconName; isActive: boolean; - ariaLabel: string; -}> = ({ icon, isActive, ariaLabel }) => ( - From 3159005d29cf9f24b3d1e6c536ba0fe7080da846 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 4 Sep 2026 23:38:04 +0200 Subject: [PATCH 7/7] Hold the toolbar open while a menu it anchors is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The click-hold was released by the very menus it protected. A menu opened from the toolbar renders in a portal outside it, so a click on a row that leaves the menu open failed the containment check, cleared the hold, and folded the bar under its own open menu — with the menu still anchored to a control that had just been clipped away. Opening a menu from the keyboard never took the hold at all. The reveal now keys on the menu's own open state, the way the sidebar's row actions already do, so hover, focus and an open menu are the three CSS selectors that reveal the controls and the JavaScript hold is gone. Ark marks its own menu triggers; the playback popover renders its trigger externally, so that one marks itself, which `ToolbarButton` can now carry. --- .../components/BottomBar/bottom-bar.tsx | 2 - .../BottomBar/collapsible-group.tsx | 7 +++- .../BottomBar/playback-settings-menu.tsx | 1 + .../components/BottomBar/toolbar-button.tsx | 8 ++++ .../BottomBar/use-bottom-bar-layout.ts | 39 +++---------------- 5 files changed, 20 insertions(+), 37 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx index 3d13e2d0de0..c594ef13d90 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx @@ -167,12 +167,10 @@ export const BottomBar: React.FC = ({
{ <> void; /** Forwarded to the underlying ` diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts index 680ed1a3209..7444756dd96 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-bottom-bar-layout.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { useElementSize } from "../../../../../react/hooks/use-element-size"; import { VIEWPORT_CONTROLS_CLEARANCE } from "../../../../constants/ui"; @@ -16,15 +16,11 @@ export interface BottomBarLayout { readonly liftY: number; /** True while the bar has room only for its essential controls. */ readonly isCollapsed: boolean; - /** True while a click inside the bar holds the hidden controls out. */ - readonly isHolding: boolean; /** Reports what one collapsible group takes up. See the collapse context. */ readonly reportGroupWidth: ( id: string, width: CollapsibleGroupWidth | null, ) => void; - /** Holds the hidden controls out until a click lands outside the bar. */ - readonly hold: () => void; } /** @@ -35,10 +31,9 @@ export interface BottomBarLayout { * clamps, and each collapsible group reports both what it takes when shown and * what it is currently hiding, so the decision never chases itself. * - * Whether the hidden controls are on screen is not decided here: hover and - * keyboard focus reveal them in CSS, which cannot go stale the way mirrored - * state does. Only the click-hold needs JavaScript, because the menu a click - * opens renders outside the bar. + * Whether the hidden controls are on screen is not decided here at all: hover, + * keyboard focus and an open menu reveal them in CSS, which cannot go stale + * the way mirrored state does. */ export const useBottomBarLayout = ( /** Spans the canvas; the bar is centred in it and measured against it. */ @@ -88,28 +83,6 @@ export const useBottomBarLayout = ( [], ); - const [isHolding, setIsHolding] = useState(false); - - // The menu a control opens renders outside the bar, so the pointer leaving - // for the menu would collapse the bar out from under the click that opened - // it. A click inside holds the controls out until one lands elsewhere. - useEffect(() => { - if (!isHolding) { - return; - } - - const release = (event: PointerEvent) => { - const bar = barRef.current; - if (bar && event.target instanceof Node && bar.contains(event.target)) { - return; - } - setIsHolding(false); - }; - - document.addEventListener("pointerdown", release); - return () => document.removeEventListener("pointerdown", release); - }, [barRef, isHolding]); - const insets = useCanvasInsets(); const bounds = { containerWidth, @@ -132,7 +105,7 @@ export const useBottomBarLayout = ( // What the bar would take with every control shown. Its two terms move // together while a group folds, so the sum holds still throughout. const expandedWidth = barWidth + hiddenWidth; - const isCollapsed = !isHolding && !fitsWithinBounds(bounds, expandedWidth); + const isCollapsed = !fitsWithinBounds(bounds, expandedWidth); // While a panel animates, the bar's own transition carries it there, and an // offset taken from a width that is itself animating would restart that @@ -147,8 +120,6 @@ export const useBottomBarLayout = ( offsetX: getBottomBarOffset(bounds, isAnimating ? settledWidth : barWidth), liftY: insets.bottom, isCollapsed, - isHolding, reportGroupWidth, - hold: () => setIsHolding(true), }; };