Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/calm-tooltips-float.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions e2e/stability-tooltip.spec.js
Original file line number Diff line number Diff line change
@@ -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();
});
106 changes: 106 additions & 0 deletions packages/react/src/html/ui/components/MetaBar/StabilityBadge.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<span
ref={triggerRef}
className={styles.tooltipTrigger}
aria-label={ariaLabel}
tabIndex={0}
onMouseEnter={() => setOpen(true)}
onMouseLeave={closeTooltip}
onFocus={() => setOpen(true)}
onBlur={closeTooltip}
>
<Badge size="small" kind={kind}>
{label}
</Badge>
</span>

{open &&
createPortal(
<span
ref={tooltipRef}
role="tooltip"
className={styles.tooltipContent}
style={position ?? { visibility: 'hidden' }}
>
{tooltip}
</span>,
document.body
)}
</>
);
};

export default withIsland(StabilityBadge, {
name: 'StabilityBadge',
on: { idle: true },
});
21 changes: 8 additions & 13 deletions packages/react/src/html/ui/components/MetaBar/index.jsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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}

<Badge
size="small"
className={styles.badge}
<StabilityBadge
kind={STABILITY_KINDS[stability]}
data-tooltip={STABILITY_TOOLTIPS[stability]}
aria-label={ariaLabel}
tabIndex={0}
>
{STABILITY_LABELS[stability]}
</Badge>
label={STABILITY_LABELS[stability]}
tooltip={tooltip}
ariaLabel={ariaLabel}
/>
</>
);
};
Expand Down
27 changes: 26 additions & 1 deletion packages/react/src/html/ui/components/MetaBar/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
1 change: 1 addition & 0 deletions packages/react/src/html/ui/islands/loaders.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
};