soapbox/app/soapbox/components/dropdown-menu.tsx

421 wiersze
12 KiB
TypeScript
Czysty Zwykły widok Historia

import classNames from 'clsx';
import { supportsPassiveEvents } from 'detect-passive-events';
import React from 'react';
2022-04-02 18:03:12 +00:00
import { spring } from 'react-motion';
// @ts-ignore: TODO: upgrade react-overlays. v3.1 and above have TS definitions
import Overlay from 'react-overlays/lib/Overlay';
2022-04-02 18:03:12 +00:00
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { Counter, IconButton } from 'soapbox/components/ui';
2022-04-24 22:53:03 +00:00
import SvgIcon from 'soapbox/components/ui/icon/svg-icon';
2022-11-16 13:32:32 +00:00
import Motion from 'soapbox/features/ui/util/optional-motion';
2022-04-02 18:03:12 +00:00
import type { Status } from 'soapbox/types/entities';
2020-03-27 20:59:38 +00:00
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
2020-03-27 20:59:38 +00:00
let id = 0;
2022-04-02 18:03:12 +00:00
export interface MenuItem {
action?: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
2022-04-02 18:03:12 +00:00
middleClick?: React.EventHandler<React.MouseEvent>,
text: string,
2022-04-02 18:03:12 +00:00
href?: string,
to?: string,
newTab?: boolean,
isLogout?: boolean,
icon?: string,
2022-04-28 21:20:21 +00:00
count?: number,
2022-04-02 18:03:12 +00:00
destructive?: boolean,
meta?: string,
active?: boolean,
2022-04-02 18:03:12 +00:00
}
export type Menu = Array<MenuItem | null>;
interface IDropdownMenu extends RouteComponentProps {
items: Menu,
onClose: () => void,
style?: React.CSSProperties,
placement?: DropdownPlacement,
arrowOffsetLeft?: string,
arrowOffsetTop?: string,
openedViaKeyboard: boolean,
}
2020-03-27 20:59:38 +00:00
2022-04-02 18:03:12 +00:00
interface IDropdownMenuState {
mounted: boolean,
}
class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState> {
static defaultProps: Partial<IDropdownMenu> = {
2020-03-27 20:59:38 +00:00
style: {},
placement: 'bottom',
};
state = {
mounted: false,
};
2022-04-02 18:03:12 +00:00
node: HTMLDivElement | null = null;
focusedItem: HTMLAnchorElement | null = null;
handleDocumentClick = (e: Event) => {
if (this.node && !this.node.contains(e.target as Node)) {
2020-03-27 20:59:38 +00:00
this.props.onClose();
}
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
componentDidMount() {
2020-03-27 20:59:38 +00:00
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('keydown', this.handleKeyDown, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus({ preventScroll: true });
}
2020-03-27 20:59:38 +00:00
this.setState({ mounted: true });
}
componentWillUnmount() {
2022-04-02 18:03:12 +00:00
document.removeEventListener('click', this.handleDocumentClick);
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('touchend', this.handleDocumentClick);
2020-03-27 20:59:38 +00:00
}
2022-04-02 18:03:12 +00:00
setRef: React.RefCallback<HTMLDivElement> = c => {
2020-03-27 20:59:38 +00:00
this.node = c;
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-04-02 18:03:12 +00:00
setFocusRef: React.RefCallback<HTMLAnchorElement> = c => {
2020-03-27 20:59:38 +00:00
this.focusedItem = c;
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-04-02 18:03:12 +00:00
handleKeyDown = (e: KeyboardEvent) => {
if (!this.node) return;
2020-03-27 20:59:38 +00:00
const items = Array.from(this.node.getElementsByTagName('a'));
2022-04-02 18:03:12 +00:00
const index = items.indexOf(document.activeElement as any);
let element = null;
2020-03-27 20:59:38 +00:00
switch (e.key) {
2022-05-11 21:06:35 +00:00
case 'ArrowDown':
element = items[index + 1] || items[0];
2022-05-11 21:06:35 +00:00
break;
case 'ArrowUp':
element = items[index - 1] || items[items.length - 1];
break;
case 'Tab':
if (e.shiftKey) {
element = items[index - 1] || items[items.length - 1];
} else {
element = items[index + 1] || items[0];
}
break;
case 'Home':
element = items[0];
break;
case 'End':
element = items[items.length - 1];
break;
case 'Escape':
this.props.onClose();
break;
2020-03-27 20:59:38 +00:00
}
if (element) {
element.focus();
e.preventDefault();
e.stopPropagation();
}
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-04-02 18:03:12 +00:00
handleItemKeyPress: React.EventHandler<React.KeyboardEvent> = e => {
if (e.key === 'Enter' || e.key === ' ') {
2020-03-27 20:59:38 +00:00
this.handleClick(e);
}
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-04-02 18:03:12 +00:00
handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = e => {
2020-03-27 20:59:38 +00:00
const i = Number(e.currentTarget.getAttribute('data-index'));
2022-04-02 18:03:12 +00:00
const item = this.props.items[i];
if (!item) return;
const { action, to } = item;
2020-03-27 20:59:38 +00:00
this.props.onClose();
e.stopPropagation();
if (to) {
2020-03-27 20:59:38 +00:00
e.preventDefault();
2022-03-18 01:17:28 +00:00
this.props.history.push(to);
} else if (typeof action === 'function') {
e.preventDefault();
action(e);
2020-03-27 20:59:38 +00:00
}
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-04-02 18:03:12 +00:00
handleMiddleClick: React.EventHandler<React.MouseEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
2022-04-02 18:03:12 +00:00
const item = this.props.items[i];
if (!item) return;
const { middleClick } = item;
this.props.onClose();
if (e.button === 1 && typeof middleClick === 'function') {
e.preventDefault();
middleClick(e);
}
2023-01-05 17:55:08 +00:00
};
2022-04-02 18:03:12 +00:00
handleAuxClick: React.EventHandler<React.MouseEvent> = e => {
if (e.button === 1) {
this.handleMiddleClick(e);
}
2023-01-05 17:55:08 +00:00
};
2022-04-02 18:03:12 +00:00
renderItem(option: MenuItem | null, i: number): JSX.Element {
2020-03-27 20:59:38 +00:00
if (option === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
2022-04-28 21:20:21 +00:00
const { text, href, to, newTab, isLogout, icon, count, destructive } = option;
2020-03-27 20:59:38 +00:00
return (
2022-04-24 22:53:03 +00:00
<li className={classNames('dropdown-menu__item truncate', { destructive })} key={`${text}-${i}`}>
2020-03-27 20:59:38 +00:00
<a
2021-03-30 04:26:50 +00:00
href={href || to || '#'}
2020-03-27 20:59:38 +00:00
role='button'
2022-04-02 18:03:12 +00:00
tabIndex={0}
2020-03-27 20:59:38 +00:00
ref={i === 0 ? this.setFocusRef : null}
onClick={this.handleClick}
onAuxClick={this.handleAuxClick}
onKeyPress={this.handleItemKeyPress}
2020-03-27 20:59:38 +00:00
data-index={i}
2022-04-02 18:03:12 +00:00
target={newTab ? '_blank' : undefined}
data-method={isLogout ? 'delete' : undefined}
title={text}
2020-04-14 18:44:40 +00:00
>
{icon && <SvgIcon src={icon} className='mr-3 rtl:ml-3 rtl:mr-0 h-5 w-5 flex-none' />}
2022-04-28 21:20:21 +00:00
2022-04-24 22:53:03 +00:00
<span className='truncate'>{text}</span>
2022-04-28 21:20:21 +00:00
{count ? (
2022-04-28 21:29:15 +00:00
<span className='ml-auto h-5 w-5 flex-none'>
<Counter count={count} />
2022-04-28 21:20:21 +00:00
</span>
) : null}
2020-03-27 20:59:38 +00:00
</a>
</li>
);
}
render() {
2020-03-27 20:59:38 +00:00
const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props;
const { mounted } = this.state;
return (
<Motion defaultStyle={{ opacity: 0, scaleX: 1, scaleY: 1 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
{({ opacity, scaleX, scaleY }) => (
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
2022-09-12 18:42:15 +00:00
<div
className={`dropdown-menu ${placement}`}
style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }}
ref={this.setRef}
data-testid='dropdown-menu'
>
2020-03-27 20:59:38 +00:00
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
<ul>
{items.map((option, i) => this.renderItem(option, i))}
</ul>
</div>
)}
</Motion>
);
}
}
2022-04-02 18:03:12 +00:00
const RouterDropdownMenu = withRouter(DropdownMenu);
export interface IDropdown extends RouteComponentProps {
icon?: string,
2022-04-02 23:43:34 +00:00
src?: string,
2022-04-02 18:03:12 +00:00
items: Menu,
size?: number,
active?: boolean,
pressed?: boolean,
2022-04-02 23:43:34 +00:00
title?: string,
2022-04-02 18:03:12 +00:00
disabled?: boolean,
status?: Status,
isUserTouching?: () => boolean,
isModalOpen?: boolean,
onOpen?: (
id: number,
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
dropdownPlacement: DropdownPlacement,
keyboard: boolean,
) => void,
onClose?: (id: number) => void,
dropdownPlacement?: string,
openDropdownId?: number | null,
2022-04-02 18:03:12 +00:00
openedViaKeyboard?: boolean,
text?: string,
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
2022-04-02 23:43:34 +00:00
children?: JSX.Element,
dropdownMenuStyle?: React.CSSProperties,
2022-04-02 18:03:12 +00:00
}
interface IDropdownState {
id: number,
open: boolean,
}
2020-03-27 20:59:38 +00:00
2022-04-02 18:03:12 +00:00
export type DropdownPlacement = 'top' | 'bottom';
class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
static defaultProps: Partial<IDropdown> = {
2020-03-27 20:59:38 +00:00
title: 'Menu',
};
state = {
id: id++,
2022-04-02 18:03:12 +00:00
open: false,
2020-03-27 20:59:38 +00:00
};
2022-04-02 18:03:12 +00:00
target: HTMLButtonElement | null = null;
activeElement: Element | null = null;
handleClick: React.EventHandler<React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>> = e => {
const { onOpen, onShiftClick, openDropdownId } = this.props;
2022-03-21 18:09:01 +00:00
e.stopPropagation();
if (onShiftClick && e.shiftKey) {
e.preventDefault();
onShiftClick(e);
} else if (this.state.id === openDropdownId) {
2020-03-27 20:59:38 +00:00
this.handleClose();
} else if (onOpen) {
2022-04-02 18:03:12 +00:00
const { top } = e.currentTarget.getBoundingClientRect();
const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top';
2020-03-27 20:59:38 +00:00
onOpen(this.state.id, this.handleItemClick, placement, e.type !== 'click');
2020-03-27 20:59:38 +00:00
}
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
handleClose = () => {
2022-04-02 18:03:12 +00:00
if (this.activeElement && this.activeElement === this.target) {
(this.activeElement as HTMLButtonElement).focus();
this.activeElement = null;
}
2022-04-02 18:03:12 +00:00
if (this.props.onClose) {
this.props.onClose(this.state.id);
}
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-04-02 18:03:12 +00:00
handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
2023-01-05 17:55:08 +00:00
};
2022-04-02 18:03:12 +00:00
handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (e) => {
switch (e.key) {
2022-05-11 21:06:35 +00:00
case ' ':
case 'Enter':
this.handleMouseDown(e);
break;
}
2023-01-05 17:55:08 +00:00
};
2022-04-02 18:03:12 +00:00
handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (e) => {
switch (e.key) {
2022-05-11 21:06:35 +00:00
case ' ':
case 'Enter':
this.handleClick(e);
e.stopPropagation();
e.preventDefault();
break;
}
2023-01-05 17:55:08 +00:00
};
2022-04-02 18:03:12 +00:00
handleItemClick: React.EventHandler<React.MouseEvent> = e => {
2020-03-27 20:59:38 +00:00
const i = Number(e.currentTarget.getAttribute('data-index'));
2022-04-02 18:03:12 +00:00
const item = this.props.items[i];
if (!item) return;
const { action, to } = item;
2020-03-27 20:59:38 +00:00
this.handleClose();
2022-03-21 18:09:01 +00:00
e.preventDefault();
e.stopPropagation();
2020-03-27 20:59:38 +00:00
if (typeof action === 'function') {
2022-03-21 18:09:01 +00:00
action(e);
2020-03-27 20:59:38 +00:00
} else if (to) {
2022-04-02 18:03:12 +00:00
this.props.history?.push(to);
2020-03-27 20:59:38 +00:00
}
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
2022-04-02 18:03:12 +00:00
setTargetRef: React.RefCallback<HTMLButtonElement> = c => {
2020-03-27 20:59:38 +00:00
this.target = c;
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
findTarget = () => {
return this.target;
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
componentWillUnmount = () => {
if (this.state.id === this.props.openDropdownId) {
this.handleClose();
}
2023-01-05 17:55:08 +00:00
};
2020-03-27 20:59:38 +00:00
render() {
const { src = require('@tabler/icons/dots.svg'), items, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard = false, pressed, text, children, dropdownMenuStyle } = this.props;
2020-03-27 20:59:38 +00:00
const open = this.state.id === openDropdownId;
return (
2022-03-21 18:09:01 +00:00
<>
2022-04-02 23:43:34 +00:00
{children ? (
React.cloneElement(children, {
disabled,
onClick: this.handleClick,
onMouseDown: this.handleMouseDown,
onKeyDown: this.handleButtonKeyDown,
onKeyPress: this.handleKeyPress,
ref: this.setTargetRef,
})
) : (
<IconButton
disabled={disabled}
className={classNames({
'text-gray-600 hover:text-gray-700 dark:hover:text-gray-500': true,
'text-gray-700 dark:text-gray-500': open,
2022-04-02 23:43:34 +00:00
})}
title={title}
src={src}
aria-pressed={pressed}
text={text}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
ref={this.setTargetRef}
/>
)}
2020-03-27 20:59:38 +00:00
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
<RouterDropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} style={dropdownMenuStyle} />
2020-03-27 20:59:38 +00:00
</Overlay>
2022-03-21 18:09:01 +00:00
</>
2020-03-27 20:59:38 +00:00
);
}
}
2022-04-02 18:03:12 +00:00
export default withRouter(Dropdown);