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 {