Skip to content

Commit

Permalink
Merge pull request #1132 from zeorin/fix/callback-ref
Browse files Browse the repository at this point in the history
React to ref changes
  • Loading branch information
JohannesKlauss authored Aug 19, 2024
2 parents e921499 + 70411dc commit 88264ea
Show file tree
Hide file tree
Showing 4 changed files with 58 additions and 16 deletions.
5 changes: 2 additions & 3 deletions documentation/docs/documentation/typescript.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const MyComponent = ({hotKey}: Props) => {

***

Using TypeScript to infer the type of the given ref.
Using TypeScript with refs.

```tsx
import { useHotkeys } from "react-hotkeys-hook";
Expand All @@ -50,8 +50,7 @@ interface Props {
const MyComponent = ({hotKey}: Props) => {
const [count, setCount] = useState(0);

// ref will have the type of React.MutableRef<HTMLDivElement>
const ref = useHotkeys(hotKey, () => setCount(prevCount => prevCount + 1));
const ref = useHotkeys<HTMLDivElement>(hotKey, () => setCount(prevCount => prevCount + 1));

return (
<div ref={ref}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ render(
```

Everytime we press down the `c` key, both component trigger the callback. But how can we separate those two components
and their assigned hotkeys? The answer is [`Refs`](https://reactjs.org/docs/refs-and-the-dom.html). `useHotkeys` returns
a mutable React reference that we can attach to any component that takes a ref. This way we
can tell the hook which element should receive the users focus before it triggers the callback.
and their assigned hotkeys? The answer is [`Refs`](https://react.dev/learn/manipulating-the-dom-with-refs). `useHotkeys`
returns a [React ref callback function](https://react.dev/reference/react-dom/components/common#ref-callback) that we
can attach to any component that takes a ref. This way we can tell the hook which element should receive the users focus
before it triggers its callback.

```jsx live noInline
function ScopedHotkey() {
Expand Down
18 changes: 9 additions & 9 deletions src/useHotkeys.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { HotkeyCallback, Keys, Options, OptionsOrDependencyArray, RefType } from './types'
import { DependencyList, useCallback, useEffect, useLayoutEffect, useRef } from 'react'
import { DependencyList, RefCallback, useCallback, useEffect, useState, useLayoutEffect, useRef } from 'react'
import { mapKey, parseHotkey, parseKeysHookInput } from './parseHotkeys'
import {
isHotkeyEnabled,
Expand Down Expand Up @@ -28,7 +28,7 @@ export default function useHotkeys<T extends HTMLElement>(
options?: OptionsOrDependencyArray,
dependencies?: OptionsOrDependencyArray
) {
const ref = useRef<RefType<T>>(null)
const [ref, setRef] = useState<RefType<T>>(null)
const hasTriggeredRef = useRef(false)

const _options: Options | undefined = !(options instanceof Array)
Expand Down Expand Up @@ -66,12 +66,12 @@ export default function useHotkeys<T extends HTMLElement>(

// TODO: SINCE THE EVENT IS NOW ATTACHED TO THE REF, THE ACTIVE ELEMENT CAN NEVER BE INSIDE THE REF. THE HOTKEY ONLY TRIGGERS IF THE
// REF IS THE ACTIVE ELEMENT. THIS IS A PROBLEM SINCE FOCUSED SUB COMPONENTS WON'T TRIGGER THE HOTKEY.
if (ref.current !== null) {
const rootNode = ref.current.getRootNode()
if (ref !== null) {
const rootNode = ref.getRootNode()
if (
(rootNode instanceof Document || rootNode instanceof ShadowRoot) &&
rootNode.activeElement !== ref.current &&
!ref.current.contains(rootNode.activeElement)
rootNode.activeElement !== ref &&
!ref.contains(rootNode.activeElement)
) {
stopPropagation(e)
return
Expand Down Expand Up @@ -140,7 +140,7 @@ export default function useHotkeys<T extends HTMLElement>(
}
}

const domNode = ref.current || _options?.document || document
const domNode = ref || _options?.document || document

// @ts-ignore
domNode.addEventListener('keyup', handleKeyUp)
Expand All @@ -165,7 +165,7 @@ export default function useHotkeys<T extends HTMLElement>(
)
}
}
}, [_keys, memoisedOptions, enabledScopes])
}, [ref, _keys, memoisedOptions, enabledScopes])

return ref
return setRef as RefCallback<T>
}
44 changes: 43 additions & 1 deletion tests/useHotkeys.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,7 @@ test('should reflect set splitKey character', async () => {
const user = userEvent.setup()
const callback = jest.fn()

const { rerender } = renderHook<MutableRefObject<HTMLElement | null>, HookParameters>(
const { rerender } = renderHook<RefCallback<HTMLElement>, HookParameters>(
({ keys, options }) => useHotkeys(keys, callback, options),
{
initialProps: { keys: 'a, b', options: undefined },
Expand Down Expand Up @@ -774,6 +774,48 @@ test('should only trigger when the element is focused if a ref is set', async ()
expect(callback).toHaveBeenCalledTimes(1)
})

test('should trigger when the ref is re-attached to another element', async () => {
const user = userEvent.setup()
const callback = jest.fn()

const Component = ({ cb }: { cb: HotkeyCallback }) => {
const ref = useHotkeys<HTMLDivElement>('a', cb)
const [toggle, setToggle] = useState(false)

if (toggle) {
return (
<span ref={ref} tabIndex={0} data-testid={'div'}>
<button data-testid={'toggle'} onClick={() => setToggle((t) => !t)}>
Toggle
</button>
<input type={'text'} />
</span>
)
}

return (
<div ref={ref} tabIndex={0} data-testid={'div'}>
<button data-testid={'toggle'} onClick={() => setToggle((t) => !t)}>
Toggle
</button>
<input type={'text'} />
</div>
)
}

const { getByTestId } = render(<Component cb={callback} />)

await user.keyboard('A')

expect(callback).not.toHaveBeenCalled()

await user.click(getByTestId('toggle'))
await user.click(getByTestId('div'))
await user.keyboard('A')

expect(callback).toHaveBeenCalledTimes(1)
})

test.skip('should preventDefault and stop propagation when ref is not focused', async () => {
const callback = jest.fn()

Expand Down

0 comments on commit 88264ea

Please sign in to comment.