Newer
Older
// @ts-strict-ignore
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';
Joel Jeremy Marquez
committed
import { SvgRemove } from '../../icons/v2';
import { useResponsive } from '../../ResponsiveProvider';
import { theme, styles } from '../../style';
import { Button } from '../common/Button';
import { Input } from '../common/Input';
import { Popover } from '../common/Popover';
import { View } from '../common/View';
type CommonAutocompleteProps<T extends Item> = {
focused?: boolean;
embedded?: boolean;
containerProps?: HTMLProps<HTMLDivElement>;
labelProps?: { id?: string };
inputProps?: Omit<ComponentProps<typeof Input>, 'onChange'> & {
onChange?: (value: string) => void;
};
suggestions?: T[];
renderInput?: (props: ComponentProps<typeof Input>) => ReactNode;
renderItems?: (
items: T[],
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
idx: number,
value?: string,
) => ReactNode;
itemToString?: (item: T) => string;
shouldSaveFromKey?: (e: KeyboardEvent) => boolean;
filterSuggestions?: (suggestions: T[], value: string) => T[];
openOnFocus?: boolean;
getHighlightedIndex?: (suggestions: T[]) => number | null;
highlightFirst?: boolean;
onUpdate?: (id: T['id'], value: string) => void;
strict?: boolean;
clearOnBlur?: boolean;
clearOnSelect?: boolean;
closeOnBlur?: boolean;
onClose?: () => void;
};
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 | T['id'] | null {
const idx = suggestions.findIndex(item => item.id === value);
return idx === -1 ? null : suggestions[idx];
}
return value;
}
function getItemName<T extends Item>(item: T | T['name'] | null): string {
if (item == null) {
return '';
} else if (typeof item === 'string') {
return item;
}
return item.name || '';
}
function getItemId<T extends Item>(item: T | T['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()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.includes(
value
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, ''),
);
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) {
type SingleAutocompleteProps<T extends Item> = CommonAutocompleteProps<T> & {
type?: 'single' | never;
Matiss Janis Aboltins
committed
onSelect: (id: T['id'], value: string) => void;
Matiss Janis Aboltins
committed
value: null | T | T['id'];
Matiss Janis Aboltins
committed
function SingleAutocomplete<T extends Item>({
focused,
embedded = false,
containerProps,
labelProps = {},
inputProps = {},
suggestions,
renderInput = defaultRenderInput,
renderItems = defaultRenderItems,
itemToString = defaultItemToString,
shouldSaveFromKey = defaultShouldSaveFromKey,
filterSuggestions = defaultFilterSuggestions,
openOnFocus = true,
getHighlightedIndex,
highlightFirst,
onUpdate,
strict,
onSelect,
clearOnBlur = true,
clearOnSelect = false,
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);
const open = () => setIsOpen(true);
const close = () => {
setIsOpen(false);
onClose?.();
};
const triggerRef = useRef(null);
const { isNarrowWidth } = useResponsive();
const narrowInputStyle = isNarrowWidth
? {
...styles.mobileMenuItem,
}
: {};
inputProps = {
...inputProps,
style: {
...narrowInputStyle,
...inputProps.style,
},
};
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 (clearOnSelect) {
setValue('');
} else {
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);
}
}}
selectedItem={selectedItem instanceof Object ? 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 (
!clearOnBlur &&
changes.type === Downshift.stateChangeTypes.mouseUp
) {
return;
}
if (
'highlightedIndex' in changes &&
changes.type !== Downshift.stateChangeTypes.changeInput
) {
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
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}>
<View ref={triggerRef} style={{ flexShrink: 0 }}>
{renderInput(
getInputProps({
focused,
...inputProps,
onFocus: e => {
inputProps.onFocus?.(e);
if (openOnFocus) {
open();
},
onBlur: e => {
// Should this be e.nativeEvent
e['preventDownshiftDefault'] = true;
inputProps.onBlur?.(e);
if (!closeOnBlur) return;
if (clearOnBlur) {
if (e.target.value === '') {
onSelect?.(null, e.target.value);
setSelectedItem(null);
close();
return;
}
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
// 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 {
close();
}
},
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();
onKeyDown?.(e);
} 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);
if (embedded) {
open();
} else {
close();
}
},
onChange: (e: ChangeEvent<HTMLInputElement>) => {
const { onChange } = inputProps || {};
onChange?.(e.target.value);
},
}),
)}
</View>
{isOpen &&
filtered.length > 0 &&
(embedded ? (
<View
style={{ ...styles.darkScrollbar, marginTop: 5 }}
data-testid="autocomplete"
>
{renderItems(
filtered,
getItemProps,
highlightedIndex,
<Popover
triggerRef={triggerRef}
placement="bottom start"
isOpen={isOpen}
onOpenChange={close}
isNonModal
...styles.darkScrollbar,
backgroundColor: theme.menuAutoCompleteBackground,
color: theme.menuAutoCompleteText,
minWidth: 200,
width: triggerRef.current?.clientWidth,
}}
data-testid="autocomplete"
>
{renderItems(
filtered,
getItemProps,
highlightedIndex,
</Popover>
))}
</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}>
Joel Jeremy Marquez
committed
<SvgRemove style={{ width: 8, height: 8 }} />
type MultiAutocompleteProps<T extends Item> = CommonAutocompleteProps<T> & {
type: 'multi';
onSelect: (ids: T['id'][], id?: T['id']) => void;
value: null | T[] | T['id'][];
Matiss Janis Aboltins
committed
function MultiAutocomplete<T extends Item>({
value: selectedItems = [],
clearOnBlur = true,
Matiss Janis Aboltins
committed
}: MultiAutocompleteProps<T>) {
const [focused, setFocused] = useState(false);
const selectedItemIds = selectedItems.map(getItemId);
function onRemoveItem(id: T['id']) {
const items = selectedItemIds.filter(i => i !== id);
function onAddItem(id: T['id']) {
onSelect([...selectedItemIds, id], id);
Matiss Janis Aboltins
committed
function onKeyDown(
e: KeyboardEvent<HTMLInputElement>,
prevOnKeyDown?: ComponentProps<typeof Input>['onKeyDown'],
) {
if (e.key === 'Backspace' && e.currentTarget.value === '') {
onRemoveItem(selectedItemIds[selectedItems.length - 1]);
type="single"
clearOnBlur={clearOnBlur}
clearOnSelect={true}
item => !selectedItemIds.includes(getItemId(item)),
)}
onSelect={onAddItem}
highlightFirst
strict={strict}
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) {
if (!show) {
return null;
}
<View
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>>;
export function Autocomplete<T extends Item>({
}: AutocompleteProps<T>) {
if (props.type === 'multi') {
Matiss Janis Aboltins
committed
return <SingleAutocomplete {...props} />;