Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 16 additions & 5 deletions src/frontend/components/lib/motion.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -8,23 +8,34 @@ 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 <AnimatePresence>. 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: {
opacity: 0,
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;
4 changes: 2 additions & 2 deletions src/frontend/components/lib/section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
// <AnimatePresence mode="popLayout"> can measure and pop this out of flow on
Expand All @@ -19,7 +19,7 @@ export function Section({
return (
<motion.section
ref={ref}
{...bounceMotionProps}
{...layoutBounceProps}
className={cn(
"flex flex-wrap bg-background-dark py-3 px-5 rounded-xl border-2 border-[#fbf0df] transition-colors will-change-transform w-full",
className,
Expand Down
105 changes: 91 additions & 14 deletions src/frontend/components/mam-response.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import { Check, Copy } from "lucide-react";
import { type ComponentPropsWithRef, type Ref, useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import {
type ComponentPropsWithRef,
type Ref,
useEffect,
useRef,
useState,
} from "react";
import { Temporal } from "temporal-polyfill";

import { useDashedIdent } from "#frontend/hooks/dashed-ident.ts";
import { cn } from "#frontend/lib/cn.ts";
import {
classify,
type ContactStatus,
type PublicState,
} from "#shared/public-state.ts";

import { Button } from "./lib/button";
import { bounceProps } from "./lib/motion";
import { Section } from "./lib/section";
import { NextUpdate } from "./next-update";

Expand Down Expand Up @@ -84,30 +94,97 @@ export function MamResponse({

function CopyableIP({ ip }: Readonly<{ ip: string }>) {
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<ReturnType<typeof setTimeout> | 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 (
<span className="inline-flex items-center gap-2">
<span className="font-mono">{ip}</span>
<button
<Button
type="button"
onClick={handleCopy}
aria-label={copied ? "Copied!" : "Copy IP address"}
className={cn(
"cursor-pointer focus-ring",
copied
? "text-success"
: "text-muted-text hover:text-primary-background-dark",
)}
variant="ghost"
size="icon"
onClick={copy}
onPointerEnter={() => setOpen(true)}
onPointerLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onKeyDown={(event) => {
if (event.key === "Escape") setOpen(false);
}}
aria-label="Copy IP address"
style={{ anchorName }}
className={cn(copied && "text-success hover:text-success")}
>
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
</button>
<span className="grid place-items-center">
<AnimatePresence initial={false}>
<motion.span
key={copied ? "check" : "copy"}
{...bounceProps}
className="col-start-1 row-start-1"
>
{copied ? (
<Check className="size-4" />
) : (
<Copy className="size-4" />
)}
</motion.span>
</AnimatePresence>
</span>
</Button>
<span role="status" className="sr-only">
{copied ? "Copied!" : ""}
</span>
<AnimatePresence mode="wait">
{open && (
<motion.div
key={copyText}
aria-hidden
{...bounceProps}
style={{
positionAnchor: anchorName,
position: "absolute",

// BUG: overflow is measured against the containing block, not the
// viewport; so, with flip-inline, the fallback always engages. I
// like the right-side toolip though, so we just omit fallback
// behavior and accept that it may clip on small screens.
//
// positionTryFallbacks: "flip-inline",

left: "calc(anchor(right) + 0.5rem)",
top: "anchor(center)",
translate: "0 -50%",
transformOrigin: "left center",
}}
className="z-10 rounded-md bg-background px-2 py-1 text-sm font-normal whitespace-nowrap shadow-md"
>
{copyText}
</motion.div>
)}
</AnimatePresence>
</span>
);
}
Expand Down
22 changes: 22 additions & 0 deletions src/frontend/hooks/dashed-ident.ts
Original file line number Diff line number Diff line change
@@ -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, "-");
}
Loading