/* Copyright 2024 New Vector Ltd. Copyright 2017-2021 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ import React, { type JSX, type ChangeEvent, createRef, type CSSProperties, type ReactElement, type ReactNode, type Ref, } from "react"; import classnames from "classnames"; import { ChevronDownIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import AccessibleButton, { type ButtonEvent } from "./AccessibleButton"; import { _t } from "../../../languageHandler"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { objectHasDiff } from "../../../utils/objects"; import { type NonEmptyArray } from "../../../@types/common"; interface IMenuOptionProps { children: ReactElement; highlighted?: boolean; dropdownKey: string; id?: string; inputRef?: Ref; onClick(dropdownKey: string): void; onMouseEnter(dropdownKey: string): void; } class MenuOption extends React.Component { public static defaultProps = { disabled: false, }; private onMouseEnter = (): void => { this.props.onMouseEnter(this.props.dropdownKey); }; private onClick = (e: React.MouseEvent): void => { e.preventDefault(); e.stopPropagation(); this.props.onClick(this.props.dropdownKey); }; public render(): React.ReactNode { const optClasses = classnames({ mx_Dropdown_option: true, mx_Dropdown_option_highlight: this.props.highlighted, }); return (
  • {this.props.children}
  • ); } } export interface DropdownProps { id: string; // ARIA label label: string; value?: string; className?: string; autoComplete?: string; children: NonEmptyArray; // 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; // 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; searchQuery: string; } /* * 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 { private readonly buttonRef = createRef(); private dropdownRootElement: HTMLDivElement | null = null; private ignoreEvent: MouseEvent | null = null; private childrenByKey: Record = {}; public constructor(props: DropdownProps) { super(props); this.reindexChildren(this.props.children); const firstChild = props.children[0]; 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, // the current search query searchQuery: "", }; } public componentDidMount(): void { // 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); } public componentDidUpdate(prevProps: Readonly): 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, }); } } public componentWillUnmount(): void { document.removeEventListener("click", this.onDocumentClick, false); } private reindexChildren(children: ReactElement[]): void { this.childrenByKey = {}; React.Children.forEach(children, (child) => { this.childrenByKey[(child as DropdownProps["children"][number]).key] = child; }); } private onDocumentClick = (ev: MouseEvent): void => { // Close the dropdown if the user clicks anywhere that isn't // within our root element if (ev !== this.ignoreEvent) { this.setState({ expanded: false, }); } }; private onRootClick = (ev: MouseEvent): void => { // 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; }; private onAccessibleButtonClick = (ev: ButtonEvent): void => { if (this.props.disabled) return; const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent); if (!this.state.expanded) { 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(); } else if (!(ev as React.KeyboardEvent).key) { // collapse on other non-keyboard event activations this.setState({ expanded: false }); ev.preventDefault(); } }; private close(): void { this.setState({ expanded: false, }); // their focus was on the input, its getting unmounted, move it to the button if (this.buttonRef.current) { this.buttonRef.current.focus(); } } private onMenuOptionClick = (dropdownKey: string): void => { this.close(); this.props.onOptionChange(dropdownKey); }; private onKeyDown = (e: React.KeyboardEvent): void => { let handled = true; // 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); // fallthrough case KeyBindingAction.Escape: this.close(); break; case KeyBindingAction.ArrowDown: if (this.state.expanded) { const nextKey = this.nextOption(this.state.highlightedOption); this.setState({ highlightedOption: nextKey, }); ( this.dropdownRootElement?.querySelector(`#${this.props.id}__${nextKey}`) as HTMLLIElement )?.focus(); } else { this.setState({ expanded: true }); } break; case KeyBindingAction.ArrowUp: if (this.state.expanded) { const prevKey = this.prevOption(this.state.highlightedOption); this.setState({ highlightedOption: prevKey, }); ( this.dropdownRootElement?.querySelector(`#${this.props.id}__${prevKey}`) as HTMLLIElement )?.focus(); } else { this.setState({ expanded: true }); } break; default: handled = false; } if (handled) { e.preventDefault(); e.stopPropagation(); } }; private onInputChange = (e: ChangeEvent): void => { this.setState({ searchQuery: e.currentTarget.value, }); if (this.props.onSearchChange) { this.props.onSearchChange(e.currentTarget.value); } }; private collectRoot = (e: HTMLDivElement): void => { if (this.dropdownRootElement) { this.dropdownRootElement.removeEventListener("click", this.onRootClick, false); } if (e) { e.addEventListener("click", this.onRootClick, false); } this.dropdownRootElement = e; }; private setHighlightedOption = (optionKey: string): void => { this.setState({ highlightedOption: optionKey, }); }; private nextOption(optionKey: string): string { const keys = Object.keys(this.childrenByKey); const index = keys.indexOf(optionKey); return keys[(index + 1) % keys.length]; } private prevOption(optionKey: string): string { const keys = Object.keys(this.childrenByKey); const index = keys.indexOf(optionKey); return keys[index <= 0 ? keys.length - 1 : (index - 1) % keys.length]; } private scrollIntoView(node: Element | null): void { node?.scrollIntoView({ block: "nearest", behavior: "auto", }); } private getMenuOptions(): JSX.Element[] { const options = React.Children.map(this.props.children, (child: ReactElement) => { const highlighted = this.state.highlightedOption === child.key; return ( {child} ); }); if (!options?.length) { return [
  • {_t("common|no_results")}
  • , ]; } return options; } public render(): React.ReactNode { let currentValue: JSX.Element | undefined; const menuStyle: CSSProperties = {}; if (this.props.menuWidth) menuStyle.width = this.props.menuWidth; let menu: JSX.Element | undefined; if (this.state.expanded) { if (this.props.searchEnabled) { currentValue = ( ); } menu = (
      {this.getMenuOptions()}
    ); } 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 = (
    {selectedChild || this.props.placeholder}
    ); } const dropdownClasses = classnames("mx_Dropdown", this.props.className, { mx_Dropdown_disabled: !!this.props.disabled, }); // Note the menu sits inside the AccessibleButton div so it's anchored // to the input, but overflows below it. The root contains both. return (
    {currentValue} {menu}
    ); } }