Skip to content

Commit d49eddd

Browse files
Merge pull request #52 from commitd/sh/51
feat(usemergedrefs): add a hook to merge refs
2 parents 007e819 + 6bdace0 commit d49eddd

File tree

5 files changed

+166
-0
lines changed

5 files changed

+166
-0
lines changed

src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './useHover'
99
export * from './useInterval'
1010
export * from './useKeyboard'
1111
export * from './useLocalState'
12+
export * from './useMergedRefs'
1213
export * from './useModal'
1314
export * from './usePoll'
1415
export * from './useTimeout'

src/useMergedRefs/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './useMergedRefs'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Column, Input, Text } from '@committed/components'
2+
import { Meta, Story } from '@storybook/react'
3+
import React, { useRef } from 'react'
4+
import { useBoolean, useHover, useKeyboard } from '../'
5+
import { useMergedRefs } from './useMergedRefs'
6+
7+
export interface UseMergedRefsDocsProps<T> {
8+
/** spread array of refs to be merged, T extends `HTMLElement` */
9+
refs?: Array<
10+
React.RefCallback<T> | React.MutableRefObject<T> | undefined | null
11+
>
12+
}
13+
14+
/**
15+
* useMergedRefs merges the passed refs into a single memoized ref function.
16+
*
17+
* ```ts
18+
* const ExampleComponent = React.forwardRef((props, forwardedRef) => {
19+
* const ref = React.useRef();
20+
* return <div {...props} ref={useMergeRefs(ref, forwardedRef)} />;
21+
* });
22+
* ```
23+
*
24+
* @param refs spread array of refs to be merged
25+
*/
26+
export const UseMergedRefsDocs = <T extends HTMLElement>(
27+
props: UseMergedRefsDocsProps<T>
28+
) => null
29+
30+
export default {
31+
title: 'Hooks/useMergedRefs',
32+
component: UseMergedRefsDocs,
33+
excludeStories: ['UseMergedRefsDocs'],
34+
} as Meta
35+
36+
const ExampleComponent = React.forwardRef<HTMLInputElement>(
37+
(props, forwardedRef) => {
38+
const internalRef = React.useRef<HTMLInputElement>(null)
39+
const [isHovered] = useHover(internalRef)
40+
const mergedRef = useMergedRefs<HTMLInputElement>(forwardedRef, internalRef)
41+
42+
return (
43+
<Input
44+
css={{
45+
color: isHovered ? '$primary' : '$primaryContrast',
46+
}}
47+
ref={mergedRef}
48+
/>
49+
)
50+
}
51+
)
52+
53+
const Template: Story = () => {
54+
// const ExampleComponent = React.forwardRef<HTMLInputElement>(
55+
// (props, forwardedRef) => {
56+
// const internalRef = React.useRef<HTMLInputElement>(null)
57+
// const [isHovered] = useHover(internalRef)
58+
// const mergedRef = useMergedRefs<HTMLInputElement>(forwardedRef, internalRef)
59+
//
60+
// return (
61+
// <Input
62+
// css={{
63+
// color: isHovered ? '$primary' : '$primaryContrast',
64+
// }}
65+
// ref={mergedRef}
66+
// />
67+
// )
68+
// }
69+
// )
70+
71+
const externalRef = useRef<HTMLInputElement>(null)
72+
73+
const [typing, { setTrue, setFalse }] = useBoolean(false)
74+
useKeyboard('', setFalse, { event: 'keyup', element: externalRef })
75+
useKeyboard('', setTrue, { event: 'keydown', element: externalRef })
76+
77+
return (
78+
<Column gap>
79+
<ExampleComponent ref={externalRef} />
80+
<Text>{typing ? 'Typing...' : ``}</Text>
81+
</Column>
82+
)
83+
}
84+
85+
export const Default = Template.bind({})
+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { renderHook } from '@testing-library/react-hooks'
2+
import React from 'react'
3+
import { useMergedRefs } from '.'
4+
5+
test('Should provide initial null', () => {
6+
const { result: first } = renderHook(() => useMergedRefs())
7+
expect(first.current).toBeNull()
8+
})
9+
test('Should be null if all supplied null', () => {
10+
const { result: first } = renderHook(() => useMergedRefs(null, null))
11+
expect(first.current).toBeNull()
12+
})
13+
14+
test('useMergedRefs should merge the refs', () => {
15+
const testVal = true
16+
const refAsFunc = jest.fn()
17+
const refAsObj = { current: undefined }
18+
19+
const { result: first } = renderHook(() => useMergedRefs(refAsFunc, refAsObj))
20+
expect(first.current).not.toBeNull()
21+
22+
first.current?.(testVal)
23+
24+
expect(refAsFunc).toHaveBeenCalledTimes(1)
25+
expect(refAsFunc).toHaveBeenCalledWith(testVal)
26+
expect(refAsObj.current).toBe(testVal)
27+
})
28+
29+
test('useMergedRefs should not fail if invalid values', () => {
30+
const refAsInvalid = { invalid: undefined }
31+
32+
const { result: first } = renderHook(() =>
33+
useMergedRefs(null, (refAsInvalid as unknown) as React.Ref<string>)
34+
)
35+
// Would be valid if null here
36+
expect(first.current).not.toBeNull()
37+
38+
// should not throw
39+
first.current?.('testVal')
40+
})

src/useMergedRefs/useMergedRefs.ts

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React, { useMemo } from 'react'
2+
3+
type Ref<T> =
4+
| React.RefCallback<T | null>
5+
| React.MutableRefObject<T | null>
6+
| undefined
7+
| null
8+
9+
/**
10+
* useMergedRefs merges the passed refs into a single memoized ref function.
11+
*
12+
* ```ts
13+
* const ExampleComponent = React.forwardRef((props, forwardedRef) => {
14+
* const ref = React.useRef();
15+
* return <div {...props} ref={useMergeRefs(ref, forwardedRef)} />;
16+
* });
17+
* ```
18+
*
19+
* @param refs spread array of refs to be merged
20+
*/
21+
export function useMergedRefs<T>(
22+
...refs: Ref<T>[]
23+
): React.RefCallback<T> | null {
24+
return useMemo(() => {
25+
if (refs.every((ref) => ref == null)) {
26+
return null
27+
}
28+
return (value: T) => {
29+
refs.forEach((ref) => {
30+
if (typeof ref === 'function') {
31+
ref(value)
32+
} else if (ref != null && 'current' in ref) {
33+
ref.current = value
34+
}
35+
})
36+
}
37+
// eslint-disable-next-line react-hooks/exhaustive-deps
38+
}, refs)
39+
}

0 commit comments

Comments
 (0)