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: 2 additions & 1 deletion app/components/home-social-links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,12 @@ export default function HomeSocialLinks() {
<HoverTip
tip="For private or sensitive messages, please reach out via Signal."
placement="top"
tapToToggle
>
<button
type="button"
aria-label="Contact guidance"
className="inline-flex size-5 items-center justify-center rounded-full border border-zinc-300 dark:border-zinc-600 text-[10px] leading-none text-zinc-400 dark:text-zinc-500 opacity-60 transition-all duration-300 hover:opacity-100 hover:border-[#C4894F] hover:text-[#C4894F] dark:hover:border-[#D9A870] dark:hover:text-[#D9A870] bg-transparent cursor-default"
className="inline-flex size-5 items-center justify-center rounded-full border border-zinc-300 dark:border-zinc-600 text-[10px] leading-none text-zinc-400 dark:text-zinc-500 opacity-60 transition-all duration-300 hover:opacity-100 hover:border-[#C4894F] hover:text-[#C4894F] dark:hover:border-[#D9A870] dark:hover:text-[#D9A870] bg-transparent cursor-pointer"
style={{ fontFamily: "'Nunito'", fontWeight: 600 }}
>
?
Expand Down
72 changes: 62 additions & 10 deletions app/components/hover-tip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,31 @@ type HoverTipProps = {
align?: "center" | "end";
/** Allow hovering / clicking links inside the tooltip */
interactive?: boolean;
/** Tap trigger to toggle on touch devices (no hover) */
tapToToggle?: boolean;
/** Render tooltip in a body portal (escapes overflow:hidden ancestors) */
portal?: boolean;
className?: string;
tipClassName?: string;
};

/** Below site nav (`z-50`) so portaled tips never cover the header bar */
const TIP_Z_INDEX = 40;

function usePrefersHover() {
const [prefersHover, setPrefersHover] = useState(true);

useEffect(() => {
const mq = window.matchMedia("(hover: hover)");
const update = () => setPrefersHover(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);

return prefersHover;
}

const TIP_BASE_CLASS =
"rounded-sm border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-[#252019] px-2.5 py-1.5 text-center text-[11px] leading-snug text-zinc-600 dark:text-zinc-300 shadow-[3px_3px_0_0_var(--color-border-tertiary)] transition-all duration-150";

Expand All @@ -42,7 +61,7 @@ function portalFixedStyle(
const gap = 8;
const base: CSSProperties = {
position: "fixed",
zIndex: 99999,
zIndex: TIP_Z_INDEX,
fontFamily: "'Nunito'",
fontWeight: 400,
};
Expand Down Expand Up @@ -84,15 +103,19 @@ export default function HoverTip({
placement = "top",
align = "center",
interactive = false,
portal = false,
tapToToggle = false,
portal = true,
className = "",
tipClassName = "",
}: HoverTipProps) {
const triggerRef = useRef<HTMLSpanElement>(null);
const tipRef = useRef<HTMLSpanElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prefersHover = usePrefersHover();
const [mounted, setMounted] = useState(false);
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState<DOMRect | null>(null);
const touchToggle = tapToToggle || interactive;

useEffect(() => {
queueMicrotask(() => setMounted(true));
Expand All @@ -118,12 +141,39 @@ export default function HoverTip({
}, []);

const scheduleHide = useCallback(() => {
if (!interactive) {
if (!interactive || !prefersHover) {
hide();
return;
}
hideTimerRef.current = setTimeout(hide, 120);
}, [hide, interactive]);
}, [hide, interactive, prefersHover]);

const toggle = useCallback(() => {
if (open) hide();
else show();
}, [open, hide, show]);

const onTriggerClick = useCallback(
(e: React.MouseEvent) => {
if (!touchToggle || prefersHover) return;
e.preventDefault();
e.stopPropagation();
toggle();
},
[touchToggle, prefersHover, toggle]
);

useEffect(() => {
if (!open || prefersHover || !touchToggle) return;
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node;
if (triggerRef.current?.contains(target)) return;
if (tipRef.current?.contains(target)) return;
hide();
};
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open, prefersHover, touchToggle, hide]);

useEffect(() => {
if (!open || !portal) return;
Expand Down Expand Up @@ -153,20 +203,22 @@ export default function HoverTip({
<span
ref={triggerRef}
className={`inline-flex ${className}`}
onMouseEnter={show}
onMouseLeave={scheduleHide}
onMouseEnter={prefersHover ? show : undefined}
onMouseLeave={prefersHover ? scheduleHide : undefined}
onFocus={show}
onBlur={scheduleHide}
onBlur={prefersHover ? scheduleHide : undefined}
onClick={onTriggerClick}
>
{children}
</span>
{createPortal(
<span
ref={tipRef}
role="tooltip"
style={fixedStyle}
className={`${TIP_BASE_CLASS} w-max max-w-[220px] ${tipVisibilityClass(open)} ${tipClassName}`}
onMouseEnter={interactive ? show : undefined}
onMouseLeave={interactive ? scheduleHide : undefined}
onMouseEnter={interactive && prefersHover ? show : undefined}
onMouseLeave={interactive && prefersHover ? scheduleHide : undefined}
>
{tip}
</span>,
Expand All @@ -190,7 +242,7 @@ export default function HoverTip({
{children}
<span
role="tooltip"
className={`absolute ${position} z-50 w-max max-w-[220px] ${TIP_BASE_CLASS} opacity-0 translate-y-0.5 group-hover/hover-tip:opacity-100 group-hover/hover-tip:translate-y-0 group-focus-within/hover-tip:opacity-100 group-focus-within/hover-tip:translate-y-0 ${
className={`absolute ${position} z-40 w-max max-w-[220px] ${TIP_BASE_CLASS} opacity-0 translate-y-0.5 group-hover/hover-tip:opacity-100 group-hover/hover-tip:translate-y-0 group-focus-within/hover-tip:opacity-100 group-focus-within/hover-tip:translate-y-0 ${
interactive ? "pointer-events-auto" : "pointer-events-none"
} ${tipClassName}`}
style={{ fontFamily: "'Nunito'", fontWeight: 400 }}
Expand Down
19 changes: 19 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,25 @@ body {
color: var(--foreground);
}

/* Non-portal HoverTip: collapsed tooltips must not widen the page (mobile overflow) */
.group\/hover-tip:not(:hover):not(:focus-within) > [role="tooltip"] {
position: fixed;
top: 0;
left: 0;
right: auto;
bottom: auto;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
transform: none;
max-width: none;
}

/* ── Magazine / Editorial card ──────────────────────────────── */
.mag-card {
font-family: var(--font-ui-en);
Expand Down