-
-
Notifications
You must be signed in to change notification settings - Fork 53
HoverCard Component #583
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
HoverCard Component #583
Changes from all commits
9d7b7f9
bd7c922
aef2255
1440811
8c5f0e6
97917fc
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,50 @@ | ||
| import React from 'react'; | ||
|
|
||
| import HoverCardRoot from './fragments/HoverCardRoot'; | ||
| import HoverCardTrigger from './fragments/HoverCardTrigger'; | ||
| import HoverCardPortal from './fragments/HoverCardPortal'; | ||
| import HoverCardContent from './fragments/HoverCardContent'; | ||
| import HoverCardArrow from './fragments/HoverCardArrow'; | ||
|
|
||
| type HoverCardProps = { | ||
| children: React.ReactNode, | ||
| content: React.ReactNode, | ||
| customRootClass?: string, | ||
| openDelay?: number, | ||
| closeDelay?: number, | ||
| onOpenChange?: (open: boolean) => void | ||
| props?: React.HTMLAttributes<HTMLElement>, | ||
| } | ||
|
|
||
| const HoverCard = ({ | ||
| children, | ||
| onOpenChange = () => { }, | ||
| content = undefined, | ||
| customRootClass = '', | ||
| openDelay = 100, | ||
| closeDelay = 200, | ||
| ...props | ||
| }: HoverCardProps) => { | ||
| return ( | ||
| <HoverCardRoot | ||
| open={undefined} | ||
| onOpenChange={onOpenChange} | ||
| openDelay={openDelay} | ||
| closeDelay={closeDelay} | ||
| customRootClass={customRootClass} | ||
| {...props} | ||
kotAPI marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| > | ||
| <HoverCardTrigger> | ||
| {children} | ||
| </HoverCardTrigger> | ||
| <HoverCardPortal > | ||
| <HoverCardContent> | ||
| {content} | ||
| <HoverCardArrow /> | ||
| </HoverCardContent> | ||
| </HoverCardPortal> | ||
| </HoverCardRoot> | ||
| ); | ||
| }; | ||
|
|
||
| export default HoverCard; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { createContext } from 'react'; | ||
|
|
||
| type HoverCardContextType = { | ||
| isOpen: boolean; | ||
| handleOpenChange: (open: boolean) => void; | ||
| floatingRefs: { | ||
| setReference: (node: HTMLElement | null) => void; | ||
| setFloating: (node: HTMLElement | null) => void; | ||
| }; | ||
| getReferenceProps: () => Record<string, any>; | ||
| getFloatingProps: () => Record<string, any>; | ||
| floatingStyles: React.CSSProperties; | ||
| rootClass: string; | ||
| closeWithDelay: () => void; | ||
| closeWithoutDelay: () => void; | ||
| openWithDelay: () => void; | ||
| floatingContext: any; | ||
| arrowRef: any; | ||
|
|
||
| }; | ||
|
|
||
| const HoverCardContext = createContext<HoverCardContextType>({} as HoverCardContextType); | ||
|
|
||
| export default HoverCardContext; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,12 @@ | ||||||||||||||||||||||||||||||||||||||||
| import React, { useContext } from 'react'; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| import Floater from '~/core/primitives/Floater'; | ||||||||||||||||||||||||||||||||||||||||
| import HoverCardContext from '../contexts/HoverCardContext'; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const HoverCardArrow = ({ ...props }) => { | ||||||||||||||||||||||||||||||||||||||||
| const { floatingContext, arrowRef, rootClass } = useContext(HoverCardContext); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return <Floater.Arrow className={`${rootClass}-arrow`} {...props} context={floatingContext} ref={arrowRef} />; | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+6
to
+10
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. 🛠️ Refactor suggestion Add TypeScript types for better type safety and documentation. The component could benefit from explicit type definitions for better maintainability and developer experience. Consider applying these changes: -const HoverCardArrow = ({ ...props }) => {
+interface HoverCardArrowProps extends React.ComponentPropsWithoutRef<typeof Floater.Arrow> {}
+
+const HoverCardArrow = ({ className, ...props }: HoverCardArrowProps) => {
const { floatingContext, arrowRef, rootClass } = useContext(HoverCardContext);
- return <Floater.Arrow className={`${rootClass}-arrow`} {...props} context={floatingContext} ref={arrowRef} />;
+ return (
+ <Floater.Arrow
+ className={className ? `${rootClass}-arrow ${className}` : `${rootClass}-arrow`}
+ {...props}
+ context={floatingContext}
+ ref={arrowRef}
+ />
+ );
};📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| export default HoverCardArrow; | ||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,41 @@ | ||||||||||||||||||||||||||||||||||||||||
| import React, { useContext, useEffect } from 'react'; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| import HoverCardContext from '../contexts/HoverCardContext'; | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| type HoverCardContentProps = { | ||||||||||||||||||||||||||||||||||||||||
| children: React.ReactNode, | ||||||||||||||||||||||||||||||||||||||||
| props?: React.HTMLAttributes<HTMLElement> | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| const HoverCardContent = ({ children, ...props }: HoverCardContentProps) => { | ||||||||||||||||||||||||||||||||||||||||
| const { | ||||||||||||||||||||||||||||||||||||||||
| isOpen, | ||||||||||||||||||||||||||||||||||||||||
| floatingRefs, | ||||||||||||||||||||||||||||||||||||||||
| floatingStyles, | ||||||||||||||||||||||||||||||||||||||||
| getFloatingProps, | ||||||||||||||||||||||||||||||||||||||||
| rootClass, | ||||||||||||||||||||||||||||||||||||||||
| closeWithDelay, | ||||||||||||||||||||||||||||||||||||||||
| closeWithoutDelay, | ||||||||||||||||||||||||||||||||||||||||
| openWithDelay | ||||||||||||||||||||||||||||||||||||||||
| } = useContext(HoverCardContext); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||
| const handleScroll = () => closeWithoutDelay(); | ||||||||||||||||||||||||||||||||||||||||
| window.addEventListener('scroll', handleScroll); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| return () => { | ||||||||||||||||||||||||||||||||||||||||
| window.removeEventListener('scroll', handleScroll); | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
| }, [closeWithoutDelay]); | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+22
to
+29
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. 🛠️ Refactor suggestion Optimize scroll event handling The current implementation attaches a non-throttled scroll listener to the window, which could impact performance. Consider:
+import { throttle } from 'lodash';
+
useEffect(() => {
- const handleScroll = () => closeWithoutDelay();
+ const handleScroll = throttle(() => closeWithoutDelay(), 100);
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
+ handleScroll.cancel();
};
}, [closeWithoutDelay]);📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| if (!isOpen) return null; | ||||||||||||||||||||||||||||||||||||||||
| return <div | ||||||||||||||||||||||||||||||||||||||||
| onPointerEnter={openWithDelay} | ||||||||||||||||||||||||||||||||||||||||
| onPointerLeave={closeWithDelay} | ||||||||||||||||||||||||||||||||||||||||
| className={`${rootClass}`} {...props} | ||||||||||||||||||||||||||||||||||||||||
| ref={floatingRefs.setFloating} | ||||||||||||||||||||||||||||||||||||||||
| style={floatingStyles} | ||||||||||||||||||||||||||||||||||||||||
| {...getFloatingProps()}>{children}</div>; | ||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+31
to
+39
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. 🛠️ Refactor suggestion Enhance accessibility and className handling
if (!isOpen) return null;
return <div
onPointerEnter={openWithDelay}
onPointerLeave={closeWithDelay}
- className={`${rootClass}`} {...props}
+ className={clsx(rootClass, props.className)}
+ role="tooltip"
+ aria-hidden={!isOpen}
ref={floatingRefs.setFloating}
style={floatingStyles}
{...getFloatingProps()}>{children}</div>;
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| export default HoverCardContent; | ||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import React, { useContext } from 'react'; | ||
| import Floater from '~/core/primitives/Floater'; | ||
| import HoverCardContext from '../contexts/HoverCardContext'; | ||
|
|
||
| type HoverCardPortalProps = { | ||
| children: React.ReactNode, | ||
| rootElement: HTMLElement | React.MutableRefObject<HTMLElement | null> | undefined, | ||
| props: React.HTMLAttributes<HTMLElement> | ||
| } | ||
|
|
||
| const HoverCardPortal = ({ children, rootElement = undefined, ...props }: HoverCardPortalProps) => { | ||
| const { rootTriggerClass } = useContext(HoverCardContext); | ||
| const rootElem = rootElement || document.getElementsByClassName(rootTriggerClass)[0] as HTMLElement; | ||
|
|
||
| return <Floater.Portal | ||
| root={rootElem} | ||
| {...props} | ||
| >{children}</Floater.Portal>; | ||
kotAPI marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }; | ||
|
|
||
| export default HoverCardPortal; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| import React, { useState, useRef } from 'react'; | ||
|
|
||
| import HoverCardContext from '../contexts/HoverCardContext'; | ||
| import Floater from '~/core/primitives/Floater'; | ||
| import { customClassSwitcher } from '~/core'; | ||
|
|
||
| const COMPONENT_NAME = 'HoverCard'; | ||
|
|
||
| type HoverCardRootProps = { | ||
| children: React.ReactNode, | ||
| open: boolean | undefined, | ||
| onOpenChange: (open: boolean) => void, | ||
| customRootClass: string, | ||
| openDelay: number, | ||
| closeDelay: number, | ||
| props?: React.HTMLAttributes<HTMLElement> | ||
| } | ||
|
|
||
| const HoverCardRoot = ({ children, open: controlledOpen = undefined, onOpenChange, customRootClass = '', openDelay = 100, closeDelay = 200, ...props }: HoverCardRootProps) => { | ||
| const rootClass = customClassSwitcher(customRootClass, COMPONENT_NAME); | ||
| const rootTriggerClass = customClassSwitcher(customRootClass, `${COMPONENT_NAME}-trigger`); | ||
| const arrowRef = useRef(null); | ||
| const ARROW_HEIGHT = 8; | ||
| const SPACING_GAP = 2; | ||
|
|
||
| const { refs: floatingRefs, floatingStyles, context: floatingContext } = Floater.useFloating({ | ||
| placement: 'bottom', | ||
| strategy: 'fixed', | ||
| middleware: [ | ||
| Floater.arrow({ | ||
| element: arrowRef | ||
| }), | ||
| Floater.offset(ARROW_HEIGHT + SPACING_GAP), | ||
| Floater.flip({ | ||
| mainAxis: true | ||
| }) | ||
| ] | ||
| }); | ||
|
|
||
| const [uncontrolledOpen, setUncontrolledOpen] = useState(false); | ||
|
|
||
| // when hovered out, we set this to true, after delay we check if it's still true and then we set open to false | ||
| const [mouseIsExiting, setMouseIsExiting] = useState(false); | ||
|
|
||
| const isControlled = controlledOpen !== undefined; | ||
| const open = isControlled ? controlledOpen : uncontrolledOpen; | ||
|
|
||
| const handleOpenChange = (newOpen: boolean) => { | ||
| if (!isControlled) { | ||
| setUncontrolledOpen(newOpen); | ||
| } | ||
| onOpenChange?.(newOpen); | ||
| }; | ||
|
|
||
| const role = Floater.useRole(floatingContext); | ||
| const dismiss = Floater.useDismiss(floatingContext); | ||
|
|
||
| const hover = Floater.useHover(floatingContext, { | ||
| delay: 100 | ||
| }); | ||
kotAPI marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const { getReferenceProps, getFloatingProps } = Floater.useInteractions([ | ||
| hover, | ||
| role, | ||
| dismiss | ||
| ]); | ||
|
|
||
| const markMouseIsExiting = () => { | ||
| setMouseIsExiting(true); | ||
| }; | ||
|
|
||
| const markMouseIsEntering = () => { | ||
| setMouseIsExiting(false); | ||
| }; | ||
|
|
||
| const openWithDelay = () => { | ||
| markMouseIsEntering(); | ||
| setTimeout(() => { | ||
| handleOpenChange(true); | ||
| }, openDelay); | ||
| }; | ||
|
|
||
| const closeWithDelay = () => { | ||
| markMouseIsExiting(); | ||
|
|
||
| setTimeout(() => { | ||
| setMouseIsExiting(prevState => { | ||
| if (prevState) { | ||
| handleOpenChange(false); | ||
| } | ||
| return prevState; | ||
| }); | ||
| }, closeDelay); | ||
| }; | ||
kotAPI marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const closeWithoutDelay = () => { | ||
| handleOpenChange(false); | ||
| }; | ||
|
|
||
| const sendValues = { | ||
| isOpen: open, | ||
| handleOpenChange, | ||
| floatingRefs, | ||
| floatingStyles, | ||
| floatingContext, | ||
| arrowRef, | ||
| getReferenceProps, | ||
| getFloatingProps, | ||
| rootClass, | ||
| rootTriggerClass, | ||
| closeWithDelay, | ||
| closeWithoutDelay, | ||
| openWithDelay | ||
| }; | ||
|
|
||
| return <HoverCardContext.Provider value={sendValues}> | ||
| <div className={rootClass} {...props}>{children}</div> | ||
| </HoverCardContext.Provider>; | ||
| }; | ||
|
|
||
| export default HoverCardRoot; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import React, { useContext } from 'react'; | ||
|
|
||
| import HoverCardContext from '../contexts/HoverCardContext'; | ||
|
|
||
| import Primitive from '~/core/primitives/Primitive'; | ||
|
|
||
| type HoverCardTriggerProps = { | ||
| children: React.ReactNode, | ||
| props?: React.HTMLAttributes<HTMLElement> | ||
| } | ||
|
|
||
| const HoverCardTrigger = ({ children, className = '', ...props }: HoverCardTriggerProps) => { | ||
| const { floatingRefs, closeWithDelay, openWithDelay, rootTriggerClass } = useContext(HoverCardContext); | ||
|
|
||
| return <> | ||
| <Primitive.span | ||
| className={`${rootTriggerClass} ${className}`} | ||
| onClick={() => {}} | ||
| onMouseEnter={openWithDelay} onMouseLeave={closeWithDelay} | ||
| ref={floatingRefs.setReference} | ||
| {...props} | ||
| >{children}</Primitive.span> | ||
| </>; | ||
| }; | ||
|
|
||
| export default HoverCardTrigger; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import HoverCard from '../HoverCard'; | ||
| import SandboxEditor from '~/components/tools/SandboxEditor/SandboxEditor'; | ||
| import Button from '~/components/ui/Button/Button'; | ||
|
|
||
| // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export | ||
| export default { | ||
| title: 'Components/HoverCard', | ||
| component: HoverCard, | ||
| render: (args) => { | ||
| const Content = () => { | ||
| return <div> | ||
| <div className=' space-y-2'> | ||
| The quick brown fox jumps over the lazy dog | ||
| </div> | ||
| </div>; | ||
| }; | ||
| return <SandboxEditor className='bg-gray-200 h-[400px] flex items-center justify-center'> | ||
| <HoverCard className='text-gray-900 text-center' content={<Content />} {...args} > | ||
| <div className="p-10 bg-gray-100 rounded-md shadow">Hover me</div> | ||
| </HoverCard> | ||
| </SandboxEditor>; | ||
| } | ||
| }; | ||
|
|
||
| // More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args | ||
| export const All = { | ||
|
|
||
| }; | ||
|
|
||
| export const Controlled = { | ||
| args: { | ||
| open: true | ||
| } | ||
| }; |
Uh oh!
There was an error while loading. Please reload this page.