Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react): useFocus 신규 훅 추가 #510

Merged
merged 13 commits into from
Oct 16, 2024
5 changes: 5 additions & 0 deletions .changeset/heavy-onions-allow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@modern-kit/react": minor
---

feat(react): useFocus 신규 훅 추가 - @99mini
69 changes: 69 additions & 0 deletions docs/docs/react/hooks/useFocus.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useFocus } from '@modern-kit/react';

# useFocus

대상 요소를 기준으로 포커스 상태를 반환하고, 포커스 상태에 따른 액션을 정의할 수 있는 커스텀 훅입니다.

<br />

## 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<T extends HTMLElement> {
ref: RefObject<T>;
isFocus: boolean;
setFocus: () => void;
}

const useFocus<T extends HTMLElement>({
onFocus = noop,
onBlur = noop,
}: UseFocusProps = {}): UseFocusReturnType<T>
ssi02014 marked this conversation as resolved.
Show resolved Hide resolved
```

## Usage
```tsx title="typescript"
import { useFocus } from '@modern-kit/react';

const Example = () => {
const { ref, isFocus, setFocus } = useFocus<HTMLInputElement>({
onFocus: () => console.log("focus"),
onBlur: () => console.log("blur"),
});

return (
<div>
<input ref={ref} />
<button onClick={() => setFocus()}>focus trigger</button>
<div>{isFocus ? 'Focus' : 'Blur'}</div>
</div>
)
};
```

## Example

export const Example = () => {
const { ref, isFocus, setFocus } = useFocus({
onFocus: () => console.log("focus"),
onBlur: () => console.log("blur"),
});

return (
<div>
<input ref={ref} />
<button onClick={() => setFocus()}>focus trigger</button>
<div>{isFocus ? 'Focus' : 'Blur'}</div>
</div>
)
};

<Example />
1 change: 1 addition & 0 deletions packages/react/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
74 changes: 74 additions & 0 deletions packages/react/src/hooks/useFocus/index.ts
Original file line number Diff line number Diff line change
@@ -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<T extends HTMLElement> {
ref: RefObject<T>;
isFocus: boolean;
setFocus: () => void;
}

/**
* @description 대상 요소를 기준으로 포커스 상태를 반환하고, 포커스 상태에 따른 액션을 정의할 수 있는 커스텀 훅입니다.
*
* @template T - HTML 엘리먼트 타입을 지정합니다.
* @param {{
* onFocus?: (event: FocusEvent) => void;
* onBlur?: (event: FocusEvent) => void;
* }} props - 포커스 상태에 따른 콜백 함수를 포함한 선택적 속성입니다.
* - `onFocus`: 요소에 포커스가 들어올 때 호출되는 함수입니다. 기본값은 `noop` 함수입니다.
* - `onBlur`: 요소에서 포커스가 빠져나갈 때 호출되는 함수입니다. 기본값은 `noop` 함수입니다.
*
* @returns {UseFocusReturnType<T>} `ref`, `isFocus`, `setFocus`를 포함한 객체를 반환합니다.
* - `ref`: 추적할 대상 요소의 참조입니다.
* - `isFocus`: 요소가 포커스 상태인지 여부를 나타내는 불리언 값입니다.
* - `setFocus`: 요소에 포커스를 참 값으로 설정하는 함수입니다.
*
* @example
* ```tsx
* const { ref, isFocused, setFocus } = useFocus<HTMLInputElement>({
* onFocus: () => console.log("focus"),
* onBlur: () => console.log("blur")
* });
*
* <input ref={ref} />
* <button onClick={() => setFocus()}>focus trigger</button>
* <div>{isFocused ? 'focus' : 'blur'}</div>
* ```
*/
export function useFocus<T extends HTMLElement>({
onFocus = noop,
onBlur = noop,
}: UseFocusProps = {}): UseFocusReturnType<T> {
const [isFocus, setIsFocus] = useState(false);

const ref = useRef<T>(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 };
}
101 changes: 101 additions & 0 deletions packages/react/src/hooks/useFocus/useFocus.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<any, any>;
blurMockFn?: Mock<any, any>;
connectedRef?: boolean;
}) => {
const { ref, isFocus, setFocus } = useFocus<HTMLInputElement>({
onFocus: focusMockFn,
onBlur: blurMockFn,
});

return (
<div>
<input ref={connectedRef ? ref : undefined} role="focus-target" />
<button role="target-trigger" onClick={() => setFocus()} />
<div role="target-status">{isFocus ? 'Focus' : 'Blur'}</div>
</div>
);
};

describe('useFocus', () => {
it('should trigger callback at target focus and blur', async () => {
const focusMockFn = vi.fn();
const blurMockFn = vi.fn();

const { user } = renderSetup(
<TestComponent
focusMockFn={focusMockFn}
blurMockFn={blurMockFn}
connectedRef
/>
);

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(
<TestComponent focusMockFn={focusMockFn} blurMockFn={blurMockFn} />
);

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(<TestComponent connectedRef />);

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');
});
});
Loading