Skip to content

Commit

Permalink
[MIRROR] Improves popper/dropdown performance (#1534)
Browse files Browse the repository at this point in the history
* Improves popper/dropdown performance

* Update QuirksPage.tsx

---------

Co-authored-by: NovaBot <[email protected]>
Co-authored-by: Jeremiah <[email protected]>
Co-authored-by: Giz <[email protected]>
  • Loading branch information
4 people authored Jan 12, 2024
1 parent a860ef5 commit dfd864a
Show file tree
Hide file tree
Showing 10 changed files with 233 additions and 356 deletions.
14 changes: 2 additions & 12 deletions tgui/docs/component-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,9 +358,8 @@ and displays selected entry.
- See inherited props: [Box](#box)
- See inherited props: [Icon](#icon)
- `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
dropdown when open. See Dropdown.tsx for more advanced 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
Expand Down Expand Up @@ -736,16 +735,7 @@ to fine tune the value, or single click it to manually type a number.

### `Popper`

Popper lets you position elements so that they don't go out of the bounds of the window. See [popper.js](https://popper.js.org/) for more information.

**Props:**

- `popperContent: ReactNode` - The content that will be put inside the popper.
- `options?: { ... }` - An object of options to pass to `createPopper`. See [https://popper.js.org/docs/v2/constructors/#options]
- `isOpen: boolean` - Whether or not the popper is open.
- `placement?: string` - The placement of the popper. See [https://popper.js.org/docs/v2/constructors/#placement]
- `onClickOutside?: (e) => void` - A function that will be called when the user clicks outside of the popper.
- `additionalStyles: { ... }` - A map of CSS styles to add to the element that will contain the popper.
Popper lets you position elements so that they don't go out of the bounds of the window. See [react-tiny-popover](https://github.com/alexkatz/react-tiny-popover) for more information.

### `ProgressBar`

Expand Down
239 changes: 141 additions & 98 deletions tgui/packages/tgui/components/Dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { classes } from 'common/react';
import { ReactNode, useState } from 'react';
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { Popover } from 'react-tiny-popover';

import { Box, BoxProps } from './Box';
import { BoxProps, unit } from './Box';
import { Button } from './Button';
import { Icon } from './Icon';
import { Popper } from './Popper';

type DropdownEntry = {
displayText: ReactNode;
Expand All @@ -14,21 +14,37 @@ type DropdownEntry = {
type DropdownOption = string | DropdownEntry;

type Props = {
/** An array of strings which will be displayed in the
dropdown when open. See Dropdown.tsx for more advanced usage with DropdownEntry */
options: DropdownOption[];
onSelected: (selected: any) => void;
/** Called when a value is picked from the list, `value` is the value that was picked */
onSelected: (value: any) => void;
} & Partial<{
/** Whether to display previous / next buttons */
buttons: boolean;
/** Whether to clip the selected text */
clipSelectedText: boolean;
/** Color of dropdown button */
color: string;
/** Disables the dropdown */
disabled: boolean;
/** Text to always display in place of the selected text */
displayText: ReactNode;
/** Icon to display in dropdown button */
icon: string;
/** Angle of the icon */
iconRotation: number;
/** Whether or not the icon should spin */
iconSpin: boolean;
/** Width of the dropdown menu. Default: 15rem */
menuWidth: string;
/** Whether or not the arrow on the right hand side of the dropdown button is visible */
noChevron: boolean;
/** Called when dropdown button is clicked */
onClick: (event) => void;
/** Dropdown renders over instead of below */
over: boolean;
/** Currently selected entry */
selected: string | number;
}> &
BoxProps;
Expand Down Expand Up @@ -58,51 +74,73 @@ export function Dropdown(props: Props) {
width,
} = props;

const [open, setOpen] = useState(false);
const [opacity, setOpacity] = useState(0);
const [open, setOpen] = useState(true);
const adjustedOpen = over ? !open : open;

/** Get the index of the selected option */
function getSelectedIndex() {
return options.findIndex((option) => {
return getOptionValue(option) === selected;
});
}
const innerRef = useRef<HTMLDivElement>(null);

/** Update the selected value when clicking the left/right buttons */
function updateSelected(direction: 'previous' | 'next') {
if (options.length < 1 || disabled) {
return;
}

let selectedIndex = getSelectedIndex();
const startIndex = 0;
const endIndex = options.length - 1;

const hasSelected = selectedIndex >= 0;
if (!hasSelected) {
selectedIndex = direction === 'next' ? endIndex : startIndex;
}

const newIndex =
direction === 'next'
? selectedIndex === endIndex
? startIndex
: selectedIndex + 1
: selectedIndex === startIndex
? endIndex
: selectedIndex - 1;

onSelected?.(getOptionValue(options[newIndex]));
}
const updateSelected = useCallback(
(direction: 'previous' | 'next') => {
if (options.length < 1 || disabled) {
return;
}
const startIndex = 0;
const endIndex = options.length - 1;

let selectedIndex = options.findIndex(
(option) => getOptionValue(option) === selected,
);

if (selectedIndex < 0) {
selectedIndex = direction === 'next' ? endIndex : startIndex;
}

let newIndex = selectedIndex;
if (direction === 'next') {
newIndex = selectedIndex === endIndex ? startIndex : selectedIndex++;
} else {
newIndex = selectedIndex === startIndex ? endIndex : selectedIndex--;
}

onSelected?.(getOptionValue(options[newIndex]));
},
[disabled, onSelected, options, selected],
);

/**
* HACK: Just like the original dropdown,
* the menu does not propagate correctly on the first event.
* This tricks it into getting the parent component's position
* so there is no flickering on open.
*/
useEffect(() => {
const timer = setTimeout(() => {
setOpen(false);
setOpacity(1);
}, 1);

return () => clearTimeout(timer);
}, []);

/** Allows the menu to be scrollable on open */
useEffect(() => {
if (!open) return;

innerRef.current?.focus();
}, [open]);

return (
<Popper
autoFocus
<Popover
isOpen={open}
onClickOutside={() => setOpen(false)}
placement={over ? 'top-start' : 'bottom-start'}
popperContent={
<div className="Layout Dropdown__menu" style={{ minWidth: menuWidth }}>
positions={over ? 'top' : 'bottom'}
content={
<div
className="Layout Dropdown__menu"
style={{ minWidth: menuWidth, opacity }}
ref={innerRef}
>
{options.length === 0 && (
<div className="Dropdown__menuentry">No options</div>
)}
Expand All @@ -116,8 +154,7 @@ export function Dropdown(props: Props) {
'Dropdown__menuentry',
selected === value && 'selected',
])}
id="dropdown-item"
key={value}
key={index}
onClick={() => {
setOpen(false);
onSelected?.(value);
Expand All @@ -130,64 +167,70 @@ export function Dropdown(props: Props) {
</div>
}
>
<Box className="Dropdown" width={width}>
<div
className={classes([
'Dropdown__control',
'Button',
'Button--dropdown',
'Button--color--' + color,
disabled && 'Button--disabled',
className,
])}
onClick={(event) => {
if (disabled && !open) {
return;
}
setOpen(!open);
onClick?.(event);
}}
>
{icon && (
<Icon mr={1} name={icon} rotation={iconRotation} spin={iconSpin} />
)}
<span
className="Dropdown__selected-text"
style={{
overflow: clipSelectedText ? 'hidden' : 'visible',
<div>
<div className="Dropdown" style={{ width: unit(width) }}>
<div
className={classes([
'Dropdown__control',
'Button',
'Button--dropdown',
'Button--color--' + color,
disabled && 'Button--disabled',
className,
])}
onClick={(event) => {
if (disabled && !open) {
return;
}
setOpen(!open);
onClick?.(event);
}}
>
{displayText || selected}
</span>
{!noChevron && (
<span className="Dropdown__arrow-button">
<Icon name={adjustedOpen ? 'chevron-up' : 'chevron-down'} />
{icon && (
<Icon
mr={1}
name={icon}
rotation={iconRotation}
spin={iconSpin}
/>
)}
<span
className="Dropdown__selected-text"
style={{
overflow: clipSelectedText ? 'hidden' : 'visible',
}}
>
{displayText || selected}
</span>
{!noChevron && (
<span className="Dropdown__arrow-button">
<Icon name={adjustedOpen ? 'chevron-up' : 'chevron-down'} />
</span>
)}
</div>
{buttons && (
<>
<Button
disabled={disabled}
height={1.8}
icon="chevron-left"
onClick={() => {
updateSelected('previous');
}}
/>

<Button
disabled={disabled}
height={1.8}
icon="chevron-right"
onClick={() => {
updateSelected('next');
}}
/>
</>
)}
</div>

{buttons && (
<>
<Button
disabled={disabled}
height={1.8}
icon="chevron-left"
onClick={() => {
updateSelected('previous');
}}
/>

<Button
disabled={disabled}
height={1.8}
icon="chevron-right"
onClick={() => {
updateSelected('next');
}}
/>
</>
)}
</Box>
</Popper>
</div>
</Popover>
);
}
Loading

0 comments on commit dfd864a

Please sign in to comment.