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
83 changes: 83 additions & 0 deletions packages/ui-components/src/Common/Badge/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
77 changes: 59 additions & 18 deletions packages/ui-components/src/Common/Badge/index.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -10,6 +16,7 @@ type BadgeSize = 'small' | 'medium';
type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
size?: BadgeSize;
kind?: BadgeKind;
'data-tooltip'?: string;
};

const Badge: FC<PropsWithChildren<BadgeProps>> = ({
Expand All @@ -18,22 +25,56 @@ const Badge: FC<PropsWithChildren<BadgeProps>> = ({
className,
children,
...props
}) => (
<span
className={classNames(
styles.badge,
styles[kind],
styles[size],
{
[styles.circular]:
typeof children === 'string' && children.length === 1,
},
className
)}
{...props}
>
{children}
</span>
);
}) => {
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 = (
<span
aria-describedby={tooltip ? tooltipId : undefined}
className={classNames(
styles.badge,
styles[kind],
styles[size],
{
[styles.circular]:
typeof children === 'string' && children.length === 1,
},
className
)}
style={tooltip ? ({ anchorName, ...style } as CSSProperties) : style}
{...rest}
>
{children}
</span>
);

if (!tooltip) {
return badge;
}

return (
<>
{badge}

<span
id={tooltipId}
role="tooltip"
className={styles.tooltip}
style={{ positionAnchor: anchorName } as CSSProperties}
>
{tooltip}
Comment thread
btea marked this conversation as resolved.
</span>
</>
);
};

export default Badge;
139 changes: 139 additions & 0 deletions packages/ui-components/src/Containers/MetaBar/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<TocEntry> = [
{ 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 => (
<>
<style>{`
.sb-compact-toc dd ol li { font-size: 0.75rem; line-height: 1rem; }
.sb-compact-toc dd ol { gap: 0.25rem; }
.sb-compact-toc dd { margin-bottom: 1rem; }
`}</style>

<div className="sb-compact-toc">
<MetaBar {...args} />
</div>
</>
),
args: {
items: {
'components.metabar.readingTime': '15 minutes',
'components.metabar.addedIn': 'v1.0.0',
},
headings: {
items: TOC_ENTRIES.map((entry, index) => {
const badge = entry.stability ? (
<Badge
aria-label={`Stability: ${entry.stability}`}
className={entry.badgePosition === 'leading' ? 'mr-1' : 'ml-1'}
data-tooltip={entry.stability}
tabIndex={0}
>
{entry.stability[0]}
</Badge>
) : 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}` },
};
}),
},
},
};
Loading