diff --git a/src/app/App.tsx b/src/app/App.tsx index be144e2d..c130ca95 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -14,8 +14,6 @@ import { } from '@firebase/auth'; import { useLocation, Route, RouteComponentProps, Switch, Redirect } from 'wouter'; -import { createTheme, ThemeProvider, StyledEngineProvider } from '@mui/material/styles'; -import CssBaseline from '@mui/material/CssBaseline'; import { defined } from '@system-dynamics/core/common'; import { HostedWebEditor } from '@system-dynamics/diagram/HostedWebEditor'; @@ -40,13 +38,6 @@ interface EditorMatchParams { readonly [paramName: string | number]: string | undefined; } -const theme = createTheme({ - palette: { - /* primary: purple, - * secondary: green, */ - }, -}); - class UserInfoSingleton { private resultPromise?: Promise<[User | undefined, number]>; private result?: [User | undefined, number]; @@ -253,7 +244,6 @@ class InnerApp extends React.PureComponent<{}, AppState> { return ( -
@@ -270,11 +260,7 @@ export class App extends React.PureComponent { render(): React.JSX.Element { return ( - - - - - + ); } diff --git a/src/app/Home.module.css b/src/app/Home.module.css index 7753730f..99c1476a 100644 --- a/src/app/Home.module.css +++ b/src/app/Home.module.css @@ -66,3 +66,10 @@ .newProjectForm { } + +.centeredFlex { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; +} diff --git a/src/app/Home.tsx b/src/app/Home.tsx index c0e20ac4..b54263d2 100644 --- a/src/app/Home.tsx +++ b/src/app/Home.tsx @@ -6,28 +6,29 @@ import * as React from 'react'; import { Link } from 'wouter'; import clsx from 'clsx'; -import AppBar from '@mui/material/AppBar'; -import Button from '@mui/material/Button'; -import Grid from '@mui/material/Grid'; -import ImageList from '@mui/material/ImageList'; -import ImageListItem from '@mui/material/ImageListItem'; -import IconButton from '@mui/material/IconButton'; -import Menu from '@mui/material/Menu'; -import MenuItem from '@mui/material/MenuItem'; -import Paper from '@mui/material/Paper'; -import Toolbar from '@mui/material/Toolbar'; -import Typography from '@mui/material/Typography'; -import Avatar from '@mui/material/Avatar'; import { List } from 'immutable'; -import { PopoverOrigin } from '@mui/material/Popover'; -import AccountCircle from '@mui/icons-material/AccountCircle'; -import MenuIcon from '@mui/icons-material/Menu'; + +import { + AppBar, + Button, + ImageList, + ImageListItem, + IconButton, + Menu, + MenuItem, + Paper, + Toolbar, + Avatar, + AccountCircleIcon, + MenuIcon, +} from '@system-dynamics/diagram'; import { NewProject } from './NewProject'; import { Project } from './Project'; import { User } from './User'; import styles from './Home.module.css'; +import typography from './typography.module.css'; interface HomeState { anchorEl?: HTMLElement; @@ -40,9 +41,9 @@ interface HomeProps { onNewProjectDone?: () => void; } -const AnchorOrigin: PopoverOrigin = { - vertical: 'bottom', - horizontal: 'right', +const AnchorOrigin = { + vertical: 'bottom' as const, + horizontal: 'right' as const, }; class Home extends React.Component { @@ -97,11 +98,11 @@ class Home extends React.Component { newProjectForm() { return (
- - +
+
- - +
+
); } @@ -118,10 +119,8 @@ class Home extends React.Component {
model preview
- - {project.displayName} - - {project.description}  +

{project.displayName}

+

{project.description} 

@@ -139,7 +138,7 @@ class Home extends React.Component { const account = photoUrl ? ( ) : ( - + ); const content = this.props.isNewProject ? this.newProjectForm() : this.projects(); @@ -151,15 +150,11 @@ class Home extends React.Component { - +
Simlin - {/* */} - {/**/} - {/* System Dynamics*/} - {/**/} - +
@@ -300,12 +307,14 @@ export class Login extends React.Component { break; case 'showSignup': loginUI = ( - + - - Create account - +
Create account
{ />
- - - - - - Publicly accessible - - - +
+
+ } + label="Publicly accessible" + /> +
+
@@ -248,14 +251,14 @@ export class NewProject extends React.Component

- +

{warningText || '\xa0'} - - +

+

- +

); } diff --git a/src/app/NewUser.tsx b/src/app/NewUser.tsx index ad0db666..4c4f5084 100644 --- a/src/app/NewUser.tsx +++ b/src/app/NewUser.tsx @@ -4,15 +4,17 @@ import * as React from 'react'; -import Button from '@mui/material/Button'; -import Dialog from '@mui/material/Dialog'; -import DialogActions from '@mui/material/DialogActions'; -import DialogContent from '@mui/material/DialogContent'; -import DialogContentText from '@mui/material/DialogContentText'; -import DialogTitle from '@mui/material/DialogTitle'; -import TextField from '@mui/material/TextField'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import Checkbox from '@mui/material/Checkbox'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + TextField, + FormControlLabel, + Checkbox, +} from '@system-dynamics/diagram'; import { User } from './User'; @@ -44,9 +46,9 @@ export class NewUser extends React.Component { }); }; - handleAgreedToTerms = (): void => { + handleAgreedToTerms = (checked: boolean): void => { this.setState({ - agreedToTerms: !this.state.agreedToTerms, + agreedToTerms: checked, }); }; diff --git a/src/app/index-component.tsx b/src/app/index-component.tsx index 1b39b356..80aee720 100644 --- a/src/app/index-component.tsx +++ b/src/app/index-component.tsx @@ -5,13 +5,9 @@ import * as React from 'react'; import { createRoot } from 'react-dom/client'; -import { createTheme, ThemeProvider } from '@mui/material/styles'; - import { baseURL } from '@system-dynamics/core/common'; import { HostedWebEditor } from '@system-dynamics/diagram/HostedWebEditor'; -const theme = createTheme({}); - // try to get the base URL from the src attribute of the current script // (so that e.g. localhost:3000 works for testing), but fall back to baseURL // from common if that doesn't work. @@ -37,9 +33,7 @@ class SDModel extends HTMLElement {
- - - +
, ); } diff --git a/src/app/package.json b/src/app/package.json index dfab7963..4fbaa2e9 100644 --- a/src/app/package.json +++ b/src/app/package.json @@ -7,10 +7,6 @@ "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/app/typography.module.css b/src/app/typography.module.css new file mode 100644 index 00000000..a9c44c57 --- /dev/null +++ b/src/app/typography.module.css @@ -0,0 +1,52 @@ +.heading2 { + font-size: 3.75rem; + font-weight: 300; + line-height: 1.2; + letter-spacing: -0.00833em; +} + +.heading5 { + font-size: 1.5rem; + font-weight: 400; + line-height: 1.334; + letter-spacing: 0em; +} + +.heading6 { + font-size: 1.25rem; + font-weight: 500; + line-height: 1.6; + letter-spacing: 0.0075em; +} + +.subtitle1 { + font-size: 1rem; + font-weight: 400; + line-height: 1.75; + letter-spacing: 0.00938em; +} + +.subtitle2 { + font-size: 0.875rem; + font-weight: 500; + line-height: 1.57; + letter-spacing: 0.00714em; +} + +.body2 { + font-size: 0.875rem; + line-height: 1.43; + letter-spacing: 0.01071em; +} + +.textRight { + text-align: right; +} + +.textCenter { + text-align: center; +} + +.colorInherit { + color: inherit; +} diff --git a/src/diagram/ErrorDetails.tsx b/src/diagram/ErrorDetails.tsx index 0e02a532..f225a99a 100644 --- a/src/diagram/ErrorDetails.tsx +++ b/src/diagram/ErrorDetails.tsx @@ -30,9 +30,7 @@ export class ErrorDetails extends React.PureComponent { !modelErrors.isEmpty() ) ) { - errors.push( -
simulation error: {errorCodeDescription(simError.code)}
, - ); + errors.push(
simulation error: {errorCodeDescription(simError.code)}
); } if (!modelErrors.isEmpty()) { for (const err of modelErrors) { diff --git a/src/diagram/LineChart.tsx b/src/diagram/LineChart.tsx index 647241ef..1e9b08cf 100644 --- a/src/diagram/LineChart.tsx +++ b/src/diagram/LineChart.tsx @@ -128,7 +128,11 @@ export class LineChart extends React.PureComponent, xScale: (v: number) => number, yScale: (v: number) => number): string { + private buildPath( + points: ReadonlyArray<{ x: number; y: number }>, + xScale: (v: number) => number, + yScale: (v: number) => number, + ): string { const parts: string[] = []; let started = false; for (const p of points) { @@ -320,13 +324,7 @@ export class LineChart extends React.PureComponent - + {formatTickLabel(tick)} @@ -348,22 +346,8 @@ export class LineChart extends React.PureComponent - - + + {formatTickLabel(tick)} @@ -372,7 +356,11 @@ export class LineChart extends React.PureComponent {/* Series lines */} - + {series.map((s, i) => ( {formatTickLabel(tooltip.dataX)} {tooltip.seriesValues.map((sv, i) => (
- - {sv.name}: {fmt(sv.value)} + + + {sv.name}: {fmt(sv.value)} +
))} diff --git a/src/diagram/ModelPropertiesDrawer.tsx b/src/diagram/ModelPropertiesDrawer.tsx index 3056c306..a04b0eee 100644 --- a/src/diagram/ModelPropertiesDrawer.tsx +++ b/src/diagram/ModelPropertiesDrawer.tsx @@ -42,11 +42,7 @@ export class ModelPropertiesDrawer extends React.PureComponent +
diff --git a/src/diagram/VariableDetails.tsx b/src/diagram/VariableDetails.tsx index faf54a7e..a6a53b9a 100644 --- a/src/diagram/VariableDetails.tsx +++ b/src/diagram/VariableDetails.tsx @@ -269,9 +269,7 @@ export class VariableDetails extends React.PureComponent = []; if (errors) { errors.forEach((error) => { - errorList.push( -
error: {errorCodeDescription(error.code)}
, - ); + errorList.push(
error: {errorCodeDescription(error.code)}
); }); } if (unitErrors) { @@ -288,12 +286,7 @@ export class VariableDetails extends React.PureComponent + ); } diff --git a/src/diagram/components/Accordion.module.css b/src/diagram/components/Accordion.module.css new file mode 100644 index 00000000..941f1495 --- /dev/null +++ b/src/diagram/components/Accordion.module.css @@ -0,0 +1,97 @@ +.accordion { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + background-color: #fff; +} + +.accordion::before { + display: none; +} + +.item { + border: none; +} + +.header { + margin: 0; +} + +.trigger { + all: unset; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0 16px; + min-height: 48px; + cursor: pointer; + font-size: 0.9375rem; + font-weight: 400; + line-height: 1.5; + transition: min-height 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.trigger:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +.trigger:focus-visible { + outline: 2px solid #1976d2; + outline-offset: -2px; +} + +.trigger[data-disabled] { + opacity: 0.38; + cursor: not-allowed; +} + +.content { + flex: 1; + display: flex; + align-items: center; +} + +.expandIcon { + display: flex; + align-items: center; + color: rgba(0, 0, 0, 0.54); + transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.trigger[data-state='open'] .expandIcon { + transform: rotate(180deg); +} + +.details { + overflow: hidden; +} + +.details[data-state='open'] { + animation: slideDown 200ms cubic-bezier(0.87, 0, 0.13, 1); +} + +.details[data-state='closed'] { + animation: slideUp 200ms cubic-bezier(0.87, 0, 0.13, 1); +} + +@keyframes slideDown { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } +} + +@keyframes slideUp { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } +} + +.detailsInner { + padding: 8px 16px 16px; +} diff --git a/src/diagram/components/Accordion.tsx b/src/diagram/components/Accordion.tsx new file mode 100644 index 00000000..cf8ef43d --- /dev/null +++ b/src/diagram/components/Accordion.tsx @@ -0,0 +1,79 @@ +import * as React from 'react'; +import * as RadixAccordion from '@radix-ui/react-accordion'; +import clsx from 'clsx'; + +import styles from './Accordion.module.css'; + +export interface AccordionProps { + defaultExpanded?: boolean; + expanded?: boolean; + onChange?: (expanded: boolean) => void; + disabled?: boolean; + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export function Accordion(props: AccordionProps): React.ReactElement { + const { defaultExpanded, expanded, onChange, disabled, className, style, children } = props; + + const value = expanded ? 'item' : ''; + const defaultValue = defaultExpanded ? 'item' : undefined; + + return ( + { + if (onChange) { + onChange(newValue === 'item'); + } + }} + disabled={disabled} + className={clsx(styles.accordion, className)} + style={style} + > + + {children} + + + ); +} + +export interface AccordionSummaryProps { + expandIcon?: React.ReactNode; + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export function AccordionSummary(props: AccordionSummaryProps): React.ReactElement { + const { expandIcon, className, style, children } = props; + + return ( + + + {children} + {expandIcon && {expandIcon}} + + + ); +} + +export interface AccordionDetailsProps { + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export function AccordionDetails(props: AccordionDetailsProps): React.ReactElement { + const { className, style, children } = props; + + return ( + +
{children}
+
+ ); +} diff --git a/src/diagram/components/AppBar.module.css b/src/diagram/components/AppBar.module.css new file mode 100644 index 00000000..325663ea --- /dev/null +++ b/src/diagram/components/AppBar.module.css @@ -0,0 +1,31 @@ +.appBar { + display: flex; + flex-direction: column; + width: 100%; + box-sizing: border-box; + flex-shrink: 0; + background-color: #1976d2; + color: #fff; + box-shadow: + 0px 2px 4px -1px rgba(0, 0, 0, 0.2), + 0px 4px 5px 0px rgba(0, 0, 0, 0.14), + 0px 1px 10px 0px rgba(0, 0, 0, 0.12); +} + +.positionFixed { + position: fixed; + z-index: 1100; + top: 0; + left: auto; + right: 0; +} + +.positionStatic { + position: static; +} + +.positionSticky { + position: sticky; + z-index: 1100; + top: 0; +} diff --git a/src/diagram/components/AppBar.tsx b/src/diagram/components/AppBar.tsx new file mode 100644 index 00000000..8814cf79 --- /dev/null +++ b/src/diagram/components/AppBar.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import clsx from 'clsx'; + +import styles from './AppBar.module.css'; + +export interface AppBarProps { + position?: 'fixed' | 'static' | 'sticky'; + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export default function AppBar(props: AppBarProps): React.ReactElement { + const { position = 'static', className, style, children } = props; + + const positionClass = + position === 'fixed' ? styles.positionFixed : position === 'sticky' ? styles.positionSticky : styles.positionStatic; + + return ( +
+ {children} +
+ ); +} diff --git a/src/diagram/components/Autocomplete.tsx b/src/diagram/components/Autocomplete.tsx index e7b675a3..3af224e4 100644 --- a/src/diagram/components/Autocomplete.tsx +++ b/src/diagram/components/Autocomplete.tsx @@ -1,133 +1,127 @@ -// 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)} -
    - ); -} +// 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/Avatar.module.css b/src/diagram/components/Avatar.module.css new file mode 100644 index 00000000..8df9c734 --- /dev/null +++ b/src/diagram/components/Avatar.module.css @@ -0,0 +1,25 @@ +.avatar { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + font-size: 1.25rem; + line-height: 1; + border-radius: 50%; + overflow: hidden; + user-select: none; + background-color: #bdbdbd; + color: #fafafa; +} + +.image { + width: 100%; + height: 100%; + text-align: center; + object-fit: cover; + color: transparent; + text-indent: 10000px; +} diff --git a/src/diagram/components/Avatar.tsx b/src/diagram/components/Avatar.tsx new file mode 100644 index 00000000..efc39b1a --- /dev/null +++ b/src/diagram/components/Avatar.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import clsx from 'clsx'; + +import styles from './Avatar.module.css'; + +export interface AvatarProps { + src?: string; + alt?: string; + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export default function Avatar(props: AvatarProps): React.ReactElement { + const { src, alt, className, style, children } = props; + + return ( +
      + {src ? {alt : children} +
      + ); +} diff --git a/src/diagram/components/Button.module.css b/src/diagram/components/Button.module.css index 4003ecf4..edaea2c4 100644 --- a/src/diagram/components/Button.module.css +++ b/src/diagram/components/Button.module.css @@ -99,6 +99,58 @@ pointer-events: none; } +/* outlined + primary */ +.outlinedPrimary { + color: var(--color-primary); + border: 1px solid rgba(25, 118, 210, 0.5); + background: transparent; +} + +.outlinedPrimary:hover { + border-color: var(--color-primary); + background-color: rgba(25, 118, 210, 0.04); +} + +/* outlined + secondary */ +.outlinedSecondary { + color: var(--color-secondary); + border: 1px solid rgba(220, 0, 78, 0.5); + background: transparent; +} + +.outlinedSecondary:hover { + border-color: var(--color-secondary); + background-color: rgba(220, 0, 78, 0.04); +} + +/* outlined + inherit */ +.outlinedInherit { + color: inherit; + border: 1px solid rgba(0, 0, 0, 0.23); + background: transparent; +} + +.outlinedInherit:hover { + border-color: rgba(0, 0, 0, 0.87); + background-color: rgba(0, 0, 0, 0.04); +} + +/* text + inherit */ +.textInherit { + color: inherit; + background: transparent; +} + +.textInherit:hover { + background-color: rgba(0, 0, 0, 0.04); +} + +.disabledOutlined { + color: rgba(0, 0, 0, 0.26); + border-color: rgba(0, 0, 0, 0.12); + pointer-events: none; +} + .startIcon { display: inherit; margin-right: 8px; diff --git a/src/diagram/components/Button.tsx b/src/diagram/components/Button.tsx index 7eb0a4d9..097681f7 100644 --- a/src/diagram/components/Button.tsx +++ b/src/diagram/components/Button.tsx @@ -1,50 +1,95 @@ -// 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 ( - - ); - } -} +// 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' | 'outlined'; + color?: 'primary' | 'secondary' | 'inherit'; + size?: 'small' | 'medium' | 'large'; + disabled?: boolean; + onClick?: (event: React.MouseEvent) => void; + className?: string; + style?: React.CSSProperties; + startIcon?: React.ReactNode; + children?: React.ReactNode; + type?: 'button' | 'submit' | 'reset'; + component?: 'button' | 'label'; + 'aria-label'?: string; + 'aria-owns'?: string; + 'aria-haspopup'?: boolean | 'true' | 'false'; +} + +export default class Button extends React.PureComponent { + render() { + const { + variant = 'text', + color = 'primary', + size = 'medium', + disabled, + onClick, + className, + style, + startIcon, + children, + type = 'button', + component = 'button', + 'aria-label': ariaLabel, + 'aria-owns': ariaOwns, + 'aria-haspopup': ariaHaspopup, + } = 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 if (variant === 'outlined') { + variantColorClass = + color === 'secondary' + ? styles.outlinedSecondary + : color === 'inherit' + ? styles.outlinedInherit + : styles.outlinedPrimary; + disabledClass = disabled ? styles.disabledOutlined : undefined; + } else { + variantColorClass = + color === 'secondary' ? styles.textSecondary : color === 'inherit' ? styles.textInherit : styles.textPrimary; + disabledClass = disabled ? styles.disabledText : undefined; + } + + const buttonClassName = clsx(styles.button, sizeClass, variantColorClass, disabledClass, className); + + if (component === 'label') { + return ( + + ); + } + + return ( + + ); + } +} diff --git a/src/diagram/components/Card.module.css b/src/diagram/components/Card.module.css new file mode 100644 index 00000000..e979882b --- /dev/null +++ b/src/diagram/components/Card.module.css @@ -0,0 +1,31 @@ +.card { + background-color: #fff; + color: rgba(0, 0, 0, 0.87); + border-radius: 4px; + overflow: hidden; +} + +.outlined { + border: 1px solid rgba(0, 0, 0, 0.12); +} + +.elevation { + 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); +} + +.cardContent { + padding: 16px; +} + +.cardContent:last-child { + padding-bottom: 24px; +} + +.cardActions { + display: flex; + align-items: center; + padding: 8px; +} diff --git a/src/diagram/components/Card.tsx b/src/diagram/components/Card.tsx new file mode 100644 index 00000000..10bcf12a --- /dev/null +++ b/src/diagram/components/Card.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import clsx from 'clsx'; + +import styles from './Card.module.css'; + +export interface CardProps { + variant?: 'outlined' | 'elevation'; + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export default function Card(props: CardProps): React.ReactElement { + const { variant = 'elevation', className, style, children } = props; + + return ( +
      + {children} +
      + ); +} + +export interface CardContentProps { + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export function CardContent(props: CardContentProps): React.ReactElement { + const { className, style, children } = props; + + return ( +
      + {children} +
      + ); +} + +export interface CardActionsProps { + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export function CardActions(props: CardActionsProps): React.ReactElement { + const { className, style, children } = props; + + return ( +
      + {children} +
      + ); +} diff --git a/src/diagram/components/Checkbox.module.css b/src/diagram/components/Checkbox.module.css new file mode 100644 index 00000000..1682a94f --- /dev/null +++ b/src/diagram/components/Checkbox.module.css @@ -0,0 +1,46 @@ +.checkbox { + all: unset; + width: 18px; + height: 18px; + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid rgba(0, 0, 0, 0.54); + background-color: transparent; + cursor: pointer; +} + +.checkbox:disabled { + cursor: not-allowed; + opacity: 0.38; +} + +.checkbox:focus-visible { + outline: 2px solid #1976d2; + outline-offset: 2px; +} + +.checkbox[data-state='checked'] { + border-color: transparent; +} + +.primary[data-state='checked'] { + background-color: #1976d2; +} + +.secondary[data-state='checked'] { + background-color: #dc004e; +} + +.indicator { + display: flex; + align-items: center; + justify-content: center; + color: #fff; +} + +.checkIcon { + width: 14px; + height: 14px; +} diff --git a/src/diagram/components/Checkbox.tsx b/src/diagram/components/Checkbox.tsx new file mode 100644 index 00000000..05c68445 --- /dev/null +++ b/src/diagram/components/Checkbox.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import * as RadixCheckbox from '@radix-ui/react-checkbox'; +import clsx from 'clsx'; + +import styles from './Checkbox.module.css'; +import { CheckIcon } from './icons'; + +export interface CheckboxProps { + checked?: boolean; + defaultChecked?: boolean; + onChange?: (checked: boolean) => void; + disabled?: boolean; + name?: string; + color?: 'primary' | 'secondary'; + className?: string; + style?: React.CSSProperties; +} + +export default function Checkbox(props: CheckboxProps): React.ReactElement { + const { checked, defaultChecked, onChange, disabled, name, color = 'primary', className, style } = props; + + return ( + + + + + + ); +} diff --git a/src/diagram/components/Dialog.module.css b/src/diagram/components/Dialog.module.css new file mode 100644 index 00000000..1b0f84ec --- /dev/null +++ b/src/diagram/components/Dialog.module.css @@ -0,0 +1,80 @@ +.overlay { + background-color: rgba(0, 0, 0, 0.5); + position: fixed; + inset: 0; + z-index: 1200; + animation: fadeIn 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.content { + background-color: #fff; + border-radius: 4px; + box-shadow: + 0px 11px 15px -7px rgba(0, 0, 0, 0.2), + 0px 24px 38px 3px rgba(0, 0, 0, 0.14), + 0px 9px 46px 8px rgba(0, 0, 0, 0.12); + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 90vw; + max-width: 600px; + max-height: 85vh; + overflow-y: auto; + z-index: 1300; + animation: contentShow 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes contentShow { + from { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +.content:focus { + outline: none; +} + +.title { + margin: 0; + padding: 16px 24px; + font-size: 1.25rem; + font-weight: 500; + line-height: 1.6; + color: rgba(0, 0, 0, 0.87); +} + +.dialogContent { + padding: 8px 24px; + flex: 1 1 auto; + overflow-y: auto; +} + +.contentText { + margin: 0; + color: rgba(0, 0, 0, 0.6); + font-size: 1rem; + line-height: 1.5; +} + +.actions { + display: flex; + align-items: center; + padding: 8px; + justify-content: flex-end; + gap: 8px; +} diff --git a/src/diagram/components/Dialog.tsx b/src/diagram/components/Dialog.tsx new file mode 100644 index 00000000..7dface68 --- /dev/null +++ b/src/diagram/components/Dialog.tsx @@ -0,0 +1,112 @@ +import * as React from 'react'; +import * as RadixDialog from '@radix-ui/react-dialog'; +import clsx from 'clsx'; + +import styles from './Dialog.module.css'; + +export interface DialogProps { + open: boolean; + onClose?: () => void; + disableEscapeKeyDown?: boolean; + 'aria-labelledby'?: string; + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export function Dialog(props: DialogProps): React.ReactElement { + const { open, onClose, disableEscapeKeyDown, className, style, children } = props; + const ariaLabelledBy = props['aria-labelledby']; + + return ( + { + if (!isOpen && onClose) { + onClose(); + } + }} + > + + + { + if (disableEscapeKeyDown) { + event.preventDefault(); + } + }} + > + {children} + + + + ); +} + +export interface DialogTitleProps { + id?: string; + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export function DialogTitle(props: DialogTitleProps): React.ReactElement { + const { id, className, style, children } = props; + + return ( + + {children} + + ); +} + +export interface DialogContentProps { + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export function DialogContent(props: DialogContentProps): React.ReactElement { + const { className, style, children } = props; + + return ( +
      + {children} +
      + ); +} + +export interface DialogContentTextProps { + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export function DialogContentText(props: DialogContentTextProps): React.ReactElement { + const { className, style, children } = props; + + return ( +

      + {children} +

      + ); +} + +export interface DialogActionsProps { + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export function DialogActions(props: DialogActionsProps): React.ReactElement { + const { className, style, children } = props; + + return ( +
      + {children} +
      + ); +} diff --git a/src/diagram/components/Drawer.tsx b/src/diagram/components/Drawer.tsx index 823798c9..4df9ca92 100644 --- a/src/diagram/components/Drawer.tsx +++ b/src/diagram/components/Drawer.tsx @@ -1,103 +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 = ( - <> -