From b8fc54a9140c021cad9d348d0ff29e3015c5bb88 Mon Sep 17 00:00:00 2001 From: Rob Bailey Date: Sun, 7 May 2023 12:40:18 -0700 Subject: [PATCH] Dropdowns v2 (#75164) ## About The Pull Request Complete rewrite of dropdowns to use popper elements that are injected at the body level. This means they are now horribly overcomplicated and also suck a lot less ![2023-05-04_06-57-12](https://user-images.githubusercontent.com/5194834/236229806-2f676e7b-d6c5-427a-af2f-520d883be790.gif) Also ported `Icon` to typescript for a reason that didn't end up making it in the PR ## Why It's Good For The Game ![image](https://user-images.githubusercontent.com/5194834/236226086-35ec2009-e9a9-404b-9b52-3ab8de0a0ae6.png) ![image](https://user-images.githubusercontent.com/5194834/236226573-880c0380-0f70-41c7-90c6-d51ba4238561.png) ## Changelog :cl: add: good tgui dropdowns del: tgui dropdown suck qol: tgui dropdowns suck less balance: buffed tgui dropdowns /:cl: --- tgui/docs/component-reference.md | 11 +- tgui/packages/tgui/components/Dropdown.js | 161 ---------- tgui/packages/tgui/components/Dropdown.tsx | 298 ++++++++++++++++++ .../tgui/components/{Icon.js => Icon.tsx} | 42 ++- .../tgui/interfaces/AirlockController.tsx | 2 +- tgui/packages/tgui/interfaces/HoloPay.tsx | 2 +- .../IntegratedCircuit/FundamentalTypes.js | 3 +- tgui/packages/tgui/interfaces/NavBeacon.tsx | 2 +- .../interfaces/ParticleEdit/Generators.tsx | 2 +- .../PreferencesMenu/RandomizationButton.tsx | 1 + .../tgui/styles/components/Dropdown.scss | 19 +- 11 files changed, 345 insertions(+), 198 deletions(-) delete mode 100644 tgui/packages/tgui/components/Dropdown.js create mode 100644 tgui/packages/tgui/components/Dropdown.tsx rename tgui/packages/tgui/components/{Icon.js => Icon.tsx} (59%) diff --git a/tgui/docs/component-reference.md b/tgui/docs/component-reference.md index 591efc256f2..9fff68172fd 100644 --- a/tgui/docs/component-reference.md +++ b/tgui/docs/component-reference.md @@ -363,15 +363,14 @@ and displays selected entry. - See inherited props: [Box](#box) - See inherited props: [Icon](#icon) -- `options: string[]` - An array of strings which will be displayed in the -dropdown when open -- `selected: string` - Currently selected entry -- `width: number` - Width of dropdown button and resulting menu +- `options: string[] | DropdownEntry[]` - An array of strings which will be displayed in the +dropdown when open. See Dropdown.tsx for more adcanced usage with DropdownEntry +- `selected: any` - Currently selected entry +- `width: string` - Width of dropdown button and resulting menu; css width value - `over: boolean` - Dropdown renders over instead of below - `color: string` - Color of dropdown button - `nochevron: boolean` - Whether or not the arrow on the right hand side of the dropdown button is visible -- `noscroll: boolean` - Whether or not the dropdown menu should have a scroll bar -- `displayText: string` - Text to always display in place of the selected text +- `displayText: string | number | InfernoNode` - Text to always display in place of the selected text - `onClick: (e) => void` - Called when dropdown button is clicked - `onSelected: (value) => void` - Called when a value is picked from the list, `value` is the value that was picked diff --git a/tgui/packages/tgui/components/Dropdown.js b/tgui/packages/tgui/components/Dropdown.js deleted file mode 100644 index e6fe8a840a5..00000000000 --- a/tgui/packages/tgui/components/Dropdown.js +++ /dev/null @@ -1,161 +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 = [] } = this.props; - const ops = options.map((option) => { - let displayText, value; - - if (typeof option === 'string') { - displayText = option; - value = option; - } else { - displayText = option.displayText; - value = option.value; - } - - return ( - { - this.setSelected(value); - }}> - {displayText} - - ); - }); - return ops.length ? ops : 'No Options Found'; - } - - render() { - const { props } = this; - const { - icon, - iconRotation, - iconSpin, - clipSelectedText = true, - color = 'default', - dropdownStyle, - over, - noscroll, - nochevron, - width, - openWidth = width, - onClick, - onOpen, - selected, - disabled, - displayText, - ...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': openWidth, - }} - 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); - - if (props.onOpen) { - props.onOpen(event); - } - }}> - {icon && ( - - )} - - {displayText ? displayText : this.state.selected} - - {!!nochevron || ( - - - - )} - - {menu} -
- ); - } -} diff --git a/tgui/packages/tgui/components/Dropdown.tsx b/tgui/packages/tgui/components/Dropdown.tsx new file mode 100644 index 00000000000..bf4a93cf48c --- /dev/null +++ b/tgui/packages/tgui/components/Dropdown.tsx @@ -0,0 +1,298 @@ +import { createPopper, VirtualElement } from '@popperjs/core'; +import { classes } from 'common/react'; +import { Component, findDOMfromVNode, InfernoNode, render } from 'inferno'; +import { Box, BoxProps } from './Box'; +import { Icon } from './Icon'; + +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; +}; + +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; + handleClick: any; + state: DropdownState = { + open: false, + }; + + constructor() { + super(); + + this.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); + } + } + + render() { + const { props } = this; + const { + icon, + iconRotation, + iconSpin, + clipSelectedText = true, + color = 'default', + dropdownStyle, + over, + nochevron, + width, + onClick, + onSelected, + selected, + disabled, + displayText, + ...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 || ( + + + + )} + + ); + } +} diff --git a/tgui/packages/tgui/components/Icon.js b/tgui/packages/tgui/components/Icon.tsx similarity index 59% rename from tgui/packages/tgui/components/Icon.js rename to tgui/packages/tgui/components/Icon.tsx index 4be82fdbaf9..bb54f5fd86a 100644 --- a/tgui/packages/tgui/components/Icon.js +++ b/tgui/packages/tgui/components/Icon.tsx @@ -7,24 +7,37 @@ */ import { classes, pureComponentHooks } from 'common/react'; -import { computeBoxClassName, computeBoxProps } from './Box'; +import { InfernoNode } from 'inferno'; +import { BoxProps, computeBoxClassName, computeBoxProps } from './Box'; const FA_OUTLINE_REGEX = /-o$/; -export const Icon = (props) => { - const { name, size, spin, className, rotation, inverse, ...rest } = props; +type IconPropsUnique = { + name: string; + size?: number; + spin?: boolean; + className?: string; + rotation?: number; + style?: string | CSSProperties; +}; + +export type IconProps = IconPropsUnique & BoxProps; + +export const Icon = (props: IconProps) => { + let { style, ...restlet } = props; + const { name, size, spin, className, rotation, ...rest } = restlet; if (size) { - if (!rest.style) { - rest.style = {}; + if (!style) { + style = {}; } - rest.style['font-size'] = size * 100 + '%'; + style['font-size'] = size * 100 + '%'; } - if (typeof rotation === 'number') { - if (!rest.style) { - rest.style = {}; + if (rotation) { + if (!style) { + style = {}; } - rest.style['transform'] = `rotate(${rotation}deg)`; + style['transform'] = `rotate(${rotation}deg)`; } const boxProps = computeBoxProps(rest); @@ -63,7 +76,14 @@ export const Icon = (props) => { Icon.defaultHooks = pureComponentHooks; -export const IconStack = (props) => { +type IconStackUnique = { + children: InfernoNode; + className?: string; +}; + +export type IconStackProps = IconStackUnique & BoxProps; + +export const IconStack = (props: IconStackProps) => { const { className, children, ...rest } = props; return ( { const { currentStatus: { icon, color }, } = props; - let spin = icon === 'fan' ? 1 : 0; + let spin = icon === 'fan'; return ( diff --git a/tgui/packages/tgui/interfaces/HoloPay.tsx b/tgui/packages/tgui/interfaces/HoloPay.tsx index 0fd64002225..9cc2648c186 100644 --- a/tgui/packages/tgui/interfaces/HoloPay.tsx +++ b/tgui/packages/tgui/interfaces/HoloPay.tsx @@ -109,7 +109,7 @@ const TerminalDisplay = (props, context) => { title="Terminal"> - + diff --git a/tgui/packages/tgui/interfaces/IntegratedCircuit/FundamentalTypes.js b/tgui/packages/tgui/interfaces/IntegratedCircuit/FundamentalTypes.js index 040bb067824..23615d80a15 100644 --- a/tgui/packages/tgui/interfaces/IntegratedCircuit/FundamentalTypes.js +++ b/tgui/packages/tgui/interfaces/IntegratedCircuit/FundamentalTypes.js @@ -87,8 +87,7 @@ export const FUNDAMENTAL_DATA_TYPES = { options={data} onSelected={setValue} displayText={value} - openWidth={large ? '200px' : undefined} - noscroll + menuWidth={large ? '200px' : undefined} /> ); }, diff --git a/tgui/packages/tgui/interfaces/NavBeacon.tsx b/tgui/packages/tgui/interfaces/NavBeacon.tsx index 14954b0d0de..fee94e13338 100644 --- a/tgui/packages/tgui/interfaces/NavBeacon.tsx +++ b/tgui/packages/tgui/interfaces/NavBeacon.tsx @@ -25,7 +25,7 @@ export type DisabledProps = { }; export type NavBeaconStaticControl = { - direction_options: String[]; + direction_options: string[]; has_codes: BooleanLike; }; diff --git a/tgui/packages/tgui/interfaces/ParticleEdit/Generators.tsx b/tgui/packages/tgui/interfaces/ParticleEdit/Generators.tsx index 3eb459997d0..2a5b2279acd 100644 --- a/tgui/packages/tgui/interfaces/ParticleEdit/Generators.tsx +++ b/tgui/packages/tgui/interfaces/ParticleEdit/Generators.tsx @@ -181,7 +181,7 @@ export const GeneratorListEntry = (props: GeneratorProps, context) => { + onSelected={(value) => act('edit', { var: var_name, var_mod: P_DATA_GENERATOR, diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/RandomizationButton.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/RandomizationButton.tsx index ea33f7437f0..9bd735fc4db 100644 --- a/tgui/packages/tgui/interfaces/PreferencesMenu/RandomizationButton.tsx +++ b/tgui/packages/tgui/interfaces/PreferencesMenu/RandomizationButton.tsx @@ -49,6 +49,7 @@ export const RandomizationButton = (props: { ]} nochevron onSelected={setValue} + menuWidth="120px" width="auto" /> ); diff --git a/tgui/packages/tgui/styles/components/Dropdown.scss b/tgui/packages/tgui/styles/components/Dropdown.scss index 6909f8d9312..a04913a61c9 100644 --- a/tgui/packages/tgui/styles/components/Dropdown.scss +++ b/tgui/packages/tgui/styles/components/Dropdown.scss @@ -7,11 +7,12 @@ .Dropdown { position: relative; + align-items: center; } .Dropdown__control { - position: relative; display: inline-block; + align-items: center; font-family: Verdana, sans-serif; font-size: base.em(12px); width: base.em(100px); @@ -29,28 +30,18 @@ } .Dropdown__menu { - position: absolute; overflow-y: auto; + align-items: center; z-index: 5; - width: base.em(100px); max-height: base.em(200px); - overflow-y: scroll; border-radius: 0 0 base.em(2px) base.em(2px); color: #fff; background-color: #000; background-color: rgba(0, 0, 0, 0.75); } -.Dropdown__menu-noscroll { - position: absolute; - overflow-y: auto; - z-index: 5; - width: base.em(100px); - max-height: base.em(200px); - border-radius: 0 0 base.em(2px) base.em(2px); - color: #fff; - background-color: #000; - background-color: rgba(0, 0, 0, 0.75); +.Dropdown__menu-scroll { + overflow-y: scroll; } .Dropdown__menuentry {