From 809f548c637d1243a0305422e224bdf06291d7d6 Mon Sep 17 00:00:00 2001 From: Tim Martin Date: Wed, 24 Jun 2026 01:59:09 -0500 Subject: [PATCH] ui: add a tooltip to copy IP button --- CHANGELOG.md | 3 + src/frontend/components/lib/motion.ts | 21 +++-- src/frontend/components/lib/section.tsx | 4 +- src/frontend/components/mam-response.tsx | 105 ++++++++++++++++++++--- src/frontend/hooks/dashed-ident.ts | 22 +++++ 5 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 src/frontend/hooks/dashed-ident.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c8f9157..cdbdf32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ about how to read this document in [CONTRIBUTING.md](/CONTRIBUTING.md). animations. Fixes [#161](https://github.com/t-mart/mousehole/issues/161) - `ui`: Link the version in the footer to the changelog. Fixes [#162](https://github.com/t-mart/mousehole/issues/162) +- `ui`: Copying the Host IP now confirms with a tooltip. Closes + [#155](https://github.com/t-mart/mousehole/issues/155) and + [#160](https://github.com/t-mart/mousehole/issues/160) ## [v0.5.0](https://github.com/t-mart/mousehole/releases/tag/v0.5.0) - 2026-06-20 diff --git a/src/frontend/components/lib/motion.ts b/src/frontend/components/lib/motion.ts index 0c25e39..ddc77b9 100644 --- a/src/frontend/components/lib/motion.ts +++ b/src/frontend/components/lib/motion.ts @@ -1,5 +1,5 @@ /** - * Motion props for a "quick settle" effect on layout reflow, without any bounce. + * A quick, un-bouncy settle for layout reflow. */ export const layoutTransition = { type: "tween" as const, @@ -8,17 +8,18 @@ export const layoutTransition = { }; /** - * Motion props for a "bounce" effect on mount and unmount, with a quick settle for layout reflow. + * Scale-and-fade bounce for mount and unmount inside an . No + * layout animation, so it suits an element that doesn't reflow its neighbors, + * such as a tooltip. */ -export const bounceMotionProps = { - layout: true, +export const bounceProps = { initial: { opacity: 0, scale: 0 }, animate: { opacity: 1, scale: 1, transition: { opacity: { duration: 0.3 }, - scale: { type: "spring", visualDuration: 0.4, bounce: 0.5 }, + scale: { type: "spring", visualDuration: 0.3, bounce: 0.5 }, }, }, exit: { @@ -26,5 +27,15 @@ export const bounceMotionProps = { scale: 0, transition: { duration: 0.12, ease: "easeIn" }, }, +} as const; + +/** + * `bounceProps` plus a layout animation, for an element whose entrance and exit + * reflow its siblings (dashboard Sections under popLayout). `layoutTransition` + * tunes that reflow. + */ +export const layoutBounceProps = { + ...bounceProps, + layout: true, transition: { layout: layoutTransition }, } as const; diff --git a/src/frontend/components/lib/section.tsx b/src/frontend/components/lib/section.tsx index dc108ff..4c9d481 100644 --- a/src/frontend/components/lib/section.tsx +++ b/src/frontend/components/lib/section.tsx @@ -4,7 +4,7 @@ import { motion } from "motion/react"; import { cn } from "#frontend/lib/cn.ts"; -import { bounceMotionProps } from "./motion"; +import { layoutBounceProps } from "./motion"; // `ref` is forwarded to the underlying motion.section so an ancestor // can measure and pop this out of flow on @@ -19,7 +19,7 @@ export function Section({ return ( ) { const [copied, setCopied] = useState(false); + const [open, setOpen] = useState(false); + const copyText = copied ? "Copied!" : "Copy IP address"; + + const anchorName = useDashedIdent("copy-ip"); + + // Each copy restarts the "Copied!" window. Clear any pending reset first, or + // an earlier press's timer fires mid-spam and flips the label back too soon. + const resetTimerRef = useRef | undefined>( + undefined, + ); + useEffect(() => () => clearTimeout(resetTimerRef.current), []); - function handleCopy() { + function copy() { void navigator.clipboard.writeText(ip).then(() => { setCopied(true); - setTimeout(() => setCopied(false), 2000); + clearTimeout(resetTimerRef.current); + resetTimerRef.current = setTimeout(() => setCopied(false), 2000); }); } + // Hand-rolled tooltip (no native popover): `open` drives the mount, + // AnimatePresence runs the bounce, and CSS anchor positioning pins it to the + // button. Show on hover/focus, hide on leave/blur/Escape. The tooltip is + // aria-hidden (its text only echoes the button's label); the button's + // aria-label names it, and a visually hidden live region announces "Copied!". return ( {ip} - + + + + {copied ? ( + + ) : ( + + )} + + + + + + {copied ? "Copied!" : ""} + + + {open && ( + + {copyText} + + )} + ); } diff --git a/src/frontend/hooks/dashed-ident.ts b/src/frontend/hooks/dashed-ident.ts new file mode 100644 index 0000000..a6531de --- /dev/null +++ b/src/frontend/hooks/dashed-ident.ts @@ -0,0 +1,22 @@ +import { useId } from "react"; + +/** + * Builds a dashed-ident that is stable across renders, and unique per + * component instance. Handy for CSS custom properties. + * + * This hook provides the same guarantees as `useId`, but it returns a + * [dashed-ident](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/dashed-ident) instead + * of an arbitrary string. The returned value is suitable for use as a CSS custom + * property name, e.g. `--my-component-abc123`. + * + * @param prefix optional text that prefixes the identifier, e.g. "copy-ip" + * @returns a dashed-ident such as `--copy-ip-abc123` or `--abc123` + */ +export function useDashedIdent(prefix?: string): string { + const id = replaceForDashedIdent(useId()); + return prefix ? `--${replaceForDashedIdent(prefix)}-${id}` : `--${id}`; +} + +function replaceForDashedIdent(s: string): string { + return s.replaceAll(/[^a-zA-Z0-9]/g, "-"); +}