Newer
Older
import React, {
useState,
useRef,
useEffect,
useMemo,
type ComponentProps,
type HTMLProps,
type ReactNode,
type KeyboardEvent,
type ChangeEvent,
} from 'react';
Matiss Janis Aboltins
committed
import Downshift, { type StateChangeTypes } from 'downshift';
Joel Jeremy Marquez
committed
import { css } from 'glamor';
import Remove from '../../icons/v2/Remove';
Joel Jeremy Marquez
committed
import { theme, type CSSProperties } from '../../style';
import { Button } from '../common/Button';
import { Input } from '../common/Input';
import { View } from '../common/View';
Matiss Janis Aboltins
committed
import { Tooltip } from '../tooltips';
Matiss Janis Aboltins
committed
type Item = {
id?: string;
name: string;
};
const inst: { lastChangeType?: StateChangeTypes } = {};
Matiss Janis Aboltins
committed
Matiss Janis Aboltins
committed
function findItem<T extends Item>(
strict: boolean,
suggestions: T[],
value: T | T['id'],
): T | null {
const idx = suggestions.findIndex(item => item.id === value);
Matiss Janis Aboltins
committed
if (typeof value === 'string') {
throw new Error('value can be string only if strict = false');
}
Matiss Janis Aboltins
committed
function getItemName(item: null | string | Item): string {
if (item == null) {
return '';
} else if (typeof item === 'string') {
return item;
}
return item.name || '';
}
Matiss Janis Aboltins
committed
function getItemId(item: Item | Item['id']) {
if (typeof item === 'string') {
return item;
}
return item ? item.id : null;
}
Matiss Janis Aboltins
committed
export function defaultFilterSuggestion<T extends Item>(
suggestion: T,
value: string,
) {
return getItemName(suggestion).toLowerCase().includes(value.toLowerCase());
Matiss Janis Aboltins
committed
function defaultFilterSuggestions<T extends Item>(
suggestions: T[],
value: string,
) {
defaultFilterSuggestion(suggestion, value),
Matiss Janis Aboltins
committed
function fireUpdate<T extends Item>(
onUpdate: ((selected: string | null, value: string) => void) | undefined,
strict: boolean,
suggestions: T[],
index: number,
value: string,
) {
// If the index is null, look up the id in the suggestions. If the
// value is empty it will select nothing (as expected). If it's not
// empty but nothing is selected, it still resolves to an id. It
// would very confusing otherwise: the menu could be in a state
// where nothing is highlighted but there is a valid value.
let selected = null;
if (!strict) {
selected = value;
} else {
if (index == null) {
// If passing in a value directly, validate the id
const sug = suggestions.find(sug => sug.id === value);
if (sug) {
selected = sug.id;
}
} else if (index < suggestions.length) {
selected = suggestions[index].id;
}
}
Matiss Janis Aboltins
committed
function defaultRenderInput(props: ComponentProps<typeof Input>) {
Matiss Janis Aboltins
committed
function defaultRenderItems<T extends Item>(
items: T[],
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
highlightedIndex: number,
) {
const name = getItemName(item);
// Downshift calls `setTimeout(..., 250)` in the `onMouseMove`
// event handler they set on this element. When this code runs
// in WebKit on touch-enabled devices, taps on this element end
// up not triggering the `onClick` event (and therefore delaying
// response to user input) until after the `setTimeout` callback
// finishes executing. This is caused by content observation code
// that implements various strategies to prevent the user from
// accidentally clicking content that changed as a result of code
// run in the `onMouseMove` event.
//
// Long story short, we don't want any delay here between the user
// tapping and the resulting action being performed. It turns out
// there's some "fast path" logic that can be triggered in various
// ways to force WebKit to bail on the content observation process.
// One of those ways is setting `role="button"` (or a number of
// other aria roles) on the element, which is what we're doing here.
//
// ref:
// * https://github.com/WebKit/WebKit/blob/447d90b0c52b2951a69df78f06bb5e6b10262f4b/LayoutTests/fast/events/touch/ios/content-observation/400ms-hover-intent.html
// * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebCore/page/ios/ContentChangeObserver.cpp
// * https://github.com/WebKit/WebKit/blob/58956cf59ba01267644b5e8fe766efa7aa6f0c5c/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L783
role="button"
>
{name}
</div>
);
})}
</div>
);
}
Matiss Janis Aboltins
committed
function defaultShouldSaveFromKey(e: KeyboardEvent) {
return e.code === 'Enter';
Matiss Janis Aboltins
committed
function defaultItemToString<T extends Item>(item?: T) {
Matiss Janis Aboltins
committed
type SingleAutocompleteProps<T extends Item> = {
focused?: boolean;
embedded?: boolean;
containerProps?: HTMLProps<HTMLDivElement>;
labelProps?: { id?: string };
inputProps?: Omit<ComponentProps<typeof Input>, 'onChange'> & {
onChange?: (value: string) => void;
};
Matiss Janis Aboltins
committed
suggestions?: T[];
tooltipStyle?: CSSProperties;
tooltipProps?: ComponentProps<typeof Tooltip>;
renderInput?: (props: ComponentProps<typeof Input>) => ReactNode;
renderItems?: (
Matiss Janis Aboltins
committed
items: T[],
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
Matiss Janis Aboltins
committed
value?: string,
Matiss Janis Aboltins
committed
itemToString?: (item: T) => string;
shouldSaveFromKey?: (e: KeyboardEvent) => boolean;
Matiss Janis Aboltins
committed
filterSuggestions?: (suggestions: T[], value: string) => T[];
Matiss Janis Aboltins
committed
getHighlightedIndex?: (suggestions: T[]) => number | null;
Matiss Janis Aboltins
committed
onUpdate?: (id: T['id'], value: string) => void;
Matiss Janis Aboltins
committed
onSelect: (id: T['id'], value: string) => void;
closeOnBlur?: boolean;
Matiss Janis Aboltins
committed
value: T | T['id'];
Matiss Janis Aboltins
committed
function SingleAutocomplete<T extends Item>({
focused,
embedded = false,
containerProps,
labelProps = {},
inputProps = {},
suggestions,
tooltipStyle,
tooltipProps,
renderInput = defaultRenderInput,
renderItems = defaultRenderItems,
itemToString = defaultItemToString,
shouldSaveFromKey = defaultShouldSaveFromKey,
filterSuggestions = defaultFilterSuggestions,
openOnFocus = true,
getHighlightedIndex,
highlightFirst,
onUpdate,
strict,
onSelect,
tableBehavior,
closeOnBlur = true,
value: initialValue,
Matiss Janis Aboltins
committed
}: SingleAutocompleteProps<T>) {
const [selectedItem, setSelectedItem] = useState(() =>
findItem(strict, suggestions, initialValue),
);
const [value, setValue] = useState(
selectedItem ? getItemName(selectedItem) : '',
);
const [isChanged, setIsChanged] = useState(false);
const [originalItem, setOriginalItem] = useState(selectedItem);
const filteredSuggestions = useMemo(
() => filterSuggestions(suggestions, value),
[filterSuggestions, suggestions, value],
);
const [highlightedIndex, setHighlightedIndex] = useState(null);
const [isOpen, setIsOpen] = useState(embedded);
Matiss Janis Aboltins
committed
// Update the selected item if the suggestion list or initial
// input value has changed
useEffect(() => {
setSelectedItem(findItem(strict, suggestions, initialValue));
}, [initialValue, suggestions, strict]);
Matiss Janis Aboltins
committed
function resetState(newValue?: string) {
const val = newValue === undefined ? initialValue : newValue;
Matiss Janis Aboltins
committed
const selectedItem = findItem<T>(strict, suggestions, val);
setSelectedItem(selectedItem);
setValue(selectedItem ? getItemName(selectedItem) : '');
setOriginalItem(selectedItem);
setHighlightedIndex(null);
setIsOpen(embedded);
setIsChanged(false);
}
Matiss Janis Aboltins
committed
function onSelectAfter() {
setValue('');
setSelectedItem(null);
setHighlightedIndex(null);
setIsChanged(false);
}
Matiss Janis Aboltins
committed
const filtered = isChanged ? filteredSuggestions || suggestions : suggestions;
onSelect={(item, { inputValue }) => {
setSelectedItem(item);
setHighlightedIndex(null);
if (isMulti) {
setValue('');
} else {
setIsOpen(false);
}
if (onSelect) {
// I AM NOT PROUD OF THIS OK??
// This WHOLE FILE is a mess anyway
// OK SIT DOWN AND I WILL EXPLAIN
// This component uses `componentWillReceiveProps` and in there
// it will re-filter suggestions if the suggestions change and
// a `highlightedIndex` exists. When we select something,
// we clear `highlightedIndex` so it should show all suggestions
// again. HOWEVER, in the case of a multi-autocomplete, it's
// changing the suggestions every time something is selected.
// In that case, cWRP is running *before* our state setting that
// cleared `highlightedIndex`. Forcing this to run later assures
// us that we will clear out local state before cWRP runs.
// YEAH THAT'S ALL OK I JUST WANT TO SHIP THIS
setTimeout(() => {
onSelect(getItemId(item), inputValue);
}, 0);
}
}}
highlightedIndex={highlightedIndex}
selectedItem={selectedItem || null}
itemToString={itemToString}
inputValue={value}
isOpen={isOpen}
onInputValueChange={(value, changes) => {
// OMG this is the dumbest thing ever. I need to remove Downshift
// and build my own component. For some reason this is fired on blur
// with an empty value which clears out the input when the app blurs
if (!document.hasFocus()) {
return;
}
if (
[
// Do nothing if it's simply updating the selected item
Downshift.stateChangeTypes.controlledPropUpdatedSelectedItem,
// Do nothing if it is a "touch" selection event
Downshift.stateChangeTypes.touchEnd,
// @ts-expect-error Types say there is no type
].includes(changes.type)
) {
return;
}
// Otherwise, filter the items and always the first item if
// desired
const filteredSuggestions = filterSuggestions(suggestions, value);
if (value === '') {
// A blank value shouldn't highlight any item so that the field
// can be left blank if desired
// @ts-expect-error Types say there is no type
if (changes.type !== Downshift.stateChangeTypes.clickItem) {
fireUpdate(onUpdate, strict, filteredSuggestions, null, null);
}
setHighlightedIndex(null);
} else {
const defaultGetHighlightedIndex = filteredSuggestions => {
return highlightFirst && filteredSuggestions.length ? 0 : null;
};
const highlightedIndex = (
getHighlightedIndex || defaultGetHighlightedIndex
)(filteredSuggestions);
// @ts-expect-error Types say there is no type
if (changes.type !== Downshift.stateChangeTypes.clickItem) {
fireUpdate(
onUpdate,
strict,
filteredSuggestions,
highlightedIndex,
value,
);
}
setHighlightedIndex(highlightedIndex);
}
setValue(value);
setIsChanged(true);
}}
onStateChange={changes => {
if (
tableBehavior &&
changes.type === Downshift.stateChangeTypes.mouseUp
) {
return;
}
if (
'highlightedIndex' in changes &&
changes.type !== Downshift.stateChangeTypes.changeInput
) {
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
setHighlightedIndex(changes.highlightedIndex);
}
if ('selectedItem' in changes) {
setSelectedItem(changes.selectedItem);
}
// We only ever want to update the value if the user explicitly
// highlighted an item via the keyboard. It shouldn't change with
// mouseover; otherwise the user could accidentally hover over an
// item without realizing it and change the value.
if (
isOpen &&
(changes.type === Downshift.stateChangeTypes.keyDownArrowUp ||
changes.type === Downshift.stateChangeTypes.keyDownArrowDown)
) {
fireUpdate(
onUpdate,
strict,
filteredSuggestions || suggestions,
changes.highlightedIndex != null
? changes.highlightedIndex
: highlightedIndex,
value,
);
}
inst.lastChangeType = changes.type;
}}
labelId={labelProps?.id}
>
{({
getInputProps,
getItemProps,
isOpen,
inputValue,
highlightedIndex,
}) => (
// Super annoying but it works best to return a div so we
// can't use a View here, but we can fake it be using the
// className
<div className={`view ${css({ display: 'flex' })}`} {...containerProps}>
{renderInput(
getInputProps({
focused,
...inputProps,
onFocus: e => {
if (openOnFocus) {
setIsOpen(true);
}
},
onBlur: e => {
Joel Jeremy Marquez
committed
// Should this be e.nativeEvent
e['preventDownshiftDefault'] = true;
if (!closeOnBlur) return;
if (!tableBehavior) {
if (e.target.value === '') {
setSelectedItem(null);
setIsOpen(false);
return;
}
// If not using table behavior, reset the input on blur. Tables
// handle saving the value on blur.
const value = selectedItem ? getItemId(selectedItem) : null;
resetState(value);
} else {
setIsOpen(false);
}
},
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => {
const { onKeyDown } = inputProps || {};
// If the dropdown is open, an item is highlighted, and the user
// pressed enter, always capture that and handle it ourselves
if (isOpen) {
if (e.key === 'Enter') {
if (highlightedIndex != null) {
if (
inst.lastChangeType ===
Downshift.stateChangeTypes.itemMouseEnter
) {
// If the last thing the user did was hover an item, intentionally
// ignore the default behavior of selecting the item. It's too
// common to accidentally hover an item and then save it
e.preventDefault();
} else {
// Otherwise, stop propagation so that the table navigator
// doesn't handle it
e.stopPropagation();
}
} else if (!strict) {
// Handle it ourselves
e.stopPropagation();
onSelect(value, (e.target as HTMLInputElement).value);
return onSelectAfter();
} else {
// No highlighted item, still allow the table to save the item
// as `null`, even though we're allowing the table to move
e.preventDefault();
}
} else if (shouldSaveFromKey(e)) {
e.preventDefault();
}
}
// Handle escape ourselves
if (e.key === 'Escape') {
e.nativeEvent['preventDownshiftDefault'] = true;
if (!embedded) {
e.stopPropagation();
}
fireUpdate(
onUpdate,
strict,
suggestions,
null,
getItemId(originalItem),
);
setValue(getItemName(originalItem));
setSelectedItem(findItem(strict, suggestions, originalItem));
setHighlightedIndex(null);
setIsOpen(embedded ? true : false);
}
},
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const { onChange } = inputProps || {};
)}
{isOpen &&
filtered.length > 0 &&
(embedded ? (
<View style={{ marginTop: 5 }} data-testid="autocomplete">
{renderItems(
filtered,
getItemProps,
highlightedIndex,
)}
</View>
) : (
<Tooltip
position="bottom-stretch"
offset={2}
style={{
padding: 0,
backgroundColor: theme.menuAutoCompleteBackground,
color: theme.menuAutoCompleteText,
minWidth: 200,
}}
{...tooltipProps}
data-testid="autocomplete"
>
{renderItems(
filtered,
getItemProps,
highlightedIndex,
)}
</Tooltip>
))}
</div>
)}
</Downshift>
);
}
Matiss Janis Aboltins
committed
type MultiItemProps = {
name: string;
onRemove: () => void;
};
function MultiItem({ name, onRemove }: MultiItemProps) {
return (
<View
style={{
alignItems: 'center',
flexDirection: 'row',
<Button type="bare" style={{ marginLeft: 1 }} onClick={onRemove}>
<Remove style={{ width: 8, height: 8 }} />
</Button>
</View>
);
}
Matiss Janis Aboltins
committed
type MultiAutocompleteProps<
T extends Item,
Value = SingleAutocompleteProps<T>['value'],
> = Omit<SingleAutocompleteProps<T>, 'value' | 'onSelect'> & {
value: Value[];
onSelect: (ids: Value[], id?: string) => void;
Matiss Janis Aboltins
committed
function MultiAutocomplete<T extends Item>({
value: selectedItems,
onSelect,
suggestions,
strict,
...props
Matiss Janis Aboltins
committed
}: MultiAutocompleteProps<T>) {
const [focused, setFocused] = useState(false);
Matiss Janis Aboltins
committed
const lastSelectedItems = useRef<typeof selectedItems>();
useEffect(() => {
lastSelectedItems.current = selectedItems;
});
Matiss Janis Aboltins
committed
function onRemoveItem(id: (typeof selectedItems)[0]) {
const items = selectedItems.filter(i => i !== id);
Matiss Janis Aboltins
committed
function onAddItem(id: string) {
if (id) {
id = id.trim();
onSelect([...selectedItems, id], id);
}
}
Matiss Janis Aboltins
committed
function onKeyDown(
e: KeyboardEvent<HTMLInputElement>,
prevOnKeyDown?: ComponentProps<typeof Input>['onKeyDown'],
) {
if (e.key === 'Backspace' && e.currentTarget.value === '') {
onRemoveItem(selectedItems[selectedItems.length - 1]);
}
item => !selectedItems.includes(getItemId(item)),
)}
onSelect={onAddItem}
highlightFirst
strict={strict}
tooltipProps={{
forceLayout: lastSelectedItems.current !== selectedItems,
Joel Jeremy Marquez
committed
renderInput={inputProps => (
Joel Jeremy Marquez
committed
style={{
display: 'flex',
flexWrap: 'wrap',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.tableBackground,
borderRadius: 4,
border: '1px solid ' + theme.formInputBorder,
...(focused && {
border: '1px solid ' + theme.formInputBorderSelected,
boxShadow: '0 1px 1px ' + theme.formInputShadowSelected,
Joel Jeremy Marquez
committed
}),
}}
>
{selectedItems.map((item, idx) => {
item = findItem(strict, suggestions, item);
return (
item && (
<MultiItem
key={getItemId(item) || idx}
name={getItemName(item)}
onRemove={() => onRemoveItem(getItemId(item))}
/>
)
);
})}
<Input
Joel Jeremy Marquez
committed
{...inputProps}
onKeyDown={e => onKeyDown(e, inputProps.onKeyDown)}
Joel Jeremy Marquez
committed
inputProps.onFocus(e);
Joel Jeremy Marquez
committed
inputProps.onBlur(e);
}}
style={{
flex: 1,
minWidth: 30,
border: 0,
':focus': { border: 0, boxShadow: 'none' },
...inputProps.style,
type AutocompleteFooterProps = {
show?: boolean;
embedded: boolean;
children: ReactNode;
};
export function AutocompleteFooter({
show = true,
embedded,
children,
}: AutocompleteFooterProps) {
Joel Jeremy Marquez
committed
style={{
flexShrink: 0,
...(embedded ? { paddingTop: 5 } : { padding: 5 }),
}}
onMouseDown={e => e.preventDefault()}
>
{children}
</View>
)
);
}
Matiss Janis Aboltins
committed
type AutocompleteProps<T extends Item> =
| ComponentProps<typeof SingleAutocomplete<T>>
| ComponentProps<typeof MultiAutocomplete<T>>;
Matiss Janis Aboltins
committed
function isMultiAutocomplete<T extends Item>(
_props: AutocompleteProps<T>,
multi?: boolean,
Matiss Janis Aboltins
committed
): _props is ComponentProps<typeof MultiAutocomplete<T>> {
return multi;
}
Matiss Janis Aboltins
committed
export default function Autocomplete<T extends Item>({
multi,
...props
Matiss Janis Aboltins
committed
}: AutocompleteProps<T> & { multi?: boolean }) {
if (isMultiAutocomplete(props, multi)) {
Matiss Janis Aboltins
committed
return <SingleAutocomplete {...props} />;