diff --git a/tgui/packages/tgui_ch/assets.js b/tgui/packages/tgui_ch/assets.js deleted file mode 100644 index f41181e078..0000000000 --- a/tgui/packages/tgui_ch/assets.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -const EXCLUDED_PATTERNS = [/v4shim/i]; -const loadedMappings = {}; - -export const resolveAsset = (name) => loadedMappings[name] || name; - -export const assetMiddleware = (store) => (next) => (action) => { - const { type, payload } = action; - if (type === 'asset/stylesheet') { - Byond.loadCss(payload); - return; - } - if (type === 'asset/mappings') { - for (let name of Object.keys(payload)) { - // Skip anything that matches excluded patterns - if (EXCLUDED_PATTERNS.some((regex) => regex.test(name))) { - continue; - } - const url = payload[name]; - const ext = name.split('.').pop(); - loadedMappings[name] = url; - if (ext === 'css') { - Byond.loadCss(url); - } - if (ext === 'js') { - Byond.loadJs(url); - } - } - return; - } - next(action); -}; diff --git a/tgui/packages/tgui_ch/assets.ts b/tgui/packages/tgui_ch/assets.ts new file mode 100644 index 0000000000..e519d0c2f7 --- /dev/null +++ b/tgui/packages/tgui_ch/assets.ts @@ -0,0 +1,45 @@ +/** + * @file + * @copyright 2020 Aleksej Komarov + * @license MIT + */ + +import { Action, AnyAction, Middleware } from '../common/redux'; + +import { Dispatch } from 'common/redux'; + +const EXCLUDED_PATTERNS = [/v4shim/i]; +const loadedMappings: Record = {}; + +export const resolveAsset = (name: string): string => + loadedMappings[name] || name; + +export const assetMiddleware: Middleware = + (storeApi) => + (next: Dispatch) => + (action: ActionType) => { + const { type, payload } = action as AnyAction; + if (type === 'asset/stylesheet') { + Byond.loadCss(payload); + return; + } + if (type === 'asset/mappings') { + for (const name of Object.keys(payload)) { + // Skip anything that matches excluded patterns + if (EXCLUDED_PATTERNS.some((regex) => regex.test(name))) { + continue; + } + const url = payload[name]; + const ext = name.split('.').pop(); + loadedMappings[name] = url; + if (ext === 'css') { + Byond.loadCss(url); + } + if (ext === 'js') { + Byond.loadJs(url); + } + } + return; + } + next(action); + }; diff --git a/tgui/packages/tgui_ch/backend.ts b/tgui/packages/tgui_ch/backend.ts index e579f769a7..fb77a61e3c 100644 --- a/tgui/packages/tgui_ch/backend.ts +++ b/tgui/packages/tgui_ch/backend.ts @@ -14,6 +14,7 @@ import { perf } from 'common/perf'; import { createAction } from 'common/redux'; import { setupDrag } from './drag'; +import { globalEvents } from './events'; import { focusMap } from './focus'; import { createLogger } from './logging'; import { resumeRenderer, suspendRenderer } from './renderer'; @@ -133,10 +134,18 @@ export const backendMiddleware = (store) => { } if (type === 'ping') { - Byond.sendMessage('pingReply'); + Byond.sendMessage('ping/reply'); return; } + if (type === 'byond/mousedown') { + globalEvents.emit('byond/mousedown'); + } + + if (type === 'byond/mouseup') { + globalEvents.emit('byond/mouseup'); + } + if (type === 'backend/suspendStart' && !suspendInterval) { logger.log(`suspending (${Byond.windowId})`); // Keep sending suspend messages until it succeeds. @@ -213,8 +222,10 @@ export const backendMiddleware = (store) => { */ export const sendAct = (action: string, payload: object = {}) => { // Validate that payload is an object - const isObject = - typeof payload === 'object' && payload !== null && !Array.isArray(payload); + // prettier-ignore + const isObject = typeof payload === 'object' + && payload !== null + && !Array.isArray(payload); if (!isObject) { logger.error(`Payload for act() must be an object, got this:`, payload); return; @@ -339,13 +350,15 @@ export const useSharedState = ( return [ sharedState, (nextState) => { + // prettier-ignore Byond.sendMessage({ type: 'setSharedState', key, - value: - JSON.stringify( - typeof nextState === 'function' ? nextState(sharedState) : nextState - ) || '', + value: JSON.stringify( + typeof nextState === 'function' + ? nextState(sharedState) + : nextState + ) || '', }); }, ]; diff --git a/tgui/packages/tgui_ch/components/AnimatedNumber.js b/tgui/packages/tgui_ch/components/AnimatedNumber.js deleted file mode 100644 index a763becfd4..0000000000 --- a/tgui/packages/tgui_ch/components/AnimatedNumber.js +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { clamp, toFixed } from 'common/math'; -import { Component } from 'inferno'; - -const FPS = 20; -const Q = 0.5; - -const isSafeNumber = (value) => { - return ( - typeof value === 'number' && Number.isFinite(value) && !Number.isNaN(value) - ); -}; - -export class AnimatedNumber extends Component { - constructor(props) { - super(props); - this.timer = null; - this.state = { - value: 0, - }; - // Use provided initial state - if (isSafeNumber(props.initial)) { - this.state.value = props.initial; - } - // Set initial state with value provided in props - else if (isSafeNumber(props.value)) { - this.state.value = Number(props.value); - } - } - - tick() { - const { props, state } = this; - const currentValue = Number(state.value); - const targetValue = Number(props.value); - // Avoid poisoning our state with infinities and NaN - if (!isSafeNumber(targetValue)) { - return; - } - // Smooth the value using an exponential moving average - const value = currentValue * Q + targetValue * (1 - Q); - this.setState({ value }); - } - - componentDidMount() { - this.timer = setInterval(() => this.tick(), 1000 / FPS); - } - - componentWillUnmount() { - clearTimeout(this.timer); - } - - render() { - const { props, state } = this; - const { format, children } = props; - const currentValue = state.value; - const targetValue = props.value; - // Directly display values which can't be animated - if (!isSafeNumber(targetValue)) { - return targetValue || null; - } - let formattedValue; - // Use custom formatter - if (format) { - formattedValue = format(currentValue); - } - // Fix our animated precision at target value's precision. - else { - const fraction = String(targetValue).split('.')[1]; - const precision = fraction ? fraction.length : 0; - formattedValue = toFixed(currentValue, clamp(precision, 0, 8)); - } - // Use a custom render function - if (typeof children === 'function') { - return children(formattedValue, currentValue); - } - return formattedValue; - } -} diff --git a/tgui/packages/tgui_ch/components/AnimatedNumber.tsx b/tgui/packages/tgui_ch/components/AnimatedNumber.tsx new file mode 100644 index 0000000000..53bdba90ab --- /dev/null +++ b/tgui/packages/tgui_ch/components/AnimatedNumber.tsx @@ -0,0 +1,189 @@ +/** + * @file + * @copyright 2020 Aleksej Komarov + * @license MIT + */ + +import { clamp, toFixed } from 'common/math'; +import { Component, createRef } from 'inferno'; + +const isSafeNumber = (value: number) => { + // prettier-ignore + return typeof value === 'number' + && Number.isFinite(value) + && !Number.isNaN(value); +}; + +export type AnimatedNumberProps = { + /** + * The target value to approach. + */ + value: number; + + /** + * If provided, the initial value displayed. By default, the same as `value`. + * If `initial` and `value` are different, the component immediately starts + * animating. + */ + initial?: number; + + /** + * If provided, a function that formats the inner string. By default, + * attempts to match the numeric precision of `value`. + */ + format?: (value: number) => string; +}; + +/** + * Animated numbers are animated at roughly 60 frames per second. + */ +const SIXTY_HZ = 1_000.0 / 60.0; + +/** + * The exponential moving average coefficient. Larger values result in a faster + * convergence. + */ +const Q = 0.8333; + +/** + * A small number. + */ +const EPSILON = 10e-4; + +/** + * An animated number label. Shows a number, formatted with an optionally + * provided function, and animates it towards its target value. + */ +export class AnimatedNumber extends Component { + /** + * The inner `` being updated sixty times per second. + */ + ref = createRef(); + + /** + * The interval being used to update the inner span. + */ + interval?: NodeJS.Timeout; + + /** + * The current value. This values approaches the target value. + */ + currentValue: number = 0; + + constructor(props: AnimatedNumberProps) { + super(props); + + const { initial, value } = props; + + if (initial !== undefined && isSafeNumber(initial)) { + this.currentValue = initial; + } else if (isSafeNumber(value)) { + this.currentValue = value; + } + } + + componentDidMount() { + if (this.currentValue !== this.props.value) { + this.startTicking(); + } + } + + componentWillUnmount() { + // Stop animating when the component is unmounted. + this.stopTicking(); + } + + shouldComponentUpdate(newProps: AnimatedNumberProps) { + if (newProps.value !== this.props.value) { + // The target value has been adjusted; start animating if we aren't + // already. + this.startTicking(); + } + + // We render the inner `span` directly using a ref to bypass inferno diffing + // and reach 60 frames per second--tell inferno not to re-render this tree. + return false; + } + + /** + * Starts animating the inner span. If the inner span is already animating, + * this is a no-op. + */ + startTicking() { + if (this.interval !== undefined) { + // We're already ticking; do nothing. + return; + } + + this.interval = setInterval(() => this.tick(), SIXTY_HZ); + } + + /** + * Stops animating the inner span. + */ + stopTicking() { + if (this.interval === undefined) { + // We're not ticking; do nothing. + return; + } + + clearInterval(this.interval); + + this.interval = undefined; + } + + /** + * Steps forward one frame. + */ + tick() { + const { currentValue } = this; + const { value } = this.props; + + if (isSafeNumber(value)) { + // Converge towards the value. + this.currentValue = currentValue * Q + value * (1 - Q); + } else { + // If the value is unsafe, we're never going to converge, so stop ticking. + this.stopTicking(); + } + + if ( + Math.abs(value - this.currentValue) < Math.max(EPSILON, EPSILON * value) + ) { + // We're about as close as we're going to get--snap to the value and + // stop ticking. + this.currentValue = value; + this.stopTicking(); + } + + if (this.ref.current) { + // Directly update the inner span, without bothering inferno. + this.ref.current.textContent = this.getText(); + } + } + + /** + * Gets the inner text of the span. + */ + getText() { + const { props, currentValue } = this; + const { format, value } = props; + + if (!isSafeNumber(value)) { + return String(value); + } + + if (format) { + return format(this.currentValue); + } + + const fraction = String(value).split('.')[1]; + const precision = fraction ? fraction.length : 0; + + return toFixed(currentValue, clamp(precision, 0, 8)); + } + + render() { + return {this.getText()}; + } +} diff --git a/tgui/packages/tgui_ch/components/BodyZoneSelector.tsx b/tgui/packages/tgui_ch/components/BodyZoneSelector.tsx new file mode 100644 index 0000000000..cf8dc430e2 --- /dev/null +++ b/tgui/packages/tgui_ch/components/BodyZoneSelector.tsx @@ -0,0 +1,153 @@ +import { Component, createRef } from 'inferno'; +import { resolveAsset } from '../assets'; +import { Box } from './Box'; + +export enum BodyZone { + Head = 'head', + Chest = 'chest', + LeftArm = 'l_arm', + RightArm = 'r_arm', + LeftLeg = 'l_leg', + RightLeg = 'r_leg', + Eyes = 'eyes', + Mouth = 'mouth', + Groin = 'groin', +} + +const bodyZonePixelToZone = (x: number, y: number): BodyZone | null => { + // TypeScript translation of /atom/movable/screen/zone_sel/proc/get_zone_at + if (y < 1) { + return null; + } else if (y < 10) { + if (x > 10 && x < 15) { + return BodyZone.RightLeg; + } else if (x > 17 && x < 22) { + return BodyZone.LeftLeg; + } + } else if (y < 13) { + if (x > 8 && x < 11) { + return BodyZone.RightArm; + } else if (x > 12 && x < 20) { + return BodyZone.Groin; + } else if (x > 21 && x < 24) { + return BodyZone.LeftArm; + } + } else if (y < 22) { + if (x > 8 && x < 11) { + return BodyZone.RightArm; + } else if (x > 12 && x < 20) { + return BodyZone.Chest; + } else if (x > 21 && x < 24) { + return BodyZone.LeftArm; + } + } else if (y < 30 && x > 12 && x < 20) { + if (y > 23 && y < 24 && x > 15 && x < 17) { + return BodyZone.Mouth; + } else if (y > 25 && y < 27 && x > 14 && x < 18) { + return BodyZone.Eyes; + } else { + return BodyZone.Head; + } + } + + return null; +}; + +type BodyZoneSelectorProps = { + onClick?: (zone: BodyZone) => void; + scale?: number; + selectedZone: BodyZone | null; + theme?: string; +}; + +type BodyZoneSelectorState = { + hoverZone: BodyZone | null; +}; + +export class BodyZoneSelector extends Component< + BodyZoneSelectorProps, + BodyZoneSelectorState +> { + ref = createRef(); + state: BodyZoneSelectorState = { + hoverZone: null, + }; + + render() { + const { hoverZone } = this.state; + const { scale = 3, selectedZone, theme = 'midnight' } = this.props; + + return ( +
+ { + const onClick = this.props.onClick; + if (onClick && this.state.hoverZone) { + onClick(this.state.hoverZone); + } + }} + onMouseMove={(event) => { + if (!this.props.onClick) { + return; + } + + const rect = this.ref.current?.getBoundingClientRect(); + if (!rect) { + return; + } + + const x = event.clientX - rect.left; + const y = 32 * scale - (event.clientY - rect.top); + + this.setState({ + hoverZone: bodyZonePixelToZone(x / scale, y / scale), + }); + }} + style={{ + '-ms-interpolation-mode': 'nearest-neighbor', + 'position': 'absolute', + 'width': `${32 * scale}px`, + 'height': `${32 * scale}px`, + }} + /> + + {selectedZone && ( + + )} + + {hoverZone && hoverZone !== selectedZone && ( + + )} +
+ ); + } +} diff --git a/tgui/packages/tgui_ch/components/Box.tsx b/tgui/packages/tgui_ch/components/Box.tsx index d4e21d85ef..96244d5bd7 100644 --- a/tgui/packages/tgui_ch/components/Box.tsx +++ b/tgui/packages/tgui_ch/components/Box.tsx @@ -9,7 +9,7 @@ import { createVNode, InfernoNode, SFC } from 'inferno'; import { ChildFlags, VNodeFlags } from 'inferno-vnode-flags'; import { CSS_COLORS } from '../constants'; -export interface BoxProps { +export type BoxProps = { [key: string]: any; as?: string; className?: string | BooleanLike; @@ -35,7 +35,6 @@ export interface BoxProps { textAlign?: string | BooleanLike; verticalAlign?: string | BooleanLike; textTransform?: string | BooleanLike; // VOREStation Addition - unselectable?: string | BooleanLike; // VOREStation Addition inline?: BooleanLike; bold?: BooleanLike; italic?: BooleanLike; @@ -60,20 +59,13 @@ export interface BoxProps { backgroundColor?: string | BooleanLike; // VOREStation Addition Start // Flex props - order?: string | BooleanLike; - flexDirection?: string | BooleanLike; flexGrow?: string | BooleanLike; - flexShrink?: string | BooleanLike; flexWrap?: string | BooleanLike; - flexFlow?: string | BooleanLike; flexBasis?: string | BooleanLike; flex?: string | BooleanLike; - alignItems?: string | BooleanLike; - justifyContent?: string | BooleanLike; - alignSelf?: string | BooleanLike; // VOREStation Addition End fillPositionedParent?: boolean; -} +}; /** * Coverts our rem-like spacing unit into a CSS unit. @@ -173,7 +165,6 @@ const styleMapperByPropName = { textAlign: mapRawPropTo('text-align'), verticalAlign: mapRawPropTo('vertical-align'), textTransform: mapRawPropTo('text-transform'), // VOREStation Addition - unselectable: mapRawPropTo('unselectable'), // VOREStation Addition // Boolean props inline: mapBooleanPropTo('display', 'inline-block'), bold: mapBooleanPropTo('font-weight', 'bold'), @@ -212,17 +203,10 @@ const styleMapperByPropName = { backgroundColor: mapColorPropTo('background-color'), // VOREStation Addition Start // Flex props - order: mapRawPropTo('order'), - flexDirection: mapRawPropTo('flex-direction'), flexGrow: mapRawPropTo('flex-grow'), - flexShrink: mapRawPropTo('flex-shrink'), flexWrap: mapRawPropTo('flex-wrap'), - flexFlow: mapRawPropTo('flex-flow'), flexBasis: mapRawPropTo('flex-basis'), flex: mapRawPropTo('flex'), - alignItems: mapRawPropTo('align-items'), - justifyContent: mapRawPropTo('justify-content'), - alignSelf: mapRawPropTo('align-self'), // VOREStation Addition End // Utility props fillPositionedParent: (style, value) => { diff --git a/tgui/packages/tgui_ch/components/Button.js b/tgui/packages/tgui_ch/components/Button.js index 99a7ceae49..58d879d228 100644 --- a/tgui/packages/tgui_ch/components/Button.js +++ b/tgui/packages/tgui_ch/components/Button.js @@ -36,6 +36,7 @@ export const Button = (props) => { children, onclick, onClick, + verticalAlignContent, ...rest } = props; const hasContent = !!(content || children); @@ -69,6 +70,10 @@ export const Button = (props) => { circular && 'Button--circular', compact && 'Button--compact', iconPosition && 'Button--iconPosition--' + iconPosition, + verticalAlignContent && 'Button--flex', + verticalAlignContent && fluid && 'Button--flex--fluid', + verticalAlignContent && + 'Button--verticalAlignContent--' + verticalAlignContent, color && typeof color === 'string' ? 'Button--color--' + color : 'Button--color--default', @@ -80,7 +85,6 @@ export const Button = (props) => { if (props.captureKeys === false) { return; } - const keyCode = window.event ? e.which : e.keyCode; // Simulate a click when pressing space or enter. if (keyCode === KEY_SPACE || keyCode === KEY_ENTER) { @@ -97,25 +101,27 @@ export const Button = (props) => { } }} {...computeBoxProps(rest)}> - {icon && iconPosition !== 'right' && ( - - )} - {content} - {children} - {icon && iconPosition === 'right' && ( - - )} +
+ {icon && iconPosition !== 'right' && ( + + )} + {content} + {children} + {icon && iconPosition === 'right' && ( + + )} +
); @@ -305,3 +311,55 @@ export class ButtonInput extends Component { } Button.Input = ButtonInput; + +export class ButtonFile extends Component { + constructor() { + super(); + this.inputRef = createRef(); + } + + async read(files) { + const promises = Array.from(files).map((file) => { + let reader = new FileReader(); + return new Promise((resolve) => { + reader.onload = () => resolve(reader.result); + reader.readAsText(file); + }); + }); + + return await Promise.all(promises); + } + + render() { + const { onSelectFiles, accept, multiple, ...rest } = this.props; + const filePicker = ( + { + const files = this.inputRef.current.files; + if (files.length) { + const readFiles = await this.read(files); + onSelectFiles(multiple ? readFiles : readFiles[0]); + } + }} + /> + ); + return ( + <> + + ); +}; + +Dialog.Button = DialogButton; + +type UnsavedChangesDialogProps = { + documentName: string; + onSave: () => void; + onDiscard: () => void; + onClose: () => void; +}; + +export const UnsavedChangesDialog = (props: UnsavedChangesDialogProps) => { + const { documentName, onSave, onDiscard, onClose } = props; + return ( + +
+ Do you want to save changes to {documentName}? +
+
+ Save + Don't Save + Cancel +
+
+ ); +}; diff --git a/tgui/packages/tgui_ch/components/DraggableControl.js b/tgui/packages/tgui_ch/components/DraggableControl.js index a22fde5227..8ada6f2fa4 100644 --- a/tgui/packages/tgui_ch/components/DraggableControl.js +++ b/tgui/packages/tgui_ch/components/DraggableControl.js @@ -40,13 +40,11 @@ export class DraggableControl extends Component { suppressingFlicker: true, }); clearTimeout(this.flickerTimer); - this.flickerTimer = setTimeout( - () => - this.setState({ - suppressingFlicker: false, - }), - suppressFlicker - ); + this.flickerTimer = setTimeout(() => { + this.setState({ + suppressingFlicker: false, + }); + }, suppressFlicker); } }; @@ -81,8 +79,14 @@ export class DraggableControl extends Component { }; this.handleDragMove = (e) => { - const { minValue, maxValue, step, stepPixelSize, dragMatrix } = - this.props; + // prettier-ignore + const { + minValue, + maxValue, + step, + stepPixelSize, + dragMatrix, + } = this.props; this.setState((prevState) => { const state = { ...prevState }; const offset = getScalarScreenOffset(e, dragMatrix) - state.origin; @@ -170,17 +174,19 @@ export class DraggableControl extends Component { if (dragging || suppressingFlicker) { displayValue = intermediateValue; } - // Setup a display element - // Shows a formatted number based on what we are currently doing - // with the draggable surface. - const renderDisplayElement = (value) => value + (unit ? ' ' + unit : ''); - const displayElement = - (animated && !dragging && !suppressingFlicker && ( - - {renderDisplayElement} - - )) || - renderDisplayElement(format ? format(displayValue) : displayValue); + // prettier-ignore + const displayElement = ( + <> + { + (animated && !dragging && !suppressingFlicker) ? + () : + (format ? format(displayValue) : displayValue) + } + + { (unit ? ' ' + unit : '') } + + ); + // Setup an input element // Handles direct input via the keyboard const inputElement = ( diff --git a/tgui/packages/tgui_ch/components/Dropdown.js b/tgui/packages/tgui_ch/components/Dropdown.js deleted file mode 100644 index 32c8dca237..0000000000 --- a/tgui/packages/tgui_ch/components/Dropdown.js +++ /dev/null @@ -1,154 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { classes } from 'common/react'; -import { Component } from 'inferno'; -import { Box } from './Box'; -import { Icon } from './Icon'; - -export class Dropdown extends Component { - constructor(props) { - super(props); - this.state = { - selected: props.selected, - open: false, - }; - this.handleClick = () => { - if (this.state.open) { - this.setOpen(false); - } - }; - } - - componentWillUnmount() { - window.removeEventListener('click', this.handleClick); - } - - setOpen(open) { - this.setState({ open: open }); - if (open) { - setTimeout(() => window.addEventListener('click', this.handleClick)); - this.menuRef.focus(); - } else { - window.removeEventListener('click', this.handleClick); - } - } - - setSelected(selected) { - this.setState({ - selected: selected, - }); - this.setOpen(false); - this.props.onSelected(selected); - } - - buildMenu() { - const { options = [], placeholder } = this.props; // VOREStation edit - const ops = options.map((option) => ( - { - this.setSelected(option); - }}> - {option} - - )); - // VOREStation addition start - if (placeholder) { - ops.unshift( -
{ - this.setSelected(null); - }}> - -- {placeholder} -- -
- ); - } - // VOREStation addition end - return ops.length ? ops : 'No Options Found'; - } - - render() { - const { props } = this; - const { - icon, - iconRotation, - iconSpin, - color = 'default', - over, - noscroll, - nochevron, - width, - onClick, - selected, - disabled, - displayText, - placeholder, // VOREStation Addition - ...boxProps - } = props; - const { className, ...rest } = boxProps; - - const adjustedOpen = over ? !this.state.open : this.state.open; - - const menu = this.state.open ? ( -
{ - this.menuRef = menu; - }} - tabIndex="-1" - style={{ - 'width': width, - }} - className={classes([ - (noscroll && 'Dropdown__menu-noscroll') || 'Dropdown__menu', - over && 'Dropdown__over', - ])}> - {this.buildMenu()} -
- ) : null; - - return ( -
- { - if (disabled && !this.state.open) { - return; - } - this.setOpen(!this.state.open); - }}> - {icon && ( - - )} - - { - displayText - ? displayText - : this.state.selected || placeholder /* VOREStation Edit */ - } - - {!!nochevron || ( - - - - )} - - {menu} -
- ); - } -} diff --git a/tgui/packages/tgui_ch/components/Dropdown.tsx b/tgui/packages/tgui_ch/components/Dropdown.tsx new file mode 100644 index 0000000000..0d417af459 --- /dev/null +++ b/tgui/packages/tgui_ch/components/Dropdown.tsx @@ -0,0 +1,395 @@ +import { createPopper, VirtualElement } from '@popperjs/core'; +import { classes } from 'common/react'; +import { Component, findDOMfromVNode, InfernoNode, render } from 'inferno'; +import { Box, BoxProps } from './Box'; +import { Button } from './Button'; +import { Icon } from './Icon'; +import { Stack } from './Stack'; + +export interface DropdownEntry { + displayText: string | number | InfernoNode; + value: string | number | Enumerator; +} + +type DropdownUniqueProps = { + options: string[] | DropdownEntry[]; + icon?: string; + iconRotation?: number; + clipSelectedText?: boolean; + width?: string; + menuWidth?: string; + over?: boolean; + color?: string; + nochevron?: boolean; + displayText?: string | number | InfernoNode; + onClick?: (event) => void; + // you freaks really are just doing anything with this shit + selected?: any; + onSelected?: (selected: any) => void; + buttons?: boolean; +}; + +export type DropdownProps = BoxProps & DropdownUniqueProps; + +const DEFAULT_OPTIONS = { + placement: 'left-start', + modifiers: [ + { + name: 'eventListeners', + enabled: false, + }, + ], +}; +const NULL_RECT: DOMRect = { + width: 0, + height: 0, + top: 0, + right: 0, + bottom: 0, + left: 0, + x: 0, + y: 0, + toJSON: () => null, +} as const; + +type DropdownState = { + selected?: string; + open: boolean; +}; + +const DROPDOWN_DEFAULT_CLASSNAMES = 'Layout Dropdown__menu'; +const DROPDOWN_SCROLL_CLASSNAMES = 'Layout Dropdown__menu-scroll'; + +export class Dropdown extends Component { + static renderedMenu: HTMLDivElement | undefined; + static singletonPopper: ReturnType | undefined; + static currentOpenMenu: Element | undefined; + static virtualElement: VirtualElement = { + getBoundingClientRect: () => + Dropdown.currentOpenMenu?.getBoundingClientRect() ?? NULL_RECT, + }; + menuContents: any; + state: DropdownState = { + open: false, + selected: this.props.selected, + }; + + handleClick = () => { + if (this.state.open) { + this.setOpen(false); + } + }; + + getDOMNode() { + return findDOMfromVNode(this.$LI, true); + } + + componentDidMount() { + const domNode = this.getDOMNode(); + + if (!domNode) { + return; + } + } + + openMenu() { + let renderedMenu = Dropdown.renderedMenu; + if (renderedMenu === undefined) { + renderedMenu = document.createElement('div'); + renderedMenu.className = DROPDOWN_DEFAULT_CLASSNAMES; + document.body.appendChild(renderedMenu); + Dropdown.renderedMenu = renderedMenu; + } + + const domNode = this.getDOMNode()!; + Dropdown.currentOpenMenu = domNode; + + renderedMenu.scrollTop = 0; + renderedMenu.style.width = + this.props.menuWidth || + // Hack, but domNode should *always* be the parent control meaning it will have width + // @ts-ignore + `${domNode.offsetWidth}px`; + renderedMenu.style.opacity = '1'; + renderedMenu.style.pointerEvents = 'auto'; + + // ie hack + // ie has this bizarre behavior where focus just silently fails if the + // element being targeted "isn't ready" + // 400 is probably way too high, but the lack of hotloading is testing my + // patience on tuning it + // I'm beyond giving a shit at this point it fucking works whatever + setTimeout(() => { + Dropdown.renderedMenu?.focus(); + }, 400); + this.renderMenuContent(); + } + + closeMenu() { + if (Dropdown.currentOpenMenu !== this.getDOMNode()) { + return; + } + + Dropdown.currentOpenMenu = undefined; + Dropdown.renderedMenu!.style.opacity = '0'; + Dropdown.renderedMenu!.style.pointerEvents = 'none'; + } + + componentWillUnmount() { + this.closeMenu(); + this.setOpen(false); + } + + renderMenuContent() { + const renderedMenu = Dropdown.renderedMenu; + if (!renderedMenu) { + return; + } + if (renderedMenu.offsetHeight > 200) { + renderedMenu.className = DROPDOWN_SCROLL_CLASSNAMES; + } else { + renderedMenu.className = DROPDOWN_DEFAULT_CLASSNAMES; + } + + const { options = [] } = this.props; + const ops = options.map((option) => { + let value, displayText; + + if (typeof option === 'string') { + displayText = option; + value = option; + } else if (option !== null) { + displayText = option.displayText; + value = option.value; + } + + return ( +
{ + this.setSelected(value); + }}> + {displayText} +
+ ); + }); + + const to_render = ops.length ? ops : 'No Options Found'; + + render( +
{to_render}
, + renderedMenu, + () => { + let singletonPopper = Dropdown.singletonPopper; + if (singletonPopper === undefined) { + singletonPopper = createPopper( + Dropdown.virtualElement, + renderedMenu!, + { + ...DEFAULT_OPTIONS, + placement: 'bottom-start', + } + ); + + Dropdown.singletonPopper = singletonPopper; + } else { + singletonPopper.setOptions({ + ...DEFAULT_OPTIONS, + placement: 'bottom-start', + }); + + singletonPopper.update(); + } + }, + this.context + ); + } + + setOpen(open: boolean) { + this.setState((state) => ({ + ...state, + open, + })); + if (open) { + setTimeout(() => { + this.openMenu(); + window.addEventListener('click', this.handleClick); + }); + } else { + this.closeMenu(); + window.removeEventListener('click', this.handleClick); + } + } + + setSelected(selected: string) { + this.setState((state) => ({ + ...state, + selected, + })); + this.setOpen(false); + if (this.props.onSelected) { + this.props.onSelected(selected); + } + } + + getOptionValue(option): string { + return typeof option === 'string' ? option : option.value; + } + + getSelectedIndex(): number { + const selected = this.state.selected || this.props.selected; + const { options = [] } = this.props; + + return options.findIndex((option) => { + return this.getOptionValue(option) === selected; + }); + } + + toPrevious(): void { + if (this.props.options.length < 1) { + return; + } + + let selectedIndex = this.getSelectedIndex(); + const startIndex = 0; + const endIndex = this.props.options.length - 1; + + const hasSelected = selectedIndex >= 0; + if (!hasSelected) { + selectedIndex = startIndex; + } + + const previousIndex = + selectedIndex === startIndex ? endIndex : selectedIndex - 1; + + this.setSelected(this.getOptionValue(this.props.options[previousIndex])); + } + + toNext(): void { + if (this.props.options.length < 1) { + return; + } + + let selectedIndex = this.getSelectedIndex(); + const startIndex = 0; + const endIndex = this.props.options.length - 1; + + const hasSelected = selectedIndex >= 0; + if (!hasSelected) { + selectedIndex = endIndex; + } + + const nextIndex = + selectedIndex === endIndex ? startIndex : selectedIndex + 1; + + this.setSelected(this.getOptionValue(this.props.options[nextIndex])); + } + + render() { + const { props } = this; + const { + icon, + iconRotation, + iconSpin, + clipSelectedText = true, + color = 'default', + dropdownStyle, + over, + nochevron, + width, + onClick, + onSelected, + selected, + disabled, + displayText, + buttons, + ...boxProps + } = props; + const { className, ...rest } = boxProps; + + const adjustedOpen = over ? !this.state.open : this.state.open; + + return ( + + + { + if (disabled && !this.state.open) { + return; + } + this.setOpen(!this.state.open); + if (onClick) { + onClick(event); + } + }} + {...rest}> + {icon && ( + + )} + + {displayText || this.state.selected} + + {nochevron || ( + + + + )} + + + {buttons && ( + <> + + + + + + + + +
+ {items.map((item, i) => ( + + #{i + 1}: {item} + + ))} +
+ + )) || ( +
+ No items inserted. +
+ )} + + + ); +}; diff --git a/tgui/packages/tgui_ch/interfaces/MedicalRecords.js b/tgui/packages/tgui_ch/interfaces/MedicalRecords.js index da0d91846b..599ca9f466 100644 --- a/tgui/packages/tgui_ch/interfaces/MedicalRecords.js +++ b/tgui/packages/tgui_ch/interfaces/MedicalRecords.js @@ -275,14 +275,16 @@ const MedicalRecordsViewMedical = (_properties, context) => { {medical.fields.map((field, i) => ( - - {field.value} - }> + Entry 2 + + + Entry 3 + + + Entry 4 + + Test} label="Buttons prop"> + Entry 5 + + + + Entry 6 + + + Entry 7 + + + Entry 8 + + +
+
+
+ + + Entry 1 + + + Entry 2 + + + Entry 3 + + +
+
+
+ + + Entry 1 + + + + ); +}; diff --git a/tgui/packages/tgui_ch/stories/ProgressBar.stories.js b/tgui/packages/tgui_ch/stories/ProgressBar.stories.js index bd2ce2455f..500a3099a8 100644 --- a/tgui/packages/tgui_ch/stories/ProgressBar.stories.js +++ b/tgui/packages/tgui_ch/stories/ProgressBar.stories.js @@ -5,7 +5,7 @@ */ import { useLocalState } from '../backend'; -import { Box, Button, ProgressBar, Section } from '../components'; +import { Box, Button, Input, LabeledList, ProgressBar, Section } from '../components'; export const meta = { title: 'ProgressBar', @@ -14,22 +14,39 @@ export const meta = { const Story = (props, context) => { const [progress, setProgress] = useLocalState(context, 'progress', 0.5); + const [color, setColor] = useLocalState(context, 'color', ''); + + const color_data = color + ? { color: color } + : { + ranges: { + good: [0.5, Infinity], + bad: [-Infinity, 0.1], + average: [0, 0.5], + }, + }; + return (
- + Value: {Number(progress).toFixed(1)} -
); diff --git a/tgui/packages/tgui_ch/styles/components/Button.scss b/tgui/packages/tgui_ch/styles/components/Button.scss index e464d8abc3..acdcaa65e3 100644 --- a/tgui/packages/tgui_ch/styles/components/Button.scss +++ b/tgui/packages/tgui_ch/styles/components/Button.scss @@ -138,3 +138,29 @@ $bg-map: colors.$bg-map !default; .Button--selected { @include button-color($color-selected); } + +.Button--flex { + display: inline-flex; //Inline even for fluid + flex-direction: column; +} + +.Button--flex--fluid { + width: 100%; +} + +.Button--verticalAlignContent--top { + justify-content: flex-start; +} + +.Button--verticalAlignContent--middle { + justify-content: center; +} + +.Button--verticalAlignContent--bottom { + justify-content: flex-end; +} + +.Button__content { + display: block; + align-self: stretch; +} diff --git a/tgui/packages/tgui_ch/styles/components/LabeledList.scss b/tgui/packages/tgui_ch/styles/components/LabeledList.scss index 71946180b2..e6c4d4b2cc 100644 --- a/tgui/packages/tgui_ch/styles/components/LabeledList.scss +++ b/tgui/packages/tgui_ch/styles/components/LabeledList.scss @@ -32,10 +32,9 @@ padding: 0.25em 0.5em; border: 0; text-align: left; - vertical-align: baseline; } -.LabeledList__label { +.LabeledList__label--nowrap { width: 1%; white-space: nowrap; min-width: 5em;