Skip to content
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

Merged
merged 6 commits into from
Dec 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/components/tools/Popper/Popper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,11 @@ const Popper = ({
}
}
)}>{children}</span>
{isOpen && <div className={`${rootClass}-floating-element`} ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()} >
{showArrow && <FloatingArrow className={`rad-ui-arrow ${rootClass}-arrow`} ref={arrowRef} context={context} />}
{pop}</div>}
{
isOpen && <div className={`${rootClass}-floating-element`} ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()} >
{showArrow && <FloatingArrow className={`rad-ui-arrow ${rootClass}-arrow`} ref={arrowRef} context={context} />}
{pop}</div>
}
</span>;
};

Expand Down
17 changes: 2 additions & 15 deletions src/components/ui/AlertDialog/fragments/AlertDialogPortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,11 @@ export type AlertDialogPortalProps = {

const AlertDialogPortal = ({ children }: AlertDialogPortalProps) => {
const { rootClass } = useContext(AlertDialogContext);
const rootElement = document.getElementsByClassName(
rootClass
)[0] as HTMLElement | null;
const rootElement = document.getElementsByClassName(rootClass)[0] as HTMLElement | null;

return (
<Floater.Portal
root={
rootElement ||
(() => {
if (process.env.NODE_ENV === 'development') {
console.warn(
`AlertDialog: No element found with class "${rootClass}". ` +
'Falling back to document.body. Dark mode styling may not work correctly.'
);
}
return document.body;
})()
}
root={rootElement || document.body}
>
{children}
</Floater.Portal>
Expand Down
50 changes: 50 additions & 0 deletions src/components/ui/HoverCard/HoverCard.tsx
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>,
}
kotAPI marked this conversation as resolved.
Show resolved Hide resolved

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;
24 changes: 24 additions & 0 deletions src/components/ui/HoverCard/contexts/HoverCardContext.tsx
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;
12 changes: 12 additions & 0 deletions src/components/ui/HoverCard/fragments/HoverCardArrow.tsx
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
Copy link
Contributor

Choose a reason for hiding this comment

The 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const HoverCardArrow = ({ ...props }) => {
const { floatingContext, arrowRef, rootClass } = useContext(HoverCardContext);
return <Floater.Arrow className={`${rootClass}-arrow`} {...props} context={floatingContext} ref={arrowRef} />;
};
interface HoverCardArrowProps extends React.ComponentPropsWithoutRef<typeof Floater.Arrow> {}
const HoverCardArrow = ({ className, ...props }: HoverCardArrowProps) => {
const { floatingContext, arrowRef, rootClass } = useContext(HoverCardContext);
return (
<Floater.Arrow
className={className ? `${rootClass}-arrow ${className}` : `${rootClass}-arrow`}
{...props}
context={floatingContext}
ref={arrowRef}
/>
);
};


export default HoverCardArrow;
41 changes: 41 additions & 0 deletions src/components/ui/HoverCard/fragments/HoverCardContent.tsx
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
Copy link
Contributor

Choose a reason for hiding this comment

The 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:

  1. Adding throttling to the scroll handler
  2. Using a more specific scroll container if possible
+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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
const handleScroll = () => closeWithoutDelay();
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [closeWithoutDelay]);
import { throttle } from 'lodash';
useEffect(() => {
const handleScroll = throttle(() => closeWithoutDelay(), 100);
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
handleScroll.cancel();
};
}, [closeWithoutDelay]);


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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Enhance accessibility and className handling

  1. Add ARIA attributes for better accessibility
  2. Use a proper className handling utility
  3. Consider debouncing pointer events
     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>;

Committable suggestion skipped: line range outside the PR's diff.


export default HoverCardContent;
21 changes: 21 additions & 0 deletions src/components/ui/HoverCard/fragments/HoverCardPortal.tsx
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;
121 changes: 121 additions & 0 deletions src/components/ui/HoverCard/fragments/HoverCardRoot.tsx
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;
26 changes: 26 additions & 0 deletions src/components/ui/HoverCard/fragments/HoverCardTrigger.tsx
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;
34 changes: 34 additions & 0 deletions src/components/ui/HoverCard/stories/HoverCard.stories.js
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
}
};
Loading