diff --git a/frontend/app/element/tooltip.scss b/frontend/app/element/tooltip.scss new file mode 100644 index 000000000..5012c90dc --- /dev/null +++ b/frontend/app/element/tooltip.scss @@ -0,0 +1,10 @@ +// 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: 4px 8px; + border-radius: 4px; +} \ No newline at end of file diff --git a/frontend/app/element/tooltip.stories.tsx b/frontend/app/element/tooltip.stories.tsx new file mode 100644 index 000000000..8d9b801c4 --- /dev/null +++ b/frontend/app/element/tooltip.stories.tsx @@ -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 = { + 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; + +export const Uncontrolled: Story = { + render: (args) => ( +
+
+ + Top + Top Tooltip + + + Left + Left Tooltip + + + Bottom + Bottom Tooltip + + + Right + Right Tooltip + +
+
+ ), + 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 ( +
+
+ + setOpen((v) => !v)}>My Trigger + My tooltip + +
+
+ ); + }, + args: { + placement: "top", + className: "custom-tooltip", + showArrow: true, + }, +}; diff --git a/frontend/app/element/tooltip.tsx b/frontend/app/element/tooltip.tsx new file mode 100644 index 000000000..6d73ed515 --- /dev/null +++ b/frontend/app/element/tooltip.tsx @@ -0,0 +1,191 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +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 function 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] + ); +} + +type ContextType = ReturnType | null; + +const TooltipContext = React.createContext(null); + +export const useTooltipState = () => { + const context = React.useContext(TooltipContext); + + if (context == null) { + throw new Error("Tooltip components must be wrapped in "); + } + + return context; +}; + +export function 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 {children}; +} + +export const TooltipTrigger = React.forwardRef & { 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).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).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 ( + + ); + } +); + +export const TooltipContent = React.forwardRef>( + 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).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 ( + + {state.open && ( +
+ {props.children} +
+
+ )} + + ); + } +);