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

tooltip component #1550

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open

tooltip component #1550

wants to merge 3 commits into from

Conversation

adred
Copy link
Member

@adred adred commented Dec 18, 2024

Summary by CodeRabbit

  • New Features

    • Introduced a new tooltip component with customizable options for display and interaction.
    • Added styles for the tooltip component, including responsive width and opacity settings.
    • Created Storybook examples for both uncontrolled and controlled tooltip usage.
  • Bug Fixes

    • Implemented error handling to ensure tooltip components are properly wrapped in the provider.

Copy link

coderabbitai bot commented Dec 18, 2024

Walkthrough

A new tooltip component system has been implemented in the frontend, introducing a comprehensive solution for displaying tooltips using React and Floating UI. The implementation includes a flexible Tooltip component with support for both controlled and uncontrolled states, custom positioning, and advanced interaction handling. The system comprises a custom hook, context management, trigger, and content components, along with accompanying Storybook stories and SCSS styling.

Changes

File Change Summary
frontend/app/element/tooltip.scss Added new SCSS file defining .tooltip class with styling properties including width, background color, text color, padding, and border radius.
frontend/app/element/tooltip.stories.tsx Created Storybook stories showcasing Uncontrolled and Controlled tooltip behaviors with various positioning and interaction examples.
frontend/app/element/tooltip.tsx Implemented comprehensive tooltip component system with useTooltip hook, TooltipContext, TooltipTrigger, and TooltipContent components.

Sequence Diagram

sequenceDiagram
    participant User
    participant TooltipTrigger
    participant TooltipContent
    participant FloatingUI

    User->>TooltipTrigger: Hover/Focus
    TooltipTrigger->>FloatingUI: Request Positioning
    FloatingUI-->>TooltipContent: Calculate Placement
    TooltipContent->>User: Display Tooltip
Loading

Poem

🐰 Tooltips dance with grace and might,
Floating UI makes everything right!
Hover, click, and watch them gleam,
A rabbit's coding fever dream!
Tooltips whisper secrets so bright 🌟


🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR. (Beta)
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (3)
frontend/app/element/tooltip.tsx (1)

43-49: Check arrow middleware offset
In lines 43-49, you apply an offset of 5px, then set the arrow with a 5px offset (line 188) to avoid the tooltip arrow overlapping the trigger. Verify that these values are cohesive for all placements. Consider making them configurable or referencing a single constant for maintainability.

frontend/app/element/tooltip.stories.tsx (2)

43-81: Uncontrolled story usage
In lines 43-81, the Uncontrolled story demonstrates internal open-state handling. This clearly shows how the tooltip behaves without explicit external control. Consider adding a minimal delay or a comment reference if the usability of the tooltip changes with heavier content or transitions.


83-113: Ensure onOpenChange is documented
Lines 83-113 demonstrate a controlled approach and call onOpenChange from user clicks. This is valuable for complex usage scenarios. Consider adding a unique knobs/control in Storybook for the open state, so that users can see the effect of toggling the tooltip externally.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 69bb1d4 and e4bda65.

📒 Files selected for processing (3)
  • frontend/app/element/tooltip.scss (1 hunks)
  • frontend/app/element/tooltip.stories.tsx (1 hunks)
  • frontend/app/element/tooltip.tsx (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • frontend/app/element/tooltip.scss
🔇 Additional comments (5)
frontend/app/element/tooltip.tsx (4)

31-42: Validate controlled vs uncontrolled state logic
Having both controlled and uncontrolled states in lines 31-42 is a great approach for flexibility. However, ensure that the open prop and initialOpen prop do not conflict. If both are provided, the controlled state on line 40 will override the uncontrolled state. This is intended but might cause confusion for users if not adequately documented.


81-89: Ensure consistent usage of TooltipContext
The check in lines 84-85 ensures that the consumer of the tooltip is properly nested. This pattern is solid for preventing misuse. Also make sure you handle any top-level usage that might attempt to render a tooltip outside of a provider.


98-139: Ref safety for child components
In lines 98-113, you correctly handle referencing logic for child components by merging the child’s ref with the Floating UI reference. This is very good. However, it's important to be cautious when children are React Fragments or portal-based elements, ensuring no errors occur if the child doesn't forward refs.


141-197: Arrow styling and separate concerns
In lines 141-197, the arrow's background is inherited from the tooltip (line 185). Consider a separate styling approach (like a pseudo-element) for the tooltip arrow, making arrow styling more flexible. Also, check if the arrow remains visible on lighter backgrounds.

frontend/app/element/tooltip.stories.tsx (1)

10-38: Comprehensive argTypes
Lines 10-38 define a wide range of controls (placement, className, etc.). This is an excellent practice, as it empowers non-technical stakeholders to experiment with the component in Storybook.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (4)
frontend/app/element/tooltip.tsx (4)

22-30: Add JSDoc comments to improve documentation.

Consider adding JSDoc comments to document the purpose and usage of each option in the TooltipOptions interface.

 interface TooltipOptions {
+    /** Initial open state for uncontrolled mode */
     initialOpen?: boolean;
+    /** Preferred placement of the tooltip */
     placement?: Placement;
+    /** Controlled open state */
     open?: boolean;
+    /** Additional CSS class names */
     className?: string;
+    /** Whether to show the arrow pointer */
     showArrow?: boolean;
+    /** Callback for open state changes */
     onOpenChange?: (open: boolean) => void;
 }

91-96: Pass className to the wrapper element.

The className option is not being applied to any element. Consider wrapping the children in a div with the provided className.

 export const Tooltip = ({ children, ...options }: { children: React.ReactNode } & TooltipOptions) => {
     const tooltip = useTooltip(options);
-    return <TooltipContext.Provider value={tooltip}>{children}</TooltipContext.Provider>;
+    return (
+        <TooltipContext.Provider value={tooltip}>
+            <div className={options.className}>{children}</div>
+        </TooltipContext.Provider>
+    );
 };

98-139: Consider improvements to the trigger component.

  1. The "grey" className is hardcoded for the default button, making it less flexible.
  2. The ref handling logic is complex and could benefit from being extracted into a separate utility.
+// Extract ref handling to a utility
+const createRefHandler = (
+    setReference: (node: HTMLElement | null) => void,
+    propRef: React.ForwardedRef<HTMLElement>,
+    childRef?: React.Ref<HTMLElement>
+) => (node: HTMLElement | null) => {
+    setReference(node);
+    if (typeof propRef === "function") propRef(node);
+    else if (propRef) (propRef as React.MutableRefObject<HTMLElement | null>).current = node;
+    if (childRef) {
+        if (typeof childRef === "function") childRef(node);
+        else (childRef as React.MutableRefObject<HTMLElement | null>).current = node;
+    }
+};

 export const TooltipTrigger = React.forwardRef<HTMLElement, React.HTMLProps<HTMLElement> & {
     asChild?: boolean;
+    buttonClassName?: string;  // Add prop for button className
 }>(
-    function TooltipTrigger({ children, asChild = false, ...props }, propRef) {
+    function TooltipTrigger({ children, asChild = false, buttonClassName = "grey", ...props }, propRef) {
         const state = useTooltipState();

-        const setRefs = (node: HTMLElement | null) => {
-            state.refs.setReference(node);
-            if (typeof propRef === "function") propRef(node);
-            else if (propRef) (propRef as React.MutableRefObject<HTMLElement | null>).current = node;
-
-            if (React.isValidElement(children) && children.type !== React.Fragment && "ref" in children) {
-                if (typeof children.ref === "function") children.ref(node);
-                else (children.ref as React.MutableRefObject<HTMLElement | null>).current = node;
-            }
-        };
+        const childRef = React.isValidElement(children) && children.type !== React.Fragment && "ref" in children
+            ? (children as any).ref
+            : undefined;
+        const setRefs = createRefHandler(state.refs.setReference, propRef, childRef);

         if (asChild && React.isValidElement(children)) {
             // ... rest of the code
         }

         return (
             <Button
-                className="grey"
+                className={buttonClassName}
                 ref={setRefs}
                 // ... rest of the code
             >

1-197: Overall architectural assessment is positive.

The tooltip implementation is well-structured and follows React best practices. It provides a flexible API with both controlled and uncontrolled modes, uses modern React patterns, and leverages the powerful Floating UI library for positioning. The component composition is clean, with clear separation of concerns between the hook, context, and individual components.

Consider adding:

  1. Unit tests to verify the behavior in both controlled and uncontrolled modes
  2. Storybook stories to showcase different configurations
  3. Performance optimization by memoizing callbacks and values where appropriate
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e4bda65 and bd3cb70.

📒 Files selected for processing (2)
  • frontend/app/element/tooltip.scss (1 hunks)
  • frontend/app/element/tooltip.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/app/element/tooltip.scss
🔇 Additional comments (2)
frontend/app/element/tooltip.tsx (2)

1-21: LGTM: Imports and license header are properly structured.

The imports are well-organized and the license header is correctly formatted.


76-89: LGTM: Well-implemented context and state management.

The context implementation follows React best practices with proper error handling for missing context providers.

Comment on lines +31 to +75
export const useTooltip = ({
initialOpen = false,
placement = "top",
open: controlledOpen,
onOpenChange: setControlledOpen,
}: TooltipOptions = {}) => {
const arrowRef = React.useRef(null);
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen);

const open = controlledOpen ?? uncontrolledOpen;
const setOpen = setControlledOpen ?? setUncontrolledOpen;

const data = useFloating({
placement,
open,
onOpenChange: setOpen,
whileElementsMounted: autoUpdate,
middleware: [offset(5), flip(), shift(), arrow({ element: arrowRef })],
});

const context = data.context;

const hover = useHover(context, {
move: false,
enabled: controlledOpen == null,
});
const focus = useFocus(context, {
enabled: controlledOpen == null,
});
const dismiss = useDismiss(context);
const role = useRole(context, { role: "tooltip" });

const interactions = useInteractions([hover, focus, dismiss, role]);

return React.useMemo(
() => ({
open,
setOpen,
arrowRef,
...interactions,
...data,
}),
[open, setOpen, arrowRef, interactions, data]
);
};
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Consider implementing unused options and optimizing arrow middleware.

  1. The className and showArrow options from TooltipOptions are not being used.
  2. The arrow middleware is always added regardless of the showArrow option.
 const data = useFloating({
     placement,
     open,
     onOpenChange: setOpen,
     whileElementsMounted: autoUpdate,
-    middleware: [offset(5), flip(), shift(), arrow({ element: arrowRef })],
+    middleware: [
+        offset(5),
+        flip(),
+        shift(),
+        ...(options.showArrow ? [arrow({ element: arrowRef })] : []),
+    ],
 });

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

Comment on lines +141 to +197
export const TooltipContent = React.forwardRef<HTMLDivElement, React.HTMLProps<HTMLDivElement>>(
function TooltipContent(props, propRef) {
const state = useTooltipState();

const ref = React.useMemo(() => {
const setRef = (node: HTMLDivElement | null) => {
state.refs.setFloating(node); // Use `refs.setFloating` from `useFloating`
if (typeof propRef === "function") propRef(node);
else if (propRef) (propRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
};
return setRef;
}, [state.refs.setFloating, propRef]);

const { x: arrowX, y: arrowY } = state.middlewareData.arrow ?? {};

const staticSide =
{
top: "bottom",
right: "left",
bottom: "top",
left: "right",
}[state.placement.split("-")[0]] ?? "";

return (
<FloatingPortal>
{state.open && (
<div
ref={ref}
style={{
position: state.strategy,
top: state.y ?? 0,
left: state.x ?? 0,
visibility: state.x == null ? "hidden" : "visible",
...props.style,
}}
{...state.getFloatingProps(props)}
>
{props.children}
<div
ref={state.arrowRef}
style={{
position: "absolute",
width: "10px",
height: "10px",
background: "inherit",
left: arrowX != null ? `${arrowX}px` : "",
top: arrowY != null ? `${arrowY}px` : "",
[staticSide]: "-5px",
transform: "rotate(45deg)",
}}
/>
</div>
)}
</FloatingPortal>
);
}
);
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Improve accessibility and respect showArrow option.

  1. The arrow is always rendered regardless of the showArrow option.
  2. Missing ARIA attributes for better accessibility.
 export const TooltipContent = React.forwardRef<HTMLDivElement, React.HTMLProps<HTMLDivElement>>(
     function TooltipContent(props, propRef) {
         const state = useTooltipState();
+        const { showArrow } = state;  // Get showArrow from state

         // ... rest of the code

         return (
             <FloatingPortal>
                 {state.open && (
                     <div
                         ref={ref}
+                        role="tooltip"
+                        aria-label={typeof props.children === 'string' ? props.children : undefined}
                         style={{
                             position: state.strategy,
                             top: state.y ?? 0,
                             left: state.x ?? 0,
                             visibility: state.x == null ? "hidden" : "visible",
                             ...props.style,
                         }}
                         {...state.getFloatingProps(props)}
                     >
                         {props.children}
+                        {showArrow && (
                         <div
                             ref={state.arrowRef}
                             style={{
                                 position: "absolute",
                                 width: "10px",
                                 height: "10px",
                                 background: "inherit",
                                 left: arrowX != null ? `${arrowX}px` : "",
                                 top: arrowY != null ? `${arrowY}px` : "",
                                 [staticSide]: "-5px",
                                 transform: "rotate(45deg)",
                             }}
                         />
+                        )}
                     </div>
                 )}
             </FloatingPortal>
         );
     }
 );
📝 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
export const TooltipContent = React.forwardRef<HTMLDivElement, React.HTMLProps<HTMLDivElement>>(
function TooltipContent(props, propRef) {
const state = useTooltipState();
const ref = React.useMemo(() => {
const setRef = (node: HTMLDivElement | null) => {
state.refs.setFloating(node); // Use `refs.setFloating` from `useFloating`
if (typeof propRef === "function") propRef(node);
else if (propRef) (propRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
};
return setRef;
}, [state.refs.setFloating, propRef]);
const { x: arrowX, y: arrowY } = state.middlewareData.arrow ?? {};
const staticSide =
{
top: "bottom",
right: "left",
bottom: "top",
left: "right",
}[state.placement.split("-")[0]] ?? "";
return (
<FloatingPortal>
{state.open && (
<div
ref={ref}
style={{
position: state.strategy,
top: state.y ?? 0,
left: state.x ?? 0,
visibility: state.x == null ? "hidden" : "visible",
...props.style,
}}
{...state.getFloatingProps(props)}
>
{props.children}
<div
ref={state.arrowRef}
style={{
position: "absolute",
width: "10px",
height: "10px",
background: "inherit",
left: arrowX != null ? `${arrowX}px` : "",
top: arrowY != null ? `${arrowY}px` : "",
[staticSide]: "-5px",
transform: "rotate(45deg)",
}}
/>
</div>
)}
</FloatingPortal>
);
}
);
export const TooltipContent = React.forwardRef<HTMLDivElement, React.HTMLProps<HTMLDivElement>>(
function TooltipContent(props, propRef) {
const state = useTooltipState();
const { showArrow } = state; // Get showArrow from state
const ref = React.useMemo(() => {
const setRef = (node: HTMLDivElement | null) => {
state.refs.setFloating(node); // Use `refs.setFloating` from `useFloating`
if (typeof propRef === "function") propRef(node);
else if (propRef) (propRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
};
return setRef;
}, [state.refs.setFloating, propRef]);
const { x: arrowX, y: arrowY } = state.middlewareData.arrow ?? {};
const staticSide =
{
top: "bottom",
right: "left",
bottom: "top",
left: "right",
}[state.placement.split("-")[0]] ?? "";
return (
<FloatingPortal>
{state.open && (
<div
ref={ref}
role="tooltip"
aria-label={typeof props.children === 'string' ? props.children : undefined}
style={{
position: state.strategy,
top: state.y ?? 0,
left: state.x ?? 0,
visibility: state.x == null ? "hidden" : "visible",
...props.style,
}}
{...state.getFloatingProps(props)}
>
{props.children}
{showArrow && (
<div
ref={state.arrowRef}
style={{
position: "absolute",
width: "10px",
height: "10px",
background: "inherit",
left: arrowX != null ? `${arrowX}px` : "",
top: arrowY != null ? `${arrowY}px` : "",
[staticSide]: "-5px",
transform: "rotate(45deg)",
}}
/>
)}
</div>
)}
</FloatingPortal>
);
}
);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant