Convert DropdownMenu to typescript

This commit is contained in:
Alex Gleason 2022-04-02 13:03:12 -05:00
parent a080ed8647
commit 96ccc66641
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
5 changed files with 175 additions and 127 deletions

View file

@ -1,36 +1,51 @@
import classNames from 'classnames';
import { supportsPassiveEvents } from 'detect-passive-events';
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import spring from 'react-motion/lib/spring';
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';
import { withRouter } from 'react-router-dom';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import Icon from 'soapbox/components/icon';
import { IconButton } from 'soapbox/components/ui';
import Motion from 'soapbox/features/ui/util/optional_motion';
import Motion from '../features/ui/util/optional_motion';
import { IconButton } from './ui';
import type { Status } from 'soapbox/types/entities';
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
let id = 0;
@withRouter
class DropdownMenu extends React.PureComponent {
export interface MenuItem {
action: React.EventHandler<React.KeyboardEvent | React.MouseEvent>,
middleClick?: React.EventHandler<React.MouseEvent>,
text: string,
href?: string,
to?: string,
newTab?: boolean,
isLogout?: boolean,
icon: string,
destructive?: boolean,
}
static propTypes = {
items: PropTypes.array.isRequired,
onClose: PropTypes.func.isRequired,
style: PropTypes.object,
placement: PropTypes.string,
arrowOffsetLeft: PropTypes.string,
arrowOffsetTop: PropTypes.string,
openedViaKeyboard: PropTypes.bool,
history: PropTypes.object,
};
export type Menu = Array<MenuItem | null>;
static defaultProps = {
interface IDropdownMenu extends RouteComponentProps {
items: Menu,
onClose: () => void,
style?: React.CSSProperties,
placement?: DropdownPlacement,
arrowOffsetLeft?: string,
arrowOffsetTop?: string,
openedViaKeyboard: boolean,
}
interface IDropdownMenuState {
mounted: boolean,
}
class DropdownMenu extends React.PureComponent<IDropdownMenu, IDropdownMenuState> {
static defaultProps: Partial<IDropdownMenu> = {
style: {},
placement: 'bottom',
};
@ -39,8 +54,11 @@ class DropdownMenu extends React.PureComponent {
mounted: false,
};
handleDocumentClick = e => {
if (this.node && !this.node.contains(e.target)) {
node: HTMLDivElement | null = null;
focusedItem: HTMLAnchorElement | null = null;
handleDocumentClick = (e: Event) => {
if (this.node && !this.node.contains(e.target as Node)) {
this.props.onClose();
}
}
@ -56,22 +74,24 @@ class DropdownMenu extends React.PureComponent {
}
componentWillUnmount() {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('keydown', this.handleKeyDown, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
document.removeEventListener('click', this.handleDocumentClick);
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('touchend', this.handleDocumentClick);
}
setRef = c => {
setRef: React.RefCallback<HTMLDivElement> = c => {
this.node = c;
}
setFocusRef = c => {
setFocusRef: React.RefCallback<HTMLAnchorElement> = c => {
this.focusedItem = c;
}
handleKeyDown = e => {
handleKeyDown = (e: KeyboardEvent) => {
if (!this.node) return;
const items = Array.from(this.node.getElementsByTagName('a'));
const index = items.indexOf(document.activeElement);
const index = items.indexOf(document.activeElement as any);
let element = null;
switch(e.key) {
@ -106,15 +126,17 @@ class DropdownMenu extends React.PureComponent {
}
}
handleItemKeyPress = e => {
handleItemKeyPress: React.EventHandler<React.KeyboardEvent> = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e);
}
}
handleClick = e => {
handleClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const { action, to } = this.props.items[i];
const item = this.props.items[i];
if (!item) return;
const { action, to } = item;
this.props.onClose();
@ -127,9 +149,11 @@ class DropdownMenu extends React.PureComponent {
}
}
handleMiddleClick = e => {
handleMiddleClick: React.EventHandler<React.MouseEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const { middleClick } = this.props.items[i];
const item = this.props.items[i];
if (!item) return;
const { middleClick } = item;
this.props.onClose();
@ -139,13 +163,13 @@ class DropdownMenu extends React.PureComponent {
}
}
handleAuxClick = e => {
handleAuxClick: React.EventHandler<React.MouseEvent> = e => {
if (e.button === 1) {
this.handleMiddleClick(e);
}
}
renderItem(option, i) {
renderItem(option: MenuItem | null, i: number): JSX.Element {
if (option === null) {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
@ -157,14 +181,14 @@ class DropdownMenu extends React.PureComponent {
<a
href={href || to || '#'}
role='button'
tabIndex='0'
tabIndex={0}
ref={i === 0 ? this.setFocusRef : null}
onClick={this.handleClick}
onAuxClick={this.handleAuxClick}
onKeyPress={this.handleItemKeyPress}
data-index={i}
target={newTab ? '_blank' : null}
data-method={isLogout ? 'delete' : null}
target={newTab ? '_blank' : undefined}
data-method={isLogout ? 'delete' : undefined}
>
{icon && <Icon src={icon} />}
{text}
@ -182,7 +206,7 @@ class DropdownMenu extends React.PureComponent {
// It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by
// react-overlays
<div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} ref={this.setRef}>
<div className={`dropdown-menu ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : undefined }} ref={this.setRef}>
<div className={`dropdown-menu__arrow ${placement}`} style={{ left: arrowOffsetLeft, top: arrowOffsetTop }} />
<ul>
{items.map((option, i) => this.renderItem(option, i))}
@ -195,40 +219,56 @@ class DropdownMenu extends React.PureComponent {
}
export default @withRouter
class Dropdown extends React.PureComponent {
const RouterDropdownMenu = withRouter(DropdownMenu);
static propTypes = {
icon: PropTypes.string,
src: PropTypes.string,
items: PropTypes.array.isRequired,
size: PropTypes.number,
active: PropTypes.bool,
pressed: PropTypes.bool,
title: PropTypes.string,
disabled: PropTypes.bool,
status: ImmutablePropTypes.record,
isUserTouching: PropTypes.func,
isModalOpen: PropTypes.bool.isRequired,
onOpen: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
dropdownPlacement: PropTypes.string,
openDropdownId: PropTypes.number,
openedViaKeyboard: PropTypes.bool,
text: PropTypes.string,
onShiftClick: PropTypes.func,
history: PropTypes.object,
};
export interface IDropdown extends RouteComponentProps {
icon?: string,
src: string,
items: Menu,
size?: number,
active?: boolean,
pressed?: boolean,
title: string,
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,
openedViaKeyboard?: boolean,
text?: string,
onShiftClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
}
static defaultProps = {
interface IDropdownState {
id: number,
open: boolean,
}
export type DropdownPlacement = 'top' | 'bottom';
class Dropdown extends React.PureComponent<IDropdown, IDropdownState> {
static defaultProps: Partial<IDropdown> = {
title: 'Menu',
};
state = {
id: id++,
open: false,
};
handleClick = e => {
target: HTMLButtonElement | null = null;
activeElement: Element | null = null;
handleClick: React.EventHandler<React.MouseEvent<HTMLButtonElement> | React.KeyboardEvent<HTMLButtonElement>> = e => {
const { onOpen, onShiftClick, openDropdownId } = this.props;
e.stopPropagation();
@ -237,38 +277,41 @@ class Dropdown extends React.PureComponent {
onShiftClick(e);
} else if (this.state.id === openDropdownId) {
this.handleClose();
} else {
const { top } = e.target.getBoundingClientRect();
const placement = top * 2 < innerHeight ? 'bottom' : 'top';
} else if(onOpen) {
const { top } = e.currentTarget.getBoundingClientRect();
const placement: DropdownPlacement = top * 2 < innerHeight ? 'bottom' : 'top';
onOpen(this.state.id, this.handleItemClick, placement, e.type !== 'click');
}
}
handleClose = () => {
if (this.activeElement) {
this.activeElement.focus();
if (this.activeElement && this.activeElement === this.target) {
(this.activeElement as HTMLButtonElement).focus();
this.activeElement = null;
}
if (this.props.onClose) {
this.props.onClose(this.state.id);
}
}
handleMouseDown = () => {
handleMouseDown: React.EventHandler<React.MouseEvent | React.KeyboardEvent> = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
}
handleButtonKeyDown = (e) => {
handleButtonKeyDown: React.EventHandler<React.KeyboardEvent> = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleMouseDown();
this.handleMouseDown(e);
break;
}
}
handleKeyPress = (e) => {
handleKeyPress: React.EventHandler<React.KeyboardEvent<HTMLButtonElement>> = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
@ -279,9 +322,12 @@ class Dropdown extends React.PureComponent {
}
}
handleItemClick = e => {
handleItemClick: React.EventHandler<React.MouseEvent> = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const { action, to } = this.props.items[i];
const item = this.props.items[i];
if (!item) return;
const { action, to } = item;
this.handleClose();
e.preventDefault();
@ -290,11 +336,11 @@ class Dropdown extends React.PureComponent {
if (typeof action === 'function') {
action(e);
} else if (to) {
this.props.history.push(to);
this.props.history?.push(to);
}
}
setTargetRef = c => {
setTargetRef: React.RefCallback<HTMLButtonElement> = c => {
this.target = c;
}
@ -309,7 +355,7 @@ class Dropdown extends React.PureComponent {
}
render() {
const { src, items, size, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard, pressed, text } = this.props;
const { src, items, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard = false, pressed, text } = this.props;
const open = this.state.id === openDropdownId;
return (
@ -322,8 +368,7 @@ class Dropdown extends React.PureComponent {
})}
title={title}
src={src}
pressed={pressed}
size={size}
aria-pressed={pressed}
text={text}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
@ -333,10 +378,12 @@ class Dropdown extends React.PureComponent {
/>
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
<DropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} />
<RouterDropdownMenu items={items} onClose={this.handleClose} openedViaKeyboard={openedViaKeyboard} />
</Overlay>
</>
);
}
}
export default withRouter(Dropdown);

View file

@ -24,6 +24,7 @@ import { IconButton, Hoverable } from './ui';
import type { History } from 'history';
import type { AnyAction, Dispatch } from 'redux';
import type { Menu } from 'soapbox/components/dropdown_menu';
import type { RootState } from 'soapbox/store';
import type { Status } from 'soapbox/types/entities';
import type { Features } from 'soapbox/utils/features';
@ -367,7 +368,7 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
const ownAccount = status.getIn(['account', 'id']) === me;
const username = String(status.getIn(['account', 'username']));
const menu = [];
const menu: Menu = [];
menu.push({
text: intl.formatMessage(messages.open),
@ -487,13 +488,13 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
text: intl.formatMessage(messages.admin_account, { name: username }),
href: `/pleroma/admin/#/users/${status.getIn(['account', 'id'])}/`,
icon: require('@tabler/icons/icons/gavel.svg'),
action: (event: Event) => event.stopPropagation(),
action: (event) => event.stopPropagation(),
});
menu.push({
text: intl.formatMessage(messages.admin_status),
href: `/pleroma/admin/#/statuses/${status.get('id')}/`,
icon: require('@tabler/icons/icons/pencil.svg'),
action: (event: Event) => event.stopPropagation(),
action: (event) => event.stopPropagation(),
});
}
@ -607,13 +608,11 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
reblogButton = (
<DropdownMenuContainer
items={reblogMenu}
// @ts-ignore
disabled={!publicStatus}
active={status.get('reblogged')}
pressed={status.get('reblogged')}
active={status.reblogged}
pressed={status.reblogged}
title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)}
src={reblogIcon}
direction='right'
onShiftClick={this.handleReblogClick}
/>
);
@ -714,11 +713,9 @@ class StatusActionBar extends ImmutablePureComponent<IStatusActionBar, IStatusAc
<StatusAction>
<DropdownMenuContainer
items={menu}
// @ts-ignore
title={intl.formatMessage(messages.more)}
status={status}
src={require('@tabler/icons/icons/dots.svg')}
direction='right'
/>
</StatusAction>
</div>

View file

@ -4,15 +4,10 @@ import InlineSVG from 'react-inlinesvg';
import Text from '../text/text';
interface IIconButton {
alt?: string,
className?: string,
interface IIconButton extends React.ButtonHTMLAttributes<HTMLButtonElement> {
iconClassName?: string,
disabled?: boolean,
src: string,
onClick?: React.EventHandler<React.MouseEvent>,
text?: string,
title?: string,
transparent?: boolean
}

View file

@ -1,29 +0,0 @@
import { connect } from 'react-redux';
import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu';
import { openModal, closeModal } from '../actions/modals';
import DropdownMenu from '../components/dropdown_menu';
import { isUserTouching } from '../is_mobile';
const mapStateToProps = state => ({
isModalOpen: Boolean(state.get('modals').size && state.get('modals').last().modalType === 'ACTIONS'),
dropdownPlacement: state.getIn(['dropdown_menu', 'placement']),
openDropdownId: state.getIn(['dropdown_menu', 'openId']),
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
});
const mapDispatchToProps = (dispatch, { status, items }) => ({
onOpen(id, onItemClick, dropdownPlacement, keyboard) {
dispatch(isUserTouching() ? openModal('ACTIONS', {
status,
actions: items,
onClick: onItemClick,
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
},
onClose(id) {
dispatch(closeModal('ACTIONS'));
dispatch(closeDropdownMenu(id));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);

View file

@ -0,0 +1,38 @@
import { connect } from 'react-redux';
import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu';
import { openModal, closeModal } from '../actions/modals';
import DropdownMenu from '../components/dropdown_menu';
import { isUserTouching } from '../is_mobile';
import type { Dispatch } from 'redux';
import type { DropdownPlacement, IDropdown } from 'soapbox/components/dropdown_menu';
import type { RootState } from 'soapbox/store';
const mapStateToProps = (state: RootState) => ({
isModalOpen: Boolean(state.modals.size && state.modals.last().modalType === 'ACTIONS'),
dropdownPlacement: state.dropdown_menu.get('placement'),
openDropdownId: state.dropdown_menu.get('openId'),
openedViaKeyboard: state.dropdown_menu.get('keyboard'),
});
const mapDispatchToProps = (dispatch: Dispatch, { status, items }: Partial<IDropdown>) => ({
onOpen(
id: number,
onItemClick: React.EventHandler<React.MouseEvent | React.KeyboardEvent>,
dropdownPlacement: DropdownPlacement,
keyboard: boolean,
) {
dispatch(isUserTouching() ? openModal('ACTIONS', {
status,
actions: items,
onClick: onItemClick,
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
},
onClose(id: number) {
dispatch(closeModal('ACTIONS'));
dispatch(closeDropdownMenu(id));
},
});
export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu);