From 176f5b4ce15d6c03f35ce1e07237c9d96d9d3fe2 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 10 Mar 2026 18:46:51 +0100 Subject: [PATCH 01/46] Show chevron on hover in VerticalSubViewsContainer Header --- .../vertical/vertical-sub-views-container.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx index e28b4cfe1d3..3058df0d367 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -111,11 +111,20 @@ const resizeHandleStyle = css({ const headerRowStyle = css({ height: "[44px]", - px: "2", + px: "0.5", display: "flex", justifyContent: "space-between", alignItems: "center", + + /* Reveal the chevron icon on hover */ + "& [data-toggle-icon]": { + width: "3.5", + opacity: "[0]", + }, + "&:hover [data-toggle-icon]": { + opacity: "[1]", + }, }); const headerActionStyle = css({ @@ -136,10 +145,12 @@ const sectionToggleStyle = css({ }); const sectionToggleIconStyle = css({ - w: "4", display: "flex", justifyContent: "center", - transition: "[transform 150ms ease-out]", + alignItems: "center", + overflow: "hidden", + transition: + "[width 150ms ease-out, opacity 150ms ease-out, transform 150ms ease-out]", }); const sectionToggleIconExpandedStyle = css({ @@ -271,6 +282,7 @@ const SubViewHeader: React.FC = ({ aria-controls={`subview-content-${id}`} >
Date: Tue, 10 Mar 2026 18:49:48 +0100 Subject: [PATCH 02/46] Add FilterableListSubView and refactor subviews to use it Extract common filterable list pattern into createFilterableListSubView factory. Refactor nodes-list, types-list, parameters-list, and differential-equations-list to use the shared component, reducing duplication of selection handling, row styles, and empty state rendering. Co-Authored-By: Claude Opus 4.6 --- .../subviews/differential-equations-list.tsx | 174 ++++---------- .../subviews/filterable-list-sub-view.tsx | 194 +++++++++++++++ .../LeftSideBar/subviews/nodes-list.tsx | 172 ++++---------- .../LeftSideBar/subviews/parameters-list.tsx | 221 ++++++------------ .../LeftSideBar/subviews/types-list.tsx | 166 ++++--------- 5 files changed, 394 insertions(+), 533 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index 21f4892b107..638c7a6b830 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -1,4 +1,4 @@ -import { css, cva } from "@hashintel/ds-helpers/css"; +import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { TbPlus, TbX } from "react-icons/tb"; import { v4 as uuidv4 } from "uuid"; @@ -9,127 +9,16 @@ import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { DEFAULT_DIFFERENTIAL_EQUATION_CODE } from "../../../../../core/default-codes"; import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; -import type { SelectionItem } from "../../../../../state/selection"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; - -const listContainerStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[4px]", -}); - -const equationRowStyle = cva({ - base: { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - padding: "[4px 2px 4px 8px]", - fontSize: "[13px]", - borderRadius: "sm", - cursor: "pointer", - }, - variants: { - isSelected: { - true: { - backgroundColor: "[rgba(59, 130, 246, 0.15)]", - _hover: { - backgroundColor: "[rgba(59, 130, 246, 0.2)]", - }, - }, - false: { - backgroundColor: "neutral.s10", - _hover: { - backgroundColor: "[rgba(0, 0, 0, 0.05)]", - }, - }, - }, - }, -}); +import { createFilterableListSubView } from "./filterable-list-sub-view"; const equationNameContainerStyle = css({ display: "flex", alignItems: "center", gap: "[6px]", + flex: "[1]", }); -const emptyMessageStyle = css({ - fontSize: "[13px]", - color: "[#9ca3af]", -}); - -/** - * DifferentialEquationsSectionContent displays the list of differential equations. - * This is the content portion without the collapsible header. - */ -const DifferentialEquationsSectionContent: React.FC = () => { - const { - petriNetDefinition: { differentialEquations }, - removeDifferentialEquation, - } = use(SDCPNContext); - - const { isSelected, selectItem, toggleItem } = use(EditorContext); - - const isReadOnly = useIsReadOnly(); - - return ( -
- {differentialEquations.map((eq) => { - const eqSelected = isSelected(eq.id); - const item: SelectionItem = { - type: "differentialEquation", - id: eq.id, - }; - - return ( -
{ - // Don't trigger selection if clicking the delete button - if ( - event.target instanceof HTMLElement && - event.target.closest("button[aria-label^='Delete']") - ) { - return; - } - if (event.metaKey || event.ctrlKey) { - toggleItem(item); - } else { - selectItem(item); - } - }} - role="button" - tabIndex={0} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - selectItem(item); - } - }} - className={equationRowStyle({ isSelected: eqSelected })} - > -
- {eq.name} -
- removeDifferentialEquation(eq.id)} - aria-label={`Delete equation ${eq.name}`} - tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} - > - - -
- ); - })} - {differentialEquations.length === 0 && ( -
No differential equations yet
- )} -
- ); -}; - /** * DifferentialEquationsSectionHeaderAction renders the add button for the section header. */ @@ -168,16 +57,47 @@ const DifferentialEquationsSectionHeaderAction: React.FC = () => { /** * SubView definition for Differential Equations list. */ -export const differentialEquationsListSubView: SubView = { - id: "differential-equations-list", - title: "Differential Equations", - tooltip: `Differential equations govern how token data changes over time when tokens remain in a place ("dynamics").`, - component: DifferentialEquationsSectionContent, - renderHeaderAction: () => , - defaultCollapsed: true, - resizable: { - defaultHeight: 100, - minHeight: 60, - maxHeight: 250, - }, -}; +export const differentialEquationsListSubView: SubView = + createFilterableListSubView({ + id: "differential-equations-list", + title: "Differential Equations", + tooltip: `Differential equations govern how token data changes over time when tokens remain in a place ("dynamics").`, + defaultCollapsed: true, + resizable: { + defaultHeight: 100, + minHeight: 60, + maxHeight: 250, + }, + useItems: () => { + const { + petriNetDefinition: { differentialEquations }, + } = use(SDCPNContext); + return differentialEquations; + }, + getSelectionItem: (eq) => ({ type: "differentialEquation", id: eq.id }), + renderItem: (eq, _isSelected) => { + const { removeDifferentialEquation } = use(SDCPNContext); + const isReadOnly = useIsReadOnly(); + + return ( + <> +
+ {eq.name} +
+ removeDifferentialEquation(eq.id)} + aria-label={`Delete equation ${eq.name}`} + tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} + > + + + + ); + }, + emptyMessage: "No differential equations yet", + renderHeaderAction: () => , + }); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx new file mode 100644 index 00000000000..bf7d0d9ca14 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -0,0 +1,194 @@ +import { css, cva } from "@hashintel/ds-helpers/css"; +import type { ReactNode } from "react"; +import { use } from "react"; +import { TbFilter } from "react-icons/tb"; + +import { IconButton } from "../../../../../components/icon-button"; +import type { + SubView, + SubViewResizeConfig, +} from "../../../../../components/sub-view/types"; +import { EditorContext } from "../../../../../state/editor-context"; +import type { SelectionItem } from "../../../../../state/selection"; + +export const listContainerStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[2px]", +}); + +export const listItemRowStyle = cva({ + base: { + display: "flex", + alignItems: "center", + gap: "[8px]", + padding: "[4px 2px 4px 8px]", + borderRadius: "sm", + cursor: "pointer", + fontSize: "[13px]", + }, + variants: { + isSelected: { + true: { + backgroundColor: "[rgba(59, 130, 246, 0.15)]", + _hover: { + backgroundColor: "[rgba(59, 130, 246, 0.2)]", + }, + }, + false: { + backgroundColor: "[transparent]", + _hover: { + backgroundColor: "[rgba(0, 0, 0, 0.05)]", + }, + }, + }, + }, +}); + +export const listItemNameStyle = css({ + flex: "[1]", + fontSize: "[13px]", + color: "[#374151]", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + +export const emptyMessageStyle = css({ + fontSize: "[13px]", + color: "[#9ca3af]", +}); + +interface FilterableListItem { + id: string; +} + +interface FilterableListSubViewConfig { + id: string; + title: string; + tooltip?: string; + defaultCollapsed?: boolean; + resizable?: SubViewResizeConfig; + useItems: () => T[]; + getSelectionItem: (item: T) => SelectionItem; + renderItem: (item: T, isSelected: boolean) => ReactNode; + emptyMessage: string; + renderHeaderAction?: () => ReactNode; +} + +const FilterHeaderAction: React.FC<{ + renderExtraAction?: () => ReactNode; +}> = ({ renderExtraAction }) => ( + <> + {renderExtraAction?.()} + + + + +); + +function FilterableListContent({ + useItems, + getSelectionItem, + renderItem, + emptyMessage, +}: { + useItems: () => T[]; + getSelectionItem: (item: T) => SelectionItem; + renderItem: (item: T, isSelected: boolean) => ReactNode; + emptyMessage: string; +}) { + const items = useItems(); + const { + isSelected: checkIsSelected, + selectItem, + toggleItem, + } = use(EditorContext); + + return ( +
+ {items.map((item) => { + const isSelected = checkIsSelected(item.id); + const selectionItem = getSelectionItem(item); + + return ( +
{ + if ( + event.target instanceof HTMLElement && + (event.target.closest("button[aria-label^='Delete']") || + event.target.closest("input")) + ) { + return; + } + if (event.metaKey || event.ctrlKey) { + toggleItem(selectionItem); + } else { + selectItem(selectionItem); + } + }} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + selectItem(selectionItem); + } + }} + className={listItemRowStyle({ isSelected })} + > + {renderItem(item, isSelected)} +
+ ); + })} + {items.length === 0 && ( +
{emptyMessage}
+ )} +
+ ); +} + +/** + * Creates a SubView definition for a filterable list. + * + * This factory function encapsulates the common pattern of a list of selectable items + * with a filter button in the header. Each subview can optionally provide an additional + * header action (e.g., an "Add" button) and customize how items are rendered. + */ +export function createFilterableListSubView( + config: FilterableListSubViewConfig, +): SubView { + const { + id, + title, + tooltip, + defaultCollapsed, + resizable, + useItems, + getSelectionItem, + renderItem, + emptyMessage, + renderHeaderAction: renderExtraAction, + } = config; + + const Component: React.FC = () => ( + + ); + + return { + id, + title, + tooltip, + component: Component, + renderHeaderAction: () => ( + + ), + defaultCollapsed, + resizable, + }; +} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx index 7d9dd66c51e..008657f43dd 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx @@ -1,45 +1,10 @@ -import { css, cva } from "@hashintel/ds-helpers/css"; +import { cva } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { FaCircle, FaSquare } from "react-icons/fa6"; import type { SubView } from "../../../../../components/sub-view/types"; -import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; -import type { SelectionItem } from "../../../../../state/selection"; - -const listContainerStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[2px]", -}); - -const nodeRowStyle = cva({ - base: { - display: "flex", - alignItems: "center", - gap: "[6px]", - px: "2", - py: "1", - borderRadius: "md", - cursor: "default", - }, - variants: { - isSelected: { - true: { - backgroundColor: "blue.s20", - _hover: { - backgroundColor: "blue.s30", - }, - }, - false: { - backgroundColor: "[transparent]", - _hover: { - backgroundColor: "[rgba(0, 0, 0, 0.05)]", - }, - }, - }, - }, -}); +import { createFilterableListSubView } from "./filterable-list-sub-view"; const nodeIconStyle = cva({ base: { @@ -78,112 +43,53 @@ const nodeNameStyle = cva({ }, }); -const emptyMessageStyle = css({ - fontSize: "[13px]", - color: "[#9ca3af]", -}); - -/** - * NodesSectionContent displays the list of places and transitions. - * This is the content portion without the collapsible header. - */ -const NodesSectionContent: React.FC = () => { - const { - petriNetDefinition: { places, transitions }, - } = use(SDCPNContext); - const { isSelected, selectItem, toggleItem } = use(EditorContext); - - const handleLayerClick = (event: React.MouseEvent, item: SelectionItem) => { - if (event.metaKey || event.ctrlKey) { - toggleItem(item); - } else { - selectItem(item); - } - }; - - return ( -
- {/* Places */} - {places.map((place) => { - const placeSelected = isSelected(place.id); - const item: SelectionItem = { type: "place", id: place.id }; - return ( -
handleLayerClick(event, item)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - selectItem(item); - } - }} - className={nodeRowStyle({ isSelected: placeSelected })} - > - - - {place.name || `Place ${place.id}`} - -
- ); - })} - - {/* Transitions */} - {transitions.map((transition) => { - const transitionSelected = isSelected(transition.id); - const item: SelectionItem = { - type: "transition", - id: transition.id, - }; - return ( -
handleLayerClick(event, item)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - selectItem(item); - } - }} - className={nodeRowStyle({ isSelected: transitionSelected })} - > - - - {transition.name || `Transition ${transition.id}`} - -
- ); - })} - - {/* Empty state */} - {places.length === 0 && transitions.length === 0 && ( -
No nodes yet
- )} -
- ); -}; +interface NodeItem { + id: string; + name: string; + kind: "place" | "transition"; +} /** * SubView definition for Nodes list. */ -export const nodesListSubView: SubView = { +export const nodesListSubView: SubView = createFilterableListSubView({ id: "nodes-list", title: "Nodes", tooltip: "Manage nodes in the net, including places and transitions. Places represent states in the net, and transitions represent events which change the state of the net.", - component: NodesSectionContent, resizable: { defaultHeight: 150, minHeight: 80, maxHeight: 400, }, -}; + useItems: () => { + const { + petriNetDefinition: { places, transitions }, + } = use(SDCPNContext); + + return [ + ...places.map((place) => ({ + id: place.id, + name: place.name || `Place ${place.id}`, + kind: "place" as const, + })), + ...transitions.map((transition) => ({ + id: transition.id, + name: transition.name || `Transition ${transition.id}`, + kind: "transition" as const, + })), + ]; + }, + getSelectionItem: (node) => ({ type: node.kind, id: node.id }), + renderItem: (node, isSelected) => ( + <> + {node.kind === "place" ? ( + + ) : ( + + )} + {node.name} + + ), + emptyMessage: "No nodes yet", +}); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index 3745aff7cce..99700d817b1 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -10,43 +10,8 @@ import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { SimulationContext } from "../../../../../simulation/context"; import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; -import type { SelectionItem } from "../../../../../state/selection"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; - -const listContainerStyle = css({ - display: "flex", - flexDirection: "column", - gap: "1", -}); - -const parameterRowStyle = cva({ - base: { - width: "[100%]", - display: "flex", - alignItems: "center", - justifyContent: "space-between", - padding: "[4px 2px 4px 8px]", - fontSize: "[13px]", - borderRadius: "sm", - cursor: "pointer", - }, - variants: { - isSelected: { - true: { - backgroundColor: "[rgba(59, 130, 246, 0.15)]", - _hover: { - backgroundColor: "[rgba(59, 130, 246, 0.2)]", - }, - }, - false: { - backgroundColor: "neutral.s10", - _hover: { - backgroundColor: "[rgba(0, 0, 0, 0.03)]", - }, - }, - }, - }, -}); +import { createFilterableListSubView } from "./filterable-list-sub-view"; const parameterVarNameStyle = css({ margin: "[0]", @@ -65,11 +30,6 @@ const parameterValueInputStyle = css({ textAlign: "right", }); -const emptyMessageStyle = css({ - fontSize: "[13px]", - color: "neutral.s85", -}); - /** * Header action component for adding parameters. * Shown in the panel header when not in simulation mode. @@ -109,124 +69,89 @@ const ParametersHeaderAction: React.FC = () => { ); }; -/** - * ParametersList displays global parameters list as a SubView. - */ -const ParametersList: React.FC = () => { - const { - petriNetDefinition: { parameters }, - removeParameter, - } = use(SDCPNContext); - const { globalMode, isSelected, selectItem, toggleItem } = use(EditorContext); - const { - state: simulationState, - parameterValues, - setParameterValue, - } = use(SimulationContext); - - const isReadOnly = useIsReadOnly(); - const isSimulationNotRun = - globalMode === "simulate" && simulationState === "NotRun"; - const isSimulationMode = globalMode === "simulate"; - - return ( -
-
- {parameters.map((param) => { - const paramSelected = isSelected(param.id); - const item: SelectionItem = { type: "parameter", id: param.id }; - - return ( -
{ - // Don't trigger selection if clicking the delete button or input - if ( - event.target instanceof HTMLElement && - (event.target.closest("button[aria-label^='Delete']") || - event.target.closest("input")) - ) { - return; - } - if (event.metaKey || event.ctrlKey) { - toggleItem(item); - } else { - selectItem(item); - } - }} - role="button" - tabIndex={0} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - selectItem(item); - } - }} - className={parameterRowStyle({ isSelected: paramSelected })} - > -
-
{param.name}
-
-                  {param.variableName}
-                
-
-
- {isSimulationMode ? ( - - setParameterValue( - param.variableName, - (event.target as HTMLInputElement).value, - ) - } - placeholder={param.defaultValue} - readOnly={!isSimulationNotRun} - className={parameterValueInputStyle} - /> - ) : ( - removeParameter(param.id)} - aria-label={`Delete ${param.name}`} - tooltip={ - isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined - } - > - - - )} -
-
- ); - })} - {parameters.length === 0 && ( -
No global parameters yet
- )} -
-
- ); -}; +// Custom row style for parameters - overrides the default to add space-between layout +const parameterRowContentStyle = cva({ + base: { + width: "[100%]", + display: "flex", + alignItems: "center", + justifyContent: "space-between", + }, +}); /** * SubView definition for Global Parameters List. */ -export const parametersListSubView: SubView = { +export const parametersListSubView: SubView = createFilterableListSubView({ id: "parameters-list", title: "Global Parameters", tooltip: "Parameters are injected into dynamics, lambda, and kernel functions.", - component: ParametersList, - renderHeaderAction: () => , defaultCollapsed: true, resizable: { defaultHeight: 100, minHeight: 60, maxHeight: 250, }, -}; + useItems: () => { + const { + petriNetDefinition: { parameters }, + } = use(SDCPNContext); + return parameters; + }, + getSelectionItem: (param) => ({ type: "parameter", id: param.id }), + renderItem: (param, _isSelected) => { + const { removeParameter } = use(SDCPNContext); + const { globalMode } = use(EditorContext); + const { + state: simulationState, + parameterValues, + setParameterValue, + } = use(SimulationContext); + + const isReadOnly = useIsReadOnly(); + const isSimulationNotRun = + globalMode === "simulate" && simulationState === "NotRun"; + const isSimulationMode = globalMode === "simulate"; + + return ( +
+
+
{param.name}
+
{param.variableName}
+
+
+ {isSimulationMode ? ( + + setParameterValue( + param.variableName, + (event.target as HTMLInputElement).value, + ) + } + placeholder={param.defaultValue} + readOnly={!isSimulationNotRun} + className={parameterValueInputStyle} + /> + ) : ( + removeParameter(param.id)} + aria-label={`Delete ${param.name}`} + tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} + > + + + )} +
+
+ ); + }, + emptyMessage: "No global parameters yet", + renderHeaderAction: () => , +}); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index 24039cd5ee6..0bad7e0b421 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -1,4 +1,4 @@ -import { css, cva } from "@hashintel/ds-helpers/css"; +import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { TbPlus, TbX } from "react-icons/tb"; @@ -7,41 +7,11 @@ import type { SubView } from "../../../../../components/sub-view/types"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; -import type { SelectionItem } from "../../../../../state/selection"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; - -const listContainerStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[2px]", -}); - -const typeRowStyle = cva({ - base: { - display: "flex", - alignItems: "center", - gap: "[8px]", - padding: "[4px 2px 4px 8px]", - borderRadius: "sm", - cursor: "pointer", - }, - variants: { - isSelected: { - true: { - backgroundColor: "[rgba(59, 130, 246, 0.15)]", - _hover: { - backgroundColor: "[rgba(59, 130, 246, 0.2)]", - }, - }, - false: { - backgroundColor: "[transparent]", - _hover: { - backgroundColor: "[rgba(0, 0, 0, 0.05)]", - }, - }, - }, - }, -}); +import { + createFilterableListSubView, + listItemNameStyle, +} from "./filterable-list-sub-view"; const colorDotStyle = css({ width: "[12px]", @@ -50,20 +20,6 @@ const colorDotStyle = css({ flexShrink: 0, }); -const typeNameStyle = css({ - flex: "[1]", - fontSize: "[13px]", - color: "[#374151]", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}); - -const emptyMessageStyle = css({ - fontSize: "[13px]", - color: "[#9ca3af]", -}); - // Pool of 10 well-differentiated colors for types const TYPE_COLOR_POOL = [ "#3b82f6", // Blue @@ -108,78 +64,6 @@ function getNextTypeNumber(existingNames: string[]): number { return maxNumber + 1; } -/** - * TypesSectionContent displays the list of token types. - * This is the content portion without the collapsible header. - */ -const TypesSectionContent: React.FC = () => { - const { - petriNetDefinition: { types }, - removeType, - } = use(SDCPNContext); - - const { isSelected, selectItem, toggleItem } = use(EditorContext); - - const isReadOnly = useIsReadOnly(); - - return ( -
- {types.map((type) => { - const typeSelected = isSelected(type.id); - const item: SelectionItem = { type: "type", id: type.id }; - - return ( -
{ - // Don't trigger selection if clicking the delete button - if ( - event.target instanceof HTMLElement && - event.target.closest("button[aria-label^='Delete']") - ) { - return; - } - if (event.metaKey || event.ctrlKey) { - toggleItem(item); - } else { - selectItem(item); - } - }} - role="button" - tabIndex={0} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - selectItem(item); - } - }} - className={typeRowStyle({ isSelected: typeSelected })} - > -
- {type.name} - removeType(type.id)} - aria-label={`Delete token type ${type.name}`} - tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} - > - - -
- ); - })} - {types.length === 0 && ( -
No token types yet
- )} -
- ); -}; - /** * TypesSectionHeaderAction renders the add button for the types section header. */ @@ -230,16 +114,48 @@ const TypesSectionHeaderAction: React.FC = () => { /** * SubView definition for Token Types list. */ -export const typesListSubView: SubView = { +export const typesListSubView: SubView = createFilterableListSubView({ id: "token-types-list", title: "Token Types", tooltip: "Manage data types which can be assigned to tokens in a place.", - component: TypesSectionContent, - renderHeaderAction: () => , defaultCollapsed: true, resizable: { defaultHeight: 120, minHeight: 60, maxHeight: 300, }, -}; + useItems: () => { + const { + petriNetDefinition: { types }, + } = use(SDCPNContext); + return types; + }, + getSelectionItem: (type) => ({ type: "type", id: type.id }), + renderItem: (type, _isSelected) => { + const { removeType } = use(SDCPNContext); + const isReadOnly = useIsReadOnly(); + + return ( + <> +
+ {type.name} + removeType(type.id)} + aria-label={`Delete token type ${type.name}`} + tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} + > + + + + ); + }, + emptyMessage: "No token types yet", + renderHeaderAction: () => , +}); From 1d19cf064b3b79ab4d68a47d19a6e8a19a79ad4f Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 10 Mar 2026 19:15:47 +0100 Subject: [PATCH 03/46] Style list items to match Figma design and replace delete with ellipsis menu - Update list item styles: 32px height, 8px border radius, 14px font, semantic color tokens, neutral hover/selected backgrounds - Replace inline delete button with horizontal ellipsis (TbDots) that opens a Menu with a destructive "Delete" action - Ellipsis button fades in on row hover with subtle icon slide animation, stays visible while menu is open via data-state selector - Add text overflow ellipsis to all list item names - Update nodes-list font size and colors to match design tokens Co-Authored-By: Claude Opus 4.6 --- .../subviews/differential-equations-list.tsx | 36 +++++++---- .../subviews/filterable-list-sub-view.tsx | 61 ++++++++++++++----- .../LeftSideBar/subviews/nodes-list.tsx | 6 +- .../LeftSideBar/subviews/parameters-list.tsx | 51 ++++++++++++---- .../LeftSideBar/subviews/types-list.tsx | 32 ++++++---- 5 files changed, 129 insertions(+), 57 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index 638c7a6b830..ad8ab6a94f6 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -1,9 +1,10 @@ import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; -import { TbPlus, TbX } from "react-icons/tb"; +import { TbDots, TbPlus, TbTrash } from "react-icons/tb"; import { v4 as uuidv4 } from "uuid"; import { IconButton } from "../../../../../components/icon-button"; +import { Menu } from "../../../../../components/menu"; import type { SubView } from "../../../../../components/sub-view/types"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { DEFAULT_DIFFERENTIAL_EQUATION_CODE } from "../../../../../core/default-codes"; @@ -17,6 +18,10 @@ const equationNameContainerStyle = css({ alignItems: "center", gap: "[6px]", flex: "[1]", + minWidth: "[0]", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", }); /** @@ -84,17 +89,24 @@ export const differentialEquationsListSubView: SubView =
{eq.name}
- removeDifferentialEquation(eq.id)} - aria-label={`Delete equation ${eq.name}`} - tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} - > - - + + + + } + items={[ + { + id: "delete", + label: "Delete", + icon: , + destructive: true, + disabled: isReadOnly, + onClick: () => removeDifferentialEquation(eq.id), + }, + ]} + /> ); }, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index bf7d0d9ca14..365a9698508 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -14,49 +14,78 @@ import type { SelectionItem } from "../../../../../state/selection"; export const listContainerStyle = css({ display: "flex", flexDirection: "column", - gap: "[2px]", }); export const listItemRowStyle = cva({ base: { display: "flex", alignItems: "center", - gap: "[8px]", - padding: "[4px 2px 4px 8px]", - borderRadius: "sm", + gap: "1", + minHeight: "8", + pl: "2", + pr: "1", + py: "1", + borderRadius: "lg", cursor: "pointer", - fontSize: "[13px]", + fontSize: "sm", + fontWeight: "medium", + color: "neutral.s105", + + /* Reveal the action button on hover or when its menu is open */ + "& [data-row-action]": { + opacity: "[0]", + transition: "[opacity 150ms ease-out]", + }, + "& [data-row-action] svg": { + transform: "[translateX(4px)]", + transition: "[transform 150ms ease-out]", + }, + "&:hover [data-row-action], & [data-row-action][data-state=open]": { + opacity: "[1]", + }, + "&:hover [data-row-action] svg, & [data-row-action][data-state=open] svg": { + transform: "none", + }, }, variants: { isSelected: { true: { - backgroundColor: "[rgba(59, 130, 246, 0.15)]", + backgroundColor: "neutral.bg.subtle", _hover: { - backgroundColor: "[rgba(59, 130, 246, 0.2)]", + backgroundColor: "neutral.bg.subtle.hover", }, }, false: { backgroundColor: "[transparent]", _hover: { - backgroundColor: "[rgba(0, 0, 0, 0.05)]", + backgroundColor: "neutral.bg.subtle.hover", }, }, }, }, }); +export const listItemContentStyle = css({ + display: "flex", + alignItems: "center", + gap: "1.5", + flex: "[1]", + minWidth: "[0]", +}); + export const listItemNameStyle = css({ flex: "[1]", - fontSize: "[13px]", - color: "[#374151]", + fontSize: "sm", + fontWeight: "medium", + color: "neutral.s105", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", }); export const emptyMessageStyle = css({ - fontSize: "[13px]", - color: "[#9ca3af]", + fontSize: "sm", + color: "neutral.s85", }); interface FilterableListItem { @@ -87,7 +116,7 @@ const FilterHeaderAction: React.FC<{ ); -function FilterableListContent({ +const FilterableListContent = ({ useItems, getSelectionItem, renderItem, @@ -97,7 +126,7 @@ function FilterableListContent({ getSelectionItem: (item: T) => SelectionItem; renderItem: (item: T, isSelected: boolean) => ReactNode; emptyMessage: string; -}) { +}) => { const items = useItems(); const { isSelected: checkIsSelected, @@ -117,7 +146,7 @@ function FilterableListContent({ onClick={(event) => { if ( event.target instanceof HTMLElement && - (event.target.closest("button[aria-label^='Delete']") || + (event.target.closest("button[aria-label='More options']") || event.target.closest("input")) ) { return; @@ -146,7 +175,7 @@ function FilterableListContent({ )}
); -} +}; /** * Creates a SubView definition for a filterable list. diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx index 008657f43dd..c35fe0254a1 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx @@ -24,7 +24,7 @@ const nodeIconStyle = cva({ const nodeNameStyle = cva({ base: { - fontSize: "[13px]", + fontSize: "sm", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", @@ -33,11 +33,9 @@ const nodeNameStyle = cva({ isSelected: { true: { color: "[#1e40af]", - fontWeight: "medium", }, false: { - color: "[#374151]", - fontWeight: "normal", + color: "neutral.s105", }, }, }, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index 99700d817b1..f60b0ebce91 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -1,9 +1,10 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import { use } from "react"; -import { TbPlus, TbX } from "react-icons/tb"; +import { TbDots, TbPlus, TbTrash } from "react-icons/tb"; import { v4 as uuidv4 } from "uuid"; import { IconButton } from "../../../../../components/icon-button"; +import { Menu } from "../../../../../components/menu"; import { NumberInput } from "../../../../../components/number-input"; import type { SubView } from "../../../../../components/sub-view/types"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; @@ -76,6 +77,19 @@ const parameterRowContentStyle = cva({ display: "flex", alignItems: "center", justifyContent: "space-between", + minWidth: "[0]", + gap: "1", + }, +}); + +const parameterNameStyle = css({ + flex: "[1]", + minWidth: "[0]", + overflow: "hidden", + "& > div": { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", }, }); @@ -116,7 +130,7 @@ export const parametersListSubView: SubView = createFilterableListSubView({ return (
-
+
{param.name}
{param.variableName}
@@ -136,17 +150,28 @@ export const parametersListSubView: SubView = createFilterableListSubView({ className={parameterValueInputStyle} /> ) : ( - removeParameter(param.id)} - aria-label={`Delete ${param.name}`} - tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} - > - - + + + + } + items={[ + { + id: "delete", + label: "Delete", + icon: , + destructive: true, + disabled: isReadOnly, + onClick: () => removeParameter(param.id), + }, + ]} + /> )}
diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index 0bad7e0b421..4c6847081a3 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -1,8 +1,9 @@ import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; -import { TbPlus, TbX } from "react-icons/tb"; +import { TbDots, TbPlus, TbTrash } from "react-icons/tb"; import { IconButton } from "../../../../../components/icon-button"; +import { Menu } from "../../../../../components/menu"; import type { SubView } from "../../../../../components/sub-view/types"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { EditorContext } from "../../../../../state/editor-context"; @@ -142,17 +143,24 @@ export const typesListSubView: SubView = createFilterableListSubView({ style={{ backgroundColor: type.displayColor }} /> {type.name} - removeType(type.id)} - aria-label={`Delete token type ${type.name}`} - tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} - > - - + + + + } + items={[ + { + id: "delete", + label: "Delete", + icon: , + destructive: true, + disabled: isReadOnly, + onClick: () => removeType(type.id), + }, + ]} + /> ); }, From 6bb23a30d655ad80949620b02e7cde470c2fc66c Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 10 Mar 2026 19:23:02 +0100 Subject: [PATCH 04/46] Update InfoIconTooltip --- .../petrinaut/src/components/tooltip.tsx | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/tooltip.tsx b/libs/@hashintel/petrinaut/src/components/tooltip.tsx index 24841281799..ce5c713077b 100644 --- a/libs/@hashintel/petrinaut/src/components/tooltip.tsx +++ b/libs/@hashintel/petrinaut/src/components/tooltip.tsx @@ -3,6 +3,7 @@ import { Portal } from "@ark-ui/react/portal"; import { Tooltip as ArkTooltip } from "@ark-ui/react/tooltip"; import { css, cva, cx } from "@hashintel/ds-helpers/css"; import type { ReactNode } from "react"; +import { FaInfoCircle } from "react-icons/fa"; import { usePortalContainerRef } from "../state/portal-container-context"; @@ -111,31 +112,17 @@ export const Tooltip: React.FC = ({ const circleInfoIconStyle = css({ display: "inline-block", - width: "[11px]", - height: "[11px]", - marginLeft: "1.5", - marginBottom: "[1.6px]", + marginLeft: "1", + marginBottom: "[2px]", color: "neutral.s85", verticalAlign: "middle", fill: "[currentColor]", }); -const CircleInfoIcon = () => { - return ( - - ); -}; - export const InfoIconTooltip = ({ tooltip }: { tooltip: string }) => { return ( - + ); }; From 6a3873a90ae972f73cd957b6e755054b383e8540 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 10 Mar 2026 19:23:36 +0100 Subject: [PATCH 05/46] Update tooltip border-radius --- libs/@hashintel/petrinaut/src/components/tooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@hashintel/petrinaut/src/components/tooltip.tsx b/libs/@hashintel/petrinaut/src/components/tooltip.tsx index ce5c713077b..886b4052786 100644 --- a/libs/@hashintel/petrinaut/src/components/tooltip.tsx +++ b/libs/@hashintel/petrinaut/src/components/tooltip.tsx @@ -10,7 +10,7 @@ import { usePortalContainerRef } from "../state/portal-container-context"; const tooltipContentStyle = css({ backgroundColor: "neutral.s120", color: "neutral.s10", - borderRadius: "xl", + borderRadius: "lg", fontSize: "[13px]", zIndex: "tooltip", boxShadow: "[0 2px 8px rgba(0, 0, 0, 0.15)]", From 68be0c8b9801108caf2925ffc41e4ede9adc96e5 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 10 Mar 2026 19:43:52 +0100 Subject: [PATCH 06/46] Move row menu to FilterableListSubView and add filter/sort/search buttons - Add getMenuItems config param so the container owns Menu rendering - Extract RowMenu component that skips rendering when items array is empty - Move menu item definitions from renderItem to getMenuItems in all subviews - Replace TbFilter with LuListFilter, add LuArrowDownWideNarrow (sort) and LuSearch (search) icon buttons in the header (no-ops for now) - Place filter/sort/search buttons before subview-specific actions Co-Authored-By: Claude Opus 4.6 --- .../subviews/differential-equations-list.tsx | 45 ++++++-------- .../subviews/filterable-list-sub-view.tsx | 61 +++++++++++++++++-- .../LeftSideBar/subviews/parameters-list.tsx | 58 ++++++++---------- .../LeftSideBar/subviews/types-list.tsx | 56 +++++++---------- 4 files changed, 120 insertions(+), 100 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index ad8ab6a94f6..3711c248b48 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -1,10 +1,9 @@ import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; -import { TbDots, TbPlus, TbTrash } from "react-icons/tb"; +import { TbPlus, TbTrash } from "react-icons/tb"; import { v4 as uuidv4 } from "uuid"; import { IconButton } from "../../../../../components/icon-button"; -import { Menu } from "../../../../../components/menu"; import type { SubView } from "../../../../../components/sub-view/types"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { DEFAULT_DIFFERENTIAL_EQUATION_CODE } from "../../../../../core/default-codes"; @@ -80,35 +79,25 @@ export const differentialEquationsListSubView: SubView = return differentialEquations; }, getSelectionItem: (eq) => ({ type: "differentialEquation", id: eq.id }), - renderItem: (eq, _isSelected) => { + renderItem: (eq) => ( +
+ {eq.name} +
+ ), + getMenuItems: (eq) => { const { removeDifferentialEquation } = use(SDCPNContext); const isReadOnly = useIsReadOnly(); - return ( - <> -
- {eq.name} -
- - - - } - items={[ - { - id: "delete", - label: "Delete", - icon: , - destructive: true, - disabled: isReadOnly, - onClick: () => removeDifferentialEquation(eq.id), - }, - ]} - /> - - ); + return [ + { + id: "delete", + label: "Delete", + icon: , + destructive: true, + disabled: isReadOnly, + onClick: () => removeDifferentialEquation(eq.id), + }, + ]; }, emptyMessage: "No differential equations yet", renderHeaderAction: () => , diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 365a9698508..4a4389d5523 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -1,9 +1,12 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import type { ReactNode } from "react"; import { use } from "react"; -import { TbFilter } from "react-icons/tb"; +import { LuArrowDownWideNarrow, LuListFilter, LuSearch } from "react-icons/lu"; +import { TbDots } from "react-icons/tb"; import { IconButton } from "../../../../../components/icon-button"; +import type { MenuItem } from "../../../../../components/menu"; +import { Menu } from "../../../../../components/menu"; import type { SubView, SubViewResizeConfig, @@ -29,7 +32,9 @@ export const listItemRowStyle = cva({ cursor: "pointer", fontSize: "sm", fontWeight: "medium", - color: "neutral.s105", + color: "neutral.s115", + + transition: "[background-color 100ms ease-out, opacity 150ms ease-out]", /* Reveal the action button on hover or when its menu is open */ "& [data-row-action]": { @@ -37,7 +42,7 @@ export const listItemRowStyle = cva({ transition: "[opacity 150ms ease-out]", }, "& [data-row-action] svg": { - transform: "[translateX(4px)]", + transform: "[translateX(2px)]", transition: "[transform 150ms ease-out]", }, "&:hover [data-row-action], & [data-row-action][data-state=open]": { @@ -58,7 +63,7 @@ export const listItemRowStyle = cva({ false: { backgroundColor: "[transparent]", _hover: { - backgroundColor: "neutral.bg.subtle.hover", + backgroundColor: "neutral.bg.surface.hover", }, }, }, @@ -101,6 +106,8 @@ interface FilterableListSubViewConfig { useItems: () => T[]; getSelectionItem: (item: T) => SelectionItem; renderItem: (item: T, isSelected: boolean) => ReactNode; + /** Return menu items for the row's ellipsis menu. When omitted, no menu is shown. */ + getMenuItems?: (item: T) => MenuItem[]; emptyMessage: string; renderHeaderAction?: () => ReactNode; } @@ -109,22 +116,59 @@ const FilterHeaderAction: React.FC<{ renderExtraAction?: () => ReactNode; }> = ({ renderExtraAction }) => ( <> - {renderExtraAction?.()} - + + + + + + + + {renderExtraAction?.()} ); +/** + * Renders the row ellipsis menu. Separated into its own component so that + * `getMenuItems` (which may call hooks) is invoked as part of a component render. + */ +const RowMenu = ({ + getMenuItems, + item, +}: { + getMenuItems: (item: T) => MenuItem[]; + item: T; +}) => { + const menuItems = getMenuItems(item); + if (menuItems.length === 0) { + return null; + } + + return ( + + + + } + items={menuItems} + /> + ); +}; + const FilterableListContent = ({ useItems, getSelectionItem, renderItem, + getMenuItems, emptyMessage, }: { useItems: () => T[]; getSelectionItem: (item: T) => SelectionItem; renderItem: (item: T, isSelected: boolean) => ReactNode; + getMenuItems?: (item: T) => MenuItem[]; emptyMessage: string; }) => { const items = useItems(); @@ -167,6 +211,9 @@ const FilterableListContent = ({ className={listItemRowStyle({ isSelected })} > {renderItem(item, isSelected)} + {getMenuItems && ( + + )}
); })} @@ -196,6 +243,7 @@ export function createFilterableListSubView( useItems, getSelectionItem, renderItem, + getMenuItems, emptyMessage, renderHeaderAction: renderExtraAction, } = config; @@ -205,6 +253,7 @@ export function createFilterableListSubView( useItems={useItems} getSelectionItem={getSelectionItem} renderItem={renderItem} + getMenuItems={getMenuItems} emptyMessage={emptyMessage} /> ); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index f60b0ebce91..3b1cffbf141 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -1,10 +1,9 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import { use } from "react"; -import { TbDots, TbPlus, TbTrash } from "react-icons/tb"; +import { TbPlus, TbTrash } from "react-icons/tb"; import { v4 as uuidv4 } from "uuid"; import { IconButton } from "../../../../../components/icon-button"; -import { Menu } from "../../../../../components/menu"; import { NumberInput } from "../../../../../components/number-input"; import type { SubView } from "../../../../../components/sub-view/types"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; @@ -114,8 +113,7 @@ export const parametersListSubView: SubView = createFilterableListSubView({ return parameters; }, getSelectionItem: (param) => ({ type: "parameter", id: param.id }), - renderItem: (param, _isSelected) => { - const { removeParameter } = use(SDCPNContext); + renderItem: (param) => { const { globalMode } = use(EditorContext); const { state: simulationState, @@ -123,7 +121,6 @@ export const parametersListSubView: SubView = createFilterableListSubView({ setParameterValue, } = use(SimulationContext); - const isReadOnly = useIsReadOnly(); const isSimulationNotRun = globalMode === "simulate" && simulationState === "NotRun"; const isSimulationMode = globalMode === "simulate"; @@ -134,8 +131,8 @@ export const parametersListSubView: SubView = createFilterableListSubView({
{param.name}
{param.variableName}
-
- {isSimulationMode ? ( + {isSimulationMode && ( +
- ) : ( - - - - } - items={[ - { - id: "delete", - label: "Delete", - icon: , - destructive: true, - disabled: isReadOnly, - onClick: () => removeParameter(param.id), - }, - ]} - /> - )} -
+
+ )} ); }, + getMenuItems: (param) => { + const { removeParameter } = use(SDCPNContext); + const { globalMode } = use(EditorContext); + const isReadOnly = useIsReadOnly(); + + if (globalMode === "simulate") { + return []; + } + + return [ + { + id: "delete", + label: "Delete", + icon: , + destructive: true, + disabled: isReadOnly, + onClick: () => removeParameter(param.id), + }, + ]; + }, emptyMessage: "No global parameters yet", renderHeaderAction: () => , }); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index 4c6847081a3..70b3cf3118f 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -1,18 +1,14 @@ import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; -import { TbDots, TbPlus, TbTrash } from "react-icons/tb"; +import { TbPlus, TbTrash } from "react-icons/tb"; import { IconButton } from "../../../../../components/icon-button"; -import { Menu } from "../../../../../components/menu"; import type { SubView } from "../../../../../components/sub-view/types"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; -import { - createFilterableListSubView, - listItemNameStyle, -} from "./filterable-list-sub-view"; +import { createFilterableListSubView } from "./filterable-list-sub-view"; const colorDotStyle = css({ width: "[12px]", @@ -132,37 +128,29 @@ export const typesListSubView: SubView = createFilterableListSubView({ return types; }, getSelectionItem: (type) => ({ type: "type", id: type.id }), - renderItem: (type, _isSelected) => { + renderItem: (type) => ( + <> +
+ {type.name} + + ), + getMenuItems: (type) => { const { removeType } = use(SDCPNContext); const isReadOnly = useIsReadOnly(); - return ( - <> -
- {type.name} - - - - } - items={[ - { - id: "delete", - label: "Delete", - icon: , - destructive: true, - disabled: isReadOnly, - onClick: () => removeType(type.id), - }, - ]} - /> - - ); + return [ + { + id: "delete", + label: "Delete", + icon: , + destructive: true, + disabled: isReadOnly, + onClick: () => removeType(type.id), + }, + ]; }, emptyMessage: "No token types yet", renderHeaderAction: () => , From 110ba5f7cd9d6b794937fb55a31f1218bf2238ee Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 10 Mar 2026 19:48:30 +0100 Subject: [PATCH 07/46] Tweak colors and paddings --- libs/@hashintel/petrinaut/src/components/menu.tsx | 2 +- .../sub-view/vertical/vertical-sub-views-container.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/menu.tsx b/libs/@hashintel/petrinaut/src/components/menu.tsx index c7d6bfc3b85..6eed2f6e347 100644 --- a/libs/@hashintel/petrinaut/src/components/menu.tsx +++ b/libs/@hashintel/petrinaut/src/components/menu.tsx @@ -117,7 +117,7 @@ const itemStyle = cva({ }, destructive: { true: { - color: "red.s60", + color: "red.s100", }, }, }, diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx index 3058df0d367..6f9226f56ce 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -111,7 +111,8 @@ const resizeHandleStyle = css({ const headerRowStyle = css({ height: "[44px]", - px: "0.5", + pl: "0.5", + pr: "2", display: "flex", justifyContent: "space-between", @@ -132,7 +133,6 @@ const headerActionStyle = css({ maxHeight: "[44px]", display: "flex", alignItems: "center", - gap: "1", }); const sectionToggleStyle = css({ From 24d26045246586cf0c2f7fef46b17abe70237dae Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 10 Mar 2026 19:56:04 +0100 Subject: [PATCH 08/46] Fix row interaction: stopPropagation on menu trigger, highlight row when menu open - Use stopPropagation on ellipsis button instead of DOM query guard - Show hover background on row when its menu is open via :has selector Co-Authored-By: Claude Opus 4.6 --- .../subviews/filterable-list-sub-view.tsx | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 4a4389d5523..457ee72b8fb 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -59,12 +59,18 @@ export const listItemRowStyle = cva({ _hover: { backgroundColor: "neutral.bg.subtle.hover", }, + "&:has([data-row-action][data-state=open])": { + backgroundColor: "neutral.bg.subtle.hover", + }, }, false: { backgroundColor: "[transparent]", _hover: { backgroundColor: "neutral.bg.surface.hover", }, + "&:has([data-row-action][data-state=open])": { + backgroundColor: "neutral.bg.surface.hover", + }, }, }, }, @@ -149,7 +155,12 @@ const RowMenu = ({ + event.stopPropagation()} + > } @@ -188,13 +199,6 @@ const FilterableListContent = ({
{ - if ( - event.target instanceof HTMLElement && - (event.target.closest("button[aria-label='More options']") || - event.target.closest("input")) - ) { - return; - } if (event.metaKey || event.ctrlKey) { toggleItem(selectionItem); } else { From 1f9d3aac502d09ee1c8cb761e40d3b74a5d8185f Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 01:55:33 +0100 Subject: [PATCH 09/46] Improve header hover behavior, item layout, and icon handling - Scope chevron hover to toggle section only, not full header row - Show header actions and info tooltip only on hover/focus-within with opacity animation - Add outlined variant to InfoIconTooltip, used in subview headers - Move item icons to FilterableListItem.icon prop with consistent rendering - Wrap list items in content/name containers in filterable-list-sub-view - Remove redundant wrapper styles from individual subview lists - Add alwaysShowHeaderAction option to SubView, used by visualizer - Align row menu to bottom-end Co-Authored-By: Claude Opus 4.6 --- .../src/components/sub-view/types.ts | 5 ++ .../vertical/vertical-sub-views-container.tsx | 80 ++++++++++++++++--- .../petrinaut/src/components/tooltip.tsx | 15 +++- .../subviews/differential-equations-list.tsx | 23 ++---- .../subviews/filterable-list-sub-view.tsx | 21 ++++- .../LeftSideBar/subviews/nodes-list.tsx | 56 +++---------- .../LeftSideBar/subviews/parameters-list.tsx | 36 ++------- .../LeftSideBar/subviews/types-list.tsx | 25 +++--- .../subviews/place-visualizer/subview.tsx | 1 + 9 files changed, 141 insertions(+), 121 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/types.ts b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts index d0c2be4ccab..e0d78390921 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/types.ts +++ b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts @@ -60,6 +60,11 @@ export interface SubView { * Defaults to false (expanded). Ignored when `main` is true. */ defaultCollapsed?: boolean; + /** + * When true, the header action is always visible instead of only on hover/focus. + * Defaults to false. + */ + alwaysShowHeaderAction?: boolean; /** * Configuration for making the subview resizable when expanded. * Only affects vertical layout. When set, the section can be resized by dragging its bottom edge. diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx index 6f9226f56ce..c89d94dfed6 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -37,6 +37,17 @@ const sectionWrapperStyle = css({ flexDirection: "column", height: "[100%]", overflow: "hidden", + + /* Reveal header actions and info tooltip on hover or focus-within */ + "&:hover [data-header-action], &:focus-within [data-header-action]": { + opacity: "[1]", + width: "auto", + overflow: "visible", + transition: "[opacity 150ms ease-out]", + }, + "&:hover [data-info-tooltip], &:focus-within [data-info-tooltip]": { + opacity: "[1]", + }, }); const sectionContentStyle = css({ @@ -117,22 +128,25 @@ const headerRowStyle = css({ display: "flex", justifyContent: "space-between", alignItems: "center", +}); - /* Reveal the chevron icon on hover */ - "& [data-toggle-icon]": { - width: "3.5", - opacity: "[0]", - }, - "&:hover [data-toggle-icon]": { - opacity: "[1]", - }, +const headerActionVisibleStyle = css({ + /** Constrain height so buttons don't grow the header */ + maxHeight: "[44px]", + display: "flex", + alignItems: "center", + flexShrink: 0, }); const headerActionStyle = css({ - /** Constrain height so buttons don't grow the header */ maxHeight: "[44px]", display: "flex", alignItems: "center", + flexShrink: 0, + opacity: "[0]", + width: "[0]", + overflow: "hidden", + transition: "[opacity 150ms ease-out, width 0s 150ms]", }); const sectionToggleStyle = css({ @@ -142,9 +156,28 @@ const sectionToggleStyle = css({ fontSize: "sm", color: "neutral.s100", cursor: "pointer", + flex: "[1]", + minWidth: "[0]", + overflow: "hidden", + + /* Reveal the chevron icon on toggle section hover */ + "& [data-toggle-icon]": { + width: "3.5", + opacity: "[0]", + }, + "&:hover [data-toggle-icon]": { + opacity: "[1]", + }, +}); + +const sectionToggleLabelStyle = css({ + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", }); const sectionToggleIconStyle = css({ + flexShrink: 0, display: "flex", justifyContent: "center", alignItems: "center", @@ -157,10 +190,19 @@ const sectionToggleIconExpandedStyle = css({ transform: "[rotate(90deg)]", }); +const infoTooltipWrapperStyle = css({ + opacity: "[0]", + transition: "[opacity 150ms ease-out]", +}); + const mainTitleStyle = css({ fontWeight: "semibold", fontSize: "base", px: "1", + flex: "[1]", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", }); /** @@ -252,6 +294,7 @@ interface SubViewHeaderProps { isExpanded: boolean; onToggle: () => void; renderHeaderAction?: () => React.ReactNode; + alwaysShowHeaderAction?: boolean; } const SubViewHeader: React.FC = ({ @@ -262,6 +305,7 @@ const SubViewHeader: React.FC = ({ isExpanded, onToggle, renderHeaderAction, + alwaysShowHeaderAction, }) => (
{main ? ( @@ -290,14 +334,25 @@ const SubViewHeader: React.FC = ({ >
- + {title} - {tooltip && } + {tooltip && ( + + + + )}
)} {isExpanded && renderHeaderAction && ( -
{renderHeaderAction()}
+
+ {renderHeaderAction()} +
)}
); @@ -395,6 +450,7 @@ export const VerticalSubViewsContainer: React.FC< isExpanded={isExpanded} onToggle={() => toggleSection(subView)} renderHeaderAction={subView.renderHeaderAction} + alwaysShowHeaderAction={subView.alwaysShowHeaderAction} /> {isExpanded && ( diff --git a/libs/@hashintel/petrinaut/src/components/tooltip.tsx b/libs/@hashintel/petrinaut/src/components/tooltip.tsx index 886b4052786..7cf04afed98 100644 --- a/libs/@hashintel/petrinaut/src/components/tooltip.tsx +++ b/libs/@hashintel/petrinaut/src/components/tooltip.tsx @@ -4,6 +4,7 @@ import { Tooltip as ArkTooltip } from "@ark-ui/react/tooltip"; import { css, cva, cx } from "@hashintel/ds-helpers/css"; import type { ReactNode } from "react"; import { FaInfoCircle } from "react-icons/fa"; +import { LuInfo } from "react-icons/lu"; import { usePortalContainerRef } from "../state/portal-container-context"; @@ -116,13 +117,21 @@ const circleInfoIconStyle = css({ marginBottom: "[2px]", color: "neutral.s85", verticalAlign: "middle", - fill: "[currentColor]", + // fill: "[currentColor]", }); -export const InfoIconTooltip = ({ tooltip }: { tooltip: string }) => { +export const InfoIconTooltip = ({ + tooltip, + outlined, +}: { + tooltip: string; + outlined?: boolean; +}) => { + const Icon = outlined ? LuInfo : FaInfoCircle; + return ( - + ); }; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index 3711c248b48..16b4338387c 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -1,4 +1,3 @@ -import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { TbPlus, TbTrash } from "react-icons/tb"; import { v4 as uuidv4 } from "uuid"; @@ -10,18 +9,10 @@ import { DEFAULT_DIFFERENTIAL_EQUATION_CODE } from "../../../../../core/default- import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; -import { createFilterableListSubView } from "./filterable-list-sub-view"; - -const equationNameContainerStyle = css({ - display: "flex", - alignItems: "center", - gap: "[6px]", - flex: "[1]", - minWidth: "[0]", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}); +import { + createFilterableListSubView, + listItemNameStyle, +} from "./filterable-list-sub-view"; /** * DifferentialEquationsSectionHeaderAction renders the add button for the section header. @@ -79,11 +70,7 @@ export const differentialEquationsListSubView: SubView = return differentialEquations; }, getSelectionItem: (eq) => ({ type: "differentialEquation", id: eq.id }), - renderItem: (eq) => ( -
- {eq.name} -
- ), + renderItem: (eq) => {eq.name}, getMenuItems: (eq) => { const { removeDifferentialEquation } = use(SDCPNContext); const isReadOnly = useIsReadOnly(); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 457ee72b8fb..7960ebd127c 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -17,6 +17,7 @@ import type { SelectionItem } from "../../../../../state/selection"; export const listContainerStyle = css({ display: "flex", flexDirection: "column", + gap: "[1px]", }); export const listItemRowStyle = cva({ @@ -25,9 +26,7 @@ export const listItemRowStyle = cva({ alignItems: "center", gap: "1", minHeight: "8", - pl: "2", - pr: "1", - py: "1", + p: "1", borderRadius: "lg", cursor: "pointer", fontSize: "sm", @@ -94,6 +93,13 @@ export const listItemNameStyle = css({ whiteSpace: "nowrap", }); +const listItemIconStyle = css({ + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", +}); + export const emptyMessageStyle = css({ fontSize: "sm", color: "neutral.s85", @@ -101,6 +107,7 @@ export const emptyMessageStyle = css({ interface FilterableListItem { id: string; + icon?: ReactNode; } interface FilterableListSubViewConfig { @@ -165,6 +172,7 @@ const RowMenu = ({ } items={menuItems} + placement="bottom-end" /> ); }; @@ -214,7 +222,12 @@ const FilterableListContent = ({ }} className={listItemRowStyle({ isSelected })} > - {renderItem(item, isSelected)} +
+ {item.icon && ( + {item.icon} + )} + {renderItem(item, isSelected)} +
{getMenuItems && ( )} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx index c35fe0254a1..1a1c70ba659 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx @@ -1,44 +1,17 @@ -import { cva } from "@hashintel/ds-helpers/css"; +import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { FaCircle, FaSquare } from "react-icons/fa6"; import type { SubView } from "../../../../../components/sub-view/types"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; -import { createFilterableListSubView } from "./filterable-list-sub-view"; +import { + createFilterableListSubView, + listItemNameStyle, +} from "./filterable-list-sub-view"; -const nodeIconStyle = cva({ - base: { - flexShrink: 0, - }, - variants: { - isSelected: { - true: { - color: "[#3b82f6]", - }, - false: { - color: "[#9ca3af]", - }, - }, - }, -}); - -const nodeNameStyle = cva({ - base: { - fontSize: "sm", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - }, - variants: { - isSelected: { - true: { - color: "[#1e40af]", - }, - false: { - color: "neutral.s105", - }, - }, - }, +const nodeIconStyle = css({ + flexShrink: 0, + color: "[#9ca3af]", }); interface NodeItem { @@ -70,24 +43,17 @@ export const nodesListSubView: SubView = createFilterableListSubView({ id: place.id, name: place.name || `Place ${place.id}`, kind: "place" as const, + icon: , })), ...transitions.map((transition) => ({ id: transition.id, name: transition.name || `Transition ${transition.id}`, kind: "transition" as const, + icon: , })), ]; }, getSelectionItem: (node) => ({ type: node.kind, id: node.id }), - renderItem: (node, isSelected) => ( - <> - {node.kind === "place" ? ( - - ) : ( - - )} - {node.name} - - ), + renderItem: (node) => {node.name}, emptyMessage: "No nodes yet", }); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index 3b1cffbf141..ee424c24671 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -1,4 +1,4 @@ -import { css, cva } from "@hashintel/ds-helpers/css"; +import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { TbPlus, TbTrash } from "react-icons/tb"; import { v4 as uuidv4 } from "uuid"; @@ -11,7 +11,10 @@ import { SimulationContext } from "../../../../../simulation/context"; import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; -import { createFilterableListSubView } from "./filterable-list-sub-view"; +import { + createFilterableListSubView, + listItemNameStyle, +} from "./filterable-list-sub-view"; const parameterVarNameStyle = css({ margin: "[0]", @@ -69,29 +72,6 @@ const ParametersHeaderAction: React.FC = () => { ); }; -// Custom row style for parameters - overrides the default to add space-between layout -const parameterRowContentStyle = cva({ - base: { - width: "[100%]", - display: "flex", - alignItems: "center", - justifyContent: "space-between", - minWidth: "[0]", - gap: "1", - }, -}); - -const parameterNameStyle = css({ - flex: "[1]", - minWidth: "[0]", - overflow: "hidden", - "& > div": { - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - }, -}); - /** * SubView definition for Global Parameters List. */ @@ -126,8 +106,8 @@ export const parametersListSubView: SubView = createFilterableListSubView({ const isSimulationMode = globalMode === "simulate"; return ( -
-
+ <> +
{param.name}
{param.variableName}
@@ -148,7 +128,7 @@ export const parametersListSubView: SubView = createFilterableListSubView({ />
)} -
+ ); }, getMenuItems: (param) => { diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index 70b3cf3118f..0f7cfc34334 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -8,7 +8,10 @@ import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; -import { createFilterableListSubView } from "./filterable-list-sub-view"; +import { + createFilterableListSubView, + listItemNameStyle, +} from "./filterable-list-sub-view"; const colorDotStyle = css({ width: "[12px]", @@ -125,18 +128,18 @@ export const typesListSubView: SubView = createFilterableListSubView({ const { petriNetDefinition: { types }, } = use(SDCPNContext); - return types; + return types.map((type) => ({ + ...type, + icon: ( +
+ ), + })); }, getSelectionItem: (type) => ({ type: "type", id: type.id }), - renderItem: (type) => ( - <> -
- {type.name} - - ), + renderItem: (type) => {type.name}, getMenuItems: (type) => { const { removeType } = use(SDCPNContext); const isReadOnly = useIsReadOnly(); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx index dba1f0351f8..c783a209d9e 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx @@ -319,6 +319,7 @@ export const placeVisualizerSubView: SubView = { "Custom visualization of tokens in this place, defined by visualizer code.", component: PlaceVisualizerContent, renderHeaderAction: () => , + alwaysShowHeaderAction: true, defaultCollapsed: true, minHeight: 200, }; From b8c62f1c8dfc970c14538187a1faa1146ac6311c Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 02:16:01 +0100 Subject: [PATCH 10/46] Add icon support and styled main headers to PropertiesPanel SubViews Add optional `icon` prop to SubView type and update VerticalSubViewsContainer to render distinct main vs collapsible header styles matching the Figma design. Main headers now show an outline icon + subtle text with a bottom border. All PropertiesPanel main SubViews (Place, Transition, Arc, Parameter, Type, Differential Equation) now include Lucide outline icons and use alwaysShowHeaderAction where applicable. Co-Authored-By: Claude Opus 4.6 --- .../src/components/sub-view/types.ts | 2 + .../vertical/vertical-sub-views-container.tsx | 45 ++++++++++++++++--- .../PropertiesPanel/arc-properties/main.tsx | 3 ++ .../subviews/main.tsx | 3 ++ .../parameter-properties/subviews/main.tsx | 3 ++ .../place-properties/subviews/main.tsx | 3 ++ .../transition-properties/subviews/main.tsx | 3 ++ .../type-properties/subviews/main.tsx | 2 + 8 files changed, 58 insertions(+), 6 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/types.ts b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts index e0d78390921..2c22ca782a7 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/types.ts +++ b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts @@ -26,6 +26,8 @@ export interface SubView { title: string; /** Optional tooltip shown when hovering over the title/tab */ tooltip?: string; + /** Optional icon displayed before the title in the header */ + icon?: ReactNode; /** The component to render for this subview */ component: ComponentType; /** diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx index c89d94dfed6..e3584a4c0de 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -130,6 +130,16 @@ const headerRowStyle = css({ alignItems: "center", }); +const mainHeaderRowStyle = css({ + p: "3", + borderBottomWidth: "thin", + borderBottomColor: "neutral.a30", + + display: "flex", + justifyContent: "space-between", + alignItems: "center", +}); + const headerActionVisibleStyle = css({ /** Constrain height so buttons don't grow the header */ maxHeight: "[44px]", @@ -195,11 +205,28 @@ const infoTooltipWrapperStyle = css({ transition: "[opacity 150ms ease-out]", }); -const mainTitleStyle = css({ - fontWeight: "semibold", - fontSize: "base", - px: "1", +const mainHeaderContentStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", flex: "[1]", + minWidth: "[0]", + overflow: "hidden", +}); + +const headerIconStyle = css({ + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "5", + color: "neutral.s85", +}); + +const mainTitleStyle = css({ + fontWeight: "medium", + fontSize: "sm", + color: "neutral.s85", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", @@ -290,6 +317,7 @@ interface SubViewHeaderProps { id: string; title: string; tooltip?: string; + icon?: React.ReactNode; main?: boolean; isExpanded: boolean; onToggle: () => void; @@ -301,15 +329,19 @@ const SubViewHeader: React.FC = ({ id, title, tooltip, + icon, main = false, isExpanded, onToggle, renderHeaderAction, alwaysShowHeaderAction, }) => ( -
+
{main ? ( -
{title}
+
+ {icon && {icon}} + {title} +
) : (
toggleSection(subView)} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx index f29144b6960..f108b3d11d7 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx @@ -1,5 +1,6 @@ import { css } from "@hashintel/ds-helpers/css"; import { createContext, use } from "react"; +import { LuMinus } from "react-icons/lu"; import { TbTrash } from "react-icons/tb"; import { IconButton } from "../../../../../components/icon-button"; @@ -127,9 +128,11 @@ const DeleteArcAction: React.FC = () => { const arcMainContentSubView: SubView = { id: "arc-main-content", title: "Arc", + icon: , main: true, component: ArcMainContent, renderHeaderAction: () => , + alwaysShowHeaderAction: true, }; const subViews: SubView[] = [arcMainContentSubView]; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx index 61b563a724e..2b32307eaa1 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx @@ -1,5 +1,6 @@ import { css } from "@hashintel/ds-helpers/css"; import { useState } from "react"; +import { LuSigma } from "react-icons/lu"; import { TbDotsVertical, TbSparkles } from "react-icons/tb"; import { Button } from "../../../../../../components/button"; @@ -343,7 +344,9 @@ const DiffEqCodeAction: React.FC = () => { export const diffEqMainContentSubView: SubView = { id: "diff-eq-main-content", title: "Differential Equation", + icon: , main: true, component: DiffEqMainContent, renderHeaderAction: () => , + alwaysShowHeaderAction: true, }; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx index dd9d4f65eaa..33356d38741 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx @@ -1,3 +1,5 @@ +import { LuVariable } from "react-icons/lu"; + import { Input } from "../../../../../../components/input"; import { Section, SectionList } from "../../../../../../components/section"; import type { SubView } from "../../../../../../components/sub-view/types"; @@ -96,6 +98,7 @@ const ParameterMainContent: React.FC = () => { export const parameterMainContentSubView: SubView = { id: "parameter-main-content", title: "Parameter", + icon: , main: true, component: ParameterMainContent, }; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx index 273b879335f..387b634f8b1 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx @@ -1,5 +1,6 @@ import { css } from "@hashintel/ds-helpers/css"; import { use, useEffect, useRef, useState } from "react"; +import { LuCircle } from "react-icons/lu"; import { TbArrowRight, TbTrash } from "react-icons/tb"; import { Button } from "../../../../../../components/button"; @@ -352,7 +353,9 @@ const DeletePlaceAction: React.FC = () => { export const placeMainContentSubView: SubView = { id: "place-main-content", title: "Place", + icon: , main: true, component: PlaceMainContent, renderHeaderAction: () => , + alwaysShowHeaderAction: true, }; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx index 25cb77f9ac6..ae55fbc26d1 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx @@ -1,5 +1,6 @@ import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; +import { LuSquare } from "react-icons/lu"; import { TbTrash } from "react-icons/tb"; import { @@ -231,7 +232,9 @@ const DeleteTransitionAction: React.FC = () => { export const transitionMainContentSubView: SubView = { id: "transition-main-content", title: "Transition", + icon: , main: true, component: TransitionMainContent, renderHeaderAction: () => , + alwaysShowHeaderAction: true, }; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx index f4a9964a405..83a6fe77880 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx @@ -1,5 +1,6 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import { useState } from "react"; +import { LuTag } from "react-icons/lu"; import { TbPlus, TbX } from "react-icons/tb"; import { v4 as uuidv4 } from "uuid"; @@ -366,6 +367,7 @@ const TypeMainContent: React.FC = () => { export const typeMainContentSubView: SubView = { id: "type-main-content", title: "Type", + icon: , main: true, component: TypeMainContent, }; From 4816581775342d137acbd5bd602d16fc215c2c32 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 18:10:54 +0100 Subject: [PATCH 11/46] Centralize entity icons and standardize list item icon rendering Create constants/entity-icons.tsx as the single source of truth for all Petri net entity icons (outline and filled variants). Update FilterableListSubView to control icon size and default color centrally, with an iconColor override used by TokenTypes for per-type coloring. All list subviews now show their entity icon consistently. Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/constants/entity-icons.tsx | 22 +++++++ .../subviews/differential-equations-list.tsx | 6 +- .../subviews/filterable-list-sub-view.tsx | 19 ++++-- .../LeftSideBar/subviews/nodes-list.tsx | 15 ++--- .../LeftSideBar/subviews/parameters-list.tsx | 63 ++++--------------- .../LeftSideBar/subviews/types-list.tsx | 17 +---- .../PropertiesPanel/arc-properties/main.tsx | 4 +- .../subviews/main.tsx | 4 +- .../parameter-properties/subviews/main.tsx | 5 +- .../place-properties/subviews/main.tsx | 4 +- .../transition-properties/subviews/main.tsx | 4 +- .../type-properties/subviews/main.tsx | 4 +- 12 files changed, 75 insertions(+), 92 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/constants/entity-icons.tsx diff --git a/libs/@hashintel/petrinaut/src/constants/entity-icons.tsx b/libs/@hashintel/petrinaut/src/constants/entity-icons.tsx new file mode 100644 index 00000000000..8e42f6bf879 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/constants/entity-icons.tsx @@ -0,0 +1,22 @@ +/** + * Centralized icon definitions for Petri net entity types. + * + * Each entity has an outline icon (used in PropertiesPanel headers) + * and optionally a filled icon (used in list item rows). + */ +import { FaCircle, FaSquare } from "react-icons/fa6"; +import { GrVolumeControl } from "react-icons/gr"; +import { LuCircle, LuMinus, LuSquare } from "react-icons/lu"; +import { RiColorFilterFill, RiFormula } from "react-icons/ri"; + +/** Outline icons — used in PropertiesPanel headers */ +export const PlaceIcon = LuCircle; +export const TransitionIcon = LuSquare; +export const ArcIcon = LuMinus; +export const ParameterIcon = GrVolumeControl; +export const TokenTypeIcon = RiColorFilterFill; +export const DifferentialEquationIcon = RiFormula; + +/** Filled icons — used in list item rows */ +export const PlaceFilledIcon = FaCircle; +export const TransitionFilledIcon = FaSquare; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index 16b4338387c..4668850e13d 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from "uuid"; import { IconButton } from "../../../../../components/icon-button"; import type { SubView } from "../../../../../components/sub-view/types"; +import { DifferentialEquationIcon } from "../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { DEFAULT_DIFFERENTIAL_EQUATION_CODE } from "../../../../../core/default-codes"; import { EditorContext } from "../../../../../state/editor-context"; @@ -67,7 +68,10 @@ export const differentialEquationsListSubView: SubView = const { petriNetDefinition: { differentialEquations }, } = use(SDCPNContext); - return differentialEquations; + return differentialEquations.map((eq) => ({ + ...eq, + icon: DifferentialEquationIcon, + })); }, getSelectionItem: (eq) => ({ type: "differentialEquation", id: eq.id }), renderItem: (eq) => {eq.name}, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 7960ebd127c..4f09b6546fb 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -1,5 +1,5 @@ import { css, cva } from "@hashintel/ds-helpers/css"; -import type { ReactNode } from "react"; +import type { ComponentType, ReactNode } from "react"; import { use } from "react"; import { LuArrowDownWideNarrow, LuListFilter, LuSearch } from "react-icons/lu"; import { TbDots } from "react-icons/tb"; @@ -93,6 +93,9 @@ export const listItemNameStyle = css({ whiteSpace: "nowrap", }); +const LIST_ITEM_ICON_SIZE = 12; +const LIST_ITEM_ICON_COLOR = "#9ca3af"; + const listItemIconStyle = css({ flexShrink: 0, display: "flex", @@ -101,13 +104,16 @@ const listItemIconStyle = css({ }); export const emptyMessageStyle = css({ + pt: "1", + px: "1", fontSize: "sm", - color: "neutral.s85", + color: "neutral.s65", }); interface FilterableListItem { id: string; - icon?: ReactNode; + icon?: ComponentType<{ size: number }>; + iconColor?: string; } interface FilterableListSubViewConfig { @@ -224,7 +230,12 @@ const FilterableListContent = ({ >
{item.icon && ( - {item.icon} + + + )} {renderItem(item, isSelected)}
diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx index 1a1c70ba659..f2cfc527504 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx @@ -1,19 +1,16 @@ -import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; -import { FaCircle, FaSquare } from "react-icons/fa6"; import type { SubView } from "../../../../../components/sub-view/types"; +import { + PlaceFilledIcon, + TransitionFilledIcon, +} from "../../../../../constants/entity-icons"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; import { createFilterableListSubView, listItemNameStyle, } from "./filterable-list-sub-view"; -const nodeIconStyle = css({ - flexShrink: 0, - color: "[#9ca3af]", -}); - interface NodeItem { id: string; name: string; @@ -43,13 +40,13 @@ export const nodesListSubView: SubView = createFilterableListSubView({ id: place.id, name: place.name || `Place ${place.id}`, kind: "place" as const, - icon: , + icon: PlaceFilledIcon, })), ...transitions.map((transition) => ({ id: transition.id, name: transition.name || `Transition ${transition.id}`, kind: "transition" as const, - icon: , + icon: TransitionFilledIcon, })), ]; }, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index ee424c24671..44445f2a7df 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -4,10 +4,9 @@ import { TbPlus, TbTrash } from "react-icons/tb"; import { v4 as uuidv4 } from "uuid"; import { IconButton } from "../../../../../components/icon-button"; -import { NumberInput } from "../../../../../components/number-input"; import type { SubView } from "../../../../../components/sub-view/types"; +import { ParameterIcon } from "../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; -import { SimulationContext } from "../../../../../simulation/context"; import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; @@ -17,20 +16,9 @@ import { } from "./filterable-list-sub-view"; const parameterVarNameStyle = css({ - margin: "[0]", - fontSize: "[11px]", - color: "neutral.s100", -}); - -const actionsContainerStyle = css({ - display: "flex", - alignItems: "center", - gap: "1.5", -}); - -const parameterValueInputStyle = css({ - width: "[80px]", - textAlign: "right", + margin: "0", + fontSize: "xs", + color: "neutral.s90", }); /** @@ -90,45 +78,18 @@ export const parametersListSubView: SubView = createFilterableListSubView({ const { petriNetDefinition: { parameters }, } = use(SDCPNContext); - return parameters; + return parameters.map((param) => ({ + ...param, + icon: ParameterIcon, + })); }, getSelectionItem: (param) => ({ type: "parameter", id: param.id }), renderItem: (param) => { - const { globalMode } = use(EditorContext); - const { - state: simulationState, - parameterValues, - setParameterValue, - } = use(SimulationContext); - - const isSimulationNotRun = - globalMode === "simulate" && simulationState === "NotRun"; - const isSimulationMode = globalMode === "simulate"; - return ( - <> -
-
{param.name}
-
{param.variableName}
-
- {isSimulationMode && ( -
- - setParameterValue( - param.variableName, - (event.target as HTMLInputElement).value, - ) - } - placeholder={param.defaultValue} - readOnly={!isSimulationNotRun} - className={parameterValueInputStyle} - /> -
- )} - +
+
{param.name}
+
{param.variableName}
+
); }, getMenuItems: (param) => { diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index 0f7cfc34334..722c958f652 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -1,9 +1,9 @@ -import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { TbPlus, TbTrash } from "react-icons/tb"; import { IconButton } from "../../../../../components/icon-button"; import type { SubView } from "../../../../../components/sub-view/types"; +import { TokenTypeIcon } from "../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; @@ -13,13 +13,6 @@ import { listItemNameStyle, } from "./filterable-list-sub-view"; -const colorDotStyle = css({ - width: "[12px]", - height: "[12px]", - borderRadius: "[50%]", - flexShrink: 0, -}); - // Pool of 10 well-differentiated colors for types const TYPE_COLOR_POOL = [ "#3b82f6", // Blue @@ -130,12 +123,8 @@ export const typesListSubView: SubView = createFilterableListSubView({ } = use(SDCPNContext); return types.map((type) => ({ ...type, - icon: ( -
- ), + icon: TokenTypeIcon, + iconColor: type.displayColor, })); }, getSelectionItem: (type) => ({ type: "type", id: type.id }), diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx index f108b3d11d7..8a8cc1e567e 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx @@ -1,6 +1,5 @@ import { css } from "@hashintel/ds-helpers/css"; import { createContext, use } from "react"; -import { LuMinus } from "react-icons/lu"; import { TbTrash } from "react-icons/tb"; import { IconButton } from "../../../../../components/icon-button"; @@ -8,6 +7,7 @@ import { NumberInput } from "../../../../../components/number-input"; import { Section, SectionList } from "../../../../../components/section"; import type { SubView } from "../../../../../components/sub-view/types"; import { VerticalSubViewsContainer } from "../../../../../components/sub-view/vertical/vertical-sub-views-container"; +import { ArcIcon } from "../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import type { SDCPN } from "../../../../../core/types/sdcpn"; import { EditorContext } from "../../../../../state/editor-context"; @@ -128,7 +128,7 @@ const DeleteArcAction: React.FC = () => { const arcMainContentSubView: SubView = { id: "arc-main-content", title: "Arc", - icon: , + icon: , main: true, component: ArcMainContent, renderHeaderAction: () => , diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx index 2b32307eaa1..d5b403fb50f 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx @@ -1,6 +1,5 @@ import { css } from "@hashintel/ds-helpers/css"; import { useState } from "react"; -import { LuSigma } from "react-icons/lu"; import { TbDotsVertical, TbSparkles } from "react-icons/tb"; import { Button } from "../../../../../../components/button"; @@ -11,6 +10,7 @@ import { Section, SectionList } from "../../../../../../components/section"; import { Select } from "../../../../../../components/select"; import type { SubView } from "../../../../../../components/sub-view/types"; import { Tooltip } from "../../../../../../components/tooltip"; +import { DifferentialEquationIcon } from "../../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../../constants/ui-messages"; import { DEFAULT_DIFFERENTIAL_EQUATION_CODE, @@ -344,7 +344,7 @@ const DiffEqCodeAction: React.FC = () => { export const diffEqMainContentSubView: SubView = { id: "diff-eq-main-content", title: "Differential Equation", - icon: , + icon: , main: true, component: DiffEqMainContent, renderHeaderAction: () => , diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx index 33356d38741..849030705c1 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx @@ -1,8 +1,7 @@ -import { LuVariable } from "react-icons/lu"; - import { Input } from "../../../../../../components/input"; import { Section, SectionList } from "../../../../../../components/section"; import type { SubView } from "../../../../../../components/sub-view/types"; +import { ParameterIcon } from "../../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../../constants/ui-messages"; import { useIsReadOnly } from "../../../../../../state/use-is-read-only"; import { useParameterPropertiesContext } from "../context"; @@ -98,7 +97,7 @@ const ParameterMainContent: React.FC = () => { export const parameterMainContentSubView: SubView = { id: "parameter-main-content", title: "Parameter", - icon: , + icon: , main: true, component: ParameterMainContent, }; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx index 387b634f8b1..505fe95ed5b 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx @@ -1,6 +1,5 @@ import { css } from "@hashintel/ds-helpers/css"; import { use, useEffect, useRef, useState } from "react"; -import { LuCircle } from "react-icons/lu"; import { TbArrowRight, TbTrash } from "react-icons/tb"; import { Button } from "../../../../../../components/button"; @@ -10,6 +9,7 @@ import { Section, SectionList } from "../../../../../../components/section"; import { Select, type SelectOption } from "../../../../../../components/select"; import type { SubView } from "../../../../../../components/sub-view/types"; import { Switch } from "../../../../../../components/switch"; +import { PlaceIcon } from "../../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../../constants/ui-messages"; import { EditorContext } from "../../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../../state/sdcpn-context"; @@ -353,7 +353,7 @@ const DeletePlaceAction: React.FC = () => { export const placeMainContentSubView: SubView = { id: "place-main-content", title: "Place", - icon: , + icon: , main: true, component: PlaceMainContent, renderHeaderAction: () => , diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx index ae55fbc26d1..13d66d0e1c7 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx @@ -1,6 +1,5 @@ import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; -import { LuSquare } from "react-icons/lu"; import { TbTrash } from "react-icons/tb"; import { @@ -12,6 +11,7 @@ import { IconButton } from "../../../../../../components/icon-button"; import { Input } from "../../../../../../components/input"; import { Section, SectionList } from "../../../../../../components/section"; import type { SubView } from "../../../../../../components/sub-view/types"; +import { TransitionIcon } from "../../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../../constants/ui-messages"; import { SDCPNContext } from "../../../../../../state/sdcpn-context"; import { useTransitionPropertiesContext } from "../context"; @@ -232,7 +232,7 @@ const DeleteTransitionAction: React.FC = () => { export const transitionMainContentSubView: SubView = { id: "transition-main-content", title: "Transition", - icon: , + icon: , main: true, component: TransitionMainContent, renderHeaderAction: () => , diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx index 83a6fe77880..42fd9acba6c 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx @@ -1,6 +1,5 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import { useState } from "react"; -import { LuTag } from "react-icons/lu"; import { TbPlus, TbX } from "react-icons/tb"; import { v4 as uuidv4 } from "uuid"; @@ -9,6 +8,7 @@ import { Input } from "../../../../../../components/input"; import { Section, SectionList } from "../../../../../../components/section"; import type { SubView } from "../../../../../../components/sub-view/types"; import { Tooltip } from "../../../../../../components/tooltip"; +import { TokenTypeIcon } from "../../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../../constants/ui-messages"; import { useIsReadOnly } from "../../../../../../state/use-is-read-only"; import { ColorSelect } from "../color-select"; @@ -367,7 +367,7 @@ const TypeMainContent: React.FC = () => { export const typeMainContentSubView: SubView = { id: "type-main-content", title: "Type", - icon: , + icon: , main: true, component: TypeMainContent, }; From 4cfa0c8b6de830daf8efc73b2ae9e649210529f7 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 19:02:17 +0100 Subject: [PATCH 12/46] Move listItemNameStyle into FilterableListSubView and reorder sidebar subviews Internalize listItemNameStyle within FilterableListSubView so renderItem returns plain text/nodes wrapped automatically. Simplify all list subview renderItem callbacks to return strings instead of styled spans. Reorder LEFT_SIDEBAR_SUBVIEWS to show Nodes first. Co-Authored-By: Claude Opus 4.6 --- .../subviews/differential-equations-list.tsx | 7 ++----- .../subviews/filterable-list-sub-view.tsx | 15 +++++++++------ .../panels/LeftSideBar/subviews/nodes-list.tsx | 7 ++----- .../LeftSideBar/subviews/parameters-list.tsx | 7 ++----- .../panels/LeftSideBar/subviews/types-list.tsx | 7 ++----- 5 files changed, 17 insertions(+), 26 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index 4668850e13d..8473f14ad5a 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -10,10 +10,7 @@ import { DEFAULT_DIFFERENTIAL_EQUATION_CODE } from "../../../../../core/default- import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; -import { - createFilterableListSubView, - listItemNameStyle, -} from "./filterable-list-sub-view"; +import { createFilterableListSubView } from "./filterable-list-sub-view"; /** * DifferentialEquationsSectionHeaderAction renders the add button for the section header. @@ -74,7 +71,7 @@ export const differentialEquationsListSubView: SubView = })); }, getSelectionItem: (eq) => ({ type: "differentialEquation", id: eq.id }), - renderItem: (eq) => {eq.name}, + renderItem: (eq) => eq.name, getMenuItems: (eq) => { const { removeDifferentialEquation } = use(SDCPNContext); const isReadOnly = useIsReadOnly(); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 4f09b6546fb..09bc9227eaa 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -14,13 +14,13 @@ import type { import { EditorContext } from "../../../../../state/editor-context"; import type { SelectionItem } from "../../../../../state/selection"; -export const listContainerStyle = css({ +const listContainerStyle = css({ display: "flex", flexDirection: "column", gap: "[1px]", }); -export const listItemRowStyle = cva({ +const listItemRowStyle = cva({ base: { display: "flex", alignItems: "center", @@ -75,7 +75,7 @@ export const listItemRowStyle = cva({ }, }); -export const listItemContentStyle = css({ +const listItemContentStyle = css({ display: "flex", alignItems: "center", gap: "1.5", @@ -83,11 +83,12 @@ export const listItemContentStyle = css({ minWidth: "[0]", }); -export const listItemNameStyle = css({ +const listItemNameStyle = css({ flex: "[1]", fontSize: "sm", fontWeight: "medium", - color: "neutral.s105", + lineHeight: "snug", + color: "neutral.s115", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", @@ -237,7 +238,9 @@ const FilterableListContent = ({ )} - {renderItem(item, isSelected)} + + {renderItem(item, isSelected)} +
{getMenuItems && ( diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx index f2cfc527504..b45d12186cb 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx @@ -6,10 +6,7 @@ import { TransitionFilledIcon, } from "../../../../../constants/entity-icons"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; -import { - createFilterableListSubView, - listItemNameStyle, -} from "./filterable-list-sub-view"; +import { createFilterableListSubView } from "./filterable-list-sub-view"; interface NodeItem { id: string; @@ -51,6 +48,6 @@ export const nodesListSubView: SubView = createFilterableListSubView({ ]; }, getSelectionItem: (node) => ({ type: node.kind, id: node.id }), - renderItem: (node) => {node.name}, + renderItem: (node) => node.name, emptyMessage: "No nodes yet", }); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index 44445f2a7df..54ae8958ba2 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -10,10 +10,7 @@ import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; -import { - createFilterableListSubView, - listItemNameStyle, -} from "./filterable-list-sub-view"; +import { createFilterableListSubView } from "./filterable-list-sub-view"; const parameterVarNameStyle = css({ margin: "0", @@ -86,7 +83,7 @@ export const parametersListSubView: SubView = createFilterableListSubView({ getSelectionItem: (param) => ({ type: "parameter", id: param.id }), renderItem: (param) => { return ( -
+
{param.name}
{param.variableName}
diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index 722c958f652..2a12fc6ca1f 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -8,10 +8,7 @@ import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; -import { - createFilterableListSubView, - listItemNameStyle, -} from "./filterable-list-sub-view"; +import { createFilterableListSubView } from "./filterable-list-sub-view"; // Pool of 10 well-differentiated colors for types const TYPE_COLOR_POOL = [ @@ -128,7 +125,7 @@ export const typesListSubView: SubView = createFilterableListSubView({ })); }, getSelectionItem: (type) => ({ type: "type", id: type.id }), - renderItem: (type) => {type.name}, + renderItem: (type) => type.name, getMenuItems: (type) => { const { removeType } = use(SDCPNContext); const isReadOnly = useIsReadOnly(); From 4390ad22204b3b754a0d34208c56e502fe4c30ad Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 19:16:35 +0100 Subject: [PATCH 13/46] Make SubView icon size controlled by VerticalSubViewsContainer Change SubView.icon from ReactNode to ComponentType<{ size: number }> so the container controls the icon size (HEADER_ICON_SIZE = 16). All SubView consumers now pass icon components directly instead of rendered elements. Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/components/sub-view/types.ts | 4 ++-- .../vertical/vertical-sub-views-container.tsx | 12 +++++++++--- .../panels/PropertiesPanel/arc-properties/main.tsx | 4 ++-- .../subviews/main.tsx | 2 +- .../panels/PropertiesPanel/multi-selection-panel.tsx | 2 ++ .../parameter-properties/subviews/main.tsx | 2 +- .../place-properties/subviews/main.tsx | 2 +- .../transition-properties/subviews/main.tsx | 2 +- .../type-properties/subviews/main.tsx | 2 +- 9 files changed, 20 insertions(+), 12 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/types.ts b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts index 2c22ca782a7..85ccda7b180 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/types.ts +++ b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts @@ -26,8 +26,8 @@ export interface SubView { title: string; /** Optional tooltip shown when hovering over the title/tab */ tooltip?: string; - /** Optional icon displayed before the title in the header */ - icon?: ReactNode; + /** Optional icon component displayed before the title in the header. Size is controlled by the container. */ + icon?: ComponentType<{ size: number }>; /** The component to render for this subview */ component: ComponentType; /** diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx index e3584a4c0de..21cdc81973d 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -9,6 +9,8 @@ import type { SubView } from "../types"; /** Height of the header row in pixels */ const HEADER_HEIGHT = 44; +/** Size of the icon in the main header */ +const HEADER_ICON_SIZE = 16; /** Default minimum panel height when no per-subview minHeight is set */ const DEFAULT_MIN_PANEL_HEIGHT = 100; @@ -317,7 +319,7 @@ interface SubViewHeaderProps { id: string; title: string; tooltip?: string; - icon?: React.ReactNode; + icon?: React.ComponentType<{ size: number }>; main?: boolean; isExpanded: boolean; onToggle: () => void; @@ -329,7 +331,7 @@ const SubViewHeader: React.FC = ({ id, title, tooltip, - icon, + icon: Icon, main = false, isExpanded, onToggle, @@ -339,7 +341,11 @@ const SubViewHeader: React.FC = ({
{main ? (
- {icon && {icon}} + {Icon && ( + + + + )} {title}
) : ( diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx index 8a8cc1e567e..4e15f057c0f 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx @@ -1,5 +1,6 @@ import { css } from "@hashintel/ds-helpers/css"; import { createContext, use } from "react"; +import { PiScribbleLoopBold } from "react-icons/pi"; import { TbTrash } from "react-icons/tb"; import { IconButton } from "../../../../../components/icon-button"; @@ -7,7 +8,6 @@ import { NumberInput } from "../../../../../components/number-input"; import { Section, SectionList } from "../../../../../components/section"; import type { SubView } from "../../../../../components/sub-view/types"; import { VerticalSubViewsContainer } from "../../../../../components/sub-view/vertical/vertical-sub-views-container"; -import { ArcIcon } from "../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import type { SDCPN } from "../../../../../core/types/sdcpn"; import { EditorContext } from "../../../../../state/editor-context"; @@ -128,7 +128,7 @@ const DeleteArcAction: React.FC = () => { const arcMainContentSubView: SubView = { id: "arc-main-content", title: "Arc", - icon: , + icon: PiScribbleLoopBold, main: true, component: ArcMainContent, renderHeaderAction: () => , diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx index d5b403fb50f..7be7e63cecb 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties/subviews/main.tsx @@ -344,7 +344,7 @@ const DiffEqCodeAction: React.FC = () => { export const diffEqMainContentSubView: SubView = { id: "diff-eq-main-content", title: "Differential Equation", - icon: , + icon: DifferentialEquationIcon, main: true, component: DiffEqMainContent, renderHeaderAction: () => , diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx index 77ff920d313..70f78a7789a 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx @@ -1,5 +1,6 @@ import { css } from "@hashintel/ds-helpers/css"; import { createContext, use } from "react"; +import { GrMultiple } from "react-icons/gr"; import { TbTrash } from "react-icons/tb"; import { IconButton } from "../../../../components/icon-button"; @@ -93,6 +94,7 @@ const DeleteSelectionAction: React.FC = () => { const multiSelectionMainSubView: SubView = { id: "multi-selection-main", title: "Multiple Selection", + icon: GrMultiple, main: true, component: MultiSelectionContent, renderHeaderAction: () => , diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx index 849030705c1..adf4cc668cf 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx @@ -97,7 +97,7 @@ const ParameterMainContent: React.FC = () => { export const parameterMainContentSubView: SubView = { id: "parameter-main-content", title: "Parameter", - icon: , + icon: ParameterIcon, main: true, component: ParameterMainContent, }; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx index 505fe95ed5b..880e9354f61 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx @@ -353,7 +353,7 @@ const DeletePlaceAction: React.FC = () => { export const placeMainContentSubView: SubView = { id: "place-main-content", title: "Place", - icon: , + icon: PlaceIcon, main: true, component: PlaceMainContent, renderHeaderAction: () => , diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx index 13d66d0e1c7..7e35000a417 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx @@ -232,7 +232,7 @@ const DeleteTransitionAction: React.FC = () => { export const transitionMainContentSubView: SubView = { id: "transition-main-content", title: "Transition", - icon: , + icon: TransitionIcon, main: true, component: TransitionMainContent, renderHeaderAction: () => , diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx index 42fd9acba6c..532b8568df2 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx @@ -367,7 +367,7 @@ const TypeMainContent: React.FC = () => { export const typeMainContentSubView: SubView = { id: "type-main-content", title: "Type", - icon: , + icon: TokenTypeIcon, main: true, component: TypeMainContent, }; From 839caa596b83b7cfab527f38eda2dc868776444b Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 19:22:08 +0100 Subject: [PATCH 14/46] Clear selection when clicking empty area in filterable list and update entity icons Clicking in the list container but not on an item now calls clearSelection to deselect all. Also update Parameter/TokenType/DifferentialEquation icons. Co-Authored-By: Claude Opus 4.6 --- libs/@hashintel/petrinaut/src/constants/entity-icons.tsx | 5 ++--- .../LeftSideBar/subviews/filterable-list-sub-view.tsx | 6 +++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/constants/entity-icons.tsx b/libs/@hashintel/petrinaut/src/constants/entity-icons.tsx index 8e42f6bf879..543ef495f4e 100644 --- a/libs/@hashintel/petrinaut/src/constants/entity-icons.tsx +++ b/libs/@hashintel/petrinaut/src/constants/entity-icons.tsx @@ -5,15 +5,14 @@ * and optionally a filled icon (used in list item rows). */ import { FaCircle, FaSquare } from "react-icons/fa6"; -import { GrVolumeControl } from "react-icons/gr"; -import { LuCircle, LuMinus, LuSquare } from "react-icons/lu"; +import { LuCircle, LuMinus, LuSettings2, LuSquare } from "react-icons/lu"; import { RiColorFilterFill, RiFormula } from "react-icons/ri"; /** Outline icons — used in PropertiesPanel headers */ export const PlaceIcon = LuCircle; export const TransitionIcon = LuSquare; export const ArcIcon = LuMinus; -export const ParameterIcon = GrVolumeControl; +export const ParameterIcon = LuSettings2; export const TokenTypeIcon = RiColorFilterFill; export const DifferentialEquationIcon = RiFormula; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 09bc9227eaa..feb8d69cd30 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -18,6 +18,7 @@ const listContainerStyle = css({ display: "flex", flexDirection: "column", gap: "[1px]", + flex: "[1]", }); const listItemRowStyle = cva({ @@ -202,10 +203,12 @@ const FilterableListContent = ({ isSelected: checkIsSelected, selectItem, toggleItem, + clearSelection, } = use(EditorContext); return ( -
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
{items.map((item) => { const isSelected = checkIsSelected(item.id); const selectionItem = getSelectionItem(item); @@ -214,6 +217,7 @@ const FilterableListContent = ({
{ + event.stopPropagation(); if (event.metaKey || event.ctrlKey) { toggleItem(selectionItem); } else { From afa768981ba2f3531e382ce905e340a71a3be32b Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 19:48:59 +0100 Subject: [PATCH 15/46] Fix bottom border + top padding on content in VerticalSubViewsContainer --- .../sub-view/vertical/vertical-sub-views-container.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx index 21cdc81973d..249c1aeb606 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -75,7 +75,7 @@ const panelContentStyle = css({ display: "flex", flexDirection: "column", p: "3", - pt: "0", + pt: "2", }); const SHADOW_HEIGHT = 7; @@ -130,16 +130,20 @@ const headerRowStyle = css({ display: "flex", justifyContent: "space-between", alignItems: "center", + + borderBottomWidth: "thin", + borderBottomColor: "neutral.a20", }); const mainHeaderRowStyle = css({ p: "3", - borderBottomWidth: "thin", - borderBottomColor: "neutral.a30", display: "flex", justifyContent: "space-between", alignItems: "center", + + borderBottomWidth: "thin", + borderBottomColor: "neutral.a20", }); const headerActionVisibleStyle = css({ From 6704d9e98971a5ec68d7da6026e4bd5f0488e835 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 19:57:41 +0100 Subject: [PATCH 16/46] Update shadow on ScrollableContent --- .../sub-view/vertical/vertical-sub-views-container.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx index 249c1aeb606..51ae3907cc6 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -95,15 +95,15 @@ const scrollShadowStyle = cva({ position: { top: { top: "[0]", - background: "[linear-gradient(to bottom, #F0F0F0, transparent)]", + background: "[linear-gradient(to bottom, #C0C0C0, #FFFFFF10)]", }, bottom: { bottom: "[0]", - background: "[linear-gradient(to top, #F0F0F0, transparent)]", + background: "[linear-gradient(to top, #C0C0C0, #FFFFFF10)]", }, }, visible: { - true: { opacity: "[0.7]" }, + true: { opacity: "[0.2]" }, }, }, }); From 099effc77767565cd4d4abbd03c62ca586c5acba Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 21:10:04 +0100 Subject: [PATCH 17/46] Adjust content padding: px:4 default, px:3 for filterable lists Increase default panel content horizontal padding to 4 and use negative margin on FilterableListSubView to reduce its effective padding to 3. Co-Authored-By: Claude Opus 4.6 --- .../sub-view/vertical/vertical-sub-views-container.tsx | 2 +- .../panels/LeftSideBar/subviews/filterable-list-sub-view.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx index 51ae3907cc6..c9a8ceb397f 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -74,7 +74,7 @@ const panelContentStyle = css({ minHeight: "[0]", display: "flex", flexDirection: "column", - p: "3", + p: "4", pt: "2", }); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index feb8d69cd30..8b1939cf02e 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -19,6 +19,8 @@ const listContainerStyle = css({ flexDirection: "column", gap: "[1px]", flex: "[1]", + /** Reduce horizontal padding from the parent */ + mx: "-1", }); const listItemRowStyle = cva({ From e770c2ccbccd65ecffbc29efa9ebcd6d3c442383 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 00:55:20 +0100 Subject: [PATCH 18/46] Add keyboard navigation, range selection, and focus highlights to filterable list and menu - Add Arrow Up/Down keyboard navigation with focused item tracking - Support Shift+Click and Shift+Arrow range selection via selectRange helper - Ctrl/Cmd+Click toggles multi-selection with anchor tracking - Escape clears selection and resets focus state - Clamp focus/anchor indices when item list shrinks - Auto-scroll focused items into view - Add ARIA listbox/option roles for accessibility - Suppress default browser focus outline on list container - Add _highlighted styles to Menu items and submenu triggers for keyboard focus Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/components/menu.tsx | 9 + .../petrinaut/src/components/section.tsx | 4 +- .../vertical/vertical-sub-views-container.tsx | 11 +- .../subviews/filterable-list-sub-view.tsx | 192 ++++++++++++++++-- 4 files changed, 191 insertions(+), 25 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/menu.tsx b/libs/@hashintel/petrinaut/src/components/menu.tsx index 6eed2f6e347..6971808b7b9 100644 --- a/libs/@hashintel/petrinaut/src/components/menu.tsx +++ b/libs/@hashintel/petrinaut/src/components/menu.tsx @@ -97,12 +97,18 @@ const itemStyle = cva({ _hover: { backgroundColor: "neutral.s10", }, + _highlighted: { + backgroundColor: "neutral.bg.subtle.hover", + }, _disabled: { opacity: "[0.4]", cursor: "not-allowed", _hover: { backgroundColor: "[transparent]", }, + _highlighted: { + backgroundColor: "[transparent]", + }, }, }, variants: { @@ -174,6 +180,9 @@ const triggerItemStyle = css({ _hover: { backgroundColor: "neutral.s10", }, + _highlighted: { + backgroundColor: "neutral.bg.subtle.hover", + }, }); const triggerItemArrowStyle = css({ diff --git a/libs/@hashintel/petrinaut/src/components/section.tsx b/libs/@hashintel/petrinaut/src/components/section.tsx index db072ae5a17..1f18c53f46f 100644 --- a/libs/@hashintel/petrinaut/src/components/section.tsx +++ b/libs/@hashintel/petrinaut/src/components/section.tsx @@ -15,7 +15,9 @@ const sectionListStyle = css({ flex: "[1]", minHeight: "[0]", "& > *:not(:last-child)": { - borderBottom: "[1px solid rgba(0, 0, 0, 0.06)]", + borderBottomWidth: "[1px]", + borderBottomStyle: "solid", + borderBottomColor: "neutral.a20", }, }); diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx index c9a8ceb397f..aac3187b855 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -103,22 +103,25 @@ const scrollShadowStyle = cva({ }, }, visible: { - true: { opacity: "[0.2]" }, + true: { opacity: "[0.15]" }, }, }, }); const resizeHandleStyle = css({ borderTopWidth: "thin", - borderTopColor: "neutral.a30", + borderTopColor: "neutral.a20", cursor: "ns-resize", backgroundColor: "[transparent]", transition: "[background-color 0.15s ease]", "&[data-separator=hover]": { - backgroundColor: "[rgba(0, 0, 0, 0.1)]", + backgroundColor: "neutral.a40", }, "&[data-separator=active]": { - backgroundColor: "[rgba(59, 130, 246, 0.4)]", + backgroundColor: "blue.s60", + outlineWidth: "[2px]", + outlineStyle: "solid", + outlineColor: "blue.s20", }, }); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 8b1939cf02e..6dd434d67e8 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -1,6 +1,6 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import type { ComponentType, ReactNode } from "react"; -import { use } from "react"; +import { use, useCallback, useEffect, useRef, useState } from "react"; import { LuArrowDownWideNarrow, LuListFilter, LuSearch } from "react-icons/lu"; import { TbDots } from "react-icons/tb"; @@ -12,7 +12,10 @@ import type { SubViewResizeConfig, } from "../../../../../components/sub-view/types"; import { EditorContext } from "../../../../../state/editor-context"; -import type { SelectionItem } from "../../../../../state/selection"; +import type { + SelectionItem, + SelectionMap, +} from "../../../../../state/selection"; const listContainerStyle = css({ display: "flex", @@ -21,6 +24,8 @@ const listContainerStyle = css({ flex: "[1]", /** Reduce horizontal padding from the parent */ mx: "-1", + /** Suppress browser default focus ring — focus is shown per-row via isFocused variant */ + outline: "none", }); const listItemRowStyle = cva({ @@ -75,6 +80,11 @@ const listItemRowStyle = cva({ }, }, }, + isFocused: { + true: { + backgroundColor: "neutral.bg.subtle.hover", + }, + }, }, }); @@ -206,34 +216,176 @@ const FilterableListContent = ({ selectItem, toggleItem, clearSelection, + setSelection, } = use(EditorContext); + const [focusedIndex, setFocusedIndex] = useState(null); + const [anchorIndex, setAnchorIndex] = useState(null); + const containerRef = useRef(null); + const rowRefs = useRef<(HTMLDivElement | null)[]>([]); + + // Clamp focus/anchor when items shrink + useEffect(() => { + if (items.length === 0) { + setFocusedIndex(null); + setAnchorIndex(null); + } else { + setFocusedIndex((prev) => + prev !== null ? Math.min(prev, items.length - 1) : prev, + ); + setAnchorIndex((prev) => + prev !== null ? Math.min(prev, items.length - 1) : prev, + ); + } + }, [items.length]); + + // Scroll focused item into view + useEffect(() => { + if (focusedIndex !== null) { + rowRefs.current[focusedIndex]?.scrollIntoView({ block: "nearest" }); + } + }, [focusedIndex]); + + const selectRange = useCallback( + (fromIndex: number | null, toIndex: number) => { + const start = Math.min(fromIndex ?? toIndex, toIndex); + const end = Math.max(fromIndex ?? toIndex, toIndex); + const newSelection: SelectionMap = new Map(); + for (let i = start; i <= end; i++) { + const item = items[i]; + if (item) { + const selItem = getSelectionItem(item); + newSelection.set(selItem.id, selItem); + } + } + setSelection(newSelection); + }, + [items, getSelectionItem, setSelection], + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (items.length === 0) { + return; + } + + switch (event.key) { + case "ArrowDown": { + event.preventDefault(); + const nextIndex = + focusedIndex === null + ? 0 + : Math.min(focusedIndex + 1, items.length - 1); + setFocusedIndex(nextIndex); + if (event.shiftKey) { + selectRange(anchorIndex ?? nextIndex, nextIndex); + } else { + const item = items[nextIndex]; + if (item) { + selectItem(getSelectionItem(item)); + } + setAnchorIndex(nextIndex); + } + break; + } + case "ArrowUp": { + event.preventDefault(); + const nextIndex = + focusedIndex === null + ? items.length - 1 + : Math.max(focusedIndex - 1, 0); + setFocusedIndex(nextIndex); + if (event.shiftKey) { + selectRange(anchorIndex ?? nextIndex, nextIndex); + } else { + const item = items[nextIndex]; + if (item) { + selectItem(getSelectionItem(item)); + } + setAnchorIndex(nextIndex); + } + break; + } + case "Enter": + case " ": { + event.preventDefault(); + if (focusedIndex !== null) { + const item = items[focusedIndex]; + if (item) { + selectItem(getSelectionItem(item)); + setAnchorIndex(focusedIndex); + } + } + break; + } + case "Escape": { + clearSelection(); + setFocusedIndex(null); + setAnchorIndex(null); + break; + } + } + }, + [ + items, + focusedIndex, + anchorIndex, + selectItem, + getSelectionItem, + clearSelection, + selectRange, + ], + ); + + const handleContainerClick = useCallback(() => { + clearSelection(); + setFocusedIndex(null); + setAnchorIndex(null); + }, [clearSelection]); + + const handleRowClick = useCallback( + (event: React.MouseEvent, index: number, selectionItem: SelectionItem) => { + event.stopPropagation(); + setFocusedIndex(index); + + if (event.shiftKey && anchorIndex !== null) { + selectRange(anchorIndex, index); + } else if (event.metaKey || event.ctrlKey) { + toggleItem(selectionItem); + setAnchorIndex(index); + } else { + selectItem(selectionItem); + setAnchorIndex(index); + } + }, + [anchorIndex, selectRange, toggleItem, selectItem], + ); + return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -
- {items.map((item) => { +
+ {items.map((item, index) => { const isSelected = checkIsSelected(item.id); const selectionItem = getSelectionItem(item); + const isFocused = focusedIndex === index; return (
{ - event.stopPropagation(); - if (event.metaKey || event.ctrlKey) { - toggleItem(selectionItem); - } else { - selectItem(selectionItem); - } - }} - role="button" - tabIndex={0} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - selectItem(selectionItem); - } + ref={(el) => { + rowRefs.current[index] = el; }} - className={listItemRowStyle({ isSelected })} + onClick={(event) => handleRowClick(event, index, selectionItem)} + role="option" + aria-selected={isSelected} + className={listItemRowStyle({ isSelected, isFocused })} >
{item.icon && ( From bda92e340f3122207f6aa7353e12fd26f6d54417 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 01:01:42 +0100 Subject: [PATCH 19/46] Hide header bottom border when sub-view is collapsed Convert headerRowStyle to cva with isCollapsed variant that sets borderBottomColor to transparent, avoiding visual double-borders between collapsed sections. Co-Authored-By: Claude Opus 4.6 --- .../vertical/vertical-sub-views-container.tsx | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx index aac3187b855..8446278ab15 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -95,15 +95,15 @@ const scrollShadowStyle = cva({ position: { top: { top: "[0]", - background: "[linear-gradient(to bottom, #C0C0C0, #FFFFFF10)]", + background: "[linear-gradient(to bottom, #D0D0D0, #FFFFFF10)]", }, bottom: { bottom: "[0]", - background: "[linear-gradient(to top, #C0C0C0, #FFFFFF10)]", + background: "[linear-gradient(to top, #D0D0D0, #FFFFFF10)]", }, }, visible: { - true: { opacity: "[0.15]" }, + true: { opacity: "[0.2]" }, }, }, }); @@ -125,17 +125,26 @@ const resizeHandleStyle = css({ }, }); -const headerRowStyle = css({ - height: "[44px]", - pl: "0.5", - pr: "2", +const headerRowStyle = cva({ + base: { + height: "[44px]", + pl: "0.5", + pr: "2", - display: "flex", - justifyContent: "space-between", - alignItems: "center", + display: "flex", + justifyContent: "space-between", + alignItems: "center", - borderBottomWidth: "thin", - borderBottomColor: "neutral.a20", + borderBottomWidth: "thin", + borderBottomColor: "neutral.a20", + }, + variants: { + isCollapsed: { + true: { + borderBottomColor: "[transparent]", + }, + }, + }, }); const mainHeaderRowStyle = css({ @@ -345,7 +354,11 @@ const SubViewHeader: React.FC = ({ renderHeaderAction, alwaysShowHeaderAction, }) => ( -
+
{main ? (
{Icon && ( From 5c244205d9b053fef472e0db7ad3165d288ca89b Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 01:20:39 +0100 Subject: [PATCH 20/46] Use token-based height for sub-view header rows Replace raw [44px] with token value for consistent sizing. Co-Authored-By: Claude Opus 4.6 --- .../sub-view/vertical/vertical-sub-views-container.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx index 8446278ab15..cb8d5eb0ae3 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -127,7 +127,7 @@ const resizeHandleStyle = css({ const headerRowStyle = cva({ base: { - height: "[44px]", + height: "11", pl: "0.5", pr: "2", @@ -149,6 +149,7 @@ const headerRowStyle = cva({ const mainHeaderRowStyle = css({ p: "3", + h: "11", display: "flex", justifyContent: "space-between", From b871ce9e7024a1a112911c1736741841d07729a9 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 01:42:09 +0100 Subject: [PATCH 21/46] Reset focused/anchor state when filterable list loses focus Clear focusedIndex and anchorIndex on blur when focus moves outside the list container, so stale keyboard state doesn't persist. Co-Authored-By: Claude Opus 4.6 --- .../LeftSideBar/subviews/filterable-list-sub-view.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 6dd434d67e8..4a342b49d8f 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -370,6 +370,12 @@ const FilterableListContent = ({ tabIndex={0} onKeyDown={handleKeyDown} onClick={handleContainerClick} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + setFocusedIndex(null); + setAnchorIndex(null); + } + }} > {items.map((item, index) => { const isSelected = checkIsSelected(item.id); From 9fb87440aa9e7d4d84ae8505986a39176dfbf176 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 01:53:40 +0100 Subject: [PATCH 22/46] Make TopBar title input fill available space Replace fixed minWidth with flex: 1 so the title input expands to use all remaining space in the left section. Co-Authored-By: Claude Opus 4.6 --- .../src/views/Editor/components/TopBar/floating-title.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/floating-title.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/floating-title.tsx index 5c776a7a0e7..1e051aa3e94 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/floating-title.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/floating-title.tsx @@ -4,13 +4,15 @@ const floatingTitleInputStyle = css({ fontSize: "sm", fontWeight: "medium", color: "neutral.s120", - minWidth: "[200px]", + flex: "1", + minWidth: "0", borderRadius: "sm", - padding: "[4px 8px]", + px: "2", + py: "1", _focus: { outline: "2px solid", outlineColor: "blue.s60", - outlineOffset: "[0px]", + outlineOffset: "0", }, _placeholder: { color: "neutral.s100", From 1ff184205621d93587ebf3e6adb3147e584d00bf Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 16:27:43 +0100 Subject: [PATCH 23/46] Add search panel to LeftSideBar with Ctrl/Cmd+F shortcut - Add isSearchOpen state and searchInputRef to EditorContext - Create SearchPanel subview with input in header via renderTitle - Add renderTitle to SubView type for custom main header content - Swap between normal subviews and search panel with CSS transition - Ctrl/Cmd+F opens search or focuses input if already open - Escape closes search globally via keyboard shortcuts hook - Wire Search button in FilterableListSubView header to open search Co-Authored-By: Claude Opus 4.6 --- .../src/components/sub-view/types.ts | 5 ++ .../vertical/vertical-sub-views-container.tsx | 9 ++- .../petrinaut/src/state/editor-context.ts | 13 ++- .../petrinaut/src/state/editor-provider.tsx | 6 ++ .../BottomBar/use-keyboard-shortcuts.ts | 28 ++++++- .../views/Editor/panels/LeftSideBar/panel.tsx | 79 ++++++++++++++++-- .../subviews/filterable-list-sub-view.tsx | 36 +++++---- .../LeftSideBar/subviews/search-panel.tsx | 81 +++++++++++++++++++ 8 files changed, 234 insertions(+), 23 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/types.ts b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts index 85ccda7b180..d2b1e4fcc62 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/types.ts +++ b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts @@ -52,6 +52,11 @@ export interface SubView { * and the content should not include its own title/actions. */ main?: boolean; + /** + * Optional custom render for the title area of a main subview header. + * When provided, replaces the static title text. Only used when `main` is true. + */ + renderTitle?: () => ReactNode; /** * Whether the section can be collapsed by clicking the header. * Defaults to true. Forced to false when `main` is true. diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx index cb8d5eb0ae3..6fe612a8d35 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -338,6 +338,7 @@ interface SubViewHeaderProps { tooltip?: string; icon?: React.ComponentType<{ size: number }>; main?: boolean; + renderTitle?: () => React.ReactNode; isExpanded: boolean; onToggle: () => void; renderHeaderAction?: () => React.ReactNode; @@ -350,6 +351,7 @@ const SubViewHeader: React.FC = ({ tooltip, icon: Icon, main = false, + renderTitle, isExpanded, onToggle, renderHeaderAction, @@ -367,7 +369,11 @@ const SubViewHeader: React.FC = ({ )} - {title} + {renderTitle ? ( + renderTitle() + ) : ( + {title} + )}
) : (
toggleSection(subView)} renderHeaderAction={subView.renderHeaderAction} diff --git a/libs/@hashintel/petrinaut/src/state/editor-context.ts b/libs/@hashintel/petrinaut/src/state/editor-context.ts index 93aa8d9b94c..dac7a581b7e 100644 --- a/libs/@hashintel/petrinaut/src/state/editor-context.ts +++ b/libs/@hashintel/petrinaut/src/state/editor-context.ts @@ -1,4 +1,4 @@ -import { createContext } from "react"; +import { createContext, createRef } from "react"; import { DEFAULT_BOTTOM_PANEL_HEIGHT, @@ -41,6 +41,7 @@ export type EditorState = { draggingStateByNodeId: DraggingStateByNodeId; timelineChartType: TimelineChartType; isPanelAnimating: boolean; + isSearchOpen: boolean; }; /** @@ -72,11 +73,16 @@ export type EditorActions = { resetDraggingState: () => void; collapseAllPanels: () => void; setTimelineChartType: (chartType: TimelineChartType) => void; + setSearchOpen: (isOpen: boolean) => void; triggerPanelAnimation: () => void; __reinitialize: () => void; }; -export type EditorContextValue = EditorState & EditorActions; +export type EditorContextValue = EditorState & + EditorActions & { + /** Ref to the search input element, used for focus management. */ + searchInputRef: React.RefObject; + }; export const initialEditorState: EditorState = { globalMode: "edit", @@ -93,6 +99,7 @@ export const initialEditorState: EditorState = { draggingStateByNodeId: {}, timelineChartType: "run", isPanelAnimating: false, + isSearchOpen: false, }; const DEFAULT_CONTEXT_VALUE: EditorContextValue = { @@ -117,6 +124,8 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = { resetDraggingState: () => {}, collapseAllPanels: () => {}, setTimelineChartType: () => {}, + setSearchOpen: () => {}, + searchInputRef: createRef(), triggerPanelAnimation: () => {}, __reinitialize: () => {}, }; diff --git a/libs/@hashintel/petrinaut/src/state/editor-provider.tsx b/libs/@hashintel/petrinaut/src/state/editor-provider.tsx index e847881ada6..772b6a3e62d 100644 --- a/libs/@hashintel/petrinaut/src/state/editor-provider.tsx +++ b/libs/@hashintel/petrinaut/src/state/editor-provider.tsx @@ -144,6 +144,9 @@ export const EditorProvider: React.FC = ({ children }) => { }, setTimelineChartType: (chartType) => setState((prev) => ({ ...prev, timelineChartType: chartType })), + setSearchOpen: (isOpen) => { + setState((prev) => ({ ...prev, isSearchOpen: isOpen })); + }, triggerPanelAnimation, __reinitialize: () => setState(initialEditorState), }; @@ -162,10 +165,13 @@ export const EditorProvider: React.FC = ({ children }) => { const { selection } = state; const isSelected = (id: string) => selection.has(id); + const searchInputRef = useRef(null); + const contextValue: EditorContextValue = { ...state, ...actions, isSelected, + searchInputRef, }; return ( diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts index 6a4e6478f1d..60149987229 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts @@ -15,7 +15,14 @@ export function useKeyboardShortcuts( onCursorModeChange: (mode: CursorMode) => void, ) { const undoRedo = use(UndoRedoContext); - const { selection, hasSelection, clearSelection } = use(EditorContext); + const { + selection, + hasSelection, + clearSelection, + isSearchOpen, + setSearchOpen, + searchInputRef, + } = use(EditorContext); const { deleteItemsByIds, readonly } = use(SDCPNContext); const isSimulationReadOnly = useIsReadOnly(); const isReadonly = isSimulationReadOnly || readonly; @@ -46,6 +53,25 @@ export function useKeyboardShortcuts( return; } + // Open search with Ctrl/Cmd+F, or focus input if already open + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") { + event.preventDefault(); + if (isSearchOpen) { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + } else { + setSearchOpen(true); + } + return; + } + + // Escape closes search when it's open + if (event.key === "Escape" && isSearchOpen) { + event.preventDefault(); + setSearchOpen(false); + return; + } + if (isInputFocused) { return; } diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/panel.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/panel.tsx index 599e34c3284..07d4ef6a964 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/panel.tsx @@ -1,5 +1,5 @@ import { css, cva, cx } from "@hashintel/ds-helpers/css"; -import { use } from "react"; +import { use, useMemo } from "react"; import { GlassPanel } from "../../../../components/glass-panel"; import { VerticalSubViewsContainer } from "../../../../components/sub-view/vertical/vertical-sub-views-container"; @@ -10,6 +10,7 @@ import { import { LEFT_SIDEBAR_SUBVIEWS } from "../../../../constants/ui-subviews"; import { EditorContext } from "../../../../state/editor-context"; import { UserSettingsContext } from "../../../../state/user-settings-context"; +import { searchSubView } from "./subviews/search-panel"; const glassPanelBaseStyle = css({ position: "absolute", @@ -40,6 +41,51 @@ const panelStyle = cva({ }, }); +const contentWrapperStyle = css({ + position: "relative", + height: "full", + overflow: "hidden", +}); + +const contentLayerStyle = cva({ + base: { + position: "absolute", + inset: "0", + display: "flex", + flexDirection: "column", + transition: "[opacity 120ms ease-in-out, transform 120ms ease-in-out]", + }, + variants: { + active: { + true: { + opacity: "1", + transform: "none", + pointerEvents: "auto", + }, + false: { + opacity: "0", + pointerEvents: "none", + }, + }, + direction: { + forward: {}, + backward: {}, + }, + }, + compoundVariants: [ + { + active: false, + direction: "forward", + css: { transform: "translateX(-8px)" }, + }, + { + active: false, + direction: "backward", + css: { transform: "translateX(8px)" }, + }, + ], +}); + /** * LeftSideBar displays tools and content panels. * Visibility is controlled by the TopBar's sidebar toggle. @@ -51,10 +97,13 @@ export const LeftSideBar: React.FC = () => { leftSidebarWidth, setLeftSidebarWidth, isPanelAnimating, + isSearchOpen, } = use(EditorContext); const { keepPanelsMounted } = use(UserSettingsContext); + const searchSubViews = useMemo(() => [searchSubView], []); + if (!isOpen && !isPanelAnimating && !keepPanelsMounted) { return null; } @@ -74,10 +123,30 @@ export const LeftSideBar: React.FC = () => { maxSize: MAX_LEFT_SIDEBAR_WIDTH, }} > - +
+
+ +
+
+ +
+
); }; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 4a342b49d8f..2cd19e3e7db 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -147,20 +147,28 @@ interface FilterableListSubViewConfig { const FilterHeaderAction: React.FC<{ renderExtraAction?: () => ReactNode; -}> = ({ renderExtraAction }) => ( - <> - - - - - - - - - - {renderExtraAction?.()} - -); +}> = ({ renderExtraAction }) => { + const { setSearchOpen } = use(EditorContext); + + return ( + <> + + + + + + + setSearchOpen(true)} + > + + + {renderExtraAction?.()} + + ); +}; /** * Renders the row ellipsis menu. Separated into its own component so that diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx new file mode 100644 index 00000000000..0a4183920c7 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx @@ -0,0 +1,81 @@ +import { css } from "@hashintel/ds-helpers/css"; +import { use, useEffect } from "react"; +import { LuSearch } from "react-icons/lu"; + +import { IconButton } from "../../../../../components/icon-button"; +import type { SubView } from "../../../../../components/sub-view/types"; +import { EditorContext } from "../../../../../state/editor-context"; + +const searchInputStyle = css({ + flex: "1", + minWidth: "0", + fontSize: "sm", + fontWeight: "medium", + color: "neutral.s120", + outline: "none", + _placeholder: { + color: "neutral.s80", + }, +}); + +const searchResultsStyle = css({ + flex: "1", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: "sm", + color: "neutral.s65", + px: "3", + py: "6", +}); + +const SearchContent: React.FC = () => ( +
Search coming soon
+); + +const SearchTitle: React.FC = () => { + const { isSearchOpen, searchInputRef } = use(EditorContext); + + useEffect(() => { + if (isSearchOpen) { + requestAnimationFrame(() => { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }); + } + }, [isSearchOpen, searchInputRef]); + + return ( + + ); +}; + +const SearchHeaderAction: React.FC = () => { + const { setSearchOpen } = use(EditorContext); + + return ( + setSearchOpen(false)} + > + ✕ + + ); +}; + +export const searchSubView: SubView = { + id: "search", + title: "Search", + icon: LuSearch, + component: SearchContent, + renderTitle: () => , + renderHeaderAction: () => , + alwaysShowHeaderAction: true, + main: true, +}; From fe8f33cc27ba9c1db098237dc4d65ab61440a195 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 17:11:15 +0100 Subject: [PATCH 24/46] Add fuzzy search with match highlighting using fuzzysort Search all sidebar items (nodes, types, equations, parameters) with fuzzy matching via fuzzysort. Matched characters are highlighted in results. Shows match count at top, all items when query is empty. Co-Authored-By: Claude Opus 4.6 --- libs/@hashintel/petrinaut/package.json | 1 + .../LeftSideBar/subviews/search-panel.tsx | 262 +++++++++++++++++- yarn.lock | 8 + 3 files changed, 264 insertions(+), 7 deletions(-) diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index af121e1142d..4a5ad26946f 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -44,6 +44,7 @@ "@xyflow/react": "12.10.1", "d3-scale": "4.0.2", "elkjs": "0.11.0", + "fuzzysort": "3.1.0", "monaco-editor": "0.55.1", "react-icons": "5.5.0", "react-resizable-panels": "4.6.5", diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx index 0a4183920c7..55c662f623f 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx @@ -1,10 +1,23 @@ -import { css } from "@hashintel/ds-helpers/css"; -import { use, useEffect } from "react"; +import { css, cva } from "@hashintel/ds-helpers/css"; +import fuzzysort from "fuzzysort"; +import type { ComponentType, ReactNode } from "react"; +import { use, useEffect, useMemo, useState } from "react"; import { LuSearch } from "react-icons/lu"; import { IconButton } from "../../../../../components/icon-button"; import type { SubView } from "../../../../../components/sub-view/types"; +import { + DifferentialEquationIcon, + ParameterIcon, + PlaceFilledIcon, + TokenTypeIcon, + TransitionFilledIcon, +} from "../../../../../constants/entity-icons"; import { EditorContext } from "../../../../../state/editor-context"; +import { SDCPNContext } from "../../../../../state/sdcpn-context"; +import type { SelectionItem } from "../../../../../state/selection"; + +// -- Styles ------------------------------------------------------------------- const searchInputStyle = css({ flex: "1", @@ -18,8 +31,86 @@ const searchInputStyle = css({ }, }); -const searchResultsStyle = css({ +const matchCountStyle = css({ + px: "3", + py: "1.5", + fontSize: "xs", + fontWeight: "medium", + color: "neutral.s80", + borderBottomWidth: "thin", + borderBottomColor: "neutral.a20", +}); + +const resultListStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[1px]", + py: "1", + mx: "-1", +}); + +const resultRowStyle = cva({ + base: { + display: "flex", + alignItems: "center", + gap: "1", + minHeight: "8", + p: "1", + borderRadius: "lg", + cursor: "pointer", + fontSize: "sm", + fontWeight: "medium", + color: "neutral.s115", + transition: "[background-color 100ms ease-out]", + }, + variants: { + isSelected: { + true: { + backgroundColor: "neutral.bg.subtle", + _hover: { backgroundColor: "neutral.bg.subtle.hover" }, + }, + false: { + backgroundColor: "[transparent]", + _hover: { backgroundColor: "neutral.bg.surface.hover" }, + }, + }, + }, +}); + +const resultContentStyle = css({ + display: "flex", + alignItems: "center", + gap: "1.5", flex: "1", + minWidth: "0", +}); + +const resultIconStyle = css({ + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", +}); + +const resultNameStyle = css({ + flex: "1", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + +const highlightStyle = css({ + color: "blue.s100", + fontWeight: "semibold", +}); + +const resultCategoryStyle = css({ + flexShrink: 0, + fontSize: "xs", + color: "neutral.s80", +}); + +const emptyResultsStyle = css({ display: "flex", alignItems: "center", justifyContent: "center", @@ -29,9 +120,166 @@ const searchResultsStyle = css({ py: "6", }); -const SearchContent: React.FC = () => ( -
Search coming soon
-); +const ICON_SIZE = 12; +const DEFAULT_ICON_COLOR = "#9ca3af"; + +// -- Search item types -------------------------------------------------------- + +interface SearchableItem { + id: string; + name: string; + category: string; + icon: ComponentType<{ size: number }>; + iconColor?: string; + selectionItem: SelectionItem; +} + +interface SearchResult { + item: SearchableItem; + highlighted: ReactNode; +} + +function useSearchableItems(): SearchableItem[] { + const { + petriNetDefinition: { + places, + transitions, + types, + differentialEquations, + parameters, + }, + } = use(SDCPNContext); + + return useMemo( + () => [ + ...places.map((p) => ({ + id: p.id, + name: p.name || `Place ${p.id}`, + category: "Node", + icon: PlaceFilledIcon, + selectionItem: { type: "place" as const, id: p.id }, + })), + ...transitions.map((t) => ({ + id: t.id, + name: t.name || `Transition ${t.id}`, + category: "Node", + icon: TransitionFilledIcon, + selectionItem: { type: "transition" as const, id: t.id }, + })), + ...types.map((t) => ({ + id: t.id, + name: t.name, + category: "Type", + icon: TokenTypeIcon, + iconColor: t.displayColor, + selectionItem: { type: "type" as const, id: t.id }, + })), + ...differentialEquations.map((eq) => ({ + id: eq.id, + name: eq.name, + category: "Equation", + icon: DifferentialEquationIcon, + selectionItem: { + type: "differentialEquation" as const, + id: eq.id, + }, + })), + ...parameters.map((p) => ({ + id: p.id, + name: p.name, + category: "Parameter", + icon: ParameterIcon, + selectionItem: { type: "parameter" as const, id: p.id }, + })), + ], + [places, transitions, types, differentialEquations, parameters], + ); +} + +// -- Components --------------------------------------------------------------- + +const SearchContent: React.FC = () => { + const { isSelected: checkIsSelected, selectItem } = use(EditorContext); + const allItems = useSearchableItems(); + const [query, setQuery] = useState(""); + + const { searchInputRef } = use(EditorContext); + + // Sync query from the input (the input lives in SearchTitle, so we read its value) + useEffect(() => { + const input = searchInputRef.current; + if (!input) { + return; + } + + const handleInput = () => setQuery(input.value); + input.addEventListener("input", handleInput); + setQuery(input.value); + return () => input.removeEventListener("input", handleInput); + }, [searchInputRef]); + + const results: SearchResult[] = useMemo(() => { + const trimmed = query.trim(); + if (trimmed === "") { + return allItems.map((item) => ({ item, highlighted: item.name })); + } + + const fuzzyResults = fuzzysort.go(trimmed, allItems, { + key: "name", + threshold: -1000, + }); + + return fuzzyResults.map((result) => ({ + item: result.obj, + highlighted: + fuzzysort.highlight(result[0], (match, i: number) => ( + + {match} + + )) ?? result.obj.name, + })); + }, [query, allItems]); + + const matchLabel = + query.trim() === "" + ? `${results.length} items` + : `${results.length} match${results.length === 1 ? "" : "es"}`; + + return ( + <> +
{matchLabel}
+ {results.length > 0 ? ( +
+ {results.map(({ item, highlighted }) => { + const isSelected = checkIsSelected(item.id); + return ( +
selectItem(item.selectionItem)} + > +
+ + + + {highlighted} +
+ {item.category} +
+ ); + })} +
+ ) : ( +
No matches
+ )} + + ); +}; const SearchTitle: React.FC = () => { const { isSearchOpen, searchInputRef } = use(EditorContext); @@ -61,7 +309,7 @@ const SearchHeaderAction: React.FC = () => { return ( setSearchOpen(false)} > ✕ diff --git a/yarn.lock b/yarn.lock index da864f12462..15f1fa14abd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7965,6 +7965,7 @@ __metadata: babel-plugin-react-compiler: "npm:1.0.0" d3-scale: "npm:4.0.2" elkjs: "npm:0.11.0" + fuzzysort: "npm:3.1.0" jsdom: "npm:24.1.3" monaco-editor: "npm:0.55.1" oxlint: "npm:1.55.0" @@ -29470,6 +29471,13 @@ __metadata: languageName: node linkType: hard +"fuzzysort@npm:3.1.0": + version: 3.1.0 + resolution: "fuzzysort@npm:3.1.0" + checksum: 10c0/da9bb32de16f2a5c2c000b99031d9f4f8a01380c12d5d3b67296443a1152c55987ce3c4ddbfe97481b0e9b6f2fb77d61dceba29a93ad36ee23ef5bab6a31afb8 + languageName: node + linkType: hard + "gaxios@npm:^6.0.0, gaxios@npm:^6.0.2, gaxios@npm:^6.0.3, gaxios@npm:^6.1.1": version: 6.7.1 resolution: "gaxios@npm:6.7.1" From 99cc2a621a2381ccb2d8df71cc427a785c007fc7 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 17:37:24 +0100 Subject: [PATCH 25/46] Add keyboard navigation to search results and fix fuzzysort highlight call ArrowDown from the search input moves focus to the first result. ArrowUp/Down navigates through results, selecting each item. ArrowUp on the first result returns focus to the input. Also fixes the fuzzysort highlight call to use the instance method (result.highlight) instead of the non-existent static method. Co-Authored-By: Claude Opus 4.6 --- .../LeftSideBar/subviews/search-panel.tsx | 141 ++++++++++++++++-- 1 file changed, 129 insertions(+), 12 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx index 55c662f623f..be0073f3d72 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx @@ -1,7 +1,7 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import fuzzysort from "fuzzysort"; import type { ComponentType, ReactNode } from "react"; -import { use, useEffect, useMemo, useState } from "react"; +import { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LuSearch } from "react-icons/lu"; import { IconButton } from "../../../../../components/icon-button"; @@ -47,6 +47,7 @@ const resultListStyle = css({ gap: "[1px]", py: "1", mx: "-1", + outline: "none", }); const resultRowStyle = cva({ @@ -74,6 +75,11 @@ const resultRowStyle = cva({ _hover: { backgroundColor: "neutral.bg.surface.hover" }, }, }, + isFocused: { + true: { + backgroundColor: "neutral.bg.subtle.hover", + }, + }, }, }); @@ -202,6 +208,9 @@ const SearchContent: React.FC = () => { const { isSelected: checkIsSelected, selectItem } = use(EditorContext); const allItems = useSearchableItems(); const [query, setQuery] = useState(""); + const [focusedIndex, setFocusedIndex] = useState(null); + const listRef = useRef(null); + const rowRefs = useRef<(HTMLDivElement | null)[]>([]); const { searchInputRef } = use(EditorContext); @@ -212,7 +221,11 @@ const SearchContent: React.FC = () => { return; } - const handleInput = () => setQuery(input.value); + const handleInput = () => { + setQuery(input.value); + // Reset focus when query changes + setFocusedIndex(null); + }; input.addEventListener("input", handleInput); setQuery(input.value); return () => input.removeEventListener("input", handleInput); @@ -231,15 +244,82 @@ const SearchContent: React.FC = () => { return fuzzyResults.map((result) => ({ item: result.obj, - highlighted: - fuzzysort.highlight(result[0], (match, i: number) => ( - - {match} - - )) ?? result.obj.name, + highlighted: result.highlight((match, i) => ( + + {match} + + )), })); }, [query, allItems]); + // Clamp focusedIndex when results shrink + useEffect(() => { + if (results.length === 0) { + setFocusedIndex(null); + } else { + setFocusedIndex((prev) => + prev !== null ? Math.min(prev, results.length - 1) : prev, + ); + } + }, [results.length]); + + // Scroll focused item into view + useEffect(() => { + if (focusedIndex !== null) { + rowRefs.current[focusedIndex]?.scrollIntoView({ block: "nearest" }); + } + }, [focusedIndex]); + + const handleListKeyDown = useCallback( + (event: React.KeyboardEvent) => { + switch (event.key) { + case "ArrowDown": { + event.preventDefault(); + if (results.length === 0) { + return; + } + const nextIndex = + focusedIndex === null + ? 0 + : Math.min(focusedIndex + 1, results.length - 1); + setFocusedIndex(nextIndex); + const item = results[nextIndex]; + if (item) { + selectItem(item.item.selectionItem); + } + break; + } + case "ArrowUp": { + event.preventDefault(); + if (focusedIndex === null || focusedIndex === 0) { + // Move focus back to the search input + setFocusedIndex(null); + searchInputRef.current?.focus(); + } else { + const nextIndex = focusedIndex - 1; + setFocusedIndex(nextIndex); + const item = results[nextIndex]; + if (item) { + selectItem(item.item.selectionItem); + } + } + break; + } + case "Enter": { + event.preventDefault(); + if (focusedIndex !== null) { + const item = results[focusedIndex]; + if (item) { + selectItem(item.item.selectionItem); + } + } + break; + } + } + }, + [results, focusedIndex, selectItem, searchInputRef], + ); + const matchLabel = query.trim() === "" ? `${results.length} items` @@ -249,14 +329,42 @@ const SearchContent: React.FC = () => { <>
{matchLabel}
{results.length > 0 ? ( -
- {results.map(({ item, highlighted }) => { +
{ + // When the list receives focus (e.g. from ArrowDown in input), + // highlight and select the first item + if (focusedIndex === null && results.length > 0) { + setFocusedIndex(0); + const first = results[0]; + if (first) { + selectItem(first.item.selectionItem); + } + } + }} + > + {results.map(({ item, highlighted }, index) => { const isSelected = checkIsSelected(item.id); + const isFocused = focusedIndex === index; return (
selectItem(item.selectionItem)} + ref={(el) => { + rowRefs.current[index] = el; + }} + role="option" + tabIndex={-1} + aria-selected={isSelected} + className={resultRowStyle({ isSelected, isFocused })} + onClick={() => { + selectItem(item.selectionItem); + setFocusedIndex(index); + }} + onKeyDown={handleListKeyDown} >
{ type="text" placeholder="Find…" className={searchInputStyle} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + // Find the result list within the same sub-view section and focus it + const section = searchInputRef.current?.closest("[data-panel]"); + const list = section?.querySelector("[role=listbox]"); + list?.focus(); + } + }} /> ); }; From 57b8e626a1562057e67813574acc3de5763db324 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 13 Mar 2026 11:04:37 +0100 Subject: [PATCH 26/46] Remove filter/sort buttons and drop manual useCallback/useMemo Hide the Filter and Sort IconButtons from FilterHeaderAction (not yet implemented). Remove all useCallback and useMemo wrappers from changed files so the React Compiler can optimize the components. Also fix search panel to show empty state when no query and prevent onFocus render loop. Co-Authored-By: Claude Opus 4.6 --- .../subviews/filterable-list-sub-view.tsx | 193 +++++++-------- .../LeftSideBar/subviews/search-panel.tsx | 219 ++++++++---------- 2 files changed, 190 insertions(+), 222 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 2cd19e3e7db..528e4a747c8 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -1,7 +1,7 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import type { ComponentType, ReactNode } from "react"; -import { use, useCallback, useEffect, useRef, useState } from "react"; -import { LuArrowDownWideNarrow, LuListFilter, LuSearch } from "react-icons/lu"; +import { use, useEffect, useRef, useState } from "react"; +import { LuSearch } from "react-icons/lu"; import { TbDots } from "react-icons/tb"; import { IconButton } from "../../../../../components/icon-button"; @@ -152,12 +152,6 @@ const FilterHeaderAction: React.FC<{ return ( <> - - - - - - ({ } }, [focusedIndex]); - const selectRange = useCallback( - (fromIndex: number | null, toIndex: number) => { - const start = Math.min(fromIndex ?? toIndex, toIndex); - const end = Math.max(fromIndex ?? toIndex, toIndex); - const newSelection: SelectionMap = new Map(); - for (let i = start; i <= end; i++) { - const item = items[i]; - if (item) { - const selItem = getSelectionItem(item); - newSelection.set(selItem.id, selItem); - } + const selectRange = (fromIndex: number | null, toIndex: number) => { + const start = Math.min(fromIndex ?? toIndex, toIndex); + const end = Math.max(fromIndex ?? toIndex, toIndex); + const newSelection: SelectionMap = new Map(); + for (let i = start; i <= end; i++) { + const item = items[i]; + if (item) { + const selItem = getSelectionItem(item); + newSelection.set(selItem.id, selItem); } - setSelection(newSelection); - }, - [items, getSelectionItem, setSelection], - ); + } + setSelection(newSelection); + }; - const handleKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (items.length === 0) { - return; - } + const handleKeyDown = (event: React.KeyboardEvent) => { + if (items.length === 0) { + return; + } - switch (event.key) { - case "ArrowDown": { - event.preventDefault(); - const nextIndex = - focusedIndex === null - ? 0 - : Math.min(focusedIndex + 1, items.length - 1); - setFocusedIndex(nextIndex); - if (event.shiftKey) { - selectRange(anchorIndex ?? nextIndex, nextIndex); - } else { - const item = items[nextIndex]; - if (item) { - selectItem(getSelectionItem(item)); - } - setAnchorIndex(nextIndex); + switch (event.key) { + case "ArrowDown": { + event.preventDefault(); + const nextIndex = + focusedIndex === null + ? 0 + : Math.min(focusedIndex + 1, items.length - 1); + setFocusedIndex(nextIndex); + if (event.shiftKey) { + selectRange(anchorIndex ?? nextIndex, nextIndex); + } else { + const item = items[nextIndex]; + if (item) { + selectItem(getSelectionItem(item)); } - break; + setAnchorIndex(nextIndex); } - case "ArrowUp": { - event.preventDefault(); - const nextIndex = - focusedIndex === null - ? items.length - 1 - : Math.max(focusedIndex - 1, 0); - setFocusedIndex(nextIndex); - if (event.shiftKey) { - selectRange(anchorIndex ?? nextIndex, nextIndex); - } else { - const item = items[nextIndex]; - if (item) { - selectItem(getSelectionItem(item)); - } - setAnchorIndex(nextIndex); + break; + } + case "ArrowUp": { + event.preventDefault(); + const nextIndex = + focusedIndex === null + ? items.length - 1 + : Math.max(focusedIndex - 1, 0); + setFocusedIndex(nextIndex); + if (event.shiftKey) { + selectRange(anchorIndex ?? nextIndex, nextIndex); + } else { + const item = items[nextIndex]; + if (item) { + selectItem(getSelectionItem(item)); } - break; + setAnchorIndex(nextIndex); } - case "Enter": - case " ": { - event.preventDefault(); - if (focusedIndex !== null) { - const item = items[focusedIndex]; - if (item) { - selectItem(getSelectionItem(item)); - setAnchorIndex(focusedIndex); - } + break; + } + case "Enter": + case " ": { + event.preventDefault(); + if (focusedIndex !== null) { + const item = items[focusedIndex]; + if (item) { + selectItem(getSelectionItem(item)); + setAnchorIndex(focusedIndex); } - break; - } - case "Escape": { - clearSelection(); - setFocusedIndex(null); - setAnchorIndex(null); - break; } + break; } - }, - [ - items, - focusedIndex, - anchorIndex, - selectItem, - getSelectionItem, - clearSelection, - selectRange, - ], - ); + case "Escape": { + clearSelection(); + setFocusedIndex(null); + setAnchorIndex(null); + break; + } + } + }; - const handleContainerClick = useCallback(() => { + const handleContainerClick = () => { clearSelection(); setFocusedIndex(null); setAnchorIndex(null); - }, [clearSelection]); + }; - const handleRowClick = useCallback( - (event: React.MouseEvent, index: number, selectionItem: SelectionItem) => { - event.stopPropagation(); - setFocusedIndex(index); + const handleRowClick = ( + event: React.MouseEvent, + index: number, + selectionItem: SelectionItem, + ) => { + event.stopPropagation(); + setFocusedIndex(index); - if (event.shiftKey && anchorIndex !== null) { - selectRange(anchorIndex, index); - } else if (event.metaKey || event.ctrlKey) { - toggleItem(selectionItem); - setAnchorIndex(index); - } else { - selectItem(selectionItem); - setAnchorIndex(index); - } - }, - [anchorIndex, selectRange, toggleItem, selectItem], - ); + if (event.shiftKey && anchorIndex !== null) { + selectRange(anchorIndex, index); + } else if (event.metaKey || event.ctrlKey) { + toggleItem(selectionItem); + setAnchorIndex(index); + } else { + selectItem(selectionItem); + setAnchorIndex(index); + } + }; return (
[ - ...places.map((p) => ({ - id: p.id, - name: p.name || `Place ${p.id}`, - category: "Node", - icon: PlaceFilledIcon, - selectionItem: { type: "place" as const, id: p.id }, - })), - ...transitions.map((t) => ({ - id: t.id, - name: t.name || `Transition ${t.id}`, - category: "Node", - icon: TransitionFilledIcon, - selectionItem: { type: "transition" as const, id: t.id }, - })), - ...types.map((t) => ({ - id: t.id, - name: t.name, - category: "Type", - icon: TokenTypeIcon, - iconColor: t.displayColor, - selectionItem: { type: "type" as const, id: t.id }, - })), - ...differentialEquations.map((eq) => ({ + return [ + ...places.map((p) => ({ + id: p.id, + name: p.name || `Place ${p.id}`, + category: "Node", + icon: PlaceFilledIcon, + selectionItem: { type: "place" as const, id: p.id }, + })), + ...transitions.map((t) => ({ + id: t.id, + name: t.name || `Transition ${t.id}`, + category: "Node", + icon: TransitionFilledIcon, + selectionItem: { type: "transition" as const, id: t.id }, + })), + ...types.map((t) => ({ + id: t.id, + name: t.name, + category: "Type", + icon: TokenTypeIcon, + iconColor: t.displayColor, + selectionItem: { type: "type" as const, id: t.id }, + })), + ...differentialEquations.map((eq) => ({ + id: eq.id, + name: eq.name, + category: "Equation", + icon: DifferentialEquationIcon, + selectionItem: { + type: "differentialEquation" as const, id: eq.id, - name: eq.name, - category: "Equation", - icon: DifferentialEquationIcon, - selectionItem: { - type: "differentialEquation" as const, - id: eq.id, - }, - })), - ...parameters.map((p) => ({ - id: p.id, - name: p.name, - category: "Parameter", - icon: ParameterIcon, - selectionItem: { type: "parameter" as const, id: p.id }, - })), - ], - [places, transitions, types, differentialEquations, parameters], - ); + }, + })), + ...parameters.map((p) => ({ + id: p.id, + name: p.name, + category: "Parameter", + icon: ParameterIcon, + selectionItem: { type: "parameter" as const, id: p.id }, + })), + ]; } // -- Components --------------------------------------------------------------- @@ -231,26 +228,23 @@ const SearchContent: React.FC = () => { return () => input.removeEventListener("input", handleInput); }, [searchInputRef]); - const results: SearchResult[] = useMemo(() => { - const trimmed = query.trim(); - if (trimmed === "") { - return allItems.map((item) => ({ item, highlighted: item.name })); - } - - const fuzzyResults = fuzzysort.go(trimmed, allItems, { - key: "name", - threshold: -1000, - }); - - return fuzzyResults.map((result) => ({ - item: result.obj, - highlighted: result.highlight((match, i) => ( - - {match} - - )), - })); - }, [query, allItems]); + const trimmed = query.trim(); + const results: SearchResult[] = + trimmed === "" + ? [] + : fuzzysort + .go(trimmed, allItems, { + key: "name", + threshold: -1000, + }) + .map((result) => ({ + item: result.obj, + highlighted: result.highlight((match, i) => ( + + {match} + + )), + })); // Clamp focusedIndex when results shrink useEffect(() => { @@ -270,64 +264,61 @@ const SearchContent: React.FC = () => { } }, [focusedIndex]); - const handleListKeyDown = useCallback( - (event: React.KeyboardEvent) => { - switch (event.key) { - case "ArrowDown": { - event.preventDefault(); - if (results.length === 0) { - return; - } - const nextIndex = - focusedIndex === null - ? 0 - : Math.min(focusedIndex + 1, results.length - 1); + const handleListKeyDown = (event: React.KeyboardEvent) => { + switch (event.key) { + case "ArrowDown": { + event.preventDefault(); + if (results.length === 0) { + return; + } + const nextIndex = + focusedIndex === null + ? 0 + : Math.min(focusedIndex + 1, results.length - 1); + setFocusedIndex(nextIndex); + const item = results[nextIndex]; + if (item) { + selectItem(item.item.selectionItem); + } + break; + } + case "ArrowUp": { + event.preventDefault(); + if (focusedIndex === null || focusedIndex === 0) { + // Move focus back to the search input + setFocusedIndex(null); + searchInputRef.current?.focus(); + } else { + const nextIndex = focusedIndex - 1; setFocusedIndex(nextIndex); const item = results[nextIndex]; if (item) { selectItem(item.item.selectionItem); } - break; } - case "ArrowUp": { - event.preventDefault(); - if (focusedIndex === null || focusedIndex === 0) { - // Move focus back to the search input - setFocusedIndex(null); - searchInputRef.current?.focus(); - } else { - const nextIndex = focusedIndex - 1; - setFocusedIndex(nextIndex); - const item = results[nextIndex]; - if (item) { - selectItem(item.item.selectionItem); - } - } - break; - } - case "Enter": { - event.preventDefault(); - if (focusedIndex !== null) { - const item = results[focusedIndex]; - if (item) { - selectItem(item.item.selectionItem); - } + break; + } + case "Enter": { + event.preventDefault(); + if (focusedIndex !== null) { + const item = results[focusedIndex]; + if (item) { + selectItem(item.item.selectionItem); } - break; } + break; } - }, - [results, focusedIndex, selectItem, searchInputRef], - ); + } + }; - const matchLabel = - query.trim() === "" - ? `${results.length} items` - : `${results.length} match${results.length === 1 ? "" : "es"}`; + const hasQuery = trimmed !== ""; + const matchLabel = hasQuery + ? `${results.length} match${results.length === 1 ? "" : "es"}` + : null; return ( <> -
{matchLabel}
+ {matchLabel &&
{matchLabel}
} {results.length > 0 ? (
{ onKeyDown={handleListKeyDown} onFocus={() => { // When the list receives focus (e.g. from ArrowDown in input), - // highlight and select the first item + // highlight the first item. Selection happens on Enter or ArrowDown. if (focusedIndex === null && results.length > 0) { setFocusedIndex(0); - const first = results[0]; - if (first) { - selectItem(first.item.selectionItem); - } } }} > @@ -382,9 +369,9 @@ const SearchContent: React.FC = () => { ); })}
- ) : ( + ) : hasQuery ? (
No matches
- )} + ) : null} ); }; From f67b28100b9d973acc95739992a55d089858cea9 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 13 Mar 2026 20:59:42 +0100 Subject: [PATCH 27/46] Update ui-subviews --- libs/@hashintel/petrinaut/src/constants/ui-subviews.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts b/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts index d063d571f81..3f810b24315 100644 --- a/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts +++ b/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts @@ -15,10 +15,10 @@ import { parametersListSubView } from "../views/Editor/panels/LeftSideBar/subvie import { typesListSubView } from "../views/Editor/panels/LeftSideBar/subviews/types-list"; export const LEFT_SIDEBAR_SUBVIEWS: SubView[] = [ + nodesListSubView, typesListSubView, differentialEquationsListSubView, parametersListSubView, - nodesListSubView, ]; // Base subviews always visible in the bottom panel From 6e366e4e322041d56df7b5ecf877e510daec03ed Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 02:21:48 +0100 Subject: [PATCH 28/46] Fix lint errors: rename getMenuItems to useMenuItems and add keyboard handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename getMenuItems → useMenuItems so the linter recognizes these callbacks as hooks (they call use() and useIsReadOnly() inside RowMenu's render). Add onKeyDown to the row div to satisfy the click-events-have-key-events a11y rule. Co-Authored-By: Claude Opus 4.6 --- .../subviews/differential-equations-list.tsx | 2 +- .../subviews/filterable-list-sub-view.tsx | 31 ++++++++++++------- .../LeftSideBar/subviews/parameters-list.tsx | 2 +- .../LeftSideBar/subviews/types-list.tsx | 2 +- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index 8473f14ad5a..1b6836354a6 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -72,7 +72,7 @@ export const differentialEquationsListSubView: SubView = }, getSelectionItem: (eq) => ({ type: "differentialEquation", id: eq.id }), renderItem: (eq) => eq.name, - getMenuItems: (eq) => { + useMenuItems: (eq) => { const { removeDifferentialEquation } = use(SDCPNContext); const isReadOnly = useIsReadOnly(); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 528e4a747c8..805ec57436c 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -139,8 +139,9 @@ interface FilterableListSubViewConfig { useItems: () => T[]; getSelectionItem: (item: T) => SelectionItem; renderItem: (item: T, isSelected: boolean) => ReactNode; - /** Return menu items for the row's ellipsis menu. When omitted, no menu is shown. */ - getMenuItems?: (item: T) => MenuItem[]; + /** Return menu items for the row's ellipsis menu. When omitted, no menu is shown. + * Named `useMenuItems` because implementations may call hooks. */ + useMenuItems?: (item: T) => MenuItem[]; emptyMessage: string; renderHeaderAction?: () => ReactNode; } @@ -169,13 +170,13 @@ const FilterHeaderAction: React.FC<{ * `getMenuItems` (which may call hooks) is invoked as part of a component render. */ const RowMenu = ({ - getMenuItems, + useMenuItems, item, }: { - getMenuItems: (item: T) => MenuItem[]; + useMenuItems: (item: T) => MenuItem[]; item: T; }) => { - const menuItems = getMenuItems(item); + const menuItems = useMenuItems(item); if (menuItems.length === 0) { return null; } @@ -203,13 +204,13 @@ const FilterableListContent = ({ useItems, getSelectionItem, renderItem, - getMenuItems, + useMenuItems, emptyMessage, }: { useItems: () => T[]; getSelectionItem: (item: T) => SelectionItem; renderItem: (item: T, isSelected: boolean) => ReactNode; - getMenuItems?: (item: T) => MenuItem[]; + useMenuItems?: (item: T) => MenuItem[]; emptyMessage: string; }) => { const items = useItems(); @@ -378,6 +379,14 @@ const FilterableListContent = ({ rowRefs.current[index] = el; }} onClick={(event) => handleRowClick(event, index, selectionItem)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectItem(selectionItem); + setFocusedIndex(index); + setAnchorIndex(index); + } + }} role="option" aria-selected={isSelected} className={listItemRowStyle({ isSelected, isFocused })} @@ -395,8 +404,8 @@ const FilterableListContent = ({ {renderItem(item, isSelected)}
- {getMenuItems && ( - + {useMenuItems && ( + )}
); @@ -427,7 +436,7 @@ export function createFilterableListSubView( useItems, getSelectionItem, renderItem, - getMenuItems, + useMenuItems, emptyMessage, renderHeaderAction: renderExtraAction, } = config; @@ -437,7 +446,7 @@ export function createFilterableListSubView( useItems={useItems} getSelectionItem={getSelectionItem} renderItem={renderItem} - getMenuItems={getMenuItems} + useMenuItems={useMenuItems} emptyMessage={emptyMessage} /> ); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index 54ae8958ba2..6bf4e3f0e0d 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -89,7 +89,7 @@ export const parametersListSubView: SubView = createFilterableListSubView({
); }, - getMenuItems: (param) => { + useMenuItems: (param) => { const { removeParameter } = use(SDCPNContext); const { globalMode } = use(EditorContext); const isReadOnly = useIsReadOnly(); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index 2a12fc6ca1f..daf66c33fc7 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -126,7 +126,7 @@ export const typesListSubView: SubView = createFilterableListSubView({ }, getSelectionItem: (type) => ({ type: "type", id: type.id }), renderItem: (type) => type.name, - getMenuItems: (type) => { + useMenuItems: (type) => { const { removeType } = use(SDCPNContext); const isReadOnly = useIsReadOnly(); From 40e758cd2dc7427235fed140c367f93056f95cba Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 19:30:05 +0100 Subject: [PATCH 29/46] Address AI review feedback across multiple components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix invalid HTML: change to
for listItemNameStyle wrapper so block-level renderItem content (e.g. parameters) nests correctly - Guard listbox onKeyDown to ignore events bubbling from nested controls - Prevent Ctrl+F from intercepting inside Monaco/inputs by checking isInputFocused before handling the shortcut - Restrict Escape-to-close-search to only fire when search input is focused - Remove duplicate onKeyDown on search result rows (parent listbox handles navigation; rows now only handle Enter/Space for a11y) - Add alwaysShowHeaderAction to multi-selection panel for consistency - Remove commented-out CSS in tooltip.tsx - Update stale doc comment (getMenuItems → useMenuItems) Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/components/tooltip.tsx | 1 - .../BottomBar/use-keyboard-shortcuts.ts | 17 +++++++++++++---- .../subviews/filterable-list-sub-view.tsx | 10 +++++++--- .../LeftSideBar/subviews/search-panel.tsx | 8 +++++++- .../PropertiesPanel/multi-selection-panel.tsx | 1 + 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/tooltip.tsx b/libs/@hashintel/petrinaut/src/components/tooltip.tsx index 7cf04afed98..70b29ece054 100644 --- a/libs/@hashintel/petrinaut/src/components/tooltip.tsx +++ b/libs/@hashintel/petrinaut/src/components/tooltip.tsx @@ -117,7 +117,6 @@ const circleInfoIconStyle = css({ marginBottom: "[2px]", color: "neutral.s85", verticalAlign: "middle", - // fill: "[currentColor]", }); export const InfoIconTooltip = ({ diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts index 60149987229..a758337063f 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts @@ -53,8 +53,13 @@ export function useKeyboardShortcuts( return; } - // Open search with Ctrl/Cmd+F, or focus input if already open - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") { + // Open search with Ctrl/Cmd+F, or focus input if already open. + // Skip when focus is inside Monaco or another input so their native find works. + if ( + !isInputFocused && + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "f" + ) { event.preventDefault(); if (isSearchOpen) { searchInputRef.current?.focus(); @@ -65,8 +70,12 @@ export function useKeyboardShortcuts( return; } - // Escape closes search when it's open - if (event.key === "Escape" && isSearchOpen) { + // Escape closes search only when the search input itself is focused + if ( + event.key === "Escape" && + isSearchOpen && + document.activeElement === searchInputRef.current + ) { event.preventDefault(); setSearchOpen(false); return; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 805ec57436c..5631e12694c 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -167,7 +167,7 @@ const FilterHeaderAction: React.FC<{ /** * Renders the row ellipsis menu. Separated into its own component so that - * `getMenuItems` (which may call hooks) is invoked as part of a component render. + * `useMenuItems` (which may call hooks) is invoked as part of a component render. */ const RowMenu = ({ useMenuItems, @@ -264,6 +264,10 @@ const FilterableListContent = ({ }; const handleKeyDown = (event: React.KeyboardEvent) => { + // Ignore key events bubbling from nested interactive controls (e.g. row menu buttons) + if (event.target !== event.currentTarget) { + return; + } if (items.length === 0) { return; } @@ -400,9 +404,9 @@ const FilterableListContent = ({ )} - +
{renderItem(item, isSelected)} - +
{useMenuItems && ( diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx index 146e3b103e7..3b296ce9565 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx @@ -351,7 +351,13 @@ const SearchContent: React.FC = () => { selectItem(item.selectionItem); setFocusedIndex(index); }} - onKeyDown={handleListKeyDown} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectItem(item.selectionItem); + setFocusedIndex(index); + } + }} >
, + alwaysShowHeaderAction: true, }; const subViews: SubView[] = [multiSelectionMainSubView]; From 3c52812228c2db84ad5057542a0cc798e2e729d7 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 19:54:58 +0100 Subject: [PATCH 30/46] Extract row menu into proper components to fix React Compiler error The compiler flagged useMenuItems and useItems as dynamic hook props. Fix by: - Replacing useMenuItems callback with renderRowMenu component prop, so each consumer defines a proper React component (DiffEqRowMenu, ParameterRowMenu, TypeRowMenu) that calls hooks safely - Moving useItems() call from FilterableListContent into the factory's Component, passing plain items data down instead of a hook prop - Exporting RowMenu helper for consumers to render shared menu chrome Co-Authored-By: Claude Opus 4.6 --- .../subviews/differential-equations-list.tsx | 41 ++++++++----- .../subviews/filterable-list-sub-view.tsx | 59 ++++++++----------- .../LeftSideBar/subviews/parameters-list.tsx | 51 +++++++++------- .../LeftSideBar/subviews/types-list.tsx | 41 ++++++++----- 4 files changed, 106 insertions(+), 86 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index 1b6836354a6..8f68300697d 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -10,7 +10,10 @@ import { DEFAULT_DIFFERENTIAL_EQUATION_CODE } from "../../../../../core/default- import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; -import { createFilterableListSubView } from "./filterable-list-sub-view"; +import { + RowMenu, + createFilterableListSubView, +} from "./filterable-list-sub-view"; /** * DifferentialEquationsSectionHeaderAction renders the add button for the section header. @@ -47,6 +50,26 @@ const DifferentialEquationsSectionHeaderAction: React.FC = () => { ); }; +const DiffEqRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { + const { removeDifferentialEquation } = use(SDCPNContext); + const isReadOnly = useIsReadOnly(); + + return ( + , + destructive: true, + disabled: isReadOnly, + onClick: () => removeDifferentialEquation(item.id), + }, + ]} + /> + ); +}; + /** * SubView definition for Differential Equations list. */ @@ -72,21 +95,7 @@ export const differentialEquationsListSubView: SubView = }, getSelectionItem: (eq) => ({ type: "differentialEquation", id: eq.id }), renderItem: (eq) => eq.name, - useMenuItems: (eq) => { - const { removeDifferentialEquation } = use(SDCPNContext); - const isReadOnly = useIsReadOnly(); - - return [ - { - id: "delete", - label: "Delete", - icon: , - destructive: true, - disabled: isReadOnly, - onClick: () => removeDifferentialEquation(eq.id), - }, - ]; - }, + renderRowMenu: DiffEqRowMenu, emptyMessage: "No differential equations yet", renderHeaderAction: () => , }); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 5631e12694c..130c13fd3e7 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -139,9 +139,9 @@ interface FilterableListSubViewConfig { useItems: () => T[]; getSelectionItem: (item: T) => SelectionItem; renderItem: (item: T, isSelected: boolean) => ReactNode; - /** Return menu items for the row's ellipsis menu. When omitted, no menu is shown. - * Named `useMenuItems` because implementations may call hooks. */ - useMenuItems?: (item: T) => MenuItem[]; + /** Component to render the row's ellipsis menu. Receives the item as a prop. + * Use `RowMenu` helper to render the shared menu chrome. */ + renderRowMenu?: ComponentType<{ item: T }>; emptyMessage: string; renderHeaderAction?: () => ReactNode; } @@ -166,18 +166,11 @@ const FilterHeaderAction: React.FC<{ }; /** - * Renders the row ellipsis menu. Separated into its own component so that - * `useMenuItems` (which may call hooks) is invoked as part of a component render. + * Shared row menu chrome. Consumers call hooks in their own `renderRowMenu` + * component and pass the resulting items here. */ -const RowMenu = ({ - useMenuItems, - item, -}: { - useMenuItems: (item: T) => MenuItem[]; - item: T; -}) => { - const menuItems = useMenuItems(item); - if (menuItems.length === 0) { +export const RowMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => { + if (items.length === 0) { return null; } @@ -194,26 +187,25 @@ const RowMenu = ({ } - items={menuItems} + items={items} placement="bottom-end" /> ); }; const FilterableListContent = ({ - useItems, + items, getSelectionItem, renderItem, - useMenuItems, + renderRowMenu: RenderRowMenu, emptyMessage, }: { - useItems: () => T[]; + items: T[]; getSelectionItem: (item: T) => SelectionItem; renderItem: (item: T, isSelected: boolean) => ReactNode; - useMenuItems?: (item: T) => MenuItem[]; + renderRowMenu?: ComponentType<{ item: T }>; emptyMessage: string; }) => { - const items = useItems(); const { isSelected: checkIsSelected, selectItem, @@ -408,9 +400,7 @@ const FilterableListContent = ({ {renderItem(item, isSelected)}
- {useMenuItems && ( - - )} + {RenderRowMenu && }
); })} @@ -440,20 +430,23 @@ export function createFilterableListSubView( useItems, getSelectionItem, renderItem, - useMenuItems, + renderRowMenu, emptyMessage, renderHeaderAction: renderExtraAction, } = config; - const Component: React.FC = () => ( - - ); + const Component: React.FC = () => { + const items = useItems(); + return ( + + ); + }; return { id, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index 6bf4e3f0e0d..b6ed1046451 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -10,7 +10,10 @@ import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; -import { createFilterableListSubView } from "./filterable-list-sub-view"; +import { + RowMenu, + createFilterableListSubView, +} from "./filterable-list-sub-view"; const parameterVarNameStyle = css({ margin: "0", @@ -57,6 +60,31 @@ const ParametersHeaderAction: React.FC = () => { ); }; +const ParameterRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { + const { removeParameter } = use(SDCPNContext); + const { globalMode } = use(EditorContext); + const isReadOnly = useIsReadOnly(); + + if (globalMode === "simulate") { + return null; + } + + return ( + , + destructive: true, + disabled: isReadOnly, + onClick: () => removeParameter(item.id), + }, + ]} + /> + ); +}; + /** * SubView definition for Global Parameters List. */ @@ -89,26 +117,7 @@ export const parametersListSubView: SubView = createFilterableListSubView({
); }, - useMenuItems: (param) => { - const { removeParameter } = use(SDCPNContext); - const { globalMode } = use(EditorContext); - const isReadOnly = useIsReadOnly(); - - if (globalMode === "simulate") { - return []; - } - - return [ - { - id: "delete", - label: "Delete", - icon: , - destructive: true, - disabled: isReadOnly, - onClick: () => removeParameter(param.id), - }, - ]; - }, + renderRowMenu: ParameterRowMenu, emptyMessage: "No global parameters yet", renderHeaderAction: () => , }); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index daf66c33fc7..c4566d67a53 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -8,7 +8,10 @@ import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; -import { createFilterableListSubView } from "./filterable-list-sub-view"; +import { + RowMenu, + createFilterableListSubView, +} from "./filterable-list-sub-view"; // Pool of 10 well-differentiated colors for types const TYPE_COLOR_POOL = [ @@ -101,6 +104,26 @@ const TypesSectionHeaderAction: React.FC = () => { ); }; +const TypeRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { + const { removeType } = use(SDCPNContext); + const isReadOnly = useIsReadOnly(); + + return ( + , + destructive: true, + disabled: isReadOnly, + onClick: () => removeType(item.id), + }, + ]} + /> + ); +}; + /** * SubView definition for Token Types list. */ @@ -126,21 +149,7 @@ export const typesListSubView: SubView = createFilterableListSubView({ }, getSelectionItem: (type) => ({ type: "type", id: type.id }), renderItem: (type) => type.name, - useMenuItems: (type) => { - const { removeType } = use(SDCPNContext); - const isReadOnly = useIsReadOnly(); - - return [ - { - id: "delete", - label: "Delete", - icon: , - destructive: true, - disabled: isReadOnly, - onClick: () => removeType(type.id), - }, - ]; - }, + renderRowMenu: TypeRowMenu, emptyMessage: "No token types yet", renderHeaderAction: () => , }); From d216d1e3b0359565f00b5dfe0b3a16fca989bcb4 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 20:00:27 +0100 Subject: [PATCH 31/46] Clip overflow on editor root to hide menus when collapsed Adds overflow: hidden to editorRootStyle so portalled menus and tooltips don't render outside the component boundary. Co-Authored-By: Claude Opus 4.6 --- libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index 3efa6428d6c..487dc69981e 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -39,6 +39,7 @@ const canvasContainerStyle = css({ const editorRootStyle = css({ position: "relative", height: "full", + overflow: "hidden", backgroundColor: "neutral.s25", }); From 6615a9c0c7fb2b7527294e1009076650a23a652e Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sun, 15 Mar 2026 01:15:23 +0100 Subject: [PATCH 32/46] Fix duplicate context call and hidden layer keyboard focus - Merge two use(EditorContext) calls into one in SearchContent - Add visibility: hidden to inactive sidebar content layer to prevent keyboard focus reaching invisible elements Co-Authored-By: Claude Opus 4.6 --- .../src/views/Editor/panels/LeftSideBar/panel.tsx | 1 + .../Editor/panels/LeftSideBar/subviews/search-panel.tsx | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/panel.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/panel.tsx index 07d4ef6a964..27d45e2f9e5 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/panel.tsx @@ -65,6 +65,7 @@ const contentLayerStyle = cva({ false: { opacity: "0", pointerEvents: "none", + visibility: "hidden", }, }, direction: { diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx index 3b296ce9565..a0f23aed71f 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx @@ -202,15 +202,17 @@ function useSearchableItems(): SearchableItem[] { // -- Components --------------------------------------------------------------- const SearchContent: React.FC = () => { - const { isSelected: checkIsSelected, selectItem } = use(EditorContext); + const { + isSelected: checkIsSelected, + selectItem, + searchInputRef, + } = use(EditorContext); const allItems = useSearchableItems(); const [query, setQuery] = useState(""); const [focusedIndex, setFocusedIndex] = useState(null); const listRef = useRef(null); const rowRefs = useRef<(HTMLDivElement | null)[]>([]); - const { searchInputRef } = use(EditorContext); - // Sync query from the input (the input lives in SearchTitle, so we read its value) useEffect(() => { const input = searchInputRef.current; From dc9d848b9c93ee2d6f7c28e8c802c6fd915b5664 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 16 Mar 2026 11:15:43 +0100 Subject: [PATCH 33/46] Address AI review feedback: fix search focus, stale refs, and sidebar open - Blur search input on Escape close to prevent invisible input capturing keys - Open left sidebar when Ctrl/Cmd+F is pressed with sidebar closed - Truncate stale rowRefs when list items shrink to prevent memory leaks - Stop propagation on search row keyDown to prevent duplicate handler firing - Remove unnecessary export on emptyMessageStyle Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Editor/components/BottomBar/use-keyboard-shortcuts.ts | 3 +++ .../panels/LeftSideBar/subviews/filterable-list-sub-view.tsx | 5 +++-- .../Editor/panels/LeftSideBar/subviews/search-panel.tsx | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts index a758337063f..ae882d5bab2 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts @@ -22,6 +22,7 @@ export function useKeyboardShortcuts( isSearchOpen, setSearchOpen, searchInputRef, + setLeftSidebarOpen, } = use(EditorContext); const { deleteItemsByIds, readonly } = use(SDCPNContext); const isSimulationReadOnly = useIsReadOnly(); @@ -65,6 +66,7 @@ export function useKeyboardShortcuts( searchInputRef.current?.focus(); searchInputRef.current?.select(); } else { + setLeftSidebarOpen(true); setSearchOpen(true); } return; @@ -77,6 +79,7 @@ export function useKeyboardShortcuts( document.activeElement === searchInputRef.current ) { event.preventDefault(); + searchInputRef.current?.blur(); setSearchOpen(false); return; } diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 130c13fd3e7..c0069115756 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -117,7 +117,7 @@ const listItemIconStyle = css({ justifyContent: "center", }); -export const emptyMessageStyle = css({ +const emptyMessageStyle = css({ pt: "1", px: "1", fontSize: "sm", @@ -219,8 +219,9 @@ const FilterableListContent = ({ const containerRef = useRef(null); const rowRefs = useRef<(HTMLDivElement | null)[]>([]); - // Clamp focus/anchor when items shrink + // Clamp focus/anchor when items shrink and truncate stale row refs useEffect(() => { + rowRefs.current.length = items.length; if (items.length === 0) { setFocusedIndex(null); setAnchorIndex(null); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx index a0f23aed71f..ca71eb8a015 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx @@ -248,8 +248,9 @@ const SearchContent: React.FC = () => { )), })); - // Clamp focusedIndex when results shrink + // Clamp focusedIndex when results shrink and truncate stale row refs useEffect(() => { + rowRefs.current.length = results.length; if (results.length === 0) { setFocusedIndex(null); } else { @@ -355,6 +356,7 @@ const SearchContent: React.FC = () => { }} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { + event.stopPropagation(); event.preventDefault(); selectItem(item.selectionItem); setFocusedIndex(index); From 2209dd1fd95f0ed4601c5c372c665b006dbd4d67 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 16 Mar 2026 12:39:36 +0100 Subject: [PATCH 34/46] Add entities tree view setting and empty SubView placeholder Add useEntitiesTreeView user setting (persisted to localStorage) with a toggle in the ViewportSettingsDialog. When enabled, the left sidebar switches from the per-category list SubViews to a single "Entities" main SubView. The tree content will be implemented in a follow-up. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../petrinaut/src/constants/ui-subviews.ts | 3 +++ .../src/state/user-settings-context.ts | 4 +++ .../src/state/user-settings-provider.tsx | 2 ++ .../views/Editor/panels/LeftSideBar/panel.tsx | 13 +++++++--- .../LeftSideBar/subviews/entities-tree.tsx | 26 +++++++++++++++++++ .../components/viewport-settings-dialog.tsx | 11 ++++++++ 6 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx diff --git a/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts b/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts index 3f810b24315..75a801e9eb1 100644 --- a/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts +++ b/libs/@hashintel/petrinaut/src/constants/ui-subviews.ts @@ -10,6 +10,7 @@ import { diagnosticsSubView } from "../views/Editor/panels/BottomPanel/subviews/ import { simulationSettingsSubView } from "../views/Editor/panels/BottomPanel/subviews/simulation-settings"; import { simulationTimelineSubView } from "../views/Editor/panels/BottomPanel/subviews/simulation-timeline"; import { differentialEquationsListSubView } from "../views/Editor/panels/LeftSideBar/subviews/differential-equations-list"; +import { entitiesTreeSubView } from "../views/Editor/panels/LeftSideBar/subviews/entities-tree"; import { nodesListSubView } from "../views/Editor/panels/LeftSideBar/subviews/nodes-list"; import { parametersListSubView } from "../views/Editor/panels/LeftSideBar/subviews/parameters-list"; import { typesListSubView } from "../views/Editor/panels/LeftSideBar/subviews/types-list"; @@ -21,6 +22,8 @@ export const LEFT_SIDEBAR_SUBVIEWS: SubView[] = [ parametersListSubView, ]; +export const LEFT_SIDEBAR_TREE_SUBVIEWS: SubView[] = [entitiesTreeSubView]; + // Base subviews always visible in the bottom panel export const BOTTOM_PANEL_SUBVIEWS: SubView[] = [ diagnosticsSubView, diff --git a/libs/@hashintel/petrinaut/src/state/user-settings-context.ts b/libs/@hashintel/petrinaut/src/state/user-settings-context.ts index 65f6c32f9a9..64a6db2e83c 100644 --- a/libs/@hashintel/petrinaut/src/state/user-settings-context.ts +++ b/libs/@hashintel/petrinaut/src/state/user-settings-context.ts @@ -39,6 +39,7 @@ export type UserSettings = { activeBottomPanelTab: BottomPanelTab; timelineChartType: TimelineChartType; partialSelection: boolean; + useEntitiesTreeView: boolean; subViewPanels: SubViewPanelsSettings; }; @@ -56,6 +57,7 @@ export type UserSettingsActions = { setCursorMode: (value: CursorMode) => void; setTimelineChartType: (value: TimelineChartType) => void; setPartialSelection: (value: boolean) => void; + setUseEntitiesTreeView: (value: boolean) => void; updateSubViewSection: ( containerName: string, sectionId: string, @@ -79,6 +81,7 @@ export const defaultUserSettings: UserSettings = { activeBottomPanelTab: "diagnostics", timelineChartType: "run", partialSelection: true, + useEntitiesTreeView: false, subViewPanels: {}, }; @@ -97,6 +100,7 @@ const DEFAULT_CONTEXT_VALUE: UserSettingsContextValue = { setCursorMode: () => {}, setTimelineChartType: () => {}, setPartialSelection: () => {}, + setUseEntitiesTreeView: () => {}, updateSubViewSection: () => {}, }; diff --git a/libs/@hashintel/petrinaut/src/state/user-settings-provider.tsx b/libs/@hashintel/petrinaut/src/state/user-settings-provider.tsx index a28aea5fb36..20eadd18f9a 100644 --- a/libs/@hashintel/petrinaut/src/state/user-settings-provider.tsx +++ b/libs/@hashintel/petrinaut/src/state/user-settings-provider.tsx @@ -71,6 +71,8 @@ export const UserSettingsProvider: React.FC = ({ setState((prev) => ({ ...prev, timelineChartType: value })), setPartialSelection: (value: boolean) => setState((prev) => ({ ...prev, partialSelection: value })), + setUseEntitiesTreeView: (value: boolean) => + setState((prev) => ({ ...prev, useEntitiesTreeView: value })), updateSubViewSection: ( containerName: string, sectionId: string, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/panel.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/panel.tsx index 27d45e2f9e5..3648cea7602 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/panel.tsx @@ -7,7 +7,10 @@ import { MAX_LEFT_SIDEBAR_WIDTH, MIN_LEFT_SIDEBAR_WIDTH, } from "../../../../constants/ui"; -import { LEFT_SIDEBAR_SUBVIEWS } from "../../../../constants/ui-subviews"; +import { + LEFT_SIDEBAR_SUBVIEWS, + LEFT_SIDEBAR_TREE_SUBVIEWS, +} from "../../../../constants/ui-subviews"; import { EditorContext } from "../../../../state/editor-context"; import { UserSettingsContext } from "../../../../state/user-settings-context"; import { searchSubView } from "./subviews/search-panel"; @@ -101,7 +104,11 @@ export const LeftSideBar: React.FC = () => { isSearchOpen, } = use(EditorContext); - const { keepPanelsMounted } = use(UserSettingsContext); + const { keepPanelsMounted, useEntitiesTreeView } = use(UserSettingsContext); + + const sidebarSubViews = useEntitiesTreeView + ? LEFT_SIDEBAR_TREE_SUBVIEWS + : LEFT_SIDEBAR_SUBVIEWS; const searchSubViews = useMemo(() => [searchSubView], []); @@ -133,7 +140,7 @@ export const LeftSideBar: React.FC = () => { >
{ + return
Entities tree coming soon
; +}; + +export const entitiesTreeSubView: SubView = { + id: "entities-tree", + title: "Entities", + icon: LuTreePine, + main: true, + component: EntitiesTreeContent, +}; diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/components/viewport-settings-dialog.tsx b/libs/@hashintel/petrinaut/src/views/SDCPN/components/viewport-settings-dialog.tsx index 5ca2fcb5ac2..b4cb64fcee3 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/components/viewport-settings-dialog.tsx +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/components/viewport-settings-dialog.tsx @@ -78,6 +78,8 @@ export const ViewportSettingsDialog: React.FC = ({ setArcRendering, partialSelection, setPartialSelection, + useEntitiesTreeView, + setUseEntitiesTreeView, } = use(UserSettingsContext); return ( @@ -119,6 +121,15 @@ export const ViewportSettingsDialog: React.FC = ({ onCheckedChange={setPartialSelection} /> + + + { if (event.key === "ArrowDown") { From d1dceedf8330c5ee7a77115a998c9c390814d717 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 16 Mar 2026 17:37:32 +0100 Subject: [PATCH 40/46] All SubViews in LeftSidePanel expanded by default --- .../panels/LeftSideBar/subviews/differential-equations-list.tsx | 2 +- .../Editor/panels/LeftSideBar/subviews/parameters-list.tsx | 2 +- .../src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index 8f68300697d..756d3449c03 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -78,7 +78,7 @@ export const differentialEquationsListSubView: SubView = id: "differential-equations-list", title: "Differential Equations", tooltip: `Differential equations govern how token data changes over time when tokens remain in a place ("dynamics").`, - defaultCollapsed: true, + defaultCollapsed: false, resizable: { defaultHeight: 100, minHeight: 60, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index b6ed1046451..c7331b56093 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -93,7 +93,7 @@ export const parametersListSubView: SubView = createFilterableListSubView({ title: "Global Parameters", tooltip: "Parameters are injected into dynamics, lambda, and kernel functions.", - defaultCollapsed: true, + defaultCollapsed: false, resizable: { defaultHeight: 100, minHeight: 60, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index c4566d67a53..36da4d543ba 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -131,7 +131,7 @@ export const typesListSubView: SubView = createFilterableListSubView({ id: "token-types-list", title: "Token Types", tooltip: "Manage data types which can be assigned to tokens in a place.", - defaultCollapsed: true, + defaultCollapsed: false, resizable: { defaultHeight: 120, minHeight: 60, From 9dc679a39a7b10861707a0a6c17a375c0d422bcd Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 16 Mar 2026 18:02:16 +0100 Subject: [PATCH 41/46] Add group action buttons and renderGroupAction to tree items Add renderGroupAction to FilterableListItem so group rows can show an action button (hover-to-reveal). Export header action components from types-list, differential-equations-list, and parameters-list, then import them in the entities tree as group actions. Nodes has no action. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../subviews/differential-equations-list.tsx | 2 +- .../panels/LeftSideBar/subviews/entities-tree.tsx | 7 +++++++ .../subviews/filterable-list-sub-view.tsx | 12 ++++++++++++ .../panels/LeftSideBar/subviews/parameters-list.tsx | 2 +- .../panels/LeftSideBar/subviews/types-list.tsx | 2 +- 5 files changed, 22 insertions(+), 3 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index 756d3449c03..ca28f9ba27c 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -18,7 +18,7 @@ import { /** * DifferentialEquationsSectionHeaderAction renders the add button for the section header. */ -const DifferentialEquationsSectionHeaderAction: React.FC = () => { +export const DifferentialEquationsSectionHeaderAction: React.FC = () => { const { petriNetDefinition: { types, differentialEquations }, addDifferentialEquation, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx index 5e93ae80d57..8a8d4cfe103 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx @@ -15,10 +15,13 @@ import { EditorContext } from "../../../../../state/editor-context"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; import type { SelectionItem } from "../../../../../state/selection"; import { useIsReadOnly } from "../../../../../state/use-is-read-only"; +import { DifferentialEquationsSectionHeaderAction } from "./differential-equations-list"; import { RowMenu, createFilterableListSubView, } from "./filterable-list-sub-view"; +import { ParametersHeaderAction } from "./parameters-list"; +import { TypesSectionHeaderAction } from "./types-list"; const parameterVarNameStyle = css({ margin: "0", @@ -33,6 +36,7 @@ interface EntityTreeItem { iconColor?: string; children?: EntityTreeItem[]; emptyGroupMessage?: string; + renderGroupAction?: ComponentType; selectionItem?: SelectionItem; variableName?: string; } @@ -121,6 +125,7 @@ function useEntityTreeItems(): EntityTreeItem[] { id: "group-types", name: "Token Types", emptyGroupMessage: "No token types", + renderGroupAction: TypesSectionHeaderAction, children: types.map((t) => ({ id: t.id, name: t.name, @@ -133,6 +138,7 @@ function useEntityTreeItems(): EntityTreeItem[] { id: "group-equations", name: "Differential Equations", emptyGroupMessage: "No differential equations", + renderGroupAction: DifferentialEquationsSectionHeaderAction, children: differentialEquations.map((eq) => ({ id: eq.id, name: eq.name, @@ -147,6 +153,7 @@ function useEntityTreeItems(): EntityTreeItem[] { id: "group-parameters", name: "Parameters", emptyGroupMessage: "No parameters", + renderGroupAction: ParametersHeaderAction, children: parameters.map((p) => ({ id: p.id, name: p.name, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index 9d645ae809f..bd36e7f7d84 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -168,6 +168,8 @@ interface FilterableListItem { children?: FilterableListItem[]; /** Message shown when this group is expanded but has no children. */ emptyGroupMessage?: string; + /** Optional action component shown on the right side of a group row (e.g. an add button). */ + renderGroupAction?: ComponentType; } interface FilterableListSubViewConfig { @@ -567,6 +569,16 @@ const FilterableListContent = ({ {renderItem(item, isSelected)}
+ {isGroup && item.renderGroupAction && ( + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + + )} {!isGroup && RenderRowMenu && }
); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index c7331b56093..a7671216f55 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -25,7 +25,7 @@ const parameterVarNameStyle = css({ * Header action component for adding parameters. * Shown in the panel header when not in simulation mode. */ -const ParametersHeaderAction: React.FC = () => { +export const ParametersHeaderAction: React.FC = () => { const { petriNetDefinition: { parameters }, addParameter, diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index 36da4d543ba..36075d62ffd 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -60,7 +60,7 @@ function getNextTypeNumber(existingNames: string[]): number { /** * TypesSectionHeaderAction renders the add button for the types section header. */ -const TypesSectionHeaderAction: React.FC = () => { +export const TypesSectionHeaderAction: React.FC = () => { const { petriNetDefinition: { types }, addType, From f70eb496fdda66470d9ad8540e761f6c2c658d64 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 16 Mar 2026 19:24:48 +0100 Subject: [PATCH 42/46] Clean and update resizable dimensions --- libs/@hashintel/petrinaut/src/components/sub-view/types.ts | 5 ----- .../sub-view/vertical/vertical-sub-views-container.tsx | 7 ++++--- .../LeftSideBar/subviews/differential-equations-list.tsx | 6 +++--- .../Editor/panels/LeftSideBar/subviews/nodes-list.tsx | 6 +++--- .../Editor/panels/LeftSideBar/subviews/parameters-list.tsx | 6 +++--- .../Editor/panels/LeftSideBar/subviews/types-list.tsx | 6 +++--- 6 files changed, 16 insertions(+), 20 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/types.ts b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts index 8bc36ab7714..7357a06b893 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/types.ts +++ b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts @@ -40,11 +40,6 @@ export interface SubView { * Only affects vertical layout. Defaults to false. */ flexGrow?: boolean; - /** - * Minimum height for this section in the proportional layout (in pixels). - * Defaults to 100px. - */ - minHeight?: number; /** * Whether this is the main (primary) subview. * When true, shows a non-collapsible header with a larger title style. diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx index 0239d85f117..9a463b02642 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -1,5 +1,5 @@ import { css, cva, cx } from "@hashintel/ds-helpers/css"; -import { Fragment, use, useEffect, useRef, useState } from "react"; +import React, { Fragment, use, useEffect, useRef, useState } from "react"; import { FaChevronRight } from "react-icons/fa6"; import { Group, Panel, Separator } from "react-resizable-panels"; @@ -12,7 +12,7 @@ const HEADER_HEIGHT = 44; /** Size of the icon in the main header */ const HEADER_ICON_SIZE = 16; /** Default minimum panel height when no per-subview minHeight is set */ -const DEFAULT_MIN_PANEL_HEIGHT = 100; +const DEFAULT_MIN_PANEL_HEIGHT = 400; const containerStyle = css({ flex: "[1]", @@ -496,7 +496,8 @@ export const VerticalSubViewsContainer: React.FC< const isCollapsible = !isMain && (subView.collapsible ?? true); const isExpanded = !isCollapsible || !isSectionCollapsed(subView); const Component = subView.component; - const minSize = subView.minHeight ?? DEFAULT_MIN_PANEL_HEIGHT; + const minSize = + subView.resizable?.minHeight ?? DEFAULT_MIN_PANEL_HEIGHT; return ( diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index ca28f9ba27c..e679c81d3ee 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -80,9 +80,9 @@ export const differentialEquationsListSubView: SubView = tooltip: `Differential equations govern how token data changes over time when tokens remain in a place ("dynamics").`, defaultCollapsed: false, resizable: { - defaultHeight: 100, - minHeight: 60, - maxHeight: 250, + defaultHeight: 300, + minHeight: 200, + maxHeight: 600, }, useItems: () => { const { diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx index b45d12186cb..dc3f7de90ea 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/nodes-list.tsx @@ -23,9 +23,9 @@ export const nodesListSubView: SubView = createFilterableListSubView({ tooltip: "Manage nodes in the net, including places and transitions. Places represent states in the net, and transitions represent events which change the state of the net.", resizable: { - defaultHeight: 150, - minHeight: 80, - maxHeight: 400, + defaultHeight: 300, + minHeight: 200, + maxHeight: 600, }, useItems: () => { const { diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index a7671216f55..b742b6b4588 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -95,9 +95,9 @@ export const parametersListSubView: SubView = createFilterableListSubView({ "Parameters are injected into dynamics, lambda, and kernel functions.", defaultCollapsed: false, resizable: { - defaultHeight: 100, - minHeight: 60, - maxHeight: 250, + defaultHeight: 300, + minHeight: 200, + maxHeight: 600, }, useItems: () => { const { diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index 36075d62ffd..6629069b067 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -133,9 +133,9 @@ export const typesListSubView: SubView = createFilterableListSubView({ tooltip: "Manage data types which can be assigned to tokens in a place.", defaultCollapsed: false, resizable: { - defaultHeight: 120, - minHeight: 60, - maxHeight: 300, + defaultHeight: 300, + minHeight: 200, + maxHeight: 600, }, useItems: () => { const { From ac4670ba466c6903eb96069da6d3026c51d3d7f2 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 16 Mar 2026 19:54:40 +0100 Subject: [PATCH 43/46] Animate subtree collapse/expand with CSS interpolate-size Use the `interpolate-size: allow-keywords` CSS property to smoothly animate group children height between 0 and auto. Children are now always present in the DOM (marked hidden for keyboard nav) so the CSS transition has stable elements to animate. Extracted a shared `itemRow` helper to deduplicate group header and child row rendering. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../subviews/filterable-list-sub-view.tsx | 231 +++++++++++------- 1 file changed, 142 insertions(+), 89 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index bd36e7f7d84..f83255a9029 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -1,6 +1,6 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import type { ComponentType, ReactNode } from "react"; -import { use, useEffect, useRef, useState } from "react"; +import { Fragment, use, useEffect, useRef, useState } from "react"; import { LuChevronRight, LuSearch } from "react-icons/lu"; import { TbDots } from "react-icons/tb"; @@ -26,6 +26,14 @@ const listContainerStyle = css({ mx: "-1", /** Suppress browser default focus ring — focus is shown per-row via isFocused variant */ outline: "none", + /** Enable animating height to/from `auto` for collapsible group children */ + interpolateSize: "[allow-keywords]", +}); + +/** Wrapper around a group's children that animates height on collapse/expand */ +const groupChildrenStyle = css({ + overflow: "hidden", + transition: "[height 150ms ease-out]", }); const listItemRowStyle = cva({ @@ -266,27 +274,38 @@ const FilterableListContent = ({ const containerRef = useRef(null); const rowRefs = useRef<(HTMLDivElement | null)[]>([]); - // Flatten tree: items with children become group header + child rows + // Flatten tree: items with children become group header + child rows. + // Children are always included (even when collapsed) so the DOM stays + // stable for height animation. The `hidden` flag marks collapsed children + // so keyboard navigation can skip them. const flatRows: { item: T; depth: number; isGroup: boolean; + hidden: boolean; emptyGroupMessage?: string; }[] = []; for (const item of items) { const children = item.children as T[] | undefined; const isGroup = children !== undefined; - flatRows.push({ item, depth: 0, isGroup }); - if (isGroup && !collapsedGroups.has(item.id)) { + flatRows.push({ item, depth: 0, isGroup, hidden: false }); + if (isGroup) { + const isCollapsed = collapsedGroups.has(item.id); if (children!.length > 0) { for (const child of children!) { - flatRows.push({ item: child, depth: 1, isGroup: false }); + flatRows.push({ + item: child, + depth: 1, + isGroup: false, + hidden: isCollapsed, + }); } } else if (item.emptyGroupMessage) { flatRows.push({ item, depth: 1, isGroup: false, + hidden: isCollapsed, emptyGroupMessage: item.emptyGroupMessage, }); } @@ -334,7 +353,7 @@ const FilterableListContent = ({ const newSelection: SelectionMap = new Map(); for (let i = start; i <= end; i++) { const row = flatRows[i]; - if (row && !row.isGroup && !row.emptyGroupMessage) { + if (row && !row.isGroup && !row.hidden && !row.emptyGroupMessage) { const selItem = getSelectionItem(row.item); newSelection.set(selItem.id, selItem); } @@ -358,16 +377,17 @@ const FilterableListContent = ({ focusedIndex === null ? 0 : Math.min(focusedIndex + 1, flatRows.length - 1); - // Skip empty placeholder rows + // Skip hidden and empty placeholder rows while ( nextIndex < flatRows.length - 1 && - flatRows[nextIndex]?.emptyGroupMessage + (flatRows[nextIndex]?.hidden || + flatRows[nextIndex]?.emptyGroupMessage) ) { nextIndex++; } setFocusedIndex(nextIndex); const row = flatRows[nextIndex]; - if (row && !row.isGroup && !row.emptyGroupMessage) { + if (row && !row.isGroup && !row.hidden && !row.emptyGroupMessage) { if (event.shiftKey) { selectRange(anchorIndex ?? nextIndex, nextIndex); } else { @@ -383,13 +403,17 @@ const FilterableListContent = ({ focusedIndex === null ? flatRows.length - 1 : Math.max(focusedIndex - 1, 0); - // Skip empty placeholder rows - while (nextIndex > 0 && flatRows[nextIndex]?.emptyGroupMessage) { + // Skip hidden and empty placeholder rows + while ( + nextIndex > 0 && + (flatRows[nextIndex]?.hidden || + flatRows[nextIndex]?.emptyGroupMessage) + ) { nextIndex--; } setFocusedIndex(nextIndex); const row = flatRows[nextIndex]; - if (row && !row.isGroup && !row.emptyGroupMessage) { + if (row && !row.isGroup && !row.hidden && !row.emptyGroupMessage) { if (event.shiftKey) { selectRange(anchorIndex ?? nextIndex, nextIndex); } else { @@ -404,7 +428,7 @@ const FilterableListContent = ({ event.preventDefault(); if (focusedIndex !== null) { const row = flatRows[focusedIndex]; - if (row && !row.emptyGroupMessage) { + if (row && !row.hidden && !row.emptyGroupMessage) { if (row.isGroup) { toggleGroup(row.item.id); } else { @@ -491,96 +515,125 @@ const FilterableListContent = ({ } }} > - {flatRows.map(({ item, depth, isGroup, emptyGroupMessage }, index) => { - if (emptyGroupMessage) { + {items.map((topItem) => { + const children = topItem.children as T[] | undefined; + const isGroup = children !== undefined; + const isCollapsed = isGroup && collapsedGroups.has(topItem.id); + + const itemRow = (item: T, depth: number) => { + const index = flatRows.findIndex( + (r) => r.item === item && r.depth === depth, + ); + const isItemGroup = item === topItem && isGroup; + const selected = !isItemGroup && checkIsSelected(item.id); + const focused = focusedIndex === index; + return (
{ + rowRefs.current[index] = el; + }} + onClick={(event) => + handleRowClick(event, index, { + item, + isGroup: isItemGroup, + }) + } + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (isItemGroup) { + toggleGroup(item.id); + } else { + selectItem(getSelectionItem(item)); + setFocusedIndex(index); + setAnchorIndex(index); + } + } + }} + role="option" + aria-selected={selected} className={listItemRowStyle({ - selectable: false, - isSelected: false, - isFocused: false, + selectable: true, + isSelected: selected, + isFocused: focused, })} - style={{ paddingLeft: depth * NESTING_INDENT + 4 }} + style={ + depth > 0 + ? { paddingLeft: depth * NESTING_INDENT + 4 } + : undefined + } >
-
- {emptyGroupMessage} + {isItemGroup && ( + + + + )} + {item.icon && ( + + + + )} +
+ {renderItem(item, selected)}
+ {isItemGroup && item.renderGroupAction && ( + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + + )} + {!isItemGroup && RenderRowMenu && }
); - } + }; - const isSelected = !isGroup && checkIsSelected(item.id); - const isFocused = focusedIndex === index; - const isCollapsed = isGroup && collapsedGroups.has(item.id); + if (!isGroup) { + return itemRow(topItem, 0); + } return ( -
{ - rowRefs.current[index] = el; - }} - onClick={(event) => handleRowClick(event, index, { item, isGroup })} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - if (isGroup) { - toggleGroup(item.id); - } else { - selectItem(getSelectionItem(item)); - setFocusedIndex(index); - setAnchorIndex(index); - } - } - }} - role="option" - aria-selected={isSelected} - className={listItemRowStyle({ - selectable: true, - isSelected, - isFocused, - })} - style={ - depth > 0 - ? { paddingLeft: depth * NESTING_INDENT + 4 } - : undefined - } - > -
- {isGroup && ( - - - - )} - {item.icon && ( - - - - )} -
- {renderItem(item, isSelected)} -
+ + {itemRow(topItem, 0)} +
+ {children!.length > 0 + ? children!.map((child) => itemRow(child, 1)) + : topItem.emptyGroupMessage && ( +
+
+
+ {topItem.emptyGroupMessage} +
+
+
+ )}
- {isGroup && item.renderGroupAction && ( - event.stopPropagation()} - onKeyDown={(event) => event.stopPropagation()} - > - - - )} - {!isGroup && RenderRowMenu && } -
+ ); })} {items.length === 0 && ( From e1ee5df935eee211803cacf1781151ffefb412ed Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 16 Mar 2026 20:15:51 +0100 Subject: [PATCH 44/46] Tweak minHeights --- .../sub-view/vertical/vertical-sub-views-container.tsx | 2 +- .../subviews/place-initial-state/subview.tsx | 5 ++--- .../place-properties/subviews/place-visualizer/subview.tsx | 6 +++++- .../PropertiesPanel/transition-properties/subviews/main.tsx | 5 +++++ .../subviews/transition-firing-time/subview.tsx | 6 +++++- .../subviews/transition-results/subview.tsx | 6 +++++- 6 files changed, 23 insertions(+), 7 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx index 9a463b02642..e77ae197c15 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx +++ b/libs/@hashintel/petrinaut/src/components/sub-view/vertical/vertical-sub-views-container.tsx @@ -12,7 +12,7 @@ const HEADER_HEIGHT = 44; /** Size of the icon in the main header */ const HEADER_ICON_SIZE = 16; /** Default minimum panel height when no per-subview minHeight is set */ -const DEFAULT_MIN_PANEL_HEIGHT = 400; +const DEFAULT_MIN_PANEL_HEIGHT = 100; const containerStyle = css({ flex: "[1]", diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx index 9463112776d..6d313fbb45b 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx @@ -142,10 +142,9 @@ export const placeInitialStateSubView: SubView = { component: PlaceInitialStateContent, renderHeaderAction: () => , defaultCollapsed: true, - minHeight: 250, resizable: { - defaultHeight: 300, minHeight: 250, - maxHeight: 700, + maxHeight: 1200, + defaultHeight: 300, }, }; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx index c783a209d9e..a39409806db 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx @@ -321,5 +321,9 @@ export const placeVisualizerSubView: SubView = { renderHeaderAction: () => , alwaysShowHeaderAction: true, defaultCollapsed: true, - minHeight: 200, + resizable: { + minHeight: 200, + maxHeight: 1200, + defaultHeight: 300, + }, }; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx index 7e35000a417..1128b2efe03 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx @@ -237,4 +237,9 @@ export const transitionMainContentSubView: SubView = { component: TransitionMainContent, renderHeaderAction: () => , alwaysShowHeaderAction: true, + resizable: { + minHeight: 100, + maxHeight: 1200, + defaultHeight: 300, + }, }; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/transition-firing-time/subview.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/transition-firing-time/subview.tsx index 571a3102a05..a694f285629 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/transition-firing-time/subview.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/transition-firing-time/subview.tsx @@ -148,9 +148,13 @@ export const transitionFiringTimeSubView: SubView = { id: "transition-firing-time", title: "Firing Time", defaultCollapsed: true, - minHeight: 340, tooltip: "Define the rate at or conditions under which this transition will fire, optionally based on each set of input tokens' data (where input tokens have types).", component: TransitionFiringTimeContent, renderHeaderAction: () => , + resizable: { + minHeight: 250, + maxHeight: 1200, + defaultHeight: 300, + }, }; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/transition-results/subview.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/transition-results/subview.tsx index 98289d5d71f..ffe19fb3796 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/transition-results/subview.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/transition-results/subview.tsx @@ -178,6 +178,10 @@ export const transitionResultsSubView: SubView = { tooltip: "This function determines the data for output tokens, optionally based on the input token data and any global parameters defined.", component: TransitionResultsContent, - minHeight: 300, renderHeaderAction: () => , + resizable: { + minHeight: 300, + maxHeight: 1200, + defaultHeight: 500, + }, }; From 90c92c9b71dc67199e9b39c66a3ed19d56f5badc Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 17 Mar 2026 01:10:47 +0100 Subject: [PATCH 45/46] Tweak Menu highlight color --- libs/@hashintel/petrinaut/src/components/menu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/menu.tsx b/libs/@hashintel/petrinaut/src/components/menu.tsx index 6971808b7b9..7fb853b957b 100644 --- a/libs/@hashintel/petrinaut/src/components/menu.tsx +++ b/libs/@hashintel/petrinaut/src/components/menu.tsx @@ -95,7 +95,7 @@ const itemStyle = cva({ color: "neutral.s120", cursor: "pointer", _hover: { - backgroundColor: "neutral.s10", + backgroundColor: "neutral.s25", }, _highlighted: { backgroundColor: "neutral.bg.subtle.hover", @@ -178,7 +178,7 @@ const triggerItemStyle = css({ cursor: "pointer", justifyContent: "space-between", _hover: { - backgroundColor: "neutral.s10", + backgroundColor: "neutral.s25", }, _highlighted: { backgroundColor: "neutral.bg.subtle.hover", From ca42c7e6c6ffe82ac1f775e6f8548617df6a1344 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 17 Mar 2026 12:27:57 +0100 Subject: [PATCH 46/46] Remove unused `fitContent` property from SubView type The property was defined and threaded through the factory but never consumed by VerticalSubViewsContainer, silently misleading consumers. Co-Authored-By: Claude Opus 4.6 (1M context) --- libs/@hashintel/petrinaut/src/components/sub-view/types.ts | 6 ------ .../LeftSideBar/subviews/filterable-list-sub-view.tsx | 4 ---- 2 files changed, 10 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/types.ts b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts index 7357a06b893..a7bb8df8557 100644 --- a/libs/@hashintel/petrinaut/src/components/sub-view/types.ts +++ b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts @@ -72,10 +72,4 @@ export interface SubView { * Only affects vertical layout. When set, the section can be resized by dragging its bottom edge. */ resizable?: SubViewResizeConfig; - /** - * When true, the panel's maximum height is determined by its content height. - * The panel will not grow beyond what the content needs, but can still be - * shrunk by the user or other panels. Only affects vertical layout. - */ - fitContent?: boolean; } diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx index f83255a9029..0d8e18b68c0 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -186,8 +186,6 @@ interface FilterableListSubViewConfig { tooltip?: string; defaultCollapsed?: boolean; resizable?: SubViewResizeConfig; - /** When true, the panel's maximum height is determined by its content. */ - fitContent?: boolean; useItems: () => T[]; getSelectionItem: (item: T) => SelectionItem; renderItem: (item: T, isSelected: boolean) => ReactNode; @@ -659,7 +657,6 @@ export function createFilterableListSubView( tooltip, defaultCollapsed, resizable, - fitContent, useItems, getSelectionItem, renderItem, @@ -691,6 +688,5 @@ export function createFilterableListSubView( ), defaultCollapsed, resizable, - fitContent, }; }