import '../../styles/app.css';
import '@luke-ui/theme-tactile/stylesheet.css';
import { Prose } from '@luke-ui/react/prose';
import { Provider } from '@luke-ui/react/provider';
import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline';
import {
createMemoryHistory,
createRootRoute,
createRouter,
RouterProvider,
} from '@tanstack/react-router';
import type { ReactNode } from 'react';
import { act } from 'react';
import type { Root } from 'react-dom/client';
import { createRoot } from 'react-dom/client';
import { afterEach, assert, expect, test } from 'vite-plus/test';
import { CodeBlock } from '../code-block/code-block.js';
import { ComponentPropsTable } from '../component-props-table.js';
import { ExampleBlock } from '../example-block.js';
import { IconGallery } from '../icon-gallery.js';
import { DocsThemeRoot } from '../theme-controls.js';
let container: HTMLElement | undefined;
let root: Root | undefined;
afterEach(() => {
if (root) act(() => root?.unmount());
container?.remove();
container = undefined;
root = undefined;
});
// Elements Prose styles. A widget root that opts out must not carry these fingerprints.
const PROSE_BLOCKS =
'p, ul, ol, li, dl, dt, dd, blockquote, pre, hr, figure, figcaption, table, th, td';
/** Prose-owned values that neither the UA nor typical widget chrome use without Preflight. */
function hasProseFingerprint(element: Element): boolean {
const style = getComputedStyle(element);
if (element.matches('ul, ol')) {
return style.paddingInlineStart === '24px';
}
if (element.matches('li')) {
return style.marginBlockStart === '12px';
}
if (element.matches('table')) {
// Prose forces `display: block` so a narrow table can scroll; the UA keeps `table`.
return style.display === 'block';
}
if (element.matches('th, td')) {
return style.paddingBlock === '8px' && style.paddingInline === '12px';
}
if (element.matches('pre')) {
return style.paddingBlock === '12px' && style.paddingInline === '16px';
}
if (element.matches('p')) {
// Capsize trim boxes are Prose-only on plain paragraphs.
const before = getComputedStyle(element, '::before');
return before.display === 'table' && before.content.includes('""');
}
if (element.matches('blockquote, figure')) {
// Prose sibling gaps (`* + blockquote|figure` → space.40). UA margins differ.
return style.marginBlockStart === '40px';
}
if (element.matches('hr')) {
return style.blockSize === '0px' && style.borderBlockStartStyle === 'solid';
}
return false;
}
function proseLeaks(widget: Element) {
return [...widget.querySelectorAll(PROSE_BLOCKS)]
.filter((element) => element.closest('[data-prose-example]') == null)
.filter(hasProseFingerprint)
.map((element) => element.outerHTML.slice(0, 80));
}
function widgetRoot(selector: string) {
assert(container, 'expected a container');
const widget = container.querySelector(selector);
assert(widget, `expected ${selector}`);
return widget;
}
test('keeps Prose rhythm off an ExampleBlock and its example', async () => {
await renderInProse();
const frame = widgetRoot('.not-prose');
expect(getComputedStyle(frame).marginBlockStart).toBe('16px');
expect(frame.querySelectorAll('li')).toHaveLength(2);
expect(proseLeaks(frame)).toEqual([]);
});
test('lets a Prose example inside an ExampleBlock keep its own rhythm', async () => {
await renderInProse();
const frame = widgetRoot('.not-prose');
const nestedList = frame.querySelector('ul');
assert(nestedList, 'expected a list inside the Prose example');
expect(getComputedStyle(nestedList).listStyleType).toBe('disc');
expect(getComputedStyle(nestedList).paddingInlineStart).toBe('24px');
const items = nestedList.querySelectorAll(':scope > li');
assert(items[1], 'expected a second list item');
expect(getComputedStyle(items[1]).marginBlockStart).not.toBe('0px');
});
test('keeps Prose rhythm off a CodeBlock', async () => {
await renderInProse();
const figure = widgetRoot('figure');
expect(getComputedStyle(figure).marginBlockStart).toBe('0px');
expect(proseLeaks(figure)).toEqual([]);
});
test('keeps Prose rhythm off a component props table', async () => {
await renderInProse(
,
);
const table = widgetRoot('#props');
expect(table.classList.contains('not-prose')).toBe(true);
expect(table.textContent).toContain('Visible label.');
expect(proseLeaks(table)).toEqual([]);
});
test('keeps Prose rhythm off the icon gallery', async () => {
await renderInProse();
const gallery = widgetRoot('.not-prose');
expect(proseLeaks(gallery)).toEqual([]);
});
test('still applies Prose rhythm to content around the widgets', async () => {
await renderInProse(
<>
First.
Second.
Quoted.
>,
);
const second = widgetRoot('[data-testid="second"]');
expect(getComputedStyle(second).marginBlockStart).toBe('32px');
expect(getComputedStyle(second, '::before').display).toBe('table');
expect(getComputedStyle(widgetRoot('[data-testid="list"]')).listStyleType).toBe('disc');
expect(getComputedStyle(widgetRoot('[data-testid="list"]')).paddingInlineStart).toBe('24px');
expect(getComputedStyle(widgetRoot('[data-testid="second-item"]')).marginBlockStart).toBe('12px');
expect(getComputedStyle(widgetRoot('[data-testid="quote"]')).marginBlockStart).toBe('40px');
expect(getComputedStyle(widgetRoot('[data-testid="figure"]')).marginBlockStart).toBe('40px');
expect(getComputedStyle(widgetRoot('[data-testid="table"]')).display).toBe('block');
const cell = widgetRoot('[data-testid="cell"]');
expect(getComputedStyle(cell).paddingBlock).toBe('8px');
expect(getComputedStyle(cell).paddingInline).toBe('12px');
// Positive control: fingerprints that proseLeaks must keep detecting, including cells.
const leaks = proseLeaks(widgetRoot('[data-testid="article"]'));
expect(leaks.some((html) => html.includes('data-testid="cell"'))).toBe(true);
});
async function renderInProse(children: ReactNode) {
const rootRoute = createRootRoute({
component: () => (
Lead paragraph.
{children}
),
});
const router = createRouter({
history: createMemoryHistory({ initialEntries: ['/'] }),
routeTree: rootRoute,
});
container = document.body.appendChild(document.createElement('div'));
container.style.inlineSize = '800px';
root = createRoot(container);
await act(async () => {
root?.render();
await router.load();
});
// Example modules load lazily behind Suspense, so keep wrapping the wait in `act`.
// Bound it so a module that never resolves fails instead of hanging the test.
const deadline = Date.now() + 10_000;
while (container.querySelector('[role="region"][aria-label^="Loading"]') != null) {
if (Date.now() > deadline) {
throw new Error(
`Timed out waiting for the example module to load. Rendered: ${container.textContent ?? ''}`,
);
}
// oxlint-disable-next-line no-await-in-loop
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
}
}