Skip to content

Instantly share code, notes, and snippets.

@rmdort
Last active August 11, 2020 04:48
Show Gist options
  • Save rmdort/08298c7e45a37def28dbe0c940592512 to your computer and use it in GitHub Desktop.
Save rmdort/08298c7e45a37def28dbe0c940592512 to your computer and use it in GitHub Desktop.
Simple downshift hook
import React, {
useState,
useCallback,
useEffect,
useRef,
useMemo
} from "react";
export enum KeyCodes {
Right = 39,
Left = 37,
Up = 38,
Down = 40,
}
/**
* Converts a value to string
* @param value
*/
export const castToString = (value: any): string | undefined => {
if (value === null || value === void 0) return void 0;
return typeof value !== "string" ? "" + value : value;
};
export interface ShiftDownProps {
initialInputValue?: React.ReactText;
initialIsOpen?: boolean;
initialSelectedItem?: Item | React.ReactText;
selectedItem?: Item | string;
options?: Item[] | string[];
filterOnInitialOpen?: boolean;
itemToString?: (item: Item | string) => string;
onChange?: (item: Item | React.ReactText | undefined) => void;
filter?: (item: Item | string) => boolean;
defaultHighlightedIndex?: number | null
}
export interface Item {
label: React.ReactText;
value: any;
}
const defaultItemToString = (text: Item | string) => text as string;
/**
* Simple DownShift replacement. With types.
* Using this due to lack of good typing in Downshift
* @param props
*/
const useShiftDown = (props: ShiftDownProps) => {
const {
initialInputValue = "",
initialIsOpen = false,
initialSelectedItem,
options = [],
filterOnInitialOpen = false,
itemToString = defaultItemToString,
onChange,
filter,
defaultHighlightedIndex = null,
selectedItem: controlledSelecteditem
} = props;
const { current: isControlled } = useRef(controlledSelecteditem !== void 0);
const [highlightedIndex, setHighlightedIndex] = useState<number | null>(defaultHighlightedIndex);
const [isOpen, setIsOpen] = useState<boolean>(initialIsOpen);
const [inputValue, setInputValue] = useState<string>(
castToString(initialInputValue) ?? ""
);
const [selectedItem, setSelectedItem] = useState<
Item | React.ReactText | undefined
>(initialSelectedItem);
const menuRef = useRef<HTMLElement>(null);
const inputRef = useRef<HTMLElement | HTMLInputElement>(null);
const isDirty = useRef<boolean>(false);
const handleSetSelectedItem = useCallback(
(item: Item | string | undefined) => {
setSelectedItem(item);
handleSetInputValue(itemToString(item ?? ""));
},
[]
);
useEffect(() => {
if (!isControlled) return;
if (controlledSelecteditem === selectedItem) {
return;
}
handleSetInputValue(itemToString(controlledSelecteditem ?? ""));
}, [isControlled, controlledSelecteditem, selectedItem]);
const filteredItems = useMemo(() => {
return (options as any[]).filter(item => {
if (
!inputValue ||
(!isDirty.current && !filterOnInitialOpen && inputValue)
)
return true;
if (filter) return filter(item);
const key = typeof item === "object" ? item.value || item.label : item;
return new RegExp(inputValue, "gi").test(key);
});
}, [inputValue, options, filter]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<any>) => {
const keyCode = e.nativeEvent.which;
if (keyCode === KeyCodes.Up || keyCode === KeyCodes.Down) {
if (!isOpen) {
return setIsOpen(true);
}
}
if (keyCode === KeyCodes.Up) {
setHighlightedIndex(prev => {
const next = prev === null ? filteredItems.length - 1 : prev - 1;
if (next < 0) return filteredItems.length - 1;
return next;
});
event?.preventDefault();
} else if (keyCode === KeyCodes.Down) {
setHighlightedIndex(prev => {
const next = prev === null ? 0 : prev + 1;
if (next > filteredItems.length - 1) return 0;
return next;
});
event?.preventDefault();
}
if (keyCode === KeyCodes.Escape) {
closeMenu();
}
if (keyCode === KeyCodes.Enter) {
if (highlightedIndex !== null) {
handleSelect(filteredItems[highlightedIndex]);
}
closeMenu();
}
},
[filteredItems, highlightedIndex, isOpen]
);
useEffect(() => {
const listener = (event: globalThis.MouseEvent) => {
if (
!menuRef ||
!menuRef.current ||
menuRef.current.contains(event.target as Node)
) {
return;
}
closeMenu();
};
document.addEventListener("mouseup", listener);
return () => {
document.removeEventListener("mouseup", listener);
};
}, [menuRef]);
const handleMouseMove = useCallback(
index => {
if (index === highlightedIndex) {
return;
}
setHighlightedIndex(index);
},
[highlightedIndex]
);
const handleMouseDown = useCallback((event: React.MouseEvent<any>) => {
event.preventDefault();
}, []);
const handleSelect = useCallback((item: Item | string) => {
setSelectedItem(item);
handleSetInputValue(itemToString(item));
}, []);
const handleSetInputValue = useCallback((value: string) => {
setInputValue(value);
setHighlightedIndex(defaultHighlightedIndex);
isDirty.current = true;
}, []);
useEffect(() => {
if (selectedItem === initialSelectedItem) {
return;
}
onChange?.(selectedItem);
}, [selectedItem]);
const closeMenu = useCallback(() => {
setIsOpen(false);
}, []);
const openMenu = useCallback(() => {
setIsOpen(true);
}, []);
const toggleMenu = useCallback(() => {
setIsOpen(prev => !prev);
}, []);
const handleFocus = useCallback(() => {
openMenu();
}, []);
const handleBlur = useCallback(() => {
closeMenu();
}, []);
useEffect(() => {
if (isOpen) inputRef.current?.focus();
}, [isOpen]);
useEffect(() => {
// isDirty.current && openMenu()
}, [inputValue]);
return {
highlightedIndex,
onKeyDown: handleKeyDown,
inputValue,
setInputValue: handleSetInputValue,
isOpen,
openMenu,
closeMenu,
toggleMenu,
menuRef,
inputRef,
selectedItem,
setSelectedItem: handleSetSelectedItem,
items: filteredItems,
onMouseMove: handleMouseMove,
onMouseDown: handleMouseDown,
onClick: handleSelect,
onFocus: handleFocus,
onBlur: handleBlur
};
};
export default useShiftDown;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment