Skip to content

Commit

Permalink
Feat: Added useHover hook (#98)
Browse files Browse the repository at this point in the history
* add useHover hook

* add story with temp import

* post review fixes

* more post review fixes
  • Loading branch information
boredcity authored Oct 2, 2023
1 parent 5fa3b83 commit 82e0804
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 0 deletions.
70 changes: 70 additions & 0 deletions apps/playground/src/stories/hooks/useHover.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useHover } from "@nanlabs/react-hooks";

export const Example = () => {
const [isMemoEnabled, setIsMemoEnabled] = useState(false);
const [isRefTarget, setIsRefTarget] = useState(false);
const [events, setEvents] = useState<string[]>([])

const getEventHandler = useCallback((eventName: string) => {
return () => setEvents((events) => [...events, `"${eventName}" callback fired`])
}, [setEvents])

const notMemoizedCallbacks = {
onChange: getEventHandler('onChange'),
onLeave: getEventHandler('onLeave'),
onEnter: getEventHandler('onEnter'),
}

const memoizedCallbacks = useMemo(() => ({...notMemoizedCallbacks}), [getEventHandler])

const divRef = useRef<HTMLDivElement | null>(null)
const getElement = () => divRef.current;

const isHovered = useHover(
isRefTarget ? divRef : getElement,
isMemoEnabled ? memoizedCallbacks : notMemoizedCallbacks
);

const hoverableStyles = {
width: 200,
height: 200,
backgroundColor: isHovered ? 'tomato' : 'rebeccapurple',
color: isHovered ? 'black' : 'white',
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
justifyContent: 'center',
gap: 10
}

return <div style={{display: 'flex', gap: 10}}>
<div style={hoverableStyles} ref={divRef}>
<h2>
{isHovered ? 'Hovered' : 'Hover me'}
</h2>

<span>
<button onClick={() => setIsMemoEnabled((val) => !val)}>
{isMemoEnabled ? 'Callbacks memoized' : 'Callbacks change each render'}
</button>
</span>

<span>
<button onClick={() => setIsRefTarget((val) => !val)}>
{isRefTarget ? 'Target provided as ref' : 'Target provided as function'}
</button>
</span>
</div>
<div> Event log:
<ol>
{events.map((event, i) => <li key={`${event}-${i}`}>{event}</li>)}
</ol>
</div>
</div>;
};

export default {
title: 'React Hooks/useHover',
component: useHover,
};
1 change: 1 addition & 0 deletions packages/react-hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from "./use-queue";
export * from "./use-append-task";
export * from "./use-copy-to-clipboard";
export * from "./use-previous";
export * from "./use-hover";
1 change: 1 addition & 0 deletions packages/react-hooks/use-hover/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as useHover } from "./useHover";
72 changes: 72 additions & 0 deletions packages/react-hooks/use-hover/useHover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { useEffect, type MutableRefObject, useState, useCallback, useRef } from 'react';

type TargetValue<T> = T | undefined | null;

type TargetType = HTMLElement | Element | Window | Document;

export type HoverTarget<T extends TargetType = Element> =
| (() => TargetValue<T>)
| TargetValue<T>
| MutableRefObject<TargetValue<T>>;

export interface Options {
onEnter?: () => void;
onLeave?: () => void;
onChange?: (isHovering: boolean) => void;
}

const useHover = <T extends TargetType = Element>(target: HoverTarget<T>, options?: Options): boolean => {
const [isHovered, setIsHovered] = useState(false);

const onMouseEnter = useCallback(() => {
const isHovered = true;
setIsHovered(isHovered)
options?.onChange?.(isHovered);
options?.onEnter?.()
}, [options?.onEnter, options?.onChange])

const onMouseLeave = useCallback(() => {
const isHovered = false;
setIsHovered(isHovered)
options?.onChange?.(isHovered);
options?.onLeave?.()
}, [options?.onLeave, options?.onChange])

const prevCallbacks = useRef({ onMouseEnter, onMouseLeave });

useEffect(() => {
if (
!target ||
typeof window === 'undefined' ||
window?.matchMedia("(any-hover: none)").matches
) {
return;
}
let targetElement: TargetValue<T>;


if (typeof target === 'function') {
targetElement = target()
} else if ('current' in target) {
targetElement = target.current;
}

if (!targetElement) {
return;
}

targetElement?.addEventListener('mouseenter', onMouseEnter)
targetElement?.addEventListener('mouseleave', onMouseLeave)

// clean up previous listeners
prevCallbacks.current = { onMouseEnter, onMouseLeave }
return () => {
targetElement?.removeEventListener('mouseenter', prevCallbacks.current.onMouseEnter)
targetElement?.removeEventListener('mouseleave', prevCallbacks.current.onMouseLeave)
}
}, [target, onMouseEnter, onMouseLeave])

return isHovered;
};

export default useHover

0 comments on commit 82e0804

Please sign in to comment.