blob: 60284a852300f612192628c0725080d34f369c0f [file] [log] [blame]
// Copyright 2021 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import React from 'react';
import {FilterOptionsState} from '@material-ui/core';
import Autocomplete, {
AutocompleteChangeDetails, AutocompleteChangeReason,
AutocompleteRenderGetTagProps, AutocompleteRenderInputParams,
AutocompleteRenderOptionState,
} from '@material-ui/core/Autocomplete';
import Chip, {ChipProps} from '@material-ui/core/Chip';
import TextField from '@material-ui/core/TextField';
import {Value} from '@material-ui/core/useAutocomplete';
export const MAX_AUTOCOMPLETE_OPTIONS = 100;
interface AutocompleteProps<T> {
label: string;
options: T[];
value?: Value<T, boolean, false, true>;
fixedValues?: T[];
inputType?: React.InputHTMLAttributes<unknown>['type'];
multiple?: boolean;
placeholder?: string;
onChange?: (
event: React.SyntheticEvent,
value: Value<T, boolean, false, true>,
reason: AutocompleteChangeReason,
details?: AutocompleteChangeDetails<T>
) => void;
getOptionDescription?: (option: T) => string;
getOptionLabel?: (option: T) => string;
}
/**
* A wrapper around Material UI Autocomplete that customizes and extends it for
* Monorail's theme and options. Adds support for:
* - Fixed values that render as disabled chips.
* - Option descriptions that render alongside the option labels.
* - Matching on word boundaries in both the labels and descriptions.
* - Highlighting of the matching substrings.
* @return Autocomplete instance with Monorail-specific properties set.
*/
export function ReactAutocomplete<T>(
{
label, options, value = undefined, fixedValues = [], inputType = 'text',
multiple = false, placeholder = '', onChange = () => {},
getOptionDescription = () => '', getOptionLabel = (o) => String(o)
}: AutocompleteProps<T>
): React.ReactNode {
value = value || (multiple ? [] : '');
return <Autocomplete
id={label}
autoHighlight
autoSelect
filterOptions={_filterOptions(getOptionDescription)}
filterSelectedOptions={multiple}
freeSolo
getOptionLabel={getOptionLabel}
multiple={multiple}
onChange={_onChange(fixedValues, multiple, onChange)}
onKeyDown={_onKeyDown}
options={options}
renderInput={_renderInput(inputType, placeholder)}
renderOption={_renderOption(getOptionDescription, getOptionLabel)}
renderTags={_renderTags(fixedValues, getOptionLabel)}
style={{width: 'var(--mr-edit-field-width)'}}
value={multiple ? [...fixedValues, ...value] : value}
/>;
}
/**
* Modifies the default option matching behavior to match on all Regex word
* boundaries and to match on both label and description.
* @param getOptionDescription Function to get the description for an option.
* @return The text for a given option.
*/
function _filterOptions<T>(getOptionDescription: (option: T) => string) {
return (
options: T[],
{inputValue, getOptionLabel}: FilterOptionsState<T>
): T[] => {
if (!inputValue.length) {
return [];
}
const prefixMatch = (option: T) => {
const label = getOptionLabel(option);
return label.substring(0, inputValue.length).toLowerCase() === inputValue.toLowerCase();
}
const prefixMatchOptions = options.filter(prefixMatch);
const prefixMatchOptionsSet = new Set(prefixMatchOptions);
const regex = _matchRegex(inputValue);
const predicate = (option: T) => {
return !prefixMatchOptionsSet.has(option) && (getOptionLabel(option).match(regex) ||
getOptionDescription(option).match(regex));
}
const matchOptions = options.filter(predicate);
return [...prefixMatchOptions, ...matchOptions].slice(0, MAX_AUTOCOMPLETE_OPTIONS);
}
}
/**
* Computes an onChange handler for Autocomplete. Adds logic to make sure
* fixedValues are preserved and wraps whatever onChange handler the parent
* passed in.
* @param fixedValues Values that display in the edit field but can't be
* edited by the user. Usually set by filter rules in Monorail.
* @param multiple Whether this input takes multiple values or not.
* @param onChange onChange property passed in by parent, used to sync value
* changes to parent.
* @return Function that's run on Autocomplete changes.
*/
function _onChange<T, Multiple, DisableClearable, FreeSolo>(
fixedValues: T[],
multiple: Multiple,
onChange: (
event: React.SyntheticEvent,
value: Value<T, Multiple, DisableClearable, FreeSolo>,
reason: AutocompleteChangeReason,
details?: AutocompleteChangeDetails<T>
) => void,
) {
return (
event: React.SyntheticEvent,
newValue: Value<T, Multiple, DisableClearable, FreeSolo>,
reason: AutocompleteChangeReason,
details?: AutocompleteChangeDetails<T>
): void => {
// Ensure that fixed values can't be removed.
if (multiple) {
newValue = newValue.filter((option: T) => !fixedValues.includes(option));
}
// Propagate onChange callback.
onChange(event, newValue, reason, details);
}
}
/**
* Custom keydown handler.
* @param e Keyboard event.
*/
function _onKeyDown(e: React.KeyboardEvent) {
// Convert spaces to Enter events to allow users to type space to create new
// chips.
if (e.key === ' ') {
e.key = 'Enter';
}
}
/**
* @param inputType A valid HTML 5 input type for the `input` element.
* @param placeholder Placeholder text for the input.
* @return A function that renders the input element used by
* ReactAutocomplete.
*/
function _renderInput(inputType = 'text', placeholder = ''):
(params: AutocompleteRenderInputParams) => React.ReactNode {
return (params: AutocompleteRenderInputParams): React.ReactNode =>
<TextField
{...params} variant="standard" size="small"
type={inputType} placeholder={placeholder}
/>;
}
/**
* Renders a single instance of an option for Autocomplete.
* @param getOptionDescription Function to get the description text shown.
* @param getOptionLabel Function to get the name of the option shown to the
* user.
* @return ReactNode containing the JSX to be rendered.
*/
function _renderOption<T>(
getOptionDescription: (option: T) => string,
getOptionLabel: (option: T) => string
): React.ReactNode {
return (
props: React.HTMLAttributes<HTMLLIElement>,
option: T,
{inputValue}: AutocompleteRenderOptionState
): React.ReactNode => {
// Render the option label.
const label = getOptionLabel(option);
const matchValue = label.match(_matchRegex(inputValue));
let optionTemplate = <>{label}</>;
if (matchValue) {
// Highlight the matching text.
optionTemplate = <>
{matchValue[1]}
<strong>{matchValue[2]}</strong>
{matchValue[3]}
</>;
}
// Render the option description.
const description = getOptionDescription(option);
const matchDescription =
description && description.match(_matchRegex(inputValue));
let descriptionTemplate = <>{description}</>;
if (matchDescription) {
// Highlight the matching text.
descriptionTemplate = <>
{matchDescription[1]}
<strong>{matchDescription[2]}</strong>
{matchDescription[3]}
</>;
}
// Put the label and description together into one <li>.
return <li
{...props}
className={`${props.className} autocomplete-option`}
style={{display: 'flex', flexDirection: 'row', wordWrap: 'break-word'}}
>
<span style={{display: 'block', width: (description ? '40%' : '100%')}}>
{optionTemplate}
</span>
{description &&
<span style={{display: 'block', boxSizing: 'border-box',
paddingLeft: '8px', width: '60%'}}>
{descriptionTemplate}
</span>
}
</li>;
};
}
/**
* Helper to render the Chips elements used by Autocomplete. Ensures that
* fixedValues are disabled.
* @param fixedValues Undeleteable values in an issue usually set by filter
* rules.
* @param getOptionLabel Function to compute text for the option.
* @return Function to render the ReactNode for all the chips.
*/
function _renderTags<T>(
fixedValues: T[], getOptionLabel: (option: T) => string
) {
return (
value: T[],
getTagProps: AutocompleteRenderGetTagProps
): React.ReactNode => {
return value.map((option, index) => {
const props: ChipProps = {...getTagProps({index})};
const disabled = fixedValues.includes(option);
if (disabled) {
delete props.onDelete;
}
const label = getOptionLabel(option);
return <Chip
{...props}
key={label}
label={label}
disabled={disabled}
size="small"
/>;
});
}
}
/**
* Generates a RegExp to match autocomplete values.
* @param needle The string the user is searching for.
* @return A RegExp to find matching values.
*/
function _matchRegex(needle: string): RegExp {
// This code copied from ac.js.
// Since we use needle to build a regular expression, we need to escape RE
// characters. We match '-', '{', '$' and others in the needle and convert
// them into "\-", "\{", "\$".
const regexForRegexCharacters = /([\^*+\-\$\\\{\}\(\)\[\]\#?\.])/g;
const modifiedPrefix = needle.replace(regexForRegexCharacters, '\\$1');
// Match the modifiedPrefix anywhere as long as it is either at the very
// beginning "Th" -> "The Hobbit", or comes immediately after a word separator
// such as "Ga" -> "The-Great-Gatsby".
const patternRegex = '^(.*\\W)?(' + modifiedPrefix + ')(.*)';
return new RegExp(patternRegex, 'i' /* ignore case */);
}