"use client";
import * as Popover from "@radix-ui/react-popover";
import clsx from "clsx";
import {
type ContextType,
type Dispatch,
type FC,
type MutableRefObject,
type PropsWithChildren as PWC,
type ReactNode,
type Reducer,
type ReducerAction,
type SetStateAction,
createContext,
forwardRef,
isValidElement,
useCallback,
useContext,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect as useLayoutEffect$1,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import { useFocusRing } from "react-aria";
import { useMediaQuery } from "../../hooks";
import ChevronDown from "../../icons/chevron-down";
import MagnifyingGlass from "../../icons/magnifying-glass";
import reset from "../../styles/reset/reset.module.css";
import { Spinner } from "../Spinner";
import { Text } from "../Text";
import styles from "./combobox.module.css";
import { Dialog } from "./dialog";
import iconButton from "./icon-button.module.css";
const X = () => {
return (
`,
}}
/>
);
};
const useLayoutEffect =
typeof window === "undefined" ? useEffect : useLayoutEffect$1;
/**
* queries a
for all its [data-descendant] items and returns them
* as a `list` ref
*/
let useListItems = () => {
let listRef = useRef([]); // e
let mapRef = useRef({}); // t
let [, force] = useState<{}>(); // n
let ref = useRef(null); // r
useLayoutEffect(() => {
if (!ref.current) return;
let items = Array.from(ref.current.querySelectorAll("[data-descendant]"));
let isOutOfSync =
// UL's data-descendant length is different than the listRef length
items.length !== listRef.current.length ||
// UL's data-descendant elements are different than the listRef elements
!items.every((t, n) => listRef.current[n].element === t);
if (isOutOfSync) {
listRef.current = items.map((el) => {
let n = el.getAttribute("data-descendant");
if (!n)
throw Error("Descendant element must have a data-descendant key");
let r = mapRef.current[n];
return {
element: el,
...r,
};
});
// force a re-render to synchronize everything
force({});
}
});
return {
ref: ref,
list: listRef,
map: mapRef,
force: force,
};
};
/**
* Proxies the ComboboxContet's `list`, `map`, and `force` fields.
*
* These three fields ultimately come from {@link useListItems}.
*/
const ListContext = createContext({
list: { current: [] } as ContextType["list"],
map: { current: {} } as ContextType["map"],
force: (({}) => {}) as ContextType["force"],
});
const useValue = (
context: typeof ListContext,
t: Omit- ,
) => {
let [index, setIndex] = useState(-1);
let ref = useRef
(null);
let { list, map, force } = useContext(context);
let id = useId();
map.current["asd"];
useLayoutEffect(() => {
if (map) {
map.current[id] = {
...t,
_internalId: id,
};
}
force?.({});
return () => {
delete map?.current[id];
list && (list.current = list.current.filter((e) => e._internalId !== id));
setIndex(-1);
force?.({});
};
}, []);
useLayoutEffect(() => {
if (ref.current) {
ref.current.setAttribute("data-descendant", id);
if (map?.current[id]) {
map.current[id] = {
...t,
_internalId: id,
};
}
const nextId = list
? list.current.findIndex((e) => e._internalId === id)
: -1;
setIndex(nextId);
}
});
return {
index,
ref,
id,
};
};
interface Item {
callback(): void;
disabled?: boolean;
displayValue?: string;
isMenu?: boolean;
label: string;
prefix?: ReactNode;
value: string;
_internalId: string;
}
interface ComboboxProps {
value?: string | null;
className?: string;
noNegativeIndex?: boolean;
onChange?: (value: string | null) => void;
filter?: (list: T, inputValue: string) => T;
size?: "small" | "large";
width?: number | string;
errored?: boolean;
disabled?: boolean;
clearable?: boolean;
placeholder?: string;
"aria-label"?: string;
onChangeOpen?: (open: boolean) => void;
noTextSelection?: boolean;
noInputPrefix?: boolean;
onClear?: () => void;
prefixIcon?: ReactNode;
allowTab?: boolean;
shouldContinue?: boolean;
showMenuButton?: boolean;
}
interface State {
open: boolean;
showAllResults: boolean;
inputValue: string;
selectedValue: string | null;
selectedIndex: number;
}
type Action =
| {
type: "OPEN";
selectedIndex?: number;
}
| {
type: "RESET";
inputValue: string;
}
| {
type: "CONTINUE";
selectedValue: string;
}
| {
type: "NAVIGATE";
selectedIndex: number;
}
| {
type: "CLEAR";
}
| {
type: "CLOSE";
}
| {
type: "CHANGE";
inputValue: string;
}
| {
type: "SELECT";
selectedValue: string;
};
const reducer: Reducer = (state, action) => {
// update state based on action
switch (action.type) {
case "OPEN":
return {
...state,
open: true,
showAllResults: true,
selectedIndex: 0,
};
case "CLOSE":
return {
...state,
open: false,
};
case "CHANGE":
return {
...state,
selectedIndex: 0,
inputValue: action.inputValue,
showAllResults: false,
open: true,
};
case "SELECT":
return {
...state,
selectedValue: action.selectedValue,
inputValue: null,
open: false,
};
case "CONTINUE":
return {
...state,
selectedValue: action.selectedValue,
open: true,
};
case "NAVIGATE":
return {
...state,
selectedIndex: action.selectedIndex,
};
case "RESET":
return {
...state,
inputValue: action.inputValue,
};
case "CLEAR":
return {
...state,
inputValue: "",
selectedValue: null,
};
default:
return state;
}
};
const ComboboxContext = createContext({
allowTab: undefined,
ariaLabel: undefined,
clearSelectedValue(e: any) {},
clearable: true,
disabled: undefined,
/**
* - T = "OPEN"
* - A = "CLOSE"
* - S = "RESET"
* - z = "CLEAR"
* - O = "CHANGE"
* - I = "SELECT"
* - N = "CONTINUE"
* - D = "NAVIGATE";
*/
dispatch: (() => {}) as Dispatch>>,
errored: undefined,
filterList: [],
footerRef: { current: null },
force: (() => {}) as Dispatch>,
inputId: "combobox-input-:R6ol8mH1:",
inputRef: { current: null } as MutableRefObject,
inputValue: "",
isMobile: false,
list: { current: Array(0) },
listId: "combobox-list-:R6ol8m:",
listRef: { current: null } as MutableRefObject,
map: {
current: {},
} as MutableRefObject>,
noInputPrefix: undefined,
noNegativeIndex: undefined,
noTextSelection: undefined,
onSelect(e: any): void {},
open: false,
openWithSelection() {},
placeholder: "",
prefixIcon: undefined,
selectedIndex: 0,
selectedValue: null,
showMenuButton: true,
showAllResults: true,
size: undefined as ComboboxProps["size"],
});
const useComboboxContext = () => useContext(ComboboxContext);
/**
One
Two
Three
*/
const Combobox = forwardRef>(
(
{
children,
placeholder,
value,
onChange,
onClear,
shouldContinue = false,
onChangeOpen,
noNegativeIndex,
disabled,
errored,
width,
size,
filter = (items, searchTerm) => {
if (!searchTerm) return items;
// crude filtering
return items.filter((item) => {
return (
item.value.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.label?.toLowerCase().includes(searchTerm.toLowerCase())
);
});
},
noTextSelection,
},
forwardedRef,
) => {
const id = useId();
const inputId = "combobox-input-" + id;
const listId = "combobox-list-" + id;
// U
const inputRef = useRef(null);
// q
const footerRef = useRef(null);
const { ref: listRef, ...Y } = useListItems();
const [
{
inputValue,
// et
open,
selectedIndex,
selectedValue,
showAllResults,
},
dispatch,
] = useReducer(reducer, {
open: false,
showAllResults: true,
inputValue: "",
selectedValue: null,
selectedIndex: 0,
});
// trigger onChangeOpen prop when open changes
useEffect(() => {
onChangeOpen?.(open);
}, [onChangeOpen, open]);
useImperativeHandle(
forwardedRef,
() => ({
setValue(e) {
dispatch({
type: "RESET",
inputValue: e,
});
},
setSelectedValue(e) {
dispatch({
type: "CONTINUE",
selectedValue: e,
});
},
setSelectedIndex(e) {
dispatch({
type: "NAVIGATE",
selectedIndex: e,
});
},
getSelectedIndex: () => X,
clear() {
dispatch({
type: "CLEAR",
});
},
close() {
dispatch({
type: "CLOSE",
});
},
}),
[dispatch, selectedIndex],
);
// controlled handling
let actualValue = value ? value : selectedValue;
// ei
let items = Object.values(Y.map.current) as Item[];
// eo
let regularItems = [];
// MenuItems are sent to the end of the list
// es
let menuItems = [];
for (let item of items) {
item.isMenu ? menuItems.push(item) : regularItems.push(item);
}
let result = showAllResults
? regularItems
: filter(regularItems, inputValue);
let allItems =
showAllResults || result.length ? [...result, ...menuItems] : [];
function handleOpenWithSelection() {
if (!open) {
setTimeout(() => {
noTextSelection || inputRef.current?.select?.();
}, 10);
dispatch({
type: "OPEN",
});
}
}
// when not open, display the selected value
useEffect(() => {
let timerId;
if (!open) {
if (actualValue && items?.length) {
let match = items.find((e) => e.value === actualValue);
if (match) {
// console.log({ match, inputValue });
if (match.displayValue) {
dispatch({
type: "RESET",
inputValue: actualValue,
});
} else if (inputValue) {
timerId = K(() => {
dispatch({
type: "RESET",
inputValue: match.label,
});
});
} else {
dispatch({
type: "RESET",
inputValue: match.label,
});
}
} else {
dispatch({
type: "RESET",
inputValue: actualValue,
});
}
}
return () => {
clearTimeout(timerId);
};
}
}, [actualValue, open, items.length]);
// coerce null to empty string
useEffect(() => {
null === value &&
dispatch({
type: "RESET",
inputValue: "",
});
}, [value, dispatch]);
const isMobile = useMediaQuery("(max-width: 600px)");
return (
{
dispatch({
type: "CLEAR",
});
})
: dispatch({
type: "CLEAR",
});
},
onSelect: function (e) {
onChange?.(e);
shouldContinue
? dispatch({
type: "CONTINUE",
selectedValue: e,
})
: dispatch({
type: "SELECT",
selectedValue: e,
});
},
noNegativeIndex,
openWithSelection: handleOpenWithSelection,
// list, map, force
list: Y.list,
map: Y.map,
force: Y.force,
}}
>
{/*
{JSON.stringify(
{
// inputValue,
// open,
selectedIndex,
selectedValue,
// showAllResults,
// ...Y,
},
null,
2,
)}
*/}
{children}
);
},
);
Combobox.displayName = "Combobox";
interface InputProps {
className?: string;
onChange?: (e) => void;
loading?: boolean;
onKeyDown?: (e) => void;
onBlur?: (e) => void;
onFocus?: (e) => void;
}
const Input: FC> = (props) => {
const { className, onChange, loading, onKeyDown, onBlur, onFocus, ...rest } =
props;
const {
open,
inputValue,
dispatch,
inputRef,
inputId,
ariaLabel,
placeholder,
listId,
list,
selectedIndex,
size,
errored,
noInputPrefix,
disabled,
noTextSelection,
footerRef,
allowTab,
isMobile,
openWithSelection, // S
clearSelectedValue,
} = useComboboxContext();
const selectedId = list.current[selectedIndex]?._internalId;
const handleKeyDown = (function ({
dispatch,
open,
selectedIndex,
inputRef,
footerRef,
list,
noTextSelection,
allowTab,
onKeyDown,
}) {
return function (e) {
switch (e.key) {
case "Escape":
e.preventDefault();
if (open) {
dispatch({
type: "CLOSE",
});
}
break;
case "Home":
e.preventDefault();
dispatch({
type: "NAVIGATE",
selectedIndex: 0,
});
break;
case "End":
e.preventDefault();
dispatch({
type: "NAVIGATE",
selectedIndex: list.current.length - 1,
});
break;
case "ArrowDown": {
e.preventDefault();
if (!open) {
dispatch({
type: "OPEN",
});
noTextSelection || inputRef.current?.select?.();
break;
}
let l = footerRef.current?.querySelector(
"input:not([type=hidden]), select, button, textarea, a",
);
if (selectedIndex === list.current.length - 1 && l) {
l.focus();
dispatch({
type: "NAVIGATE",
selectedIndex: -1,
});
break;
}
if (list.current.length > 0) {
// s = list
// r = selectedIndex
let e = P(selectedIndex, list.current);
for (; e !== selectedIndex && list.current[e].disabled; )
e = P(e, list.current);
dispatch({
type: "NAVIGATE",
selectedIndex: e,
});
}
break;
}
case "ArrowUp": {
e.preventDefault();
if (!open) {
dispatch({
type: "OPEN",
selectedIndex: list.current.length - 1,
});
inputRef.current?.select();
break;
}
let a = footerRef.current?.querySelector(
"input:not([type=hidden]), select, button, textarea, a",
);
if (0 === selectedIndex && a) {
a.focus();
dispatch({
type: "NAVIGATE",
selectedIndex: -1,
});
break;
}
if (list.current.length > 0) {
// s = list
// r = selectedIndex
let e = L(selectedIndex, list.current);
for (; e !== selectedIndex && list.current[e].disabled; )
e = L(e, list.current);
dispatch({
type: "NAVIGATE",
selectedIndex: e,
});
}
break;
}
case "Tab":
case "Enter": {
if ("Tab" === e.key && !allowTab) break;
e.preventDefault();
let n = list.current[selectedIndex]?.callback;
if (!n) {
dispatch({
type: "CLOSE",
});
break;
}
n();
}
}
onKeyDown && onKeyDown(e);
};
})({
open,
noTextSelection,
list: list,
selectedIndex,
dispatch,
inputRef,
footerRef,
allowTab,
onKeyDown,
});
return (
);
};
Input.displayName = "Combobox.Input";
interface ListProps {
className?: string;
emptyMessage?: string;
ListFooterComponent?: ReactNode;
align?: "center" | "start" | "end";
side?: "top" | "bottom" | "left" | "right";
alignOffset?: number;
width?: number | string;
avoidCollisions?: boolean;
}
// Z
const List: FC> = (props) => {
const {
children,
className,
emptyMessage = "No results",
ListFooterComponent,
align = "center",
side = "bottom",
alignOffset,
width,
avoidCollisions = true,
...rest
} = props;
const {
listId,
listRef,
footerRef,
map,
list,
force,
inputRef,
open,
isMobile,
selectedIndex,
dispatch,
} = useComboboxContext();
const [_width, setWidth] = useState(0);
// const {mounted: N,rendered:R} = ({exitDelay:150})
// ???
useEffect(() => {
width && setWidth(width);
}, [width]);
// handle window resize
useEffect(() => {
let e = inputRef.current;
if (!e) return;
let t = new window.ResizeObserver(() => {
if (!width) {
let { width: t } = e.getBoundingClientRect();
setWidth(t);
}
});
return (
t.observe(e),
() => {
t.disconnect();
}
);
}, []);
// TODO: describe this effect
useEffect(() => {
if (!listRef.current) return;
let e = new Map();
let items = Array.from(
listRef.current.querySelectorAll("[data-descendant]"),
);
items
.sort(
(e, t) =>
Number(e.getAttribute("data-order")) -
Number(t.getAttribute("data-order")),
)
.forEach((t) => {
if (t.parentElement) {
t.parentElement.appendChild(t);
let n = t.closest("[data-geist-combobox-list] > *");
!n ||
n === t ||
n === listRef.current ||
e.has(n) ||
(listRef.current?.appendChild(n), e.set(n, !0));
}
});
});
// Footer?
// (({ dispatch, open, inputRef, footerRef, list }) => {
// console.log("WHAT??");
// function handleKeyDown(e) {
// switch (e.key) {
// case "Escape":
// e.preventDefault();
// if (open) {
// inputRef.current?.focus();
// dispatch({
// type: "CLOSE",
// });
// }
// break;
// case "Home":
// case "ArrowDown":
// e.preventDefault();
// inputRef.current?.focus();
// dispatch({
// type: "NAVIGATE",
// selectedIndex: 0,
// });
// break;
// case "End":
// case "ArrowUp":
// e.preventDefault();
// inputRef.current?.focus();
// dispatch({
// type: "NAVIGATE",
// selectedIndex: list.current.length - 1,
// });
// break;
// case "Tab":
// e.preventDefault();
// inputRef.current?.focus();
// }
// }
// function handleClick() {
// setTimeout(() => {
// inputRef.current?.focus();
// }, 0);
// dispatch({
// type: "NAVIGATE",
// selectedIndex: list.current.length - 1,
// });
// }
// useEffect(() => {
// let e = footerRef.current?.querySelector(
// "input:not([type=hidden]), select, button, textarea, a",
// );
// return (
// open &&
// list.current.length > 0 &&
// (e?.addEventListener("keydown", handleKeyDown),
// e?.tagName === "BUTTON" && e.addEventListener("click", handleClick)),
// () => {
// e?.removeEventListener("keydown", handleKeyDown),
// e?.removeEventListener("click", handleClick);
// }
// );
// }, [open, list.current.length]);
// })({
// dispatch,
// open,
// selectedIndex,
// inputRef,
// footerRef,
// list,
// });
let P = useMemo(
() => ({
list: list,
map: map,
force: force,
}),
[list, map, force],
);
// L
const message = !list.current?.length ? (
{emptyMessage}
) : null;
let M = null;
if (ListFooterComponent) {
M = isValidElement(ListFooterComponent) ? (
ListFooterComponent
) : (
// @ts-ignore
);
}
const listChildren = (
{children}
);
let footer = M ? (
) : null;
if (isMobile) {
return (
{
dispatch({
type: "CLOSE",
});
}}
>
{footer}
);
}
let itemHeight =
(function (e) {
let t = e.current?.querySelector("[data-descendant]");
// @ts-ignore
if (t) return t.offsetHeight;
})(listRef) || 36;
let maxHeight = 5 * itemHeight + 16 + 2 + itemHeight / 2;
let height =
0 === list.current.length
? itemHeight + 16 + 2
: list.current.length * itemHeight + 16 + 2;
return (
{
e.preventDefault();
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onOpenAutoFocus={(e) => {
e.preventDefault();
}}
side={side}
sideOffset={8}
style={{
outline: 0,
overflowY: height > maxHeight ? "auto" : "hidden",
height: height,
width: _width,
maxHeight: maxHeight,
}}
tabIndex={-1}
>
{footer}
{!open ? (
) : (
{list.current.length > 0
? `${list.current.length} result${
list.current.length > 1 ? "s" : ""
} available`
: null}
{0 === list.current.length
? "string" == typeof emptyMessage
? emptyMessage
: "No results"
: null}
)}
);
};
List.displayName = "Combobox.List";
interface OptionProps extends React.PropsWithChildren {
value: string;
prefix?: JSX.Element;
suffix?: JSX.Element;
className?: string;
truncatePrefix?: boolean;
truncateSuffix?: boolean;
displayValue?: string;
isMenu?: boolean;
"data-testid"?: string;
disabled?: boolean;
ignoreDefaultHeight?: boolean;
}
const Option: FC> = ({
children,
className,
value,
prefix,
suffix,
truncatePrefix,
truncateSuffix,
displayValue,
isMenu,
disabled,
ignoreDefaultHeight,
}) => {
const {
selectedIndex,
selectedValue,
filterList,
map,
onSelect,
size,
listRef,
open,
dispatch,
inputRef,
isMobile,
noNegativeIndex,
} = useComboboxContext();
function S() {
onSelect(value);
}
const { index, ref, id } = useValue(ListContext, {
callback: S,
label: "string" == typeof children ? children : value,
value,
prefix,
displayValue,
isMenu,
disabled,
});
let isInMap = Boolean(map.current[id]);
// grey bg
let shouldHighlight =
(-1 !== index && selectedIndex === index && open) ||
(0 === index && selectedIndex === (noNegativeIndex ? index : -1) && open);
let shouldBold = selectedValue === value && open;
let handleMouseMove = useCallback(() => {
dispatch({
type: "NAVIGATE",
selectedIndex: index,
});
}, [index]);
useEffect(() => {
shouldHighlight &&
ref.current &&
!isMobile &&
ref.current.scrollIntoView({
block: "nearest",
});
}, [shouldHighlight, isMobile]);
const order =
filterList && isInMap
? filterList.findIndex((e) => {
let { _internalId: t } = e;
return t === id;
})
: undefined;
return -1 === order ? null : (
{
e.preventDefault();
S();
}}
onPointerOver={() => {
isMobile && inputRef.current?.blur();
}}
>
{prefix ? (
{prefix}
) : null}
{children}
{suffix ? (
{suffix}
) : null}
);
};
Option.displayName = "Combobox.Option";
export default Object.assign(Combobox, {
Input,
List,
Option,
});
const ClearButton = ({ style, onClick, open }) => {
const { focusProps, isFocusVisible } = useFocusRing();
return (
{/* @ts-expect-error TODO className needs to be added to allow types */}
);
};
// wrap end of array to beginning
function P(e: number, t: any[]) {
return 0 === t.length ? e : (e + 1) % t.length;
}
// wrap beginning of array to end
function L(e: number, t: any[]) {
return 0 === t.length ? e : (e - 1 + t.length) % t.length;
}
// ??????
function K(e) {
return window.setTimeout(e, 150);
}