bits-ui
Version:
The headless components for Svelte.
235 lines (234 loc) • 9.9 kB
JavaScript
/**
* Checks if two arrays are equal by comparing their values.
*/
export function arraysAreEqual(arr1, arr2) {
if (arr1.length !== arr2.length)
return false;
return arr1.every((value, index) => isEqual(value, arr2[index]));
}
/**
* A utility function that compares two values for equality.
*/
function isEqual(a, b) {
if (Number.isNaN(a) && Number.isNaN(b))
return true;
if (Array.isArray(a) && Array.isArray(b))
return arraysAreEqual(a, b);
if (typeof a === "object" && typeof b === "object")
return isDeepEqual(a, b);
return Object.is(a, b);
}
/**
* A utility function that compares two values for deep equality.
*/
function isDeepEqual(a, b) {
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
return false;
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length)
return false;
for (const key of aKeys) {
if (!bKeys.includes(key))
return false;
if (!isEqual(a[key], b[key])) {
return false;
}
}
return true;
}
/**
* Splits an array into chunks of a given size.
* @param arr The array to split.
* @param size The size of each chunk.
* @returns An array of arrays, where each sub-array has `size` elements from the original array.
* @example ```ts
* const arr = [1, 2, 3, 4, 5, 6, 7, 8];
* const chunks = chunk(arr, 3);
* // chunks = [[1, 2, 3], [4, 5, 6], [7, 8]]
* ```
*/
export function chunk(arr, size) {
if (size <= 0)
return [];
const result = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
/**
* Checks if the given index is valid for the given array.
*
* @param index - The index to check
* @param arr - The array to check
*/
export function isValidIndex(index, arr) {
return index >= 0 && index < arr.length;
}
/**
* Returns the array element after the given index, or undefined for out-of-bounds or empty arrays.
* @param array the array.
* @param index the index of the current element.
* @param loop loop to the beginning of the array if the next index is out of bounds?
*/
/**
* Returns the array element after the given index, or undefined for out-of-bounds or empty arrays.
* For single-element arrays, returns the element if the index is 0.
* @param array the array.
* @param index the index of the current element.
* @param loop loop to the beginning of the array if the next index is out of bounds?
*/
export function next(array, index, loop = true) {
if (array.length === 0 || index < 0 || index >= array.length)
return;
if (array.length === 1 && index === 0)
return array[0];
if (index === array.length - 1)
return loop ? array[0] : undefined;
return array[index + 1];
}
/**
* Returns the array element prior to the given index, or undefined for out-of-bounds or empty arrays.
* For single-element arrays, returns the element if the index is 0.
* @param array the array.
* @param index the index of the current element.
* @param loop loop to the end of the array if the previous index is out of bounds?
*/
export function prev(array, index, loop = true) {
if (array.length === 0 || index < 0 || index >= array.length)
return;
if (array.length === 1 && index === 0)
return array[0];
if (index === 0)
return loop ? array[array.length - 1] : undefined;
return array[index - 1];
}
/**
* Returns the element some number after the given index. If the target index is out of bounds:
* - If looping is disabled, the first or last element will be returned.
* - If looping is enabled, it will wrap around the array.
* Returns undefined for empty arrays or out-of-bounds initial indices.
* @param array the array.
* @param index the index of the current element.
* @param increment the number of elements to move forward (can be negative).
* @param loop loop around the array if the target index is out of bounds?
*/
export function forward(array, index, increment, loop = true) {
if (array.length === 0 || index < 0 || index >= array.length)
return;
let targetIndex = index + increment;
if (loop) {
// Ensure positive modulus
targetIndex = ((targetIndex % array.length) + array.length) % array.length;
}
else {
// Clamp to array bounds when not looping
targetIndex = Math.max(0, Math.min(targetIndex, array.length - 1));
}
return array[targetIndex];
}
/**
* Returns the element some number before the given index. If the target index is out of bounds:
* - If looping is disabled, the first or last element will be returned.
* - If looping is enabled, it will wrap around the array.
* Returns undefined for empty arrays or out-of-bounds initial indices.
* @param array the array.
* @param index the index of the current element.
* @param decrement the number of elements to move backward (can be negative).
* @param loop loop around the array if the target index is out of bounds?
*/
export function backward(array, index, decrement, loop = true) {
if (array.length === 0 || index < 0 || index >= array.length)
return;
let targetIndex = index - decrement;
if (loop) {
// Ensure positive modulus
targetIndex = ((targetIndex % array.length) + array.length) % array.length;
}
else {
// Clamp to array bounds when not looping
targetIndex = Math.max(0, Math.min(targetIndex, array.length - 1));
}
return array[targetIndex];
}
/**
* Finds the next matching item from a list of values based on a search string.
*
* This function handles several special cases in typeahead behavior:
*
* 1. Space handling: When a search string ends with a space, it handles it specially:
* - If there's only one match for the text before the space, it ignores the space
* - If there are multiple matches and the current match already starts with the search prefix
* followed by a space, it keeps the current match (doesn't change selection on space)
* - Only after typing characters beyond the space will it move to a more specific match
*
* 2. Repeated character handling: If a search consists of repeated characters (e.g., "aaa"),
* it treats it as a single character for matching purposes
*
* 3. Cycling behavior: The function wraps around the values array starting from the current match
* to find the next appropriate match, creating a cycling selection behavior
*
* @param values - Array of string values to search through (e.g., the text content of menu items)
* @param search - The current search string typed by the user
* @param currentMatch - The currently selected/matched item, if any
* @returns The next matching value that should be selected, or undefined if no match is found
*/
export function getNextMatch(values, search, currentMatch) {
const lowerSearch = search.toLowerCase();
if (lowerSearch.endsWith(" ")) {
const searchWithoutSpace = lowerSearch.slice(0, -1);
const matchesWithoutSpace = values.filter((value) => value.toLowerCase().startsWith(searchWithoutSpace));
/**
* If there's only one match for the prefix without space, we don't
* watch to match with space.
*/
if (matchesWithoutSpace.length <= 1) {
return getNextMatch(values, searchWithoutSpace, currentMatch);
}
const currentMatchLowercase = currentMatch?.toLowerCase();
/**
* If the current match already starts with the search prefix and has a space afterward,
* and the user has only typed up to that space, keep the current match until they
* disambiguate.
*/
if (currentMatchLowercase &&
currentMatchLowercase.startsWith(searchWithoutSpace) &&
currentMatchLowercase.charAt(searchWithoutSpace.length) === " " &&
search.trim() === searchWithoutSpace) {
return currentMatch;
}
/**
* With multiple matches, find items that match the full search string with space
*/
const spacedMatches = values.filter((value) => value.toLowerCase().startsWith(lowerSearch));
/**
* If we found matches with the space, use the first one that's not the current match
*/
if (spacedMatches.length > 0) {
const currentMatchIndex = currentMatch ? values.indexOf(currentMatch) : -1;
let wrappedMatches = wrapArray(spacedMatches, Math.max(currentMatchIndex, 0));
// return the first match that is not the current one.
const nextMatch = wrappedMatches.find((match) => match !== currentMatch);
// fallback to current if no other is found.
return nextMatch || currentMatch;
}
}
const isRepeated = search.length > 1 && Array.from(search).every((char) => char === search[0]);
const normalizedSearch = isRepeated ? search[0] : search;
const normalizedLowerSearch = normalizedSearch.toLowerCase();
const currentMatchIndex = currentMatch ? values.indexOf(currentMatch) : -1;
let wrappedValues = wrapArray(values, Math.max(currentMatchIndex, 0));
const excludeCurrentMatch = normalizedSearch.length === 1;
if (excludeCurrentMatch)
wrappedValues = wrappedValues.filter((v) => v !== currentMatch);
const nextMatch = wrappedValues.find((value) => value?.toLowerCase().startsWith(normalizedLowerSearch));
return nextMatch !== currentMatch ? nextMatch : undefined;
}
/**
* Wraps an array around itself at a given start index
* Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']`
*/
export function wrapArray(array, startIndex) {
return array.map((_, index) => array[(startIndex + index) % array.length]);
}