diff --git a/app/soapbox/actions/dropdown-menu.ts b/app/soapbox/actions/dropdown-menu.ts index 0c6fc8536..cad73f364 100644 --- a/app/soapbox/actions/dropdown-menu.ts +++ b/app/soapbox/actions/dropdown-menu.ts @@ -1,13 +1,8 @@ -import type { DropdownPlacement } from 'soapbox/components/dropdown-menu'; - const DROPDOWN_MENU_OPEN = 'DROPDOWN_MENU_OPEN'; const DROPDOWN_MENU_CLOSE = 'DROPDOWN_MENU_CLOSE'; -const openDropdownMenu = (id: number, placement: DropdownPlacement, keyboard: boolean) => - ({ type: DROPDOWN_MENU_OPEN, id, placement, keyboard }); - -const closeDropdownMenu = (id: number) => - ({ type: DROPDOWN_MENU_CLOSE, id }); +const openDropdownMenu = () => ({ type: DROPDOWN_MENU_OPEN }); +const closeDropdownMenu = () => ({ type: DROPDOWN_MENU_CLOSE }); export { DROPDOWN_MENU_OPEN, diff --git a/app/soapbox/components/dropdown-menu.tsx b/app/soapbox/components/dropdown-menu.tsx deleted file mode 100644 index 740c95d28..000000000 --- a/app/soapbox/components/dropdown-menu.tsx +++ /dev/null @@ -1,420 +0,0 @@ -import clsx from 'clsx'; -import { supportsPassiveEvents } from 'detect-passive-events'; -import React from 'react'; -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, RouteComponentProps } from 'react-router-dom'; - -import { Counter, IconButton } from 'soapbox/components/ui'; -import SvgIcon from 'soapbox/components/ui/icon/svg-icon'; -import Motion from 'soapbox/features/ui/util/optional-motion'; - -import type { Status } from 'soapbox/types/entities'; - -const listenerOptions = supportsPassiveEvents ? { passive: true } : false; -let id = 0; - -export interface MenuItem { - action?: React.EventHandler, - middleClick?: React.EventHandler, - text: string, - href?: string, - to?: string, - newTab?: boolean, - isLogout?: boolean, - icon?: string, - count?: number, - destructive?: boolean, - meta?: string, - active?: boolean, -} - -export type Menu = Array; - -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', - }; - - state = { - mounted: false, - }; - - node: HTMLDivElement | null = null; - focusedItem: HTMLAnchorElement | null = null; - - handleDocumentClick = (e: Event) => { - if (this.node && !this.node.contains(e.target as Node)) { - this.props.onClose(); - } - }; - - componentDidMount() { - 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 }); - } - this.setState({ mounted: true }); - } - - componentWillUnmount() { - document.removeEventListener('click', this.handleDocumentClick); - document.removeEventListener('keydown', this.handleKeyDown); - document.removeEventListener('touchend', this.handleDocumentClick); - } - - setRef: React.RefCallback = c => { - this.node = c; - }; - - setFocusRef: React.RefCallback = c => { - this.focusedItem = c; - }; - - handleKeyDown = (e: KeyboardEvent) => { - if (!this.node) return; - - const items = Array.from(this.node.getElementsByTagName('a')); - const index = items.indexOf(document.activeElement as any); - let element = null; - - switch (e.key) { - case 'ArrowDown': - element = items[index + 1] || items[0]; - 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; - } - - if (element) { - element.focus(); - e.preventDefault(); - e.stopPropagation(); - } - }; - - handleItemKeyPress: React.EventHandler = e => { - if (e.key === 'Enter' || e.key === ' ') { - this.handleClick(e); - } - }; - - handleClick: React.EventHandler = e => { - const i = Number(e.currentTarget.getAttribute('data-index')); - const item = this.props.items[i]; - if (!item) return; - const { action, to } = item; - - this.props.onClose(); - - e.stopPropagation(); - - if (to) { - e.preventDefault(); - this.props.history.push(to); - } else if (typeof action === 'function') { - e.preventDefault(); - action(e); - } - }; - - handleMiddleClick: React.EventHandler = e => { - const i = Number(e.currentTarget.getAttribute('data-index')); - 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); - } - }; - - handleAuxClick: React.EventHandler = e => { - if (e.button === 1) { - this.handleMiddleClick(e); - } - }; - - renderItem(option: MenuItem | null, i: number): JSX.Element { - if (option === null) { - return
  • ; - } - - const { text, href, to, newTab, isLogout, icon, count, destructive } = option; - - return ( -
  • - - {icon && } - - {text} - - {count ? ( - - - - ) : null} - -
  • - ); - } - - render() { - const { items, style, placement, arrowOffsetLeft, arrowOffsetTop } = this.props; - const { mounted } = this.state; - return ( - - {({ 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 -
    -
    -
      - {items.map((option, i) => this.renderItem(option, i))} -
    -
    - )} - - ); - } - -} - -const RouterDropdownMenu = withRouter(DropdownMenu); - -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 | null, - openedViaKeyboard?: boolean, - text?: string, - onShiftClick?: React.EventHandler, - children?: JSX.Element, - dropdownMenuStyle?: React.CSSProperties, -} - -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, - }; - - target: HTMLButtonElement | null = null; - activeElement: Element | null = null; - - handleClick: React.EventHandler | React.KeyboardEvent> = e => { - const { onOpen, onShiftClick, openDropdownId } = this.props; - e.stopPropagation(); - - if (onShiftClick && e.shiftKey) { - e.preventDefault(); - onShiftClick(e); - } else if (this.state.id === openDropdownId) { - this.handleClose(); - } 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 === this.target) { - (this.activeElement as HTMLButtonElement).focus(); - this.activeElement = null; - } - - if (this.props.onClose) { - this.props.onClose(this.state.id); - } - }; - - handleMouseDown: React.EventHandler = () => { - if (!this.state.open) { - this.activeElement = document.activeElement; - } - }; - - handleButtonKeyDown: React.EventHandler = (e) => { - switch (e.key) { - case ' ': - case 'Enter': - this.handleMouseDown(e); - break; - } - }; - - handleKeyPress: React.EventHandler> = (e) => { - switch (e.key) { - case ' ': - case 'Enter': - this.handleClick(e); - e.stopPropagation(); - e.preventDefault(); - break; - } - }; - - handleItemClick: React.EventHandler = e => { - const i = Number(e.currentTarget.getAttribute('data-index')); - const item = this.props.items[i]; - if (!item) return; - - const { action, to } = item; - - this.handleClose(); - e.preventDefault(); - e.stopPropagation(); - - if (typeof action === 'function') { - action(e); - } else if (to) { - this.props.history?.push(to); - } - }; - - setTargetRef: React.RefCallback = c => { - this.target = c; - }; - - findTarget = () => { - return this.target; - }; - - componentWillUnmount = () => { - if (this.state.id === this.props.openDropdownId) { - this.handleClose(); - } - }; - - render() { - const { src = require('@tabler/icons/dots.svg'), items, title, disabled, dropdownPlacement, openDropdownId, openedViaKeyboard = false, pressed, text, children, dropdownMenuStyle } = this.props; - const open = this.state.id === openDropdownId; - - return ( - <> - {children ? ( - React.cloneElement(children, { - disabled, - onClick: this.handleClick, - onMouseDown: this.handleMouseDown, - onKeyDown: this.handleButtonKeyDown, - onKeyPress: this.handleKeyPress, - ref: this.setTargetRef, - }) - ) : ( - - )} - - - - - - ); - } - -} - -export default withRouter(Dropdown); diff --git a/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx b/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx new file mode 100644 index 000000000..c4bbbd5f1 --- /dev/null +++ b/app/soapbox/components/dropdown-menu/dropdown-menu-item.tsx @@ -0,0 +1,109 @@ +import clsx from 'clsx'; +import React, { useEffect, useRef } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { Counter, Icon } from '../ui'; + +export interface MenuItem { + action?: React.EventHandler + active?: boolean + count?: number + destructive?: boolean + href?: string + icon?: string + meta?: string + middleClick?(event: React.MouseEvent): void + target?: React.HTMLAttributeAnchorTarget + text: string + to?: string +} + +interface IDropdownMenuItem { + index: number + item: MenuItem | null + onClick?(): void +} + +const DropdownMenuItem = ({ index, item, onClick }: IDropdownMenuItem) => { + const history = useHistory(); + + const itemRef = useRef(null); + + const handleClick: React.EventHandler = (event) => { + event.stopPropagation(); + + if (!item) return; + if (onClick) onClick(); + + + if (item.to) { + event.preventDefault(); + history.push(item.to); + } else if (typeof item.action === 'function') { + event.preventDefault(); + item.action(event); + } + }; + + const handleAuxClick: React.EventHandler = (event) => { + if (!item) return; + if (onClick) onClick(); + + if (event.button === 1 && item.middleClick) { + item.middleClick(event); + } + }; + + const handleItemKeyPress: React.EventHandler = (event) => { + if (event.key === 'Enter' || event.key === ' ') { + handleClick(event); + } + }; + + useEffect(() => { + const firstItem = index === 0; + + if (itemRef.current && firstItem) { + itemRef.current.focus(); + } + }, [itemRef.current, index]); + + if (item === null) { + return
  • ; + } + + return ( +
  • + + {item.icon && } + + {item.text} + + {item.count ? ( + + + + ) : null} + +
  • + ); +}; + +export default DropdownMenuItem; \ No newline at end of file diff --git a/app/soapbox/components/dropdown-menu/dropdown-menu.tsx b/app/soapbox/components/dropdown-menu/dropdown-menu.tsx new file mode 100644 index 000000000..59b279844 --- /dev/null +++ b/app/soapbox/components/dropdown-menu/dropdown-menu.tsx @@ -0,0 +1,299 @@ +import { offset, Placement, useFloating } from '@floating-ui/react'; +import clsx from 'clsx'; +import { supportsPassiveEvents } from 'detect-passive-events'; +import React, { useEffect, useRef, useState } from 'react'; +import { useHistory } from 'react-router-dom'; + +import { closeDropdownMenu, openDropdownMenu } from 'soapbox/actions/dropdown-menu'; +import { closeModal, openModal } from 'soapbox/actions/modals'; +import { useAppDispatch } from 'soapbox/hooks'; +import { isUserTouching } from 'soapbox/is-mobile'; + +import { IconButton, Portal } from '../ui'; + +import DropdownMenuItem, { MenuItem } from './dropdown-menu-item'; + +import type { Status } from 'soapbox/types/entities'; + +export type Menu = Array; + +interface IDropdownMenu { + children?: JSX.Element, + disabled?: boolean, + items: Menu, + onClose?: () => void, + onOpen?: () => void, + onShiftClick?: React.EventHandler, + placement?: Placement, + src?: string, + status?: Status, + title?: string, +} + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +const DropdownMenu = (props: IDropdownMenu) => { + const { + children, + disabled, + items, + onClose, + onOpen, + onShiftClick, + placement = 'top', + src = require('@tabler/icons/dots.svg'), + title = 'Menu', + ...filteredProps + } = props; + + const dispatch = useAppDispatch(); + const history = useHistory(); + + const [isOpen, setIsOpen] = useState(false); + + const activeElement = useRef(null); + const target = useRef(null); + + const isOnMobile = isUserTouching(); + + const { x, y, strategy, refs } = useFloating({ + placement, + middleware: [offset(12)], + }); + + const handleClick: React.EventHandler< + React.MouseEvent | React.KeyboardEvent + > = (event) => { + event.stopPropagation(); + + if (onShiftClick && event.shiftKey) { + event.preventDefault(); + + onShiftClick(event); + return; + } + + if (isOpen) { + handleClose(); + } else { + handleOpen(); + } + }; + + /** + * On mobile screens, let's replace the Popper dropdown with a Modal. + */ + const handleOpen = () => { + if (isOnMobile) { + dispatch( + openModal('ACTIONS', { + status: filteredProps.status, + actions: items, + onClick: handleItemClick, + }), + ); + } else { + dispatch(openDropdownMenu()); + setIsOpen(true); + } + + if (onOpen) { + onOpen(); + } + }; + + const handleClose = () => { + if (activeElement.current && activeElement.current === target.current) { + (activeElement.current as any).focus(); + activeElement.current = null; + } + + if (isOnMobile) { + dispatch(closeModal('ACTIONS')); + } else { + dispatch(closeDropdownMenu()); + setIsOpen(false); + } + + if (onClose) { + onClose(); + } + }; + + const handleMouseDown: React.EventHandler = () => { + if (!isOpen) { + activeElement.current = document.activeElement; + } + }; + + const handleButtonKeyDown: React.EventHandler = (event) => { + switch (event.key) { + case ' ': + case 'Enter': + handleMouseDown(event); + break; + } + }; + + const handleKeyPress: React.EventHandler> = (event) => { + switch (event.key) { + case ' ': + case 'Enter': + event.stopPropagation(); + event.preventDefault(); + handleClick(event); + break; + } + }; + + const handleItemClick: React.EventHandler = (event) => { + event.preventDefault(); + event.stopPropagation(); + + const i = Number(event.currentTarget.getAttribute('data-index')); + const item = items[i]; + if (!item) return; + + const { action, to } = item; + + handleClose(); + + if (typeof action === 'function') { + action(event); + } else if (to) { + history.push(to); + } + }; + + const handleDocumentClick = (event: Event) => { + if (refs.floating.current && !refs.floating.current.contains(event.target as Node)) { + handleClose(); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (!refs.floating.current) return; + + const items = Array.from(refs.floating.current.getElementsByTagName('a')); + const index = items.indexOf(document.activeElement as any); + + let element = null; + + switch (e.key) { + case 'ArrowDown': + element = items[index + 1] || items[0]; + 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': + handleClose(); + break; + } + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + }; + + + useEffect(() => { + document.addEventListener('click', handleDocumentClick, false); + document.addEventListener('keydown', handleKeyDown, false); + document.addEventListener('touchend', handleDocumentClick, listenerOptions); + + return () => { + document.removeEventListener('click', handleDocumentClick); + document.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('touchend', handleDocumentClick); + }; + }, [refs.floating.current]); + + return ( + <> + {children ? ( + React.cloneElement(children, { + disabled, + onClick: handleClick, + onMouseDown: handleMouseDown, + onKeyDown: handleButtonKeyDown, + onKeyPress: handleKeyPress, + ref: refs.setReference, + }) + ) : ( + + )} + + {isOpen ? ( + +
    +
      + {items.map((item, idx) => ( + + ))} +
    + + {/* Arrow */} +
    +
    + + ) : null} + + ); +}; + +export default DropdownMenu; \ No newline at end of file diff --git a/app/soapbox/components/dropdown-menu/index.ts b/app/soapbox/components/dropdown-menu/index.ts new file mode 100644 index 000000000..014166d01 --- /dev/null +++ b/app/soapbox/components/dropdown-menu/index.ts @@ -0,0 +1,3 @@ +export { default } from './dropdown-menu'; +export type { Menu } from './dropdown-menu'; +export type { MenuItem } from './dropdown-menu-item'; \ No newline at end of file diff --git a/app/soapbox/components/sidebar-navigation.tsx b/app/soapbox/components/sidebar-navigation.tsx index f28aa510d..3bbb04923 100644 --- a/app/soapbox/components/sidebar-navigation.tsx +++ b/app/soapbox/components/sidebar-navigation.tsx @@ -2,15 +2,13 @@ import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Stack } from 'soapbox/components/ui'; -import DropdownMenu from 'soapbox/containers/dropdown-menu-container'; import { useStatContext } from 'soapbox/contexts/stat-context'; import ComposeButton from 'soapbox/features/ui/components/compose-button'; import { useAppSelector, useFeatures, useOwnAccount, useSettings } from 'soapbox/hooks'; +import DropdownMenu, { Menu } from './dropdown-menu'; import SidebarNavigationLink from './sidebar-navigation-link'; -import type { Menu } from 'soapbox/components/dropdown-menu'; - const messages = defineMessages({ follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, bookmarks: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, @@ -185,7 +183,7 @@ const SidebarNavigation = () => { )} {menu.length > 0 && ( - + } diff --git a/app/soapbox/components/status-action-bar.tsx b/app/soapbox/components/status-action-bar.tsx index 30c757625..813ed0aae 100644 --- a/app/soapbox/components/status-action-bar.tsx +++ b/app/soapbox/components/status-action-bar.tsx @@ -14,10 +14,10 @@ import { deleteStatusModal, toggleStatusSensitivityModal } from 'soapbox/actions import { initMuteModal } from 'soapbox/actions/mutes'; import { initReport } from 'soapbox/actions/reports'; import { deleteStatus, editStatus, toggleMuteStatus } from 'soapbox/actions/statuses'; +import DropdownMenu from 'soapbox/components/dropdown-menu'; import StatusActionButton from 'soapbox/components/status-action-button'; import StatusReactionWrapper from 'soapbox/components/status-reaction-wrapper'; import { HStack } from 'soapbox/components/ui'; -import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container'; import { useAppDispatch, useAppSelector, useFeatures, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks'; import toast from 'soapbox/toast'; import { isLocal, isRemote } from 'soapbox/utils/accounts'; @@ -617,13 +617,13 @@ const StatusActionBar: React.FC = ({ /> {(features.quotePosts && me) ? ( - {reblogButton} - + ) : ( reblogButton )} @@ -662,12 +662,12 @@ const StatusActionBar: React.FC = ({ /> )} - + - + ); diff --git a/app/soapbox/components/statuses/sensitive-content-overlay.tsx b/app/soapbox/components/statuses/sensitive-content-overlay.tsx index 48f19c003..96539a05f 100644 --- a/app/soapbox/components/statuses/sensitive-content-overlay.tsx +++ b/app/soapbox/components/statuses/sensitive-content-overlay.tsx @@ -4,10 +4,10 @@ import { defineMessages, useIntl } from 'react-intl'; import { openModal } from 'soapbox/actions/modals'; import { deleteStatus } from 'soapbox/actions/statuses'; -import DropdownMenu from 'soapbox/containers/dropdown-menu-container'; import { useAppDispatch, useOwnAccount, useSettings, useSoapboxConfig } from 'soapbox/hooks'; import { defaultMediaVisibility } from 'soapbox/utils/status'; +import DropdownMenu from '../dropdown-menu'; import { Button, HStack, Text } from '../ui'; import type { Status as StatusEntity } from 'soapbox/types/entities'; diff --git a/app/soapbox/components/ui/accordion/accordion.tsx b/app/soapbox/components/ui/accordion/accordion.tsx index b12b5382a..e9df06014 100644 --- a/app/soapbox/components/ui/accordion/accordion.tsx +++ b/app/soapbox/components/ui/accordion/accordion.tsx @@ -2,7 +2,7 @@ import clsx from 'clsx'; import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; -import DropdownMenu from 'soapbox/containers/dropdown-menu-container'; +import DropdownMenu from 'soapbox/components/dropdown-menu'; import HStack from '../hstack/hstack'; import Icon from '../icon/icon'; diff --git a/app/soapbox/components/ui/index.ts b/app/soapbox/components/ui/index.ts index f0ea8f07c..964125cf0 100644 --- a/app/soapbox/components/ui/index.ts +++ b/app/soapbox/components/ui/index.ts @@ -38,6 +38,7 @@ export { } from './menu/menu'; export { default as Modal } from './modal/modal'; export { default as PhoneInput } from './phone-input/phone-input'; +export { default as Portal } from './portal/portal'; export { default as ProgressBar } from './progress-bar/progress-bar'; export { default as RadioButton } from './radio-button/radio-button'; export { default as Select } from './select/select'; diff --git a/app/soapbox/components/ui/portal/portal.tsx b/app/soapbox/components/ui/portal/portal.tsx new file mode 100644 index 000000000..a635a1c99 --- /dev/null +++ b/app/soapbox/components/ui/portal/portal.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; + +interface IPortal { + children: React.ReactNode +} + +/** + * Portal + */ +const Portal: React.FC = ({ children }) => ReactDOM.createPortal( + children, + document.querySelector('#soapbox') as HTMLDivElement, +); + +export default Portal; \ No newline at end of file diff --git a/app/soapbox/containers/dropdown-menu-container.ts b/app/soapbox/containers/dropdown-menu-container.ts deleted file mode 100644 index 67d7fd07e..000000000 --- a/app/soapbox/containers/dropdown-menu-container.ts +++ /dev/null @@ -1,46 +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'; - -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.placement, - openDropdownId: state.dropdown_menu.openId, - openedViaKeyboard: state.dropdown_menu.keyboard, -}); - -const mapDispatchToProps = (dispatch: Dispatch, { status, items, ...filteredProps }: Partial) => ({ - onOpen( - id: number, - onItemClick: React.EventHandler, - dropdownPlacement: DropdownPlacement, - keyboard: boolean, - ) { - dispatch(isUserTouching() ? openModal('ACTIONS', { - status, - actions: items, - onClick: onItemClick, - }) : openDropdownMenu(id, dropdownPlacement, keyboard)); - - if (filteredProps.onOpen) { - filteredProps.onOpen(id, onItemClick, dropdownPlacement, keyboard); - } - }, - onClose(id: number) { - dispatch(closeModal('ACTIONS')); - dispatch(closeDropdownMenu(id)); - - if (filteredProps.onClose) { - filteredProps.onClose(id); - } - }, -}); - -export default connect(mapStateToProps, mapDispatchToProps)(DropdownMenu); diff --git a/app/soapbox/features/account/components/header.tsx b/app/soapbox/features/account/components/header.tsx index 61978f16e..153b961ae 100644 --- a/app/soapbox/features/account/components/header.tsx +++ b/app/soapbox/features/account/components/header.tsx @@ -661,7 +661,7 @@ const Header: React.FC = ({ account }) => { return ; } else { const Comp = (menuItem.action ? MenuItem : MenuLink) as any; - const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' }; + const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.target || '_self' }; return ( diff --git a/app/soapbox/features/admin/components/report-status.tsx b/app/soapbox/features/admin/components/report-status.tsx index 3caacd1a8..cb685a634 100644 --- a/app/soapbox/features/admin/components/report-status.tsx +++ b/app/soapbox/features/admin/components/report-status.tsx @@ -2,10 +2,10 @@ import React from 'react'; import { useIntl, defineMessages } from 'react-intl'; import { deleteStatusModal } from 'soapbox/actions/moderation'; +import DropdownMenu from 'soapbox/components/dropdown-menu'; import StatusContent from 'soapbox/components/status-content'; import StatusMedia from 'soapbox/components/status-media'; import { HStack, Stack } from 'soapbox/components/ui'; -import DropdownMenu from 'soapbox/containers/dropdown-menu-container'; import { useAppDispatch } from 'soapbox/hooks'; import type { AdminReport, Status } from 'soapbox/types/entities'; diff --git a/app/soapbox/features/admin/components/report.tsx b/app/soapbox/features/admin/components/report.tsx index 2d961d095..2929a2bd1 100644 --- a/app/soapbox/features/admin/components/report.tsx +++ b/app/soapbox/features/admin/components/report.tsx @@ -4,9 +4,9 @@ import { Link } from 'react-router-dom'; import { closeReports } from 'soapbox/actions/admin'; import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation'; +import DropdownMenu from 'soapbox/components/dropdown-menu'; import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper'; import { Accordion, Avatar, Button, Stack, HStack, Text } from 'soapbox/components/ui'; -import DropdownMenu from 'soapbox/containers/dropdown-menu-container'; import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; import { makeGetReport } from 'soapbox/selectors'; import toast from 'soapbox/toast'; diff --git a/app/soapbox/features/chats/components/chat-list-item.tsx b/app/soapbox/features/chats/components/chat-list-item.tsx index 795a29a74..37b300f79 100644 --- a/app/soapbox/features/chats/components/chat-list-item.tsx +++ b/app/soapbox/features/chats/components/chat-list-item.tsx @@ -3,10 +3,10 @@ import { defineMessages, useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; import { openModal } from 'soapbox/actions/modals'; +import DropdownMenu from 'soapbox/components/dropdown-menu'; import RelativeTimestamp from 'soapbox/components/relative-timestamp'; import { Avatar, HStack, IconButton, Stack, Text } from 'soapbox/components/ui'; import VerificationBadge from 'soapbox/components/verification-badge'; -import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container'; import { useChatContext } from 'soapbox/contexts/chat-context'; import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks'; import { IChat, useChatActions } from 'soapbox/queries/chats'; @@ -115,14 +115,14 @@ const ChatListItem: React.FC = ({ chat, onClick }) => { {features.chatsDelete && (
    - + - +
    )} diff --git a/app/soapbox/features/chats/components/chat-message.tsx b/app/soapbox/features/chats/components/chat-message.tsx index 06d87a6b3..d2a43c25c 100644 --- a/app/soapbox/features/chats/components/chat-message.tsx +++ b/app/soapbox/features/chats/components/chat-message.tsx @@ -7,8 +7,8 @@ import { defineMessages, useIntl } from 'react-intl'; import { openModal } from 'soapbox/actions/modals'; import { initReport } from 'soapbox/actions/reports'; +import DropdownMenu from 'soapbox/components/dropdown-menu'; import { HStack, Icon, Stack, Text } from 'soapbox/components/ui'; -import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container'; import emojify from 'soapbox/features/emoji/emoji'; import Bundle from 'soapbox/features/ui/components/bundle'; import { MediaGallery } from 'soapbox/features/ui/util/async-components'; @@ -230,7 +230,7 @@ const ChatMessage = (props: IChatMessage) => { ) : null} {menu.length > 0 && ( - setIsMenuOpen(true)} onClose={() => setIsMenuOpen(false)} @@ -248,7 +248,7 @@ const ChatMessage = (props: IChatMessage) => { className='h-4 w-4' /> - + )}
    diff --git a/app/soapbox/features/event/components/event-header.tsx b/app/soapbox/features/event/components/event-header.tsx index 6844acb2d..7a376d6f8 100644 --- a/app/soapbox/features/event/components/event-header.tsx +++ b/app/soapbox/features/event/components/event-header.tsx @@ -396,7 +396,7 @@ const EventHeader: React.FC = ({ status }) => { return ; } else { const Comp = (menuItem.action ? MenuItem : MenuLink) as any; - const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' }; + const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.target || '_self' }; return ( diff --git a/app/soapbox/features/group/group-members.tsx b/app/soapbox/features/group/group-members.tsx index f27946f7f..501bc11a0 100644 --- a/app/soapbox/features/group/group-members.tsx +++ b/app/soapbox/features/group/group-members.tsx @@ -180,7 +180,7 @@ const GroupMember: React.FC = ({ accountId, accountRole, groupId, return ; } else { const Comp = (menuItem.action ? MenuItem : MenuLink) as any; - const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.newTab ? '_blank' : '_self' }; + const itemProps = menuItem.action ? { onSelect: menuItem.action } : { to: menuItem.to, as: Link, target: menuItem.target || '_self' }; return ( diff --git a/app/soapbox/features/theme-editor/index.tsx b/app/soapbox/features/theme-editor/index.tsx index 1cd1c7ce1..ed77371c5 100644 --- a/app/soapbox/features/theme-editor/index.tsx +++ b/app/soapbox/features/theme-editor/index.tsx @@ -5,9 +5,9 @@ import { v4 as uuidv4 } from 'uuid'; import { updateSoapboxConfig } from 'soapbox/actions/admin'; import { getHost } from 'soapbox/actions/instance'; import { fetchSoapboxConfig } from 'soapbox/actions/soapbox'; +import DropdownMenu from 'soapbox/components/dropdown-menu'; import List, { ListItem } from 'soapbox/components/list'; import { Button, Column, Form, FormActions } from 'soapbox/components/ui'; -import DropdownMenuContainer from 'soapbox/containers/dropdown-menu-container'; import ColorWithPicker from 'soapbox/features/soapbox-config/components/color-with-picker'; import { useAppDispatch, useAppSelector, useSoapboxConfig } from 'soapbox/hooks'; import { normalizeSoapboxConfig } from 'soapbox/normalizers'; @@ -194,7 +194,7 @@ const ThemeEditor: React.FC = () => { - = ({ status, actions, onClick, onClo return
  • ; } - const { icon = null, text, meta = null, active = false, href = '#', isLogout, destructive } = action; + const { icon = null, text, meta = null, active = false, href = '#', destructive } = action; const Comp = href === '#' ? 'button' : 'a'; const compProps = href === '#' ? { onClick: onClick } : { href: href, rel: 'noopener' }; @@ -38,7 +38,6 @@ const ActionsModal: React.FC = ({ status, actions, onClick, onClo space={2.5} data-index={i} className={clsx('w-full', { active, destructive })} - data-method={isLogout ? 'delete' : null} element={Comp} > {icon && } diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 38c659379..662d6988e 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -357,7 +357,7 @@ const UI: React.FC = ({ children }) => { const features = useFeatures(); const vapidKey = useAppSelector(state => getVapidKey(state)); - const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.openId !== null); + const dropdownMenuIsOpen = useAppSelector(state => state.dropdown_menu.isOpen); const accessToken = useAppSelector(state => getAccessToken(state)); const streamingUrl = instance.urls.get('streaming_api'); const standalone = useAppSelector(isStandalone); diff --git a/app/soapbox/reducers/dropdown-menu.ts b/app/soapbox/reducers/dropdown-menu.ts index dd5c70369..991e09e0e 100644 --- a/app/soapbox/reducers/dropdown-menu.ts +++ b/app/soapbox/reducers/dropdown-menu.ts @@ -6,12 +6,9 @@ import { } from '../actions/dropdown-menu'; import type { AnyAction } from 'redux'; -import type { DropdownPlacement } from 'soapbox/components/dropdown-menu'; const ReducerRecord = ImmutableRecord({ - openId: null as number | null, - placement: null as any as DropdownPlacement, - keyboard: false, + isOpen: false, }); type State = ReturnType; @@ -19,9 +16,9 @@ type State = ReturnType; export default function dropdownMenu(state: State = ReducerRecord(), action: AnyAction) { switch (action.type) { case DROPDOWN_MENU_OPEN: - return state.merge({ openId: action.id, placement: action.placement, keyboard: action.keyboard }); + return state.set('isOpen', true); case DROPDOWN_MENU_CLOSE: - return state.openId === action.id ? state.set('openId', null) : state; + return state.set('isOpen', false); default: return state; } diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 1910a359c..f3c18c3f9 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -467,7 +467,7 @@ const getInstanceFeatures = (instance: Instance) => { * Whether client settings can be retrieved from the API. * @see GET /api/pleroma/frontend_configurations */ - frontendConfigurations: v.software === PLEROMA, + frontendConfigurations: false, // v.software === PLEROMA, /** * Groups. diff --git a/app/styles/application.scss b/app/styles/application.scss index 4694fccb9..29fd28053 100644 --- a/app/styles/application.scss +++ b/app/styles/application.scss @@ -14,7 +14,6 @@ // COMPONENTS @import 'components/buttons'; -@import 'components/dropdown-menu'; @import 'components/modal'; @import 'components/compose-form'; @import 'components/emoji-reacts'; diff --git a/app/styles/components/dropdown-menu.scss b/app/styles/components/dropdown-menu.scss deleted file mode 100644 index 6c6a1e99b..000000000 --- a/app/styles/components/dropdown-menu.scss +++ /dev/null @@ -1,69 +0,0 @@ -.dropdown-menu { - @apply absolute bg-white dark:bg-gray-900 z-[1001] rounded-md shadow-lg py-1 w-56 dark:ring-2 dark:ring-primary-700 focus:outline-none; - - &.left { transform-origin: 100% 50%; } - &.top { transform-origin: 50% 100%; } - &.bottom { transform-origin: 50% 0; } - &.right { transform-origin: 0 50%; } - - &__arrow { - @apply absolute w-0 h-0; - border: 0 solid transparent; - - &.left { - @apply border-l-white dark:border-l-gray-900; - right: -5px; - margin-top: -5px; - border-width: 5px 0 5px 5px; - } - - &.top { - @apply border-t-white dark:border-t-gray-900; - bottom: -5px; - margin-left: -5px; - border-width: 5px 5px 0; - } - - &.bottom { - @apply border-b-white dark:border-b-gray-900; - top: -5px; - margin-left: -5px; - border-width: 0 5px 5px; - } - - &.right { - @apply border-r-white dark:border-r-gray-900; - left: -5px; - margin-top: -5px; - border-width: 5px 5px 5px 0; - } - } - - ul { - overflow: hidden; - } - - &__item { - @apply focus-within:ring-primary-500 focus-within:ring-2; - - a { - @apply flex px-4 py-2.5 text-sm text-gray-700 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-gray-100 dark:focus:bg-primary-800 cursor-pointer; - - > .svg-icon:first-child { - @apply h-5 w-5 mr-2.5 transition-none; - - svg { - @apply stroke-[1.5px] transition-none; - } - } - } - - &.destructive a { - @apply text-danger-600 dark:text-danger-400; - } - } - - &__separator { - @apply block my-2 h-[1px] bg-gray-100 dark:bg-gray-800; - } -} diff --git a/package.json b/package.json index 006ae7360..f75bbe97a 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.18.6", "@babel/runtime": "^7.20.13", + "@floating-ui/react": "^0.19.1", "@fontsource/inter": "^4.5.1", "@fontsource/roboto-mono": "^4.5.8", "@gamestdio/websocket": "^0.3.2", diff --git a/yarn.lock b/yarn.lock index fa8362347..2ed4166f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1722,6 +1722,34 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@floating-ui/core@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.2.0.tgz#ae7ae7923d41f3d84cb2fd88740a89436610bbec" + integrity sha512-GHUXPEhMEmTpnpIfesFA2KAoMJPb1SPQw964tToQwt+BbGXdhqTCWT1rOb0VURGylsxsYxiGMnseJ3IlclVpVA== + +"@floating-ui/dom@^1.1.1": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.2.0.tgz#a60212069cc58961c478037c30eba4b191c75316" + integrity sha512-QXzg57o1cjLz3cGETzKXjI3kx1xyS49DW9l7kV2jw2c8Yftd434t2hllX0sVGn2Q8MtcW/4pNm8bfE1/4n6mng== + dependencies: + "@floating-ui/core" "^1.2.0" + +"@floating-ui/react-dom@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-1.2.2.tgz#ed256992fd44fcfcddc96da68b4b92f123d61871" + integrity sha512-DbmFBLwFrZhtXgCI2ra7wXYT8L2BN4/4AMQKyu05qzsVji51tXOfF36VE2gpMB6nhJGHa85PdEg75FB4+vnLFQ== + dependencies: + "@floating-ui/dom" "^1.1.1" + +"@floating-ui/react@^0.19.1": + version "0.19.1" + resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.19.1.tgz#bcaeaf3856dfeea388816f7e66750cab26208376" + integrity sha512-h7hr53rLp+VVvWvbu0dOBvGsLeeZwn1DTLIllIaLYjGWw20YhAgEqegHU+nc7BJ30ttxq4Sq6hqARm0ne6chXQ== + dependencies: + "@floating-ui/react-dom" "^1.2.2" + aria-hidden "^1.1.3" + tabbable "^6.0.1" + "@fontsource/inter@^4.5.1": version "4.5.1" resolved "https://registry.yarnpkg.com/@fontsource/inter/-/inter-4.5.1.tgz#058d8a02354f3c78e369d452c15d33557ec1b705" @@ -5381,6 +5409,13 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +aria-hidden@^1.1.3: + version "1.2.2" + resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.2.tgz#8c4f7cc88d73ca42114106fdf6f47e68d31475b8" + integrity sha512-6y/ogyDTk/7YAe91T3E2PR1ALVKyM2QbTio5HwM+N1Q6CMlCKhvClyIjkckBswa0f2xJhjsfzIGa1yVSe1UMVA== + dependencies: + tslib "^2.0.0" + aria-query@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-4.2.2.tgz#0d2ca6c9aceb56b8977e9fed6aed7e15bbd2f83b" @@ -16517,6 +16552,11 @@ tabbable@^5.3.3: resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf" integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA== +tabbable@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.0.1.tgz#427a09b13c83ae41eed3e88abb76a4af28bde1a6" + integrity sha512-SYJSIgeyXW7EuX1ytdneO5e8jip42oHWg9xl/o3oTYhmXusZVgiA+VlPvjIN+kHii9v90AmzTZEBcsEvuAY+TA== + table@^6.8.1: version "6.8.1" resolved "https://registry.yarnpkg.com/table/-/table-6.8.1.tgz#ea2b71359fe03b017a5fbc296204471158080bdf"