fix: glossary tooltip layout and viewport overflow
linkify() was returning a bare array, so its fragments landed as direct children of flex <li> bullets — each text/link piece became its own flex item and scrambled the reading order on wrap. Now wrapped in a single <span> 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
38c7fb1db9
commit
d8892c53ce
@@ -1,27 +1,58 @@
|
|||||||
import type { ReactNode } from 'react'
|
import { useRef, useState, type ReactNode } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useLang } from '../context/LangContext'
|
import { useLang } from '../context/LangContext'
|
||||||
import { GLOSSARY } from '../glossary/terms'
|
import { GLOSSARY } from '../glossary/terms'
|
||||||
|
|
||||||
const BY_ID = new Map(GLOSSARY.map((entry) => [entry.id, entry]))
|
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<Placement, string> = {
|
||||||
|
start: 'left-0',
|
||||||
|
center: 'left-1/2 -translate-x-1/2',
|
||||||
|
end: 'right-0',
|
||||||
|
}
|
||||||
|
|
||||||
export default function GlossaryMark({ id, children }: { id: string; children: ReactNode }) {
|
export default function GlossaryMark({ id, children }: { id: string; children: ReactNode }) {
|
||||||
const { lang } = useLang()
|
const { lang } = useLang()
|
||||||
const entry = BY_ID.get(id)
|
const entry = BY_ID.get(id)
|
||||||
|
const anchorRef = useRef<HTMLAnchorElement>(null)
|
||||||
|
const [placement, setPlacement] = useState<Placement>('center')
|
||||||
|
|
||||||
if (!entry) return <>{children}</>
|
if (!entry) return <>{children}</>
|
||||||
|
|
||||||
const name = entry.fullName ? entry.fullName[lang] : entry.display
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
ref={anchorRef}
|
||||||
to={`/glossary#${entry.id}`}
|
to={`/glossary#${entry.id}`}
|
||||||
|
onMouseEnter={handleEnter}
|
||||||
className="group relative border-b border-dotted border-sky-500/60 hover:border-sky-400 hover:text-sky-300 transition-colors"
|
className="group relative border-b border-dotted border-sky-500/60 hover:border-sky-400 hover:text-sky-300 transition-colors"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<span
|
<span
|
||||||
className="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-64 -translate-x-1/2 rounded-lg
|
className={`pointer-events-none absolute bottom-full z-20 mb-2 w-64 rounded-lg
|
||||||
border border-slate-700 bg-slate-900 p-3 text-left text-xs font-normal normal-case
|
border border-slate-700 bg-slate-900 p-3 text-left text-xs font-normal normal-case
|
||||||
text-slate-400 opacity-0 shadow-2xl transition-opacity group-hover:opacity-100"
|
text-slate-400 opacity-0 shadow-2xl transition-opacity group-hover:opacity-100
|
||||||
|
${PLACEMENT_CLASS[placement]}`}
|
||||||
>
|
>
|
||||||
<span className="mb-1 block font-semibold text-slate-200">{name}</span>
|
<span className="mb-1 block font-semibold text-slate-200">{name}</span>
|
||||||
{entry.definition[lang]}
|
{entry.definition[lang]}
|
||||||
|
|||||||
+10
-3
@@ -16,12 +16,17 @@ for (const entry of GLOSSARY) {
|
|||||||
const ALL_SURFACES = [...MATCH_TO_ID.keys()].sort((a, b) => b.length - a.length)
|
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')
|
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 <GlossaryMark>. */
|
/** Wraps every occurrence of a known glossary term in `text` with a hoverable, linked <GlossaryMark>.
|
||||||
|
* 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 `<li>`) without each fragment becoming its own
|
||||||
|
* flex item and breaking the reading order. */
|
||||||
export function linkify(text: string): React.ReactNode {
|
export function linkify(text: string): React.ReactNode {
|
||||||
const parts = text.split(GLOSSARY_REGEX)
|
const parts = text.split(GLOSSARY_REGEX)
|
||||||
if (parts.length === 1) return text
|
if (parts.length === 1) return text
|
||||||
|
|
||||||
return parts.map((part, i) => {
|
return (
|
||||||
|
<span>
|
||||||
|
{parts.map((part, i) => {
|
||||||
const id = MATCH_TO_ID.get(part)
|
const id = MATCH_TO_ID.get(part)
|
||||||
if (id === undefined) return <Fragment key={i}>{part}</Fragment>
|
if (id === undefined) return <Fragment key={i}>{part}</Fragment>
|
||||||
return (
|
return (
|
||||||
@@ -29,5 +34,7 @@ export function linkify(text: string): React.ReactNode {
|
|||||||
{part}
|
{part}
|
||||||
</GlossaryMark>
|
</GlossaryMark>
|
||||||
)
|
)
|
||||||
})
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user