Files
ThreadNet-Web/src/components/views/elements/Dropdown.tsx
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

410 lines
14 KiB
TypeScript
Raw Normal View History

2017-03-14 11:50:13 +00:00
/*
2024-09-09 14:57:16 +01:00
Copyright 2024 New Vector Ltd.
Copyright 2017-2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
2017-03-14 11:50:13 +00:00
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
2024-09-09 14:57:16 +01:00
Please see LICENSE files in the repository root for full details.
2017-03-14 11:50:13 +00:00
*/
2025-02-05 13:25:06 +00:00
import React, {
type ChangeEvent,
createRef,
type CSSProperties,
type ReactElement,
type ReactNode,
type Ref,
} from "react";
2017-03-14 11:50:13 +00:00
import classnames from "classnames";
2021-07-06 09:56:02 +01:00
2025-02-05 13:25:06 +00:00
import AccessibleButton, { type ButtonEvent } from "./AccessibleButton";
2017-05-30 16:09:57 +02:00
import { _t } from "../../../languageHandler";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { objectHasDiff } from "../../../utils/objects";
2025-02-05 13:25:06 +00:00
import { type NonEmptyArray } from "../../../@types/common";
2017-03-14 11:50:13 +00:00
2021-07-06 09:56:02 +01:00
interface IMenuOptionProps {
children: ReactElement;
highlighted?: boolean;
dropdownKey: string;
id?: string;
2023-04-20 18:13:30 +01:00
inputRef?: Ref<HTMLLIElement>;
2021-07-06 09:56:02 +01:00
onClick(dropdownKey: string): void;
onMouseEnter(dropdownKey: string): void;
}
2017-03-14 11:50:13 +00:00
2021-07-06 09:56:02 +01:00
class MenuOption extends React.Component<IMenuOptionProps> {
public static defaultProps = {
2017-11-03 12:30:58 +00:00
disabled: false,
};
private onMouseEnter = (): void => {
2017-03-14 11:50:13 +00:00
this.props.onMouseEnter(this.props.dropdownKey);
2021-07-06 09:56:02 +01:00
};
2017-03-14 11:50:13 +00:00
private onClick = (e: React.MouseEvent): void => {
2017-03-14 11:50:13 +00:00
e.preventDefault();
e.stopPropagation();
this.props.onClick(this.props.dropdownKey);
2021-07-06 09:56:02 +01:00
};
2017-03-14 11:50:13 +00:00
public render(): React.ReactNode {
2017-03-14 11:50:13 +00:00
const optClasses = classnames({
mx_Dropdown_option: true,
mx_Dropdown_option_highlight: this.props.highlighted,
});
return (
2023-04-20 18:13:30 +01:00
<li
id={this.props.id}
className={optClasses}
2021-07-06 09:56:02 +01:00
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
role="option"
aria-selected={this.props.highlighted}
ref={this.props.inputRef}
2017-03-14 11:50:13 +00:00
>
2017-10-11 17:56:17 +01:00
{this.props.children}
2023-04-20 18:13:30 +01:00
</li>
2017-10-11 17:56:17 +01:00
);
2017-03-14 11:50:13 +00:00
}
2017-10-11 17:56:17 +01:00
}
2017-03-14 11:50:13 +00:00
export interface DropdownProps {
2021-07-06 09:56:02 +01:00
id: string;
// ARIA label
label: string;
value?: string;
className?: string;
2023-04-20 18:13:30 +01:00
autoComplete?: string;
children: NonEmptyArray<ReactElement & { key: string }>;
2021-07-06 09:56:02 +01:00
// 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;
// Placeholder to show when no value is selected
placeholder?: string;
2021-07-06 09:56:02 +01:00
// 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;
2021-07-06 09:56:02 +01:00
searchQuery: string;
}
2017-03-14 11:50:13 +00:00
/*
* Reusable dropdown select control, akin to react-select,
* but somewhat simpler as react-select is 79KB of minified
* javascript.
*/
export default class Dropdown extends React.Component<DropdownProps, IState> {
2021-07-06 09:56:02 +01:00
private readonly buttonRef = createRef<HTMLDivElement>();
private dropdownRootElement: HTMLDivElement | null = null;
private ignoreEvent: MouseEvent | null = null;
2021-07-06 09:56:02 +01:00
private childrenByKey: Record<string, ReactNode> = {};
public constructor(props: DropdownProps) {
2017-03-14 11:50:13 +00:00
super(props);
2021-07-06 09:56:02 +01:00
this.reindexChildren(this.props.children);
2017-03-14 11:50:13 +00:00
const firstChild = props.children[0];
2017-03-14 11:50:13 +00:00
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.key,
2017-03-14 11:50:13 +00:00
// the current search query
searchQuery: "",
};
}
2017-03-14 11:50:13 +00:00
public componentDidMount(): void {
2017-03-14 11:50:13 +00:00
// Listen for all clicks on the document so we can close the
// menu when the user clicks somewhere else
2021-07-06 09:56:02 +01:00
document.addEventListener("click", this.onDocumentClick, false);
2017-03-14 11:50:13 +00:00
}
public componentDidUpdate(prevProps: Readonly<DropdownProps>): void {
if (objectHasDiff(this.props, prevProps) && this.props.children?.length) {
this.reindexChildren(this.props.children);
const firstChild = this.props.children[0];
this.setState({
highlightedOption: firstChild.key,
});
}
2017-03-14 11:50:13 +00:00
}
public componentWillUnmount(): void {
document.removeEventListener("click", this.onDocumentClick, false);
2017-03-14 11:50:13 +00:00
}
2021-07-06 09:56:02 +01:00
private reindexChildren(children: ReactElement[]): void {
2017-03-14 11:50:13 +00:00
this.childrenByKey = {};
React.Children.forEach(children, (child) => {
this.childrenByKey[(child as DropdownProps["children"][number]).key] = child;
2017-03-14 11:50:13 +00:00
});
}
private onDocumentClick = (ev: MouseEvent): void => {
2017-03-14 11:50:13 +00:00
// Close the dropdown if the user clicks anywhere that isn't
// within our root element
if (ev !== this.ignoreEvent) {
this.setState({
expanded: false,
});
}
2021-07-06 09:56:02 +01:00
};
2017-03-14 11:50:13 +00:00
private onRootClick = (ev: MouseEvent): void => {
2017-03-14 11:50:13 +00:00
// 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
// dropdown immediately after opening it.
// NB. We can't just stopPropagation() because then the event
// doesn't reach the React onClick().
this.ignoreEvent = ev;
2021-07-06 09:56:02 +01:00
};
2017-03-14 11:50:13 +00:00
private onAccessibleButtonClick = (ev: ButtonEvent): void => {
if (this.props.disabled) return;
const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent);
if (!this.state.expanded) {
2021-11-26 15:41:04 +00:00
this.setState({ expanded: true });
ev.preventDefault();
} else if (action === KeyBindingAction.Enter) {
// the accessible button consumes enter onKeyDown for firing onClick, so handle it here
this.props.onOptionChange(this.state.highlightedOption);
this.close();
2021-11-26 15:41:04 +00:00
} else if (!(ev as React.KeyboardEvent).key) {
// collapse on other non-keyboard event activations
this.setState({ expanded: false });
ev.preventDefault();
}
2021-07-06 09:56:02 +01:00
};
2017-03-14 11:50:13 +00:00
private close(): void {
2017-03-14 11:50:13 +00:00
this.setState({
expanded: false,
});
// their focus was on the input, its getting unmounted, move it to the button
2021-07-06 09:56:02 +01:00
if (this.buttonRef.current) {
this.buttonRef.current.focus();
}
2019-12-16 10:03:40 +00:00
}
private onMenuOptionClick = (dropdownKey: string): void => {
2021-07-06 09:56:02 +01:00
this.close();
2017-03-14 11:50:13 +00:00
this.props.onOptionChange(dropdownKey);
2021-07-06 09:56:02 +01:00
};
2017-03-14 11:50:13 +00:00
private onKeyDown = (e: React.KeyboardEvent): void => {
let handled = true;
2017-03-14 11:50:13 +00:00
// These keys don't generate keypress events and so needs to be on keyup
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Enter:
this.props.onOptionChange(this.state.highlightedOption);
2019-12-17 17:31:29 +00:00
// fallthrough
case KeyBindingAction.Escape:
2021-07-06 09:56:02 +01:00
this.close();
break;
case KeyBindingAction.ArrowDown:
if (this.state.expanded) {
this.setState({
highlightedOption: this.nextOption(this.state.highlightedOption),
});
} else {
this.setState({ expanded: true });
}
break;
case KeyBindingAction.ArrowUp:
if (this.state.expanded) {
this.setState({
highlightedOption: this.prevOption(this.state.highlightedOption),
});
} else {
this.setState({ expanded: true });
}
break;
default:
handled = false;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
2017-03-14 11:50:13 +00:00
}
2021-07-06 09:56:02 +01:00
};
2017-03-14 11:50:13 +00:00
private onInputChange = (e: ChangeEvent<HTMLInputElement>): void => {
2017-03-14 11:50:13 +00:00
this.setState({
2021-07-06 09:56:02 +01:00
searchQuery: e.currentTarget.value,
2017-03-14 11:50:13 +00:00
});
if (this.props.onSearchChange) {
2021-07-06 09:56:02 +01:00
this.props.onSearchChange(e.currentTarget.value);
2017-03-14 11:50:13 +00:00
}
2021-07-06 09:56:02 +01:00
};
2017-03-14 11:50:13 +00:00
private collectRoot = (e: HTMLDivElement): void => {
2017-03-14 11:50:13 +00:00
if (this.dropdownRootElement) {
2021-07-06 09:56:02 +01:00
this.dropdownRootElement.removeEventListener("click", this.onRootClick, false);
2017-03-14 11:50:13 +00:00
}
if (e) {
2021-07-06 09:56:02 +01:00
e.addEventListener("click", this.onRootClick, false);
2017-03-14 11:50:13 +00:00
}
this.dropdownRootElement = e;
2021-07-06 09:56:02 +01:00
};
2017-03-14 11:50:13 +00:00
private setHighlightedOption = (optionKey: string): void => {
2017-03-14 11:50:13 +00:00
this.setState({
highlightedOption: optionKey,
});
2021-07-06 09:56:02 +01:00
};
2017-03-14 11:50:13 +00:00
2021-07-06 09:56:02 +01:00
private nextOption(optionKey: string): string {
2017-03-14 11:50:13 +00:00
const keys = Object.keys(this.childrenByKey);
const index = keys.indexOf(optionKey);
return keys[(index + 1) % keys.length];
}
2021-07-06 09:56:02 +01:00
private prevOption(optionKey: string): string {
2017-03-14 11:50:13 +00:00
const keys = Object.keys(this.childrenByKey);
const index = keys.indexOf(optionKey);
return keys[index <= 0 ? keys.length - 1 : (index - 1) % keys.length];
2017-03-14 11:50:13 +00:00
}
private scrollIntoView(node: Element | null): void {
node?.scrollIntoView({
block: "nearest",
behavior: "auto",
});
}
private getMenuOptions(): JSX.Element[] {
2022-09-12 11:58:05 +01:00
const options = React.Children.map(this.props.children, (child: ReactElement) => {
const highlighted = this.state.highlightedOption === child.key;
2017-03-14 11:50:13 +00:00
return (
<MenuOption
id={`${this.props.id}__${child.key}`}
key={child.key}
2021-07-06 09:56:02 +01:00
dropdownKey={child.key as string}
highlighted={highlighted}
2021-07-06 09:56:02 +01:00
onMouseEnter={this.setHighlightedOption}
onClick={this.onMenuOptionClick}
inputRef={highlighted ? this.scrollIntoView : undefined}
2017-03-14 11:50:13 +00:00
>
2017-10-11 17:56:17 +01:00
{child}
2017-03-14 11:50:13 +00:00
</MenuOption>
);
});
if (!options?.length) {
return [
2023-04-20 18:13:30 +01:00
<li key="0" className="mx_Dropdown_option" role="option" aria-selected={false}>
{_t("common|no_results")}
2023-04-20 18:13:30 +01:00
</li>,
];
2017-03-14 11:50:13 +00:00
}
return options;
}
public render(): React.ReactNode {
2023-04-20 18:13:30 +01:00
let currentValue: JSX.Element | undefined;
2017-03-14 11:50:13 +00:00
2021-07-06 09:56:02 +01:00
const menuStyle: CSSProperties = {};
2017-03-14 11:50:13 +00:00
if (this.props.menuWidth) menuStyle.width = this.props.menuWidth;
2023-04-20 18:13:30 +01:00
let menu: JSX.Element | undefined;
2017-03-14 11:50:13 +00:00
if (this.state.expanded) {
if (this.props.searchEnabled) {
currentValue = (
<input
id={`${this.props.id}_input`}
type="text"
2021-07-06 09:56:02 +01:00
autoFocus={true}
2023-04-20 18:13:30 +01:00
autoComplete={this.props.autoComplete}
className="mx_Dropdown_option"
2021-07-06 09:56:02 +01:00
onChange={this.onInputChange}
value={this.state.searchQuery}
role="combobox"
aria-autocomplete="list"
aria-activedescendant={`${this.props.id}__${this.state.highlightedOption}`}
aria-expanded={this.state.expanded}
aria-controls={`${this.props.id}_listbox`}
aria-disabled={this.props.disabled}
aria-label={this.props.label}
onKeyDown={this.onKeyDown}
/>
);
}
menu = (
2023-04-20 18:13:30 +01:00
<ul className="mx_Dropdown_menu" style={menuStyle} role="listbox" id={`${this.props.id}_listbox`}>
2021-07-06 09:56:02 +01:00
{this.getMenuOptions()}
2023-04-20 18:13:30 +01:00
</ul>
);
}
if (!currentValue) {
let selectedChild: ReactNode | undefined;
if (this.props.value) {
selectedChild = this.props.getShortOption
? this.props.getShortOption(this.props.value)
: this.childrenByKey[this.props.value];
}
currentValue = (
<div className="mx_Dropdown_option" id={`${this.props.id}_value`}>
{selectedChild || this.props.placeholder}
2017-10-11 17:56:17 +01:00
</div>
);
2017-03-14 11:50:13 +00:00
}
const dropdownClasses = classnames("mx_Dropdown", this.props.className, {
mx_Dropdown_disabled: !!this.props.disabled,
});
2017-03-14 11:50:13 +00:00
// 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={dropdownClasses} ref={this.collectRoot}>
<AccessibleButton
className="mx_Dropdown_input mx_no_textinput"
onClick={this.onAccessibleButtonClick}
aria-haspopup="listbox"
aria-expanded={this.state.expanded}
disabled={this.props.disabled}
ref={this.buttonRef}
aria-label={this.props.label}
aria-describedby={`${this.props.id}_value`}
aria-owns={`${this.props.id}_input`}
onKeyDown={this.onKeyDown}
>
2017-10-11 17:56:17 +01:00
{currentValue}
2021-11-26 15:41:04 +00:00
<span className="mx_Dropdown_arrow" />
2017-10-11 17:56:17 +01:00
{menu}
2017-03-14 11:50:13 +00:00
</AccessibleButton>
</div>
);
}
}