addons/components/Typeahead.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {TypeaheadResult} from './Types';
b69ab319import type {ReactProps} from './utils';
b69ab3110
b69ab3111import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
b69ab3112import {debounce} from 'shared/debounce';
b69ab3113import {Icon} from './Icon';
b69ab3114import {Subtle} from './Subtle';
b69ab3115import {TextField} from './TextField';
b69ab3116import {extractTokens, TokensList, tokensToString} from './Tokens';
b69ab3117
b69ab3118export function Typeahead({
b69ab3119 tokenString,
b69ab3120 setTokenString,
b69ab3121 fetchTokens,
b69ab3122 onSaveNewToken,
b69ab3123 onClickToken,
b69ab3124 renderExtra,
b69ab3125 maxTokens,
b69ab3126 autoFocus,
b69ab3127 debounceInterval,
b69ab3128 ...rest
b69ab3129}: {
b69ab3130 tokenString: string;
b69ab3131 setTokenString: (newValue: string) => void;
b69ab3132 fetchTokens: (
b69ab3133 prefix: string,
b69ab3134 ) => Promise<{values: Array<TypeaheadResult>; fetchStartTimestamp: number}>;
b69ab3135 onSaveNewToken?: (newValue: string) => void;
b69ab3136 onClickToken?: (token: string) => void;
b69ab3137 /** Render more content below typeahead, useful for buttons that can add new tokens */
b69ab3138 renderExtra?: (saveNewValue: (value: string) => void) => React.ReactNode;
b69ab3139 maxTokens?: number;
b69ab3140 autoFocus: boolean;
b69ab3141 debounceInterval?: number;
b69ab3142} & ReactProps<HTMLInputElement>) {
b69ab3143 const ref = useRef<HTMLInputElement>(null);
b69ab3144
b69ab3145 useEffect(() => {
b69ab3146 if (ref.current && autoFocus) {
b69ab3147 ref.current?.focus();
b69ab3148 }
b69ab3149 }, [autoFocus, ref]);
b69ab3150
b69ab3151 const [tokens, remaining] = extractTokens(tokenString);
b69ab3152
b69ab3153 const [typeaheadSuggestions, setTypeaheadSuggestions] = useState<TypeaheadSuggestions>(undefined);
b69ab3154
b69ab3155 const [selectedSuggestionIndex, setSelectedIndex] = useState(0);
b69ab3156
b69ab3157 const fetchTokenHandler = useCallback(
b69ab3158 (value: string, previousTokens: Array<string>) => {
b69ab3159 fetchTokens(value).then(({values, fetchStartTimestamp}) => {
b69ab3160 // don't show typeahead suggestions that are already entered
b69ab3161 const newValues = values.filter(v => !previousTokens.includes(v.value));
b69ab3162
b69ab3163 setTypeaheadSuggestions(last =>
b69ab3164 last?.type === 'success' && last.timestamp > fetchStartTimestamp
b69ab3165 ? // this result is older than the one we've already set: ignore it
b69ab3166 last
b69ab3167 : {type: 'success', values: newValues, timestamp: fetchStartTimestamp},
b69ab3168 );
b69ab3169 });
b69ab3170 },
b69ab3171 [fetchTokens],
b69ab3172 );
b69ab3173
b69ab3174 const debouncedFetchTokenHandler = useMemo(() => {
b69ab3175 return debounce(fetchTokenHandler, debounceInterval ?? 0);
b69ab3176 }, [debounceInterval, fetchTokenHandler]);
b69ab3177
b69ab3178 const onInput = (event: {target: EventTarget | null}) => {
b69ab3179 const newValue = (event?.target as HTMLInputElement)?.value;
b69ab3180 setTokenString(tokensToString(tokens, newValue));
b69ab3181
b69ab3182 if (typeaheadSuggestions?.type !== 'success' || typeaheadSuggestions.values.length === 0) {
b69ab3183 setTypeaheadSuggestions({type: 'loading'});
b69ab3184 }
b69ab3185
b69ab3186 debounceInterval
b69ab3187 ? debouncedFetchTokenHandler(newValue, tokens)
b69ab3188 : fetchTokenHandler(newValue, tokens);
b69ab3189 };
b69ab3190
b69ab3191 const saveNewValue = (value: string | undefined) => {
b69ab3192 if (value && !tokens.includes(value)) {
b69ab3193 setTokenString(tokensToString([...tokens, value], ''));
b69ab3194 // clear out typeahead
b69ab3195 setTypeaheadSuggestions({type: 'success', values: [], timestamp: Date.now()});
b69ab3196
b69ab3197 onSaveNewToken?.(value);
b69ab3198 }
b69ab3199 };
b69ab31100
b69ab31101 return (
b69ab31102 <>
b69ab31103 <div
b69ab31104 className="commit-info-tokenized-field"
b69ab31105 onKeyDown={event => {
b69ab31106 if (event.key === 'Backspace' && ref.current?.value.length === 0) {
b69ab31107 // pop one token off
b69ab31108 setTokenString(tokensToString(tokens.slice(0, -1), ''));
b69ab31109 return;
b69ab31110 }
b69ab31111
b69ab31112 const values = (typeaheadSuggestions as TypeaheadSuggestions & {type: 'success'})?.values;
b69ab31113 if (values == null) {
b69ab31114 return;
b69ab31115 }
b69ab31116
b69ab31117 if (event.key === 'ArrowDown') {
b69ab31118 setSelectedIndex(last => Math.min(last + 1, values.length - 1));
b69ab31119 event.preventDefault();
b69ab31120 } else if (event.key === 'ArrowUp') {
b69ab31121 // allow -1, so you can up arrow "above" the top, to make it highlight nothing
b69ab31122 setSelectedIndex(last => Math.max(last - 1, -1));
b69ab31123 event.preventDefault();
b69ab31124 } else if (event.key === 'Enter') {
b69ab31125 saveNewValue(values[selectedSuggestionIndex].value);
b69ab31126 event.preventDefault();
b69ab31127 }
b69ab31128 }}>
b69ab31129 <TokensList
b69ab31130 tokens={tokens}
b69ab31131 onClickToken={onClickToken}
b69ab31132 onClickX={(token: string) => {
b69ab31133 setTokenString(
b69ab31134 tokensToString(
b69ab31135 tokens.filter(t => t !== token),
b69ab31136 // keep anything already typed in
b69ab31137 ref.current?.value ?? '',
b69ab31138 ),
b69ab31139 );
b69ab31140 }}
b69ab31141 />
b69ab31142 {tokens.length >= (maxTokens ?? Infinity) ? null : (
b69ab31143 <div className="commit-info-field-with-typeahead">
b69ab31144 <TextField ref={ref} value={remaining} onInput={onInput} {...rest} />
b69ab31145 {typeaheadSuggestions?.type === 'loading' ||
b69ab31146 (typeaheadSuggestions?.values?.length ?? 0) > 0 ? (
b69ab31147 <div className="typeahead-suggestions tooltip tooltip-bottom">
b69ab31148 <div className="tooltip-arrow tooltip-arrow-bottom" />
b69ab31149 {typeaheadSuggestions?.type === 'loading' ? (
b69ab31150 <Icon icon="loading" />
b69ab31151 ) : (
b69ab31152 typeaheadSuggestions?.values.map((suggestion, index) => (
b69ab31153 <span
b69ab31154 key={suggestion.value}
b69ab31155 className={
b69ab31156 'suggestion' +
b69ab31157 (index === selectedSuggestionIndex ? ' selected-suggestion' : '')
b69ab31158 }
b69ab31159 onMouseDown={() => {
b69ab31160 saveNewValue(suggestion.value);
b69ab31161 }}>
b69ab31162 {suggestion.image && <ImageWithFallback src={suggestion.image} />}
b69ab31163 <span className="suggestion-label">
b69ab31164 <span>{suggestion.label}</span>
b69ab31165 {(suggestion.detail || suggestion.label !== suggestion.value) && (
b69ab31166 <Subtle>{suggestion.detail ?? suggestion.value}</Subtle>
b69ab31167 )}
b69ab31168 </span>
b69ab31169 </span>
b69ab31170 ))
b69ab31171 )}
b69ab31172 </div>
b69ab31173 ) : null}
b69ab31174 </div>
b69ab31175 )}
b69ab31176 </div>
b69ab31177 {renderExtra?.(saveNewValue)}
b69ab31178 </>
b69ab31179 );
b69ab31180}
b69ab31181
b69ab31182const TRANSPARENT_1PX_GIF =
b69ab31183 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==';
b69ab31184function ImageWithFallback({
b69ab31185 src,
b69ab31186 ...rest
b69ab31187}: {src: string} & React.DetailedHTMLProps<
b69ab31188 React.ImgHTMLAttributes<HTMLImageElement>,
b69ab31189 HTMLImageElement
b69ab31190>) {
b69ab31191 return (
b69ab31192 <img
b69ab31193 src={src}
b69ab31194 onError={e => {
b69ab31195 // Images that fail to load would show a broken image icon.
b69ab31196 // Instead, on error we can replace the image src with a transparent 1x1 gif to hide it
b69ab31197 // and use our CSS fallback.
b69ab31198 if (e.target) {
b69ab31199 (e.target as HTMLImageElement).src = TRANSPARENT_1PX_GIF;
b69ab31200 }
b69ab31201 }}
b69ab31202 {...rest}
b69ab31203 />
b69ab31204 );
b69ab31205}
b69ab31206
b69ab31207type TypeaheadSuggestions =
b69ab31208 | {
b69ab31209 type: 'loading';
b69ab31210 }
b69ab31211 | {type: 'success'; values: Array<TypeaheadResult>; timestamp: number}
b69ab31212 | undefined;