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

View file

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

View file

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