Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame^] | 1 | // Copyright 2021 The Chromium Authors |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 2 | // Use of this source code is governed by a BSD-style license that can be |
| 3 | // found in the LICENSE file. |
| 4 | |
| 5 | import React from 'react'; |
| 6 | |
| 7 | import {FilterOptionsState} from '@material-ui/core'; |
| 8 | import Autocomplete, { |
| 9 | AutocompleteChangeDetails, AutocompleteChangeReason, |
| 10 | AutocompleteRenderGetTagProps, AutocompleteRenderInputParams, |
| 11 | AutocompleteRenderOptionState, |
| 12 | } from '@material-ui/core/Autocomplete'; |
| 13 | import Chip, {ChipProps} from '@material-ui/core/Chip'; |
| 14 | import TextField from '@material-ui/core/TextField'; |
| 15 | import {Value} from '@material-ui/core/useAutocomplete'; |
| 16 | |
| 17 | export const MAX_AUTOCOMPLETE_OPTIONS = 100; |
| 18 | |
| 19 | interface AutocompleteProps<T> { |
| 20 | label: string; |
| 21 | options: T[]; |
| 22 | value?: Value<T, boolean, false, true>; |
| 23 | fixedValues?: T[]; |
| 24 | inputType?: React.InputHTMLAttributes<unknown>['type']; |
| 25 | multiple?: boolean; |
| 26 | placeholder?: string; |
| 27 | onChange?: ( |
| 28 | event: React.SyntheticEvent, |
| 29 | value: Value<T, boolean, false, true>, |
| 30 | reason: AutocompleteChangeReason, |
| 31 | details?: AutocompleteChangeDetails<T> |
| 32 | ) => void; |
| 33 | getOptionDescription?: (option: T) => string; |
| 34 | getOptionLabel?: (option: T) => string; |
| 35 | } |
| 36 | |
| 37 | /** |
| 38 | * A wrapper around Material UI Autocomplete that customizes and extends it for |
| 39 | * Monorail's theme and options. Adds support for: |
| 40 | * - Fixed values that render as disabled chips. |
| 41 | * - Option descriptions that render alongside the option labels. |
| 42 | * - Matching on word boundaries in both the labels and descriptions. |
| 43 | * - Highlighting of the matching substrings. |
| 44 | * @return Autocomplete instance with Monorail-specific properties set. |
| 45 | */ |
| 46 | export function ReactAutocomplete<T>( |
| 47 | { |
| 48 | label, options, value = undefined, fixedValues = [], inputType = 'text', |
| 49 | multiple = false, placeholder = '', onChange = () => {}, |
| 50 | getOptionDescription = () => '', getOptionLabel = (o) => String(o) |
| 51 | }: AutocompleteProps<T> |
| 52 | ): React.ReactNode { |
| 53 | value = value || (multiple ? [] : ''); |
| 54 | |
| 55 | return <Autocomplete |
| 56 | id={label} |
| 57 | autoHighlight |
| 58 | autoSelect |
| 59 | filterOptions={_filterOptions(getOptionDescription)} |
| 60 | filterSelectedOptions={multiple} |
| 61 | freeSolo |
| 62 | getOptionLabel={getOptionLabel} |
| 63 | multiple={multiple} |
| 64 | onChange={_onChange(fixedValues, multiple, onChange)} |
| 65 | onKeyDown={_onKeyDown} |
| 66 | options={options} |
| 67 | renderInput={_renderInput(inputType, placeholder)} |
| 68 | renderOption={_renderOption(getOptionDescription, getOptionLabel)} |
| 69 | renderTags={_renderTags(fixedValues, getOptionLabel)} |
| 70 | style={{width: 'var(--mr-edit-field-width)'}} |
| 71 | value={multiple ? [...fixedValues, ...value] : value} |
| 72 | />; |
| 73 | } |
| 74 | |
| 75 | /** |
| 76 | * Modifies the default option matching behavior to match on all Regex word |
| 77 | * boundaries and to match on both label and description. |
| 78 | * @param getOptionDescription Function to get the description for an option. |
| 79 | * @return The text for a given option. |
| 80 | */ |
| 81 | function _filterOptions<T>(getOptionDescription: (option: T) => string) { |
| 82 | return ( |
| 83 | options: T[], |
| 84 | {inputValue, getOptionLabel}: FilterOptionsState<T> |
| 85 | ): T[] => { |
| 86 | if (!inputValue.length) { |
| 87 | return []; |
| 88 | } |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 89 | const prefixMatch = (option: T) => { |
| 90 | const label = getOptionLabel(option); |
| 91 | return label.substring(0, inputValue.length).toLowerCase() === inputValue.toLowerCase(); |
| 92 | } |
| 93 | const prefixMatchOptions = options.filter(prefixMatch); |
| 94 | |
| 95 | const prefixMatchOptionsSet = new Set(prefixMatchOptions); |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 96 | const regex = _matchRegex(inputValue); |
| 97 | const predicate = (option: T) => { |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 98 | return !prefixMatchOptionsSet.has(option) && (getOptionLabel(option).match(regex) || |
| 99 | getOptionDescription(option).match(regex)); |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 100 | } |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 101 | const matchOptions = options.filter(predicate); |
| 102 | return [...prefixMatchOptions, ...matchOptions].slice(0, MAX_AUTOCOMPLETE_OPTIONS); |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 103 | } |
| 104 | } |
| 105 | |
| 106 | /** |
| 107 | * Computes an onChange handler for Autocomplete. Adds logic to make sure |
| 108 | * fixedValues are preserved and wraps whatever onChange handler the parent |
| 109 | * passed in. |
| 110 | * @param fixedValues Values that display in the edit field but can't be |
| 111 | * edited by the user. Usually set by filter rules in Monorail. |
| 112 | * @param multiple Whether this input takes multiple values or not. |
| 113 | * @param onChange onChange property passed in by parent, used to sync value |
| 114 | * changes to parent. |
| 115 | * @return Function that's run on Autocomplete changes. |
| 116 | */ |
| 117 | function _onChange<T, Multiple, DisableClearable, FreeSolo>( |
| 118 | fixedValues: T[], |
| 119 | multiple: Multiple, |
| 120 | onChange: ( |
| 121 | event: React.SyntheticEvent, |
| 122 | value: Value<T, Multiple, DisableClearable, FreeSolo>, |
| 123 | reason: AutocompleteChangeReason, |
| 124 | details?: AutocompleteChangeDetails<T> |
| 125 | ) => void, |
| 126 | ) { |
| 127 | return ( |
| 128 | event: React.SyntheticEvent, |
| 129 | newValue: Value<T, Multiple, DisableClearable, FreeSolo>, |
| 130 | reason: AutocompleteChangeReason, |
| 131 | details?: AutocompleteChangeDetails<T> |
| 132 | ): void => { |
| 133 | // Ensure that fixed values can't be removed. |
| 134 | if (multiple) { |
| 135 | newValue = newValue.filter((option: T) => !fixedValues.includes(option)); |
| 136 | } |
| 137 | |
| 138 | // Propagate onChange callback. |
| 139 | onChange(event, newValue, reason, details); |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | /** |
| 144 | * Custom keydown handler. |
| 145 | * @param e Keyboard event. |
| 146 | */ |
| 147 | function _onKeyDown(e: React.KeyboardEvent) { |
| 148 | // Convert spaces to Enter events to allow users to type space to create new |
| 149 | // chips. |
| 150 | if (e.key === ' ') { |
| 151 | e.key = 'Enter'; |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | /** |
| 156 | * @param inputType A valid HTML 5 input type for the `input` element. |
| 157 | * @param placeholder Placeholder text for the input. |
| 158 | * @return A function that renders the input element used by |
| 159 | * ReactAutocomplete. |
| 160 | */ |
| 161 | function _renderInput(inputType = 'text', placeholder = ''): |
| 162 | (params: AutocompleteRenderInputParams) => React.ReactNode { |
| 163 | return (params: AutocompleteRenderInputParams): React.ReactNode => |
| 164 | <TextField |
| 165 | {...params} variant="standard" size="small" |
| 166 | type={inputType} placeholder={placeholder} |
| 167 | />; |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * Renders a single instance of an option for Autocomplete. |
| 172 | * @param getOptionDescription Function to get the description text shown. |
| 173 | * @param getOptionLabel Function to get the name of the option shown to the |
| 174 | * user. |
| 175 | * @return ReactNode containing the JSX to be rendered. |
| 176 | */ |
| 177 | function _renderOption<T>( |
| 178 | getOptionDescription: (option: T) => string, |
| 179 | getOptionLabel: (option: T) => string |
| 180 | ): React.ReactNode { |
| 181 | return ( |
| 182 | props: React.HTMLAttributes<HTMLLIElement>, |
| 183 | option: T, |
| 184 | {inputValue}: AutocompleteRenderOptionState |
| 185 | ): React.ReactNode => { |
| 186 | // Render the option label. |
| 187 | const label = getOptionLabel(option); |
| 188 | const matchValue = label.match(_matchRegex(inputValue)); |
| 189 | let optionTemplate = <>{label}</>; |
| 190 | if (matchValue) { |
| 191 | // Highlight the matching text. |
| 192 | optionTemplate = <> |
| 193 | {matchValue[1]} |
| 194 | <strong>{matchValue[2]}</strong> |
| 195 | {matchValue[3]} |
| 196 | </>; |
| 197 | } |
| 198 | |
| 199 | // Render the option description. |
| 200 | const description = getOptionDescription(option); |
| 201 | const matchDescription = |
| 202 | description && description.match(_matchRegex(inputValue)); |
| 203 | let descriptionTemplate = <>{description}</>; |
| 204 | if (matchDescription) { |
| 205 | // Highlight the matching text. |
| 206 | descriptionTemplate = <> |
| 207 | {matchDescription[1]} |
| 208 | <strong>{matchDescription[2]}</strong> |
| 209 | {matchDescription[3]} |
| 210 | </>; |
| 211 | } |
| 212 | |
| 213 | // Put the label and description together into one <li>. |
| 214 | return <li |
| 215 | {...props} |
| 216 | className={`${props.className} autocomplete-option`} |
| 217 | style={{display: 'flex', flexDirection: 'row', wordWrap: 'break-word'}} |
| 218 | > |
| 219 | <span style={{display: 'block', width: (description ? '40%' : '100%')}}> |
| 220 | {optionTemplate} |
| 221 | </span> |
| 222 | {description && |
| 223 | <span style={{display: 'block', boxSizing: 'border-box', |
| 224 | paddingLeft: '8px', width: '60%'}}> |
| 225 | {descriptionTemplate} |
| 226 | </span> |
| 227 | } |
| 228 | </li>; |
| 229 | }; |
| 230 | } |
| 231 | |
| 232 | /** |
| 233 | * Helper to render the Chips elements used by Autocomplete. Ensures that |
| 234 | * fixedValues are disabled. |
| 235 | * @param fixedValues Undeleteable values in an issue usually set by filter |
| 236 | * rules. |
| 237 | * @param getOptionLabel Function to compute text for the option. |
| 238 | * @return Function to render the ReactNode for all the chips. |
| 239 | */ |
| 240 | function _renderTags<T>( |
| 241 | fixedValues: T[], getOptionLabel: (option: T) => string |
| 242 | ) { |
| 243 | return ( |
| 244 | value: T[], |
| 245 | getTagProps: AutocompleteRenderGetTagProps |
| 246 | ): React.ReactNode => { |
| 247 | return value.map((option, index) => { |
| 248 | const props: ChipProps = {...getTagProps({index})}; |
| 249 | const disabled = fixedValues.includes(option); |
| 250 | if (disabled) { |
| 251 | delete props.onDelete; |
| 252 | } |
| 253 | |
| 254 | const label = getOptionLabel(option); |
| 255 | return <Chip |
| 256 | {...props} |
| 257 | key={label} |
| 258 | label={label} |
| 259 | disabled={disabled} |
| 260 | size="small" |
| 261 | />; |
| 262 | }); |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | /** |
| 267 | * Generates a RegExp to match autocomplete values. |
| 268 | * @param needle The string the user is searching for. |
| 269 | * @return A RegExp to find matching values. |
| 270 | */ |
| 271 | function _matchRegex(needle: string): RegExp { |
| 272 | // This code copied from ac.js. |
| 273 | // Since we use needle to build a regular expression, we need to escape RE |
| 274 | // characters. We match '-', '{', '$' and others in the needle and convert |
| 275 | // them into "\-", "\{", "\$". |
| 276 | const regexForRegexCharacters = /([\^*+\-\$\\\{\}\(\)\[\]\#?\.])/g; |
| 277 | const modifiedPrefix = needle.replace(regexForRegexCharacters, '\\$1'); |
| 278 | |
| 279 | // Match the modifiedPrefix anywhere as long as it is either at the very |
| 280 | // beginning "Th" -> "The Hobbit", or comes immediately after a word separator |
| 281 | // such as "Ga" -> "The-Great-Gatsby". |
| 282 | const patternRegex = '^(.*\\W)?(' + modifiedPrefix + ')(.*)'; |
| 283 | return new RegExp(patternRegex, 'i' /* ignore case */); |
| 284 | } |