From d8892c53cedb83488eaec7fe591aabccbd61ac9a Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Thu, 2 Jul 2026 18:46:54 +0200 Subject: [PATCH] fix: glossary tooltip layout and viewport overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit linkify() was returning a bare array, so its fragments landed as direct children of flex
  • bullets — each text/link piece became its own flex item and scrambled the reading order on wrap. Now wrapped in a single so text flows normally. Tooltips also always centered under the term regardless of position, overflowing off-screen near the viewport edges. GlossaryMark now measures the anchor on hover and flips to a left- or right-aligned placement when centering would push it out of view. Co-Authored-By: Claude Sonnet 5 --- src/components/GlossaryMark.tsx | 37 ++++++++++++++++++++++++++++++--- src/lib/glossary.tsx | 27 +++++++++++++++--------- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/components/GlossaryMark.tsx b/src/components/GlossaryMark.tsx index 9b31ace..5670854 100644 --- a/src/components/GlossaryMark.tsx +++ b/src/components/GlossaryMark.tsx @@ -1,27 +1,58 @@ -import type { ReactNode } from 'react' +import { useRef, useState, type ReactNode } from 'react' import { Link } from 'react-router-dom' import { useLang } from '../context/LangContext' import { GLOSSARY } from '../glossary/terms' const BY_ID = new Map(GLOSSARY.map((entry) => [entry.id, entry])) +const TOOLTIP_WIDTH = 256 // px, matches w-64 +const VIEWPORT_MARGIN = 16 // px, keep clear of the screen edge + +type Placement = 'start' | 'center' | 'end' + +const PLACEMENT_CLASS: Record = { + start: 'left-0', + center: 'left-1/2 -translate-x-1/2', + end: 'right-0', +} + export default function GlossaryMark({ id, children }: { id: string; children: ReactNode }) { const { lang } = useLang() const entry = BY_ID.get(id) + const anchorRef = useRef(null) + const [placement, setPlacement] = useState('center') + if (!entry) return <>{children} const name = entry.fullName ? entry.fullName[lang] : entry.display + function handleEnter() { + const rect = anchorRef.current?.getBoundingClientRect() + if (!rect) return + const centerX = rect.left + rect.width / 2 + const half = TOOLTIP_WIDTH / 2 + if (centerX - half < VIEWPORT_MARGIN) { + setPlacement('start') + } else if (centerX + half > window.innerWidth - VIEWPORT_MARGIN) { + setPlacement('end') + } else { + setPlacement('center') + } + } + return ( {children} {name} {entry.definition[lang]} diff --git a/src/lib/glossary.tsx b/src/lib/glossary.tsx index f442eeb..9139d21 100644 --- a/src/lib/glossary.tsx +++ b/src/lib/glossary.tsx @@ -16,18 +16,25 @@ for (const entry of GLOSSARY) { const ALL_SURFACES = [...MATCH_TO_ID.keys()].sort((a, b) => b.length - a.length) const GLOSSARY_REGEX = new RegExp(`\\b(${ALL_SURFACES.map(escapeRegExp).join('|')})\\b`, 'g') -/** Wraps every occurrence of a known glossary term in `text` with a hoverable, linked . */ +/** Wraps every occurrence of a known glossary term in `text` with a hoverable, linked . + * Always returns a single inline element — never a bare array — so callers can safely place the + * result inside a `flex` container (e.g. a bulleted `
  • `) without each fragment becoming its own + * flex item and breaking the reading order. */ export function linkify(text: string): React.ReactNode { const parts = text.split(GLOSSARY_REGEX) if (parts.length === 1) return text - return parts.map((part, i) => { - const id = MATCH_TO_ID.get(part) - if (id === undefined) return {part} - return ( - - {part} - - ) - }) + return ( + + {parts.map((part, i) => { + const id = MATCH_TO_ID.get(part) + if (id === undefined) return {part} + return ( + + {part} + + ) + })} + + ) }