From 6bdace0aac3595c40c9f981b01a16e9cf91b6479 Mon Sep 17 00:00:00 2001 From: Stuart Hendren Date: Fri, 5 Aug 2022 15:20:25 +0000 Subject: [PATCH] feat(usemergedrefs): add a hook to merge refs A new useMergedRefs hook has been added for use when multiple internal and external refs need to be passed to a component. fixes #51 --- src/index.ts | 1 + src/useMergedRefs/index.ts | 1 + src/useMergedRefs/useMergedRefs.stories.tsx | 85 +++++++++++++++++++++ src/useMergedRefs/useMergedRefs.test.ts | 40 ++++++++++ src/useMergedRefs/useMergedRefs.ts | 39 ++++++++++ 5 files changed, 166 insertions(+) create mode 100644 src/useMergedRefs/index.ts create mode 100644 src/useMergedRefs/useMergedRefs.stories.tsx create mode 100644 src/useMergedRefs/useMergedRefs.test.ts create mode 100644 src/useMergedRefs/useMergedRefs.ts diff --git a/src/index.ts b/src/index.ts index 57806d8..127e6a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export * from './useHover' export * from './useInterval' export * from './useKeyboard' export * from './useLocalState' +export * from './useMergedRefs' export * from './useModal' export * from './usePoll' export * from './useTimeout' diff --git a/src/useMergedRefs/index.ts b/src/useMergedRefs/index.ts new file mode 100644 index 0000000..2555898 --- /dev/null +++ b/src/useMergedRefs/index.ts @@ -0,0 +1 @@ +export * from './useMergedRefs' diff --git a/src/useMergedRefs/useMergedRefs.stories.tsx b/src/useMergedRefs/useMergedRefs.stories.tsx new file mode 100644 index 0000000..b3c8eb1 --- /dev/null +++ b/src/useMergedRefs/useMergedRefs.stories.tsx @@ -0,0 +1,85 @@ +import { Column, Input, Text } from '@committed/components' +import { Meta, Story } from '@storybook/react' +import React, { useRef } from 'react' +import { useBoolean, useHover, useKeyboard } from '../' +import { useMergedRefs } from './useMergedRefs' + +export interface UseMergedRefsDocsProps { + /** spread array of refs to be merged, T extends `HTMLElement` */ + refs?: Array< + React.RefCallback | React.MutableRefObject | undefined | null + > +} + +/** + * useMergedRefs merges the passed refs into a single memoized ref function. + * + * ```ts + * const ExampleComponent = React.forwardRef((props, forwardedRef) => { + * const ref = React.useRef(); + * return
; + * }); + * ``` + * + * @param refs spread array of refs to be merged + */ +export const UseMergedRefsDocs = ( + props: UseMergedRefsDocsProps +) => null + +export default { + title: 'Hooks/useMergedRefs', + component: UseMergedRefsDocs, + excludeStories: ['UseMergedRefsDocs'], +} as Meta + +const ExampleComponent = React.forwardRef( + (props, forwardedRef) => { + const internalRef = React.useRef(null) + const [isHovered] = useHover(internalRef) + const mergedRef = useMergedRefs(forwardedRef, internalRef) + + return ( + + ) + } +) + +const Template: Story = () => { + // const ExampleComponent = React.forwardRef( + // (props, forwardedRef) => { + // const internalRef = React.useRef(null) + // const [isHovered] = useHover(internalRef) + // const mergedRef = useMergedRefs(forwardedRef, internalRef) + // + // return ( + // + // ) + // } + // ) + + const externalRef = useRef(null) + + const [typing, { setTrue, setFalse }] = useBoolean(false) + useKeyboard('', setFalse, { event: 'keyup', element: externalRef }) + useKeyboard('', setTrue, { event: 'keydown', element: externalRef }) + + return ( + + + {typing ? 'Typing...' : ``} + + ) +} + +export const Default = Template.bind({}) diff --git a/src/useMergedRefs/useMergedRefs.test.ts b/src/useMergedRefs/useMergedRefs.test.ts new file mode 100644 index 0000000..90070a3 --- /dev/null +++ b/src/useMergedRefs/useMergedRefs.test.ts @@ -0,0 +1,40 @@ +import { renderHook } from '@testing-library/react-hooks' +import React from 'react' +import { useMergedRefs } from '.' + +test('Should provide initial null', () => { + const { result: first } = renderHook(() => useMergedRefs()) + expect(first.current).toBeNull() +}) +test('Should be null if all supplied null', () => { + const { result: first } = renderHook(() => useMergedRefs(null, null)) + expect(first.current).toBeNull() +}) + +test('useMergedRefs should merge the refs', () => { + const testVal = true + const refAsFunc = jest.fn() + const refAsObj = { current: undefined } + + const { result: first } = renderHook(() => useMergedRefs(refAsFunc, refAsObj)) + expect(first.current).not.toBeNull() + + first.current?.(testVal) + + expect(refAsFunc).toHaveBeenCalledTimes(1) + expect(refAsFunc).toHaveBeenCalledWith(testVal) + expect(refAsObj.current).toBe(testVal) +}) + +test('useMergedRefs should not fail if invalid values', () => { + const refAsInvalid = { invalid: undefined } + + const { result: first } = renderHook(() => + useMergedRefs(null, (refAsInvalid as unknown) as React.Ref) + ) + // Would be valid if null here + expect(first.current).not.toBeNull() + + // should not throw + first.current?.('testVal') +}) diff --git a/src/useMergedRefs/useMergedRefs.ts b/src/useMergedRefs/useMergedRefs.ts new file mode 100644 index 0000000..d66421d --- /dev/null +++ b/src/useMergedRefs/useMergedRefs.ts @@ -0,0 +1,39 @@ +import React, { useMemo } from 'react' + +type Ref = + | React.RefCallback + | React.MutableRefObject + | undefined + | null + +/** + * useMergedRefs merges the passed refs into a single memoized ref function. + * + * ```ts + * const ExampleComponent = React.forwardRef((props, forwardedRef) => { + * const ref = React.useRef(); + * return
; + * }); + * ``` + * + * @param refs spread array of refs to be merged + */ +export function useMergedRefs( + ...refs: Ref[] +): React.RefCallback | null { + return useMemo(() => { + if (refs.every((ref) => ref == null)) { + return null + } + return (value: T) => { + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(value) + } else if (ref != null && 'current' in ref) { + ref.current = value + } + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, refs) +}