235 lines
9.7 KiB
JavaScript
235 lines
9.7 KiB
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]);
|
|
}
|