diff --git a/.changeset/tricky-actors-worry.md b/.changeset/tricky-actors-worry.md new file mode 100644 index 000000000..4ccd944a1 --- /dev/null +++ b/.changeset/tricky-actors-worry.md @@ -0,0 +1,5 @@ +--- +"@blobscan/web": patch +--- + +Added additional logic to the `Tooltip` component to prevent overflow. diff --git a/apps/web/package.json b/apps/web/package.json index 97996dfd3..e5240e485 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "@blobscan/env": "workspace:^0.0.1", "@blobscan/eth-units": "workspace:^0.0.1", "@blobscan/open-telemetry": "workspace:^0.0.8", + "@floating-ui/react": "^0.26.23", "@blobscan/rollups": "workspace:^0.0.1", "@fontsource/inter": "^4.5.15", "@fontsource/public-sans": "^4.5.12", diff --git a/apps/web/src/components/CopyToClipboard.tsx b/apps/web/src/components/CopyToClipboard.tsx index f6a4bff4f..fc37c3c54 100644 --- a/apps/web/src/components/CopyToClipboard.tsx +++ b/apps/web/src/components/CopyToClipboard.tsx @@ -1,9 +1,8 @@ -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { CheckIcon } from "@heroicons/react/24/outline"; -import { useHover } from "~/hooks/useHover"; import Copy from "~/icons/copy.svg"; -import { Tooltip } from "./Tooltip"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./Tooltip"; type CopyToClipboardProps = { label?: string; @@ -15,29 +14,34 @@ export function CopyToClipboard({ value, }: CopyToClipboardProps) { const [isCopied, setIsCopied] = useState(false); - const buttonRef = useRef(null); - const isHovered = useHover(buttonRef); - useEffect(() => setIsCopied(false), [isHovered]); return ( - // TODO: Use Button component - + {isCopied ? "Copied!" : label} + { + try { + await navigator.clipboard.writeText(value); + setIsCopied(true); + } catch (error) { + console.error("Failed to copy to clipboard", error); + } + }} + > + {isCopied ? ( + + ) : ( + + )} + + ); } diff --git a/apps/web/src/components/Tooltip.tsx b/apps/web/src/components/Tooltip.tsx index 0f93e8889..1f8935863 100644 --- a/apps/web/src/components/Tooltip.tsx +++ b/apps/web/src/components/Tooltip.tsx @@ -1,21 +1,173 @@ +import { + cloneElement, + createContext, + forwardRef, + isValidElement, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import type { ReactNode } from "react"; +import { + useFloating, + autoUpdate, + offset, + flip, + shift, + useHover, + useFocus, + useDismiss, + useRole, + useInteractions, + FloatingPortal, + FloatingArrow, + arrow, + useMergeRefs, +} from "@floating-ui/react"; -type TooltipProps = { - show: boolean; +type TooltipOptions = { children: ReactNode; + onChange?: (isOpen: boolean) => void; }; -export function Tooltip({ show, children }: TooltipProps) { +function useTooltip({ onChange }: TooltipOptions) { + const [isOpen, setIsOpen] = useState(false); + + const arrowRef = useRef(null); + + const data = useFloating({ + placement: "top", + open: isOpen, + onOpenChange: setIsOpen, + whileElementsMounted: autoUpdate, + middleware: [ + shift(), + offset(10), + flip({ fallbackAxisSideDirection: "start" }), + arrow({ element: arrowRef }), + ], + }); + + const context = data.context; + + const hover = useHover(context, { move: false }); + const focus = useFocus(context); + const dismiss = useDismiss(context); + const role = useRole(context, { role: "tooltip" }); + + const interactions = useInteractions([hover, focus, dismiss, role]); + + useEffect(() => { + if (onChange) { + onChange(isOpen); + } + }, [isOpen, onChange]); + + return useMemo( + () => ({ + isOpen, + setIsOpen, + ...interactions, + ...data, + arrowRef, + }), + [isOpen, setIsOpen, interactions, data, arrowRef] + ); +} + +type ContextType = ReturnType | null; + +const TooltipContext = createContext(null); + +export const useTooltipContext = () => { + const context = useContext(TooltipContext); + + if (context == null) { + throw new Error("Tooltip components must be wrapped in "); + } + + return context; +}; + +export function Tooltip(options: TooltipOptions) { + const tooltip = useTooltip(options); + + return ( + + {options.children} + + ); +} + +export const TooltipTrigger = forwardRef< + HTMLElement, + React.HTMLProps & { asChild?: boolean } +>(function TooltipTrigger({ children, asChild = false, ...props }, propRef) { + const context = useTooltipContext(); + + const childrenRef = + isValidElement(children) && "ref" in children + ? (children.ref as React.Ref) + : null; + + const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]); + + if (asChild && isValidElement(children)) { + return cloneElement( + children, + context.getReferenceProps({ + ref, + ...props, + ...children.props, + "data-state": context.isOpen ? "open" : "closed", + }) + ); + } + return ( -
-
- {children} + {children} + + ); +}); + +export const TooltipContent = forwardRef< + HTMLDivElement, + React.HTMLProps +>(function TooltipContent({ style, children, ...props }, propRef) { + const context = useTooltipContext(); + const ref = useMergeRefs([context.refs.setFloating, propRef]); + + if (!context.isOpen) { + return null; + } + + return ( + +
+
+ {children} +
+ +
-
-
+
); -} +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 530424142..e681bf38d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,6 +256,9 @@ importers: '@blobscan/open-telemetry': specifier: workspace:^0.0.8 version: link:../../packages/open-telemetry + '@floating-ui/react': + specifier: ^0.26.23 + version: 0.26.23(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@blobscan/rollups': specifier: workspace:^0.0.1 version: link:../../packages/rollups @@ -1835,14 +1838,14 @@ packages: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/react@0.26.19': - resolution: {integrity: sha512-Jk6zITdjjIvjO/VdQFvpRaD3qPwOHH6AoDHxjhpy+oK4KFgaSP871HYWUAPdnLmx1gQ+w/pB312co3tVml+BXA==} + '@floating-ui/react@0.26.23': + resolution: {integrity: sha512-9u3i62fV0CFF3nIegiWiRDwOs7OW/KhSUJDNx2MkQM3LbE5zQOY01sL3nelcVBXvX7Ovvo3A49I8ql+20Wg/Hw==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/utils@0.2.4': - resolution: {integrity: sha512-dWO2pw8hhi+WrXq1YJy2yCuWoL20PddgGaqTgVe4cOS9Q6qklXCiA1tJEqX6BEwRNSCP84/afac9hd4MS+zEUA==} + '@floating-ui/utils@0.2.7': + resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==} '@fontsource/inter@4.5.15': resolution: {integrity: sha512-FzleM9AxZQK2nqsTDtBiY0PMEVWvnKnuu2i09+p6DHvrHsuucoV2j0tmw+kAT3L4hvsLdAIDv6MdGehsPIdT+Q==} @@ -8451,12 +8454,12 @@ snapshots: '@floating-ui/core@1.6.4': dependencies: - '@floating-ui/utils': 0.2.4 + '@floating-ui/utils': 0.2.7 '@floating-ui/dom@1.6.7': dependencies: '@floating-ui/core': 1.6.4 - '@floating-ui/utils': 0.2.4 + '@floating-ui/utils': 0.2.7 '@floating-ui/react-dom@2.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: @@ -8464,15 +8467,15 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - '@floating-ui/react@0.26.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + '@floating-ui/react@0.26.23(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@floating-ui/react-dom': 2.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - '@floating-ui/utils': 0.2.4 + '@floating-ui/utils': 0.2.7 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) tabbable: 6.2.0 - '@floating-ui/utils@0.2.4': {} + '@floating-ui/utils@0.2.7': {} '@fontsource/inter@4.5.15': {} @@ -8525,7 +8528,7 @@ snapshots: '@headlessui/react@2.1.2(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@floating-ui/react': 0.26.19(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@floating-ui/react': 0.26.23(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@react-aria/focus': 3.17.1(react@18.2.0) '@react-aria/interactions': 3.21.3(react@18.2.0) '@tanstack/react-virtual': 3.8.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)