diff --git a/src/app/package.json b/src/app/package.json index 4fbaa2e9e..dfab79635 100644 --- a/src/app/package.json +++ b/src/app/package.json @@ -7,6 +7,10 @@ "license": "Apache-2.0", "browser": "lib.browser", "dependencies": { + "@emotion/react": "^11.0.0", + "@emotion/styled": "^11.0.0", + "@mui/icons-material": "^5.0.0", + "@mui/material": "^5.0.0", "@system-dynamics/core": "^1.0.0", "@system-dynamics/diagram": "^1.0.0", "@system-dynamics/engine2": "^2.0.0" diff --git a/src/diagram/AuxIcon.tsx b/src/diagram/AuxIcon.tsx index faa007740..4e496c6e6 100644 --- a/src/diagram/AuxIcon.tsx +++ b/src/diagram/AuxIcon.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; -import SvgIcon from '@mui/material/SvgIcon'; +import SvgIcon from './components/SvgIcon'; import styles from './AuxIcon.module.css'; @@ -19,5 +19,3 @@ export const AuxIcon: React.FunctionComponent = (props) => { }; AuxIcon.displayName = 'Variable'; - -(AuxIcon as any).muiName = 'AuxIcon'; diff --git a/src/diagram/Editor.module.css b/src/diagram/Editor.module.css index f7db5d461..12768c59b 100644 --- a/src/diagram/Editor.module.css +++ b/src/diagram/Editor.module.css @@ -18,6 +18,19 @@ width: 240px; margin-top: 12px; margin-left: 12px; + border-radius: 4px; + background: #fff; + box-shadow: 0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12); +} + +.snapshotCardContent { + padding: 16px; +} + +.snapshotCardActions { + display: flex; + align-items: center; + padding: 8px; } .snapshotImg { @@ -51,6 +64,9 @@ right: 8px; height: 48px; width: 359px; + border-radius: 4px; + background: #fff; + box-shadow: 0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12); } @media (min-width: 900px) and (max-width: 1199.95px) { @@ -114,6 +130,3 @@ background-color: var(--color-secondary); } -:global(.MuiAutocomplete-option[data-focus="true"]) { - background: #ADD8E6; -} diff --git a/src/diagram/Editor.tsx b/src/diagram/Editor.tsx index f1cd8c27b..2c7fa89f9 100644 --- a/src/diagram/Editor.tsx +++ b/src/diagram/Editor.tsx @@ -7,21 +7,13 @@ import * as React from 'react'; import { List, Map, Set, Stack } from 'immutable'; import clsx from 'clsx'; -import IconButton from '@mui/material/IconButton'; -import TextField from '@mui/material/TextField'; -import Autocomplete from '@mui/material/Autocomplete'; -import Paper from '@mui/material/Paper'; -import Snackbar from '@mui/material/Snackbar'; -import ClearIcon from '@mui/icons-material/Clear'; -import EditIcon from '@mui/icons-material/Edit'; -import MenuIcon from '@mui/icons-material/Menu'; -import SpeedDial, { CloseReason } from '@mui/material/SpeedDial'; -import SpeedDialAction from '@mui/material/SpeedDialAction'; -import SpeedDialIcon from '@mui/material/SpeedDialIcon'; -import { Card } from '@mui/material'; -import Button from '@mui/material/Button'; -import CardActions from '@mui/material/CardActions'; -import CardContent from '@mui/material/CardContent'; +import IconButton from './components/IconButton'; +import TextField from './components/TextField'; +import Autocomplete from './components/Autocomplete'; +import Snackbar from './components/Snackbar'; +import { ClearIcon, EditIcon, MenuIcon } from './components/icons'; +import SpeedDial, { CloseReason, SpeedDialAction, SpeedDialIcon } from './components/SpeedDial'; +import Button from './components/Button'; import { canonicalize } from '@system-dynamics/core/canonicalize'; import { Project as Engine2Project, SimlinErrorKind, SimlinUnitErrorKind } from '@system-dynamics/engine2'; @@ -416,17 +408,22 @@ export class Editor extends React.PureComponent { })); } - handleDialClick = (_event: React.MouseEvent) => { + handleDialClick = (_event: React.MouseEvent) => { this.setState({ dialOpen: !this.state.dialOpen, selectedTool: this.state.dialOpen ? undefined : this.state.selectedTool, }); }; - handleDialClose = (e: React.SyntheticEvent<{}>, reason: CloseReason) => { + handleDialClose = (e: React.SyntheticEvent, reason: CloseReason) => { if (reason === 'mouseLeave' || reason === 'blur') { return; } + // When an action is clicked, close the dial but keep the selected tool + if (reason === 'actionClick') { + this.setState({ dialOpen: false }); + return; + } this.setState({ dialOpen: false, selectedTool: undefined, @@ -1681,7 +1678,7 @@ export class Editor extends React.PureComponent { const status = this.state.status; return ( - +
{
- +
); } @@ -2045,7 +2042,7 @@ export class Editor extends React.PureComponent { this.setState({ selectedTool: undefined }); }; - handleSelectStock = (e: React.MouseEvent) => { + handleSelectStock = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.setState({ @@ -2053,7 +2050,7 @@ export class Editor extends React.PureComponent { }); }; - handleSelectFlow = (e: React.MouseEvent) => { + handleSelectFlow = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.setState({ @@ -2061,7 +2058,7 @@ export class Editor extends React.PureComponent { }); }; - handleSelectAux = (e: React.MouseEvent) => { + handleSelectAux = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.setState({ @@ -2069,7 +2066,7 @@ export class Editor extends React.PureComponent { }); }; - handleSelectLink = (e: React.MouseEvent) => { + handleSelectLink = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.setState({ @@ -2363,16 +2360,16 @@ export class Editor extends React.PureComponent { } return ( - - +
+
diagram snapshot - - +
+
- - +
+
); } diff --git a/src/diagram/ErrorDetails.module.css b/src/diagram/ErrorDetails.module.css index 26387a439..c87bbcf27 100644 --- a/src/diagram/ErrorDetails.module.css +++ b/src/diagram/ErrorDetails.module.css @@ -1,5 +1,8 @@ .card { width: 359px; + border-radius: 4px; + background: #fff; + box-shadow: 0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12); } @media (min-width: 900px) and (max-width: 1199.95px) { @@ -15,6 +18,7 @@ } .inner { + padding: 16px; padding-top: 72px; } diff --git a/src/diagram/ErrorDetails.tsx b/src/diagram/ErrorDetails.tsx index 364250f9d..0e02a532a 100644 --- a/src/diagram/ErrorDetails.tsx +++ b/src/diagram/ErrorDetails.tsx @@ -5,7 +5,6 @@ import * as React from 'react'; import { List, Map } from 'immutable'; -import { Card, CardContent, Typography } from '@mui/material'; import { SimError, ModelError, EquationError, ErrorCode, UnitError } from '@system-dynamics/core/datamodel'; import { errorCodeDescription } from '@system-dynamics/engine2'; @@ -32,7 +31,7 @@ export class ErrorDetails extends React.PureComponent { ) ) { errors.push( - simulation error: {errorCodeDescription(simError.code)}, +
simulation error: {errorCodeDescription(simError.code)}
, ); } if (!modelErrors.isEmpty()) { @@ -42,19 +41,19 @@ export class ErrorDetails extends React.PureComponent { } const details = err.details; errors.push( - +
model error: {errorCodeDescription(err.code)} {details ? `: ${details}` : undefined} - , +
, ); } } for (const [ident, errs] of varErrors) { for (const err of errs) { errors.push( - +
variable "{ident}" error: {errorCodeDescription(err.code)} - , +
, ); } } @@ -62,20 +61,20 @@ export class ErrorDetails extends React.PureComponent { for (const err of errs) { const details = err.details; errors.push( - +
variable "{ident}" unit error: {errorCodeDescription(err.code)} {details ? `: ${details}` : undefined} - , +
, ); } } return ( - - - {errors.length > 0 ? errors : Your model is error free!} - - +
+
+ {errors.length > 0 ? errors :
Your model is error free!
} +
+
); } } diff --git a/src/diagram/ErrorToast.tsx b/src/diagram/ErrorToast.tsx index b2d55d767..5f3df7d56 100644 --- a/src/diagram/ErrorToast.tsx +++ b/src/diagram/ErrorToast.tsx @@ -5,13 +5,9 @@ import * as React from 'react'; import clsx from 'clsx'; -import IconButton from '@mui/material/IconButton'; -import SnackbarContent from '@mui/material/SnackbarContent'; -import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import CloseIcon from '@mui/icons-material/Close'; -import ErrorIcon from '@mui/icons-material/Error'; -import InfoIcon from '@mui/icons-material/Info'; -import WarningIcon from '@mui/icons-material/Warning'; +import IconButton from './components/IconButton'; +import { SnackbarContent } from './components/Snackbar'; +import { CheckCircleIcon, CloseIcon, ErrorIcon, InfoIcon, WarningIcon } from './components/icons'; import styles from './ErrorToast.module.css'; diff --git a/src/diagram/FlowIcon.tsx b/src/diagram/FlowIcon.tsx index 734674a80..e560e2067 100644 --- a/src/diagram/FlowIcon.tsx +++ b/src/diagram/FlowIcon.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; -import SvgIcon from '@mui/material/SvgIcon'; +import SvgIcon from './components/SvgIcon'; import styles from './FlowIcon.module.css'; @@ -27,5 +27,3 @@ export class FlowIcon extends React.PureComponent { ); } } - -(FlowIcon as any).muiName = 'FlowIcon'; diff --git a/src/diagram/LinkIcon.tsx b/src/diagram/LinkIcon.tsx index 37482377c..e1d19b9e4 100644 --- a/src/diagram/LinkIcon.tsx +++ b/src/diagram/LinkIcon.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; -import SvgIcon from '@mui/material/SvgIcon'; +import SvgIcon from './components/SvgIcon'; import styles from './LinkIcon.module.css'; @@ -28,5 +28,3 @@ export class LinkIcon extends React.PureComponent { ); } } - -(LinkIcon as any).muiName = 'LinkIcon'; diff --git a/src/diagram/LookupEditor.module.css b/src/diagram/LookupEditor.module.css index ca3b581b0..cd0a9e6e8 100644 --- a/src/diagram/LookupEditor.module.css +++ b/src/diagram/LookupEditor.module.css @@ -34,3 +34,13 @@ .buttonRight { float: right; } + +.cardContent { + padding: 16px; +} + +.cardActions { + display: flex; + align-items: center; + padding: 8px; +} diff --git a/src/diagram/LookupEditor.tsx b/src/diagram/LookupEditor.tsx index 55ef6f64f..515c8d0ef 100644 --- a/src/diagram/LookupEditor.tsx +++ b/src/diagram/LookupEditor.tsx @@ -5,7 +5,8 @@ import * as React from 'react'; import { List } from 'immutable'; -import { Button, CardActions, CardContent, TextField } from '@mui/material'; +import Button from './components/Button'; +import TextField from './components/TextField'; import { defined } from '@system-dynamics/core/common'; import { @@ -359,7 +360,7 @@ export class LookupEditor extends React.PureComponent - +
- - +
+
@@ -428,7 +429,7 @@ export class LookupEditor extends React.PureComponent
- + ); } diff --git a/src/diagram/ModelPropertiesDrawer.tsx b/src/diagram/ModelPropertiesDrawer.tsx index f1579ea3c..3056c306c 100644 --- a/src/diagram/ModelPropertiesDrawer.tsx +++ b/src/diagram/ModelPropertiesDrawer.tsx @@ -5,20 +5,16 @@ import * as React from 'react'; import { Link } from 'wouter'; -import Button from '@mui/material/Button'; -import IconButton from '@mui/material/IconButton'; -import SwipeableDrawer from '@mui/material/SwipeableDrawer'; -import TextField from '@mui/material/TextField'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import ClearIcon from '@mui/icons-material/Clear'; -import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; +import Button from './components/Button'; +import IconButton from './components/IconButton'; +import Drawer from './components/Drawer'; +import TextField from './components/TextField'; +import { ArrowBackIcon, ClearIcon, CloudDownloadIcon } from './components/icons'; import { ModelIcon } from './ModelIcon'; import styles from './ModelPropertiesDrawer.module.css'; -const iOS = typeof navigator !== undefined && /iPad|iPhone|iPod/.test(navigator.userAgent); - interface ModelPropertiesDrawerProps { modelName: string; open: boolean; @@ -46,9 +42,7 @@ export class ModelPropertiesDrawer extends React.PureComponent - + ); } } diff --git a/src/diagram/Snapshotter.module.css b/src/diagram/Snapshotter.module.css index a3459fe63..067d14c98 100644 --- a/src/diagram/Snapshotter.module.css +++ b/src/diagram/Snapshotter.module.css @@ -1,6 +1,11 @@ .card { + display: flex; + align-items: center; height: 40px; margin-right: var(--spacing-1); + border-radius: 4px; + background: #fff; + box-shadow: 0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12); } .button { diff --git a/src/diagram/Snapshotter.tsx b/src/diagram/Snapshotter.tsx index 1a67eea2c..f74bee3c3 100644 --- a/src/diagram/Snapshotter.tsx +++ b/src/diagram/Snapshotter.tsx @@ -4,9 +4,8 @@ import * as React from 'react'; -import IconButton from '@mui/material/IconButton'; -import Paper from '@mui/material/Paper'; -import PhotoCamera from '@mui/icons-material/PhotoCamera'; +import IconButton from './components/IconButton'; +import { PhotoCameraIcon } from './components/icons'; import styles from './Snapshotter.module.css'; @@ -21,11 +20,11 @@ export class Snapshotter extends React.PureComponent { render() { return ( - +
- + - +
); } } diff --git a/src/diagram/StockIcon.tsx b/src/diagram/StockIcon.tsx index a4a06a675..cd8c1d781 100644 --- a/src/diagram/StockIcon.tsx +++ b/src/diagram/StockIcon.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; -import SvgIcon from '@mui/material/SvgIcon'; +import SvgIcon from './components/SvgIcon'; import styles from './StockIcon.module.css'; @@ -19,5 +19,3 @@ export const StockIcon: React.FunctionComponent = (props) => { }; StockIcon.displayName = 'Stock'; - -(StockIcon as any).muiName = 'StockIcon'; diff --git a/src/diagram/UndoRedoBar.module.css b/src/diagram/UndoRedoBar.module.css index 19092d7c3..72c9f28df 100644 --- a/src/diagram/UndoRedoBar.module.css +++ b/src/diagram/UndoRedoBar.module.css @@ -1,6 +1,11 @@ .card { + display: flex; + align-items: center; height: 40px; margin-right: var(--spacing-1); + border-radius: 4px; + background: #fff; + box-shadow: 0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12); } .divider { diff --git a/src/diagram/UndoRedoBar.tsx b/src/diagram/UndoRedoBar.tsx index 2e00a83de..518dc3196 100644 --- a/src/diagram/UndoRedoBar.tsx +++ b/src/diagram/UndoRedoBar.tsx @@ -4,10 +4,8 @@ import * as React from 'react'; -import IconButton from '@mui/material/IconButton'; -import Paper from '@mui/material/Paper'; -import RedoIcon from '@mui/icons-material/Redo'; -import UndoIcon from '@mui/icons-material/Undo'; +import IconButton from './components/IconButton'; +import { RedoIcon, UndoIcon } from './components/icons'; import styles from './UndoRedoBar.module.css'; @@ -30,7 +28,7 @@ export class UndoRedoBar extends React.PureComponent { const { undoEnabled, redoEnabled } = this.props; return ( - +
@@ -38,7 +36,7 @@ export class UndoRedoBar extends React.PureComponent { - +
); } } diff --git a/src/diagram/VariableDetails.module.css b/src/diagram/VariableDetails.module.css index 5f6e88340..452899669 100644 --- a/src/diagram/VariableDetails.module.css +++ b/src/diagram/VariableDetails.module.css @@ -2,6 +2,9 @@ width: 359px; max-height: calc(100vh - 18px); overflow-y: auto; + border-radius: 4px; + background: #fff; + box-shadow: 0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12); scrollbar-gutter: stable; } @@ -103,3 +106,13 @@ .errorList { color: #cc0000; } + +.cardContent { + padding: 16px; +} + +.cardActions { + display: flex; + align-items: center; + padding: 8px; +} diff --git a/src/diagram/VariableDetails.tsx b/src/diagram/VariableDetails.tsx index 4826a33af..faf54a7e2 100644 --- a/src/diagram/VariableDetails.tsx +++ b/src/diagram/VariableDetails.tsx @@ -9,7 +9,8 @@ import { LineChart, ChartSeries } from './LineChart'; import { createEditor, Descendant, Text, Transforms } from 'slate'; import { withHistory } from 'slate-history'; import { Editable, ReactEditor, RenderLeafProps, Slate, withReact } from 'slate-react'; -import { Button, Card, CardActions, CardContent, Tab, Tabs, Typography } from '@mui/material'; +import Button from './components/Button'; +import { Tabs, Tab } from './components/Tabs'; import katex from 'katex'; import { brewer } from 'chroma-js'; @@ -194,7 +195,7 @@ export class VariableDetails extends React.PureComponent, newValue: number) => { + handleTabChange = (event: React.SyntheticEvent, newValue: number) => { this.props.onActiveTabChange(newValue); }; @@ -269,7 +270,7 @@ export class VariableDetails extends React.PureComponent { errorList.push( - error: {errorCodeDescription(error.code)}, +
error: {errorCodeDescription(error.code)}
, ); }); } @@ -277,10 +278,10 @@ export class VariableDetails extends React.PureComponent { const details = error.details; errorList.push( - +
unit error: {errorCodeDescription(error.code)} {details ? `: ${details}` : undefined} - , +
, ); }); } @@ -324,7 +325,7 @@ export class VariableDetails extends React.PureComponent +
{showPreview ? (
- +
@@ -398,12 +399,12 @@ export class VariableDetails extends React.PureComponent
-
+


{chartOrErrors} - +
); } @@ -427,8 +428,8 @@ export class VariableDetails extends React.PureComponent { // Utility: map KaTeX glyph to ASCII char const mapGlyphToAscii = (ch: string): string => { - if (ch === '·' || ch === '×' || ch === '⋅') return '*'; - if (ch === '−') return '-'; + if (ch === '\u00b7' || ch === '\u00d7' || ch === '\u22c5') return '*'; + if (ch === '\u2212') return '-'; return ch; }; @@ -790,7 +791,7 @@ export class VariableDetails extends React.PureComponent; } else { table = ( - +
); } @@ -819,7 +820,7 @@ export class VariableDetails extends React.PureComponent; return ( - +
{content} - +
); } } diff --git a/src/diagram/ZoomBar.module.css b/src/diagram/ZoomBar.module.css index 9fd6b033f..bf7de76a3 100644 --- a/src/diagram/ZoomBar.module.css +++ b/src/diagram/ZoomBar.module.css @@ -1,6 +1,11 @@ .card { + display: flex; + align-items: center; height: 40px; margin-right: var(--spacing-1); + border-radius: 4px; + background: #fff; + box-shadow: 0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12); } .divider1 { diff --git a/src/diagram/ZoomBar.tsx b/src/diagram/ZoomBar.tsx index 5372392ee..fa4838641 100644 --- a/src/diagram/ZoomBar.tsx +++ b/src/diagram/ZoomBar.tsx @@ -4,11 +4,8 @@ import * as React from 'react'; -import clsx from 'clsx'; -import IconButton from '@mui/material/IconButton'; -import Paper from '@mui/material/Paper'; -import AddIcon from '@mui/icons-material/Add'; -import RemoveIcon from '@mui/icons-material/Remove'; +import IconButton from './components/IconButton'; +import { AddIcon, RemoveIcon } from './components/icons'; import styles from './ZoomBar.module.css'; @@ -24,9 +21,9 @@ function snapToZoom(zoom: number): number { }); } -const ε = 0.001; +const E = 0.001; function eq(a: number, b: number): boolean { - return Math.abs(a - b) < ε; + return Math.abs(a - b) < E; } function findNext(zoom: number, dir: 'out' | 'in'): number | undefined { @@ -75,7 +72,7 @@ export class ZoomBar extends React.PureComponent { const zoomOutEnabled = zoom > zooms[0]; return ( - +
{ > - +
); } } diff --git a/src/diagram/components/Autocomplete.module.css b/src/diagram/components/Autocomplete.module.css new file mode 100644 index 000000000..7d9af6262 --- /dev/null +++ b/src/diagram/components/Autocomplete.module.css @@ -0,0 +1,30 @@ +.wrapper { + display: inline-flex; + position: relative; + width: 100%; +} + +.listbox { + position: absolute; + z-index: 1300; + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.23); + border-radius: 4px; + box-shadow: 0px 2px 1px -1px rgba(0,0,0,0.2), 0px 1px 1px 0px rgba(0,0,0,0.14), 0px 1px 3px 0px rgba(0,0,0,0.12); + max-height: 300px; + overflow-y: auto; + margin: 0; + padding: 0; + list-style: none; +} + +.option { + padding: 6px 16px; + cursor: pointer; + font-family: "Roboto", "Helvetica", "Arial", sans-serif; + font-size: 1rem; +} + +.optionHighlighted { + background: #ADD8E6; +} diff --git a/src/diagram/components/Autocomplete.tsx b/src/diagram/components/Autocomplete.tsx new file mode 100644 index 000000000..e7b675a3e --- /dev/null +++ b/src/diagram/components/Autocomplete.tsx @@ -0,0 +1,133 @@ +// Copyright 2025 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +import * as React from 'react'; +import ReactDOM from 'react-dom'; + +import { useCombobox } from 'downshift'; +import clsx from 'clsx'; + +import styles from './Autocomplete.module.css'; + +interface AutocompleteProps { + key?: string; + value?: string | null; + defaultValue?: string; + onChange: (event: any, newValue: string | null) => void; + clearOnEscape?: boolean; + options: string[]; + renderInput: (params: any) => React.ReactNode; +} + +function itemToString(item: string | null): string { + return item || ''; +} + +export default function Autocomplete(props: AutocompleteProps) { + const { value, onChange, clearOnEscape, options, renderInput } = props; + + const [inputValue, setInputValue] = React.useState(value || ''); + const wrapperRef = React.useRef(null); + const [dropdownPosition, setDropdownPosition] = React.useState<{ + top: number; + left: number; + width: number; + } | null>(null); + + // Sync inputValue when value prop changes externally + React.useEffect(() => { + setInputValue(value || ''); + }, [value]); + + const filteredOptions = React.useMemo(() => { + if (!inputValue) return options; + const lower = inputValue.toLowerCase(); + return options.filter((opt) => opt.toLowerCase().includes(lower)); + }, [options, inputValue]); + + const { + isOpen, + getInputProps, + getMenuProps, + getItemProps, + highlightedIndex, + } = useCombobox({ + items: filteredOptions, + itemToString, + inputValue, + selectedItem: value || null, + onInputValueChange: ({ inputValue: newInputValue }) => { + setInputValue(newInputValue || ''); + }, + onSelectedItemChange: ({ selectedItem }) => { + onChange(null, selectedItem || null); + }, + stateReducer: (state, actionAndChanges) => { + const { type, changes } = actionAndChanges; + if (clearOnEscape && type === useCombobox.stateChangeTypes.InputKeyDownEscape) { + return { + ...changes, + selectedItem: null, + inputValue: '', + }; + } + return changes; + }, + }); + + React.useEffect(() => { + if (isOpen && wrapperRef.current) { + const rect = wrapperRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX, + width: rect.width, + }); + } + }, [isOpen]); + + const inputProps = getInputProps(); + const params = { + InputProps: { + disableUnderline: false, + ref: wrapperRef, + }, + inputProps, + }; + + const menuProps = getMenuProps(); + + const listbox = + isOpen && filteredOptions.length > 0 && dropdownPosition ? ( +
    + {filteredOptions.map((item, index) => ( +
  • + {item} +
  • + ))} +
+ ) : ( +
    + ); + + return ( +
    + {renderInput(params)} + {ReactDOM.createPortal(listbox, document.body)} +
    + ); +} diff --git a/src/diagram/components/Button.module.css b/src/diagram/components/Button.module.css new file mode 100644 index 000000000..4003ecf47 --- /dev/null +++ b/src/diagram/components/Button.module.css @@ -0,0 +1,106 @@ +.button { + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + box-sizing: border-box; + outline: 0; + border: 0; + cursor: pointer; + user-select: none; + vertical-align: middle; + text-decoration: none; + font-family: "Roboto", "Helvetica", "Arial", sans-serif; + font-weight: 500; + line-height: 1.75; + letter-spacing: 0.02857em; + text-transform: uppercase; + border-radius: 4px; + min-width: 64px; + transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1), + border-color 250ms cubic-bezier(0.4, 0, 0.2, 1), + color 250ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.button:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* Size variants */ +.sizeSmall { + padding: 4px 5px; + font-size: 0.8125rem; +} + +.sizeMedium { + padding: 6px 8px; + font-size: 0.875rem; +} + +.sizeLarge { + padding: 8px 22px; + font-size: 0.9375rem; +} + +/* text + primary */ +.textPrimary { + color: var(--color-primary); + background: transparent; +} + +.textPrimary:hover { + background-color: rgba(25, 118, 210, 0.04); +} + +/* text + secondary */ +.textSecondary { + color: var(--color-secondary); + background: transparent; +} + +.textSecondary:hover { + background-color: rgba(220, 0, 78, 0.04); +} + +/* contained + primary */ +.containedPrimary { + color: #fff; + background-color: var(--color-primary); + box-shadow: 0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12); +} + +.containedPrimary:hover { + background-color: #1565c0; +} + +/* contained + secondary */ +.containedSecondary { + color: #fff; + background-color: var(--color-secondary); + box-shadow: 0px 3px 1px -2px rgba(0,0,0,0.2), 0px 2px 2px 0px rgba(0,0,0,0.14), 0px 1px 5px 0px rgba(0,0,0,0.12); +} + +.containedSecondary:hover { + background-color: #9a0036; +} + +/* disabled states */ +.disabledText { + color: rgba(0, 0, 0, 0.26); + pointer-events: none; +} + +.disabledContained { + color: rgba(0, 0, 0, 0.26); + background-color: rgba(0, 0, 0, 0.12); + box-shadow: none; + pointer-events: none; +} + +.startIcon { + display: inherit; + margin-right: 8px; + margin-left: -4px; +} diff --git a/src/diagram/components/Button.tsx b/src/diagram/components/Button.tsx new file mode 100644 index 000000000..7eb0a4d9f --- /dev/null +++ b/src/diagram/components/Button.tsx @@ -0,0 +1,50 @@ +// Copyright 2025 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +import * as React from 'react'; + +import clsx from 'clsx'; + +import styles from './Button.module.css'; + +interface ButtonProps { + variant?: 'text' | 'contained'; + color?: 'primary' | 'secondary'; + size?: 'small' | 'medium' | 'large'; + disabled?: boolean; + onClick?: (event: React.MouseEvent) => void; + className?: string; + startIcon?: React.ReactNode; + children?: React.ReactNode; +} + +export default class Button extends React.PureComponent { + render() { + const { variant = 'text', color = 'primary', size = 'medium', disabled, onClick, className, startIcon, children } = this.props; + + const sizeClass = size === 'small' ? styles.sizeSmall : size === 'large' ? styles.sizeLarge : styles.sizeMedium; + + let variantColorClass: string; + let disabledClass: string | undefined; + if (variant === 'contained') { + variantColorClass = color === 'secondary' ? styles.containedSecondary : styles.containedPrimary; + disabledClass = disabled ? styles.disabledContained : undefined; + } else { + variantColorClass = color === 'secondary' ? styles.textSecondary : styles.textPrimary; + disabledClass = disabled ? styles.disabledText : undefined; + } + + return ( + + ); + } +} diff --git a/src/diagram/components/Drawer.module.css b/src/diagram/components/Drawer.module.css new file mode 100644 index 000000000..68e3c8f63 --- /dev/null +++ b/src/diagram/components/Drawer.module.css @@ -0,0 +1,35 @@ +.backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1200; + opacity: 1; + transition: opacity 225ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.backdropHidden { + opacity: 0; + pointer-events: none; +} + +.panel { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 1200; + background: #fff; + overflow-y: auto; + box-shadow: 0px 8px 10px -5px rgba(0,0,0,0.2), 0px 16px 24px 2px rgba(0,0,0,0.14), 0px 6px 30px 5px rgba(0,0,0,0.12); + transform: translateX(0); + visibility: visible; + transition: transform 225ms cubic-bezier(0, 0, 0.2, 1); +} + +.panelHidden { + transform: translateX(-100%); + visibility: hidden; +} diff --git a/src/diagram/components/Drawer.tsx b/src/diagram/components/Drawer.tsx new file mode 100644 index 000000000..823798c92 --- /dev/null +++ b/src/diagram/components/Drawer.tsx @@ -0,0 +1,103 @@ +// Copyright 2025 The Simlin Authors. All rights reserved. +// Use of this source code is governed by the Apache License, +// Version 2.0, that can be found in the LICENSE file. + +import * as React from 'react'; +import ReactDOM from 'react-dom'; + +import clsx from 'clsx'; + +import styles from './Drawer.module.css'; + +interface DrawerProps { + open: boolean; + onOpen?: () => void; + onClose: () => void; + children?: React.ReactNode; +} + +export default class Drawer extends React.PureComponent { + private panelRef = React.createRef(); + private previousActiveElement: Element | null = null; + + componentDidMount() { + document.addEventListener('keydown', this.handleKeyDown); + } + + componentDidUpdate(prevProps: DrawerProps) { + if (this.props.open && !prevProps.open) { + // Drawer just opened - save current focus and focus the panel + this.previousActiveElement = document.activeElement; + this.panelRef.current?.focus(); + } else if (!this.props.open && prevProps.open) { + // Drawer just closed - restore focus + if (this.previousActiveElement instanceof HTMLElement) { + this.previousActiveElement.focus(); + } + this.previousActiveElement = null; + } + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.handleKeyDown); + } + + handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && this.props.open) { + this.props.onClose(); + } + + // Focus trap: when Tab is pressed and drawer is open, keep focus within the panel + if (event.key === 'Tab' && this.props.open && this.panelRef.current) { + const panel = this.panelRef.current; + const focusableElements = panel.querySelectorAll( + 'a, button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"]), [contenteditable]', + ); + + if (focusableElements.length === 0) { + event.preventDefault(); + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + if (event.shiftKey && document.activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } else if (!event.shiftKey && document.activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + } + }; + + handleBackdropClick = () => { + this.props.onClose(); + }; + + render() { + const { open, children } = this.props; + + const content = ( + <> +