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
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
11 changes: 11 additions & 0 deletions frontend/app/element/tooltip.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

.tooltip {
width: max-content;
background-color: rgb(from var(--block-bg-color) r g b / 70%);
color: var(--main-text-color);
padding: 8px 10px;
border-radius: 4px;
font-size: 12px;
}
113 changes: 113 additions & 0 deletions frontend/app/element/tooltip.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";

import "./tooltip.scss";

const meta: Meta<typeof Tooltip> = {
title: "Elements/Tooltip",
component: Tooltip,
argTypes: {
placement: {
description: "Placement of the tooltip relative to the trigger",
control: {
type: "select",
options: ["top", "left", "bottom", "right"],
},
},
className: {
description: "Custom class for styling the tooltip content",
control: { type: "text" },
},
initialOpen: {
description: "Initial open state of the tooltip (uncontrolled mode)",
control: { type: "boolean" },
},
open: {
description: "Controlled open state of the tooltip",
control: { type: "boolean" },
},
showArrow: {
description: "Whether to show an arrow for the tooltip",
control: { type: "boolean" },
},
},
};

export default meta;
type Story = StoryObj<typeof Tooltip>;

export const Uncontrolled: Story = {
render: (args) => (
<div
style={{
width: "100%",
height: "600px",
padding: "20px",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
}}
>
<div className="App">
<Tooltip {...args}>
<TooltipTrigger>Top</TooltipTrigger>
<TooltipContent className="tooltip">Top Tooltip</TooltipContent>
</Tooltip>
<Tooltip {...args} placement="left">
<TooltipTrigger>Left</TooltipTrigger>
<TooltipContent className="tooltip">Left Tooltip</TooltipContent>
</Tooltip>
<Tooltip {...args} placement="bottom">
<TooltipTrigger>Bottom</TooltipTrigger>
<TooltipContent className="tooltip">Bottom Tooltip</TooltipContent>
</Tooltip>
<Tooltip {...args} placement="right">
<TooltipTrigger>Right</TooltipTrigger>
<TooltipContent className="tooltip">Right Tooltip</TooltipContent>
</Tooltip>
</div>
</div>
),
args: {
initialOpen: false,
placement: "top",
className: "custom-tooltip",
showArrow: true,
},
};

// Controlled Tooltip Example
export const Controlled: Story = {
render: (args) => {
const [open, setOpen] = useState(false);

return (
<div
style={{
width: "100%",
height: "600px",
padding: "20px",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
}}
>
<div className="App">
<Tooltip {...args} open={open} onOpenChange={setOpen}>
<TooltipTrigger onClick={() => setOpen((v) => !v)}>My Trigger</TooltipTrigger>
<TooltipContent className="tooltip">My tooltip</TooltipContent>
</Tooltip>
</div>
</div>
);
},
args: {
placement: "top",
className: "custom-tooltip",
showArrow: true,
},
};
197 changes: 197 additions & 0 deletions frontend/app/element/tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0

import { Button } from "@/element/button";
import type { Placement } from "@floating-ui/react";
import {
arrow,
autoUpdate,
flip,
FloatingPortal,
offset,
shift,
useDismiss,
useFloating,
useFocus,
useHover,
useInteractions,
useRole,
} from "@floating-ui/react";
import * as React from "react";

interface TooltipOptions {
initialOpen?: boolean;
placement?: Placement;
open?: boolean;
className?: string;
showArrow?: boolean;
onOpenChange?: (open: boolean) => void;
}

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]
);
};
Comment on lines +31 to +75
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.


type ContextType = ReturnType<typeof useTooltip> | null;

const TooltipContext = React.createContext<ContextType>(null);

export const useTooltipState = () => {
const context = React.useContext(TooltipContext);

if (context == null) {
throw new Error("Tooltip components must be wrapped in <Tooltip />");
}

return context;
};

export const Tooltip = ({ children, ...options }: { children: React.ReactNode } & TooltipOptions) => {
// This can accept any props as options, e.g. `placement`,
// or other positioning options.
const tooltip = useTooltip(options);
return <TooltipContext.Provider value={tooltip}>{children}</TooltipContext.Provider>;
};

export const TooltipTrigger = React.forwardRef<HTMLElement, React.HTMLProps<HTMLElement> & { asChild?: boolean }>(
function TooltipTrigger({ children, asChild = false, ...props }, propRef) {
const state = useTooltipState();

const setRefs = (node: HTMLElement | null) => {
state.refs.setReference(node); // Use Floating UI's ref for trigger
if (typeof propRef === "function") propRef(node);
else if (propRef) (propRef as React.MutableRefObject<HTMLElement | null>).current = node;

// Handle child ref only if it's not a ReactPortal
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;
}
};

// Allow custom elements with asChild
if (asChild && React.isValidElement(children)) {
return React.cloneElement(
children,
state.getReferenceProps({
ref: setRefs,
...props,
...children.props,
"data-state": state.open ? "open" : "closed",
})
);
}

// Default trigger as a button
return (
<Button
className="grey"
ref={setRefs}
data-state={state.open ? "open" : "closed"}
{...state.getReferenceProps(props)}
>
{children}
</Button>
);
}
);

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>
);
}
);
Comment on lines +141 to +197
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>
);
}
);

Loading