Convert Dropdown to Typescript

This commit is contained in:
Michael Telatynski 2021-07-06 09:56:02 +01:00
parent 1b25ab930e
commit 44bbf60973

View file

@ -1,7 +1,6 @@
/* /*
Copyright 2017 Vector Creations Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,34 +15,38 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { ChangeEvent, createRef, CSSProperties, ReactElement, ReactNode, Ref } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
import AccessibleButton from './AccessibleButton'; import AccessibleButton from './AccessibleButton';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
class MenuOption extends React.Component { interface IMenuOptionProps {
constructor(props) { children: ReactElement;
super(props); highlighted?: boolean;
this._onMouseEnter = this._onMouseEnter.bind(this); dropdownKey: string;
this._onClick = this._onClick.bind(this); id?: string;
} inputRef?: Ref<HTMLDivElement>;
onClick(dropdownKey: string): void;
onMouseEnter(dropdownKey: string): void;
}
class MenuOption extends React.Component<IMenuOptionProps> {
static defaultProps = { static defaultProps = {
disabled: false, disabled: false,
}; };
_onMouseEnter() { private onMouseEnter = () => {
this.props.onMouseEnter(this.props.dropdownKey); this.props.onMouseEnter(this.props.dropdownKey);
} };
_onClick(e) { private onClick = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
this.props.onClick(this.props.dropdownKey); this.props.onClick(this.props.dropdownKey);
} };
render() { render() {
const optClasses = classnames({ const optClasses = classnames({
@ -54,8 +57,8 @@ class MenuOption extends React.Component {
return <div return <div
id={this.props.id} id={this.props.id}
className={optClasses} className={optClasses}
onClick={this._onClick} onClick={this.onClick}
onMouseEnter={this._onMouseEnter} onMouseEnter={this.onMouseEnter}
role="option" role="option"
aria-selected={this.props.highlighted} aria-selected={this.props.highlighted}
ref={this.props.inputRef} ref={this.props.inputRef}
@ -65,69 +68,75 @@ class MenuOption extends React.Component {
} }
} }
MenuOption.propTypes = { interface IProps {
children: PropTypes.oneOfType([ id: string;
PropTypes.arrayOf(PropTypes.node), // ARIA label
PropTypes.node, label: string;
]), value?: string;
highlighted: PropTypes.bool, className?: string;
dropdownKey: PropTypes.string, children: ReactElement[];
onClick: PropTypes.func.isRequired, // negative for consistency with HTML
onMouseEnter: PropTypes.func.isRequired, disabled?: boolean;
inputRef: PropTypes.any, // The width that the dropdown should be. If specified,
}; // the dropped-down part of the menu will be set to this
// width.
menuWidth?: number;
searchEnabled?: boolean;
// Called when the selected option changes
onOptionChange(dropdownKey: string): void;
// Called when the value of the search field changes
onSearchChange?(query: string): void;
// Function that, given the key of an option, returns
// a node representing that option to be displayed in the
// box itself as the currently-selected option (ie. as
// opposed to in the actual dropped-down part). If
// unspecified, the appropriate child element is used as
// in the dropped-down menu.
getShortOption?(value: string): ReactNode;
}
interface IState {
expanded: boolean;
highlightedOption: string | null;
searchQuery: string;
}
/* /*
* Reusable dropdown select control, akin to react-select, * Reusable dropdown select control, akin to react-select,
* but somewhat simpler as react-select is 79KB of minified * but somewhat simpler as react-select is 79KB of minified
* javascript. * javascript.
*
* TODO: Port NetworkDropdown to use this.
*/ */
@replaceableComponent("views.elements.Dropdown") @replaceableComponent("views.elements.Dropdown")
export default class Dropdown extends React.Component { export default class Dropdown extends React.Component<IProps, IState> {
constructor(props) { private readonly buttonRef = createRef<HTMLDivElement>();
private dropdownRootElement: HTMLDivElement = null;
private ignoreEvent: MouseEvent = null;
private childrenByKey: Record<string, ReactNode> = {};
constructor(props: IProps) {
super(props); super(props);
this.dropdownRootElement = null; this.reindexChildren(this.props.children);
this.ignoreEvent = null;
this._onInputClick = this._onInputClick.bind(this); const firstChild = React.Children.toArray(props.children)[0] as ReactElement;
this._onRootClick = this._onRootClick.bind(this);
this._onDocumentClick = this._onDocumentClick.bind(this);
this._onMenuOptionClick = this._onMenuOptionClick.bind(this);
this._onInputChange = this._onInputChange.bind(this);
this._collectRoot = this._collectRoot.bind(this);
this._collectInputTextBox = this._collectInputTextBox.bind(this);
this._setHighlightedOption = this._setHighlightedOption.bind(this);
this.inputTextBox = null;
this._reindexChildren(this.props.children);
const firstChild = React.Children.toArray(props.children)[0];
this.state = { this.state = {
// True if the menu is dropped-down // True if the menu is dropped-down
expanded: false, expanded: false,
// The key of the highlighted option // The key of the highlighted option
// (the option that would become selected if you pressed enter) // (the option that would become selected if you pressed enter)
highlightedOption: firstChild ? firstChild.key : null, highlightedOption: firstChild ? firstChild.key as string : null,
// the current search query // the current search query
searchQuery: '', searchQuery: '',
}; };
}
// TODO: [REACT-WARNING] Replace component with real class, use constructor for refs
UNSAFE_componentWillMount() { // eslint-disable-line camelcase
this._button = createRef();
// Listen for all clicks on the document so we can close the // Listen for all clicks on the document so we can close the
// menu when the user clicks somewhere else // menu when the user clicks somewhere else
document.addEventListener('click', this._onDocumentClick, false); document.addEventListener('click', this.onDocumentClick, false);
} }
componentWillUnmount() { componentWillUnmount() {
document.removeEventListener('click', this._onDocumentClick, false); document.removeEventListener('click', this.onDocumentClick, false);
} }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
@ -135,21 +144,21 @@ export default class Dropdown extends React.Component {
if (!nextProps.children || nextProps.children.length === 0) { if (!nextProps.children || nextProps.children.length === 0) {
return; return;
} }
this._reindexChildren(nextProps.children); this.reindexChildren(nextProps.children);
const firstChild = nextProps.children[0]; const firstChild = nextProps.children[0];
this.setState({ this.setState({
highlightedOption: firstChild ? firstChild.key : null, highlightedOption: firstChild ? firstChild.key : null,
}); });
} }
_reindexChildren(children) { private reindexChildren(children: ReactElement[]): void {
this.childrenByKey = {}; this.childrenByKey = {};
React.Children.forEach(children, (child) => { React.Children.forEach(children, (child) => {
this.childrenByKey[child.key] = child; this.childrenByKey[child.key] = child;
}); });
} }
_onDocumentClick(ev) { private onDocumentClick = (ev: MouseEvent) => {
// Close the dropdown if the user clicks anywhere that isn't // Close the dropdown if the user clicks anywhere that isn't
// within our root element // within our root element
if (ev !== this.ignoreEvent) { if (ev !== this.ignoreEvent) {
@ -157,9 +166,9 @@ export default class Dropdown extends React.Component {
expanded: false, expanded: false,
}); });
} }
} };
_onRootClick(ev) { private onRootClick = (ev: MouseEvent) => {
// This captures any clicks that happen within our elements, // This captures any clicks that happen within our elements,
// such that we can then ignore them when they're seen by the // such that we can then ignore them when they're seen by the
// click listener on the document handler, ie. not close the // click listener on the document handler, ie. not close the
@ -167,9 +176,9 @@ export default class Dropdown extends React.Component {
// NB. We can't just stopPropagation() because then the event // NB. We can't just stopPropagation() because then the event
// doesn't reach the React onClick(). // doesn't reach the React onClick().
this.ignoreEvent = ev; this.ignoreEvent = ev;
} };
_onInputClick(ev) { private onInputClick = (ev: React.MouseEvent) => {
if (this.props.disabled) return; if (this.props.disabled) return;
if (!this.state.expanded) { if (!this.state.expanded) {
@ -178,24 +187,24 @@ export default class Dropdown extends React.Component {
}); });
ev.preventDefault(); ev.preventDefault();
} }
} };
_close() { private close() {
this.setState({ this.setState({
expanded: false, expanded: false,
}); });
// their focus was on the input, its getting unmounted, move it to the button // their focus was on the input, its getting unmounted, move it to the button
if (this._button.current) { if (this.buttonRef.current) {
this._button.current.focus(); this.buttonRef.current.focus();
} }
} }
_onMenuOptionClick(dropdownKey) { private onMenuOptionClick = (dropdownKey: string) => {
this._close(); this.close();
this.props.onOptionChange(dropdownKey); this.props.onOptionChange(dropdownKey);
} };
_onInputKeyDown = (e) => { private onInputKeyDown = (e: React.KeyboardEvent) => {
let handled = true; let handled = true;
// These keys don't generate keypress events and so needs to be on keyup // These keys don't generate keypress events and so needs to be on keyup
@ -204,16 +213,16 @@ export default class Dropdown extends React.Component {
this.props.onOptionChange(this.state.highlightedOption); this.props.onOptionChange(this.state.highlightedOption);
// fallthrough // fallthrough
case Key.ESCAPE: case Key.ESCAPE:
this._close(); this.close();
break; break;
case Key.ARROW_DOWN: case Key.ARROW_DOWN:
this.setState({ this.setState({
highlightedOption: this._nextOption(this.state.highlightedOption), highlightedOption: this.nextOption(this.state.highlightedOption),
}); });
break; break;
case Key.ARROW_UP: case Key.ARROW_UP:
this.setState({ this.setState({
highlightedOption: this._prevOption(this.state.highlightedOption), highlightedOption: this.prevOption(this.state.highlightedOption),
}); });
break; break;
default: default:
@ -224,53 +233,46 @@ export default class Dropdown extends React.Component {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
} }
} };
_onInputChange(e) { private onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({ this.setState({
searchQuery: e.target.value, searchQuery: e.currentTarget.value,
}); });
if (this.props.onSearchChange) { if (this.props.onSearchChange) {
this.props.onSearchChange(e.target.value); this.props.onSearchChange(e.currentTarget.value);
} }
} };
_collectRoot(e) { private collectRoot = (e: HTMLDivElement) => {
if (this.dropdownRootElement) { if (this.dropdownRootElement) {
this.dropdownRootElement.removeEventListener( this.dropdownRootElement.removeEventListener('click', this.onRootClick, false);
'click', this._onRootClick, false,
);
} }
if (e) { if (e) {
e.addEventListener('click', this._onRootClick, false); e.addEventListener('click', this.onRootClick, false);
} }
this.dropdownRootElement = e; this.dropdownRootElement = e;
} };
_collectInputTextBox(e) { private setHighlightedOption = (optionKey: string) => {
this.inputTextBox = e;
if (e) e.focus();
}
_setHighlightedOption(optionKey) {
this.setState({ this.setState({
highlightedOption: optionKey, highlightedOption: optionKey,
}); });
} };
_nextOption(optionKey) { private nextOption(optionKey: string): string {
const keys = Object.keys(this.childrenByKey); const keys = Object.keys(this.childrenByKey);
const index = keys.indexOf(optionKey); const index = keys.indexOf(optionKey);
return keys[(index + 1) % keys.length]; return keys[(index + 1) % keys.length];
} }
_prevOption(optionKey) { private prevOption(optionKey: string): string {
const keys = Object.keys(this.childrenByKey); const keys = Object.keys(this.childrenByKey);
const index = keys.indexOf(optionKey); const index = keys.indexOf(optionKey);
return keys[(index - 1) % keys.length]; return keys[(index - 1) % keys.length];
} }
_scrollIntoView(node) { private scrollIntoView(node: Element) {
if (node) { if (node) {
node.scrollIntoView({ node.scrollIntoView({
block: "nearest", block: "nearest",
@ -279,18 +281,18 @@ export default class Dropdown extends React.Component {
} }
} }
_getMenuOptions() { private getMenuOptions() {
const options = React.Children.map(this.props.children, (child) => { const options = React.Children.map(this.props.children, (child) => {
const highlighted = this.state.highlightedOption === child.key; const highlighted = this.state.highlightedOption === child.key;
return ( return (
<MenuOption <MenuOption
id={`${this.props.id}__${child.key}`} id={`${this.props.id}__${child.key}`}
key={child.key} key={child.key}
dropdownKey={child.key} dropdownKey={child.key as string}
highlighted={highlighted} highlighted={highlighted}
onMouseEnter={this._setHighlightedOption} onMouseEnter={this.setHighlightedOption}
onClick={this._onMenuOptionClick} onClick={this.onMenuOptionClick}
inputRef={highlighted ? this._scrollIntoView : undefined} inputRef={highlighted ? this.scrollIntoView : undefined}
> >
{ child } { child }
</MenuOption> </MenuOption>
@ -307,7 +309,7 @@ export default class Dropdown extends React.Component {
render() { render() {
let currentValue; let currentValue;
const menuStyle = {}; const menuStyle: CSSProperties = {};
if (this.props.menuWidth) menuStyle.width = this.props.menuWidth; if (this.props.menuWidth) menuStyle.width = this.props.menuWidth;
let menu; let menu;
@ -316,10 +318,10 @@ export default class Dropdown extends React.Component {
currentValue = ( currentValue = (
<input <input
type="text" type="text"
autoFocus={true}
className="mx_Dropdown_option" className="mx_Dropdown_option"
ref={this._collectInputTextBox} onKeyDown={this.onInputKeyDown}
onKeyDown={this._onInputKeyDown} onChange={this.onInputChange}
onChange={this._onInputChange}
value={this.state.searchQuery} value={this.state.searchQuery}
role="combobox" role="combobox"
aria-autocomplete="list" aria-autocomplete="list"
@ -332,7 +334,7 @@ export default class Dropdown extends React.Component {
} }
menu = ( menu = (
<div className="mx_Dropdown_menu" style={menuStyle} role="listbox" id={`${this.props.id}_listbox`}> <div className="mx_Dropdown_menu" style={menuStyle} role="listbox" id={`${this.props.id}_listbox`}>
{ this._getMenuOptions() } { this.getMenuOptions() }
</div> </div>
); );
} }
@ -356,14 +358,14 @@ export default class Dropdown extends React.Component {
// Note the menu sits inside the AccessibleButton div so it's anchored // Note the menu sits inside the AccessibleButton div so it's anchored
// to the input, but overflows below it. The root contains both. // to the input, but overflows below it. The root contains both.
return <div className={classnames(dropdownClasses)} ref={this._collectRoot}> return <div className={classnames(dropdownClasses)} ref={this.collectRoot}>
<AccessibleButton <AccessibleButton
className="mx_Dropdown_input mx_no_textinput" className="mx_Dropdown_input mx_no_textinput"
onClick={this._onInputClick} onClick={this.onInputClick}
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={this.state.expanded} aria-expanded={this.state.expanded}
disabled={this.props.disabled} disabled={this.props.disabled}
inputRef={this._button} inputRef={this.buttonRef}
aria-label={this.props.label} aria-label={this.props.label}
aria-describedby={`${this.props.id}_value`} aria-describedby={`${this.props.id}_value`}
> >
@ -374,28 +376,3 @@ export default class Dropdown extends React.Component {
</div>; </div>;
} }
} }
Dropdown.propTypes = {
id: PropTypes.string.isRequired,
// The width that the dropdown should be. If specified,
// the dropped-down part of the menu will be set to this
// width.
menuWidth: PropTypes.number,
// Called when the selected option changes
onOptionChange: PropTypes.func.isRequired,
// Called when the value of the search field changes
onSearchChange: PropTypes.func,
searchEnabled: PropTypes.bool,
// Function that, given the key of an option, returns
// a node representing that option to be displayed in the
// box itself as the currently-selected option (ie. as
// opposed to in the actual dropped-down part). If
// unspecified, the appropriate child element is used as
// in the dropped-down menu.
getShortOption: PropTypes.func,
value: PropTypes.string,
// negative for consistency with HTML
disabled: PropTypes.bool,
// ARIA label
label: PropTypes.string.isRequired,
};