Refactor the Dropdown into a function component (#4932)

* add unit test for dropdown component

* refactor dropdown to function component wip

* use useLayoutEffect for updating the position

* update types to use DropdownHandle

* remove unused forcedposition

* remove dropup

* add warning about dropdown container

* add useCallback/useMemo

* re-export the container id for tests

* split the state
This commit is contained in:
James Gatz 2022-07-19 13:58:25 +02:00 committed by GitHub
parent 5f250334f6
commit 526bfc35c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 529 additions and 400 deletions

View File

@ -0,0 +1,118 @@
import { cleanup, fireEvent, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Dropdown, dropdownsContainerId } from '../dropdown/dropdown';
import { DropdownButton } from '../dropdown/dropdown-button';
import { DropdownItem } from '../dropdown/dropdown-item';
const prepareDom = () => {
const dropdownsContainer = document.createElement('div');
dropdownsContainer.setAttribute('id', dropdownsContainerId);
dropdownsContainer.style.position = 'fixed';
dropdownsContainer.style.right = '-90000px';
dropdownsContainer.style.left = '-90000px';
dropdownsContainer.style.width = '100vw';
dropdownsContainer.style.height = '100vh';
document.body.appendChild(dropdownsContainer);
};
describe('Dropdown', () => {
beforeEach(() => {
prepareDom();
});
afterEach(() => {
cleanup();
});
it('should render a dropdown', async () => {
const onSelect = jest.fn();
const options = [
{ id: 1, label: 'List of Numbers', value: [1, 2, 3] },
{ id: 2, label: 'Another List of Numbers', value: [4, 5, 6] },
{ id: 3, label: 'List of more Numbers', value: [7, 8, 9] },
];
const { queryByText } = render(
<Dropdown>
<DropdownButton>
Open <i className="fa fa-caret-down" />
</DropdownButton>
{options.map(option => (
<DropdownItem
key={option.id}
onClick={onSelect}
value={option.value}
>
{option.label}
</DropdownItem>
))}
</Dropdown>
);
const button = queryByText('Open');
fireEvent.click(button);
const option2 = queryByText(options[1].label);
fireEvent.click(option2);
expect(onSelect).toHaveBeenCalledWith(options[1].value, expect.any(Object));
cleanup();
});
it('handle navigation via keyboard', async () => {
const user = userEvent.setup();
const onSelect = jest.fn();
const options = [
{ id: 1, label: 'List of Numbers', value: [1, 2, 3] },
{ id: 2, label: 'Another List of Numbers', value: [4, 5, 6] },
{ id: 3, label: 'List of more Numbers', value: [7, 8, 9] },
];
const { queryByText, queryByTitle } = render(
<Dropdown>
<DropdownButton>
Open <i className="fa fa-caret-down" />
</DropdownButton>
{options.map(option => (
<DropdownItem
key={option.id}
title={option.label}
onClick={onSelect}
value={option.value}
>
{option.label}
</DropdownItem>
))}
</Dropdown>
);
// Click the open button
const button = queryByText('Open');
fireEvent.click(button);
// Navigate with the arrows to the second option
await user.keyboard('[ArrowDown]');
await user.keyboard('[ArrowDown]');
const parent = queryByTitle(options[1].label)?.parentElement;
expect(parent).toHaveClass('active');
// Press enter on the second option
await user.keyboard('[Enter]');
expect(onSelect).toHaveBeenCalledWith(options[1].value, expect.any(Object));
// The dropdown button should regain focus
expect(button).toHaveFocus();
});
});

View File

@ -1,10 +1,20 @@
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import classnames from 'classnames';
import { any, equals } from 'ramda';
import React, { CSSProperties, Fragment, PureComponent, ReactNode } from 'react';
import React, {
CSSProperties,
forwardRef,
Fragment,
isValidElement,
ReactNode,
useCallback,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import ReactDOM from 'react-dom';
import { AUTOBIND_CFG } from '../../../../common/constants';
import { hotKeyRefs } from '../../../../common/hotkeys';
import { executeHotKey } from '../../../../common/hotkeys-listener';
import { fuzzyMatch } from '../../../../common/misc';
@ -12,7 +22,6 @@ import { KeydownBinder } from '../../keydown-binder';
import { DropdownButton } from './dropdown-button';
import { DropdownDivider } from './dropdown-divider';
import { DropdownItem } from './dropdown-item';
export const dropdownsContainerId = 'dropdowns-container';
export interface DropdownProps {
children: ReactNode;
@ -26,454 +35,455 @@ export interface DropdownProps {
beside?: boolean;
}
interface State {
open: boolean;
dropUp: boolean;
filter: string;
filterVisible: boolean;
filterItems?: number[] | null;
filterActiveIndex: number;
forcedPosition?: { x: number; y: number } | null;
uniquenessKey: number;
}
export const dropdownsContainerId = 'dropdowns-container';
const isComponent = (match: string) => (child: ReactNode) => any(equals(match), [
// @ts-expect-error this is required by our API for Dropdown
child.type.name,
// @ts-expect-error this is required by our API for Dropdown
child.type.displayName,
]);
const isComponent = (match: string) => (child: ReactNode) =>
any(equals(match), [
// @ts-expect-error this is required by our API for Dropdown
child.type.name,
// @ts-expect-error this is required by our API for Dropdown
child.type.displayName,
]);
const isDropdownItem = isComponent(DropdownItem.name);
const isDropdownButton = isComponent(DropdownButton.name);
const isDropdownDivider = isComponent(DropdownDivider.name);
@autoBindMethodsForReact(AUTOBIND_CFG)
export class Dropdown extends PureComponent<DropdownProps, State> {
private _node: HTMLDivElement | null = null;
private _dropdownList: HTMLDivElement | null = null;
private _filter: HTMLInputElement | null = null;
// This walks the children tree and returns the dropdown specific components.
// It allows us to use arrays, fragments etc.
const _getFlattenedChildren = (children: ReactNode[] | ReactNode) => {
let newChildren: ReactNode[] = [];
// Ensure children is an array
const flatChildren = Array.isArray(children) ? children : [children];
state: State = {
open: false,
dropUp: false,
// Filter Stuff
filter: '',
filterVisible: false,
filterItems: null,
filterActiveIndex: 0,
// Position
forcedPosition: null,
// Use this to force new menu every time dropdown opens
uniquenessKey: 0,
};
for (const child of flatChildren) {
if (!child) {
// Ignore null components
continue;
}
_setRef(node: HTMLDivElement) {
this._node = node;
}
_handleCheckFilterSubmit(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
// Listen for the Enter key and "click" on the active list item
const selector = `li[data-filter-index="${this.state.filterActiveIndex}"] button`;
const button = this._dropdownList?.querySelector(selector);
// @ts-expect-error -- TSCONVERSION
button?.click();
if (isValidElement(child) && child.type === Fragment) {
newChildren = [
...newChildren,
..._getFlattenedChildren(child.props.children),
];
} else if (Array.isArray(child)) {
newChildren = [...newChildren, ..._getFlattenedChildren(child)];
} else {
newChildren.push(child);
}
}
_handleChangeFilter(event: React.ChangeEvent<HTMLInputElement>) {
const newFilter = event.target.value;
return newChildren;
};
// Nothing to do if the filter didn't change
if (newFilter === this.state.filter) {
return;
}
export interface DropdownHandle {
show: (
filterVisible?: boolean,
) => void;
hide: () => void;
toggle: (filterVisible?: boolean) => void;
}
// Filter the list items that are filterable (have data-filter-index property)
const filterItems: number[] = [];
export const Dropdown = forwardRef<DropdownHandle, DropdownProps>(
({ right, outline, className, style, children, beside, onOpen, onHide, wide }, ref) => {
const [open, setOpen] = useState(false);
// @TODO: This is a hack to force new menu every time dropdown opens
const [uniquenessKey, setUniquenessKey] = useState(0);
const [filter, setFilter] = useState('');
const [filterVisible, setFilterVisible] = useState(false);
const [filterItems, setFilterItems] = useState<number[] | null>(null);
const [filterActiveIndex, setFilterActiveIndex] = useState(0);
// @ts-expect-error -- TSCONVERSION convert to array or use querySelectorAll().forEach
for (const listItem of this._dropdownList.querySelectorAll('li')) {
if (!listItem.hasAttribute('data-filter-index')) {
continue;
}
const dropdownContainerRef = useRef<HTMLDivElement>(null);
const dropdownListRef = useRef<HTMLDivElement>(null);
const filterInputRef = useRef<HTMLInputElement>(null);
const match = fuzzyMatch(newFilter, listItem.textContent || '');
const _handleCheckFilterSubmit = useCallback((
event: React.KeyboardEvent<HTMLInputElement>
) => {
if (event.key === 'Enter') {
// Listen for the Enter key and "click" on the active list item
const selector = `li[data-filter-index="${filterActiveIndex}"] button`;
if (!newFilter || match) {
const filterIndex = listItem.getAttribute('data-filter-index');
if (filterIndex) {
filterItems.push(parseInt(filterIndex, 10));
const button = dropdownListRef.current?.querySelector(selector);
if (button instanceof HTMLButtonElement) {
button.click();
}
}
}
}, [filterActiveIndex]);
this.setState({
filter: newFilter,
filterItems: newFilter ? filterItems : null,
filterActiveIndex: filterItems[0] || -1,
filterVisible: this.state.filterVisible ? true : newFilter.length > 0,
});
}
const _handleChangeFilter = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newFilter = event.target.value;
_handleDropdownNavigation(event: KeyboardEvent) {
const { key, shiftKey } = event;
// Handle tab and arrows to move up and down dropdown entries
const { filterItems, filterActiveIndex } = this.state;
// Nothing to do if the filter didn't change
if (newFilter === filter) {
return;
}
if (['Tab', 'ArrowDown', 'ArrowUp'].includes(key)) {
event.preventDefault();
const items = filterItems || [];
// Filter the list items that are filterable (have data-filter-index property)
const filterItems: number[] = [];
const filterableItems = dropdownListRef.current?.querySelectorAll('li');
if (filterableItems instanceof NodeList) {
for (const listItem of filterableItems) {
if (!listItem.hasAttribute('data-filter-index')) {
continue;
}
const match = fuzzyMatch(newFilter, listItem.textContent || '');
if (!newFilter || match) {
const filterIndex = listItem.getAttribute('data-filter-index');
if (!filterItems) {
// @ts-expect-error -- TSCONVERSION convert to array or use querySelectorAll().forEach
for (const li of this._dropdownList.querySelectorAll('li')) {
if (li.hasAttribute('data-filter-index')) {
const filterIndex = li.getAttribute('data-filter-index');
if (filterIndex) {
items.push(parseInt(filterIndex, 10));
filterItems.push(parseInt(filterIndex, 10));
}
}
}
setFilter(newFilter);
setFilterItems(newFilter ? filterItems : null);
setFilterActiveIndex(filterItems[0] || -1);
setFilterVisible(filterVisible || newFilter.length > 0);
}
}, [filter, filterVisible]);
const _handleDropdownNavigation = useCallback((event: KeyboardEvent) => {
const { key, shiftKey } = event;
// Handle tab and arrows to move up and down dropdown entries
if (['Tab', 'ArrowDown', 'ArrowUp'].includes(key)) {
event.preventDefault();
const items = filterItems || [];
if (!filterItems) {
const filterableItems = dropdownListRef.current?.querySelectorAll('li');
if (filterableItems instanceof NodeList) {
for (const li of filterableItems) {
if (li.hasAttribute('data-filter-index')) {
const filterIndex = li.getAttribute('data-filter-index');
if (filterIndex) {
items.push(parseInt(filterIndex, 10));
}
}
}
}
}
const i = items.indexOf(filterActiveIndex);
if (key === 'ArrowUp' || (key === 'Tab' && shiftKey)) {
const nextI = i > 0 ? items[i - 1] : items[items.length - 1];
setFilterActiveIndex(nextI);
} else {
setFilterActiveIndex(items[i + 1] || items[0]);
}
}
const i = items.indexOf(filterActiveIndex);
filterInputRef.current?.focus();
}, [filterActiveIndex, filterItems]);
if (key === 'ArrowUp' || (key === 'Tab' && shiftKey)) {
const nextI = i > 0 ? items[i - 1] : items[items.length - 1];
this.setState({
filterActiveIndex: nextI,
});
const _handleBodyKeyDown = (event: KeyboardEvent) => {
if (!open) {
return;
}
// Catch all key presses (like global app hotkeys) if we're open
event.stopPropagation();
_handleDropdownNavigation(event);
executeHotKey(event, hotKeyRefs.CLOSE_DROPDOWN, () => {
hide();
});
};
const isNearBottomOfScreen = () => {
if (!dropdownContainerRef.current) {
return false;
}
const bodyHeight = document.body.getBoundingClientRect().height;
const dropdownTop = dropdownContainerRef.current.getBoundingClientRect().top;
return dropdownTop > bodyHeight - 200;
};
// Recalculate the position of the dropdown
useLayoutEffect(() => {
if (!open || !dropdownListRef.current) {
return;
}
// Compute the size of all the menus
const dropdownBtnRect = dropdownContainerRef.current?.getBoundingClientRect();
if (!dropdownBtnRect) {
return;
}
const bodyRect = document.body.getBoundingClientRect();
const dropdownListRect = dropdownListRef.current.getBoundingClientRect();
// Reset all the things so we can start fresh
dropdownListRef.current.style.left = 'initial';
dropdownListRef.current.style.right = 'initial';
dropdownListRef.current.style.top = 'initial';
dropdownListRef.current.style.bottom = 'initial';
dropdownListRef.current.style.minWidth = 'initial';
dropdownListRef.current.style.maxWidth = 'initial';
const screenMargin = 6;
if (right || wide) {
// Prevent dropdown from squishing against left side of screen
const rightMargin = Math.max(
dropdownListRect.width + screenMargin,
dropdownBtnRect.right
);
const offset = beside ? dropdownBtnRect.width - 40 : 0;
dropdownListRef.current.style.right = `${
bodyRect.width - rightMargin + offset
}px`;
dropdownListRef.current.style.maxWidth = `${Math.min(
dropdownListRect.width,
rightMargin + offset
)}px`;
}
if (!right || wide) {
const offset = beside ? dropdownBtnRect.width - 40 : 0;
// Prevent dropdown from squishing against right side of screen
const leftMargin = Math.min(
bodyRect.width - dropdownListRect.width - screenMargin,
dropdownBtnRect.left
);
dropdownListRef.current.style.left = `${leftMargin + offset}px`;
dropdownListRef.current.style.maxWidth = `${Math.min(
dropdownListRect.width,
bodyRect.width - leftMargin - offset
)}px`;
}
if (isNearBottomOfScreen()) {
dropdownListRef.current.style.bottom = `${
bodyRect.height - dropdownBtnRect.top
}px`;
dropdownListRef.current.style.maxHeight = `${
dropdownBtnRect.top - screenMargin
}px`;
} else {
this.setState({
filterActiveIndex: items[i + 1] || items[0],
});
dropdownListRef.current.style.top = `${dropdownBtnRect.bottom}px`;
dropdownListRef.current.style.maxHeight = `${
bodyRect.height - dropdownBtnRect.bottom - screenMargin
}px`;
}
}
}, [beside, open, right, wide]);
this._filter?.focus();
}
const _handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
toggle();
};
_handleBodyKeyDown(event: KeyboardEvent) {
if (!this.state.open) {
return;
}
const _handleMouseDown = (event: React.MouseEvent) => {
// Intercept mouse down so that clicks don't trigger things like drag and drop.
event.preventDefault();
};
// Catch all key presses (like global app hotkeys) if we're open
event.stopPropagation();
const hide = useCallback(() => {
// Focus the dropdown button after hiding
if (dropdownContainerRef.current) {
const button = dropdownContainerRef.current.querySelector('button');
this._handleDropdownNavigation(event);
executeHotKey(event, hotKeyRefs.CLOSE_DROPDOWN, () => {
this.hide();
});
}
_checkSizeAndPosition() {
if (!this.state.open || !this._dropdownList) {
return;
}
// Get dropdown menu
const dropdownList = this._dropdownList;
// Compute the size of all the menus
// @ts-expect-error -- TSCONVERSION should exit if node is not defined
let dropdownBtnRect = this._node.getBoundingClientRect();
const bodyRect = document.body.getBoundingClientRect();
const dropdownListRect = dropdownList.getBoundingClientRect();
const { forcedPosition } = this.state;
if (forcedPosition) {
// @ts-expect-error -- TSCONVERSION missing properties
dropdownBtnRect = {
left: forcedPosition.x,
right: bodyRect.width - forcedPosition.x,
top: forcedPosition.y,
bottom: bodyRect.height - forcedPosition.y,
width: 100,
height: 10,
};
}
// Should it drop up?
const bodyHeight = bodyRect.height;
const dropdownTop = dropdownBtnRect.top;
const dropUp = dropdownTop > bodyHeight - 200;
// Reset all the things so we can start fresh
this._dropdownList.style.left = 'initial';
this._dropdownList.style.right = 'initial';
this._dropdownList.style.top = 'initial';
this._dropdownList.style.bottom = 'initial';
this._dropdownList.style.minWidth = 'initial';
this._dropdownList.style.maxWidth = 'initial';
const screenMargin = 6;
const { right, wide } = this.props;
if (right || wide) {
const { right: originalRight } = dropdownBtnRect;
// Prevent dropdown from squishing against left side of screen
const right = Math.max(dropdownListRect.width + screenMargin, originalRight);
const { beside } = this.props;
const offset = beside ? dropdownBtnRect.width - 40 : 0;
this._dropdownList.style.right = `${bodyRect.width - right + offset}px`;
this._dropdownList.style.maxWidth = `${Math.min(dropdownListRect.width, right + offset)}px`;
}
if (!right || wide) {
const { left: originalLeft } = dropdownBtnRect;
const { beside } = this.props;
const offset = beside ? dropdownBtnRect.width - 40 : 0;
// Prevent dropdown from squishing against right side of screen
const left = Math.min(bodyRect.width - dropdownListRect.width - screenMargin, originalLeft);
this._dropdownList.style.left = `${left + offset}px`;
this._dropdownList.style.maxWidth = `${Math.min(
dropdownListRect.width,
bodyRect.width - left - offset,
)}px`;
}
if (dropUp) {
const { top } = dropdownBtnRect;
this._dropdownList.style.bottom = `${bodyRect.height - top}px`;
this._dropdownList.style.maxHeight = `${top - screenMargin}px`;
} else {
const { bottom } = dropdownBtnRect;
this._dropdownList.style.top = `${bottom}px`;
this._dropdownList.style.maxHeight = `${bodyRect.height - bottom - screenMargin}px`;
}
}
_handleClick(event: React.MouseEvent<HTMLDivElement>) {
event.preventDefault();
event.stopPropagation();
this.toggle();
}
static _handleMouseDown(event: React.MouseEvent) {
// Intercept mouse down so that clicks don't trigger things like drag and drop.
event.preventDefault();
}
_addDropdownListRef(dropdownList: HTMLDivElement) {
this._dropdownList = dropdownList;
}
_addFilterRef(filter: HTMLInputElement) {
this._filter = filter;
// Automatically focus the filter element when mounted so we can start typing
if (this._filter) {
this._filter.focus();
}
}
// TODO: children should not be 'any'.
_getFlattenedChildren(children: any) {
let newChildren: ReactNode[] = [];
// Ensure children is an array
children = Array.isArray(children) ? children : [children];
for (const child of children) {
if (!child) {
// Ignore null components
continue;
button?.focus();
}
if (child.type === Fragment) {
newChildren = [...newChildren, ...this._getFlattenedChildren(child.props.children)];
} else if (Array.isArray(child)) {
newChildren = [...newChildren, ...this._getFlattenedChildren(child)];
} else {
newChildren.push(child);
}
}
setOpen(false);
return newChildren;
}
onHide?.();
}, [onHide]);
componentDidUpdate() {
this._checkSizeAndPosition();
}
const show = useCallback(
(
filterVisible = false,
) => {
setOpen(true);
setFilterVisible(filterVisible);
setFilter('');
setFilterItems(null);
setFilterActiveIndex(-1);
setUniquenessKey(uniquenessKey + 1);
hide() {
// Focus the dropdown button after hiding
if (this._node) {
const button = this._node.querySelector('button');
onOpen?.();
},
[onOpen, uniquenessKey]
);
button?.focus();
}
const toggle = useCallback(
(filterVisible = false) => {
if (open) {
hide();
} else {
show(filterVisible);
}
},
[hide, open, show]
);
this.setState({
open: false,
});
this.props.onHide?.();
}
useImperativeHandle(
ref,
() => ({
show,
hide,
toggle,
}),
[hide, show, toggle]
);
show(filterVisible = false, forcedPosition: { x: number; y: number } | null = null) {
const bodyHeight = document.body.getBoundingClientRect().height;
// @ts-expect-error -- TSCONVERSION _node can be undefined
const dropdownTop = this._node.getBoundingClientRect().top;
const dropUp = dropdownTop > bodyHeight - 200;
this.setState({
open: true,
dropUp,
forcedPosition,
filterVisible,
filter: '',
filterItems: null,
filterActiveIndex: -1,
uniquenessKey: this.state.uniquenessKey + 1,
});
this.props.onOpen?.();
}
toggle(filterVisible = false) {
if (this.state.open) {
this.hide();
} else {
this.show(filterVisible);
}
}
render() {
const { right, outline, wide, className, style, children } = this.props;
const {
dropUp,
open,
uniquenessKey,
filterVisible,
filterActiveIndex,
filterItems,
filter,
} = this.state;
const classes = classnames('dropdown', className, {
'dropdown--wide': wide,
'dropdown--open': open,
});
const menuClasses = classnames({
// eslint-disable-next-line camelcase
dropdown__menu: true,
'theme--dropdown__menu': true,
'dropdown__menu--open': open,
'dropdown__menu--outlined': outline,
'dropdown__menu--up': dropUp,
'dropdown__menu--up': isNearBottomOfScreen(),
'dropdown__menu--right': right,
});
const dropdownButtons: ReactNode[] = [];
const dropdownItems: ReactNode[] = [];
const allChildren = this._getFlattenedChildren(children);
const dropdownChildren = useMemo(() => {
const dropdownButtons: ReactNode[] = [];
const dropdownItems: ReactNode[] = [];
const visibleChildren = allChildren.filter((child, i) => {
if (isDropdownItem(child)) {
return true;
}
const allChildren = _getFlattenedChildren(children);
// It's visible if its index is in the filterItems
return !filterItems || filterItems.includes(i);
});
for (let i = 0; i < allChildren.length; i++) {
const child = allChildren[i];
if (isDropdownButton(child)) {
dropdownButtons.push(child);
} else if (isDropdownItem(child)) {
const active = i === filterActiveIndex;
const hide = !visibleChildren.includes(child);
dropdownItems.push(
<li
key={i}
data-filter-index={i}
className={classnames({
active,
hide,
})}
>
{child}
</li>,
);
} else if (isDropdownDivider(child)) {
const currentIndex = visibleChildren.indexOf(child);
const nextChild = visibleChildren[currentIndex + 1];
// Only show the divider if the next child is a DropdownItem
if (nextChild && isDropdownItem(nextChild)) {
dropdownItems.push(<li key={i}>{child}</li>);
const visibleChildren = allChildren.filter((child, i) => {
if (!isDropdownItem(child)) {
return true;
}
}
}
let finalChildren: React.ReactNode = [];
if (dropdownButtons.length !== 1) {
console.error(`Dropdown needs exactly one DropdownButton! Got ${dropdownButtons.length}`, {
allChildren,
// It's visible if its index is in the filterItems
return !filterItems || filterItems.includes(i);
});
} else {
const noResults = filter && filterItems && filterItems.length === 0;
finalChildren = [
dropdownButtons[0],
ReactDOM.createPortal(
<div
key="item"
className={menuClasses}
aria-hidden={!open}
>
<div className="dropdown__backdrop theme--transparent-overlay" />
<div
key={uniquenessKey}
ref={this._addDropdownListRef}
tabIndex={-1}
className={classnames('dropdown__list', {
'dropdown__list--filtering': filterVisible,
for (let i = 0; i < allChildren.length; i++) {
const child = allChildren[i];
if (isDropdownButton(child)) {
dropdownButtons.push(child);
} else if (isDropdownItem(child)) {
const active = i === filterActiveIndex;
const hide = !visibleChildren.includes(child);
dropdownItems.push(
<li
key={i}
data-filter-index={i}
className={classnames({
active,
hide,
})}
>
<div className="form-control dropdown__filter">
<i className="fa fa-search" />
<input
type="text"
onChange={this._handleChangeFilter}
ref={this._addFilterRef}
onKeyPress={this._handleCheckFilterSubmit}
/>
</div>
{noResults && <div className="text-center pad warning">No match :(</div>}
<ul
className={classnames({
hide: noResults,
{child}
</li>
);
} else if (isDropdownDivider(child)) {
const currentIndex = visibleChildren.indexOf(child);
const nextChild = visibleChildren[currentIndex + 1];
// Only show the divider if the next child is a DropdownItem
if (nextChild && isDropdownItem(nextChild)) {
dropdownItems.push(<li key={i}>{child}</li>);
}
}
}
let finalChildren: React.ReactNode = [];
if (dropdownButtons.length !== 1) {
console.error(
`Dropdown needs exactly one DropdownButton! Got ${dropdownButtons.length}`,
{
allChildren,
}
);
} else {
const noResults = filter && filterItems && filterItems.length === 0;
const dropdownsContainer = document.getElementById(dropdownsContainerId);
if (!dropdownsContainer) {
console.error('Dropdown: a #dropdowns-container element is required for a dropdown to render properly');
return null;
}
finalChildren = [
dropdownButtons[0],
ReactDOM.createPortal(
<div key="item" className={menuClasses} aria-hidden={!open}>
<div className="dropdown__backdrop theme--transparent-overlay" />
<div
key={uniquenessKey}
ref={dropdownListRef}
tabIndex={-1}
className={classnames('dropdown__list', {
'dropdown__list--filtering': filterVisible,
})}
>
{dropdownItems}
</ul>
</div>
</div>,
// @ts-expect-error -- TSCONVERSION
document.getElementById(dropdownsContainerId),
),
];
}
<div className="form-control dropdown__filter">
<i className="fa fa-search" />
<input
type="text"
autoFocus={open}
onChange={_handleChangeFilter}
ref={filterInputRef}
onKeyPress={_handleCheckFilterSubmit}
/>
</div>
{noResults && (
<div className="text-center pad warning">{'No match :('}</div>
)}
<ul
className={classnames({
hide: noResults,
})}
>
{dropdownItems}
</ul>
</div>
</div>,
dropdownsContainer
),
];
}
return finalChildren;
}, [_handleChangeFilter, _handleCheckFilterSubmit, children, filter, filterActiveIndex, filterItems, filterVisible, menuClasses, open, uniquenessKey]);
return (
<KeydownBinder stopMetaPropagation onKeydown={this._handleBodyKeyDown} disabled={!open}>
<KeydownBinder
stopMetaPropagation
onKeydown={_handleBodyKeyDown}
disabled={!open}
>
<div
style={style}
className={classes}
ref={this._setRef}
onClick={this._handleClick}
ref={dropdownContainerRef}
onClick={_handleClick}
tabIndex={-1}
onMouseDown={Dropdown._handleMouseDown}
onMouseDown={_handleMouseDown}
>
{finalChildren}
{dropdownChildren}
</div>
</KeydownBinder>
);
}
}
);
Dropdown.displayName = 'Dropdown';

View File

@ -5,7 +5,7 @@ import { hotKeyRefs } from '../../../common/hotkeys';
import { executeHotKey } from '../../../common/hotkeys-listener';
import type { Environment } from '../../../models/environment';
import type { Workspace } from '../../../models/workspace';
import { Dropdown } from '../base/dropdown/dropdown';
import { type DropdownHandle, Dropdown } from '../base/dropdown/dropdown';
import { DropdownButton } from '../base/dropdown/dropdown-button';
import { DropdownDivider } from '../base/dropdown/dropdown-divider';
import { DropdownHint } from '../base/dropdown/dropdown-hint';
@ -32,7 +32,7 @@ export const EnvironmentsDropdown: FC<Props> = ({
hotKeyRegistry,
workspace,
}) => {
const dropdownRef = useRef<Dropdown>(null);
const dropdownRef = useRef<DropdownHandle>(null);
const handleShowEnvironmentModal = useCallback(() => {
showModal(WorkspaceEnvironmentsEditModal, workspace);
}, [workspace]);

View File

@ -21,7 +21,7 @@ import type {
UpdateGitRepositoryCallback,
} from '../../redux/modules/git';
import * as gitActions from '../../redux/modules/git';
import { Dropdown } from '../base/dropdown/dropdown';
import { type DropdownHandle, Dropdown } from '../base/dropdown/dropdown';
import { DropdownButton } from '../base/dropdown/dropdown-button';
import { DropdownDivider } from '../base/dropdown/dropdown-divider';
import { DropdownItem } from '../base/dropdown/dropdown-item';
@ -55,7 +55,7 @@ interface State {
@autoBindMethodsForReact(AUTOBIND_CFG)
class GitSyncDropdown extends PureComponent<Props, State> {
_dropdown: Dropdown | null = null;
_dropdown: DropdownHandle | null = null;
state: State = {
initializing: false,
@ -66,7 +66,7 @@ class GitSyncDropdown extends PureComponent<Props, State> {
branches: [],
};
_setDropdownRef(dropdown: Dropdown) {
_setDropdownRef(dropdown: DropdownHandle) {
this._dropdown = dropdown;
}

View File

@ -2,7 +2,7 @@ import React, { forwardRef, useCallback, useState } from 'react';
import * as constants from '../../../common/constants';
import { METHOD_GRPC } from '../../../common/constants';
import { Dropdown } from '../base/dropdown/dropdown';
import { type DropdownHandle, Dropdown } from '../base/dropdown/dropdown';
import { DropdownButton } from '../base/dropdown/dropdown-button';
import { DropdownDivider } from '../base/dropdown/dropdown-divider';
import { DropdownItem } from '../base/dropdown/dropdown-item';
@ -19,7 +19,7 @@ interface Props {
showGrpc?: boolean;
}
export const MethodDropdown = forwardRef<Dropdown, Props>(({
export const MethodDropdown = forwardRef<DropdownHandle, Props>(({
className,
method,
onChange,

View File

@ -15,7 +15,7 @@ import { incrementDeletedRequests } from '../../../models/stats';
import type { RequestAction } from '../../../plugins';
import { getRequestActions } from '../../../plugins';
import * as pluginContexts from '../../../plugins/context/index';
import { Dropdown, DropdownProps } from '../base/dropdown/dropdown';
import { type DropdownHandle, type DropdownProps, Dropdown } from '../base/dropdown/dropdown';
import { DropdownButton } from '../base/dropdown/dropdown-button';
import { DropdownDivider } from '../base/dropdown/dropdown-divider';
import { DropdownHint } from '../base/dropdown/dropdown-hint';
@ -37,7 +37,7 @@ interface Props extends Pick<DropdownProps, 'right'> {
requestGroup?: RequestGroup;
}
export const RequestActionsDropdown = forwardRef<Dropdown, Props>(({
export const RequestActionsDropdown = forwardRef<DropdownHandle, Props>(({
activeEnvironment,
activeProject,
handleCopyAsCurl,
@ -49,6 +49,7 @@ export const RequestActionsDropdown = forwardRef<Dropdown, Props>(({
isPinned,
request,
requestGroup,
right,
}, ref) => {
const [actionPlugins, setActionPlugins] = useState<RequestAction[]>([]);
const [loadingActions, setLoadingActions] = useState<Record<string, boolean>>({});
@ -109,7 +110,7 @@ export const RequestActionsDropdown = forwardRef<Dropdown, Props>(({
// Can only generate code for regular requests, not gRPC requests
const canGenerateCode = isRequest(request);
return (
<Dropdown ref={ref} onOpen={onOpen}>
<Dropdown right={right} ref={ref} onOpen={onOpen}>
<DropdownButton>
<i className="fa fa-caret-down" />
</DropdownButton>

View File

@ -13,7 +13,7 @@ import * as pluginContexts from '../../../plugins/context/index';
import { createRequest, CreateRequestType } from '../../hooks/create-request';
import { createRequestGroup } from '../../hooks/create-request-group';
import { selectActiveEnvironment, selectActiveProject, selectActiveWorkspace } from '../../redux/selectors';
import { Dropdown, DropdownProps } from '../base/dropdown/dropdown';
import { type DropdownHandle, type DropdownProps, Dropdown } from '../base/dropdown/dropdown';
import { DropdownButton } from '../base/dropdown/dropdown-button';
import { DropdownDivider } from '../base/dropdown/dropdown-divider';
import { DropdownHint } from '../base/dropdown/dropdown-hint';
@ -42,7 +42,7 @@ export const RequestGroupActionsDropdown = forwardRef<RequestGroupActionsDropdow
}, ref) => {
const [actionPlugins, setActionPlugins] = useState<RequestGroupAction[]>([]);
const [loadingActions, setLoadingActions] = useState< Record<string, boolean>>({});
const dropdownRef = useRef<Dropdown>(null);
const dropdownRef = useRef<DropdownHandle>(null);
const activeProject = useSelector(selectActiveProject);
const activeEnvironment = useSelector(selectActiveEnvironment);

View File

@ -7,7 +7,7 @@ import { decompressObject } from '../../../common/misc';
import type { Environment } from '../../../models/environment';
import type { RequestVersion } from '../../../models/request-version';
import type { Response } from '../../../models/response';
import { Dropdown } from '../base/dropdown/dropdown';
import { type DropdownHandle, Dropdown } from '../base/dropdown/dropdown';
import { DropdownButton } from '../base/dropdown/dropdown-button';
import { DropdownDivider } from '../base/dropdown/dropdown-divider';
import { DropdownItem } from '../base/dropdown/dropdown-item';
@ -42,7 +42,7 @@ export const ResponseHistoryDropdown: FC<Props> = ({
requestVersions,
responses,
}) => {
const dropdownRef = useRef<Dropdown>(null);
const dropdownRef = useRef<DropdownHandle>(null);
const now = new Date();
const categories: Record<string, Response[]> = {

View File

@ -14,7 +14,7 @@ import { ConfigGenerator, getConfigGenerators, getWorkspaceActions } from '../..
import * as pluginContexts from '../../../plugins/context';
import { selectIsLoading } from '../../redux/modules/global';
import { selectActiveApiSpec, selectActiveEnvironment, selectActiveProject, selectActiveWorkspace, selectActiveWorkspaceName, selectSettings } from '../../redux/selectors';
import { Dropdown } from '../base/dropdown/dropdown';
import { type DropdownHandle, Dropdown } from '../base/dropdown/dropdown';
import { DropdownButton } from '../base/dropdown/dropdown-button';
import { DropdownDivider } from '../base/dropdown/dropdown-divider';
import { DropdownHint } from '../base/dropdown/dropdown-hint';
@ -36,7 +36,7 @@ export const WorkspaceDropdown: FC = () => {
const [actionPlugins, setActionPlugins] = useState<WorkspaceAction[]>([]);
const [configGeneratorPlugins, setConfigGeneratorPlugins] = useState<ConfigGenerator[]>([]);
const [loadingActions, setLoadingActions] = useState<Record<string, boolean>>({});
const dropdownRef = useRef<Dropdown>(null);
const dropdownRef = useRef<DropdownHandle>(null);
const handlePluginClick = useCallback(async ({ action, plugin, label }: WorkspaceAction, workspace: Workspace) => {
setLoadingActions({ ...loadingActions, [label]: true });

View File

@ -6,7 +6,7 @@ import { hotKeyRefs } from '../../common/hotkeys';
import { executeHotKey } from '../../common/hotkeys-listener';
import type { Request } from '../../models/request';
import { useTimeoutWhen } from '../hooks/useTimeoutWhen';
import { Dropdown } from './base/dropdown/dropdown';
import { type DropdownHandle, Dropdown } from './base/dropdown/dropdown';
import { DropdownButton } from './base/dropdown/dropdown-button';
import { DropdownDivider } from './base/dropdown/dropdown-divider';
import { DropdownHint } from './base/dropdown/dropdown-hint';
@ -51,8 +51,8 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
uniquenessKey,
}, ref) => {
const methodDropdownRef = useRef<Dropdown>(null);
const dropdownRef = useRef<Dropdown>(null);
const methodDropdownRef = useRef<DropdownHandle>(null);
const dropdownRef = useRef<DropdownHandle>(null);
const inputRef = useRef<OneLineEditor>(null);
const focusInput = useCallback(() => {

View File

@ -13,7 +13,7 @@ import { RequestGroup } from '../../../models/request-group';
import { useNunjucks } from '../../context/nunjucks/use-nunjucks';
import { createRequest } from '../../hooks/create-request';
import { selectActiveEnvironment, selectActiveProject, selectActiveWorkspace } from '../../redux/selectors';
import { Dropdown } from '../base/dropdown/dropdown';
import type { DropdownHandle } from '../base/dropdown/dropdown';
import { Editable } from '../base/editable';
import { Highlight } from '../base/highlight';
import { RequestActionsDropdown } from '../dropdowns/request-actions-dropdown';
@ -99,7 +99,7 @@ export const _SidebarRequestRow: FC<Props> = forwardRef(({
const [renderedUrl, setRenderedUrl] = useState('');
const requestActionsDropdown = useRef<Dropdown>(null);
const requestActionsDropdown = useRef<DropdownHandle>(null);
const handleShowRequestActions = useCallback((event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault();