src/dropdown/dropdown.tsx (194 lines of code) (raw):
import {cloneElement, Component, type HTMLAttributes, type ReactNode, type ReactElement} from 'react';
import * as React from 'react';
import classNames from 'classnames';
import dataTests from '../global/data-tests';
import {type PopupAttrs} from '../popup/popup';
import {isArray} from '../global/typescript-utils';
import Anchor from './anchor';
import styles from './dropdown.css';
export interface AnchorProps {
active: boolean;
pinned: boolean;
}
export interface DropdownChildProps {
hidden: boolean;
onCloseAttempt: () => void;
onMouseDown?: () => void | undefined;
onContextMenu?: () => void | undefined;
dontCloseOnAnchorClick: boolean;
}
export type DropdownChildrenFunction = (props: Omit<PopupAttrs, 'children'>) => ReactNode;
export type DropdownChildren = ReactElement<PopupAttrs> | DropdownChildrenFunction;
export interface DropdownProps extends Omit<HTMLAttributes<HTMLElement>, 'children'> {
/**
* Can be string, React element, or a function accepting an object with {active, pinned} properties and returning a React element
* React element should render some interactive HTML element like `button` or `a`
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
anchor: ReactElement<any> | readonly ReactElement<any>[] | string | ((props: AnchorProps) => ReactNode);
children: DropdownChildren;
initShown: boolean;
disabled?: boolean | null | undefined;
clickMode: boolean;
hoverMode: boolean;
hoverShowTimeOut: number;
hoverHideTimeOut: number;
onShow: () => void;
onHide: () => void;
activeClassName?: string | null | undefined;
'data-test'?: string | null | undefined;
}
interface DropdownState {
show: boolean;
pinned: boolean;
}
/**
* @name Dropdown
*/
export default class Dropdown extends Component<DropdownProps, DropdownState> {
static defaultProps = {
initShown: false,
clickMode: true,
hoverMode: false,
hoverShowTimeOut: 300,
hoverHideTimeOut: 600,
disabled: false,
onShow: () => {},
onHide: () => {},
onMouseEnter: () => {},
onMouseLeave: () => {},
};
state = {
show: this.props.initShown,
pinned: false,
};
onClick = () => {
if (this.props.disabled) {
return;
}
const {show, pinned} = this.state;
let nextPinned = pinned;
if (this.props.hoverMode) {
if (!pinned) {
nextPinned = true;
if (show) {
this.setState({pinned: true});
return;
}
} else {
nextPinned = false;
}
}
this._toggle(!show, nextPinned);
};
onChildCloseAttempt = () => {
let nextPinned = this.state.pinned;
if (this.props.hoverMode) {
nextPinned = false;
}
this._toggle(false, nextPinned);
};
hoverTimer?: number | null;
onMouseEnter = (event: React.MouseEvent<HTMLElement>) => {
if (this.props.disabled) {
return;
}
this._clearTimer();
this.props.onMouseEnter?.(event);
this.hoverTimer = window.setTimeout(() => {
if (!this.state.show) {
this._toggle(true);
}
}, this.props.hoverShowTimeOut);
};
onMouseLeave = (event: React.MouseEvent<HTMLElement>) => {
if (this.props.disabled) {
return;
}
this.props.onMouseLeave?.(event);
if (this.state.pinned) {
return;
}
this._clearTimer();
this.hoverTimer = window.setTimeout(() => {
if (this.state.show) {
this._toggle(false);
}
}, this.props.hoverHideTimeOut);
};
handlePopupInteraction = () => {
this.setState(({pinned}) => (pinned ? null : {pinned: true}));
};
toggle(show = !this.state.show) {
this._toggle(show);
}
_toggle(show: boolean, pinned = this.state.pinned) {
this.setState({show, pinned}, () => (show ? this.props.onShow() : this.props.onHide()));
}
_clearTimer() {
if (this.hoverTimer) {
clearTimeout(this.hoverTimer);
this.hoverTimer = null;
}
}
render() {
const {show, pinned} = this.state;
const {
initShown,
onShow,
onHide,
hoverShowTimeOut,
hoverHideTimeOut,
children,
anchor,
className,
activeClassName,
hoverMode,
clickMode,
'data-test': dataTest,
disabled,
...restProps
} = this.props;
const classes = classNames(styles.dropdown, className, {
[activeClassName ?? '']: activeClassName && show,
});
let anchorElement;
const active = hoverMode ? pinned : show;
switch (typeof anchor) {
case 'string':
anchorElement = <Anchor active={active}>{anchor}</Anchor>;
break;
case 'function':
anchorElement = anchor({active: show, pinned});
break;
default:
if (isArray(anchor) || typeof anchor.type === 'string') {
anchorElement = anchor;
} else {
anchorElement = cloneElement(anchor, {active});
}
}
const childProps: DropdownChildProps = {
hidden: !show,
onCloseAttempt: this.onChildCloseAttempt,
onMouseDown: hoverMode ? this.handlePopupInteraction : undefined,
onContextMenu: hoverMode ? this.handlePopupInteraction : undefined,
dontCloseOnAnchorClick: true,
};
return (
<div
data-test={dataTests('ring-dropdown', dataTest)}
{...restProps}
onMouseEnter={hoverMode ? this.onMouseEnter : undefined}
onMouseLeave={hoverMode ? this.onMouseLeave : undefined}
className={classes}
>
{clickMode ? (
<div
data-test='ring-dropdown-anchor-click-interceptor'
className={styles.clickInterceptor}
onClick={this.onClick}
// anchorElement should be a `button` or an `a`
role='presentation'
>
{anchorElement}
</div>
) : (
anchorElement
)}
{typeof children === 'function'
? children(childProps)
: cloneElement(children as ReactElement<PopupAttrs>, childProps)}
</div>
);
}
}
export type DropdownAttrs = React.JSX.LibraryManagedAttributes<typeof Dropdown, DropdownProps>;
export {Anchor};