import { useReducer, useRef, useEffect, useMemo, useLayoutEffect, useState, KeyboardEvent, Ref, RefObject, } from "react"; import { KEY_CODE } from "../../types"; export interface OptionInterface { value: string; label?: string; } export interface SelectState { value: S; isOpen: boolean; searchValue: string; hoverIndex: number; } export interface UseSelect { options: OptionInterface[]; reducer: (s: SelectState, a: any) => SelectState; initialState: SelectState; filterOptions?: ( state: SelectState, options: OptionInterface[], ) => OptionInterface[]; onChange?: (s: S) => any; onCreate?: (s: OptionInterface) => any; } interface Action { type: string; payload: S; } export function createBaseReducer(initialState: SelectState) { return function baseReducer( state: SelectState, action: Action, ): SelectState { switch (action.type) { case "close": return { ...state, isOpen: false }; case "toggle": return { ...state, isOpen: !state.isOpen }; case "hover": return { ...state, hoverIndex: action.payload }; case "clear": return { ...initialState }; default: return state; } }; } export function searchReducer( state: SelectState, action: Action, ): SelectState { switch (action.type) { case "search": return { ...state, searchValue: action.payload, hoverIndex: 0 }; default: return state; } } export function createSingleValueReducer( initialState: SelectState, ) { return function singleValueReducer( state: SelectState, action: Action, ): SelectState { switch (action.type) { case "add": { return { ...state, value: action.payload, searchValue: "" }; } case "remove": { return { ...state, value: initialState.value }; } default: return state; } }; } export function multiValueReducer( state: SelectState, action: Action, ): SelectState { switch (action.type) { case "add": { const value = [...state.value, action.payload]; return { ...state, value, searchValue: "" }; } case "remove": { const rmValue = action.payload; const value = [...state.value]; const index = value.findIndex((v) => v === rmValue); if (index >= 0) { value.splice(index, 1); } return { ...state, value, isOpen: false }; } default: return state; } } export type Reducer = (s: SelectState, action: any) => SelectState; export const combineReducers = (reducers: Reducer[]) => ( state: SelectState, action: any, ) => { const nextState = reducers.reduce((prev, r) => { return r(prev, action); }, state); return nextState; }; const setSearch = (payload: string) => ({ type: "search", payload }); function defaultFilterOptions( { value, searchValue }: SelectState, options: OptionInterface[], ) { let opts: OptionInterface[] = options; if (Array.isArray(value) && value.length > 0) { opts = options.filter((opt) => value.find((v) => v.value !== opt.value)); } if (searchValue) { return opts.filter( (opt) => opt.value .toLocaleLowerCase() .indexOf(searchValue.toLocaleLowerCase()) >= 0, ); } return opts; } interface UseSelectPosition { listboxStyles: { top: number; left: number; width: string; }; comboboxRef: RefObject; } export const useSelectPosition = (): UseSelectPosition => { const comboboxRef = useRef(null); const [listboxStyles, setListboxStyles] = useState({ top: 0, left: 0, width: "100%", }); const position = () => { if (comboboxRef.current) { const cur = comboboxRef.current.getBoundingClientRect(); setListboxStyles({ top: cur.y, left: cur.x, width: `${cur.width}px` }); } }; useLayoutEffect(() => { position(); }, [comboboxRef]); useLayoutEffect(() => { window.addEventListener("resize", position); return () => window.removeEventListener("resize", position); }, []); return { comboboxRef, listboxStyles }; }; export const useSelect = ({ options, reducer, initialState, filterOptions = defaultFilterOptions, onChange = () => {}, onCreate = () => {}, }: UseSelect) => { const [state, dispatch] = useReducer(reducer, initialState); const inputEl: Ref = useRef(null); const availableOptions = useMemo(() => filterOptions(state, options), [ state, options, filterOptions, ]); const hasItem = (item: OptionInterface) => { if (Array.isArray(state.value)) { return state.value.findIndex((i) => i.value === item.value) >= 0; } return false; }; const focus = () => { // some race condition between opening the listbox and focusing the input el setTimeout(() => { if (inputEl && inputEl.current) { inputEl.current.focus(); } }, 100); }; // const findItemIndex = (val: string) => { // return availableOptions.findIndex((o) => o.value === val); // }; const hover = (index: number | undefined) => { if (typeof index === "undefined") return; dispatch({ type: "hover", payload: index }); }; const open = () => { // console.log("open", state); // if (typeof state.value.value === "string") { // hover(findItemIndex(state.value)); // } else { // hover(0); // } hover(0); dispatch({ type: "toggle" }); focus(); }; const closeDropdown = () => dispatch({ type: "close" }); const addItem = (value: OptionInterface) => { if (!hasItem(value)) { dispatch({ type: "add", payload: value }); } closeDropdown(); focus(); }; const removeItem = (value: OptionInterface) => { if (Array.isArray(state.value) && !hasItem(value)) { return; } dispatch({ type: "remove", payload: value }); }; const search = (query: string) => { dispatch(setSearch(query)); if (!state.isOpen) { open(); } }; const create = (item: OptionInterface) => { const found = options.findIndex((o) => o.value === item.value); if (found >= 0) { dispatch(setSearch("")); return; } addItem(item); onCreate(item); }; const onKeyPress = (event: KeyboardEvent) => { const limit = availableOptions.length; if (event.keyCode === KEY_CODE.ESCAPE) { if (state.isOpen) { closeDropdown(); } } else if (event.keyCode === KEY_CODE.ENTER) { if (!state.isOpen) { open(); return; } const res = availableOptions[state.hoverIndex]; if (res) { addItem(res); } else if (state.searchValue) { create({ // label: "", value: state.searchValue, }); } closeDropdown(); } else if (event.keyCode === KEY_CODE.DOWN) { const nextHover = state.hoverIndex + 1; const next = nextHover === limit ? 0 : nextHover; hover(next); } else if (event.keyCode === KEY_CODE.UP) { const prevHover = state.hoverIndex - 1; const prev = prevHover === -1 ? limit - 1 : prevHover; hover(prev); } else if (event.keyCode === KEY_CODE.HOME) { hover(0); } else if (event.keyCode === KEY_CODE.END) { hover(limit - 1); } else if (event.keyCode === KEY_CODE.BACKSPACE) { if (Array.isArray(state.value)) { if (state.value.length > 0) { removeItem(state.value[state.value.length - 1]); } } } }; useEffect(() => { onChange(state.value); }, [state, onChange]); const { listboxStyles, comboboxRef } = useSelectPosition(); return { state, open, add: addItem, select: addItem, remove: removeItem, search, availableOptions, ref: inputEl, close: closeDropdown, hover, create, onKeyPress, clear: () => dispatch({ type: "clear" }), listboxStyles, comboboxRef, }; };