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'),
};