diff --git a/README.md b/README.md index a0eb988..165b0ea 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,20 @@ You can determine the initial on/off state of the toggle through `defaultValue`. `toggle` : Reverses the current state of the switch. +### useFocus + +Executes the provided callback function when a DOM element becomes visible on the screen. + +#### Parameters + +`onFocusCallback`: A function to execute when the element comes into focus. +`threshold`: Determines the visibility threshold for the element. A value of 1 means the element must be fully visible within the viewport. +`rootMargin`: Adjusts the viewport’s dimensions using the format "top right bottom left", with units explicitly specified. + +#### Return Value + +`ref`: A ref object to assign to the DOM element that should trigger the focus callback. + ## Animation The animation of this package is based on ClassName by default. diff --git a/src/index.ts b/src/index.ts index b0e0793..a88c174 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import useDebounce from './useDebounce/useDebounce'; import useLocalStorage from './useLocalStorage/useLocalStorage'; import useDisclosure from './useDisclosure/useDisclosure'; import useModal from './useModal/useModal'; +import useFocus from './useFocus/useFocus'; export { useInput, @@ -26,4 +27,5 @@ export { useLocalStorage, useDisclosure, useModal, + useFocus, }; diff --git a/src/stories/useFocus/Docs.mdx b/src/stories/useFocus/Docs.mdx new file mode 100644 index 0000000..6af5829 --- /dev/null +++ b/src/stories/useFocus/Docs.mdx @@ -0,0 +1,22 @@ +import { Canvas, Meta, Description } from '@storybook/blocks'; +import * as Focus from './Focus.stories'; + + + +# useFocus + +DOM Element가 화면에 노출되었을때, callback으로 전달된 함수를 실행합니다. + +## 함수인자 + +`onFocusCallback` : 요소가 focus되었을때, 수행할 함수입니다. + +`threshold` : 요소가 화면에 보일때의 기준을 결정합니다. 1이면 전체요소가 모두 화면에 들어왔을때 입니다. + +`rootMargin` : 화면의 너비, 높이를 조정할 수 있습니다. "top right bottom left" 형태로 기재하며 반드시 단위를 명시해야합니다. + +## 반환값 + +`ref`: focus될 DOM요소에 할당해줄 ref 객체입니다. + + diff --git a/src/stories/useFocus/Focus.css.ts b/src/stories/useFocus/Focus.css.ts new file mode 100644 index 0000000..12d483b --- /dev/null +++ b/src/stories/useFocus/Focus.css.ts @@ -0,0 +1,17 @@ +import { style } from '@vanilla-extract/css'; + +export const backgroundDiv = style({ + height: 100, +}); + +export const scrollDiv = style({ + position: 'absolute', + backgroundColor: 'black', + color: 'white', + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + display: 'flex', + padding: 16, + top: 200, +}); diff --git a/src/stories/useFocus/Focus.stories.ts b/src/stories/useFocus/Focus.stories.ts new file mode 100644 index 0000000..f4633ec --- /dev/null +++ b/src/stories/useFocus/Focus.stories.ts @@ -0,0 +1,21 @@ +import { Meta, StoryObj } from '@storybook/react'; +import Focus from './Focus'; + +const meta = { + title: 'hooks/useFocus', + component: Focus, + parameters: { + layout: 'centered', + docs: { + canvas: {}, + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const defaultStory: Story = { + args: {}, +}; diff --git a/src/stories/useFocus/Focus.tsx b/src/stories/useFocus/Focus.tsx new file mode 100644 index 0000000..d696b19 --- /dev/null +++ b/src/stories/useFocus/Focus.tsx @@ -0,0 +1,16 @@ +import React, { useState } from 'react'; +import useFocus from '@/useFocus/useFocus'; +import { backgroundDiv, scrollDiv } from './Focus.css'; + +export default function Focus() { + const [message, setMessage] = useState('NOT FOCUS'); + const ref = useFocus(() => setMessage('FOCUS!'), 1, '-10px'); + + return ( +
+
+ {message} +
+
+ ); +} diff --git a/src/useFocus/useFocus.test.tsx b/src/useFocus/useFocus.test.tsx new file mode 100644 index 0000000..48f2892 --- /dev/null +++ b/src/useFocus/useFocus.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render } from '@testing-library/react'; +import useFocus from './useFocus'; +import React from 'react'; + +interface Entrie { + target: Element; + isIntersecting: boolean; +} + +const mockIntersectionObserver = class { + entries: Entrie[]; + constructor(callback) { + this.entries = []; + window.addEventListener('scroll', () => { + if (window.scrollY > 50) { + this.entries.map((entry) => { + entry.isIntersecting = this.isInViewPort(); + }); + } + callback(this.entries, this); + }); + } + + isInViewPort() { + return true; + } + + observe(target: Element) { + this.entries.push({ isIntersecting: false, target }); + } + + unobserve(target) { + this.entries = this.entries.filter((ob) => ob.target !== target); + } + + disconnect() { + this.entries = []; + } +}; + +describe('useFocus 기능 테스트', () => { + beforeEach(() => { + // eslint-disable-next-line + global.IntersectionObserver = mockIntersectionObserver as any; + }); + + it('focus되었을때 onFocusCallback을 실행시킬 수 있다.', async () => { + const mock = jest.fn(); + const Test = () => { + const ref = useFocus(mock); + return
Test
; + }; + render(); + fireEvent.scroll(window, { target: { scrollY: 100 } }); + expect(mock).toHaveBeenCalled(); + }); +}); diff --git a/src/useFocus/useFocus.ts b/src/useFocus/useFocus.ts new file mode 100644 index 0000000..4f460fb --- /dev/null +++ b/src/useFocus/useFocus.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useRef } from 'react'; + +export default function useFocus( + onFocusCallback: (() => void) | (() => Promise), + threshold = 0.1, + rootMargin?: string, +) { + const elementRef = useRef(null); + + const handleScroll: IntersectionObserverCallback = useCallback(([entry]) => { + const { current } = elementRef; + if (current) { + if (entry.isIntersecting) { + onFocusCallback(); + } + } + }, []); + + useEffect(() => { + let observer: IntersectionObserver; + const { current } = elementRef; + + if (current) { + observer = new IntersectionObserver(handleScroll, { + threshold, + rootMargin: rootMargin || '0px 0px 0px 0px', + }); + + observer.observe(current); + + return () => observer && observer.disconnect(); + } + }, [handleScroll]); + + return elementRef; +}