Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
81 changes: 81 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,84 @@
}
}
}

/*
* 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
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
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 opacity-100;
}
}
75 changes: 57 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,54 @@ 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.
const tooltipAnchorId = useId();
const anchorName = `--badge-tooltip-${tooltipAnchorId.replace(
/[^a-zA-Z0-9]/g,
''
)}`;

const badge = (
<span
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
role="tooltip"
className={styles.tooltip}
style={{ positionAnchor: anchorName } as CSSProperties}
>
{tooltip}
Comment thread
btea marked this conversation as resolved.
</span>
</>
);
};

export default Badge;
138 changes: 138 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,140 @@ 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
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}` },
};
}),
},
},
};