import { Typography } from '@rmwc/typography'; import { List, ListItem, ListItemGraphic } from '@rmwc/list'; import { MenuSurfaceAnchor, MenuSurface } from '@rmwc/menu'; import { TextField, TextFieldProps, TextFieldHTMLProps } from '@rmwc/textfield'; import { Option, Callback } from '@tutorbook/model'; import { Chip } from '@rmwc/chip'; import { Checkbox } from '@rmwc/checkbox'; import { MDCMenuSurfaceFoundation } from '@material/menu-surface'; import React from 'react'; import to from 'await-to-js'; import SelectHint from './select-hint'; import styles from './select.module.scss'; type TextFieldPropOverrides = 'textarea' | 'outlined' | 'onFocus' | 'onBlur'; interface SelectState { suggestionsOpen: boolean; suggestions: Option[]; inputValue: string; lineBreak: boolean; errored: boolean; } interface UniqueSelectProps { value: Option[]; onChange: Callback[]>; getSuggestions: (query: string) => Promise[]>; renderToPortal?: boolean; autoOpenMenu?: boolean; focused?: boolean; onFocused?: () => any; onBlurred?: () => any; } type Overrides = | TextFieldPropOverrides | keyof UniqueSelectProps | keyof JSX.IntrinsicClassAttributes>; export type SelectProps = Omit> & Omit> & UniqueSelectProps; export default class Select extends React.Component< SelectProps, SelectState > { private suggestionsTimeoutID?: ReturnType; private foundationRef: React.RefObject; private inputRef: React.RefObject; private ghostElementRef: React.RefObject; private lastSelectedRef: React.MutableRefObject | null>; private textareaBreakWidth: React.MutableRefObject; private hasOpenedSuggestions = false; public constructor(props: SelectProps) { super(props); this.state = { suggestionsOpen: false, suggestions: [], errored: false, inputValue: '', lineBreak: false, }; this.foundationRef = React.createRef(); this.inputRef = React.createRef(); this.lastSelectedRef = React.createRef(); this.ghostElementRef = React.createRef(); this.textareaBreakWidth = React.createRef(); this.maybeOpenSuggestions = this.maybeOpenSuggestions.bind(this); this.openSuggestions = this.openSuggestions.bind(this); this.closeSuggestions = this.closeSuggestions.bind(this); this.updateInputValue = this.updateInputValue.bind(this); this.updateInputLine = this.updateInputLine.bind(this); } public componentDidMount(): void { void this.updateSuggestions(); } /** * Ensure that the select menu is positioned correctly **even** if it's anchor * (the `TextField`) changes shape. * @see {@link https://github.com/jamesmfriedman/rmwc/issues/611} */ public componentDidUpdate(): void { const { inputValue } = this.state; const { focused } = this.props; const shouldChangeInputValue: boolean = (inputValue === '' || inputValue === '\xa0') && this.inputValue !== inputValue; /* eslint-disable-next-line react/no-did-update-set-state */ if (shouldChangeInputValue) this.setState({ inputValue: this.inputValue }); if (this.foundationRef.current) { /* eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ (this.foundationRef.current as any).autoPosition_(); } if (focused && this.inputRef.current) this.inputRef.current.focus(); } /** * The `TextField`'s label should float if any of the following is true: * - The `TextField`'s value isn't empty. * - The `TextField` is focused. * - There are options selected (this is the only thing that's custom). * * Make sure to float the `TextField`'s label if there are options selected. * @see {@link https://github.com/jamesmfriedman/rmwc/issues/601} * @see {@link https://github.com/tutorbookapp/covid-tutoring/issues/8} */ private get inputValue(): string { const { value } = this.props; return value.length > 0 ? '\xa0' : ''; } private async updateSuggestions(query = ''): Promise { const { getSuggestions } = this.props; const [err, options] = await to[]>(getSuggestions(query)); if (err) { this.setState({ suggestions: [], errored: true }); } else { this.setState({ suggestions: options as Option[], errored: false }); } } /** * We clear the timeout set by `this.closeSuggestions` to ensure that the * user doesn't get a blip where the suggestion select menu disappears and * reappears abruptly. * @see {@link https://bit.ly/2x9eM27} */ private openSuggestions(): void { const { suggestionsOpen } = this.state; if (this.suggestionsTimeoutID) clearTimeout(this.suggestionsTimeoutID); if (!suggestionsOpen) { this.hasOpenedSuggestions = true; this.setState({ suggestionsOpen: true }); } } private closeSuggestions(): void { const { suggestionsOpen } = this.state; this.suggestionsTimeoutID = setTimeout(() => { if (suggestionsOpen) { this.setState({ suggestionsOpen: false }); this.lastSelectedRef.current = null; } }, 0); } /** * Workaround for styling the input as if it has content. If there are * options selected (in the given `options` object) and the `TextField` * would otherwise be empty, this will update the current input's value to a * string containing a space (`' '`) so that the `TextField` styles itself as * if it were filled. Otherwise, this acts as it normally would by updating * the `TextField`'s value using `setState`. * @see {@link https://github.com/jamesmfriedman/rmwc/issues/601} */ private updateInputValue(event: React.FormEvent): void { const inputValue: string = event.currentTarget.value || this.inputValue; this.updateInputLine(event); this.setState({ inputValue }); void this.updateSuggestions(event.currentTarget.value); this.openSuggestions(); } /** * We don't show the suggestion menu until after the user has started typing. * That way, the user learns that they can type to filter/search the options. * After they learn that (i.e. after the menu has been opened at least once), * we revert back to the original behavior (i.e. opening the menu whenever the * `TextField` input is focused). */ private maybeOpenSuggestions(): void { const { autoOpenMenu } = this.props; if (autoOpenMenu || this.hasOpenedSuggestions) this.openSuggestions(); } /** * This function pushes `