From ddec24f8dcdafcfc74d353b52049e3dc7b8c328f Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Tue, 27 Jan 2026 21:34:30 -0800 Subject: [PATCH 1/6] diagram: remove MUI and Emotion dependencies Replace all MUI components in the diagram package with custom implementations: SvgIcon, Button, IconButton, TextField, Snackbar, Tabs (Radix), SpeedDial, Drawer, and Autocomplete (Downshift). MUI's Card, Paper, CardContent, CardActions, and Typography are replaced with plain divs with CSS module classes that replicate MUI's elevation shadows, padding, and layout. The global MuiAutocomplete CSS selector is removed in favor of a scoped CSS module class in the new Autocomplete component. The app package gains explicit MUI dependencies (previously inherited via hoisting from diagram), and the website drops its MUI/Emotion/ThemeProvider usage entirely. --- src/app/package.json | 4 + src/diagram/AuxIcon.tsx | 4 +- src/diagram/Editor.module.css | 19 +- src/diagram/Editor.tsx | 38 ++- src/diagram/ErrorDetails.module.css | 4 + src/diagram/ErrorDetails.tsx | 25 +- src/diagram/ErrorToast.tsx | 10 +- src/diagram/FlowIcon.tsx | 4 +- src/diagram/LinkIcon.tsx | 4 +- src/diagram/LookupEditor.module.css | 10 + src/diagram/LookupEditor.tsx | 11 +- src/diagram/ModelPropertiesDrawer.tsx | 20 +- src/diagram/Snapshotter.module.css | 5 + src/diagram/Snapshotter.tsx | 11 +- src/diagram/StockIcon.tsx | 4 +- src/diagram/UndoRedoBar.module.css | 5 + src/diagram/UndoRedoBar.tsx | 10 +- src/diagram/VariableDetails.module.css | 13 + src/diagram/VariableDetails.tsx | 31 +-- src/diagram/ZoomBar.module.css | 5 + src/diagram/ZoomBar.tsx | 15 +- .../components/Autocomplete.module.css | 30 +++ src/diagram/components/Autocomplete.tsx | 128 ++++++++++ src/diagram/components/Button.module.css | 106 +++++++++ src/diagram/components/Button.tsx | 50 ++++ src/diagram/components/Drawer.module.css | 35 +++ src/diagram/components/Drawer.tsx | 55 +++++ src/diagram/components/IconButton.module.css | 38 +++ src/diagram/components/IconButton.tsx | 43 ++++ src/diagram/components/Snackbar.module.css | 35 +++ src/diagram/components/Snackbar.tsx | 91 +++++++ src/diagram/components/SpeedDial.module.css | 102 ++++++++ src/diagram/components/SpeedDial.tsx | 122 ++++++++++ src/diagram/components/SvgIcon.module.css | 9 + src/diagram/components/SvgIcon.tsx | 32 +++ src/diagram/components/Tabs.module.css | 43 ++++ src/diagram/components/Tabs.tsx | 76 ++++++ src/diagram/components/TextField.module.css | 141 +++++++++++ src/diagram/components/TextField.tsx | 147 ++++++++++++ src/diagram/components/icons.tsx | 96 ++++++++ src/diagram/package.json | 7 +- website/docs/components/SimlinDiagram.tsx | 10 +- website/package.json | 3 - yarn.lock | 224 +++++++++++------- 44 files changed, 1661 insertions(+), 214 deletions(-) create mode 100644 src/diagram/components/Autocomplete.module.css create mode 100644 src/diagram/components/Autocomplete.tsx create mode 100644 src/diagram/components/Button.module.css create mode 100644 src/diagram/components/Button.tsx create mode 100644 src/diagram/components/Drawer.module.css create mode 100644 src/diagram/components/Drawer.tsx create mode 100644 src/diagram/components/IconButton.module.css create mode 100644 src/diagram/components/IconButton.tsx create mode 100644 src/diagram/components/Snackbar.module.css create mode 100644 src/diagram/components/Snackbar.tsx create mode 100644 src/diagram/components/SpeedDial.module.css create mode 100644 src/diagram/components/SpeedDial.tsx create mode 100644 src/diagram/components/SvgIcon.module.css create mode 100644 src/diagram/components/SvgIcon.tsx create mode 100644 src/diagram/components/Tabs.module.css create mode 100644 src/diagram/components/Tabs.tsx create mode 100644 src/diagram/components/TextField.module.css create mode 100644 src/diagram/components/TextField.tsx create mode 100644 src/diagram/components/icons.tsx 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..143c323e6 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'; @@ -1681,7 +1673,7 @@ export class Editor extends React.PureComponent { const status = this.state.status; return ( - +
{
- +
); } @@ -2363,16 +2355,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 481cee1ec..d1a9c2f70 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); } @media (min-width: 900px) and (max-width: 1199.95px) { @@ -102,3 +105,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..34d78265a --- /dev/null +++ b/src/diagram/components/Autocomplete.tsx @@ -0,0 +1,128 @@ +// 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); + + 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..dddebb058 --- /dev/null +++ b/src/diagram/components/Drawer.tsx @@ -0,0 +1,55 @@ +// 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 { + componentDidMount() { + document.addEventListener('keydown', this.handleKeyDown); + } + + componentWillUnmount() { + document.removeEventListener('keydown', this.handleKeyDown); + } + + handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && this.props.open) { + this.props.onClose(); + } + }; + + handleBackdropClick = () => { + this.props.onClose(); + }; + + render() { + const { open, children } = this.props; + + const content = ( + <> +
    +
    + {children} +
    + + ); + + return ReactDOM.createPortal(content, document.body); + } +} diff --git a/src/diagram/components/IconButton.module.css b/src/diagram/components/IconButton.module.css new file mode 100644 index 000000000..cece72a93 --- /dev/null +++ b/src/diagram/components/IconButton.module.css @@ -0,0 +1,38 @@ +.iconButton { + display: inline-flex; + align-items: center; + justify-content: center; + position: relative; + box-sizing: border-box; + background-color: transparent; + outline: 0; + border: 0; + cursor: pointer; + user-select: none; + vertical-align: middle; + text-decoration: none; + text-align: center; + padding: 8px; + border-radius: 50%; + color: rgba(0, 0, 0, 0.54); + font-size: 1.5rem; + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.iconButton:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +.iconButton:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.colorInherit { + color: inherit; +} + +.disabled { + color: rgba(0, 0, 0, 0.26); + pointer-events: none; +} diff --git a/src/diagram/components/IconButton.tsx b/src/diagram/components/IconButton.tsx new file mode 100644 index 000000000..b125b9732 --- /dev/null +++ b/src/diagram/components/IconButton.tsx @@ -0,0 +1,43 @@ +// 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 './IconButton.module.css'; + +interface IconButtonProps { + color?: 'default' | 'inherit'; + disabled?: boolean; + onClick?: (event: React.MouseEvent) => void; + className?: string; + 'aria-label'?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export default class IconButton extends React.PureComponent { + render() { + const { color, disabled, onClick, className, style, children, ...rest } = this.props; + + return ( + + ); + } +} diff --git a/src/diagram/components/Snackbar.module.css b/src/diagram/components/Snackbar.module.css new file mode 100644 index 000000000..1f46c7efb --- /dev/null +++ b/src/diagram/components/Snackbar.module.css @@ -0,0 +1,35 @@ +.snackbar { + position: fixed; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + z-index: 1400; +} + +.snackbarHidden { + display: none; +} + +.snackbarContent { + background: #323232; + color: #fff; + border-radius: 4px; + padding: 6px 16px; + box-shadow: 0px 3px 5px -1px rgba(0,0,0,0.2), 0px 6px 10px 0px rgba(0,0,0,0.14), 0px 1px 18px 0px rgba(0,0,0,0.12); + display: flex; + align-items: center; + flex-wrap: wrap; +} + +.snackbarContentMessage { + padding: 8px 0; + flex: 1; +} + +.snackbarContentAction { + display: flex; + align-items: center; + margin-left: auto; + padding-left: 16px; + margin-right: -8px; +} diff --git a/src/diagram/components/Snackbar.tsx b/src/diagram/components/Snackbar.tsx new file mode 100644 index 000000000..edb6b2f7b --- /dev/null +++ b/src/diagram/components/Snackbar.tsx @@ -0,0 +1,91 @@ +// 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 './Snackbar.module.css'; + +interface SnackbarProps { + anchorOrigin?: { vertical: string; horizontal: string }; + open: boolean; + autoHideDuration?: number; + onClose?: () => void; + children?: React.ReactNode; +} + +export default class Snackbar extends React.PureComponent { + timerHandle: ReturnType | undefined; + + componentDidUpdate(prevProps: SnackbarProps) { + if (this.props.open && !prevProps.open && this.props.autoHideDuration && this.props.onClose) { + this.startTimer(); + } + if (!this.props.open && prevProps.open) { + this.clearTimer(); + } + } + + componentWillUnmount() { + this.clearTimer(); + } + + startTimer() { + this.clearTimer(); + if (this.props.autoHideDuration && this.props.onClose) { + this.timerHandle = setTimeout(this.props.onClose, this.props.autoHideDuration); + } + } + + clearTimer() { + if (this.timerHandle !== undefined) { + window.clearTimeout(this.timerHandle); + this.timerHandle = undefined; + } + } + + render() { + const { open, children } = this.props; + + const content = ( +
    + {children} +
    + ); + + return ReactDOM.createPortal(content, document.body); + } +} + +interface SnackbarContentProps { + className?: string; + message?: React.ReactNode; + action?: React.ReactNode; + 'aria-describedby'?: string; + [key: string]: unknown; +} + +export class SnackbarContent extends React.PureComponent { + render() { + const { className, message, action, 'aria-describedby': ariaDescribedby, ...rest } = this.props; + + // filter out non-DOM props that may be spread from parent destructuring + const domRest: Record = {}; + for (const [key, val] of Object.entries(rest)) { + if (key === 'onClose' || key === 'variant') { + continue; + } + domRest[key] = val; + } + + return ( +
    +
    {message}
    + {action &&
    {action}
    } +
    + ); + } +} diff --git a/src/diagram/components/SpeedDial.module.css b/src/diagram/components/SpeedDial.module.css new file mode 100644 index 000000000..2d65c6a9e --- /dev/null +++ b/src/diagram/components/SpeedDial.module.css @@ -0,0 +1,102 @@ +.speedDial { + display: inline-flex; + flex-direction: column-reverse; + align-items: center; +} + +.speedDialHidden { + display: none; +} + +.fab { + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--color-secondary); + color: #fff; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0px 3px 5px -1px rgba(0,0,0,0.2), 0px 6px 10px 0px rgba(0,0,0,0.14), 0px 1px 18px 0px rgba(0,0,0,0.12); + transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1); + outline: none; + z-index: 1; +} + +.fab:hover { + background-color: #9a0036; +} + +.fab:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.actions { + display: flex; + flex-direction: column-reverse; + align-items: center; + margin-bottom: 8px; +} + +.action { + display: flex; + align-items: center; + margin-bottom: 4px; + position: relative; +} + +.actionButton { + width: 40px; + height: 40px; + border-radius: 50%; + background: #e0e0e0; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0px 3px 5px -1px rgba(0,0,0,0.2), 0px 6px 10px 0px rgba(0,0,0,0.14), 0px 1px 18px 0px rgba(0,0,0,0.12); + transform: scale(0); + opacity: 0; + transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1), + opacity 200ms cubic-bezier(0.4, 0, 0.2, 1); + outline: none; +} + +.actionButtonOpen { + transform: scale(1); + opacity: 1; +} + +.actionLabel { + position: absolute; + right: calc(100% + 8px); + white-space: nowrap; + background: #616161; + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 0.625rem; + font-family: "Roboto", "Helvetica", "Arial", sans-serif; + pointer-events: none; + opacity: 0; + transition: opacity 200ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.actionLabelOpen { + opacity: 1; +} + +.iconWrapper { + display: flex; + align-items: center; + justify-content: center; + transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.iconWrapperOpen { + transform: rotate(45deg); +} diff --git a/src/diagram/components/SpeedDial.tsx b/src/diagram/components/SpeedDial.tsx new file mode 100644 index 000000000..b157bac3d --- /dev/null +++ b/src/diagram/components/SpeedDial.tsx @@ -0,0 +1,122 @@ +// 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 './SpeedDial.module.css'; + +export type CloseReason = 'toggle' | 'blur' | 'mouseLeave' | 'escapeKeyDown'; + +interface SpeedDialProps { + ariaLabel: string; + className?: string; + hidden?: boolean; + icon: React.ReactNode; + onClick?: (event: React.MouseEvent) => void; + onClose?: (event: React.SyntheticEvent<{}>, reason: CloseReason) => void; + open: boolean; + children?: React.ReactNode; +} + +export default class SpeedDial extends React.PureComponent { + handleMouseLeave = (event: React.MouseEvent) => { + this.props.onClose?.(event, 'mouseLeave'); + }; + + handleBlur = (event: React.FocusEvent) => { + this.props.onClose?.(event, 'blur'); + }; + + handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + this.props.onClose?.(event, 'escapeKeyDown'); + } + }; + + render() { + const { ariaLabel, className, hidden, icon, onClick, open, children } = this.props; + + const enrichedIcon = React.isValidElement(icon) + ? React.cloneElement(icon as React.ReactElement, { open }) + : icon; + + return ( + + ); + } +} + +interface SpeedDialActionProps { + icon: React.ReactNode; + title: string; + onClick?: (event: React.MouseEvent) => void; + className?: string; +} + +export class SpeedDialAction extends React.PureComponent { + render() { + const { icon, title, onClick, className } = this.props; + return ( +
    + + {title} +
    + ); + } +} + +interface SpeedDialIconProps { + icon: React.ReactNode; + openIcon?: React.ReactNode; + open?: boolean; +} + +export class SpeedDialIcon extends React.PureComponent { + render() { + const { icon, openIcon, open } = this.props; + + if (openIcon) { + return ( + + {open ? openIcon : icon} + + ); + } + + return ( + + {icon} + + ); + } +} diff --git a/src/diagram/components/SvgIcon.module.css b/src/diagram/components/SvgIcon.module.css new file mode 100644 index 000000000..9f9e15abf --- /dev/null +++ b/src/diagram/components/SvgIcon.module.css @@ -0,0 +1,9 @@ +.svgIcon { + width: 1em; + height: 1em; + display: inline-block; + fill: currentColor; + flex-shrink: 0; + font-size: 1.5rem; + user-select: none; +} diff --git a/src/diagram/components/SvgIcon.tsx b/src/diagram/components/SvgIcon.tsx new file mode 100644 index 000000000..dc9be6329 --- /dev/null +++ b/src/diagram/components/SvgIcon.tsx @@ -0,0 +1,32 @@ +// 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 './SvgIcon.module.css'; + +export interface SvgIconProps extends Omit, 'ref'> { + viewBox?: string; + className?: string; + children?: React.ReactNode; +} + +export default class SvgIcon extends React.PureComponent { + render() { + const { viewBox = '0 0 24 24', className, children, ...rest } = this.props; + return ( + + ); + } +} diff --git a/src/diagram/components/Tabs.module.css b/src/diagram/components/Tabs.module.css new file mode 100644 index 000000000..8349f2a72 --- /dev/null +++ b/src/diagram/components/Tabs.module.css @@ -0,0 +1,43 @@ +.tabsList { + display: flex; + position: relative; +} + +.tab { + flex: 1; + min-height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 12px 16px; + border: none; + background: transparent; + cursor: pointer; + font-family: "Roboto", "Helvetica", "Arial", sans-serif; + font-size: 0.875rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.02857em; + color: rgba(0, 0, 0, 0.6); + transition: color 250ms cubic-bezier(0.4, 0, 0.2, 1); + outline: none; + box-sizing: border-box; +} + +.tab[data-state="active"] { + color: var(--color-primary); +} + +.tab:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} + +.indicator { + position: absolute; + bottom: 0; + height: 2px; + background: var(--color-primary); + transition: left 300ms cubic-bezier(0.4, 0, 0.2, 1), + width 300ms cubic-bezier(0.4, 0, 0.2, 1); +} diff --git a/src/diagram/components/Tabs.tsx b/src/diagram/components/Tabs.tsx new file mode 100644 index 000000000..056258619 --- /dev/null +++ b/src/diagram/components/Tabs.tsx @@ -0,0 +1,76 @@ +// 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 * as RadixTabs from '@radix-ui/react-tabs'; +import clsx from 'clsx'; + +import styles from './Tabs.module.css'; + +interface TabsProps { + className?: string; + variant?: 'fullWidth'; + value: number; + indicatorColor?: string; + textColor?: string; + onChange: (event: React.SyntheticEvent, newValue: number) => void; + 'aria-label'?: string; + children?: React.ReactNode; +} + +interface TabProps { + label: string; +} + +export class Tab extends React.PureComponent { + // value is injected by Tabs parent + render() { + const { label, ...rest } = this.props; + const value = (rest as any)._tabValue as string; + return ( + + {label} + + ); + } +} + +export class Tabs extends React.PureComponent { + handleValueChange = (newValue: string) => { + const syntheticEvent = {} as React.SyntheticEvent; + this.props.onChange(syntheticEvent, Number(newValue)); + }; + + render() { + const { className, value, children, ...rest } = this.props; + const ariaLabel = rest['aria-label']; + + // Count children (non-null) and inject _tabValue + const childArray = React.Children.toArray(children).filter(Boolean); + const tabCount = childArray.length; + + const enrichedChildren = childArray.map((child, index) => { + if (React.isValidElement(child)) { + return React.cloneElement(child as React.ReactElement, { _tabValue: String(index) }); + } + return child; + }); + + const indicatorLeft = tabCount > 0 ? `${(value / tabCount) * 100}%` : '0%'; + const indicatorWidth = tabCount > 0 ? `${(1 / tabCount) * 100}%` : '0%'; + + return ( + + + {enrichedChildren} +
    + + + ); + } +} diff --git a/src/diagram/components/TextField.module.css b/src/diagram/components/TextField.module.css new file mode 100644 index 000000000..d1e9d0c3e --- /dev/null +++ b/src/diagram/components/TextField.module.css @@ -0,0 +1,141 @@ +.root { + display: inline-flex; + flex-direction: column; + position: relative; + vertical-align: top; + box-sizing: border-box; +} + +.fullWidth { + width: 100%; +} + +.marginNormal { + margin-top: 16px; + margin-bottom: 8px; +} + +/* Outlined variant */ +.outlinedWrapper { + position: relative; + border: 1px solid rgba(0, 0, 0, 0.23); + border-radius: 4px; + transition: border-color 200ms cubic-bezier(0.0, 0, 0.2, 1); +} + +.outlinedWrapper:hover { + border-color: rgba(0, 0, 0, 0.87); +} + +.outlinedFocused { + border-color: var(--color-primary); + border-width: 2px; +} + +.outlinedError { + border-color: var(--color-error); + border-width: 2px; +} + +.outlinedInput { + font: inherit; + padding: 16.5px 14px; + border: none; + outline: none; + background: transparent; + width: 100%; + box-sizing: border-box; +} + +.outlinedLabel { + position: absolute; + left: 14px; + top: 0; + transform: translate(0, 16px) scale(1); + transform-origin: top left; + transition: transform 200ms cubic-bezier(0.0, 0, 0.2, 1), + color 200ms cubic-bezier(0.0, 0, 0.2, 1); + color: rgba(0, 0, 0, 0.6); + pointer-events: none; + font: inherit; + line-height: 1; + white-space: nowrap; +} + +.outlinedLabelShrunk { + transform: translate(0, -9px) scale(0.75); + background: #fff; + padding: 0 4px; +} + +.outlinedLabelFocused { + color: var(--color-primary); +} + +.outlinedLabelError { + color: var(--color-error); +} + +/* Standard variant */ +.standardWrapper { + position: relative; + border-bottom: 1px solid rgba(0, 0, 0, 0.42); + transition: border-color 200ms cubic-bezier(0.0, 0, 0.2, 1); +} + +.standardWrapper:hover { + border-bottom: 2px solid rgba(0, 0, 0, 0.87); +} + +.standardFocused { + border-bottom: 2px solid var(--color-primary); +} + +.standardError { + border-bottom: 2px solid var(--color-error); +} + +.standardNoUnderline { + border-bottom: none; +} + +.standardNoUnderline:hover { + border-bottom: none; +} + +.standardInput { + font: inherit; + padding: 4px 0 5px; + border: none; + outline: none; + background: transparent; + width: 100%; + box-sizing: border-box; +} + +.standardLabel { + position: absolute; + left: 0; + top: 0; + transform: translate(0, 16px) scale(1); + transform-origin: top left; + transition: transform 200ms cubic-bezier(0.0, 0, 0.2, 1), + color 200ms cubic-bezier(0.0, 0, 0.2, 1); + color: rgba(0, 0, 0, 0.6); + pointer-events: none; + font: inherit; + line-height: 1; + white-space: nowrap; +} + +.standardLabelShrunk { + transform: translate(0, -1.5px) scale(0.75); +} + +.standardLabelFocused { + color: var(--color-primary); +} + +.standardLabelError { + color: var(--color-error); +} diff --git a/src/diagram/components/TextField.tsx b/src/diagram/components/TextField.tsx new file mode 100644 index 000000000..e764a4c31 --- /dev/null +++ b/src/diagram/components/TextField.tsx @@ -0,0 +1,147 @@ +// 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 './TextField.module.css'; + +interface TextFieldProps { + variant?: 'outlined' | 'standard'; + label?: string; + value?: string | number; + onChange?: (event: React.ChangeEvent) => void; + type?: string; + margin?: 'none' | 'normal'; + fullWidth?: boolean; + error?: boolean; + placeholder?: string; + className?: string; + InputProps?: { + disableUnderline?: boolean; + ref?: React.Ref; + }; + inputProps?: React.InputHTMLAttributes; +} + +interface TextFieldState { + isFocused: boolean; +} + +export default class TextField extends React.PureComponent { + state: TextFieldState = { isFocused: false }; + + handleFocus = () => { + this.setState({ isFocused: true }); + }; + + handleBlur = () => { + this.setState({ isFocused: false }); + }; + + render() { + const { + variant = 'outlined', + label, + value, + onChange, + type, + margin, + fullWidth, + error, + placeholder, + className, + InputProps, + inputProps, + ...rest + } = this.props; + const { isFocused } = this.state; + + const hasValue = value !== undefined && value !== null && value !== ''; + const shouldShrink = isFocused || hasValue; + + const rootClasses = clsx( + styles.root, + fullWidth && styles.fullWidth, + margin === 'normal' && styles.marginNormal, + className, + ); + + if (variant === 'standard') { + const disableUnderline = InputProps?.disableUnderline; + const wrapperRef = InputProps?.ref; + + const wrapperClasses = clsx( + styles.standardWrapper, + disableUnderline && styles.standardNoUnderline, + isFocused && !disableUnderline && styles.standardFocused, + error && !disableUnderline && styles.standardError, + ); + + const labelClasses = label + ? clsx( + styles.standardLabel, + shouldShrink && styles.standardLabelShrunk, + isFocused && styles.standardLabelFocused, + error && styles.standardLabelError, + ) + : undefined; + + return ( +
    +
    + {label && } + +
    +
    + ); + } + + // outlined variant + const wrapperClasses = clsx( + styles.outlinedWrapper, + isFocused && styles.outlinedFocused, + error && styles.outlinedError, + ); + + const labelClasses = label + ? clsx( + styles.outlinedLabel, + shouldShrink && styles.outlinedLabelShrunk, + isFocused && styles.outlinedLabelFocused, + error && styles.outlinedLabelError, + ) + : undefined; + + return ( +
    +
    + {label && } + +
    +
    + ); + } +} diff --git a/src/diagram/components/icons.tsx b/src/diagram/components/icons.tsx new file mode 100644 index 000000000..3342fedc9 --- /dev/null +++ b/src/diagram/components/icons.tsx @@ -0,0 +1,96 @@ +// 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 SvgIcon, { type SvgIconProps } from './SvgIcon'; + +type IconProps = SvgIconProps; + +export const ClearIcon = (props: IconProps) => ( + + + +); + +export const CloseIcon = ClearIcon; + +export const EditIcon = (props: IconProps) => ( + + + +); + +export const MenuIcon = (props: IconProps) => ( + + + +); + +export const ArrowBackIcon = (props: IconProps) => ( + + + +); + +export const CloudDownloadIcon = (props: IconProps) => ( + + + +); + +export const RedoIcon = (props: IconProps) => ( + + + +); + +export const UndoIcon = (props: IconProps) => ( + + + +); + +export const AddIcon = (props: IconProps) => ( + + + +); + +export const RemoveIcon = (props: IconProps) => ( + + + +); + +export const PhotoCameraIcon = (props: IconProps) => ( + + + + +); + +export const CheckCircleIcon = (props: IconProps) => ( + + + +); + +export const ErrorIcon = (props: IconProps) => ( + + + +); + +export const InfoIcon = (props: IconProps) => ( + + + +); + +export const WarningIcon = (props: IconProps) => ( + + + +); diff --git a/src/diagram/package.json b/src/diagram/package.json index 3e607d4ff..a2e90ec75 100644 --- a/src/diagram/package.json +++ b/src/diagram/package.json @@ -22,12 +22,12 @@ } }, "dependencies": { - "@emotion/react": "^11.0.0", - "@emotion/styled": "^11.0.0", - "@mui/material": "^5.0.0", + "@radix-ui/react-tabs": "^1.1.13", "@system-dynamics/core": "^1.3.5", "@system-dynamics/engine2": "^2.0.0", + "clsx": "^2.1.1", "chroma-js": "^3.1.2", + "downshift": "^9.0.13", "immutable": "^5.0.3", "js-base64": "^3.7.7", "katex": "^0.16.0", @@ -43,7 +43,6 @@ "@types/slate*/**/immutable": "5.0.3" }, "devDependencies": { - "@mui/icons-material": "^5.0.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", "@types/chroma-js": "^2.4.4", diff --git a/website/docs/components/SimlinDiagram.tsx b/website/docs/components/SimlinDiagram.tsx index 250694e32..6fe7edf2a 100644 --- a/website/docs/components/SimlinDiagram.tsx +++ b/website/docs/components/SimlinDiagram.tsx @@ -1,8 +1,6 @@ import React from 'react'; import { toUint8Array } from 'js-base64'; import { Map, Set } from 'immutable'; -import { createTheme, ThemeProvider } from '@mui/material/styles'; - import { defined, Series } from '@system-dynamics/core/common'; import { UID, ViewElement, Project } from '@system-dynamics/core/datamodel'; import { Point } from '@system-dynamics/diagram/drawing/common'; @@ -37,12 +35,6 @@ export default class SimlinDiagram extends React.PureComponent<{}, DiagramState> } render() { - const theme = createTheme({ - palette: { - mode: 'light', - }, - }); - const model = defined(this.state.project.models.get('main')); const renameVariable = (_oldName: string, _newName: string): void => { @@ -87,7 +79,7 @@ export default class SimlinDiagram extends React.PureComponent<{}, DiagramState> return (
    - {canvasElement} + {canvasElement}
    ); } diff --git a/website/package.json b/website/package.json index 86a4f328a..3bbc88ebe 100644 --- a/website/package.json +++ b/website/package.json @@ -12,9 +12,6 @@ "format": "find . -name '*.ts' -o -name '*.tsx' | egrep -v '/(lib(\\.(browser|module))?|importer/core|engine/core)/' | xargs prettier --write" }, "devDependencies": { - "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.0", - "@mui/material": "^7.1.2", "@types/react": "^19.0.0", "@types/react-helmet": "^6.1.0", "immutable": "^5.1.3", diff --git a/yarn.lock b/yarn.lock index 3d2906e45..0e79354b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -256,7 +256,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.28.6" -"@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.9", "@babel/runtime@^7.28.4", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": +"@babel/runtime@^7.12.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.5", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": version "7.28.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.6.tgz#d267a43cb1836dc4d182cce93ae75ba954ef6d2b" integrity sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA== @@ -420,7 +420,7 @@ resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.9.0.tgz#745969d649977776b43fc7648c556aaa462b4102" integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ== -"@emotion/react@^11.0.0", "@emotion/react@^11.14.0": +"@emotion/react@^11.0.0": version "11.14.0" resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.14.0.tgz#cfaae35ebc67dd9ef4ea2e9acc6cd29e157dd05d" integrity sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA== @@ -450,7 +450,7 @@ resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.4.0.tgz#c9299c34d248bc26e82563735f78953d2efca83c" integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg== -"@emotion/styled@^11.0.0", "@emotion/styled@^11.14.0": +"@emotion/styled@^11.0.0": version "11.14.1" resolved "https://registry.yarnpkg.com/@emotion/styled/-/styled-11.14.1.tgz#8c34bed2948e83e1980370305614c20955aacd1c" integrity sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw== @@ -1510,11 +1510,6 @@ resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz#85019a8704b0f63305fc5600635ee663810f2b66" integrity sha512-jbhwoQ1AY200PSSOrNXmrFCaSDSJWP7qk6urkTmIirvRXDROkqe+QwcLlUiw/PrREwsIF/vm3/dAXvjlMHF0RA== -"@mui/core-downloads-tracker@^7.3.7": - version "7.3.7" - resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.7.tgz#99d9c60be3ce5632ec915b2c287682020ce19a99" - integrity sha512-8jWwS6FweMkpyRkrJooamUGe1CQfO1yJ+lM43IyUJbrhHW/ObES+6ry4vfGi8EKaldHL3t3BG1bcLcERuJPcjg== - "@mui/icons-material@^5.0.0": version "5.18.0" resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.18.0.tgz#97d87f1b7bee5fa7b9ba844518631de3112c1e57" @@ -1540,24 +1535,6 @@ react-is "^19.0.0" react-transition-group "^4.4.5" -"@mui/material@^7.1.2": - version "7.3.7" - resolved "https://registry.yarnpkg.com/@mui/material/-/material-7.3.7.tgz#50fc9b9f8645a4d26a48d7c5f7fa0c9876a8c679" - integrity sha512-6bdIxqzeOtBAj2wAsfhWCYyMKPLkRO9u/2o5yexcL0C3APqyy91iGSWgT3H7hg+zR2XgE61+WAu12wXPON8b6A== - dependencies: - "@babel/runtime" "^7.28.4" - "@mui/core-downloads-tracker" "^7.3.7" - "@mui/system" "^7.3.7" - "@mui/types" "^7.4.10" - "@mui/utils" "^7.3.7" - "@popperjs/core" "^2.11.8" - "@types/react-transition-group" "^4.4.12" - clsx "^2.1.1" - csstype "^3.2.3" - prop-types "^15.8.1" - react-is "^19.2.3" - react-transition-group "^4.4.5" - "@mui/private-theming@^5.17.1": version "5.17.1" resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.17.1.tgz#b4b6fbece27830754ef78186e3f1307dca42f295" @@ -1567,15 +1544,6 @@ "@mui/utils" "^5.17.1" prop-types "^15.8.1" -"@mui/private-theming@^7.3.7": - version "7.3.7" - resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-7.3.7.tgz#f5b41d573df3824fbfd10a7e6ac8de94bbcf15c5" - integrity sha512-w7r1+CYhG0syCAQUWAuV5zSaU2/67WA9JXUderdb7DzCIJdp/5RmJv6L85wRjgKCMsxFF0Kfn0kPgPbPgw/jdw== - dependencies: - "@babel/runtime" "^7.28.4" - "@mui/utils" "^7.3.7" - prop-types "^15.8.1" - "@mui/styled-engine@^5.18.0": version "5.18.0" resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.18.0.tgz#914cca1385bb33ce0cde31721f529c8bd7fa301c" @@ -1587,18 +1555,6 @@ csstype "^3.1.3" prop-types "^15.8.1" -"@mui/styled-engine@^7.3.7": - version "7.3.7" - resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-7.3.7.tgz#cde5a8381e14310f293a53dd59d27ae737a305fc" - integrity sha512-y/QkNXv6cF6dZ5APztd/dFWfQ6LHKPx3skyYO38YhQD4+Cxd6sFAL3Z38WMSSC8LQz145Mpp3CcLrSCLKPwYAg== - dependencies: - "@babel/runtime" "^7.28.4" - "@emotion/cache" "^11.14.0" - "@emotion/serialize" "^1.3.3" - "@emotion/sheet" "^1.4.0" - csstype "^3.2.3" - prop-types "^15.8.1" - "@mui/system@^5.18.0": version "5.18.0" resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.18.0.tgz#e55331203a40584b26c5a855a07949ac8973bfb6" @@ -1613,27 +1569,6 @@ csstype "^3.1.3" prop-types "^15.8.1" -"@mui/system@^7.3.7": - version "7.3.7" - resolved "https://registry.yarnpkg.com/@mui/system/-/system-7.3.7.tgz#530932e078ba58031cd9bcc71494a544fa635a27" - integrity sha512-DovL3k+FBRKnhmatzUMyO5bKkhMLlQ9L7Qw5qHrre3m8zCZmE+31NDVBFfqrbrA7sq681qaEIHdkWD5nmiAjyQ== - dependencies: - "@babel/runtime" "^7.28.4" - "@mui/private-theming" "^7.3.7" - "@mui/styled-engine" "^7.3.7" - "@mui/types" "^7.4.10" - "@mui/utils" "^7.3.7" - clsx "^2.1.1" - csstype "^3.2.3" - prop-types "^15.8.1" - -"@mui/types@^7.4.10": - version "7.4.10" - resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.4.10.tgz#c80ed5850a1da7802a01c1d0153d8603ce41be10" - integrity sha512-0+4mSjknSu218GW3isRqoxKRTOrTLd/vHi/7UC4+wZcUrOAqD9kRk7UQRL1mcrzqRoe7s3UT6rsRpbLkW5mHpQ== - dependencies: - "@babel/runtime" "^7.28.4" - "@mui/types@~7.2.15": version "7.2.24" resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.24.tgz#5eff63129d9c29d80bbf2d2e561bd0690314dec2" @@ -1651,18 +1586,6 @@ prop-types "^15.8.1" react-is "^19.0.0" -"@mui/utils@^7.3.7": - version "7.3.7" - resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-7.3.7.tgz#71443559a7fbd993b5b90fcb843fa26a60046f99" - integrity sha512-+YjnjMRnyeTkWnspzoxRdiSOgkrcpTikhNPoxOZW0APXx+urHtUoXJ9lbtCZRCA5a4dg5gSbd19alL1DvRs5fg== - dependencies: - "@babel/runtime" "^7.28.4" - "@mui/types" "^7.4.10" - "@types/prop-types" "^15.7.15" - clsx "^2.1.1" - prop-types "^15.8.1" - react-is "^19.2.3" - "@napi-rs/wasm-runtime@1.0.7": version "1.0.7" resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz#dcfea99a75f06209a235f3d941e3460a51e9b14c" @@ -1914,6 +1837,119 @@ resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570" integrity sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw== +"@radix-ui/primitive@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.3.tgz#e2dbc13bdc5e4168f4334f75832d7bdd3e2de5ba" + integrity sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg== + +"@radix-ui/react-collection@1.1.7": + version "1.1.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.7.tgz#d05c25ca9ac4695cc19ba91f42f686e3ea2d9aec" + integrity sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-slot" "1.2.3" + +"@radix-ui/react-compose-refs@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30" + integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg== + +"@radix-ui/react-context@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36" + integrity sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA== + +"@radix-ui/react-direction@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz#39e5a5769e676c753204b792fbe6cf508e550a14" + integrity sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw== + +"@radix-ui/react-id@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.1.tgz#1404002e79a03fe062b7e3864aa01e24bd1471f7" + integrity sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-presence@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.5.tgz#5d8f28ac316c32f078afce2996839250c10693db" + integrity sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-primitive@2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz#db9b8bcff49e01be510ad79893fb0e4cda50f1bc" + integrity sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ== + dependencies: + "@radix-ui/react-slot" "1.2.3" + +"@radix-ui/react-roving-focus@1.1.11": + version "1.1.11" + resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz#ef54384b7361afc6480dcf9907ef2fedb5080fd9" + integrity sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA== + dependencies: + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-collection" "1.1.7" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.2.2" + +"@radix-ui/react-slot@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.3.tgz#502d6e354fc847d4169c3bc5f189de777f68cfe1" + integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + +"@radix-ui/react-tabs@^1.1.13": + version "1.1.13" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz#3537ce379d7e7ff4eeb6b67a0973e139c2ac1f15" + integrity sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A== + dependencies: + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-presence" "1.1.5" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-roving-focus" "1.1.11" + "@radix-ui/react-use-controllable-state" "1.2.2" + +"@radix-ui/react-use-callback-ref@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz#62a4dba8b3255fdc5cc7787faeac1c6e4cc58d40" + integrity sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg== + +"@radix-ui/react-use-controllable-state@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz#905793405de57d61a439f4afebbb17d0645f3190" + integrity sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg== + dependencies: + "@radix-ui/react-use-effect-event" "0.0.2" + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-use-effect-event@0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz#090cf30d00a4c7632a15548512e9152217593907" + integrity sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-use-layout-effect@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e" + integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ== + "@remix-run/router@1.23.2": version "1.23.2" resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.23.2.tgz#156c4b481c0bee22a19f7924728a67120de06971" @@ -2724,7 +2760,7 @@ dependencies: "@types/express" "*" -"@types/prop-types@^15.7.12", "@types/prop-types@^15.7.15": +"@types/prop-types@^15.7.12": version "15.7.15" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== @@ -2751,7 +2787,7 @@ dependencies: "@types/react" "*" -"@types/react-transition-group@^4.4.10", "@types/react-transition-group@^4.4.12": +"@types/react-transition-group@^4.4.10": version "4.4.12" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.12.tgz#b5d76568485b02a307238270bfe96cb51ee2a044" integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== @@ -4229,7 +4265,7 @@ compression@^1.7.0: safe-buffer "5.2.1" vary "~1.1.2" -compute-scroll-into-view@^3.0.2: +compute-scroll-into-view@^3.0.2, compute-scroll-into-view@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz#02c3386ec531fb6a9881967388e53e8564f3e9aa" integrity sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw== @@ -4490,7 +4526,7 @@ cssstyle@^4.2.1: "@asamuzakjp/css-color" "^3.2.0" rrweb-cssom "^0.8.0" -csstype@^3.0.2, csstype@^3.1.3, csstype@^3.2.2, csstype@^3.2.3: +csstype@^3.0.2, csstype@^3.1.3, csstype@^3.2.2: version "3.2.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== @@ -4818,6 +4854,17 @@ dot-prop@^5.2.0: dependencies: is-obj "^2.0.0" +downshift@^9.0.13: + version "9.0.13" + resolved "https://registry.yarnpkg.com/downshift/-/downshift-9.0.13.tgz#ed561bb8b57c16bbf5f84064a312b4bf9c4a8150" + integrity sha512-fPV+K5jwEzfEAhNhprgCmpWQ23MKwKNzdbtK0QQFiw4hbFcKhMeGB+ccorfWJzmsLR5Dty+CmLDduWlIs74G/w== + dependencies: + "@babel/runtime" "^7.24.5" + compute-scroll-into-view "^3.1.0" + prop-types "^15.8.1" + react-is "18.2.0" + tslib "^2.6.2" + dunder-proto@^1.0.0, dunder-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" @@ -10383,6 +10430,11 @@ react-helmet-async@^1.3.0: react-fast-compare "^3.2.0" shallowequal "^1.1.0" +react-is@18.2.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" + integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -10398,7 +10450,7 @@ react-is@^18.0.0, react-is@^18.3.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -react-is@^19.0.0, react-is@^19.2.3: +react-is@^19.0.0: version "19.2.3" resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.3.tgz#eec2feb69c7fb31f77d0b5c08c10ae1c88886b29" integrity sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA== @@ -12112,7 +12164,7 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.6.2, tslib@^2.8.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== From d3be20b51ed1fb1d088339b7bdb2d322178345d9 Mon Sep 17 00:00:00 2001 From: Bobby Powers Date: Wed, 28 Jan 2026 06:40:09 -0800 Subject: [PATCH 2/6] diagram: address code review feedback - SpeedDial: Close dial on action click by injecting _onActionClick callback from parent to children via cloneElement. Add 'actionClick' to CloseReason type. Editor.tsx now handles this to close the dial while keeping the selected tool. - SpeedDial: Fix type safety by changing onClick to accept HTMLButtonElement instead of HTMLDivElement, removing 'as any' casts. - Tabs: Replace _tabValue cloneElement hack with React Context for passing tab index from parent to child. - TextField: Add id/htmlFor connection between labels and inputs for accessibility. Generate unique IDs when not provided. - TextField: Fix border width layout shift by using box-shadow for the thicker focus/error indicator instead of changing border-width. - Drawer: Add role="dialog", aria-modal="true", and tabIndex for accessibility. Implement focus trap (Tab cycles within panel when open) and focus management (save/restore focus on open/close). --- src/diagram/Editor.tsx | 9 +++- src/diagram/components/Drawer.tsx | 50 +++++++++++++++++- src/diagram/components/SpeedDial.tsx | 58 +++++++++++++-------- src/diagram/components/Tabs.tsx | 31 +++++------ src/diagram/components/TextField.module.css | 18 ++++--- src/diagram/components/TextField.tsx | 30 +++++++++-- 6 files changed, 146 insertions(+), 50 deletions(-) diff --git a/src/diagram/Editor.tsx b/src/diagram/Editor.tsx index 143c323e6..82c589040 100644 --- a/src/diagram/Editor.tsx +++ b/src/diagram/Editor.tsx @@ -408,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, diff --git a/src/diagram/components/Drawer.tsx b/src/diagram/components/Drawer.tsx index dddebb058..603ed5dff 100644 --- a/src/diagram/components/Drawer.tsx +++ b/src/diagram/components/Drawer.tsx @@ -17,10 +17,27 @@ interface DrawerProps { } 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); } @@ -29,6 +46,30 @@ export default class Drawer extends React.PureComponent { 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( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + + 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 = () => { @@ -43,8 +84,15 @@ export default class Drawer extends React.PureComponent {