mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-08-31 00:58:26 +01:00
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  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   ## Changelog 🆑 add: good tgui dropdowns del: tgui dropdown suck qol: tgui dropdowns suck less balance: buffed tgui dropdowns /🆑
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user