import PropTypes from "prop-types"; import { PureComponent } from "react"; import { injectIntl, defineMessages } from "react-intl"; import classNames from "classnames"; import { supportsPassiveEvents } from "detect-passive-events"; import fuzzysort from "fuzzysort"; import Overlay from "react-overlays/Overlay"; import { languages as preloadedLanguages } from "mastodon/initial_state"; import { loupeIcon, deleteIcon } from "mastodon/utils/icons"; import TextIconButton from "./text_icon_button"; const messages = defineMessages({ changeLanguage: { id: "compose.language.change", defaultMessage: "Change language" }, search: { id: "compose.language.search", defaultMessage: "Search languages..." }, clear: { id: "emoji_button.clear", defaultMessage: "Clear" }, }); const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; class LanguageDropdownMenu extends PureComponent { static propTypes = { value: PropTypes.string.isRequired, frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired, onClose: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired, languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), intl: PropTypes.object, }; static defaultProps = { languages: preloadedLanguages, }; state = { searchValue: "", }; handleDocumentClick = e => { if (this.node && !this.node.contains(e.target)) { this.props.onClose(); e.stopPropagation(); } }; componentDidMount () { document.addEventListener("click", this.handleDocumentClick, { capture: true }); document.addEventListener("touchend", this.handleDocumentClick, listenerOptions); // Because of https://github.com/react-bootstrap/react-bootstrap/issues/2614 we need // to wait for a frame before focusing requestAnimationFrame(() => { if (this.node) { const element = this.node.querySelector("input[type=\"search\"]"); if (element) { element.focus(); } } }); } componentWillUnmount () { document.removeEventListener("click", this.handleDocumentClick, { capture: true }); document.removeEventListener("touchend", this.handleDocumentClick, listenerOptions); } setRef = c => { this.node = c; }; setListRef = c => { this.listNode = c; }; handleSearchChange = ({ target }) => { this.setState({ searchValue: target.value }); }; search () { const { languages, value, frequentlyUsedLanguages } = this.props; const { searchValue } = this.state; if (searchValue === "") { return [...languages].sort((a, b) => { // Push current selection to the top of the list if (a[0] === value) { return -1; } else if (b[0] === value) { return 1; } else { // Sort according to frequently used languages const indexOfA = frequentlyUsedLanguages.indexOf(a[0]); const indexOfB = frequentlyUsedLanguages.indexOf(b[0]); return ((indexOfA > -1 ? indexOfA : Infinity) - (indexOfB > -1 ? indexOfB : Infinity)); } }); } return fuzzysort.go(searchValue, languages, { keys: ["0", "1", "2"], limit: 5, threshold: -10000, }).map(result => result.obj); } frequentlyUsed () { const { languages, value } = this.props; const current = languages.find(lang => lang[0] === value); const results = []; if (current) { results.push(current); } return results; } handleClick = e => { const value = e.currentTarget.getAttribute("data-index"); e.preventDefault(); this.props.onClose(); this.props.onChange(value); }; handleKeyDown = e => { const { onClose } = this.props; const index = Array.from(this.listNode.childNodes).findIndex(node => node === e.currentTarget); let element = null; switch(e.key) { case "Escape": onClose(); break; case "Enter": this.handleClick(e); break; case "ArrowDown": element = this.listNode.childNodes[index + 1] || this.listNode.firstChild; break; case "ArrowUp": element = this.listNode.childNodes[index - 1] || this.listNode.lastChild; break; case "Tab": if (e.shiftKey) { element = this.listNode.childNodes[index - 1] || this.listNode.lastChild; } else { element = this.listNode.childNodes[index + 1] || this.listNode.firstChild; } break; case "Home": element = this.listNode.firstChild; break; case "End": element = this.listNode.lastChild; break; } if (element) { element.focus(); e.preventDefault(); e.stopPropagation(); } }; handleSearchKeyDown = e => { const { onChange, onClose } = this.props; const { searchValue } = this.state; let element = null; switch(e.key) { case "Tab": case "ArrowDown": element = this.listNode.firstChild; if (element) { element.focus(); e.preventDefault(); e.stopPropagation(); } break; case "Enter": element = this.listNode.firstChild; if (element) { onChange(element.getAttribute("data-index")); onClose(); } break; case "Escape": if (searchValue !== "") { e.preventDefault(); this.handleClear(); } break; } }; handleClear = () => { this.setState({ searchValue: "" }); }; renderItem = lang => { const { value } = this.props; return (
{lang[2]} ({lang[1]})
); }; render () { const { intl } = this.props; const { searchValue } = this.state; const isSearching = searchValue !== ""; const results = this.search(); return (
{results.map(this.renderItem)}
); } } class LanguageDropdown extends PureComponent { static propTypes = { value: PropTypes.string, frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string), intl: PropTypes.object.isRequired, onChange: PropTypes.func, onClose: PropTypes.func, }; state = { open: false, placement: "bottom", }; handleToggle = () => { if (this.state.open && this.activeElement) { this.activeElement.focus({ preventScroll: true }); } this.setState({ open: !this.state.open }); }; handleClose = () => { const { value, onClose } = this.props; if (this.state.open && this.activeElement) { this.activeElement.focus({ preventScroll: true }); } this.setState({ open: false }); onClose(value); }; handleChange = value => { const { onChange } = this.props; onChange(value); }; setTargetRef = c => { this.target = c; }; findTarget = () => { return this.target; }; handleOverlayEnter = (state) => { this.setState({ placement: state.placement }); }; render () { const { value, intl, frequentlyUsedLanguages } = this.props; const { open, placement } = this.state; return (
{({ props, placement }) => (
)}
); } } export default injectIntl(LanguageDropdown);