diff --git a/src/components/dropdown-menu/dropdown-menu.tsx b/src/components/dropdown-menu/dropdown-menu.tsx index d8e604536..9dc1cf139 100644 --- a/src/components/dropdown-menu/dropdown-menu.tsx +++ b/src/components/dropdown-menu/dropdown-menu.tsx @@ -19,7 +19,8 @@ type Menu = Array; interface IDropdownMenu { children?: React.ReactElement; disabled?: boolean; - items: Menu; + items?: Menu; + component?: React.FC<{ handleClose: () => any }>; onClose?: () => void; onOpen?: () => void; onShiftClick?: React.EventHandler; @@ -36,6 +37,7 @@ const DropdownMenu = (props: IDropdownMenu) => { children, disabled, items, + component: Component, onClose, onOpen, onShiftClick, @@ -221,19 +223,19 @@ const DropdownMenu = (props: IDropdownMenu) => { setTimeout(() => setIsDisplayed(isOpen), isOpen ? 0 : 150); }, [isOpen]); - if (items.length === 0) { + if (items?.length === 0 && !Component) { return null; } - const autoFocus = !items.some((item) => item?.active); + const autoFocus = items && !items.some((item) => item?.active); const getClassName = () => { const className = clsx('z-[1001] bg-white py-1 shadow-lg ease-in-out focus:outline-none black:bg-black no-reduce-motion:transition-all dark:bg-gray-900 dark:ring-2 dark:ring-primary-700', touching ? clsx({ - 'overflow-hidden fixed left-0 right-0 mx-auto w-[calc(100vw-2rem)] max-w-lg rounded-t-xl duration-200': true, + 'overflow-auto fixed left-0 right-0 mx-auto w-[calc(100vw-2rem)] max-w-lg max-h-[calc(100dvh-1rem)] rounded-t-xl duration-200': true, 'bottom-0 opacity-100': isDisplayed && isOpen, '-bottom-32 opacity-0': !(isDisplayed && isOpen), }) : clsx({ - 'rounded-md w-56 duration-100': true, + 'rounded-md min-w-56 max-w-sm duration-100': true, 'scale-0': !(isDisplayed && isOpen), 'scale-100': isDisplayed && isOpen, 'origin-bottom': placement === 'top', @@ -295,8 +297,9 @@ const DropdownMenu = (props: IDropdownMenu) => { left: x ?? 0, }} > + {Component && }
    - {items.map((item, idx) => ( + {items?.map((item, idx) => ( = ({ children, onCancel, onClose, type }) return (
    { >
    (({ }, ref) => { const intl = useIntl(); const buttonRef = React.useRef(null); + const firstRender = React.useRef(true); + + React.useEffect(() => { + if (firstRender.current) { + firstRender.current = false; + } + }, []); React.useEffect(() => { if (buttonRef?.current && !skipFocus) { @@ -95,7 +102,10 @@ const Modal = React.forwardRef(({
    diff --git a/src/features/compose/components/language-dropdown.tsx b/src/features/compose/components/language-dropdown.tsx index 2e650fa92..68c96a2b0 100644 --- a/src/features/compose/components/language-dropdown.tsx +++ b/src/features/compose/components/language-dropdown.tsx @@ -1,6 +1,4 @@ -import { offset, useFloating, flip, arrow, shift } from '@floating-ui/react'; import clsx from 'clsx'; -import { supportsPassiveEvents } from 'detect-passive-events'; import fuzzysort from 'fuzzysort'; import { Map as ImmutableMap } from 'immutable'; import React, { useEffect, useMemo, useRef, useState } from 'react'; @@ -8,7 +6,8 @@ import { defineMessages, useIntl } from 'react-intl'; import { createSelector } from 'reselect'; import { addComposeLanguage, changeComposeLanguage, changeComposeModifiedLanguage, deleteComposeLanguage } from 'soapbox/actions/compose'; -import { Button, Icon, Input, Portal } from 'soapbox/components/ui'; +import DropdownMenu from 'soapbox/components/dropdown-menu'; +import { Button, Icon, Input } from 'soapbox/components/ui'; import { type Language, languages as languagesObject } from 'soapbox/features/preferences'; import { useAppDispatch, useAppSelector, useCompose, useFeatures } from 'soapbox/hooks'; @@ -21,8 +20,6 @@ const getFrequentlyUsedLanguages = createSelector([ .toArray() )); -const listenerOptions = supportsPassiveEvents ? { passive: true } : false; - const languages = Object.entries(languagesObject) as Array<[Language, string]>; const messages = defineMessages({ @@ -35,62 +32,26 @@ const messages = defineMessages({ }); interface ILanguageDropdown { - composeId: string; + handleClose: () => any; } -const LanguageDropdown: React.FC = ({ composeId }) => { +const getLanguageDropdown = (composeId: string): React.FC => ({ handleClose: handleMenuClose }) => { const intl = useIntl(); const features = useFeatures(); const dispatch = useAppDispatch(); const frequentlyUsedLanguages = useAppSelector(getFrequentlyUsedLanguages); const node = useRef(null); - const focusedItem = useRef(null); - const arrowRef = useRef(null); + const focusedItem = useRef(null); - const [isOpen, setIsOpen] = useState(false); const [searchValue, setSearchValue] = useState(''); - const { x, y, strategy, refs, middlewareData, placement } = useFloating({ - placement: 'top', - middleware: [ - offset(12), - flip(), - shift({ - padding: 8, - }), - arrow({ - element: arrowRef, - }), - ], - }); - const { language, modified_language: modifiedLanguage, - suggested_language: suggestedLanguage, textMap, } = useCompose(composeId); - const handleClick: React.EventHandler< - React.MouseEvent | React.KeyboardEvent - > = (event) => { - event.stopPropagation(); - - setIsOpen(!isOpen); - }; - - const handleKeyPress: React.EventHandler> = (event) => { - switch (event.key) { - case ' ': - case 'Enter': - event.stopPropagation(); - event.preventDefault(); - handleClick(event); - break; - } - }; - const handleOptionKeyDown: React.KeyboardEventHandler = e => { const value = e.currentTarget.getAttribute('data-index'); const index = results.findIndex(([key]) => key === value); @@ -146,7 +107,6 @@ const LanguageDropdown: React.FC = ({ composeId }) => { e.preventDefault(); e.stopPropagation(); - handleClose(); dispatch(addComposeLanguage(composeId, value)); }; @@ -156,7 +116,6 @@ const LanguageDropdown: React.FC = ({ composeId }) => { e.preventDefault(); e.stopPropagation(); - handleClose(); dispatch(deleteComposeLanguage(composeId, value)); }; @@ -202,104 +161,106 @@ const LanguageDropdown: React.FC = ({ composeId }) => { }).map(result => result.obj); }; - 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(); - } - }; - const handleClose = () => { setSearchValue(''); - setIsOpen(false); + handleMenuClose(); }; - const arrowProps: React.CSSProperties = useMemo(() => { - if (middlewareData.arrow) { - const { x, y } = middlewareData.arrow; - - const staticPlacement = { - top: 'bottom', - right: 'left', - bottom: 'top', - left: 'right', - }[placement.split('-')[0]]; - - return { - left: x !== null ? `${x}px` : '', - top: y !== null ? `${y}px` : '', - // Ensure the static side gets unset when - // flipping to other placements' axes. - right: '', - bottom: '', - [staticPlacement as string]: `${(-(arrowRef.current?.offsetWidth || 0)) / 2}px`, - transform: 'rotate(45deg)', - }; - } - - return {}; - }, [middlewareData.arrow, placement]); - useEffect(() => { - if (isOpen) { - if (refs.floating.current) { - (refs.floating.current?.querySelector('div[aria-selected=true]') as HTMLDivElement)?.focus(); - } - - 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); - }; + if (node.current) { + (node.current?.querySelector('div[aria-selected=true]') as HTMLDivElement)?.focus(); } - }, [isOpen, refs.floating.current]); + }, [node.current]); const isSearching = searchValue !== ''; const results = search(); + return ( + <> + +
    + {results.map(([code, name]) => { + const active = code === language; + const modified = code === modifiedLanguage; + + return ( + + ) : ( + + ) + )} + + ); + })} +
    + + ); +}; + +interface ILanguageDropdownButton { + composeId: string; +} + +const LanguageDropdownButton: React.FC = ({ composeId }) => { + const intl = useIntl(); + + const { + language, + modified_language: modifiedLanguage, + suggested_language: suggestedLanguage, + textMap, + } = useCompose(composeId); + let buttonLabel = intl.formatMessage(messages.languagePrompt); if (language) { const list: string[] = [languagesObject[modifiedLanguage || language]]; @@ -311,8 +272,12 @@ const LanguageDropdown: React.FC = ({ composeId }) => { language: languagesObject[suggestedLanguage as Language] || suggestedLanguage, }); + const LanguageDropdown = useMemo(() => getLanguageDropdown(composeId), [composeId]); + return ( - <> + - ) : ( - - ) - )} -
    - ); - })} -
    -
    -
    - - ) : null} - + ); }; -export { LanguageDropdown as default }; +export { LanguageDropdownButton as default }; diff --git a/src/features/compose/components/privacy-dropdown.tsx b/src/features/compose/components/privacy-dropdown.tsx index a194aac21..6f98f8f56 100644 --- a/src/features/compose/components/privacy-dropdown.tsx +++ b/src/features/compose/components/privacy-dropdown.tsx @@ -1,21 +1,15 @@ import clsx from 'clsx'; import { supportsPassiveEvents } from 'detect-passive-events'; -import React, { useState, useRef, useEffect } from 'react'; +import React, { useRef, useEffect } from 'react'; import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; -import { spring } from 'react-motion'; -// @ts-ignore -import Overlay from 'react-overlays/lib/Overlay'; import { changeComposeFederated, changeComposeVisibility } from 'soapbox/actions/compose'; -import { closeModal, openModal } from 'soapbox/actions/modals'; +import DropdownMenu from 'soapbox/components/dropdown-menu'; import Icon from 'soapbox/components/icon'; import { Button, Toggle } from 'soapbox/components/ui'; import { useAppDispatch, useCompose, useFeatures, useInstance } from 'soapbox/hooks'; -import { userTouching } from 'soapbox/is-mobile'; import { GOTOSOCIAL, parseVersion, PLEROMA } from 'soapbox/utils/features'; -import Motion from '../../ui/util/optional-motion'; - const messages = defineMessages({ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' }, @@ -44,10 +38,8 @@ interface Option { } interface IPrivacyDropdownMenu { - style?: React.CSSProperties; items: any[]; value: string; - placement: string; onClose: () => void; onChange: (value: string | null) => void; unavailable?: boolean; @@ -57,12 +49,10 @@ interface IPrivacyDropdownMenu { } const PrivacyDropdownMenu: React.FC = ({ - style, items, placement, value, onClose, onChange, showFederated, federated, onChangeFederated, + items, value, onClose, onChange, showFederated, federated, onChangeFederated, }) => { - const node = useRef(null); - const focusedItem = useRef(null); - - const [mounted, setMounted] = useState(false); + const node = useRef(null); + const focusedItem = useRef(null); const handleDocumentClick = (e: MouseEvent | TouchEvent) => { if (node.current && !node.current.contains(e.target as HTMLElement)) { @@ -71,7 +61,8 @@ const PrivacyDropdownMenu: React.FC = ({ }; const handleKeyDown: React.KeyboardEventHandler = e => { - const index = [...e.currentTarget.parentElement!.children].indexOf(e.currentTarget); let element: ChildNode | null | undefined = null; + const index = [...e.currentTarget.parentElement!.children].indexOf(e.currentTarget); + let element: ChildNode | null | undefined = null; switch (e.key) { case 'Escape': @@ -127,7 +118,6 @@ const PrivacyDropdownMenu: React.FC = ({ document.addEventListener('touchend', handleDocumentClick, listenerOptions); focusedItem.current?.focus({ preventScroll: true }); - setMounted(true); return () => { document.removeEventListener('click', handleDocumentClick, false); @@ -136,82 +126,66 @@ const PrivacyDropdownMenu: React.FC = ({ }, []); 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(item => { - const active = item.value === value; - return ( -
    -
    - -
    - -
    - {item.text} - {item.meta} -
    -
    - ); - })} - {showFederated && ( -
    -
    - -
    - -
    - - - - -
    - - +
      + {items.map(item => { + const active = item.value === value; + return ( +
    • +
      +
      - )} -
    + +
    + {item.text} + {item.meta} +
    + + ); + })} + {showFederated && ( +
  • +
    + +
    + +
    + + + + +
    + + +
  • )} - + +
); }; @@ -224,8 +198,6 @@ const PrivacyDropdown: React.FC = ({ }) => { const dispatch = useAppDispatch(); const intl = useIntl(); - const node = useRef(null); - const activeElement = useRef(null); const instance = useInstance(); const features = useFeatures(); @@ -236,9 +208,6 @@ const PrivacyDropdown: React.FC = ({ const value = compose.privacy; const unavailable = compose.id; - const [open, setOpen] = useState(false); - const [placement, setPlacement] = useState('bottom'); - const options = [ { icon: require('@tabler/icons/outline/world.svg'), value: 'public', text: intl.formatMessage(messages.public_short), meta: intl.formatMessage(messages.public_long) }, { icon: require('@tabler/icons/outline/lock-open.svg'), value: 'unlisted', text: intl.formatMessage(messages.unlisted_short), meta: intl.formatMessage(messages.unlisted_long) }, @@ -252,70 +221,6 @@ const PrivacyDropdown: React.FC = ({ const onChangeFederated = () => dispatch(changeComposeFederated(composeId)); - const onModalOpen = (props: Record) => dispatch(openModal('ACTIONS', props)); - - const onModalClose = () => dispatch(closeModal('ACTIONS')); - - const handleToggle: React.MouseEventHandler = (e) => { - if (userTouching.matches) { - if (open) { - onModalClose(); - } else { - onModalOpen({ - actions: options.map(option => ({ ...option, active: option.value === value })), - onClick: handleModalActionClick, - }); - } - } else { - const { top } = e.currentTarget.getBoundingClientRect(); - if (open) { - activeElement.current?.focus(); - } - setPlacement(top * 2 < innerHeight ? 'bottom' : 'top'); - setOpen(!open); - } - e.stopPropagation(); - }; - - const handleModalActionClick: React.MouseEventHandler = (e) => { - e.preventDefault(); - - const { value } = options[e.currentTarget.getAttribute('data-index') as any]; - - onModalClose(); - onChange(value); - }; - - const handleKeyDown: React.KeyboardEventHandler = e => { - switch (e.key) { - case 'Escape': - handleClose(); - break; - } - }; - - const handleMouseDown = () => { - if (!open) { - activeElement.current = document.activeElement as HTMLElement | null; - } - }; - - const handleButtonKeyDown: React.KeyboardEventHandler = (e) => { - switch (e.key) { - case ' ': - case 'Enter': - handleMouseDown(); - break; - } - }; - - const handleClose = () => { - if (open) { - activeElement.current?.focus(); - } - setOpen(false); - }; - if (unavailable) { return null; } @@ -323,41 +228,30 @@ const PrivacyDropdown: React.FC = ({ const valueOption = options.find(item => item.value === value); return ( -
-
-
- - + ( - -
+ )} + > +