diff --git a/src/hooks/useEventListener/useEventListener.ts b/src/hooks/useEventListener/useEventListener.ts index 1fe7558e..24c3460f 100644 --- a/src/hooks/useEventListener/useEventListener.ts +++ b/src/hooks/useEventListener/useEventListener.ts @@ -67,7 +67,7 @@ export const useEventListener = ((...params: any[]) => { const event = (target ? params[1] : params[0]) as string | string[]; const events = Array.isArray(event) ? event : [event]; const listener = (target ? params[2] : params[1]) as (...arg: any[]) => undefined | void; - const options: UseEventListenerOptions | undefined = target ? params[3] : params[2]; + const options = (target ? params[3] : params[2]) as UseEventListenerOptions | undefined; const [internalRef, setInternalRef] = useState(); const internalListener = useEvent(listener); diff --git a/src/hooks/useField/useField.ts b/src/hooks/useField/useField.ts index 953e36fa..95bfaac4 100644 --- a/src/hooks/useField/useField.ts +++ b/src/hooks/useField/useField.ts @@ -102,8 +102,8 @@ export interface UseFieldReturn { * const { register, getValue, setValue, reset, dirty, error, setError, clearError, touched, focus, watch } = useField(); */ export const useField = < - Value extends boolean | string = string, - Type = Value extends string ? string : boolean + Value extends boolean | number | string = string, + Type = Value extends string ? string : Value extends boolean ? boolean : number >( params?: UseFieldParams ): UseFieldReturn => { diff --git a/src/hooks/useFocus/useFocus.ts b/src/hooks/useFocus/useFocus.ts index da2e200a..f07173e9 100644 --- a/src/hooks/useFocus/useFocus.ts +++ b/src/hooks/useFocus/useFocus.ts @@ -2,10 +2,14 @@ import type { RefObject } from 'react'; import { useEffect, useRef, useState } from 'react'; -import { getElement } from '@/utils/helpers'; +import { getElement, isTarget } from '@/utils/helpers'; /** The use focus target type */ -export type UseFocusTarget = (() => Element) | Element | RefObject; +export type UseFocusTarget = + | (() => Element) + | string + | Element + | RefObject; /** The use focus options type */ export interface UseFocusOptions { @@ -53,8 +57,7 @@ export interface UseFocus { * const { ref, focus, blur, focused } = useFocus(); */ export const useFocus = ((...params: any[]) => { - const target = - (params[0] && 'current' in params[0]) || params[0] instanceof Element ? params[0] : undefined; + const target = isTarget(params[0]) ? params[0] : undefined; const options = ((target ? params[1] : params[0]) as UseFocusOptions) ?? {}; const initialValue = options.initialValue ?? false; diff --git a/src/hooks/useMutationObserver/useMutationObserver.ts b/src/hooks/useMutationObserver/useMutationObserver.ts index a02f7b71..93cc6b66 100644 --- a/src/hooks/useMutationObserver/useMutationObserver.ts +++ b/src/hooks/useMutationObserver/useMutationObserver.ts @@ -91,6 +91,8 @@ export const useMutationObserver = ((...params: any[]) => { const [internalRef, setInternalRef] = useState(); const internalCallbackRef = useRef(callback); internalCallbackRef.current = callback; + const internalOptionsRef = useRef(options); + internalOptionsRef.current = options; useEffect(() => { if (!enabled && !target && !internalRef) return; @@ -102,7 +104,7 @@ export const useMutationObserver = ((...params: any[]) => { target.forEach((target) => { const element = getElement(target); if (!element) return; - observer.observe(element as Element, options); + observer.observe(element as Element, internalOptionsRef.current); }); return () => { @@ -115,12 +117,12 @@ export const useMutationObserver = ((...params: any[]) => { const observer = new MutationObserver(internalCallbackRef.current); setObserver(observer); - observer.observe(element as Element, options); + observer.observe(element as Element, internalOptionsRef.current); return () => { observer.disconnect(); }; - }, [internalRef, target, ...Object.values(options ?? {})]); + }, [internalRef, target]); const stop = () => observer?.disconnect(); diff --git a/src/hooks/useScroll/useScroll.demo.tsx b/src/hooks/useScroll/useScroll.demo.tsx index c67131d7..95358229 100644 --- a/src/hooks/useScroll/useScroll.demo.tsx +++ b/src/hooks/useScroll/useScroll.demo.tsx @@ -1,14 +1,15 @@ import type { CSSProperties } from 'react'; -import { useMemo, useRef, useState } from 'react'; +import { register } from 'node:module'; +import { useField } from '../useField/useField'; +import { useToggle } from '../useToggle/useToggle'; import { useScroll } from './useScroll'; const styles: Record = { container: { width: '300px', height: '300px', - margin: 'auto', overflow: 'scroll', backgroundColor: 'rgba(128, 128, 128, 0.05)', borderRadius: '8px' @@ -64,65 +65,82 @@ const styles: Record = { }; const Demo = () => { - const elementRef = useRef(null); + const xInput = useField({ initialValue: 0 }); + const yInput = useField({ initialValue: 0 }); + const [behavior, setBehavior] = useToggle(['auto', 'smooth']); - const [scrollX, setScrollX] = useState(0); - const [scrollY, setScrollY] = useState(0); - const [behavior, setBehavior] = useState('auto'); + const scrollX = xInput.watch(); + const scrollY = yInput.watch(); - const { x, y, isScrolling, arrivedState, directions } = useScroll(elementRef, { + const scroll = useScroll({ x: scrollX, y: scrollY, - behavior + behavior, + onScroll: (event) => { + console.log('onScroll', event); + } }); - const { left, right, top, bottom } = useMemo(() => arrivedState, [arrivedState]); - - const { - left: toLeft, - right: toRight, - top: toTop, - bottom: toBottom - } = useMemo(() => directions, [directions]); - return ( -
-
-
-
TopLeft
-
BottomLeft
-
TopRight
-
BottomRight
-
Scroll Me
+
+
+
+
+ x: + +
+
+ y: + +
-
-
-
- X Position: - setScrollX(+event.target.value)} /> +
+
+ Smooth scrolling:{' '} + setBehavior(event.target.checked ? 'smooth' : 'auto')} + /> +
+
scrolling: {String(scroll.scrolling)}
-
- Y Position: - setScrollY(+event.target.value)} /> +
+ + +
+
+
+
TopLeft
+
BottomLeft
+
TopRight
+
BottomRight
+
Scroll Me
+
-
- Smooth scrolling:{' '} - setBehavior(event.target.checked ? 'smooth' : 'auto')} - /> + +
+
+

Arrived

+
+
top: {String(scroll.arrived.top)}
+
right: {String(scroll.arrived.right)}
+
bottom: {String(scroll.arrived.bottom)}
+
left: {String(scroll.arrived.left)}
+
+
+ +
+

Directions

+
+
top: {String(scroll.directions.top)}
+
right: {String(scroll.directions.right)}
+
bottom: {String(scroll.directions.bottom)}
+
left: {String(scroll.directions.left)}
+
+
-
isScrolling: {JSON.stringify(isScrolling)}
-
Top Arrived: {JSON.stringify(top)}
-
Right Arrived: {JSON.stringify(right)}
-
Bottom Arrived: {JSON.stringify(bottom)}
-
Left Arrived: {JSON.stringify(left)}
-
Scrolling Up: {JSON.stringify(toTop)}
-
Scrolling Right: {JSON.stringify(toRight)}
-
Scrolling Down: {JSON.stringify(toBottom)}
-
Scrolling Left: {JSON.stringify(toLeft)}
-
+
); }; diff --git a/src/hooks/useScroll/useScroll.ts b/src/hooks/useScroll/useScroll.ts index 6d7f7ba6..57a4a249 100644 --- a/src/hooks/useScroll/useScroll.ts +++ b/src/hooks/useScroll/useScroll.ts @@ -1,9 +1,8 @@ -import { type RefObject, useEffect, useRef, useState } from 'react'; +import type { RefObject } from 'react'; -import { debounce as DebounceFn, throttle as ThrottleFn } from '@/utils/helpers'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { useEvent } from '../useEvent/useEvent'; -import { useEventListener } from '../useEventListener/useEventListener'; +import { getElement, isTarget } from '@/utils/helpers'; interface UseScrollOptions { /** Behavior of scrolling */ @@ -42,25 +41,21 @@ interface UseScrollOptions { }; } -interface useScrollReturn { - /** State of scrolling. */ - isScrolling: boolean; - - /** The initial x position. */ +interface UseScrollReturn { + /** State of scrolling */ + scrolling: boolean; + /** The element x position */ x: number; - - /** The initial y position. */ + /** The element y position */ y: number; - - /** State of arrived scroll. */ - arrivedState: { + /** State of scroll arrived */ + arrived: { left: boolean; right: boolean; top: boolean; bottom: boolean; }; - - /** State of scroll direction. */ + /** State of scroll direction */ directions: { left: boolean; right: boolean; @@ -69,134 +64,171 @@ interface useScrollReturn { }; } +/** The use scroll target element type */ +export type UseScrollTarget = + | (() => Element) + | string + | Document + | Element + | RefObject + | Window; + +export interface UseScroll { + (target: Target, options?: UseScrollOptions): UseScrollReturn; + + ( + options?: UseScrollOptions, + target?: never + ): { ref: (node: Target) => void } & UseScrollReturn; +} + /** * @name useScroll + * @description - Hook that allows you to control scroll a element * @category Sensors * - * @description Reactive scroll position and state. - * * @param {RefObject} ref - React ref object pointing to a scrollable element. * @param {UseScrollOptions} [options] - Optional configuration options for the hook. - * * @returns {useScrollReturn} An object containing the current scroll position, scrolling state, and scroll direction. * * @example * const { x, y, isScrolling, arrivedState, directions } = useScroll(ref); */ - const ARRIVED_STATE_THRESHOLD_PIXELS = 1; -export const useScroll = ( - element: RefObject, - options?: UseScrollOptions -): useScrollReturn => { - const { - throttle = 0, - idle = 200, - x = 0, - y = 0, - onStop = () => {}, - onScroll = () => {}, - offset = { - left: 0, - right: 0, - top: 0, - bottom: 0 +export const useScroll = ((...params: any[]) => { + const target = (isTarget(params[0]) ? params[0] : undefined) as UseScrollTarget | undefined; + const options = (target ? params[1] : params[0]) as UseScrollOptions | undefined; + const [internalRef, setInternalRef] = useState(); + const internalOptionsRef = useRef(options); + internalOptionsRef.current = options; + + const { x = 0, y = 0, behavior = 'auto' } = options ?? {}; + + const [scroll, setScroll] = useState({ + x, + y, + arrived: { + left: true, + right: false, + top: true, + bottom: false }, - eventListenerOptions = { - capture: false, - passive: true - }, - behavior = 'auto', - onError = (e: unknown) => { - console.error(e); + directions: { + left: false, + right: false, + top: false, + bottom: false } - } = options ?? {}; - - const [scrollX, setScrollX] = useState(x); - const [scrollY, setScrollY] = useState(y); + }); - const [isScrolling, setIsScrolling] = useState(false); + const [scrolling, setScrolling] = useState(false); const lastScrollTime = useRef(Date.now()); - const [arrivedState, setArrivedState] = useState({ - left: true, - right: false, - top: true, - bottom: false - }); + useLayoutEffect(() => { + if (!target && !internalRef) return; + const element = (target ? getElement(target) : internalRef) as Element; - const [directions, setDirections] = useState({ - left: false, - right: false, - top: false, - bottom: false - }); + if (!element) return; - useEffect(() => { - if (element.current) { - element.current.scrollTo({ - left: x, - top: y, - behavior - }); - } - }, [x, y, element, behavior]); - - const onScrollEnd = DebounceFn((e: Event) => { - const currentTime = Date.now(); - if (currentTime - lastScrollTime.current >= idle) { - setIsScrolling(false); - setDirections({ left: false, right: false, top: false, bottom: false }); - onStop(e); - } - }, throttle + idle); - - const onScrollHandler = useEvent((e: Event) => { - try { - const eventTarget = ( - e.target === document ? (e.target as Document).documentElement : e.target - ) as HTMLElement; - const scrollLeft = eventTarget.scrollLeft; - let scrollTop = eventTarget.scrollTop; - - if (e.target === document && !scrollTop) scrollTop = document.body.scrollTop; - - setScrollX(scrollLeft); - setScrollY(scrollTop); - setDirections({ - left: scrollLeft < scrollX, - right: scrollLeft > scrollX, - top: scrollTop < scrollY, - bottom: scrollTop > scrollY - }); - setArrivedState({ - left: scrollLeft <= 0 + (offset.left || 0), - right: - scrollLeft + eventTarget.clientWidth >= - eventTarget.scrollWidth - (offset.right || 0) - ARRIVED_STATE_THRESHOLD_PIXELS, - top: scrollTop <= 0 + (offset.top || 0), - bottom: - scrollTop + eventTarget.clientHeight >= - eventTarget.scrollHeight - (offset.bottom || 0) - ARRIVED_STATE_THRESHOLD_PIXELS - }); - setIsScrolling(true); - lastScrollTime.current = Date.now(); - onScrollEnd(e); - onScroll(e); - } catch (error) { - onError(error); - } - }); + element.scrollTo({ + left: x, + top: y, + behavior + }); + }, [x, y]); - const throttleOnScroll = ThrottleFn(onScrollHandler, throttle); + // const onScrollEnd = DebounceFn((e: Event) => { + // const currentTime = Date.now(); + // if (currentTime - lastScrollTime.current >= idle) { + // setIsScrolling(false); + // setDirections({ left: false, right: false, top: false, bottom: false }); + // onStop(e); + // } + // }, throttle + idle); - useEventListener( - element, - 'scroll', - throttle ? throttleOnScroll : onScrollHandler, - eventListenerOptions - ); + // const throttleOnScroll = throttle(onScrollHandler, throttle); - return { x: scrollX, y: scrollY, isScrolling, arrivedState, directions }; -}; + useEffect(() => { + if (!target && !internalRef) return; + const element = (target ? getElement(target) : internalRef) as Element; + + if (!element) return; + + const onScrollEnd = (event: Event) => { + setScrolling(false); + setScroll((prevScroll) => ({ + ...prevScroll, + directions: { + left: false, + right: false, + top: false, + bottom: false + } + })); + options?.onStop?.(event); + }; + + const onScroll = (event: Event) => { + try { + const target = ( + event.target === document ? (event.target as Document).documentElement : event.target + ) as HTMLElement; + + const { display, flexDirection, direction } = target.style; + const directionMultipler = direction === 'rtl' ? -1 : 1; + + const scrollLeft = target.scrollLeft; + let scrollTop = target.scrollTop; + if (target === window.document && !scrollTop) scrollTop = window.document.body.scrollTop; + + const offset = internalOptionsRef.current?.offset; + const left = scrollLeft * directionMultipler <= (offset?.left ?? 0); + const right = + scrollLeft * directionMultipler + target.clientWidth >= + target.scrollWidth - (offset?.right ?? 0) - ARRIVED_STATE_THRESHOLD_PIXELS; + const top = scrollTop <= (offset?.top ?? 0); + const bottom = + scrollTop + target.clientHeight >= + target.scrollHeight - (offset?.bottom ?? 0) - ARRIVED_STATE_THRESHOLD_PIXELS; + + const isColumnReverse = display === 'flex' && flexDirection === 'column-reverse'; + const isRowReverse = display === 'flex' && flexDirection === 'column-reverse'; + + setScrolling(true); + setScroll((prevScroll) => ({ + x: scrollLeft, + y: scrollTop, + directions: { + left: scrollLeft < prevScroll.x, + right: scrollLeft > prevScroll.x, + top: scrollTop < prevScroll.y, + bottom: scrollTop > prevScroll.y + }, + arrived: { + left: isRowReverse ? right : left, + right: isRowReverse ? left : right, + top: isColumnReverse ? bottom : top, + bottom: isColumnReverse ? top : bottom + } + })); + + lastScrollTime.current = Date.now(); + internalOptionsRef.current?.onScroll?.(event); + } catch (error) { + internalOptionsRef.current?.onError?.(error); + } + }; + + element.addEventListener('scroll', onScroll); + element.addEventListener('scrollend', onScrollEnd); + + return () => { + element.removeEventListener('scroll', onScroll); + element.removeEventListener('scrollend', onScrollEnd); + }; + }, [target, internalRef]); + + if (target) return { ...scroll, scrolling }; + return { ref: setInternalRef, ...scroll, scrolling }; +}) as UseScroll; diff --git a/src/utils/helpers/index.ts b/src/utils/helpers/index.ts index e8982c05..7b8c3761 100644 --- a/src/utils/helpers/index.ts +++ b/src/utils/helpers/index.ts @@ -2,5 +2,6 @@ export * from './debounce'; export * from './getElement'; export * from './getRetry'; export * from './isClient'; +export * from './isTarget'; export * from './throttle'; export * from './time/getDate'; diff --git a/src/utils/helpers/isTarget.ts b/src/utils/helpers/isTarget.ts new file mode 100644 index 00000000..33b15c11 --- /dev/null +++ b/src/utils/helpers/isTarget.ts @@ -0,0 +1,7 @@ +export const isTarget = (target: any) => + typeof target === 'function' || + typeof target === 'string' || + target instanceof Element || + target instanceof Window || + target instanceof Document || + (target && 'current' in target);