diff --git a/app/soapbox/features/compose/components/privacy_dropdown.js b/app/soapbox/features/compose/components/privacy_dropdown.js deleted file mode 100644 index d6ddf771c..000000000 --- a/app/soapbox/features/compose/components/privacy_dropdown.js +++ /dev/null @@ -1,284 +0,0 @@ -import classNames from 'classnames'; -import { supportsPassiveEvents } from 'detect-passive-events'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { injectIntl, defineMessages } from 'react-intl'; -import spring from 'react-motion/lib/spring'; -import Overlay from 'react-overlays/lib/Overlay'; - -import Icon from 'soapbox/components/icon'; - -import { IconButton } from '../../../components/ui'; -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' }, - unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, - unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not post to public timelines' }, - private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, - private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' }, - direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, - direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, - change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust post privacy' }, -}); - -const listenerOptions = supportsPassiveEvents ? { passive: true } : false; - -class PrivacyDropdownMenu extends React.PureComponent { - - static propTypes = { - style: PropTypes.object, - items: PropTypes.array.isRequired, - value: PropTypes.string.isRequired, - placement: PropTypes.string.isRequired, - onClose: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - unavailable: PropTypes.bool, - }; - - state = { - mounted: false, - }; - - handleDocumentClick = e => { - if (this.node && !this.node.contains(e.target)) { - this.props.onClose(); - } - } - - handleKeyDown = e => { - const { items } = this.props; - const value = e.currentTarget.getAttribute('data-index'); - const index = items.findIndex(item => { - return (item.value === value); - }); - let element = null; - - switch (e.key) { - case 'Escape': - this.props.onClose(); - break; - case 'Enter': - this.handleClick(e); - break; - case 'ArrowDown': - element = this.node.childNodes[index + 1] || this.node.firstChild; - break; - case 'ArrowUp': - element = this.node.childNodes[index - 1] || this.node.lastChild; - break; - case 'Tab': - if (e.shiftKey) { - element = this.node.childNodes[index - 1] || this.node.lastChild; - } else { - element = this.node.childNodes[index + 1] || this.node.firstChild; - } - break; - case 'Home': - element = this.node.firstChild; - break; - case 'End': - element = this.node.lastChild; - break; - } - - if (element) { - element.focus(); - this.props.onChange(element.getAttribute('data-index')); - e.preventDefault(); - e.stopPropagation(); - } - } - - handleClick = e => { - const value = e.currentTarget.getAttribute('data-index'); - - e.preventDefault(); - - this.props.onClose(); - this.props.onChange(value); - } - - componentDidMount() { - document.addEventListener('click', this.handleDocumentClick, false); - document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - if (this.focusedItem) this.focusedItem.focus({ preventScroll: true }); - this.setState({ mounted: true }); - } - - componentWillUnmount() { - document.removeEventListener('click', this.handleDocumentClick, false); - document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); - } - - setRef = c => { - this.node = c; - } - - setFocusRef = c => { - this.focusedItem = c; - } - - render() { - const { mounted } = this.state; - const { style, items, placement, value } = this.props; - - 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 => ( -
-
- -
- -
- {item.text} - {item.meta} -
-
- ))} -
- )} -
- ); - } - -} - -export default @injectIntl -class PrivacyDropdown extends React.PureComponent { - - static propTypes = { - isUserTouching: PropTypes.func, - isModalOpen: PropTypes.bool.isRequired, - onModalOpen: PropTypes.func, - onModalClose: PropTypes.func, - value: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - state = { - open: false, - placement: 'bottom', - }; - - constructor(props) { - super(props); - const { intl: { formatMessage } } = props; - - this.options = [ - { icon: require('@tabler/icons/icons/world.svg'), value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, - { icon: require('@tabler/icons/icons/lock-open.svg'), value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) }, - { icon: require('@tabler/icons/icons/lock.svg'), value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, - { icon: require('@tabler/icons/icons/mail.svg'), value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, - ]; - } - - handleToggle = (e) => { - if (this.props.isUserTouching()) { - if (this.state.open) { - this.props.onModalClose(); - } else { - this.props.onModalOpen({ - actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })), - onClick: this.handleModalActionClick, - }); - } - } else { - const { top } = e.target.getBoundingClientRect(); - if (this.state.open && this.activeElement) { - this.activeElement.focus(); - } - this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); - this.setState({ open: !this.state.open }); - } - e.stopPropagation(); - } - - handleModalActionClick = (e) => { - e.preventDefault(); - - const { value } = this.options[e.currentTarget.getAttribute('data-index')]; - - this.props.onModalClose(); - this.props.onChange(value); - } - - handleKeyDown = e => { - switch (e.key) { - case 'Escape': - this.handleClose(); - break; - } - } - - handleMouseDown = () => { - if (!this.state.open) { - this.activeElement = document.activeElement; - } - } - - handleButtonKeyDown = (e) => { - switch (e.key) { - case ' ': - case 'Enter': - this.handleMouseDown(); - break; - } - } - - handleClose = () => { - if (this.state.open && this.activeElement) { - this.activeElement.focus(); - } - this.setState({ open: false }); - } - - handleChange = value => { - this.props.onChange(value); - } - - render() { - const { value, intl, unavailable } = this.props; - const { open, placement } = this.state; - - if (unavailable) { - return null; - } - - const valueOption = this.options.find(item => item.value === value); - - return ( -
-
- -
- - - - -
- ); - } - -} diff --git a/app/soapbox/features/compose/components/privacy_dropdown.tsx b/app/soapbox/features/compose/components/privacy_dropdown.tsx new file mode 100644 index 000000000..3e3440ae4 --- /dev/null +++ b/app/soapbox/features/compose/components/privacy_dropdown.tsx @@ -0,0 +1,263 @@ +import classNames from 'classnames'; +import { supportsPassiveEvents } from 'detect-passive-events'; +import React, { useState, useRef, useEffect } from 'react'; +import { useIntl, defineMessages } from 'react-intl'; +import { spring } from 'react-motion'; +// @ts-ignore +import Overlay from 'react-overlays/lib/Overlay'; + +import Icon from 'soapbox/components/icon'; + +import { IconButton } from '../../../components/ui'; +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' }, + unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, + unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not post to public timelines' }, + private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' }, + direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, + direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, + change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust post privacy' }, +}); + +const listenerOptions = supportsPassiveEvents ? { passive: true } : false; + +interface IPrivacyDropdownMenu { + style?: React.CSSProperties, + items: any[], + value: string, + placement: string, + onClose: () => void, + onChange: (value: string | null) => void, + unavailable?: boolean, +} + +const PrivacyDropdownMenu: React.FC = ({ style, items, placement, value, onClose, onChange }) => { + const node = useRef(null); + const focusedItem = useRef(null); + + const [mounted, setMounted] = useState(false); + + const handleDocumentClick = (e: MouseEvent | TouchEvent) => { + if (node.current && !node.current.contains(e.target as HTMLElement)) { + onClose(); + } + }; + + const handleKeyDown: React.KeyboardEventHandler = e => { + const value = e.currentTarget.getAttribute('data-index'); + const index = items.findIndex(item => { + return (item.value === value); + }); + let element = null; + + switch (e.key) { + case 'Escape': + onClose(); + break; + case 'Enter': + handleClick(e); + break; + case 'ArrowDown': + element = node.current?.childNodes[index + 1] || node.current?.firstChild; + break; + case 'ArrowUp': + element = node.current?.childNodes[index - 1] || node.current?.lastChild; + break; + case 'Tab': + if (e.shiftKey) { + element = node.current?.childNodes[index - 1] || node.current?.lastChild; + } else { + element = node.current?.childNodes[index + 1] || node.current?.firstChild; + } + break; + case 'Home': + element = node.current?.firstChild; + break; + case 'End': + element = node.current?.lastChild; + break; + } + + if (element) { + (element as HTMLElement).focus(); + onChange((element as HTMLElement).getAttribute('data-index')); + e.preventDefault(); + e.stopPropagation(); + } + }; + + const handleClick: React.EventHandler = (e: MouseEvent | KeyboardEvent) => { + const value = (e.currentTarget as HTMLElement)?.getAttribute('data-index'); + + e.preventDefault(); + + onClose(); + onChange(value); + }; + + useEffect(() => { + document.addEventListener('click', handleDocumentClick, false); + document.addEventListener('touchend', handleDocumentClick, listenerOptions); + + focusedItem.current?.focus({ preventScroll: true }); + setMounted(true); + + return () => { + document.removeEventListener('click', handleDocumentClick, false); + document.removeEventListener('touchend', handleDocumentClick); + }; + }); + + 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 => ( +
+
+ +
+ +
+ {item.text} + {item.meta} +
+
+ ))} +
+ )} +
+ ); +}; + +interface IPrivacyDropdown { + isUserTouching: () => boolean, + isModalOpen: boolean, + onModalOpen: (opts: any) => void, + onModalClose: () => void, + value: string, + onChange: (value: string | null) => void, + unavailable: boolean, +} + +const PrivacyDropdown: React.FC = ({ + isUserTouching, + onChange, + onModalClose, + onModalOpen, + value, + unavailable, +}) => { + const intl = useIntl(); + const activeElement = useRef(null); + + const [open, setOpen] = useState(false); + const [placement, setPlacement] = useState('bottom'); + + const options = [ + { icon: require('@tabler/icons/icons/world.svg'), value: 'public', text: intl.formatMessage(messages.public_short), meta: intl.formatMessage(messages.public_long) }, + { icon: require('@tabler/icons/icons/lock-open.svg'), value: 'unlisted', text: intl.formatMessage(messages.unlisted_short), meta: intl.formatMessage(messages.unlisted_long) }, + { icon: require('@tabler/icons/icons/lock.svg'), value: 'private', text: intl.formatMessage(messages.private_short), meta: intl.formatMessage(messages.private_long) }, + { icon: require('@tabler/icons/icons/mail.svg'), value: 'direct', text: intl.formatMessage(messages.direct_short), meta: intl.formatMessage(messages.direct_long) }, + ]; + + const handleToggle: React.MouseEventHandler = (e) => { + if (isUserTouching()) { + 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; + } + + const valueOption = options.find(item => item.value === value); + + return ( +
+
+ +
+ + + + +
+ ); +}; + +export default PrivacyDropdown;