From af12a978f0b6351405c1b01214fbdb917d3cd082 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 10 Mar 2026 18:46:51 +0100 Subject: [PATCH 01/32] 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/32] 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 b0132935ebf8ea7783e07107c697e4a3aac352c6 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 10 Mar 2026 19:15:47 +0100 Subject: [PATCH 03/32] 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 d4f2dd9c58d39a9fbbafd5db8e1578423992d8a9 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 10 Mar 2026 19:23:02 +0100 Subject: [PATCH 04/32] 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 ccf6ed53511c3a96346b92559a7be7569c24e929 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 10 Mar 2026 19:23:36 +0100 Subject: [PATCH 05/32] 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 a1e5c5690a95c996653b966eca91ee2c293a48b8 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 10 Mar 2026 19:43:52 +0100 Subject: [PATCH 06/32] 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 e047e1f79e074ab8bf16176a90808d0cb1fc1f61 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 10 Mar 2026 19:48:30 +0100 Subject: [PATCH 07/32] 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 8b8034d9d0663fb2d79a39e88d9798d1a813e2f8 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Tue, 10 Mar 2026 19:56:04 +0100 Subject: [PATCH 08/32] 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 2b5bc14bc74686b3dfbaa7cb394ce0a847bc7a7b Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 01:55:33 +0100 Subject: [PATCH 09/32] 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 4a754a900671611c43d9a7a323517611fbefbfc7 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 02:16:01 +0100 Subject: [PATCH 10/32] 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 e16ac44e1af45f4ca073e551fc7ff45dc7e5b8c5 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 18:10:54 +0100 Subject: [PATCH 11/32] 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 38aeb4803832b1781a23e53552d2f3e5c7843846 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 19:02:17 +0100 Subject: [PATCH 12/32] 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 2569a0e5fe43330cc461bc9145d0261a6a8e2f9d Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 19:16:35 +0100 Subject: [PATCH 13/32] 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 9a63fac505e21e54fb8bcd0ce10bb0c9f5b9642b Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 19:22:08 +0100 Subject: [PATCH 14/32] 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 2f7150a47b9ca23a103d6b92e63afc9f4d8a9bd1 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 19:48:59 +0100 Subject: [PATCH 15/32] 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 792a67603cffb0fb100cd91a07afd723a262c588 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 19:57:41 +0100 Subject: [PATCH 16/32] 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 b3b5c593bd8e5e68f70004950ca61f7fd954ace5 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 11 Mar 2026 21:10:04 +0100 Subject: [PATCH 17/32] 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 e05bcfca6b66b59516ec873b19df86b6f7415e5c Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 00:55:20 +0100 Subject: [PATCH 18/32] 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 47237ba7471f8dd2f0316bdb52f58af466829d5c Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 01:01:42 +0100 Subject: [PATCH 19/32] 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 5a00bd3926af91c7459f6296310855438d5432af Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 01:20:39 +0100 Subject: [PATCH 20/32] 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 39bfc52bd150c8a1841f5366ebb0eb7cf5d85290 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 01:42:09 +0100 Subject: [PATCH 21/32] 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 4b029f713f635abd6011bbe28c7c942faa7ef97a Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 01:53:40 +0100 Subject: [PATCH 22/32] 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 173c55558af886e73456b59cfa683f606c220f9a Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 16:27:43 +0100 Subject: [PATCH 23/32] 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 7dd31d8853af815e01e162eddf9c177fe39b9797 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 17:11:15 +0100 Subject: [PATCH 24/32] 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 7fc32e3440a..289a8528203 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7964,6 +7964,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" @@ -29469,6 +29470,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 5c8d6bcf7b82c96e06aab66b4dd3287d2a20b122 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 12 Mar 2026 17:37:24 +0100 Subject: [PATCH 25/32] 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 34011bb3ce48ad90b0ce199c15938a4c8b36829d Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 13 Mar 2026 11:04:37 +0100 Subject: [PATCH 26/32] 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 4d37d1f55597a1a82a0e64800800a6af4089914a Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 13 Mar 2026 20:59:42 +0100 Subject: [PATCH 27/32] 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 e3e6b009ba911f75456fcff265dacdf963b8e269 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 02:21:48 +0100 Subject: [PATCH 28/32] 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 b7e9c1135a2470436cc76f0b151b07c2e9d942ae Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 19:30:05 +0100 Subject: [PATCH 29/32] 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 13ffdfb330a7aff67b7ceac490aa6b4841708e6e Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 19:54:58 +0100 Subject: [PATCH 30/32] 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 c71d91f0562adcbbdc68ed33efffb8d8caad8f31 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 14 Mar 2026 20:00:27 +0100 Subject: [PATCH 31/32] 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 0fe4dc934c0..90df94f81d9 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -38,6 +38,7 @@ const canvasContainerStyle = css({ const editorRootStyle = css({ position: "relative", height: "full", + overflow: "hidden", backgroundColor: "neutral.s25", }); From c4cda77f3b936517c3a8aec4cb112afe119c7eff Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sun, 15 Mar 2026 01:15:23 +0100 Subject: [PATCH 32/32] 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;