Convert Dropdown to Typescript
This commit is contained in:
parent
1b25ab930e
commit
44bbf60973
1 changed files with 108 additions and 131 deletions
|
@ -1,7 +1,6 @@
|
|||
/*
|
||||
Copyright 2017 Vector Creations Ltd
|
||||
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");
|
||||
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.
|
||||
*/
|
||||
|
||||
import React, { createRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { ChangeEvent, createRef, CSSProperties, ReactElement, ReactNode, Ref } from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
|
||||
class MenuOption extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._onMouseEnter = this._onMouseEnter.bind(this);
|
||||
this._onClick = this._onClick.bind(this);
|
||||
}
|
||||
interface IMenuOptionProps {
|
||||
children: ReactElement;
|
||||
highlighted?: boolean;
|
||||
dropdownKey: string;
|
||||
id?: string;
|
||||
inputRef?: Ref<HTMLDivElement>;
|
||||
onClick(dropdownKey: string): void;
|
||||
onMouseEnter(dropdownKey: string): void;
|
||||
}
|
||||
|
||||
class MenuOption extends React.Component<IMenuOptionProps> {
|
||||
static defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
_onMouseEnter() {
|
||||
private onMouseEnter = () => {
|
||||
this.props.onMouseEnter(this.props.dropdownKey);
|
||||
}
|
||||
};
|
||||
|
||||
_onClick(e) {
|
||||
private onClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onClick(this.props.dropdownKey);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const optClasses = classnames({
|
||||
|
@ -54,8 +57,8 @@ class MenuOption extends React.Component {
|
|||
return <div
|
||||
id={this.props.id}
|
||||
className={optClasses}
|
||||
onClick={this._onClick}
|
||||
onMouseEnter={this._onMouseEnter}
|
||||
onClick={this.onClick}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
role="option"
|
||||
aria-selected={this.props.highlighted}
|
||||
ref={this.props.inputRef}
|
||||
|
@ -65,69 +68,75 @@ class MenuOption extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
MenuOption.propTypes = {
|
||||
children: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
PropTypes.node,
|
||||
]),
|
||||
highlighted: PropTypes.bool,
|
||||
dropdownKey: PropTypes.string,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
onMouseEnter: PropTypes.func.isRequired,
|
||||
inputRef: PropTypes.any,
|
||||
};
|
||||
interface IProps {
|
||||
id: string;
|
||||
// ARIA label
|
||||
label: string;
|
||||
value?: string;
|
||||
className?: string;
|
||||
children: ReactElement[];
|
||||
// negative for consistency with HTML
|
||||
disabled?: boolean;
|
||||
// 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,
|
||||
* but somewhat simpler as react-select is 79KB of minified
|
||||
* javascript.
|
||||
*
|
||||
* TODO: Port NetworkDropdown to use this.
|
||||
*/
|
||||
@replaceableComponent("views.elements.Dropdown")
|
||||
export default class Dropdown extends React.Component {
|
||||
constructor(props) {
|
||||
export default class Dropdown extends React.Component<IProps, IState> {
|
||||
private readonly buttonRef = createRef<HTMLDivElement>();
|
||||
private dropdownRootElement: HTMLDivElement = null;
|
||||
private ignoreEvent: MouseEvent = null;
|
||||
private childrenByKey: Record<string, ReactNode> = {};
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.dropdownRootElement = null;
|
||||
this.ignoreEvent = null;
|
||||
this.reindexChildren(this.props.children);
|
||||
|
||||
this._onInputClick = this._onInputClick.bind(this);
|
||||
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];
|
||||
const firstChild = React.Children.toArray(props.children)[0] as ReactElement;
|
||||
|
||||
this.state = {
|
||||
// True if the menu is dropped-down
|
||||
expanded: false,
|
||||
// The key of the highlighted option
|
||||
// (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
|
||||
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
|
||||
// menu when the user clicks somewhere else
|
||||
document.addEventListener('click', this._onDocumentClick, false);
|
||||
document.addEventListener('click', this.onDocumentClick, false);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('click', this._onDocumentClick, false);
|
||||
document.removeEventListener('click', this.onDocumentClick, false);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return;
|
||||
}
|
||||
this._reindexChildren(nextProps.children);
|
||||
this.reindexChildren(nextProps.children);
|
||||
const firstChild = nextProps.children[0];
|
||||
this.setState({
|
||||
highlightedOption: firstChild ? firstChild.key : null,
|
||||
});
|
||||
}
|
||||
|
||||
_reindexChildren(children) {
|
||||
private reindexChildren(children: ReactElement[]): void {
|
||||
this.childrenByKey = {};
|
||||
React.Children.forEach(children, (child) => {
|
||||
this.childrenByKey[child.key] = child;
|
||||
});
|
||||
}
|
||||
|
||||
_onDocumentClick(ev) {
|
||||
private onDocumentClick = (ev: MouseEvent) => {
|
||||
// Close the dropdown if the user clicks anywhere that isn't
|
||||
// within our root element
|
||||
if (ev !== this.ignoreEvent) {
|
||||
|
@ -157,9 +166,9 @@ export default class Dropdown extends React.Component {
|
|||
expanded: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onRootClick(ev) {
|
||||
private onRootClick = (ev: MouseEvent) => {
|
||||
// This captures any clicks that happen within our elements,
|
||||
// such that we can then ignore them when they're seen by 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
|
||||
// doesn't reach the React onClick().
|
||||
this.ignoreEvent = ev;
|
||||
}
|
||||
};
|
||||
|
||||
_onInputClick(ev) {
|
||||
private onInputClick = (ev: React.MouseEvent) => {
|
||||
if (this.props.disabled) return;
|
||||
|
||||
if (!this.state.expanded) {
|
||||
|
@ -178,24 +187,24 @@ export default class Dropdown extends React.Component {
|
|||
});
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_close() {
|
||||
private close() {
|
||||
this.setState({
|
||||
expanded: false,
|
||||
});
|
||||
// their focus was on the input, its getting unmounted, move it to the button
|
||||
if (this._button.current) {
|
||||
this._button.current.focus();
|
||||
if (this.buttonRef.current) {
|
||||
this.buttonRef.current.focus();
|
||||
}
|
||||
}
|
||||
|
||||
_onMenuOptionClick(dropdownKey) {
|
||||
this._close();
|
||||
private onMenuOptionClick = (dropdownKey: string) => {
|
||||
this.close();
|
||||
this.props.onOptionChange(dropdownKey);
|
||||
}
|
||||
};
|
||||
|
||||
_onInputKeyDown = (e) => {
|
||||
private onInputKeyDown = (e: React.KeyboardEvent) => {
|
||||
let handled = true;
|
||||
|
||||
// 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);
|
||||
// fallthrough
|
||||
case Key.ESCAPE:
|
||||
this._close();
|
||||
this.close();
|
||||
break;
|
||||
case Key.ARROW_DOWN:
|
||||
this.setState({
|
||||
highlightedOption: this._nextOption(this.state.highlightedOption),
|
||||
highlightedOption: this.nextOption(this.state.highlightedOption),
|
||||
});
|
||||
break;
|
||||
case Key.ARROW_UP:
|
||||
this.setState({
|
||||
highlightedOption: this._prevOption(this.state.highlightedOption),
|
||||
highlightedOption: this.prevOption(this.state.highlightedOption),
|
||||
});
|
||||
break;
|
||||
default:
|
||||
|
@ -224,53 +233,46 @@ export default class Dropdown extends React.Component {
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onInputChange(e) {
|
||||
private onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({
|
||||
searchQuery: e.target.value,
|
||||
searchQuery: e.currentTarget.value,
|
||||
});
|
||||
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) {
|
||||
this.dropdownRootElement.removeEventListener(
|
||||
'click', this._onRootClick, false,
|
||||
);
|
||||
this.dropdownRootElement.removeEventListener('click', this.onRootClick, false);
|
||||
}
|
||||
if (e) {
|
||||
e.addEventListener('click', this._onRootClick, false);
|
||||
e.addEventListener('click', this.onRootClick, false);
|
||||
}
|
||||
this.dropdownRootElement = e;
|
||||
}
|
||||
};
|
||||
|
||||
_collectInputTextBox(e) {
|
||||
this.inputTextBox = e;
|
||||
if (e) e.focus();
|
||||
}
|
||||
|
||||
_setHighlightedOption(optionKey) {
|
||||
private setHighlightedOption = (optionKey: string) => {
|
||||
this.setState({
|
||||
highlightedOption: optionKey,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
_nextOption(optionKey) {
|
||||
private nextOption(optionKey: string): string {
|
||||
const keys = Object.keys(this.childrenByKey);
|
||||
const index = keys.indexOf(optionKey);
|
||||
return keys[(index + 1) % keys.length];
|
||||
}
|
||||
|
||||
_prevOption(optionKey) {
|
||||
private prevOption(optionKey: string): string {
|
||||
const keys = Object.keys(this.childrenByKey);
|
||||
const index = keys.indexOf(optionKey);
|
||||
return keys[(index - 1) % keys.length];
|
||||
}
|
||||
|
||||
_scrollIntoView(node) {
|
||||
private scrollIntoView(node: Element) {
|
||||
if (node) {
|
||||
node.scrollIntoView({
|
||||
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 highlighted = this.state.highlightedOption === child.key;
|
||||
return (
|
||||
<MenuOption
|
||||
id={`${this.props.id}__${child.key}`}
|
||||
key={child.key}
|
||||
dropdownKey={child.key}
|
||||
dropdownKey={child.key as string}
|
||||
highlighted={highlighted}
|
||||
onMouseEnter={this._setHighlightedOption}
|
||||
onClick={this._onMenuOptionClick}
|
||||
inputRef={highlighted ? this._scrollIntoView : undefined}
|
||||
onMouseEnter={this.setHighlightedOption}
|
||||
onClick={this.onMenuOptionClick}
|
||||
inputRef={highlighted ? this.scrollIntoView : undefined}
|
||||
>
|
||||
{ child }
|
||||
</MenuOption>
|
||||
|
@ -307,7 +309,7 @@ export default class Dropdown extends React.Component {
|
|||
render() {
|
||||
let currentValue;
|
||||
|
||||
const menuStyle = {};
|
||||
const menuStyle: CSSProperties = {};
|
||||
if (this.props.menuWidth) menuStyle.width = this.props.menuWidth;
|
||||
|
||||
let menu;
|
||||
|
@ -316,10 +318,10 @@ export default class Dropdown extends React.Component {
|
|||
currentValue = (
|
||||
<input
|
||||
type="text"
|
||||
autoFocus={true}
|
||||
className="mx_Dropdown_option"
|
||||
ref={this._collectInputTextBox}
|
||||
onKeyDown={this._onInputKeyDown}
|
||||
onChange={this._onInputChange}
|
||||
onKeyDown={this.onInputKeyDown}
|
||||
onChange={this.onInputChange}
|
||||
value={this.state.searchQuery}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
|
@ -332,7 +334,7 @@ export default class Dropdown extends React.Component {
|
|||
}
|
||||
menu = (
|
||||
<div className="mx_Dropdown_menu" style={menuStyle} role="listbox" id={`${this.props.id}_listbox`}>
|
||||
{ this._getMenuOptions() }
|
||||
{ this.getMenuOptions() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -356,14 +358,14 @@ export default class Dropdown extends React.Component {
|
|||
|
||||
// Note the menu sits inside the AccessibleButton div so it's anchored
|
||||
// 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
|
||||
className="mx_Dropdown_input mx_no_textinput"
|
||||
onClick={this._onInputClick}
|
||||
onClick={this.onInputClick}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={this.state.expanded}
|
||||
disabled={this.props.disabled}
|
||||
inputRef={this._button}
|
||||
inputRef={this.buttonRef}
|
||||
aria-label={this.props.label}
|
||||
aria-describedby={`${this.props.id}_value`}
|
||||
>
|
||||
|
@ -374,28 +376,3 @@ export default class Dropdown extends React.Component {
|
|||
</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,
|
||||
};
|
Loading…
Reference in a new issue