@ariakit/react-core
Version:
Ariakit React core
470 lines (468 loc) • 16.5 kB
JavaScript
"use client";
import {
useComposite
} from "../__chunks/NSTBQJLB.js";
import {
usePopoverAnchor
} from "../__chunks/HMCFFQCB.js";
import "../__chunks/5VQZOHHZ.js";
import {
useComboboxProviderContext
} from "../__chunks/OLVWQA7U.js";
import "../__chunks/Y67KZUMI.js";
import "../__chunks/T2AZQXQU.js";
import "../__chunks/ABN76PSX.js";
import "../__chunks/APTFW6PT.js";
import "../__chunks/OE2EFRVA.js";
import "../__chunks/SWN3JYXT.js";
import "../__chunks/5CPL3B7G.js";
import {
createElement,
createHook,
forwardRef
} from "../__chunks/VOQWLFSQ.js";
import {
useBooleanEvent,
useEvent,
useForceUpdate,
useId,
useMergeRefs,
useSafeLayoutEffect,
useUpdateEffect,
useUpdateLayoutEffect
} from "../__chunks/5GGHRIN3.js";
import "../__chunks/SK3NAZA3.js";
import {
__objRest,
__spreadProps,
__spreadValues
} from "../__chunks/3YLGPPWQ.js";
// src/combobox/combobox.tsx
import {
getPopupRole,
getScrollingElement,
getTextboxSelection,
setSelectionRange
} from "@ariakit/core/utils/dom";
import {
isFocusEventOutside,
queueBeforeEvent
} from "@ariakit/core/utils/events";
import { hasFocus } from "@ariakit/core/utils/focus";
import {
invariant,
isFalsyBooleanCallback,
noop,
normalizeString
} from "@ariakit/core/utils/misc";
import { sync } from "@ariakit/core/utils/store";
import { useEffect, useMemo, useRef, useState } from "react";
var TagName = "input";
function isFirstItemAutoSelected(items, activeValue, autoSelect) {
if (!autoSelect) return false;
const firstItem = items.find((item) => !item.disabled && item.value);
return (firstItem == null ? void 0 : firstItem.value) === activeValue;
}
function hasCompletionString(value, activeValue) {
if (!activeValue) return false;
if (value == null) return false;
value = normalizeString(value);
return activeValue.length > value.length && activeValue.toLowerCase().indexOf(value.toLowerCase()) === 0;
}
function isInputEvent(event) {
return event.type === "input";
}
function isAriaAutoCompleteValue(value) {
return value === "inline" || value === "list" || value === "both" || value === "none";
}
function getDefaultAutoSelectId(items) {
const item = items.find((item2) => {
var _a;
if (item2.disabled) return false;
return ((_a = item2.element) == null ? void 0 : _a.getAttribute("role")) !== "tab";
});
return item == null ? void 0 : item.id;
}
var useCombobox = createHook(
function useCombobox2(_a) {
var _b = _a, {
store,
focusable = true,
autoSelect: autoSelectProp = false,
getAutoSelectId,
setValueOnChange,
showMinLength = 0,
showOnChange,
showOnMouseDown,
showOnClick = showOnMouseDown,
showOnKeyDown,
showOnKeyPress = showOnKeyDown,
blurActiveItemOnClick,
setValueOnClick = true,
moveOnKeyPress = true,
autoComplete = "list"
} = _b, props = __objRest(_b, [
"store",
"focusable",
"autoSelect",
"getAutoSelectId",
"setValueOnChange",
"showMinLength",
"showOnChange",
"showOnMouseDown",
"showOnClick",
"showOnKeyDown",
"showOnKeyPress",
"blurActiveItemOnClick",
"setValueOnClick",
"moveOnKeyPress",
"autoComplete"
]);
const context = useComboboxProviderContext();
store = store || context;
invariant(
store,
process.env.NODE_ENV !== "production" && "Combobox must receive a `store` prop or be wrapped in a ComboboxProvider component."
);
const ref = useRef(null);
const [valueUpdated, forceValueUpdate] = useForceUpdate();
const canAutoSelectRef = useRef(false);
const composingRef = useRef(false);
const autoSelect = store.useState(
(state) => state.virtualFocus && autoSelectProp
);
const inline = autoComplete === "inline" || autoComplete === "both";
const [canInline, setCanInline] = useState(inline);
useUpdateLayoutEffect(() => {
if (!inline) return;
setCanInline(true);
}, [inline]);
const storeValue = store.useState("value");
const prevSelectedValueRef = useRef();
useEffect(() => {
return sync(store, ["selectedValue", "activeId"], (_, prev) => {
prevSelectedValueRef.current = prev.selectedValue;
});
}, []);
const inlineActiveValue = store.useState((state) => {
var _a2;
if (!inline) return;
if (!canInline) return;
if (state.activeValue && Array.isArray(state.selectedValue)) {
if (state.selectedValue.includes(state.activeValue)) return;
if ((_a2 = prevSelectedValueRef.current) == null ? void 0 : _a2.includes(state.activeValue)) return;
}
return state.activeValue;
});
const items = store.useState("renderedItems");
const open = store.useState("open");
const contentElement = store.useState("contentElement");
const value = useMemo(() => {
if (!inline) return storeValue;
if (!canInline) return storeValue;
const firstItemAutoSelected = isFirstItemAutoSelected(
items,
inlineActiveValue,
autoSelect
);
if (firstItemAutoSelected) {
if (hasCompletionString(storeValue, inlineActiveValue)) {
const slice = (inlineActiveValue == null ? void 0 : inlineActiveValue.slice(storeValue.length)) || "";
return storeValue + slice;
}
return storeValue;
}
return inlineActiveValue || storeValue;
}, [inline, canInline, items, inlineActiveValue, autoSelect, storeValue]);
useEffect(() => {
const element = ref.current;
if (!element) return;
const onCompositeItemMove = () => setCanInline(true);
element.addEventListener("combobox-item-move", onCompositeItemMove);
return () => {
element.removeEventListener("combobox-item-move", onCompositeItemMove);
};
}, []);
useEffect(() => {
if (!inline) return;
if (!canInline) return;
if (!inlineActiveValue) return;
const firstItemAutoSelected = isFirstItemAutoSelected(
items,
inlineActiveValue,
autoSelect
);
if (!firstItemAutoSelected) return;
if (!hasCompletionString(storeValue, inlineActiveValue)) return;
let cleanup = noop;
queueMicrotask(() => {
const element = ref.current;
if (!element) return;
const { start: prevStart, end: prevEnd } = getTextboxSelection(element);
const nextStart = storeValue.length;
const nextEnd = inlineActiveValue.length;
setSelectionRange(element, nextStart, nextEnd);
cleanup = () => {
if (!hasFocus(element)) return;
const { start, end } = getTextboxSelection(element);
if (start !== nextStart) return;
if (end !== nextEnd) return;
setSelectionRange(element, prevStart, prevEnd);
};
});
return () => cleanup();
}, [
valueUpdated,
inline,
canInline,
inlineActiveValue,
items,
autoSelect,
storeValue
]);
const scrollingElementRef = useRef(null);
const getAutoSelectIdProp = useEvent(getAutoSelectId);
const autoSelectIdRef = useRef(null);
useEffect(() => {
if (!open) return;
if (!contentElement) return;
const scrollingElement = getScrollingElement(contentElement);
if (!scrollingElement) return;
scrollingElementRef.current = scrollingElement;
const onUserScroll = () => {
canAutoSelectRef.current = false;
};
const onScroll = () => {
if (!store) return;
if (!canAutoSelectRef.current) return;
const { activeId } = store.getState();
if (activeId === null) return;
if (activeId === autoSelectIdRef.current) return;
canAutoSelectRef.current = false;
};
const options = { passive: true, capture: true };
scrollingElement.addEventListener("wheel", onUserScroll, options);
scrollingElement.addEventListener("touchmove", onUserScroll, options);
scrollingElement.addEventListener("scroll", onScroll, options);
return () => {
scrollingElement.removeEventListener("wheel", onUserScroll, true);
scrollingElement.removeEventListener("touchmove", onUserScroll, true);
scrollingElement.removeEventListener("scroll", onScroll, true);
};
}, [open, contentElement, store]);
useSafeLayoutEffect(() => {
if (!storeValue) return;
if (composingRef.current) return;
canAutoSelectRef.current = true;
}, [storeValue]);
useSafeLayoutEffect(() => {
if (autoSelect !== "always" && open) return;
canAutoSelectRef.current = open;
}, [autoSelect, open]);
const resetValueOnSelect = store.useState("resetValueOnSelect");
useUpdateEffect(() => {
var _a2, _b2;
const canAutoSelect = canAutoSelectRef.current;
if (!store) return;
if (!open) return;
if (!canAutoSelect && !resetValueOnSelect) return;
const { baseElement, contentElement: contentElement2, activeId } = store.getState();
if (baseElement && !hasFocus(baseElement)) return;
if (contentElement2 == null ? void 0 : contentElement2.hasAttribute("data-placing")) {
const observer = new MutationObserver(forceValueUpdate);
observer.observe(contentElement2, { attributeFilter: ["data-placing"] });
return () => observer.disconnect();
}
if (autoSelect && canAutoSelect) {
const userAutoSelectId = getAutoSelectIdProp(items);
const autoSelectId = userAutoSelectId !== void 0 ? userAutoSelectId : (_a2 = getDefaultAutoSelectId(items)) != null ? _a2 : store.first();
autoSelectIdRef.current = autoSelectId;
store.move(autoSelectId != null ? autoSelectId : null);
} else {
const element = (_b2 = store.item(activeId || store.first())) == null ? void 0 : _b2.element;
if (element && "scrollIntoView" in element) {
element.scrollIntoView({ block: "nearest", inline: "nearest" });
}
}
return;
}, [
store,
open,
valueUpdated,
storeValue,
autoSelect,
resetValueOnSelect,
getAutoSelectIdProp,
items
]);
useEffect(() => {
if (!inline) return;
const combobox = ref.current;
if (!combobox) return;
const elements = [combobox, contentElement].filter(
(value2) => !!value2
);
const onBlur2 = (event) => {
if (elements.every((el) => isFocusEventOutside(event, el))) {
store == null ? void 0 : store.setValue(value);
}
};
for (const element of elements) {
element.addEventListener("focusout", onBlur2);
}
return () => {
for (const element of elements) {
element.removeEventListener("focusout", onBlur2);
}
};
}, [inline, contentElement, store, value]);
const canShow = (event) => {
const currentTarget = event.currentTarget;
return currentTarget.value.length >= showMinLength;
};
const onChangeProp = props.onChange;
const showOnChangeProp = useBooleanEvent(showOnChange != null ? showOnChange : canShow);
const setValueOnChangeProp = useBooleanEvent(
// If the combobox is combined with tags, the value will be set by the tag
// input component.
setValueOnChange != null ? setValueOnChange : !store.tag
);
const onChange = useEvent((event) => {
onChangeProp == null ? void 0 : onChangeProp(event);
if (event.defaultPrevented) return;
if (!store) return;
const currentTarget = event.currentTarget;
const { value: value2, selectionStart, selectionEnd } = currentTarget;
const nativeEvent = event.nativeEvent;
canAutoSelectRef.current = true;
if (isInputEvent(nativeEvent)) {
if (nativeEvent.isComposing) {
canAutoSelectRef.current = false;
composingRef.current = true;
}
if (inline) {
const textInserted = nativeEvent.inputType === "insertText" || nativeEvent.inputType === "insertCompositionText";
const caretAtEnd = selectionStart === value2.length;
setCanInline(textInserted && caretAtEnd);
}
}
if (setValueOnChangeProp(event)) {
const isSameValue = value2 === store.getState().value;
store.setValue(value2);
queueMicrotask(() => {
setSelectionRange(currentTarget, selectionStart, selectionEnd);
});
if (inline && autoSelect && isSameValue) {
forceValueUpdate();
}
}
if (showOnChangeProp(event)) {
store.show();
}
if (!autoSelect || !canAutoSelectRef.current) {
store.setActiveId(null);
}
});
const onCompositionEndProp = props.onCompositionEnd;
const onCompositionEnd = useEvent((event) => {
canAutoSelectRef.current = true;
composingRef.current = false;
onCompositionEndProp == null ? void 0 : onCompositionEndProp(event);
if (event.defaultPrevented) return;
if (!autoSelect) return;
forceValueUpdate();
});
const onMouseDownProp = props.onMouseDown;
const blurActiveItemOnClickProp = useBooleanEvent(
blurActiveItemOnClick != null ? blurActiveItemOnClick : () => !!(store == null ? void 0 : store.getState().includesBaseElement)
);
const setValueOnClickProp = useBooleanEvent(setValueOnClick);
const showOnClickProp = useBooleanEvent(showOnClick != null ? showOnClick : canShow);
const onMouseDown = useEvent((event) => {
onMouseDownProp == null ? void 0 : onMouseDownProp(event);
if (event.defaultPrevented) return;
if (event.button) return;
if (event.ctrlKey) return;
if (!store) return;
if (blurActiveItemOnClickProp(event)) {
store.setActiveId(null);
}
if (setValueOnClickProp(event)) {
store.setValue(value);
}
if (showOnClickProp(event)) {
queueBeforeEvent(event.currentTarget, "mouseup", store.show);
}
});
const onKeyDownProp = props.onKeyDown;
const showOnKeyPressProp = useBooleanEvent(showOnKeyPress != null ? showOnKeyPress : canShow);
const onKeyDown = useEvent((event) => {
onKeyDownProp == null ? void 0 : onKeyDownProp(event);
if (!event.repeat) {
canAutoSelectRef.current = false;
}
if (event.defaultPrevented) return;
if (event.ctrlKey) return;
if (event.altKey) return;
if (event.shiftKey) return;
if (event.metaKey) return;
if (!store) return;
const { open: open2 } = store.getState();
if (open2) return;
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
if (showOnKeyPressProp(event)) {
event.preventDefault();
store.show();
}
}
});
const onBlurProp = props.onBlur;
const onBlur = useEvent((event) => {
canAutoSelectRef.current = false;
onBlurProp == null ? void 0 : onBlurProp(event);
if (event.defaultPrevented) return;
});
const id = useId(props.id);
const ariaAutoComplete = isAriaAutoCompleteValue(autoComplete) ? autoComplete : void 0;
const isActiveItem = store.useState((state) => state.activeId === null);
props = __spreadProps(__spreadValues({
id,
role: "combobox",
"aria-autocomplete": ariaAutoComplete,
"aria-haspopup": getPopupRole(contentElement, "listbox"),
"aria-expanded": open,
"aria-controls": contentElement == null ? void 0 : contentElement.id,
"data-active-item": isActiveItem || void 0,
value
}, props), {
ref: useMergeRefs(ref, props.ref),
onChange,
onCompositionEnd,
onMouseDown,
onKeyDown,
onBlur
});
props = useComposite(__spreadProps(__spreadValues({
store,
focusable
}, props), {
// Enable inline autocomplete when the user moves from the combobox input
// to an item.
moveOnKeyPress: (event) => {
if (isFalsyBooleanCallback(moveOnKeyPress, event)) return false;
if (inline) setCanInline(true);
return true;
}
}));
props = usePopoverAnchor(__spreadValues({ store }, props));
return __spreadValues({ autoComplete: "off" }, props);
}
);
var Combobox = forwardRef(function Combobox2(props) {
const htmlProps = useCombobox(props);
return createElement(TagName, htmlProps);
});
export {
Combobox,
useCombobox
};