diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index b0fc72b48e9..d2c96138fa7 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -50,6 +50,7 @@ "d3-array": "3.2.4", "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/components/menu.tsx b/libs/@hashintel/petrinaut/src/components/menu.tsx index c7d6bfc3b85..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: { @@ -117,7 +123,7 @@ const itemStyle = cva({ }, destructive: { true: { - color: "red.s60", + color: "red.s100", }, }, }, @@ -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/types.ts b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts index d0c2be4ccab..d2b1e4fcc62 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 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; /** @@ -50,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. @@ -60,6 +67,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 e28b4cfe1d3..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 @@ -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; @@ -37,6 +39,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({ @@ -61,8 +74,8 @@ const panelContentStyle = css({ minHeight: "[0]", display: "flex", flexDirection: "column", - p: "3", - pt: "0", + p: "4", + pt: "2", }); const SHADOW_HEIGHT = 7; @@ -82,48 +95,87 @@ const scrollShadowStyle = cva({ position: { top: { top: "[0]", - background: "[linear-gradient(to bottom, #F0F0F0, transparent)]", + background: "[linear-gradient(to bottom, #D0D0D0, #FFFFFF10)]", }, bottom: { bottom: "[0]", - background: "[linear-gradient(to top, #F0F0F0, transparent)]", + background: "[linear-gradient(to top, #D0D0D0, #FFFFFF10)]", }, }, visible: { - true: { opacity: "[0.7]" }, + true: { opacity: "[0.2]" }, }, }, }); 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", }, }); -const headerRowStyle = css({ - height: "[44px]", - px: "2", +const headerRowStyle = cva({ + base: { + height: "11", + pl: "0.5", + pr: "2", + + display: "flex", + justifyContent: "space-between", + alignItems: "center", + + borderBottomWidth: "thin", + borderBottomColor: "neutral.a20", + }, + variants: { + isCollapsed: { + true: { + borderBottomColor: "[transparent]", + }, + }, + }, +}); + +const mainHeaderRowStyle = css({ + p: "3", + h: "11", display: "flex", justifyContent: "space-between", alignItems: "center", + + borderBottomWidth: "thin", + borderBottomColor: "neutral.a20", }); -const headerActionStyle = css({ +const headerActionVisibleStyle = css({ /** Constrain height so buttons don't grow the header */ maxHeight: "[44px]", display: "flex", alignItems: "center", - gap: "1", + flexShrink: 0, +}); + +const headerActionStyle = css({ + 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({ @@ -133,23 +185,70 @@ 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({ - w: "4", + flexShrink: 0, 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({ transform: "[rotate(90deg)]", }); +const infoTooltipWrapperStyle = css({ + opacity: "[0]", + transition: "[opacity 150ms ease-out]", +}); + +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: "semibold", - fontSize: "base", - px: "1", + fontWeight: "medium", + fontSize: "sm", + color: "neutral.s85", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", }); /** @@ -237,24 +336,45 @@ interface SubViewHeaderProps { id: string; title: string; tooltip?: string; + icon?: React.ComponentType<{ size: number }>; main?: boolean; + renderTitle?: () => React.ReactNode; isExpanded: boolean; onToggle: () => void; renderHeaderAction?: () => React.ReactNode; + alwaysShowHeaderAction?: boolean; } const SubViewHeader: React.FC = ({ id, title, tooltip, + icon: Icon, main = false, + renderTitle, isExpanded, onToggle, renderHeaderAction, + alwaysShowHeaderAction, }) => ( -
+
{main ? ( -
{title}
+
+ {Icon && ( + + + + )} + {renderTitle ? ( + renderTitle() + ) : ( + {title} + )} +
) : (
= ({ aria-controls={`subview-content-${id}`} >
= ({ >
- + {title} - {tooltip && } + {tooltip && ( + + + + )}
)} {isExpanded && renderHeaderAction && ( -
{renderHeaderAction()}
+
+ {renderHeaderAction()} +
)}
); @@ -379,10 +511,13 @@ export const VerticalSubViewsContainer: React.FC< id={subView.id} title={subView.title} tooltip={subView.tooltip} + icon={subView.icon} main={isMain} + renderTitle={subView.renderTitle} 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 24841281799..70b29ece054 100644 --- a/libs/@hashintel/petrinaut/src/components/tooltip.tsx +++ b/libs/@hashintel/petrinaut/src/components/tooltip.tsx @@ -3,13 +3,15 @@ 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 { LuInfo } from "react-icons/lu"; 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)]", @@ -111,31 +113,24 @@ 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, + outlined, +}: { + tooltip: string; + outlined?: boolean; +}) => { + const Icon = outlined ? LuInfo : FaInfoCircle; -export const InfoIconTooltip = ({ tooltip }: { tooltip: string }) => { return ( - + ); }; 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..543ef495f4e --- /dev/null +++ b/libs/@hashintel/petrinaut/src/constants/entity-icons.tsx @@ -0,0 +1,21 @@ +/** + * 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 { 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 = LuSettings2; +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/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 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..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 @@ -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,34 @@ export function useKeyboardShortcuts( return; } + // 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(); + searchInputRef.current?.select(); + } else { + setSearchOpen(true); + } + return; + } + + // 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; + } + if (isInputFocused) { return; } 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", 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", }); 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..27d45e2f9e5 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,52 @@ 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", + visibility: "hidden", + }, + }, + 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 +98,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 +124,30 @@ export const LeftSideBar: React.FC = () => { maxSize: MAX_LEFT_SIDEBAR_WIDTH, }} > - +
+
+ +
+
+ +
+
); }; 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..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 @@ -1,134 +1,19 @@ -import { css, cva } from "@hashintel/ds-helpers/css"; import { use } from "react"; -import { TbPlus, TbX } from "react-icons/tb"; +import { TbPlus, TbTrash } from "react-icons/tb"; 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"; 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)]", - }, - }, - }, - }, -}); - -const equationNameContainerStyle = css({ - display: "flex", - alignItems: "center", - gap: "[6px]", -}); - -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
- )} -
- ); -}; +import { + RowMenu, + createFilterableListSubView, +} from "./filterable-list-sub-view"; /** * DifferentialEquationsSectionHeaderAction renders the add button for the section header. @@ -165,19 +50,52 @@ 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. */ -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.map((eq) => ({ + ...eq, + icon: DifferentialEquationIcon, + })); + }, + getSelectionItem: (eq) => ({ type: "differentialEquation", id: eq.id }), + renderItem: (eq) => eq.name, + 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 new file mode 100644 index 00000000000..130c13fd3e7 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/filterable-list-sub-view.tsx @@ -0,0 +1,462 @@ +import { css, cva } from "@hashintel/ds-helpers/css"; +import type { ComponentType, ReactNode } from "react"; +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"; +import type { MenuItem } from "../../../../../components/menu"; +import { Menu } from "../../../../../components/menu"; +import type { + SubView, + SubViewResizeConfig, +} from "../../../../../components/sub-view/types"; +import { EditorContext } from "../../../../../state/editor-context"; +import type { + SelectionItem, + SelectionMap, +} from "../../../../../state/selection"; + +const listContainerStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[1px]", + 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({ + 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, opacity 150ms ease-out]", + + /* 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(2px)]", + 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: "neutral.bg.subtle", + _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", + }, + }, + }, + isFocused: { + true: { + backgroundColor: "neutral.bg.subtle.hover", + }, + }, + }, +}); + +const listItemContentStyle = css({ + display: "flex", + alignItems: "center", + gap: "1.5", + flex: "[1]", + minWidth: "[0]", +}); + +const listItemNameStyle = css({ + flex: "[1]", + fontSize: "sm", + fontWeight: "medium", + lineHeight: "snug", + color: "neutral.s115", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + +const LIST_ITEM_ICON_SIZE = 12; +const LIST_ITEM_ICON_COLOR = "#9ca3af"; + +const listItemIconStyle = css({ + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", +}); + +export const emptyMessageStyle = css({ + pt: "1", + px: "1", + fontSize: "sm", + color: "neutral.s65", +}); + +interface FilterableListItem { + id: string; + icon?: ComponentType<{ size: number }>; + iconColor?: 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; + /** 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; +} + +const FilterHeaderAction: React.FC<{ + renderExtraAction?: () => ReactNode; +}> = ({ renderExtraAction }) => { + const { setSearchOpen } = use(EditorContext); + + return ( + <> + setSearchOpen(true)} + > + + + {renderExtraAction?.()} + + ); +}; + +/** + * Shared row menu chrome. Consumers call hooks in their own `renderRowMenu` + * component and pass the resulting items here. + */ +export const RowMenu: React.FC<{ items: MenuItem[] }> = ({ items }) => { + if (items.length === 0) { + return null; + } + + return ( + event.stopPropagation()} + > + + + } + items={items} + placement="bottom-end" + /> + ); +}; + +const FilterableListContent = ({ + items, + getSelectionItem, + renderItem, + renderRowMenu: RenderRowMenu, + emptyMessage, +}: { + items: T[]; + getSelectionItem: (item: T) => SelectionItem; + renderItem: (item: T, isSelected: boolean) => ReactNode; + renderRowMenu?: ComponentType<{ item: T }>; + emptyMessage: string; +}) => { + const { + isSelected: checkIsSelected, + 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 = (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); + }; + + 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; + } + + 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; + } + } + }; + + const handleContainerClick = () => { + clearSelection(); + setFocusedIndex(null); + setAnchorIndex(null); + }; + + 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); + } + }; + + return ( +
{ + if (!event.currentTarget.contains(event.relatedTarget)) { + setFocusedIndex(null); + setAnchorIndex(null); + } + }} + > + {items.map((item, index) => { + const isSelected = checkIsSelected(item.id); + const selectionItem = getSelectionItem(item); + const isFocused = focusedIndex === index; + + return ( +
{ + 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 })} + > +
+ {item.icon && ( + + + + )} +
+ {renderItem(item, isSelected)} +
+
+ {RenderRowMenu && } +
+ ); + })} + {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, + renderRowMenu, + emptyMessage, + renderHeaderAction: renderExtraAction, + } = config; + + const Component: React.FC = () => { + const items = useItems(); + return ( + + ); + }; + + 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..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 @@ -1,189 +1,53 @@ -import { css, 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 { + PlaceFilledIcon, + TransitionFilledIcon, +} from "../../../../../constants/entity-icons"; import { SDCPNContext } from "../../../../../state/sdcpn-context"; -import type { SelectionItem } from "../../../../../state/selection"; +import { createFilterableListSubView } from "./filterable-list-sub-view"; -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)]", - }, - }, - }, - }, -}); - -const nodeIconStyle = cva({ - base: { - flexShrink: 0, - }, - variants: { - isSelected: { - true: { - color: "[#3b82f6]", - }, - false: { - color: "[#9ca3af]", - }, - }, - }, -}); - -const nodeNameStyle = cva({ - base: { - fontSize: "[13px]", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - }, - variants: { - isSelected: { - true: { - color: "[#1e40af]", - fontWeight: "medium", - }, - false: { - color: "[#374151]", - fontWeight: "normal", - }, - }, - }, -}); - -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, + icon: PlaceFilledIcon, + })), + ...transitions.map((transition) => ({ + id: transition.id, + name: transition.name || `Transition ${transition.id}`, + kind: "transition" as const, + icon: TransitionFilledIcon, + })), + ]; + }, + getSelectionItem: (node) => ({ type: node.kind, id: node.id }), + 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 3745aff7cce..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 @@ -1,73 +1,24 @@ -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 { 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 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 { + RowMenu, + createFilterableListSubView, +} 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", -}); - -const emptyMessageStyle = css({ - fontSize: "[13px]", - color: "neutral.s85", + margin: "0", + fontSize: "xs", + color: "neutral.s90", }); /** @@ -109,124 +60,64 @@ 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 ParameterRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { + const { removeParameter } = use(SDCPNContext); + const { globalMode } = use(EditorContext); 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 }; + if (globalMode === "simulate") { + return null; + } - 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
- )} -
-
+ return ( + , + destructive: true, + disabled: isReadOnly, + onClick: () => removeParameter(item.id), + }, + ]} + /> ); }; /** * 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.map((param) => ({ + ...param, + icon: ParameterIcon, + })); + }, + getSelectionItem: (param) => ({ type: "parameter", id: param.id }), + renderItem: (param) => { + return ( +
+
{param.name}
+
{param.variableName}
+
+ ); + }, + renderRowMenu: ParameterRowMenu, + emptyMessage: "No global parameters yet", + renderHeaderAction: () => , +}); 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..a0f23aed71f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/search-panel.tsx @@ -0,0 +1,441 @@ +import { css, cva } from "@hashintel/ds-helpers/css"; +import fuzzysort from "fuzzysort"; +import type { ComponentType, ReactNode } from "react"; +import { use, useEffect, useRef, 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", + minWidth: "0", + fontSize: "sm", + fontWeight: "medium", + color: "neutral.s120", + outline: "none", + _placeholder: { + color: "neutral.s80", + }, +}); + +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", + outline: "none", +}); + +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" }, + }, + }, + isFocused: { + true: { + backgroundColor: "neutral.bg.subtle.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", + fontSize: "sm", + color: "neutral.s65", + px: "3", + py: "6", +}); + +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 [ + ...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 }, + })), + ]; +} + +// -- Components --------------------------------------------------------------- + +const SearchContent: React.FC = () => { + 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)[]>([]); + + // 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); + // Reset focus when query changes + setFocusedIndex(null); + }; + input.addEventListener("input", handleInput); + setQuery(input.value); + return () => input.removeEventListener("input", handleInput); + }, [searchInputRef]); + + 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(() => { + 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 = (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; + } + } + }; + + const hasQuery = trimmed !== ""; + const matchLabel = hasQuery + ? `${results.length} match${results.length === 1 ? "" : "es"}` + : null; + + return ( + <> + {matchLabel &&
{matchLabel}
} + {results.length > 0 ? ( +
{ + // When the list receives focus (e.g. from ArrowDown in input), + // highlight the first item. Selection happens on Enter or ArrowDown. + if (focusedIndex === null && results.length > 0) { + setFocusedIndex(0); + } + }} + > + {results.map(({ item, highlighted }, index) => { + const isSelected = checkIsSelected(item.id); + const isFocused = focusedIndex === index; + return ( +
{ + rowRefs.current[index] = el; + }} + role="option" + tabIndex={-1} + aria-selected={isSelected} + className={resultRowStyle({ isSelected, isFocused })} + onClick={() => { + selectItem(item.selectionItem); + setFocusedIndex(index); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + selectItem(item.selectionItem); + setFocusedIndex(index); + } + }} + > +
+ + + + {highlighted} +
+ {item.category} +
+ ); + })} +
+ ) : hasQuery ? ( +
No matches
+ ) : null} + + ); +}; + +const SearchTitle: React.FC = () => { + const { isSearchOpen, searchInputRef } = use(EditorContext); + + useEffect(() => { + if (isSearchOpen) { + requestAnimationFrame(() => { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }); + } + }, [isSearchOpen, searchInputRef]); + + return ( + { + 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(); + } + }} + /> + ); +}; + +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, +}; 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..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 @@ -1,68 +1,17 @@ -import { css, cva } from "@hashintel/ds-helpers/css"; import { use } from "react"; -import { TbPlus, TbX } from "react-icons/tb"; +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"; -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)]", - }, - }, - }, - }, -}); - -const colorDotStyle = css({ - width: "[12px]", - height: "[12px]", - borderRadius: "[50%]", - flexShrink: 0, -}); - -const typeNameStyle = css({ - flex: "[1]", - fontSize: "[13px]", - color: "[#374151]", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}); - -const emptyMessageStyle = css({ - fontSize: "[13px]", - color: "[#9ca3af]", -}); +import { + RowMenu, + createFilterableListSubView, +} from "./filterable-list-sub-view"; // Pool of 10 well-differentiated colors for types const TYPE_COLOR_POOL = [ @@ -108,78 +57,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. */ @@ -227,19 +104,52 @@ 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. */ -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.map((type) => ({ + ...type, + icon: TokenTypeIcon, + iconColor: type.displayColor, + })); + }, + getSelectionItem: (type) => ({ type: "type", id: type.id }), + renderItem: (type) => type.name, + renderRowMenu: TypeRowMenu, + emptyMessage: "No token types yet", + renderHeaderAction: () => , +}); 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..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"; @@ -127,9 +128,11 @@ const DeleteArcAction: React.FC = () => { const arcMainContentSubView: SubView = { id: "arc-main-content", title: "Arc", + icon: PiScribbleLoopBold, 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..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 @@ -10,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, @@ -343,7 +344,9 @@ const DiffEqCodeAction: React.FC = () => { export const diffEqMainContentSubView: SubView = { id: "diff-eq-main-content", title: "Differential Equation", + icon: DifferentialEquationIcon, main: true, component: DiffEqMainContent, renderHeaderAction: () => , + alwaysShowHeaderAction: true, }; 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..3e9a30dc2d8 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,9 +94,11 @@ const DeleteSelectionAction: React.FC = () => { const multiSelectionMainSubView: SubView = { id: "multi-selection-main", title: "Multiple Selection", + icon: GrMultiple, main: true, component: MultiSelectionContent, renderHeaderAction: () => , + alwaysShowHeaderAction: true, }; const subViews: SubView[] = [multiSelectionMainSubView]; 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..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 @@ -1,6 +1,7 @@ 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"; @@ -96,6 +97,7 @@ const ParameterMainContent: React.FC = () => { export const parameterMainContentSubView: SubView = { id: "parameter-main-content", title: "Parameter", + 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 273b879335f..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 @@ -9,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"; @@ -352,7 +353,9 @@ const DeletePlaceAction: React.FC = () => { export const placeMainContentSubView: SubView = { id: "place-main-content", title: "Place", + icon: PlaceIcon, main: true, component: PlaceMainContent, renderHeaderAction: () => , + alwaysShowHeaderAction: true, }; 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, }; 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..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 @@ -11,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"; @@ -231,7 +232,9 @@ const DeleteTransitionAction: React.FC = () => { export const transitionMainContentSubView: SubView = { id: "transition-main-content", title: "Transition", + icon: TransitionIcon, 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..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 @@ -8,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"; @@ -366,6 +367,7 @@ const TypeMainContent: React.FC = () => { export const typeMainContentSubView: SubView = { id: "type-main-content", title: "Type", + icon: TokenTypeIcon, main: true, component: TypeMainContent, }; diff --git a/yarn.lock b/yarn.lock index 49fd0ac25d6..89e447aaedd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8029,6 +8029,7 @@ __metadata: d3-array: "npm:3.2.4" d3-scale: "npm:4.0.2" elkjs: "npm:0.11.0" + fuzzysort: "npm:3.1.0" immer: "npm:10.1.3" jsdom: "npm:24.1.3" monaco-editor: "npm:0.55.1" @@ -29422,6 +29423,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"