feat: enhance mermaid lightbox interaction with improved accessibility and hover hints

This commit is contained in:
Julian Pawlowski 2026-04-07 15:06:23 +00:00
parent dba96e38e0
commit b7cf4442bd
2 changed files with 129 additions and 48 deletions

View file

@ -229,43 +229,81 @@ h1, h2, h3, h4, h5, h6 {
/* ── Mermaid diagram zoom / lightbox ──────────────────────────── */ /* ── Mermaid diagram zoom / lightbox ──────────────────────────── */
/* Wrapper that holds the diagram + the expand button */ /* Wrapper — entire diagram is the click target */
.mermaid-zoom-wrapper { .mermaid-zoom-wrapper {
position: relative; position: relative;
display: block; display: block;
cursor: zoom-in;
border-radius: var(--ifm-card-border-radius, 0.5rem);
/* Clip the hover overlay to the same rounded corners */
overflow: hidden;
} }
/* Expand button — hidden until the diagram is hovered */ /* Semi-transparent tint that fades in on hover */
.mermaid-zoom-btn { .mermaid-zoom-hint {
position: absolute; position: absolute;
top: 0.5rem; inset: 0;
right: 0.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 1.75rem; pointer-events: none;
height: 1.75rem;
padding: 0;
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 0.4rem;
color: var(--ifm-color-emphasis-600);
cursor: pointer;
opacity: 0; opacity: 0;
transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease, background: rgba(0, 0, 0, 0);
border-color 0.15s ease; transition: opacity 0.2s ease, background 0.2s ease;
z-index: 1; z-index: 1;
} }
.mermaid-zoom-wrapper:hover .mermaid-zoom-btn, .mermaid-zoom-wrapper:hover .mermaid-zoom-hint,
.mermaid-zoom-btn:focus-visible { .mermaid-zoom-wrapper:focus-visible .mermaid-zoom-hint {
opacity: 1; opacity: 1;
background: rgba(0, 0, 0, 0.06);
} }
.mermaid-zoom-btn:hover { [data-theme='dark'] .mermaid-zoom-wrapper:hover .mermaid-zoom-hint,
background: var(--ifm-color-primary); [data-theme='dark'] .mermaid-zoom-wrapper:focus-visible .mermaid-zoom-hint {
border-color: var(--ifm-color-primary); background: rgba(255, 255, 255, 0.06);
color: #fff; }
/* Centered pill badge inside the tint */
.mermaid-zoom-hint-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.8rem;
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 2rem;
color: var(--ifm-color-emphasis-800);
font-size: 0.78rem;
font-weight: 600;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
transform: translateY(4px);
transition: transform 0.2s ease;
}
.mermaid-zoom-wrapper:hover .mermaid-zoom-hint-badge,
.mermaid-zoom-wrapper:focus-visible .mermaid-zoom-hint-badge {
transform: translateY(0);
}
/* Focus ring for keyboard users */
.mermaid-zoom-wrapper:focus-visible {
outline: 2px solid var(--ifm-color-primary);
outline-offset: 2px;
}
/* Touch devices: no hover state — show badge permanently so users know it's tappable */
@media (hover: none) {
.mermaid-zoom-hint {
opacity: 1;
background: transparent;
}
.mermaid-zoom-hint-badge {
transform: translateY(0);
/* Slightly more opaque so it reads well without hover context */
opacity: 0.85;
}
} }
/* Full-screen backdrop */ /* Full-screen backdrop */

View file

@ -8,6 +8,7 @@ type Props = WrapperProps<typeof MermaidType>;
export default function MermaidWrapper(props: Props): React.JSX.Element { export default function MermaidWrapper(props: Props): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const lightboxRef = useRef<HTMLDivElement>(null);
const [overlayOpen, setOverlayOpen] = useState(false); const [overlayOpen, setOverlayOpen] = useState(false);
const [svgMarkup, setSvgMarkup] = useState(''); const [svgMarkup, setSvgMarkup] = useState('');
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@ -33,14 +34,43 @@ export default function MermaidWrapper(props: Props): React.JSX.Element {
setOverlayOpen(false); setOverlayOpen(false);
}, []); }, []);
// Keyboard + body-scroll lock while overlay is open // Keyboard + body-scroll lock + focus trap while overlay is open
useEffect(() => { useEffect(() => {
if (!overlayOpen) return; if (!overlayOpen) return;
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleClose(); if (e.key === 'Escape') {
handleClose();
return;
}
// Focus trap: keep Tab/Shift+Tab inside the lightbox
if (e.key === 'Tab' && lightboxRef.current) {
const focusable = lightboxRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last?.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
}
}; };
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
// Move focus into the lightbox on open
const closeBtn = lightboxRef.current?.querySelector<HTMLElement>('button');
closeBtn?.focus();
return () => { return () => {
document.removeEventListener('keydown', onKey); document.removeEventListener('keydown', onKey);
document.body.style.overflow = ''; document.body.style.overflow = '';
@ -48,19 +78,28 @@ export default function MermaidWrapper(props: Props): React.JSX.Element {
}, [overlayOpen, handleClose]); }, [overlayOpen, handleClose]);
return ( return (
<div ref={containerRef} className="mermaid-zoom-wrapper"> <div
<OriginalMermaid {...props} /> ref={containerRef}
<button className="mermaid-zoom-wrapper"
className="mermaid-zoom-btn"
onClick={handleOpen} onClick={handleOpen}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOpen();
}
}}
aria-label="View diagram enlarged" aria-label="View diagram enlarged"
title="View enlarged"
> >
{/* Expand / fullscreen icon */} <OriginalMermaid {...props} />
{/* Hover hint — pointer-events:none so it never swallows clicks */}
<div className="mermaid-zoom-hint" aria-hidden="true">
<div className="mermaid-zoom-hint-badge">
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
width="15" width="14"
height="15" height="14"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeWidth="2.5" strokeWidth="2.5"
@ -68,17 +107,20 @@ export default function MermaidWrapper(props: Props): React.JSX.Element {
strokeLinejoin="round" strokeLinejoin="round"
aria-hidden="true" aria-hidden="true"
> >
<polyline points="15 3 21 3 21 9" /> <circle cx="11" cy="11" r="8" />
<polyline points="9 21 3 21 3 15" /> <line x1="21" y1="21" x2="16.65" y2="16.65" />
<line x1="21" y1="3" x2="14" y2="10" /> <line x1="11" y1="8" x2="11" y2="14" />
<line x1="3" y1="21" x2="10" y2="14" /> <line x1="8" y1="11" x2="14" y2="11" />
</svg> </svg>
</button> Enlarge
</div>
</div>
{mounted && {mounted &&
overlayOpen && overlayOpen &&
createPortal( createPortal(
<div <div
ref={lightboxRef}
className="mermaid-lightbox" className="mermaid-lightbox"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
@ -95,6 +137,7 @@ export default function MermaidWrapper(props: Props): React.JSX.Element {
className="mermaid-lightbox-close" className="mermaid-lightbox-close"
onClick={handleClose} onClick={handleClose}
aria-label="Close enlarged view" aria-label="Close enlarged view"
title="Close (Esc)"
> >
<svg <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"