7.1 KB213 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {TypeaheadResult} from './Types';
9import type {ReactProps} from './utils';
10
11import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
12import {debounce} from 'shared/debounce';
13import {Icon} from './Icon';
14import {Subtle} from './Subtle';
15import {TextField} from './TextField';
16import {extractTokens, TokensList, tokensToString} from './Tokens';
17
18export function Typeahead({
19 tokenString,
20 setTokenString,
21 fetchTokens,
22 onSaveNewToken,
23 onClickToken,
24 renderExtra,
25 maxTokens,
26 autoFocus,
27 debounceInterval,
28 ...rest
29}: {
30 tokenString: string;
31 setTokenString: (newValue: string) => void;
32 fetchTokens: (
33 prefix: string,
34 ) => Promise<{values: Array<TypeaheadResult>; fetchStartTimestamp: number}>;
35 onSaveNewToken?: (newValue: string) => void;
36 onClickToken?: (token: string) => void;
37 /** Render more content below typeahead, useful for buttons that can add new tokens */
38 renderExtra?: (saveNewValue: (value: string) => void) => React.ReactNode;
39 maxTokens?: number;
40 autoFocus: boolean;
41 debounceInterval?: number;
42} & ReactProps<HTMLInputElement>) {
43 const ref = useRef<HTMLInputElement>(null);
44
45 useEffect(() => {
46 if (ref.current && autoFocus) {
47 ref.current?.focus();
48 }
49 }, [autoFocus, ref]);
50
51 const [tokens, remaining] = extractTokens(tokenString);
52
53 const [typeaheadSuggestions, setTypeaheadSuggestions] = useState<TypeaheadSuggestions>(undefined);
54
55 const [selectedSuggestionIndex, setSelectedIndex] = useState(0);
56
57 const fetchTokenHandler = useCallback(
58 (value: string, previousTokens: Array<string>) => {
59 fetchTokens(value).then(({values, fetchStartTimestamp}) => {
60 // don't show typeahead suggestions that are already entered
61 const newValues = values.filter(v => !previousTokens.includes(v.value));
62
63 setTypeaheadSuggestions(last =>
64 last?.type === 'success' && last.timestamp > fetchStartTimestamp
65 ? // this result is older than the one we've already set: ignore it
66 last
67 : {type: 'success', values: newValues, timestamp: fetchStartTimestamp},
68 );
69 });
70 },
71 [fetchTokens],
72 );
73
74 const debouncedFetchTokenHandler = useMemo(() => {
75 return debounce(fetchTokenHandler, debounceInterval ?? 0);
76 }, [debounceInterval, fetchTokenHandler]);
77
78 const onInput = (event: {target: EventTarget | null}) => {
79 const newValue = (event?.target as HTMLInputElement)?.value;
80 setTokenString(tokensToString(tokens, newValue));
81
82 if (typeaheadSuggestions?.type !== 'success' || typeaheadSuggestions.values.length === 0) {
83 setTypeaheadSuggestions({type: 'loading'});
84 }
85
86 debounceInterval
87 ? debouncedFetchTokenHandler(newValue, tokens)
88 : fetchTokenHandler(newValue, tokens);
89 };
90
91 const saveNewValue = (value: string | undefined) => {
92 if (value && !tokens.includes(value)) {
93 setTokenString(tokensToString([...tokens, value], ''));
94 // clear out typeahead
95 setTypeaheadSuggestions({type: 'success', values: [], timestamp: Date.now()});
96
97 onSaveNewToken?.(value);
98 }
99 };
100
101 return (
102 <>
103 <div
104 className="commit-info-tokenized-field"
105 onKeyDown={event => {
106 if (event.key === 'Backspace' && ref.current?.value.length === 0) {
107 // pop one token off
108 setTokenString(tokensToString(tokens.slice(0, -1), ''));
109 return;
110 }
111
112 const values = (typeaheadSuggestions as TypeaheadSuggestions & {type: 'success'})?.values;
113 if (values == null) {
114 return;
115 }
116
117 if (event.key === 'ArrowDown') {
118 setSelectedIndex(last => Math.min(last + 1, values.length - 1));
119 event.preventDefault();
120 } else if (event.key === 'ArrowUp') {
121 // allow -1, so you can up arrow "above" the top, to make it highlight nothing
122 setSelectedIndex(last => Math.max(last - 1, -1));
123 event.preventDefault();
124 } else if (event.key === 'Enter') {
125 saveNewValue(values[selectedSuggestionIndex].value);
126 event.preventDefault();
127 }
128 }}>
129 <TokensList
130 tokens={tokens}
131 onClickToken={onClickToken}
132 onClickX={(token: string) => {
133 setTokenString(
134 tokensToString(
135 tokens.filter(t => t !== token),
136 // keep anything already typed in
137 ref.current?.value ?? '',
138 ),
139 );
140 }}
141 />
142 {tokens.length >= (maxTokens ?? Infinity) ? null : (
143 <div className="commit-info-field-with-typeahead">
144 <TextField ref={ref} value={remaining} onInput={onInput} {...rest} />
145 {typeaheadSuggestions?.type === 'loading' ||
146 (typeaheadSuggestions?.values?.length ?? 0) > 0 ? (
147 <div className="typeahead-suggestions tooltip tooltip-bottom">
148 <div className="tooltip-arrow tooltip-arrow-bottom" />
149 {typeaheadSuggestions?.type === 'loading' ? (
150 <Icon icon="loading" />
151 ) : (
152 typeaheadSuggestions?.values.map((suggestion, index) => (
153 <span
154 key={suggestion.value}
155 className={
156 'suggestion' +
157 (index === selectedSuggestionIndex ? ' selected-suggestion' : '')
158 }
159 onMouseDown={() => {
160 saveNewValue(suggestion.value);
161 }}>
162 {suggestion.image && <ImageWithFallback src={suggestion.image} />}
163 <span className="suggestion-label">
164 <span>{suggestion.label}</span>
165 {(suggestion.detail || suggestion.label !== suggestion.value) && (
166 <Subtle>{suggestion.detail ?? suggestion.value}</Subtle>
167 )}
168 </span>
169 </span>
170 ))
171 )}
172 </div>
173 ) : null}
174 </div>
175 )}
176 </div>
177 {renderExtra?.(saveNewValue)}
178 </>
179 );
180}
181
182const TRANSPARENT_1PX_GIF =
183 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==';
184function ImageWithFallback({
185 src,
186 ...rest
187}: {src: string} & React.DetailedHTMLProps<
188 React.ImgHTMLAttributes<HTMLImageElement>,
189 HTMLImageElement
190>) {
191 return (
192 <img
193 src={src}
194 onError={e => {
195 // Images that fail to load would show a broken image icon.
196 // Instead, on error we can replace the image src with a transparent 1x1 gif to hide it
197 // and use our CSS fallback.
198 if (e.target) {
199 (e.target as HTMLImageElement).src = TRANSPARENT_1PX_GIF;
200 }
201 }}
202 {...rest}
203 />
204 );
205}
206
207type TypeaheadSuggestions =
208 | {
209 type: 'loading';
210 }
211 | {type: 'success'; values: Array<TypeaheadResult>; timestamp: number}
212 | undefined;
213