From 6a2147bf38cf95e0f87007c7336a645b6952cb1c Mon Sep 17 00:00:00 2001 From: Emmanuel-Develops Date: Tue, 19 Nov 2024 14:39:48 +0100 Subject: [PATCH] feat: add multiselect and dropdown --- eslint.config.js | 1 - src/components/select/BaseSelectList.tsx | 137 +++++++++++++++++++ src/components/select/Dropdown.tsx | 130 ++++++++++++++++++ src/components/select/MultiSelect.tsx | 99 ++++++++++++++ src/components/select/Select.stories.tsx | 107 +++++++++++++++ src/components/select/SelectInput.tsx | 87 ++++++++++++ src/components/select/SelectList.tsx | 44 +++++++ src/components/select/SingleSelectInput.tsx | 55 ++++++++ src/components/select/SingleSelectList.tsx | 80 +++++++++++ src/components/select/index.tsx | 2 + src/components/select/types.ts | 0 src/components/select/useSelectNavigate.tsx | 139 ++++++++++++++++++++ src/index.ts | 1 + src/utils/cn.ts | 6 + src/utils/filter.ts | 12 ++ src/utils/index.ts | 10 +- src/utils/navigation.ts | 10 ++ 17 files changed, 918 insertions(+), 2 deletions(-) create mode 100644 src/components/select/BaseSelectList.tsx create mode 100644 src/components/select/Dropdown.tsx create mode 100644 src/components/select/MultiSelect.tsx create mode 100644 src/components/select/Select.stories.tsx create mode 100644 src/components/select/SelectInput.tsx create mode 100644 src/components/select/SelectList.tsx create mode 100644 src/components/select/SingleSelectInput.tsx create mode 100644 src/components/select/SingleSelectList.tsx create mode 100644 src/components/select/index.tsx create mode 100644 src/components/select/types.ts create mode 100644 src/components/select/useSelectNavigate.tsx create mode 100644 src/utils/cn.ts create mode 100644 src/utils/filter.ts create mode 100644 src/utils/navigation.ts diff --git a/eslint.config.js b/eslint.config.js index 5e70a61..cbe3faf 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,7 +19,6 @@ module.exports = [ ...typescript.configs['recommended'].rules, ...react.configs['recommended'].rules, 'no-console': ['warn', { allow: ['warn', 'error'] }], - '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unused-vars': 'warn' }, settings: { diff --git a/src/components/select/BaseSelectList.tsx b/src/components/select/BaseSelectList.tsx new file mode 100644 index 0000000..a576125 --- /dev/null +++ b/src/components/select/BaseSelectList.tsx @@ -0,0 +1,137 @@ +import { LightningIconSolid } from "../../icons"; +import { numberFormat } from "../../utils"; +import { cn } from "../../utils/cn"; +import React from "react"; + +export type BaseSelectContextTypeForList = { + isListOpen: boolean; + currentNavigateCheckbox: string; + containerRef: React.MutableRefObject | null; +}; + +export type SelectOption = { + label: string; + count?: number; + value: string; + selected: boolean; +}; + +type StyleConfig = { + container?: string; + optionWrapper?: string; + selectedOption?: string; + optionInner?: string; + icon?: string; + label?: string; + count?: string; + noResults?: string; +}; + +export type OnOptionSelect = ({ + action, + value, + event, +}: { + action: "select" | "deselect"; + value: string; + event: React.MouseEvent; +}) => void; + +export type SelectListProps = { + options: SelectOption[]; + label: string; + onOptionSelect: OnOptionSelect; + className?: string; + styles?: StyleConfig; + noResultsMessage?: string; // New: Customizable empty state + selectContextData: BaseSelectContextTypeForList; +}; + +const defaultStyles = { + container: + "scroller font-medium mt-2 max-h-[300px] py-[6px] overflow-auto border border-bdp-stroke rounded-xl data-[is-open='false']:hidden", + optionWrapper: `flex gap-1 py-1 2xl:py-2 px-[14px] group/checkOption hover:bg-bdp-hover-state data-[current-navigated=true]:bg-bdp-hover-state + group-hover/container:data-[current-navigated=true]:bg-transparent + group-hover/container:data-[current-navigated=true]:hover:bg-bdp-hover-state + data-[selected=true]:text-bdp-accent text-bdp-primary-text`, + optionInner: "selectable-option flex grow items-center gap-3", + icon: "shrink-0 group-data-[selected=false]/checkOption:invisible w-[12px] 2xl:w-[16px] h-auto", + label: + "grow capitalize text-sm 2xl:text-base group-data-[selected=true]/checkOption:font-bold", + count: "shrink-0 group-data-[selected=true]/checkOption:font-medium", + noResults: "w-full text-sm 2xl:text-base text-center px-2", +} as const; + +const BaseSelectList = ({ + options, + label, + onOptionSelect, + className, + styles = {}, + noResultsMessage = "No matching options", + selectContextData, +}: SelectListProps) => { + const { isListOpen, currentNavigateCheckbox, containerRef } = + selectContextData; + return ( +
+ {options.length < 1 && ( +

+ {noResultsMessage} +

+ )} + {options?.map((option) => { + const checked = option.selected; + const value = option.value; + return ( + + ); + })} +
+ ); +}; + +export default BaseSelectList; diff --git a/src/components/select/Dropdown.tsx b/src/components/select/Dropdown.tsx new file mode 100644 index 0000000..c191666 --- /dev/null +++ b/src/components/select/Dropdown.tsx @@ -0,0 +1,130 @@ +"use client"; + +import React, { createContext, useCallback, useState } from "react"; +import SingleSelectList, { + SingleSelectListProps, + SingleSelectOption, +} from "./SingleSelectList"; +import SingleSelectTrigger, { + SingleSelectTriggerProps, +} from "./SingleSelectInput"; + +type StyleConfig = { + container?: string; + input?: string; + list?: string; + option?: string; +}; + +type SelectContextType = { + isListOpen: boolean; + toggleListOpen: () => void; + selectedOption: SingleSelectOption | null; + setSelectedOption: (option: SingleSelectOption | null) => void; + containerRef: React.MutableRefObject | null; + setContainerRef: React.Dispatch< + React.SetStateAction | null> + >; + handleSelectOption: (option: SingleSelectOption) => void; + triggerRef: React.RefObject; +}; + +const SingleSelectContext = createContext(null); +export const useSingleSelect = () => { + const context = React.useContext(SingleSelectContext); + if (!context) { + throw new Error( + "useSingleSelect must be used within a SingleSelectProvider", + ); + } + return context; +}; + +type SingleSelectProviderProps = { + children: React.ReactNode; + triggerRef: React.RefObject; + className?: string; + styles?: StyleConfig; + disabled?: boolean; +}; + +const SingleSelectProvider = ({ + children, + triggerRef, + disabled = false, +}: SingleSelectProviderProps) => { + const [isListOpen, setIsListOpen] = useState(false); + const [containerRef, setContainerRef] = + useState | null>(null); + const [selectedOption, setSelectedOption] = + useState(null); + + const toggleListOpen = () => { + if (!disabled) { + setIsListOpen((prev) => !prev); + } + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleSelectOption = (_option: SingleSelectOption) => { + setIsListOpen(false); + }; + + const handleClickOutside = useCallback( + (event: MouseEvent) => { + if ( + containerRef?.current && + triggerRef?.current && + !containerRef.current.contains(event.target as Node) && + !triggerRef.current.contains(event.target as Node) + ) { + setIsListOpen(false); + } + }, + [containerRef, isListOpen], + ); + + React.useEffect(() => { + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [containerRef]); + + const contextValue = { + isListOpen, + toggleListOpen, + selectedOption, + setSelectedOption, + handleSelectOption, + containerRef, + setContainerRef, + triggerRef, + }; + + return ( + +
{children}
+
+ ); +}; + +export const SingleSelect: React.FC< + Omit +> & { + List: React.FC; + Trigger: React.FC; +} = ({ + children, + disabled = false, +}: Omit) => { + const triggerRef = React.useRef(null); + return ( + + {children} + + ); +}; + +SingleSelect.List = SingleSelectList; +SingleSelect.Trigger = SingleSelectTrigger; diff --git a/src/components/select/MultiSelect.tsx b/src/components/select/MultiSelect.tsx new file mode 100644 index 0000000..6c076d9 --- /dev/null +++ b/src/components/select/MultiSelect.tsx @@ -0,0 +1,99 @@ +import React, { useState } from "react"; +import useCheckboxNavigate from "./useSelectNavigate"; +import SelectInput, { SelectInputProps } from "./SelectInput"; +import SelectList, { MultiSelectListProps } from "./SelectList"; + +export type SelectContextType = { + containerRef: React.MutableRefObject | null; + setContainerRef: React.Dispatch< + React.SetStateAction | null> + >; + searchInputRef: React.MutableRefObject | null; + setSearchInputRef: React.Dispatch< + React.SetStateAction | null> + >; + isListOpen: boolean; + toggleListOpen: () => void; + currentNavigateCheckbox: string; + toggleRefocus: () => void; + onSearch: (value: string) => void; + inputValue: string; +}; + +type SelectProviderProps = { + children: React.ReactNode; + isCollapsible?: boolean; +}; + +const SelectContext = React.createContext(null); +export const useMultiSelect = () => { + const context = React.useContext(SelectContext); + if (!context) { + throw new Error("useMultiSelect must be used within a MultiSelectProvider"); + } + return context; +}; + +export const MultiSelectProvider = ({ + children, + isCollapsible = true, +}: SelectProviderProps) => { + const [containerRef, setContainerRef] = + useState | null>(null); + const [searchInputRef, setSearchInputRef] = + useState | null>(null); + + const [isListOpen, setIsListOpen] = useState(true); + + const toggleListOpen = () => { + if (!isCollapsible) return; + setIsListOpen((prev) => !prev); + }; + + const [inputValue, setInputValue] = useState(""); + + const { currentNavigateCheckbox, toggleRefocus } = useCheckboxNavigate({ + checkboxContainer: containerRef, + searchEl: searchInputRef, + options: [], + }); + + // const [currentNavigateCheckbox, setcurrentNavigateCheckbox] = useState("") + const onSearch = (value: string) => { + const newValue = value.trim(); + setInputValue(newValue); + }; + + return ( + + {children} + + ); +}; + +export const MultiSelect: React.FC & { + Input: React.FC; + List: React.FC; +} = ({ children, isCollapsible = true }: SelectProviderProps) => { + return ( + + {children} + + ); +}; + +MultiSelect.Input = SelectInput; +MultiSelect.List = SelectList; diff --git a/src/components/select/Select.stories.tsx b/src/components/select/Select.stories.tsx new file mode 100644 index 0000000..ea4a142 --- /dev/null +++ b/src/components/select/Select.stories.tsx @@ -0,0 +1,107 @@ +import React, { useState } from "react"; +import { Meta } from "@storybook/react"; + +import { SingleSelect } from "./Dropdown"; +import { OptionSelectHandler } from "./SingleSelectList"; +import { MultiSelect } from "./MultiSelect"; + +export default { + title: "Components/MultiSelect", + argTypes: { + colorMode: { + control: { type: "radio" }, + options: ["light", "dark"], + defaultValue: "light", + }, + }, +} as Meta; + +const testOptions = [ + { + label: "Option 1", + count: 10, + value: "option-1", + selected: false, + }, + { + label: "Option 2", + count: 20, + value: "option-2", + selected: false, + }, + { + label: "Option 3", + count: 30, + value: "option-3", + selected: false, + }, + { + label: "Option 4", + count: 40, + value: "option-4", + selected: false, + }, +]; + +export const UnModifiedSelect = (args: { colorMode: "light" | "dark" }) => { + const { colorMode } = args; + const isDark = colorMode === "dark"; + + const [options, setOptions] = useState(testOptions); + + const [singleSelectValue, setSingleSelectValue] = useState( + options[0].value, + ); + + const markAsSelected = ( + _action: "select" | "deselect", + value: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _event: React.MouseEvent, + ) => { + const duplicatedOptions = [...options]; + const optionIndex = duplicatedOptions.findIndex( + (option) => option.value === value, + ); + if (optionIndex !== -1) { + duplicatedOptions[optionIndex].selected = + !duplicatedOptions[optionIndex].selected; + } + setOptions(duplicatedOptions); + }; + + const handleSingleSelect: OptionSelectHandler = (option) => { + setSingleSelectValue(option.value); + }; + + return ( +
+
+ + + { + markAsSelected(action, value, event); + }} + /> + +
+
+ + + + +
something here
+
+
+ ); +}; diff --git a/src/components/select/SelectInput.tsx b/src/components/select/SelectInput.tsx new file mode 100644 index 0000000..593f946 --- /dev/null +++ b/src/components/select/SelectInput.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useRef } from "react"; +import { SearchIcon, ArrowRight } from "../../icons"; +import { cn } from "../../utils/cn"; +import { useMultiSelect } from "./MultiSelect"; + +type StyleConfig = { + container?: string; + input?: string; + searchIcon?: string; + searchIconWrapper?: string; + arrowIcon?: string; + arrowIconWrapper?: string; +}; + +export type SelectInputProps = { + defaultPlaceholder: string; + className?: string; + styles?: StyleConfig; +}; + +const defaultStyles = { + container: "relative text-bdp-primary-text", + input: + "bg-transparent text-base 2xl:text-base font-medium w-full pl-12 pr-10 py-4 rounded-xl border-[1px] border-bdp-stroke focus:outline-none focus:outline-bdp-secondary-text focus:outline-offset-0 leading-none", + searchIcon: "stroke-bdp-secondary-text w-[16px] h-[16px]", + searchIconWrapper: "absolute top-1/2 -translate-y-1/2 left-[18px]", + arrowIcon: "", + arrowIconWrapper: + "absolute p-2 cursor-pointer top-1/2 -translate-y-1/2 right-[18px] rotate-90 data-[is-open=false]:-rotate-90 transition-transform", +} as const; + +const SelectInput = ({ + defaultPlaceholder, + className, + styles = {}, +}: SelectInputProps) => { + const selectContextData = useMultiSelect(); + + const searchRef = useRef(null!); + const { + currentNavigateCheckbox, + toggleListOpen, + isListOpen, + onSearch, + searchInputRef, + setSearchInputRef, + } = selectContextData; + + useEffect(() => { + if (searchRef.current && !searchInputRef) { + setSearchInputRef(searchRef); + } + }, []); + + return ( +
+ { + onSearch(e.target.value); + }} + ref={searchRef} + /> + + + + + + +
+ ); +}; + +export default SelectInput; diff --git a/src/components/select/SelectList.tsx b/src/components/select/SelectList.tsx new file mode 100644 index 0000000..792d659 --- /dev/null +++ b/src/components/select/SelectList.tsx @@ -0,0 +1,44 @@ +import React, { useEffect, useMemo, useRef } from "react"; +import { matchCharactersWithRegex } from "../../utils/filter"; +import BaseSelectList, { SelectListProps } from "./BaseSelectList"; +import { useMultiSelect } from "./MultiSelect"; + +export type MultiSelectListProps = Omit; + +const SelectList = (props: MultiSelectListProps) => { + const selectContextData = useMultiSelect(); + + const containerRef = useRef(null!); + const { + containerRef: containerRefProvider, + setContainerRef, + isListOpen, + currentNavigateCheckbox, + inputValue: searchTerm, + } = selectContextData; + + useEffect(() => { + if (!containerRefProvider && containerRef.current) { + setContainerRef(containerRef); + } + }, []); + + const filteredOptions = useMemo(() => { + if (searchTerm.trim()) { + return props.options.filter((option) => { + return matchCharactersWithRegex(option.label, searchTerm.trim()); + }); + } + return props.options; + }, [props.options, searchTerm]); + + return ( + + ); +}; + +export default SelectList; diff --git a/src/components/select/SingleSelectInput.tsx b/src/components/select/SingleSelectInput.tsx new file mode 100644 index 0000000..a707156 --- /dev/null +++ b/src/components/select/SingleSelectInput.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { ArrowRight } from "../../icons"; +import { cn } from "../../utils/cn"; +import { useSingleSelect } from "./Dropdown"; + +type StyleConfig = { + container?: string; + trigger?: string; + arrowIcon?: string; + arrowIconWrapper?: string; +}; + +export type SingleSelectTriggerProps = { + defaultPlaceholder: string; + className?: string; + styles?: StyleConfig; +}; + +const defaultStyles = { + container: "relative text-bdp-primary-text", + trigger: + "block bg-transparent text-base text-bdp-accent 2xl:text-base font-medium w-full pl-6 py-4 rounded-xl border-[1px] border-bdp-stroke focus:outline-none focus:outline-bdp-secondary-text focus:outline-offset-0 leading-none", + arrowIcon: "", + arrowIconWrapper: + "absolute p-2 cursor-pointer top-1/2 -translate-y-1/2 right-[18px] rotate-90 data-[is-open=false]:-rotate-90 transition-transform", +} as const; + +const SingleSelectTrigger = ({ + defaultPlaceholder, + className, + styles = {}, +}: SingleSelectTriggerProps) => { + const { selectedOption, toggleListOpen, isListOpen, triggerRef } = + useSingleSelect(); + + return ( +
+ + + + +
+ ); +}; + +export default SingleSelectTrigger; diff --git a/src/components/select/SingleSelectList.tsx b/src/components/select/SingleSelectList.tsx new file mode 100644 index 0000000..487db2d --- /dev/null +++ b/src/components/select/SingleSelectList.tsx @@ -0,0 +1,80 @@ +import React, { useEffect } from "react"; +import BaseSelectList, { SelectListProps } from "./BaseSelectList"; +import { useSingleSelect } from "./Dropdown"; + +export type SingleSelectOption = { + label: string; + value: string; +}; + +export type OptionSelectHandler = (option: SingleSelectOption) => void; + +export type SingleSelectListProps = Omit< + SelectListProps, + "selectContextData" | "options" | "onOptionSelect" +> & { + options: SingleSelectOption[]; + value: string; + onOptionSelect: OptionSelectHandler; +}; + +const SingleSelectList = (props: SingleSelectListProps) => { + const { + isListOpen, + containerRef: containerRefProvider, + setContainerRef, + handleSelectOption, + setSelectedOption, + } = useSingleSelect(); + const containerRef = React.useRef(null!); + + useEffect(() => { + if (!containerRefProvider && containerRef.current) { + setContainerRef(containerRef); + } + }, []); + + const handleOption = ({ + value, + }: { + action: "select" | "deselect"; + value: string; + event: React.MouseEvent; + }) => { + const option = props.options.find((option) => option.value === value); + if (option) { + handleSelectOption(option); + props.onOptionSelect(option); + } + }; + + const managedOptions = props.options.map((option) => ({ + ...option, + selected: option.value === props.value, + })); + + useEffect(() => { + setSelectedOption( + props.options.find((option) => option.value === props.value) || null, + ); + }, [props.value, props.options]); + + return ( + + ); +}; + +export default SingleSelectList; diff --git a/src/components/select/index.tsx b/src/components/select/index.tsx new file mode 100644 index 0000000..1d9b926 --- /dev/null +++ b/src/components/select/index.tsx @@ -0,0 +1,2 @@ +export * from "./MultiSelect"; +export * from "./Dropdown"; diff --git a/src/components/select/types.ts b/src/components/select/types.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/components/select/useSelectNavigate.tsx b/src/components/select/useSelectNavigate.tsx new file mode 100644 index 0000000..844ab1b --- /dev/null +++ b/src/components/select/useSelectNavigate.tsx @@ -0,0 +1,139 @@ +import { useEffect, useRef, useState } from "react"; +import { isInViewport } from "../../utils/navigation"; + +type ChekboxNavigateProps = { + checkboxContainer: React.MutableRefObject | null; + searchEl: React.MutableRefObject | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options: any[]; +}; + +const useCheckboxNavigate = ({ + checkboxContainer, + searchEl, + options, +}: ChekboxNavigateProps) => { + const checkboxNavIndex = useRef(null); + + const [currentNavigateCheckbox, setcurrentNavigateCheckbox] = useState(""); + + const refocus = useRef(false); + + const toggleRefocus = () => { + refocus.current = !refocus.current; + }; + + useEffect(() => { + if (!checkboxContainer || !searchEl) return; + const multiCheckboxWrapper = checkboxContainer.current; + const multiCheckboxList = + multiCheckboxWrapper && + (Array.from(multiCheckboxWrapper?.children) as HTMLElement[]); + const searchInput = searchEl.current; + // focus back to search when options changes + if (refocus.current) { + if (searchInput) { + searchInput.focus(); + } + toggleRefocus(); + } + + let currentCheckboxNavIndex = checkboxNavIndex.current; + + const handleOptionNavigation = (e: KeyboardEvent) => { + if (currentNavigateCheckbox && currentCheckboxNavIndex === null) { + const isPrevCheckInListIdx = multiCheckboxList.findIndex( + (label) => label?.dataset?.checkbox === currentNavigateCheckbox, + ); + if (isPrevCheckInListIdx !== -1) { + currentCheckboxNavIndex = isPrevCheckInListIdx; + } + } + + switch (e.key) { + // downArrow + case "ArrowDown": + e.preventDefault(); + if (currentCheckboxNavIndex === null) { + currentCheckboxNavIndex = 0; + } else { + if (currentCheckboxNavIndex >= multiCheckboxList.length - 1) { + currentCheckboxNavIndex = 0; + } else { + currentCheckboxNavIndex += 1; + } + } + break; + + // upArrow + case "ArrowUp": + e.preventDefault(); + if (currentCheckboxNavIndex === null) { + currentCheckboxNavIndex = multiCheckboxList.length - 1; + } else { + if (currentCheckboxNavIndex === 0) { + currentCheckboxNavIndex = multiCheckboxList.length - 1; + } else { + currentCheckboxNavIndex -= 1; + } + } + break; + + // Enter + case "Enter": { + e.preventDefault(); + if (currentCheckboxNavIndex) { + const input = + multiCheckboxList[currentCheckboxNavIndex]?.querySelector( + '[role="button"]', + ); + if (input) { + (input as HTMLButtonElement).click(); + } + } + break; + } + + default: + break; + } + + const currentLabel = + typeof currentCheckboxNavIndex === "number" + ? multiCheckboxList[currentCheckboxNavIndex] + : null; + + if (currentLabel) { + const inViewPort = isInViewport(currentLabel); + if (!inViewPort) { + currentLabel.scrollIntoView({ + behavior: "smooth", + block: "end", + inline: "nearest", + }); + } + } + setcurrentNavigateCheckbox(currentLabel?.dataset?.checkbox ?? ""); + }; + + if (searchInput) { + searchInput.addEventListener("keydown", handleOptionNavigation); + searchInput.addEventListener("focusout", () => + setcurrentNavigateCheckbox(""), + ); + } + + return () => { + if (searchInput) { + searchInput.removeEventListener("keydown", handleOptionNavigation); + searchInput.removeEventListener("focusout", () => + setcurrentNavigateCheckbox(""), + ); + } + }; + }, [options, checkboxContainer, searchEl, currentNavigateCheckbox]); + + return { currentNavigateCheckbox, toggleRefocus }; +}; + +export default useCheckboxNavigate; diff --git a/src/index.ts b/src/index.ts index b04b71b..7cdf8c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from "./components/button"; export * from "./components/footer"; export * from "./components/carousel"; +export * from "./components/select"; diff --git a/src/utils/cn.ts b/src/utils/cn.ts new file mode 100644 index 0000000..3ad9127 --- /dev/null +++ b/src/utils/cn.ts @@ -0,0 +1,6 @@ +import { twMerge } from "tailwind-merge"; +import { clsx } from "clsx"; + +export function cn(...inputs: (string | undefined)[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/utils/filter.ts b/src/utils/filter.ts new file mode 100644 index 0000000..63d1dba --- /dev/null +++ b/src/utils/filter.ts @@ -0,0 +1,12 @@ +export function matchCharactersWithRegex(word: string, searchTerm: string) { + const escapedSearchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + + const regexPattern = escapedSearchTerm + .split("") + .map((char) => `(?=.*${char})`) + .join(""); + + const regex = new RegExp(regexPattern, "i"); // 'i' flag for case-insensitive matching + + return regex.test(word); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 7f940e7..c3bff50 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,10 +1,11 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function debounce void>( func: T, wait: number, ): (...args: Parameters) => void { let timeout: ReturnType | null = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any return function (this: any, ...args: Parameters) { // eslint-disable-next-line @typescript-eslint/no-this-alias const context = this; @@ -21,6 +22,7 @@ export function debounce void>( }; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function throttledDebounce void>( func: T, limit: number, @@ -28,6 +30,7 @@ export function throttledDebounce void>( let inThrottle: boolean = false; let lastArgs: Parameters | null = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any return function (this: any, ...args: Parameters) { // eslint-disable-next-line @typescript-eslint/no-this-alias const context = this; @@ -47,3 +50,8 @@ export function throttledDebounce void>( } }; } + +export const numberFormat = new Intl.NumberFormat("en-US", { + compactDisplay: "short", + notation: "compact", +}); diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts new file mode 100644 index 0000000..8b28ca3 --- /dev/null +++ b/src/utils/navigation.ts @@ -0,0 +1,10 @@ +export function isInViewport(el: HTMLElement) { + const rect = el.getBoundingClientRect(); + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= + (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ); +}