diff --git a/.changeset/calm-tooltips-float.md b/.changeset/calm-tooltips-float.md new file mode 100644 index 00000000..156e078d --- /dev/null +++ b/.changeset/calm-tooltips-float.md @@ -0,0 +1,6 @@ +--- +'@doc-kit/generator-react': patch +--- + +Render sidebar stability tooltips in a portal so that the table of contents +scroll container does not clip them. diff --git a/e2e/stability-tooltip.spec.js b/e2e/stability-tooltip.spec.js new file mode 100644 index 00000000..fc99a5a2 --- /dev/null +++ b/e2e/stability-tooltip.spec.js @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; + +test('renders stability tooltips outside the MetaBar scroll container', async ({ + page, +}) => { + await page.goto('/assert.html'); + + const badge = page.getByLabel('Stability: Legacy').first(); + await badge.scrollIntoViewIfNeeded(); + await expect(badge.locator('xpath=ancestor::is-land[1]')).toHaveAttribute( + 'ready', + '' + ); + await badge.hover(); + + const tooltip = page.getByRole('tooltip').filter({ hasText: 'Legacy' }); + await expect(tooltip).toBeVisible(); + + expect(await tooltip.evaluate(element => element.closest('aside dl'))).toBe( + null + ); + + const box = await tooltip.boundingBox(); + const viewport = page.viewportSize(); + + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(viewport.width); + + await page.mouse.move(0, 0); + await expect(tooltip).not.toBeVisible(); + await badge.focus(); + await expect(tooltip).toBeVisible(); +}); diff --git a/packages/react/src/html/ui/components/MetaBar/StabilityBadge.jsx b/packages/react/src/html/ui/components/MetaBar/StabilityBadge.jsx new file mode 100644 index 00000000..08dd4161 --- /dev/null +++ b/packages/react/src/html/ui/components/MetaBar/StabilityBadge.jsx @@ -0,0 +1,106 @@ +import Badge from '@node-core/ui-components/Common/Badge'; +import { createPortal } from 'preact/compat'; +import { useCallback, useLayoutEffect, useRef, useState } from 'preact/hooks'; + +import styles from './index.module.css'; +import withIsland from '../../islands/withIsland.jsx'; + +const VIEWPORT_MARGIN = 8; + +/** + * Renders a stability badge with a tooltip that is portalled outside the + * MetaBar's scroll container. + * @param {{ label: string, tooltip: string, ariaLabel: string, kind: string }} props + */ +const StabilityBadge = ({ label, tooltip, ariaLabel, kind }) => { + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + const [open, setOpen] = useState(false); + const [position, setPosition] = useState(null); + + const updatePosition = useCallback(() => { + const trigger = triggerRef.current; + const content = tooltipRef.current; + + if (!trigger || !content) { + return; + } + + const triggerBox = trigger.getBoundingClientRect(); + const contentBox = content.getBoundingClientRect(); + const centeredLeft = + triggerBox.left + triggerBox.width / 2 - contentBox.width / 2; + const left = Math.min( + Math.max(centeredLeft, VIEWPORT_MARGIN), + window.innerWidth - contentBox.width - VIEWPORT_MARGIN + ); + const below = triggerBox.bottom + VIEWPORT_MARGIN; + const fitsBelow = + below + contentBox.height <= window.innerHeight - VIEWPORT_MARGIN; + + setPosition({ + left, + top: fitsBelow + ? below + : triggerBox.top - contentBox.height - VIEWPORT_MARGIN, + }); + }, []); + + useLayoutEffect(() => { + if (!open) { + return undefined; + } + + const frame = window.requestAnimationFrame(updatePosition); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + + return () => { + window.cancelAnimationFrame(frame); + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [open, updatePosition]); + + const closeTooltip = () => { + setOpen(false); + setPosition(null); + }; + + return ( + <> + setOpen(true)} + onMouseLeave={closeTooltip} + onFocus={() => setOpen(true)} + onBlur={closeTooltip} + > + + {label} + + + + {open && + createPortal( + + {tooltip} + , + document.body + )} + + ); +}; + +export default withIsland(StabilityBadge, { + name: 'StabilityBadge', + on: { idle: true }, +}); diff --git a/packages/react/src/html/ui/components/MetaBar/index.jsx b/packages/react/src/html/ui/components/MetaBar/index.jsx index 5f705efd..cbfe8b4c 100644 --- a/packages/react/src/html/ui/components/MetaBar/index.jsx +++ b/packages/react/src/html/ui/components/MetaBar/index.jsx @@ -1,9 +1,9 @@ import { CodeBracketIcon, DocumentIcon } from '@heroicons/react/24/outline'; -import Badge from '@node-core/ui-components/Common/Badge'; import MetaBar from '@node-core/ui-components/Containers/MetaBar'; import GitHubIcon from '@node-core/ui-components/Icons/Social/GitHub'; import styles from './index.module.css'; +import StabilityBadge from './StabilityBadge.jsx'; import { editURL } from '#theme/config'; @@ -25,24 +25,19 @@ const HeadingValue = ({ value, stability }) => { return value; } - const ariaLabel = STABILITY_TOOLTIPS[stability] - ? `Stability: ${STABILITY_TOOLTIPS[stability]}` - : undefined; + const tooltip = STABILITY_TOOLTIPS[stability]; + const ariaLabel = tooltip ? `Stability: ${tooltip}` : undefined; return ( <> {value} - - {STABILITY_LABELS[stability]} - + label={STABILITY_LABELS[stability]} + tooltip={tooltip} + ariaLabel={ariaLabel} + /> ); }; diff --git a/packages/react/src/html/ui/components/MetaBar/index.module.css b/packages/react/src/html/ui/components/MetaBar/index.module.css index 0f3163ee..c35f1b1d 100644 --- a/packages/react/src/html/ui/components/MetaBar/index.module.css +++ b/packages/react/src/html/ui/components/MetaBar/index.module.css @@ -5,7 +5,32 @@ margin-right: 0.25rem; } -.badge { +.tooltipTrigger { display: inline-block; margin-left: 0.25rem; } + +.tooltipContent { + position: fixed; + z-index: 50; + width: max-content; + max-width: 16rem; + padding: 0.375rem 0.625rem; + color: var(--color-neutral-900); + font-size: 0.875rem; + font-weight: 500; + line-height: 1.25rem; + pointer-events: none; + background-color: var(--color-white); + border: 1px solid var(--color-neutral-200); + border-radius: 0.375rem; + box-shadow: + 0 4px 6px -2px rgb(16 24 40 / 3%), + 0 12px 16px -4px rgb(16 24 40 / 8%); +} + +:global([data-theme='dark']) .tooltipContent { + color: var(--color-white); + background-color: var(--color-neutral-950); + border-color: var(--color-neutral-900); +} diff --git a/packages/react/src/html/ui/islands/loaders.mjs b/packages/react/src/html/ui/islands/loaders.mjs index b5661a4a..82ce1b89 100644 --- a/packages/react/src/html/ui/islands/loaders.mjs +++ b/packages/react/src/html/ui/islands/loaders.mjs @@ -8,4 +8,5 @@ export default { ThemeToggle: () => import('../components/ThemeToggle.jsx'), SearchBox: () => import('../components/SearchBox'), SideBar: () => import('#theme/Sidebar'), + StabilityBadge: () => import('../components/MetaBar/StabilityBadge.jsx'), };