From 9a0166c3f30fdc0fa60d4be5e88ebc03ec69f4ed Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 24 Jan 2025 17:11:23 +0100 Subject: [PATCH] Convert `LanguageDropdownMenu` to functional component (#33704) --- .../compose/components/language_dropdown.jsx | 322 ++++++++---------- 1 file changed, 144 insertions(+), 178 deletions(-) diff --git a/app/javascript/mastodon/features/compose/components/language_dropdown.jsx b/app/javascript/mastodon/features/compose/components/language_dropdown.jsx index c80aa27e4..cc97d6141 100644 --- a/app/javascript/mastodon/features/compose/components/language_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/language_dropdown.jsx @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import { useCallback, useRef, useState, useEffect, PureComponent } from 'react'; +import { useCallback, useRef, useState, useEffect, useMemo } from 'react'; import { useIntl, defineMessages } from 'react-intl'; @@ -30,68 +30,142 @@ const messages = defineMessages({ const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; -class LanguageDropdownMenu extends PureComponent { +const getFrequentlyUsedLanguages = createSelector([ + state => state.getIn(['settings', 'frequentlyUsedLanguages'], ImmutableMap()), +], languageCounters => ( + languageCounters.keySeq() + .sort((a, b) => languageCounters.get(a) - languageCounters.get(b)) + .reverse() + .toArray() +)); - static propTypes = { - value: PropTypes.string.isRequired, - guess: PropTypes.string, - frequentlyUsedLanguages: PropTypes.arrayOf(PropTypes.string).isRequired, - onClose: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), - intl: PropTypes.object, - }; +const LanguageDropdownMenu = ({ value, guess, onClose, onChange, languages = preloadedLanguages, intl }) => { + const [searchValue, setSearchValue] = useState(''); + const nodeRef = useRef(null); + const listNodeRef = useRef(null); - static defaultProps = { - languages: preloadedLanguages, - }; + const frequentlyUsedLanguages = useAppSelector(getFrequentlyUsedLanguages); - state = { - searchValue: '', - }; + const handleSearchChange = useCallback(({ target }) => { + setSearchValue(target.value); + }, [setSearchValue]); - handleDocumentClick = e => { - if (this.node && !this.node.contains(e.target)) { - this.props.onClose(); + const handleClick = useCallback((e) => { + const value = e.currentTarget.getAttribute('data-index'); + + e.preventDefault(); + + onClose(); + onChange(value); + }, [onClose, onChange]); + + const handleKeyDown = useCallback(e => { + const index = Array.from(listNodeRef.current.childNodes).findIndex(node => node === e.currentTarget); + + let element = null; + + switch(e.key) { + case 'Escape': + onClose(); + break; + case ' ': + case 'Enter': + handleClick(e); + break; + case 'ArrowDown': + element = listNodeRef.current.childNodes[index + 1] || listNodeRef.current.firstChild; + break; + case 'ArrowUp': + element = listNodeRef.current.childNodes[index - 1] || listNodeRef.current.lastChild; + break; + case 'Tab': + if (e.shiftKey) { + element = listNodeRef.current.childNodes[index - 1] || listNodeRef.current.lastChild; + } else { + element = listNodeRef.current.childNodes[index + 1] || listNodeRef.current.firstChild; + } + break; + case 'Home': + element = listNodeRef.current.firstChild; + break; + case 'End': + element = listNodeRef.current.lastChild; + break; + } + + if (element) { + element.focus(); + e.preventDefault(); e.stopPropagation(); } - }; + }, [onClose, handleClick]); - componentDidMount () { - document.addEventListener('click', this.handleDocumentClick, { capture: true }); - document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); + const handleSearchKeyDown = useCallback(e => { + let element = null; + + switch(e.key) { + case 'Tab': + case 'ArrowDown': + element = listNodeRef.current.firstChild; + + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + + break; + case 'Enter': + element = listNodeRef.current.firstChild; + + if (element) { + onChange(element.getAttribute('data-index')); + onClose(); + } + break; + case 'Escape': + if (searchValue !== '') { + e.preventDefault(); + this.handleClear(); + } + + break; + } + }, [onChange, onClose, searchValue]); + + const handleClear = useCallback(() => { + setSearchValue(''); + }, [setSearchValue]); + + const isSearching = searchValue !== ''; + + useEffect(() => { + const handleDocumentClick = (e) => { + if (nodeRef.current && !nodeRef.current.contains(e.target)) { + onClose(); + e.stopPropagation(); + } + }; + + document.addEventListener('click', handleDocumentClick, { capture: true }); + document.addEventListener('touchend', 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 (nodeRef.current) { + const element = nodeRef.current.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, guess } = this.props; - const { searchValue } = this.state; + return () => { + document.removeEventListener('click', handleDocumentClick, { capture: true }); + document.removeEventListener('touchend', handleDocumentClick, listenerOptions); + }; + }, [onClose]); + const results = useMemo(() => { if (searchValue === '') { return [...languages].sort((a, b) => { @@ -119,139 +193,34 @@ class LanguageDropdownMenu extends PureComponent { limit: 5, threshold: -10000, }).map(result => result.obj); - } + }, [searchValue, languages, guess, frequentlyUsedLanguages, value]); - 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 ' ': - 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]}) + return ( +
+
+ +
- ); - }; - render () { - const { intl } = this.props; - const { searchValue } = this.state; - const isSearching = searchValue !== ''; - const results = this.search(); - - return ( -
-
- - -
- -
- {results.map(this.renderItem)} -
+
+ {results.map((lang) => ( +
+ {lang[2]} ({lang[1]}) +
+ ))}
- ); - } +
+ ); +}; -} - -const getFrequentlyUsedLanguages = createSelector([ - state => state.getIn(['settings', 'frequentlyUsedLanguages'], ImmutableMap()), -], languageCounters => ( - languageCounters.keySeq() - .sort((a, b) => languageCounters.get(a) - languageCounters.get(b)) - .reverse() - .toArray() -)); +LanguageDropdownMenu.propTypes = { + value: PropTypes.string.isRequired, + guess: PropTypes.string, + onClose: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), + intl: PropTypes.object, +}; export const LanguageDropdown = () => { const [open, setOpen] = useState(false); @@ -263,7 +232,6 @@ export const LanguageDropdown = () => { const intl = useIntl(); const dispatch = useAppDispatch(); - const frequentlyUsedLanguages = useAppSelector(getFrequentlyUsedLanguages); const value = useAppSelector((state) => state.compose.get('language')); const text = useAppSelector((state) => state.compose.get('text')); @@ -319,10 +287,8 @@ export const LanguageDropdown = () => {