Skip to content

Commit

Permalink
Prevent tooltip overflow (#507)
Browse files Browse the repository at this point in the history
* refactor: Prevent tooltip overflow

* chore: update changeset

* refactor: Update Tooltip component to use @floating-ui/react library

* feat: Refactor CopyToClipboard component

* chore: Remove comment in CopyToClipboard component

* chore: Add padding to tooltip

* chore: Update npm dependencies to include @floating-ui/react library

* Update .changeset/tricky-actors-worry.md

Co-authored-by: elessar.eth <[email protected]>

* chore: Remove @floating-ui/react library from npm dependencies

* chore: Update npm dependencies to include @floating-ui/react library

* refactor: Improve error handling in CopyToClipboard component

* fix: Tooltip component styling

---------

Co-authored-by: elessar.eth <[email protected]>
  • Loading branch information
luis-herasme and PJColombo authored Sep 4, 2024
1 parent 2ded4f9 commit 7cd9a73
Show file tree
Hide file tree
Showing 5 changed files with 209 additions and 44 deletions.
5 changes: 5 additions & 0 deletions .changeset/tricky-actors-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@blobscan/web": patch
---

Added additional logic to the `Tooltip` component to prevent overflow.
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
48 changes: 26 additions & 22 deletions apps/web/src/components/CopyToClipboard.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,29 +14,34 @@ export function CopyToClipboard({
value,
}: CopyToClipboardProps) {
const [isCopied, setIsCopied] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const isHovered = useHover(buttonRef);
useEffect(() => setIsCopied(false), [isHovered]);

return (
// TODO: Use Button component
<button
ref={buttonRef}
className="relative cursor-pointer text-contentTertiary-light hover:text-link-light dark:text-contentTertiary-dark dark:hover:text-link-dark"
onClick={() => {
navigator.clipboard.writeText(value);
setIsCopied(true);
<Tooltip
onChange={(open) => {
if (!open) {
setIsCopied(false);
}
}}
>
{isCopied ? (
<CheckIcon className="h-5 w-5" />
) : (
<Copy className="h-5 w-5" />
)}
<Tooltip show={isHovered}>
<div className="whitespace-nowrap">{isCopied ? "Copied!" : label}</div>
</Tooltip>
</button>
<TooltipContent>{isCopied ? "Copied!" : label}</TooltipContent>
<TooltipTrigger
className="text-contentTertiary-light hover:text-link-light dark:text-contentTertiary-dark dark:hover:text-link-dark"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setIsCopied(true);
} catch (error) {
console.error("Failed to copy to clipboard", error);
}
}}
>
{isCopied ? (
<CheckIcon className="h-5 w-5" />
) : (
<Copy className="h-5 w-5" />
)}
</TooltipTrigger>
</Tooltip>
);
}

Expand Down
176 changes: 164 additions & 12 deletions apps/web/src/components/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useTooltip> | null;

const TooltipContext = createContext<ContextType>(null);

export const useTooltipContext = () => {
const context = useContext(TooltipContext);

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

return context;
};

export function Tooltip(options: TooltipOptions) {
const tooltip = useTooltip(options);

return (
<TooltipContext.Provider value={tooltip}>
{options.children}
</TooltipContext.Provider>
);
}

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

const childrenRef =
isValidElement(children) && "ref" in children
? (children.ref as React.Ref<HTMLElement>)
: 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 (
<div
className={`pointer-events-none absolute -top-2 left-[50%] z-10 -translate-x-[50%] translate-y-[-100%] ${
show ? "opacity-100" : "opacity-0"
}`}
<button
ref={ref}
data-state={context.isOpen ? "open" : "closed"}
{...context.getReferenceProps(props)}
>
<div className="rounded-full bg-accent-light px-3 py-1.5 text-xs text-white dark:bg-primary-500">
{children}
{children}
</button>
);
});

export const TooltipContent = forwardRef<
HTMLDivElement,
React.HTMLProps<HTMLDivElement>
>(function TooltipContent({ style, children, ...props }, propRef) {
const context = useTooltipContext();
const ref = useMergeRefs([context.refs.setFloating, propRef]);

if (!context.isOpen) {
return null;
}

return (
<FloatingPortal>
<div
ref={ref}
style={{
...context.floatingStyles,
...style,
}}
{...context.getFloatingProps(props)}
>
<div className="rounded-lg bg-accent-light px-2 py-1 text-xs font-normal text-white dark:bg-primary-500">
{children}
</div>

<FloatingArrow
ref={context.arrowRef}
context={context.context}
className="fill-accent-light dark:fill-primary-500"
/>
</div>
<div className="absolute bottom-0 left-[50%] h-2 w-2 -translate-x-[50%] translate-y-[50%] rotate-45 bg-accent-light dark:bg-primary-500" />
</div>
</FloatingPortal>
);
}
});
23 changes: 13 additions & 10 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 7cd9a73

Please sign in to comment.