From 3c80e79ad8d398d2505d35bc1f2edf7ba93fa87b 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 --- src/components/select/BaseSelectList.tsx | 128 +++++++++++++++++++ src/components/select/Dropdown.tsx | 120 ++++++++++++++++++ src/components/select/MultiSelect.tsx | 92 ++++++++++++++ src/components/select/Select.stories.tsx | 105 ++++++++++++++++ src/components/select/SelectInput.tsx | 83 +++++++++++++ src/components/select/SelectList.tsx | 44 +++++++ src/components/select/SingleSelectInput.tsx | 52 ++++++++ src/components/select/SingleSelectList.tsx | 80 ++++++++++++ src/components/select/index.tsx | 2 + src/components/select/types.ts | 0 src/components/select/useSelectNavigate.tsx | 131 ++++++++++++++++++++ src/index.ts | 1 + src/utils/cn.ts | 6 + src/utils/filter.ts | 12 ++ src/utils/index.ts | 5 + src/utils/navigation.ts | 10 ++ 16 files changed, 871 insertions(+) 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/src/components/select/BaseSelectList.tsx b/src/components/select/BaseSelectList.tsx new file mode 100644 index 0000000..8ab4a85 --- /dev/null +++ b/src/components/select/BaseSelectList.tsx @@ -0,0 +1,128 @@ +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..89686aa --- /dev/null +++ b/src/components/select/Dropdown.tsx @@ -0,0 +1,120 @@ +"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> & { + 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..6e5e567 --- /dev/null +++ b/src/components/select/MultiSelect.tsx @@ -0,0 +1,92 @@ +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; \ No newline at end of file diff --git a/src/components/select/Select.stories.tsx b/src/components/select/Select.stories.tsx new file mode 100644 index 0000000..2bb300f --- /dev/null +++ b/src/components/select/Select.stories.tsx @@ -0,0 +1,105 @@ +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..3d5c9d5 --- /dev/null +++ b/src/components/select/SelectInput.tsx @@ -0,0 +1,83 @@ +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; \ No newline at end of file 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..b8115e8 --- /dev/null +++ b/src/components/select/SingleSelectInput.tsx @@ -0,0 +1,52 @@ +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; \ No newline at end of file diff --git a/src/components/select/SingleSelectList.tsx b/src/components/select/SingleSelectList.tsx new file mode 100644 index 0000000..e6d632b --- /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..3b742ac --- /dev/null +++ b/src/components/select/index.tsx @@ -0,0 +1,2 @@ +export * from "./MultiSelect"; +export * from "./Dropdown"; \ No newline at end of file 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..529becc --- /dev/null +++ b/src/components/select/useSelectNavigate.tsx @@ -0,0 +1,131 @@ +import { useEffect, useRef, useState } from "react"; +import { isInViewport } from "../../utils/navigation"; + +type ChekboxNavigateProps = { + checkboxContainer: React.MutableRefObject | null; + searchEl: React.MutableRefObject | null; + 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..570ea1d --- /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)); +} \ No newline at end of file diff --git a/src/utils/filter.ts b/src/utils/filter.ts new file mode 100644 index 0000000..3ec3348 --- /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); +} \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts index 7f940e7..65f05ba 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -47,3 +47,8 @@ export function throttledDebounce void>( } }; } + +export const numberFormat = new Intl.NumberFormat("en-US", { + compactDisplay: "short", + notation: "compact", +}); \ No newline at end of file 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) + ); +}