diff --git a/app/javascript/flavours/glitch/features/compose/components/content_type_button.jsx b/app/javascript/flavours/glitch/features/compose/components/content_type_button.jsx index bbfd09e8c1b5f0..3299159fcee08c 100644 --- a/app/javascript/flavours/glitch/features/compose/components/content_type_button.jsx +++ b/app/javascript/flavours/glitch/features/compose/components/content_type_button.jsx @@ -1,17 +1,26 @@ -import { useCallback } from 'react'; +import { useCallback, useState, useRef } from 'react'; import { useIntl, defineMessages } from 'react-intl'; +import Overlay from 'react-overlays/Overlay'; + import CodeIcon from '@/material-icons/400-24px/code.svg?react'; +import DescriptionIcon from '@/material-icons/400-24px/description.svg?react'; import MarkdownIcon from '@/material-icons/400-24px/markdown.svg?react'; import { changeComposeContentType } from 'flavours/glitch/actions/compose'; import { IconButton } from 'flavours/glitch/components/icon_button'; import { useAppSelector, useAppDispatch } from 'flavours/glitch/store'; +import DropdownMenu from './dropdown_menu'; + const messages = defineMessages({ - disable_html: { id: 'compose.advanced_formatting.disable_html', defaultMessage: 'Disable HTML advanced formatting' }, - disable_markdown: { id: 'compose.advanced_formatting.disable_markdown', defaultMessage: 'Disable Markdown advanced formatting' }, - enable_markdown: { id: 'compose.advanced_formatting.enable_markdown', defaultMessage: 'Enable Markdown advanced formatting' }, + change_content_type: { id: 'compose.content-type.change', defaultMessage: 'Change advanced formatting options' }, + plain_text_label: { id: 'compose.content-type.plain', defaultMessage: 'Plain text' }, + plain_text_meta: { id: 'compose.content-type.plain_meta', defaultMessage: 'Write with no advanced formatting' }, + markdown_label: { id: 'compose.content-type.markdown', defaultMessage: 'Markdown' }, + markdown_meta: { id: 'compose.content-type.markdown_meta', defaultMessage: 'Format your posts using Markdown' }, + html_label: { id: 'compose.content-type.html', defaultMessage: 'HTML' }, + html_meta: { id: 'compose.content-type.html_meta', defaultMessage: 'Format your posts using HTML' }, }); export const ContentTypeButton = () => { @@ -21,33 +30,87 @@ export const ContentTypeButton = () => { const contentType = useAppSelector((state) => state.getIn(['compose', 'content_type'])); const dispatch = useAppDispatch(); - const handleClick = useCallback(() => { - dispatch(changeComposeContentType(contentType === 'text/plain' ? 'text/markdown' : 'text/plain')); - }, [contentType, dispatch]); + const containerRef = useRef(null); + + const [activeElement, setActiveElement] = useState(null); + const [open, setOpen] = useState(false); + const [placement, setPlacement] = useState('bottom'); + + const handleToggle = useCallback(() => { + if (open && activeElement) { + activeElement.focus({ preventScroll: true }); + setActiveElement(null); + } + + setOpen(!open); + }, [open, setOpen, activeElement, setActiveElement]); + + const handleClose = useCallback(() => { + if (open && activeElement) { + activeElement.focus({ preventScroll: true }); + setActiveElement(null); + } + + setOpen(false); + }, [open, setOpen, activeElement, setActiveElement]); + + const handleChange = useCallback((value) => { + dispatch(changeComposeContentType(value)); + }, [dispatch]); + + const handleOverlayEnter = useCallback((state) => { + setPlacement(state.placement); + }, [setPlacement]); if (!showButton) { return null; } - const active = contentType !== 'text/plain'; - const iconComponent = contentType === 'text/html' ? CodeIcon : MarkdownIcon; - const icon = contentType === 'text/html' ? 'code' : 'arrow-circle-down'; + const options = [ + { icon: 'file-text', iconComponent: DescriptionIcon, value: 'text/plain', text: intl.formatMessage(messages.plain_text_label), meta: intl.formatMessage(messages.plain_text_meta) }, + { icon: 'arrow-circle-down', iconComponent: MarkdownIcon, value: 'text/markdown', text: intl.formatMessage(messages.markdown_label), meta: intl.formatMessage(messages.markdown_meta) }, + { icon: 'code', iconComponent: CodeIcon, value: 'text/plain', text: intl.formatMessage(messages.html_label), meta: intl.formatMessage(messages.html_meta) }, + ]; - const title = { - 'text/html': intl.formatMessage(messages.disable_html), - 'text/markdown': intl.formatMessage(messages.disable_markdown), - 'text/plain': intl.formatMessage(messages.enable_markdown), + const icon = { + 'text/plain': 'file-text', + 'text/markdown': 'arrow-circle-down', + 'text/html': 'code', + }[contentType]; + + const iconComponent = { + 'text/plain': DescriptionIcon, + 'text/markdown': MarkdownIcon, + 'text/html': CodeIcon, }[contentType]; return ( - + <> + + + + {({ props, placement }) => ( +
+
+ +
+
+ )} +
+ ); }; diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.jsx b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.jsx new file mode 100644 index 00000000000000..2f1adf2fd152f0 --- /dev/null +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.jsx @@ -0,0 +1,125 @@ +import PropTypes from 'prop-types'; +import { PureComponent } from 'react'; + +import classNames from 'classnames'; + +import { supportsPassiveEvents } from 'detect-passive-events'; + +import { Icon } from 'flavours/glitch/components/icon'; + +const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; + +// copied from PrivacyDropdown; will require refactor with upstream down the line +class DropdownMenu extends PureComponent { + + static propTypes = { + style: PropTypes.object, + items: PropTypes.array.isRequired, + value: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + }; + + handleDocumentClick = e => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); + e.stopPropagation(); + } + }; + + 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, { capture: true }); + document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + if (this.focusedItem) this.focusedItem.focus({ preventScroll: true }); + } + + componentWillUnmount () { + document.removeEventListener('click', this.handleDocumentClick, { capture: true }); + document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + } + + setRef = c => { + this.node = c; + }; + + setFocusRef = c => { + this.focusedItem = c; + }; + + render () { + const { style, items, value } = this.props; + + return ( +
+ {items.map(item => ( +
+
+ +
+ +
+ {item.text} + {item.meta} +
+
+ ))} +
+ ); + } + +} + +export default DropdownMenu;