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
🆑
add: good tgui dropdowns
del: tgui dropdown suck
qol: tgui dropdowns suck less
balance: buffed tgui dropdowns
/🆑
This commit is contained in:
Rob Bailey
2023-05-07 12:40:18 -07:00
committed by GitHub
parent edd5ebe570
commit b8fc54a914
11 changed files with 345 additions and 198 deletions
+5 -6
View File
@@ -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
-161
View File
@@ -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 (
<Box
key={value}
className="Dropdown__menuentry"
onClick={() => {
this.setSelected(value);
}}>
{displayText}
</Box>
);
});
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 ? (
<div
ref={(menu) => {
this.menuRef = menu;
}}
tabIndex="-1"
style={{
'width': openWidth,
}}
className={classes([
(noscroll && 'Dropdown__menu-noscroll') || 'Dropdown__menu',
over && 'Dropdown__over',
])}>
{this.buildMenu()}
</div>
) : null;
return (
<div className="Dropdown" style={dropdownStyle}>
<Box
width={this.state.open ? openWidth : width}
className={classes([
'Dropdown__control',
'Button',
'Button--color--' + color,
disabled && 'Button--disabled',
className,
])}
{...rest}
onClick={(event) => {
if (disabled && !this.state.open) {
return;
}
this.setOpen(!this.state.open);
if (props.onOpen) {
props.onOpen(event);
}
}}>
{icon && (
<Icon name={icon} rotation={iconRotation} spin={iconSpin} mr={1} />
)}
<span
className="Dropdown__selected-text"
style={{
'overflow': clipSelectedText ? 'hidden' : 'visible',
}}>
{displayText ? displayText : this.state.selected}
</span>
{!!nochevron || (
<span className="Dropdown__arrow-button">
<Icon name={adjustedOpen ? 'chevron-up' : 'chevron-down'} />
</span>
)}
</Box>
{menu}
</div>
);
}
}
+298
View File
@@ -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<DropdownProps, DropdownState> {
static renderedMenu: HTMLDivElement | undefined;
static singletonPopper: ReturnType<typeof createPopper> | 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 (
<div
key={value}
className="Dropdown__menuentry"
onClick={() => {
this.setSelected(value);
}}>
{displayText}
</div>
);
});
const to_render = ops.length ? ops : 'No Options Found';
render(
<div>{to_render}</div>,
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 (
<Box
width={width}
className={classes([
'Dropdown__control',
'Button',
'Button--color--' + color,
disabled && 'Button--disabled',
className,
])}
onClick={(event) => {
if (disabled && !this.state.open) {
return;
}
this.setOpen(!this.state.open);
if (onClick) {
onClick(event);
}
}}
{...rest}>
{icon && (
<Icon name={icon} rotation={iconRotation} spin={iconSpin} mr={1} />
)}
<span
className="Dropdown__selected-text"
style={{
overflow: clipSelectedText ? 'hidden' : 'visible',
}}>
{displayText || this.state.selected}
</span>
{nochevron || (
<span className="Dropdown__arrow-button">
<Icon name={adjustedOpen ? 'chevron-up' : 'chevron-down'} />
</span>
)}
</Box>
);
}
}
@@ -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 (
<span
@@ -111,7 +111,7 @@ const PressureIndicator = (props, context) => {
const {
currentStatus: { icon, color },
} = props;
let spin = icon === 'fan' ? 1 : 0;
let spin = icon === 'fan';
return (
<Box color={color}>
+1 -1
View File
@@ -109,7 +109,7 @@ const TerminalDisplay = (props, context) => {
title="Terminal">
<Stack fill vertical>
<Stack.Item align="center">
<Icon color="good" name={shop_logo} size="5" />
<Icon color="good" name={shop_logo} size={5} />
</Stack.Item>
<Stack.Item grow textAlign="center">
<Tooltip content={description} position="bottom">
@@ -87,8 +87,7 @@ export const FUNDAMENTAL_DATA_TYPES = {
options={data}
onSelected={setValue}
displayText={value}
openWidth={large ? '200px' : undefined}
noscroll
menuWidth={large ? '200px' : undefined}
/>
);
},
+1 -1
View File
@@ -25,7 +25,7 @@ export type DisabledProps = {
};
export type NavBeaconStaticControl = {
direction_options: String[];
direction_options: string[];
has_codes: BooleanLike;
};
@@ -181,7 +181,7 @@ export const GeneratorListEntry = (props: GeneratorProps, context) => {
<Dropdown
options={RandTypes}
selected={rand_type}
onSelected={(e, value) =>
onSelected={(value) =>
act('edit', {
var: var_name,
var_mod: P_DATA_GENERATOR,
@@ -49,6 +49,7 @@ export const RandomizationButton = (props: {
]}
nochevron
onSelected={setValue}
menuWidth="120px"
width="auto"
/>
);
@@ -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 {