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
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 {
focusAction?: (event: FocusEvent) => void;
blurAction?: (event: FocusEvent) => void;
}

interface UseFocusReturnType<T extends HTMLElement> {
ref: RefObject<T>;
isFocus: boolean;
setFocus: () => void;
}

const useFocus<T extends HTMLElement>({
focusAction = noop,
blurAction = noop,
}: UseFocusProps = {}): UseFocusReturnType<T>
```

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

const Example = () => {
const { ref, isFocus, setFocus } = useFocus<HTMLInputElement>({
focusAction: () => console.log("focus"),
blurAction: () => 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({
focusAction: () => console.log("focus"),
blurAction: () => 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
77 changes: 77 additions & 0 deletions packages/react/src/hooks/useFocus/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { noop } from '@modern-kit/utils';
import { RefObject, useRef, useState, useCallback } from 'react';
import { useEventListener } from '../../hooks/useEventListener';

interface UseFocusProps {
focusAction?: (event: FocusEvent) => void;
blurAction?: (event: FocusEvent) => void;
}

interface UseFocusReturnType<T extends HTMLElement> {
ref: RefObject<T>;
isFocus: boolean;
setFocus: () => void;
}

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

const ref = useRef<T>(null);

const onFocus = useCallback(
(event: FocusEvent) => {
setIsFocus(true);
focusAction(event);
},
[focusAction]
);

const onBlur = useCallback(
(event: FocusEvent) => {
setIsFocus(false);
blurAction(event);
},
[blurAction]
);

const setFocus = useCallback(() => {
if (ref.current) {
ref.current.focus();
setIsFocus(true);
}
}, []);

useEventListener(ref, 'focus', onFocus);
useEventListener(ref, 'blur', onBlur);

return { ref, isFocus, setFocus };
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 잘못 안내드린 부분이라 죄송스러운 부분인데요. 인자로 받는 focusAction, blurAction의 각각의 네이밍을 onFocus, onBlur로 변경하고자 합니다. 이는 useHover의 네이밍 on{Action}과 통일이 목적입니다.

  • useHover 등 내부 함수 네이밍을 통일 계획이 있습니다. (ex. preserved{~~})

기존 onFoucs, onBlur는 useCallback이 아닌 참조를 유지해주는 usePreservedCallback로 대체합니다. 결국 인자로 넘겨주는 focusAction, blurAction은 함수 즉 참조형이기 떄문에 리액트가 리렌더링 시 마다 다른 함수로 판단하고, 내부 onFocus, onBlur도 useCallback이지만 새로운 함수를 생성합니다.

즉, 아래와 같이 최종 제안을 드려봅니다.

import { noop } from '@modern-kit/utils';
import { useEventListener } from '../useEventListener';
import { usePreservedCallback } from '../usePreservedCallback';
import { RefObject, useCallback, useRef, useState } from 'react';

interface UseFocusProps {
  onFocus?: (event: FocusEvent) => void;
  onBlur?: (event: FocusEvent) => void;
}

interface UseFocusReturnType<T extends HTMLElement> {
  ref: RefObject<T>;
  isFocus: boolean;
  setFocus: () => void;
}

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 };
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문서도 추가로 변경되어야 합니다 :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

빠르고 상세한 리뷰 감사합니다!!

기존 onFoucs, onBlur는 useCallback이 아닌 참조를 유지해주는 usePreservedCallback로 대체합니다. 결국 인자로 넘겨주는 focusAction, blurAction은 함수 즉 참조형이기 떄문에 리액트가 리렌더링 시 마다 다른 함수로 판단하고, 내부 onFocus, onBlur도 useCallback이지만 새로운 함수를 생성합니다.

이미 구현된 usePreservedCallback 함수 사용 안내 감사합니다.

구현, 테스트, 문서에 각각 변경된 인터페이스 작용하였습니다!

107 changes: 107 additions & 0 deletions packages/react/src/hooks/useFocus/useFocus.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, it, expect, Mock, vi } from 'vitest';
import { useFocus } from '.';
import { renderSetup } from '../../utils/test/renderSetup';
import { act, 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>({
focusAction: focusMockFn,
blurAction: 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 act(async () => {
await user.click(targetTrigger);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

테스트 코드에 포함된 await act는 모두 제거되어도 됩니다 🤗

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그렇군요 감사합니다!

expect(focusTarget).toHaveFocus();
expect(targetStatus.textContent).toBe('Focus');
expect(focusMockFn).toBeCalled();

await act(async () => {
await user.click(targetStatus);
});
expect(focusTarget).not.toHaveFocus();
expect(targetStatus.textContent).toBe('Blur');
expect(blurMockFn).toBeCalled();
});

it('should not throw error when ref is not connected', async () => {
Copy link
Contributor

@ssi02014 ssi02014 Oct 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it('should not throw error when ref is not connected', async () => {
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 act(async () => {
await user.click(targetTrigger);
});
expect(focusTarget).not.toHaveFocus();
expect(targetStatus.textContent).toBe('Blur');
expect(focusMockFn).not.toBeCalled();

await act(async () => {
await user.click(targetStatus);
});
expect(focusTarget).not.toHaveFocus();
expect(targetStatus.textContent).toBe('Blur');
expect(blurMockFn).not.toBeCalled();
});

it('should not throw error when focusAction and blurAction is not provided', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it('should not throw error when focusAction and blurAction is not provided', async () => {
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 act(async () => {
await user.click(targetTrigger);
});
expect(focusTarget).toHaveFocus();
expect(targetStatus.textContent).toBe('Focus');

await act(async () => {
await user.click(targetStatus);
});
expect(focusTarget).not.toHaveFocus();
expect(targetStatus.textContent).toBe('Blur');
});
});