diff --git a/.changeset/heavy-onions-allow.md b/.changeset/heavy-onions-allow.md new file mode 100644 index 00000000..fec03cd5 --- /dev/null +++ b/.changeset/heavy-onions-allow.md @@ -0,0 +1,5 @@ +--- +"@modern-kit/react": minor +--- + +feat(react): useFocus 신규 훅 추가 - @99mini diff --git a/docs/docs/react/hooks/useFocus.mdx b/docs/docs/react/hooks/useFocus.mdx new file mode 100644 index 00000000..aceee665 --- /dev/null +++ b/docs/docs/react/hooks/useFocus.mdx @@ -0,0 +1,69 @@ +import { useFocus } from '@modern-kit/react'; + +# useFocus + +대상 요소를 기준으로 포커스 상태를 반환하고, 포커스 상태에 따른 액션을 정의할 수 있는 커스텀 훅입니다. + +
+ +## Code +[🔗 실제 구현 코드 확인](https://github.com/modern-agile-team/modern-kit/blob/main/packages/react/src/hooks/useFocus/index.ts) + +## Interface +```ts title="typescript" + +interface UseFocusProps { + onFocus?: (event: FocusEvent) => void; + onBlur?: (event: FocusEvent) => void; +} + +interface UseFocusReturnType { + ref: RefObject; + isFocus: boolean; + setFocus: () => void; +} + +function useFocus({ + onFocus, + onBlur, +}: UseFocusProps = {}): UseFocusReturnType +``` + +## Usage +```tsx title="typescript" +import { useFocus } from '@modern-kit/react'; + +const Example = () => { + const { ref, isFocus, setFocus } = useFocus({ + onFocus: () => console.log("focus"), + onBlur: () => console.log("blur"), + }); + + return ( +
+ + +
{isFocus ? 'Focus' : 'Blur'}
+
+ ) +}; +``` + +## Example + +export const Example = () => { + const { ref, isFocus, setFocus } = useFocus({ + onFocus: () => console.log("focus"), + onBlur: () => console.log("blur"), + }); + + return ( +
+ + +
{isFocus ? 'Focus' : 'Blur'}
+
+ ) +}; + + \ No newline at end of file diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 7bafb3b1..4fc5c6a5 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -11,6 +11,7 @@ export * from './useDebouncedState'; export * from './useDocumentTitle'; export * from './useEventListener'; export * from './useFileReader'; +export * from './useFocus'; export * from './useForceUpdate'; export * from './useHover'; export * from './useImageStatus'; diff --git a/packages/react/src/hooks/useFocus/index.ts b/packages/react/src/hooks/useFocus/index.ts new file mode 100644 index 00000000..edfaff9d --- /dev/null +++ b/packages/react/src/hooks/useFocus/index.ts @@ -0,0 +1,74 @@ +import { noop } from '@modern-kit/utils'; +import { RefObject, useCallback, useRef, useState } from 'react'; +import { useEventListener } from '../../hooks/useEventListener'; +import { usePreservedCallback } from '../usePreservedCallback'; + +interface UseFocusProps { + onFocus?: (event: FocusEvent) => void; + onBlur?: (event: FocusEvent) => void; +} + +interface UseFocusReturnType { + ref: RefObject; + isFocus: boolean; + setFocus: () => void; +} + +/** + * @description 대상 요소를 기준으로 포커스 상태를 반환하고, 포커스 상태에 따른 액션을 정의할 수 있는 커스텀 훅입니다. + * + * @template T - HTML 엘리먼트 타입을 지정합니다. + * @param {{ + * onFocus?: (event: FocusEvent) => void; + * onBlur?: (event: FocusEvent) => void; + * }} props - 포커스 상태에 따른 콜백 함수를 포함한 선택적 속성입니다. + * - `onFocus`: 요소에 포커스가 들어올 때 호출되는 함수입니다. 기본값은 `noop` 함수입니다. + * - `onBlur`: 요소에서 포커스가 빠져나갈 때 호출되는 함수입니다. 기본값은 `noop` 함수입니다. + * + * @returns {UseFocusReturnType} `ref`, `isFocus`, `setFocus`를 포함한 객체를 반환합니다. + * - `ref`: 추적할 대상 요소의 참조입니다. + * - `isFocus`: 요소가 포커스 상태인지 여부를 나타내는 불리언 값입니다. + * - `setFocus`: 요소에 포커스를 참 값으로 설정하는 함수입니다. + * +* @example +* ```tsx +* const { ref, isFocused, setFocus } = useFocus({ +* onFocus: () => console.log("focus"), +* onBlur: () => console.log("blur") +* }); +* +* +* +*
{isFocused ? 'focus' : 'blur'}
+* ``` + */ +export function useFocus({ + onFocus = noop, + onBlur = noop, +}: UseFocusProps = {}): UseFocusReturnType { + const [isFocus, setIsFocus] = useState(false); + + const ref = useRef(null); + + const preservedFocusAction = usePreservedCallback((event: FocusEvent) => { + setIsFocus(true); + onFocus(event); + }); + + const preservedBlurAction = usePreservedCallback((event: FocusEvent) => { + setIsFocus(false); + onBlur(event); + }); + + const setFocus = useCallback(() => { + if (!ref.current) return; + + ref.current.focus(); + setIsFocus(true); + }, []); + + useEventListener(ref, 'focus', preservedFocusAction); + useEventListener(ref, 'blur', preservedBlurAction); + + return { ref, isFocus, setFocus }; +} diff --git a/packages/react/src/hooks/useFocus/useFocus.spec.tsx b/packages/react/src/hooks/useFocus/useFocus.spec.tsx new file mode 100644 index 00000000..a2b467ab --- /dev/null +++ b/packages/react/src/hooks/useFocus/useFocus.spec.tsx @@ -0,0 +1,101 @@ +import { describe, it, expect, Mock, vi } from 'vitest'; +import { useFocus } from '.'; +import { renderSetup } from '../../utils/test/renderSetup'; +import { screen } from '@testing-library/react'; + +const TestComponent = ({ + focusMockFn, + blurMockFn, + connectedRef, +}: { + focusMockFn?: Mock; + blurMockFn?: Mock; + connectedRef?: boolean; +}) => { + const { ref, isFocus, setFocus } = useFocus({ + onFocus: focusMockFn, + onBlur: blurMockFn, + }); + + return ( +
+ +
+ ); +}; + +describe('useFocus', () => { + it('should trigger callback at target focus and blur', async () => { + const focusMockFn = vi.fn(); + const blurMockFn = vi.fn(); + + const { user } = renderSetup( + + ); + + const focusTarget = screen.getByRole('focus-target'); + const targetTrigger = screen.getByRole('target-trigger'); + const targetStatus = screen.getByRole('target-status'); + + await user.click(targetTrigger); + + expect(focusTarget).toHaveFocus(); + expect(targetStatus.textContent).toBe('Focus'); + expect(focusMockFn).toBeCalled(); + + await user.click(targetStatus); + + expect(focusTarget).not.toHaveFocus(); + expect(targetStatus.textContent).toBe('Blur'); + expect(blurMockFn).toBeCalled(); + }); + + it('should not perform any actions when ref is not assigned', async () => { + const focusMockFn = vi.fn(); + const blurMockFn = vi.fn(); + + const { user } = renderSetup( + + ); + + const focusTarget = screen.getByRole('focus-target'); + const targetTrigger = screen.getByRole('target-trigger'); + const targetStatus = screen.getByRole('target-status'); + + await user.click(targetTrigger); + + expect(focusTarget).not.toHaveFocus(); + expect(targetStatus.textContent).toBe('Blur'); + expect(focusMockFn).not.toBeCalled(); + + await user.click(targetStatus); + + expect(focusTarget).not.toHaveFocus(); + expect(targetStatus.textContent).toBe('Blur'); + expect(blurMockFn).not.toBeCalled(); + }); + + it('should not perform any actions when onFocus and onBlur are not assigned', async () => { + const { user } = renderSetup(); + + const focusTarget = screen.getByRole('focus-target'); + const targetTrigger = screen.getByRole('target-trigger'); + const targetStatus = screen.getByRole('target-status'); + + await user.click(targetTrigger); + + expect(focusTarget).toHaveFocus(); + expect(targetStatus.textContent).toBe('Focus'); + + await user.click(targetStatus); + + expect(focusTarget).not.toHaveFocus(); + expect(targetStatus.textContent).toBe('Blur'); + }); +});