From 96ccc666411738efa6bc1315b22437ff8138017c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 2 Apr 2022 13:03:12 -0500 Subject: [PATCH] Convert DropdownMenu to typescript --- .../{dropdown_menu.js => dropdown_menu.tsx} | 213 +++++++++++------- app/soapbox/components/status_action_bar.tsx | 15 +- .../components/ui/icon-button/icon-button.tsx | 7 +- .../containers/dropdown_menu_container.js | 29 --- .../containers/dropdown_menu_container.ts | 38 ++++ 5 files changed, 175 insertions(+), 127 deletions(-) rename app/soapbox/components/{dropdown_menu.js => dropdown_menu.tsx} (56%) delete mode 100644 app/soapbox/containers/dropdown_menu_container.js create mode 100644 app/soapbox/containers/dropdown_menu_container.ts diff --git a/app/soapbox/components/dropdown_menu.js b/app/soapbox/components/dropdown_menu.tsx similarity index 56% rename from app/soapbox/components/dropdown_menu.js rename to app/soapbox/components/dropdown_menu.tsx index c471ac27b..7d13d6829 100644 --- a/app/soapbox/components/dropdown_menu.js +++ b/app/soapbox/components/dropdown_menu.tsx @@ -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, + middleClick?: React.EventHandler, + 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; - 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 { + + static defaultProps: Partial = { 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 = c => { this.node = c; } - setFocusRef = c => { + setFocusRef: React.RefCallback = 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 = e => { if (e.key === 'Enter' || e.key === ' ') { this.handleClick(e); } } - handleClick = e => { + handleClick: React.EventHandler = 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 = 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 = e => { if (e.button === 1) { this.handleMiddleClick(e); } } - renderItem(option, i) { + renderItem(option: MenuItem | null, i: number): JSX.Element { if (option === null) { return
  • ; } @@ -157,14 +181,14 @@ class DropdownMenu extends React.PureComponent { {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 -
    +
      {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, + dropdownPlacement: DropdownPlacement, + keyboard: boolean, + ) => void, + onClose?: (id: number) => void, + dropdownPlacement?: string, + openDropdownId?: number, + openedViaKeyboard?: boolean, + text?: string, + onShiftClick?: React.EventHandler, +} - static defaultProps = { +interface IDropdownState { + id: number, + open: boolean, +} + +export type DropdownPlacement = 'top' | 'bottom'; + +class Dropdown extends React.PureComponent { + + static defaultProps: Partial = { title: 'Menu', }; state = { id: id++, + open: false, }; - handleClick = e => { + target: HTMLButtonElement | null = null; + activeElement: Element | null = null; + + handleClick: React.EventHandler | React.KeyboardEvent> = 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; } - this.props.onClose(this.state.id); + + if (this.props.onClose) { + this.props.onClose(this.state.id); + } } - handleMouseDown = () => { + handleMouseDown: React.EventHandler = () => { if (!this.state.open) { this.activeElement = document.activeElement; } } - handleButtonKeyDown = (e) => { + handleButtonKeyDown: React.EventHandler = (e) => { switch(e.key) { case ' ': case 'Enter': - this.handleMouseDown(); + this.handleMouseDown(e); break; } } - handleKeyPress = (e) => { + handleKeyPress: React.EventHandler> = (e) => { switch(e.key) { case ' ': case 'Enter': @@ -279,9 +322,12 @@ class Dropdown extends React.PureComponent { } } - handleItemClick = e => { + handleItemClick: React.EventHandler = 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 = 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 { /> - + ); } } + +export default withRouter(Dropdown); diff --git a/app/soapbox/components/status_action_bar.tsx b/app/soapbox/components/status_action_bar.tsx index b98f017a9..558d96bb7 100644 --- a/app/soapbox/components/status_action_bar.tsx +++ b/app/soapbox/components/status_action_bar.tsx @@ -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 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 ); @@ -714,11 +713,9 @@ class StatusActionBar extends ImmutablePureComponent
    diff --git a/app/soapbox/components/ui/icon-button/icon-button.tsx b/app/soapbox/components/ui/icon-button/icon-button.tsx index 2d8c27b7f..291b0b4f8 100644 --- a/app/soapbox/components/ui/icon-button/icon-button.tsx +++ b/app/soapbox/components/ui/icon-button/icon-button.tsx @@ -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 { iconClassName?: string, - disabled?: boolean, src: string, - onClick?: React.EventHandler, text?: string, - title?: string, transparent?: boolean } diff --git a/app/soapbox/containers/dropdown_menu_container.js b/app/soapbox/containers/dropdown_menu_container.js deleted file mode 100644 index 57f4d5468..000000000 --- a/app/soapbox/containers/dropdown_menu_container.js +++ /dev/null @@ -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); diff --git a/app/soapbox/containers/dropdown_menu_container.ts b/app/soapbox/containers/dropdown_menu_container.ts new file mode 100644 index 000000000..fa149d0f4 --- /dev/null +++ b/app/soapbox/containers/dropdown_menu_container.ts @@ -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) => ({ + onOpen( + id: number, + onItemClick: React.EventHandler, + 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);