reakit
Version:
Toolkit for building accessible rich web apps with React
325 lines (310 loc) • 9.52 kB
text/typescript
import * as React from "react";
import { SetState } from "reakit-utils/types";
import {
CompositeStateReturn,
CompositeState,
CompositeActions,
} from "../../Composite/CompositeState";
import { Item } from "./types";
function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function getMatches(
inputValue: ComboboxBaseState["inputValue"],
values: ComboboxBaseState["values"],
limit: ComboboxBaseState["limit"],
list: ComboboxBaseState["list"],
autoSelect: ComboboxBaseState["autoSelect"],
minValueLength: ComboboxBaseState["minValueLength"]
) {
if (limit === 0 || inputValue.length < minValueLength) {
// We don't want to populate combobox.matches if inputValue doesn't have
// enough characters.
return [];
}
const length = limit === false ? undefined : limit;
if (!list) {
// If list is false, this means that values aren't expected to be filtered.
return values.slice(0, length);
}
const regex = new RegExp(escapeRegExp(inputValue), "i");
const matches: string[] = [];
if (autoSelect) {
const match = values.find((value) => value.search(regex) === 0);
if (match) {
matches.push(match);
}
}
for (const value of values) {
if (length && matches.length >= length) {
break;
}
// Excludes first match, that can be auto selected
if (value !== matches[0] && value.search(regex) !== -1) {
matches.push(value);
}
}
return matches;
}
export function useComboboxBaseState<T extends CompositeStateReturn>(
composite: T,
{
inputValue: initialInputValue = "",
minValueLength: initialMinValueLength = 0,
values: initialValues = [],
limit: initialLimit = 10,
list: initialList = !!initialValues.length,
inline: initialInline = false,
autoSelect: initialAutoSelect = false,
}: ComboboxBaseInitialState = {}
): ComboboxBaseStateReturn<T> {
const valuesById = React.useRef<Record<string, string | undefined>>({});
const [inputValue, setInputValue] = React.useState(initialInputValue);
const [minValueLength, setMinValueLength] = React.useState(
initialMinValueLength
);
const [values, setValues] = React.useState(initialValues);
const [limit, setLimit] = React.useState(initialLimit);
const [list, setList] = React.useState(initialList);
const [inline, setInline] = React.useState(initialInline);
const [autoSelect, setAutoSelect] = React.useState(initialAutoSelect);
const matches = React.useMemo(
() =>
getMatches(inputValue, values, limit, list, autoSelect, minValueLength),
[inputValue, values, limit, list, autoSelect, minValueLength]
);
const currentValue = React.useMemo(
() =>
composite.currentId ? valuesById.current[composite.currentId] : undefined,
[valuesById, composite.currentId]
);
const items = React.useMemo(() => {
composite.items.forEach((item) => {
if (item.id) {
(item as Item).value = valuesById.current[item.id];
}
});
return composite.items;
}, [composite.items]);
const registerItem = React.useCallback(
(item: Item) => {
composite.registerItem(item);
if (item.id) {
valuesById.current[item.id] = item.value;
}
},
[composite.registerItem]
);
const unregisterItem = React.useCallback(
(id: string) => {
composite.unregisterItem(id);
delete valuesById.current[id];
},
[composite.unregisterItem]
);
return {
...composite,
menuRole: "listbox",
items,
registerItem,
unregisterItem,
visible: true,
inputValue,
minValueLength,
currentValue,
values,
limit,
matches,
list,
inline,
autoSelect,
setInputValue,
setMinValueLength,
setValues,
setLimit,
setList,
setInline,
setAutoSelect,
};
}
export type ComboboxBaseState<T extends CompositeState = CompositeState> = Omit<
T,
"items"
> & {
/**
* Lists all the combobox items with their `id`, DOM `ref`, `disabled` state,
* `value` and `groupId` if any. This state is automatically updated when
* `registerItem` and `unregisterItem` are called.
* @example
* const combobox = useComboboxState();
* combobox.items.forEach((item) => {
* console.log(item.value);
* });
*/
items: Item[];
/**
* Indicates the type of the suggestions popup.
*/
menuRole: "listbox" | "tree" | "grid" | "dialog";
/**
* Combobox input value that will be used to filter `values` and populate
* the `matches` property.
*/
inputValue: string;
/**
* How many characters are needed for opening the combobox popover and
* populating `matches` with filtered values.
* @default 0
* @example
* const combobox = useComboboxState({
* values: ["Red", "Green"],
* minValueLength: 2,
* });
* combobox.matches; // []
* combobox.setInputValue("g");
* // On next render
* combobox.matches; // []
* combobox.setInputValue("gr");
* // On next render
* combobox.matches; // ["Green"]
*/
minValueLength: number;
/**
* Value of the item that is currently selected.
*/
currentValue?: string;
/**
* Values that will be used to produce `matches`.
* @default []
* @example
* const combobox = useComboboxState({ values: ["Red", "Green"] });
* combobox.matches; // ["Red", "Green"]
* combobox.setInputValue("g");
* // On next render
* combobox.matches; // ["Green"]
*/
values: string[];
/**
* Maximum number of `matches`. If it's set to `false`, there will be no
* limit.
* @default 10
*/
limit: number | false;
/**
* Result of filtering `values` based on `inputValue`.
* @default []
* @example
* const combobox = useComboboxState({ values: ["Red", "Green"] });
* combobox.matches; // ["Red", "Green"]
* combobox.setInputValue("g");
* // On next render
* combobox.matches; // ["Green"]
*/
matches: string[];
/**
* Determines how the combobox options behave: dynamically or statically.
* By default, it's `true` if `values` are provided. Otherwise, it's `false`:
* - If it's `true` and `values` are provided, then they will be
* automatically filtered based on `inputValue` and will populate `matches`.
* - If it's `true` and `values` aren't provided, this means that you'll
* provide and filter values by yourself. `matches` will be empty.
* - If it's `false` and `values` are provided, then they won't be
* automatically filtered and `matches` will be the same as `values`.
* @example
* const withoutValues = useComboboxState();
* withValues.list; // false;
* const withValues = useComboboxState({ values: ["Red", "Green"] });
* withValues.list; // true;
* const withList = useComboboxState({ list: true });
* withValues.list; // true;
* <Combobox list={true} /> // <input aria-autocomplete="list">
*/
list: boolean;
/**
* Determines whether focusing on an option will temporarily change the value
* of the combobox. If it's `true`, focusing on an option will temporarily
* change the combobox value to the option's value.
* @default false
*/
inline: boolean;
/**
* Determines whether the first option will be automatically selected. When
* it's set to `true`, the exact behavior will depend on the value of
* `inline`:
* - If `inline` is `true`, the first option is automatically focused when
* the combobox popover opens and the input value changes to reflect this.
* The inline completion string will be highlighted and will have a selected
* state.
* - If `inline` is `false`, the first option is automatically focused when
* the combobox popover opens, but the input value remains the same.
* @default false
*/
autoSelect: boolean;
/**
* Whether the suggestions popup is visible or not.
*/
visible: boolean;
};
export type ComboboxBaseActions<
T extends CompositeActions = CompositeActions
> = Omit<T, "registerItem"> & {
/**
* Registers a combobox item.
* @example
* const ref = React.useRef();
* const combobox = useComboboxState();
* React.useEffect(() => {
* combobox.registerItem({ ref, id: "id" });
* return () => combobox.unregisterItem("id");
* });
*/
registerItem: (item: Item) => void;
/**
* Sets `inputValue`.
* @example
* const combobox = useComboboxState();
* combobox.setInputValue("new value");
*/
setInputValue: SetState<ComboboxBaseState["inputValue"]>;
/**
* Sets `minValueLength`.
*/
setMinValueLength: SetState<ComboboxBaseState["minValueLength"]>;
/**
* Sets `values`.
* @example
* const combobox = useComboboxState();
* combobox.setValues(["Red", "Green"]);
* combobox.setValues((prevValues) => [...prevValues, "Blue"]);
*/
setValues: SetState<ComboboxBaseState["values"]>;
/**
* Sets `limit`.
*/
setLimit: SetState<ComboboxBaseState["limit"]>;
/**
* Sets `list`.
*/
setList: SetState<ComboboxBaseState["list"]>;
/**
* Sets `inline`.
*/
setInline: SetState<ComboboxBaseState["inline"]>;
/**
* Sets `autoSelect`.
*/
setAutoSelect: SetState<ComboboxBaseState["autoSelect"]>;
};
export type ComboboxBaseInitialState = Pick<
Partial<ComboboxBaseState>,
| "inputValue"
| "minValueLength"
| "values"
| "limit"
| "list"
| "inline"
| "autoSelect"
>;
export type ComboboxBaseStateReturn<
T extends CompositeStateReturn
> = ComboboxBaseState<T> & ComboboxBaseActions<T>;