-
Notifications
You must be signed in to change notification settings - Fork 11
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
Changes from 7 commits
f94cc08
eecf265
98adae6
13a2955
14c0965
80c5caa
babac3c
8589117
738963e
10867d8
38d0793
42d1bd3
5abebda
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 /> |
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 }; | ||
} | ||
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); | ||||||
}); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 테스트 코드에 포함된 await act는 모두 제거되어도 됩니다 🤗 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 () => { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
테스트 설명에 에러를 던지지 않는 것에 포커스를 두기보다 |
||||||
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 () => { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
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'); | ||||||
}); | ||||||
}); |
There was a problem hiding this comment.
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}
과 통일이 목적입니다.preserved{~~}
)기존 onFoucs, onBlur는 useCallback이 아닌 참조를 유지해주는
usePreservedCallback
로 대체합니다. 결국 인자로 넘겨주는 focusAction, blurAction은 함수 즉 참조형이기 떄문에 리액트가 리렌더링 시 마다 다른 함수로 판단하고, 내부 onFocus, onBlur도 useCallback이지만 새로운 함수를 생성합니다.즉, 아래와 같이 최종 제안을 드려봅니다.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
문서도 추가로 변경되어야 합니다 :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
빠르고 상세한 리뷰 감사합니다!!
이미 구현된
usePreservedCallback
함수 사용 안내 감사합니다.구현, 테스트, 문서에 각각 변경된 인터페이스 작용하였습니다!