diff --git a/packages/ui-components/src/Common/Badge/index.module.css b/packages/ui-components/src/Common/Badge/index.module.css index 5c757e89e2acd..f454a1c960070 100644 --- a/packages/ui-components/src/Common/Badge/index.module.css +++ b/packages/ui-components/src/Common/Badge/index.module.css @@ -54,3 +54,86 @@ } } } + +/* + * Real tooltip element rendered as the next sibling of badges carrying + * `data-tooltip`. It stays hidden unless the browser supports CSS anchor + * positioning, in which case it replaces the global `[data-tooltip]` + * pseudo-element tooltip so it cannot be clipped by scrolling ancestors + * (see nodejs/doc-kit#938). + */ +.tooltip { + @apply pointer-events-none + invisible + z-10 + w-max + max-w-64 + rounded-md + border + border-neutral-200 + bg-white + px-2.5 + py-1.5 + text-center + text-sm + font-medium + text-neutral-900 + opacity-0 + shadow-lg + transition-[opacity,visibility] + dark:border-neutral-900 + dark:bg-neutral-950 + dark:text-white; + + display: none; + + &::before { + @apply absolute + size-2 + -translate-x-1/2 + rotate-45 + border + border-r-0 + border-b-0 + border-neutral-200 + bg-white + content-['']; + + left: 50%; + top: -5px; + } +} + +/* Dark variants for the arrow must target the originating element through an + explicit ancestor selector: `dark:` utilities applied inside `&::before` + compile to an unmatchable pseudo-element compound selector. */ +[data-theme='dark'] .tooltip::before { + @apply border-neutral-900 + bg-neutral-950; +} + +@supports (anchor-name: --doc-kit-badge-tooltip) { + .badge[data-tooltip] { + &::after, + &::before { + @apply hidden; + } + } + + .tooltip { + @apply block; + + left: anchor(center); + margin-block-start: 0.5rem; + position: fixed; + position-anchor: var(--tooltip-anchor); + top: anchor(bottom); + translate: -50% 0; + } + + .badge[data-tooltip]:focus-visible ~ .tooltip, + .badge[data-tooltip]:hover ~ .tooltip { + @apply visible + opacity-100; + } +} diff --git a/packages/ui-components/src/Common/Badge/index.tsx b/packages/ui-components/src/Common/Badge/index.tsx index 818759d43555a..1deebfbd2bf6b 100644 --- a/packages/ui-components/src/Common/Badge/index.tsx +++ b/packages/ui-components/src/Common/Badge/index.tsx @@ -1,6 +1,12 @@ import classNames from 'classnames'; +import { useId } from 'react'; -import type { FC, HTMLAttributes, PropsWithChildren } from 'react'; +import type { + CSSProperties, + FC, + HTMLAttributes, + PropsWithChildren, +} from 'react'; import styles from './index.module.css'; @@ -10,6 +16,7 @@ type BadgeSize = 'small' | 'medium'; type BadgeProps = HTMLAttributes & { size?: BadgeSize; kind?: BadgeKind; + 'data-tooltip'?: string; }; const Badge: FC> = ({ @@ -18,22 +25,56 @@ const Badge: FC> = ({ className, children, ...props -}) => ( - - {children} - -); +}) => { + const { style, ...rest } = props; + const tooltip = rest['data-tooltip']; + // A unique anchor name per badge: anchoring the tooltip to the badge keeps + // it out of reach of ancestor clipping (scrolling containers), which the + // global `[data-tooltip]` pseudo-element tooltip suffers from. The same + // identifier also wires the `aria-describedby` relationship that exposes + // the stability text to assistive technologies. + const tooltipAnchorId = useId().replace(/[^a-zA-Z0-9]/g, ''); + const anchorName = `--badge-tooltip-${tooltipAnchorId}`; + const tooltipId = `badge-tooltip-${tooltipAnchorId}`; + + const badge = ( + + {children} + + ); + + if (!tooltip) { + return badge; + } + + return ( + <> + {badge} + + + {tooltip} + + + ); +}; export default Badge; diff --git a/packages/ui-components/src/Containers/MetaBar/index.stories.tsx b/packages/ui-components/src/Containers/MetaBar/index.stories.tsx index a52080cbba00a..84bfac88ff6d1 100644 --- a/packages/ui-components/src/Containers/MetaBar/index.stories.tsx +++ b/packages/ui-components/src/Containers/MetaBar/index.stories.tsx @@ -1,5 +1,6 @@ import { CodeBracketIcon } from '@heroicons/react/24/outline'; +import Badge from '#ui/Common/Badge'; import MetaBar from '#ui/Containers/MetaBar'; import GitHubIcon from '#ui/Icons/Social/GitHub'; @@ -79,3 +80,141 @@ export const Default: Story = { }; export default { component: MetaBar } as Meta; + +type TocEntry = { + text: string; + depth: 2 | 3 | 4; + stability?: 'Deprecated' | 'Experimental' | 'Legacy'; + badgePosition?: 'leading' | 'trailing'; +}; + +// Mirrors the Node.js API documentation table of contents, where headings can +// carry a stability badge exposing a `data-tooltip`. Badges sit at both edges +// of the list (line-leading and line-trailing) and the compact, long list +// scrolls, reproducing nodejs/doc-kit#938. +const TOC_ENTRIES: Array = [ + { text: 'Overview of the module', depth: 2 }, + { + text: 'assert.Assertions', + depth: 3, + stability: 'Legacy', + badgePosition: 'leading', + }, + { + text: 'assert.deepEqual compared with deepStrictEqual and when to prefer each', + depth: 3, + stability: 'Deprecated', + badgePosition: 'trailing', + }, + { text: 'assert.deepStrictEqual comparison details', depth: 3 }, + { + text: 'assert.doesNotMatch', + depth: 3, + stability: 'Experimental', + badgePosition: 'trailing', + }, + { text: 'assert.doesNotReject', depth: 4 }, + { + text: 'assert.doesNotThrow', + depth: 4, + stability: 'Experimental', + badgePosition: 'trailing', + }, + { text: 'assert.equal', depth: 3 }, + { + text: 'assert.fail with custom error handling strategies', + depth: 3, + stability: 'Deprecated', + badgePosition: 'trailing', + }, + { text: 'assert.ifError', depth: 3 }, + { + text: 'assert.match', + depth: 3, + stability: 'Experimental', + badgePosition: 'trailing', + }, + { text: 'assert.ok truthiness checks and custom messages', depth: 2 }, + { + text: 'assert.rejects', + depth: 3, + stability: 'Legacy', + badgePosition: 'leading', + }, + { text: 'assert.throws with validation objects and error classes', depth: 3 }, + { + text: 'assert.partialDeepStrictEqual', + depth: 3, + stability: 'Experimental', + badgePosition: 'trailing', + }, + { text: 'strict mode configuration', depth: 2 }, + { + text: 'assert.CallTracker', + depth: 3, + stability: 'Deprecated', + badgePosition: 'leading', + }, + { text: 'Caveats and migration notes for legacy APIs', depth: 4 }, + { text: 'assert.default', depth: 3 }, + { + text: 'assert.strict mode caveats and examples for migration', + depth: 3, + stability: 'Legacy', + badgePosition: 'trailing', + }, +]; + +export const TableOfContentsWithStabilityBadges: Story = { + render: args => ( + <> + + +
+ +
+ + ), + args: { + items: { + 'components.metabar.readingTime': '15 minutes', + 'components.metabar.addedIn': 'v1.0.0', + }, + headings: { + items: TOC_ENTRIES.map((entry, index) => { + const badge = entry.stability ? ( + + {entry.stability[0]} + + ) : null; + + const value = + badge && entry.badgePosition === 'leading' ? ( + <> + {badge} {entry.text} + + ) : ( + <> + {entry.text} + {badge} + + ); + + return { + value: value as unknown as string, + depth: entry.depth, + data: { id: `toc-${index}` }, + }; + }), + }, + }, +};