diff --git a/apps/docs/src/components/code-block/code-block.browser.test.tsx b/apps/docs/src/components/code-block/code-block.browser.test.tsx index 3d42eb54..ad86e21c 100644 --- a/apps/docs/src/components/code-block/code-block.browser.test.tsx +++ b/apps/docs/src/components/code-block/code-block.browser.test.tsx @@ -136,62 +136,43 @@ test('opts out of prose inline-code chrome on the fence code element', () => { expect(styles.backgroundColor).toBe('rgba(0, 0, 0, 0)'); }); -test('scrolls horizontally and keeps overlay copy on the physical right in RTL docs', async () => { - await page.viewport(320, 720); - document.documentElement.dir = 'rtl'; - - try { - renderCodeBlock(); - - const viewport = page.getByRole('region', { name: 'Code' }).element(); - expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth); - - const copyButton = page.getByRole('button', { name: 'Copy' }).element(); - const figure = copyButton.closest('figure'); - assert(figure != null, 'Expected a figure ancestor'); - - // Figure forces LTR like Fumadocs so copy stays on the physical right. - expect(figure.getAttribute('dir')).toBe('ltr'); - expect(getComputedStyle(figure).direction).toBe('ltr'); - - const figureBox = figure.getBoundingClientRect(); - const buttonBox = copyButton.getBoundingClientRect(); - const buttonMidX = (buttonBox.left + buttonBox.right) / 2; - const figureMidX = (figureBox.left + figureBox.right) / 2; - expect(buttonMidX).toBeGreaterThan(figureMidX); - - // Full-width scrollport — not a side column beside the button. - expect(viewport.getBoundingClientRect().width).toBeGreaterThan(figure.clientWidth * 0.9); - } finally { - document.documentElement.dir = 'ltr'; - } -}); - -test('scrolls horizontally with titled copy on the header inline-end in RTL', async () => { - await page.viewport(320, 720); - document.documentElement.dir = 'rtl'; - - try { - renderCodeBlock(); - - const viewport = page.getByRole('region', { name: 'Source' }).element(); - expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth); - expect(viewport.tabIndex).toBe(0); - - const copyButton = page.getByRole('button', { name: 'Copy' }).element(); - const figure = copyButton.closest('figure'); - assert(figure != null, 'Expected a figure ancestor'); - expect(figure.getAttribute('dir')).toBe('ltr'); - - const figureBox = figure.getBoundingClientRect(); - const buttonBox = copyButton.getBoundingClientRect(); - const buttonMidX = (buttonBox.left + buttonBox.right) / 2; - const figureMidX = (figureBox.left + figureBox.right) / 2; - expect(buttonMidX).toBeGreaterThan(figureMidX); - } finally { - document.documentElement.dir = 'ltr'; - } -}); +for (const copyCase of [ + { name: 'untitled overlay copy', title: undefined as string | undefined }, + { name: 'titled header copy', title: 'Source' }, +]) { + test(`scrolls horizontally and keeps ${copyCase.name} on the physical right in RTL docs`, async () => { + await page.viewport(320, 720); + document.documentElement.dir = 'rtl'; + + try { + renderCodeBlock(); + + const regionName = copyCase.title ?? 'Code'; + const viewport = page.getByRole('region', { name: regionName }).element(); + expect(viewport.scrollWidth).toBeGreaterThan(viewport.clientWidth); + expect(viewport.tabIndex).toBe(0); + + const copyButton = page.getByRole('button', { name: 'Copy' }).element(); + const figure = copyButton.closest('figure'); + assert(figure != null, 'Expected a figure ancestor'); + + // Figure forces LTR like Fumadocs so copy stays on the physical right. + expect(figure.getAttribute('dir')).toBe('ltr'); + expect(getComputedStyle(figure).direction).toBe('ltr'); + + const figureBox = figure.getBoundingClientRect(); + const buttonBox = copyButton.getBoundingClientRect(); + const buttonMidX = (buttonBox.left + buttonBox.right) / 2; + const figureMidX = (figureBox.left + figureBox.right) / 2; + expect(buttonMidX).toBeGreaterThan(figureMidX); + + // Full-width scrollport — not a side column beside the button. + expect(viewport.getBoundingClientRect().width).toBeGreaterThan(figure.clientWidth * 0.9); + } finally { + document.documentElement.dir = 'ltr'; + } + }); +} test('the CodeBlock scene has no axe violations', async () => { renderCodeBlock(); diff --git a/apps/docs/src/lib/agent-negotiation.test.ts b/apps/docs/src/lib/agent-negotiation.test.ts index 1d377134..578c4809 100644 --- a/apps/docs/src/lib/agent-negotiation.test.ts +++ b/apps/docs/src/lib/agent-negotiation.test.ts @@ -9,30 +9,27 @@ import { const CHROME_ACCEPT = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7'; -test.each<[accept: string | null, expected: boolean]>([ - ['text/markdown', true], +for (const [accept, expected] of [ [null, false], - ['', false], + ['text/markdown', true], ['*/*', false], ['text/*', false], ['text/markdown, text/html;q=0.9', true], ['text/html, text/markdown;q=0.5', false], ['text/markdown;q=0.8, */*;q=1', false], - ['text/markdown;q=1, */*;q=0.8', true], ['text/markdown;q=0.5, text/*;q=0.9', false], - ['text/markdown, text/*;q=0.9', true], + // Explicit text/html;q=0 beats */* when scoring HTML, so markdown still wins. ['text/markdown;q=0.5, text/html;q=0, */*', true], ['text/markdown;q=0', false], - ['text/markdown, text/html', true], ['TEXT/Markdown; charset=utf-8', true], - ['text/markdown;charset=utf-8;q=0.5, text/html;q=0.4', true], ['text/markdown;q=abc', false], - ['text/markdown;q=2', false], ['text/markdown, text/html;q=nope', true], [CHROME_ACCEPT, false], -])('prefersMarkdown(%j) is %s', (accept, expected) => { - expect(prefersMarkdown(accept)).toBe(expected); -}); +] as const satisfies ReadonlyArray) { + test(`prefersMarkdown(${JSON.stringify(accept)}) is ${expected}`, () => { + expect(prefersMarkdown(accept)).toBe(expected); + }); +} test('maps the homepage to /index.md', () => { expect(markdownPathFor('/')).toBe('/index.md'); @@ -196,24 +193,50 @@ test('builds a root-relative Markdown 404 body when the .md route returns no Mar expect(await res.text()).toBe(notFoundMarkdown('/nope')); }); -test('serves an empty Markdown 404 body for a HEAD request', async () => { - const next = makeRealisticNext('text/markdown'); - const fetchMock = vi.fn<(url: URL) => Promise>(async () => { - return new Response('# Page not found\n', { - headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, - status: 404, - }); - }); +for (const headCase of [ + { + name: '404 markdown twin', + setup: async () => { + const next = makeRealisticNext('text/markdown'); + const fetchMock = vi.fn<(url: URL) => Promise>(async () => { + return new Response('# Page not found\n', { + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + status: 404, + }); + }); + const request = new Request('https://example.com/nope', { + headers: { Accept: 'text/markdown' }, + method: 'HEAD', + }); + return { fetchMock, next, request, status: 404 }; + }, + }, + { + name: '200 markdown twin', + setup: async () => { + const next = vi.fn<() => Promise>(); + const fetchMock = vi.fn<(url: URL) => Promise>(async () => { + return new Response('# Luke UI\n', { + headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, + status: 200, + }); + }); + const request = new Request('https://luke-ui.netlify.app/', { + headers: { Accept: 'text/markdown' }, + method: 'HEAD', + }); + return { fetchMock, next, request, status: 200 }; + }, + }, +]) { + test(`serves an empty body for a HEAD ${headCase.name} request`, async () => { + const { fetchMock, next, request, status } = await headCase.setup(); + const res = await handleRequest(request, { fetch: fetchMock, next }); - const request = new Request('https://example.com/nope', { - headers: { Accept: 'text/markdown' }, - method: 'HEAD', + expect(res.status).toBe(status); + expect(res.body).toBeNull(); }); - const res = await handleRequest(request, { fetch: fetchMock, next }); - - expect(res.status).toBe(404); - expect(res.body).toBeNull(); -}); +} test('falls back to HTML with merged Vary when the path has no Markdown twin but the page exists', async () => { const next = vi.fn<(request?: Request) => Promise>(async () => { @@ -261,9 +284,8 @@ test('passes through an already-Markdown request unchanged', async () => { expect(fetchMock).not.toHaveBeenCalled(); }); -test.each(['/api', '/api/search'])( - 'passes through %s unchanged, regardless of Accept', - async (pathname) => { +for (const pathname of ['/api', '/api/search']) { + test(`passes through ${pathname} unchanged, regardless of Accept`, async () => { const apiResponse = new Response('{}', { headers: { 'Content-Type': 'application/json' } }); const next = vi.fn<() => Promise>(async () => apiResponse); const fetchMock = vi.fn<(url: URL) => Promise>(); @@ -277,8 +299,8 @@ test.each(['/api', '/api/search'])( expect(fetchMock).not.toHaveBeenCalled(); expect(res).toBe(apiResponse); expect(res.headers.get('Vary')).toBeNull(); - }, -); + }); +} test('negotiates Markdown for /apiary, a sibling path that merely starts with "api"', async () => { const next = vi.fn<(request?: Request) => Promise>(); @@ -298,27 +320,3 @@ test('negotiates Markdown for /apiary, a sibling path that merely starts with "a expect(next).not.toHaveBeenCalled(); expect(res.status).toBe(200); }); - -test('serves an empty body for a HEAD request', async () => { - const next = vi.fn<() => Promise>(async () => { - return new Response('# Luke UI\n', { - headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, - status: 200, - }); - }); - const fetchMock = vi.fn<(url: URL) => Promise>(async () => { - return new Response('# Luke UI\n', { - headers: { 'Content-Type': 'text/markdown; charset=utf-8' }, - status: 200, - }); - }); - - const request = new Request('https://luke-ui.netlify.app/', { - headers: { Accept: 'text/markdown' }, - method: 'HEAD', - }); - const res = await handleRequest(request, { fetch: fetchMock, next }); - - expect(res.status).toBe(200); - expect(res.body).toBeNull(); -}); diff --git a/apps/docs/src/lib/check-docs.test.ts b/apps/docs/src/lib/check-docs.test.ts index 51d28649..c1891d9e 100644 --- a/apps/docs/src/lib/check-docs.test.ts +++ b/apps/docs/src/lib/check-docs.test.ts @@ -295,31 +295,6 @@ source: packages/@luke-ui/react/src/exports/box.ts expect(findDocsIssues(paths)).toEqual([]); }); -test('does not report banned terms found only in JSX/MDX attribute values', () => { - const paths = createDocsFixture({ - authored: { - 'attributes.mdx': `--- -title: Attributes ---- - - - Invite people to the workspace. - - -## Continue learning - - - - Choose a styling approach. - - -`, - }, - }); - - expect(findDocsIssues(paths)).toEqual(['docs/attributes.mdx: terminology "people"']); -}); - test('reports prose patterns outside code and ignores them inside fences', () => { const paths = createDocsFixture({ authored: { @@ -432,100 +407,9 @@ function inventoryFixture(overrides: { }); } -test('accepts a guide that the root and category metadata both list', () => { - expect(findDocsIssues(inventoryFixture({}))).toEqual([]); -}); - -test('reports a guide that is absent from the root component metadata', () => { - const paths = inventoryFixture({ - metadata: { - 'actions/meta.json': { pages: [], title: 'Actions' }, - 'meta.json': { pages: ['---Actions---'], root: true, title: 'Components' }, - }, - }); - - expect(findDocsIssues(paths)).toEqual([ - 'component-guide-inventory: actions/button.mdx: guide is absent from the root component metadata (expected entry "actions/button")', - ]); -}); - -test('reports stale category metadata when the root category disappears', () => { - const paths = inventoryFixture({ - metadata: { - 'actions/meta.json': { pages: ['button'], title: 'Actions' }, - 'meta.json': { pages: [], root: true, title: 'Components' }, - }, - }); - - expect(findDocsIssues(paths)).toEqual([ - 'component-guide-inventory: actions/button.mdx: guide is absent from the root component metadata (expected entry "actions/button")', - 'component-guide-inventory: actions/meta.json: pages [button] do not match the root metadata (expected [])', - ]); -}); - -test('reports leftover category metadata when no guide remains in that category', () => { - const paths = inventoryFixture({ - components: {}, - metadata: { - 'actions/meta.json': { pages: ['button'], title: 'Actions' }, - 'meta.json': { pages: [], root: true, title: 'Components' }, - }, - packageExports: [], - sourceDirs: [], - }); - - expect(findDocsIssues(paths)).toEqual([ - 'component-guide-inventory: actions/meta.json: pages [button] do not match the root metadata (expected [])', - ]); -}); - -test('reports a root metadata entry that has no guide', () => { - const paths = inventoryFixture({ - metadata: { - 'actions/meta.json': { pages: ['button', 'link'], title: 'Actions' }, - 'meta.json': { - pages: ['---Actions---', 'actions/button', 'actions/link'], - root: true, - title: 'Components', - }, - }, - }); - - expect(findDocsIssues(paths)).toEqual([ - 'component-guide-inventory: components/meta.json: entry "actions/link" has no guide (expected actions/link.mdx)', - ]); -}); - -test('reports a repeated root metadata entry', () => { - const paths = inventoryFixture({ - metadata: { - 'actions/meta.json': { pages: ['button', 'button'], title: 'Actions' }, - 'meta.json': { - pages: ['---Actions---', 'actions/button', 'actions/button'], - root: true, - title: 'Components', - }, - }, - }); - - expect(findDocsIssues(paths)).toEqual([ - 'component-guide-inventory: components/meta.json: entry "actions/button" is repeated', - ]); -}); +type InventoryFixtureOverrides = Parameters[0]; -test('reports category metadata that does not match the root metadata', () => { - const paths = inventoryFixture({ - metadata: { - 'actions/meta.json': { pages: ['link', 'button'], title: 'Actions' }, - 'meta.json': { - pages: ['---Actions---', 'actions/button', 'actions/link'], - root: true, - title: 'Components', - }, - }, - components: { - 'actions/button.mdx': INVENTORY_GUIDE, - 'actions/link.mdx': `--- +const LINK_INVENTORY_GUIDE = `--- title: Link source: packages/@luke-ui/react/src/exports/link.ts --- @@ -539,32 +423,135 @@ The visible label is the accessible name. ## API -`, +`; + +const inventoryCases: ReadonlyArray<{ + expected: ReadonlyArray; + name: string; + overrides: InventoryFixtureOverrides; +}> = [ + { + expected: [], + name: 'accepts a guide that the root and category metadata both list', + overrides: {}, + }, + { + expected: [ + 'component-guide-inventory: actions/button.mdx: guide is absent from the root component metadata (expected entry "actions/button")', + ], + name: 'reports a guide that is absent from the root component metadata', + overrides: { + metadata: { + 'actions/meta.json': { pages: [], title: 'Actions' }, + 'meta.json': { pages: ['---Actions---'], root: true, title: 'Components' }, + }, + }, + }, + { + expected: [ + 'component-guide-inventory: actions/button.mdx: guide is absent from the root component metadata (expected entry "actions/button")', + 'component-guide-inventory: actions/meta.json: pages [button] do not match the root metadata (expected [])', + ], + name: 'reports stale category metadata when the root category disappears', + overrides: { + metadata: { + 'actions/meta.json': { pages: ['button'], title: 'Actions' }, + 'meta.json': { pages: [], root: true, title: 'Components' }, + }, + }, + }, + { + expected: [ + 'component-guide-inventory: actions/meta.json: pages [button] do not match the root metadata (expected [])', + ], + name: 'reports leftover category metadata when no guide remains in that category', + overrides: { + components: {}, + metadata: { + 'actions/meta.json': { pages: ['button'], title: 'Actions' }, + 'meta.json': { pages: [], root: true, title: 'Components' }, + }, + packageExports: [], + sourceDirs: [], + }, + }, + { + expected: [ + 'component-guide-inventory: components/meta.json: entry "actions/link" has no guide (expected actions/link.mdx)', + ], + name: 'reports a root metadata entry that has no guide', + overrides: { + metadata: { + 'actions/meta.json': { pages: ['button', 'link'], title: 'Actions' }, + 'meta.json': { + pages: ['---Actions---', 'actions/button', 'actions/link'], + root: true, + title: 'Components', + }, + }, + }, + }, + { + expected: [ + 'component-guide-inventory: components/meta.json: entry "actions/button" is repeated', + ], + name: 'reports a repeated root metadata entry', + overrides: { + metadata: { + 'actions/meta.json': { pages: ['button', 'button'], title: 'Actions' }, + 'meta.json': { + pages: ['---Actions---', 'actions/button', 'actions/button'], + root: true, + title: 'Components', + }, + }, + }, + }, + { + expected: [ + 'component-guide-inventory: actions/meta.json: pages [link, button] do not match the root metadata (expected [button, link])', + ], + name: 'reports category metadata that does not match the root metadata', + overrides: { + components: { + 'actions/button.mdx': INVENTORY_GUIDE, + 'actions/link.mdx': LINK_INVENTORY_GUIDE, + }, + metadata: { + 'actions/meta.json': { pages: ['link', 'button'], title: 'Actions' }, + 'meta.json': { + pages: ['---Actions---', 'actions/button', 'actions/link'], + root: true, + title: 'Components', + }, + }, + packageExports: ['./button', './link'], + sourceDirs: ['button', 'link'], }, - packageExports: ['./button', './link'], - sourceDirs: ['button', 'link'], + }, + { + expected: [ + 'component-guide-inventory: actions/button.mdx: source "packages/@luke-ui/react/src/exports/button.ts" is not a public package entry point (expected export "./button" in @luke-ui/react)', + ], + name: 'reports a guide source that is not a public package entry point', + overrides: { packageExports: ['./link'] }, + }, + { + expected: [ + 'component-guide-inventory: actions/button.mdx: source "packages/@luke-ui/react/src/exports/button.ts" does not exist', + ], + name: 'reports a missing exports module', + overrides: { sourceDirs: [] }, + }, +]; + +for (const inventoryCase of inventoryCases) { + test(`${inventoryCase.name}`, () => { + expect(findDocsIssues(inventoryFixture(inventoryCase.overrides))).toEqual( + inventoryCase.expected, + ); }); - - expect(findDocsIssues(paths)).toEqual([ - 'component-guide-inventory: actions/meta.json: pages [link, button] do not match the root metadata (expected [button, link])', - ]); -}); - -test('reports a guide source that is not a public package entry point', () => { - const paths = inventoryFixture({ packageExports: ['./link'] }); - - expect(findDocsIssues(paths)).toEqual([ - 'component-guide-inventory: actions/button.mdx: source "packages/@luke-ui/react/src/exports/button.ts" is not a public package entry point (expected export "./button" in @luke-ui/react)', - ]); -}); - -test('reports a missing exports module', () => { - const paths = inventoryFixture({ sourceDirs: [] }); - - expect(findDocsIssues(paths)).toEqual([ - 'component-guide-inventory: actions/button.mdx: source "packages/@luke-ui/react/src/exports/button.ts" does not exist', - ]); -}); +} function createDocsFixture(input: { authored?: Record; diff --git a/apps/docs/src/lib/component-prop-analysis.test.ts b/apps/docs/src/lib/component-prop-analysis.test.ts index 8c9102ea..d2aabeab 100644 --- a/apps/docs/src/lib/component-prop-analysis.test.ts +++ b/apps/docs/src/lib/component-prop-analysis.test.ts @@ -50,63 +50,6 @@ function forwardsDomPropsForExport(project: PropProject, path: string, name: str return typeForwardsDomProps(declaration, reactSrcDir); } -test( - 'keeps documented press props on Button while hiding generic DOM props', - async () => { - const names = await visiblePropNames( - 'packages/@luke-ui/react/src/core/button/button.tsx', - 'ButtonProps', - ); - expect(names).toContain('onPress'); - expect(names).toContain('appearance'); - expect(names).not.toContain('onClick'); - expect(names).not.toContain('onPointerMoveCapture'); - expect(names).not.toContain('itemProp'); - }, - TS_MORPH_TEST_TIMEOUT, -); - -/** - * `AriaBaseButtonProps` (react-aria's `useButton`) declares these directly alongside `type` and the - * props `DocumentedPressProps` redeclares, on the very same interface body, reached through a plain - * `extends` — no syntax separates them from their documented siblings. `button.mdx` and - * `icon-button.mdx` teach none of them and both point at the upstream React Aria page via `reactAria` - * frontmatter, so per `docs/DOCUMENTATION.md` they belong behind that link, not in the table. - */ -const ARIA_BASE_BUTTON_LONG_TAIL = [ - 'formMethod', - 'formAction', - 'formTarget', - 'formEncType', - 'formNoValidate', - 'name', - 'value', - 'preventFocusOnPress', - 'aria-pressed', - 'aria-expanded', - 'aria-haspopup', - 'aria-controls', - 'aria-current', - 'aria-disabled', -] as const; - -test( - "hides AriaBaseButtonProps' undocumented long tail on Button while keeping its labeling contract", - async () => { - const names = await visiblePropNames( - 'packages/@luke-ui/react/src/core/button/button.tsx', - 'ButtonProps', - ); - for (const prop of ARIA_BASE_BUTTON_LONG_TAIL) { - expect(names, `ButtonProps should hide ${prop}`).not.toContain(prop); - } - // The fixed `AriaLabelingProps` contract stays visible even though Button never redeclares it. - expect(names).toContain('aria-label'); - expect(names).toContain('aria-labelledby'); - }, - TS_MORPH_TEST_TIMEOUT, -); - test( 'keeps documented form and field props on TextField while hiding generic DOM props', async () => { @@ -125,21 +68,6 @@ test( TS_MORPH_TEST_TIMEOUT, ); -test( - 'keeps the primitive button styling and press contract without generic DOM props', - async () => { - const names = await visiblePropNames( - 'packages/@luke-ui/react/src/core/primitives/button/button.tsx', - 'ButtonProps', - ); - expect(names).toContain('appearance'); - expect(names).toContain('onPress'); - expect(names).not.toContain('onClick'); - expect(names).not.toContain('onPointerMoveCapture'); - }, - TS_MORPH_TEST_TIMEOUT, -); - test( 'keeps typography props on Heading while hiding generic DOM props', async () => { @@ -198,57 +126,6 @@ test( TS_MORPH_TEST_TIMEOUT, ); -test( - "keeps IconProps' curated SVG props visible without marking Icon as DOM-forwarding", - async () => { - const names = await visiblePropNames( - 'packages/@luke-ui/react/src/core/icon/icon.tsx', - 'IconProps', - ); - // Icon deliberately `Pick`s these 5 props from `SVGAttributes`/`AriaAttributes`; the old - // declaration-origin heuristic hid them because `Pick` preserves React's own declaration site. - expect(names).toContain('aria-hidden'); - expect(names).toContain('className'); - expect(names).toContain('id'); - expect(names).toContain('style'); - expect(names).toContain('viewBox'); - // Unrelated DOM/event noise never picked by Icon must stay absent. - expect(names).not.toContain('onClick'); - expect(names).not.toContain('onPointerMoveCapture'); - expect(names).not.toContain('tabIndex'); - - const { declaration } = await loadDoc( - 'packages/@luke-ui/react/src/core/icon/icon.tsx', - 'IconProps', - ); - expect(typeForwardsDomProps(declaration, reactSrcDir)).toBe(false); - expect( - forwardsDomPropsForExport( - await getSharedPropProject(repoRoot), - 'packages/@luke-ui/react/src/core/icon/icon.tsx', - 'IconProps', - ), - ).toBe(false); - }, - TS_MORPH_TEST_TIMEOUT, -); - -test( - "keeps VisuallyHidden's documented elementType prop visible while hiding generic DOM props", - async () => { - const names = await visiblePropNames( - 'packages/@luke-ui/react/src/core/visually-hidden/visually-hidden.tsx', - 'VisuallyHiddenProps', - ); - // The guide explicitly teaches ``. - expect(names).toContain('elementType'); - expect(names).not.toContain('onClick'); - expect(names).not.toContain('itemProp'); - expect(names).not.toContain('onPointerMoveCapture'); - }, - TS_MORPH_TEST_TIMEOUT, -); - test( 'keeps the redeclared form and state contract visible on the Combobox root primitive', async () => { @@ -260,10 +137,9 @@ test( expect(names).toContain('isReadOnly'); expect(names).toContain('isRequired'); expect(names).toContain('isInvalid'); - // `form` and `name` are the same react-aria long-tail prop names hidden on `ButtonProps` below — - // visible here specifically because `ComboboxRootRedeclaredRACProps` redeclares them with useful - // JSDoc, which is what "redeclared in Luke UI source wins" means in practice. Button never - // redeclares them, so they stay hidden there. The asymmetry is deliberate, not a bug. + // `form` and `name` are the same react-aria long-tail prop names hidden on AriaBaseButton + // types — visible here specifically because `ComboboxRootRedeclaredRACProps` redeclares them + // with useful JSDoc, which is what "redeclared in Luke UI source wins" means in practice. expect(names).toContain('name'); expect(names).toContain('form'); expect(names).toContain('validate'); @@ -277,75 +153,6 @@ test( TS_MORPH_TEST_TIMEOUT, ); -test( - "keeps LoadingSpinner's documented aria-label visible while hiding generic DOM props", - async () => { - const names = await visiblePropNames( - 'packages/@luke-ui/react/src/core/loading-spinner/loading-spinner.tsx', - 'LoadingSpinnerProps', - ); - expect(names).toContain('aria-label'); - expect(names).toContain('isLoading'); - expect(names).toContain('size'); - expect(names).not.toContain('onClick'); - expect(names).not.toContain('onPointerMoveCapture'); - expect(names).not.toContain('itemProp'); - expect(names).not.toContain('key'); - expect(names).not.toContain('ref'); - - const { declaration } = await loadDoc( - 'packages/@luke-ui/react/src/core/loading-spinner/loading-spinner.tsx', - 'LoadingSpinnerProps', - ); - expect(typeForwardsDomProps(declaration, reactSrcDir)).toBe(true); - }, - TS_MORPH_TEST_TIMEOUT, -); - -test( - "keeps InputGroupInput's documented aria-label and inputMode visible while hiding generic DOM props", - async () => { - const names = await visiblePropNames( - 'packages/@luke-ui/react/src/core/primitives/input-group/input-group.tsx', - 'InputGroupInputProps', - ); - for (const prop of ['aria-label', 'className', 'inputMode', 'ref', 'size'] as const) { - expect(names).toContain(prop); - } - for (const prop of [...GENERIC_DOM_NOISE, 'key'] as const) { - expect(names).not.toContain(prop); - } - - const { declaration } = await loadDoc( - 'packages/@luke-ui/react/src/core/primitives/input-group/input-group.tsx', - 'InputGroupInputProps', - ); - expect(typeForwardsDomProps(declaration, reactSrcDir)).toBe(true); - }, - TS_MORPH_TEST_TIMEOUT, -); - -test( - 'keeps a pure native wrapper empty while still forwarding DOM props', - async () => { - const names = await visiblePropNames( - 'packages/@luke-ui/react/src/core/kbd/kbd.tsx', - 'KbdProps', - ); - expect(names).toEqual([]); - for (const prop of [...GENERIC_DOM_NOISE, 'key', 'ref'] as const) { - expect(names).not.toContain(prop); - } - - const { declaration } = await loadDoc( - 'packages/@luke-ui/react/src/core/kbd/kbd.tsx', - 'KbdProps', - ); - expect(typeForwardsDomProps(declaration, reactSrcDir)).toBe(true); - }, - TS_MORPH_TEST_TIMEOUT, -); - /** * Representative generic DOM noise. None of it is a documented Luke UI prop, and every entry * reaches a component type only by inheriting a React element attribute bag wholesale, so no table @@ -357,9 +164,9 @@ const GENERIC_DOM_NOISE = ['itemProp', 'onClick', 'onPointerMoveCapture', 'tabIn * The types whose tables were empty before this analysis existed, now audited against what each * component's guide actually teaches rather than against whatever the analysis happens to emit. * - * `Kbd`, `Prose` and the two checkbox anatomy parts are pure element wrappers: their guides teach - * only that they render a native element with the component's own styling, and their source is a - * bare `extends ComponentProps<'kbd' | 'div' | 'span'>`. They document no Luke UI contract beyond + * `Kbd` stands for the pure native wrappers (`Prose`, checkbox anatomy parts, and similar): their + * guides teach only that they render a native element with the component's own styling, and their + * source is a bare `extends ComponentProps<'…'>`. They document no Luke UI contract beyond * pass-through DOM props, so their filtered tables are intentionally empty and rely on the * native-props note alone. * @@ -386,24 +193,6 @@ const AUDITED_TYPES: ReadonlyArray<{ path: 'packages/@luke-ui/react/src/core/kbd/kbd.tsx', visible: [], }, - { - forwardsDomProps: true, - name: 'ProseProps', - path: 'packages/@luke-ui/react/src/core/prose/prose.tsx', - visible: [], - }, - { - forwardsDomProps: true, - name: 'CheckboxControlProps', - path: 'packages/@luke-ui/react/src/core/primitives/checkbox/checkbox.tsx', - visible: [], - }, - { - forwardsDomProps: true, - name: 'CheckboxIndicatorProps', - path: 'packages/@luke-ui/react/src/core/primitives/checkbox/checkbox.tsx', - visible: [], - }, { // The guide teaches `aria-label` in Accessibility for naming the loading status region. forwardsDomProps: true, @@ -474,29 +263,32 @@ const AUDITED_TYPES: ReadonlyArray<{ }, ]; -test.each(AUDITED_TYPES)( - '$name documents its own contract without generic DOM props', - async ({ forwardsDomProps, hidden = [], name, path, visible }) => { - const names = await visiblePropNames(path, name); - - for (const prop of visible) { - expect(names, `${name} should document ${prop}`).toContain(prop); - } - const hiddenProps = [ - ...GENERIC_DOM_NOISE, - 'key', - ...(visible.includes('ref') ? [] : (['ref'] as const)), - ...hidden, - ]; - for (const prop of hiddenProps) { - expect(names, `${name} should hide ${prop}`).not.toContain(prop); - } - - const { declaration } = await loadDoc(path, name); - expect(typeForwardsDomProps(declaration, reactSrcDir)).toBe(forwardsDomProps); - }, - TS_MORPH_TEST_TIMEOUT, -); +for (const auditedType of AUDITED_TYPES) { + test( + `${auditedType.name} documents its own contract without generic DOM props`, + async () => { + const { forwardsDomProps, hidden = [], name, path, visible } = auditedType; + const names = await visiblePropNames(path, name); + + for (const prop of visible) { + expect(names, `${name} should document ${prop}`).toContain(prop); + } + const hiddenProps = [ + ...GENERIC_DOM_NOISE, + 'key', + ...(visible.includes('ref') ? [] : (['ref'] as const)), + ...hidden, + ]; + for (const prop of hiddenProps) { + expect(names, `${name} should hide ${prop}`).not.toContain(prop); + } + + const { declaration } = await loadDoc(path, name); + expect(typeForwardsDomProps(declaration, reactSrcDir)).toBe(forwardsDomProps); + }, + TS_MORPH_TEST_TIMEOUT, + ); +} test( 'keeps both branches of a union type documented and DOM-forwarding', @@ -638,11 +430,15 @@ test( ); /** - * Exact visible-prop sets for types whose shape depends on how React and React Aria declare their - * own interfaces. The structural analysis reads that upstream syntax, so an upstream release that - * moves a prop between a curated contract and a generic element attribute bag changes what these - * tables show. Pinning the whole set makes that change fail here loudly instead of silently - * rewriting a published API table. + * Exact visible-prop sets for the AriaBaseButton family. Those types inherit `AriaBaseButtonProps` + * (react-aria's `useButton`), which declares an undocumented long tail directly alongside genuinely + * documented siblings on the same interface body. The structural analysis reads that upstream + * syntax, so an upstream release that moves a prop between a curated contract and a generic element + * attribute bag changes what these tables show. Pinning the whole set makes that change fail here + * loudly instead of silently rewriting a published API table. + * + * `TextProps` is pinned for the same reason against RAC `Text` / `HTMLAttributes` churn. Types + * covered by `AUDITED_TYPES` are not re-pinned here. */ const PINNED_VISIBLE_PROPS: ReadonlyArray<{ exportName: string; @@ -650,14 +446,6 @@ const PINNED_VISIBLE_PROPS: ReadonlyArray<{ path: string; props: ReadonlyArray; }> = [ - { - // Every prop arrives through `Pick, …>` or a Luke UI interface, so - // nothing here may come from inheriting an attribute bag. - exportName: 'IconProps', - name: 'IconProps', - path: 'packages/@luke-ui/react/src/core/icon/icon.tsx', - props: ['aria-hidden', 'className', 'id', 'name', 'size', 'style', 'title', 'viewBox'], - }, { // `Text` omits RAC's `Text` props it redeclares and adds its own typography contract. Everything // below `HTMLAttributes` must be gone. @@ -683,40 +471,6 @@ const PINNED_VISIBLE_PROPS: ReadonlyArray<{ ], }, { - // `Code` composes `Text` and documents `lineClamp` and `textWrap`, while still forwarding - // native `` DOM props. - exportName: 'CodeProps', - name: 'CodeProps', - path: 'packages/@luke-ui/react/src/core/code/code.tsx', - props: ['lineClamp', 'textWrap'], - }, - { - exportName: 'LoadingSpinnerProps', - name: 'LoadingSpinnerProps', - path: 'packages/@luke-ui/react/src/core/loading-spinner/loading-spinner.tsx', - props: ['aria-label', 'children', 'color', 'isLoading', 'size'], - }, - { - exportName: 'InputGroupInputProps', - name: 'InputGroupInputProps', - path: 'packages/@luke-ui/react/src/core/primitives/input-group/input-group.tsx', - props: [ - 'aria-label', - 'className', - 'inputMode', - 'onHoverChange', - 'onHoverEnd', - 'onHoverStart', - 'placeholder', - 'ref', - 'render', - 'size', - ], - }, - { - // The five button-shaped types below all inherit `AriaBaseButtonProps` (react-aria's - // `useButton`), which declares `ARIA_BASE_BUTTON_LONG_TAIL` directly alongside genuinely - // documented siblings on the same interface body — see the comment above that list. exportName: 'ButtonProps', name: 'core ButtonProps', path: 'packages/@luke-ui/react/src/core/button/button.tsx', @@ -901,11 +655,14 @@ const PINNED_VISIBLE_PROPS: ReadonlyArray<{ }, ]; -test.each(PINNED_VISIBLE_PROPS)( - '$name shows exactly its documented props', - async ({ exportName, path, props }) => { - const names = await visiblePropNames(path, exportName); - expect([...names].sort()).toEqual([...props].sort()); - }, - TS_MORPH_TEST_TIMEOUT, -); +for (const pinned of PINNED_VISIBLE_PROPS) { + test( + `${pinned.name} shows exactly its documented props`, + async () => { + const { exportName, path, props } = pinned; + const names = await visiblePropNames(path, exportName); + expect([...names].sort()).toEqual([...props].sort()); + }, + TS_MORPH_TEST_TIMEOUT, + ); +} diff --git a/apps/docs/src/lib/docs-prose-rules.test.ts b/apps/docs/src/lib/docs-prose-rules.test.ts index bded7868..16f50c7f 100644 --- a/apps/docs/src/lib/docs-prose-rules.test.ts +++ b/apps/docs/src/lib/docs-prose-rules.test.ts @@ -59,39 +59,45 @@ test('"us" stays case-sensitive so it does not match inside another word', () => expect(findProseRuleLabels('This uses the default configuration.')).toEqual([]); }); -test('extractProseForRules strips an HTML entity so — is not read as an unspaced em dash', () => { - const prose = extractProseForRules('Use the — entity here.'); - expect(findProseRuleLabels(prose)).toEqual([]); -}); - -test('extractProseForRules strips inline code so a banned word inside it is not reported', () => { - const prose = extractProseForRules('Call `we.simply()` to configure it.'); - expect(findProseRuleLabels(prose)).toEqual([]); -}); - -test('extractProseForRules strips a JSX attribute value so a banned word inside it is not reported', () => { - const prose = extractProseForRules(''); - expect(findProseRuleLabels(prose)).toEqual([]); -}); - -test('extractProseForRules strips a single-line import line so a banned word in a binding name is not reported', () => { - const prose = extractProseForRules("import { we, us } from './data';\n\nConfigure it."); - expect(findProseRuleLabels(prose)).toEqual([]); -}); - -test('extractProseForRules strips fenced code so a banned word inside a code sample is not reported', () => { - const prose = extractProseForRules("```tsx\nconst users = ['we', 'us'];\n```\n\nConfigure it."); - expect(findProseRuleLabels(prose)).toEqual([]); -}); - -test('extractProseForRules strips frontmatter so a banned word in a title is not reported', () => { - const prose = extractProseForRules('---\ntitle: We simply note that\n---\n\nConfigure it.'); - expect(findProseRuleLabels(prose)).toEqual([]); -}); - -test('extractProseForRules leaves ordinary prose outside code, tags, and imports intact', () => { - const prose = extractProseForRules( - "import { Foo } from './foo';\n\nThis prose keeps its semicolon; it should still be reported.\n\n", - ); - expect(findProseRuleLabels(prose)).toEqual(['prose semicolon']); -}); +for (const proseCase of [ + { + expected: [] as ReadonlyArray, + name: 'strips an HTML entity so — is not read as an unspaced em dash', + source: 'Use the — entity here.', + }, + { + expected: [], + name: 'strips inline code so a banned word inside it is not reported', + source: 'Call `we.simply()` to configure it.', + }, + { + expected: [], + name: 'strips a JSX attribute value so a banned word inside it is not reported', + source: '', + }, + { + expected: [], + name: 'strips a single-line import line so a banned word in a binding name is not reported', + source: "import { we, us } from './data';\n\nConfigure it.", + }, + { + expected: [], + name: 'strips fenced code so a banned word inside a code sample is not reported', + source: "```tsx\nconst users = ['we', 'us'];\n```\n\nConfigure it.", + }, + { + expected: [], + name: 'strips frontmatter so a banned word in a title is not reported', + source: '---\ntitle: We simply note that\n---\n\nConfigure it.', + }, + { + expected: ['prose semicolon'], + name: 'leaves ordinary prose outside code, tags, and imports intact', + source: + "import { Foo } from './foo';\n\nThis prose keeps its semicolon; it should still be reported.\n\n", + }, +]) { + test(`extractProseForRules ${proseCase.name}`, () => { + expect(findProseRuleLabels(extractProseForRules(proseCase.source))).toEqual(proseCase.expected); + }); +} diff --git a/apps/docs/src/lib/guide-prop-audit.test.ts b/apps/docs/src/lib/guide-prop-audit.test.ts index 1c057e9e..8b99c2b2 100644 --- a/apps/docs/src/lib/guide-prop-audit.test.ts +++ b/apps/docs/src/lib/guide-prop-audit.test.ts @@ -66,19 +66,20 @@ test('every authored guide/table pair has curated taught-prop metadata', () => { }).toEqual({ missing: [], stale: [] }); }); -test.each( - authoredTables.flatMap((table) => { - const props = GUIDE_TAUGHT_PROPS[guideTableKey(table.guide, table.path, table.name)]; - if (props === undefined || props.length === 0) return []; - return [{ ...table, props }]; - }), -)( - '$guide teaches documented props on $name', - async ({ guide, name, path, props }) => { - const names = await visiblePropNames(path, name); - for (const prop of props) { - expect(names, `${guide} teaches ${prop} on ${name}`).toContain(prop); - } - }, - TS_MORPH_TEST_TIMEOUT, -); +for (const table of authoredTables.flatMap((table) => { + const props = GUIDE_TAUGHT_PROPS[guideTableKey(table.guide, table.path, table.name)]; + if (props === undefined || props.length === 0) return []; + return [{ ...table, props }]; +})) { + test( + `${table.guide} teaches documented props on ${table.name}`, + async () => { + const { guide, name, path, props } = table; + const names = await visiblePropNames(path, name); + for (const prop of props) { + expect(names, `${guide} teaches ${prop} on ${name}`).toContain(prop); + } + }, + TS_MORPH_TEST_TIMEOUT, + ); +} diff --git a/apps/docs/src/lib/home-content.test.ts b/apps/docs/src/lib/home-content.test.ts deleted file mode 100644 index 196420a4..00000000 --- a/apps/docs/src/lib/home-content.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { expect, test } from 'vite-plus/test'; -import { HOME_INTRO, homeMarkdown } from './home-content.js'; - -test('opens with the Luke UI heading and the lead sentence', () => { - expect(homeMarkdown().startsWith(`# Luke UI\n\n${HOME_INTRO}\n`)).toBe(true); -}); - -test('links to Installation', () => { - expect(homeMarkdown()).toContain('- [Installation](/docs/installation)'); -}); diff --git a/apps/docs/src/lib/story-wrapper.browser.test.tsx b/apps/docs/src/lib/story-wrapper.browser.test.tsx index 9cb0084d..3a773dc6 100644 --- a/apps/docs/src/lib/story-wrapper.browser.test.tsx +++ b/apps/docs/src/lib/story-wrapper.browser.test.tsx @@ -10,8 +10,6 @@ import type { Root } from 'react-dom/client'; import { createRoot } from 'react-dom/client'; import { afterEach, expect, test } from 'vite-plus/test'; import { Comparison, ComparisonItem } from '#docs'; -import AutoGridResponsive from '../examples/auto-grid/responsive.js'; -import GridResponsive from '../examples/grid/responsive.js'; import { StoryWrapper } from './story-wrapper.js'; const mounted: Array<{ container: HTMLElement; root: Root }> = []; @@ -128,28 +126,26 @@ test('flow retains deliberately narrow example sizing', () => { expect(getComputedStyle(autoGrid).gridTemplateColumns.split(' ').filter(Boolean).length).toBe(1); }); -test('Comparison is compact and centred in flow', () => { - const { available, exampleRoot, storyRoot } = renderInWrapper(comparisonFixture()); - const storyBox = storyRoot.getBoundingClientRect(); - const comparisonBox = exampleRoot.getBoundingClientRect(); - - expect(comparisonBox.width).toBeLessThan(available); - expect( - Math.abs(comparisonBox.left - storyBox.left - (storyBox.right - comparisonBox.right)), - ).toBeLessThanOrEqual(1); -}); +for (const comparisonCase of [ + { containerWidth: undefined as number | undefined, expectCompact: true, name: 'wide' }, + { containerWidth: 280, expectCompact: false, name: 'narrow' }, +]) { + test(`Comparison stays within the ${comparisonCase.name} flow surface and centred`, () => { + const { available, exampleRoot, storyRoot } = renderInWrapper(comparisonFixture(), { + containerWidth: comparisonCase.containerWidth, + }); + const storyBox = storyRoot.getBoundingClientRect(); + const comparisonBox = exampleRoot.getBoundingClientRect(); -test('Comparison caps its width to the narrow flow surface', () => { - const { available, exampleRoot, storyRoot } = renderInWrapper(comparisonFixture(), { - containerWidth: 280, + expect(comparisonBox.width <= available).toBe(true); + expect(comparisonBox.width < available || !comparisonCase.expectCompact).toBe(true); + expect(comparisonBox.left).toBeGreaterThanOrEqual(storyBox.left - 1); + expect(comparisonBox.right).toBeLessThanOrEqual(storyBox.right + 1); + expect( + Math.abs(comparisonBox.left - storyBox.left - (storyBox.right - comparisonBox.right)), + ).toBeLessThanOrEqual(1); }); - const storyBox = storyRoot.getBoundingClientRect(); - const comparisonBox = exampleRoot.getBoundingClientRect(); - - expect(comparisonBox.width).toBeLessThanOrEqual(available); - expect(comparisonBox.left).toBeGreaterThanOrEqual(storyBox.left); - expect(comparisonBox.right).toBeLessThanOrEqual(storyBox.right); -}); +} test('full-bleed has no inset padding or minimum height', () => { const { exampleRoot, storyRoot } = renderInWrapper( @@ -165,23 +161,6 @@ test('full-bleed has no inset padding or minimum height', () => { ); }); -test('responsive layout examples resolve against the preview container width', () => { - for (const [width, gridColumns, autoGridColumns] of [ - [900, 4, 3], - [400, 2, 2], - ] as const) { - const grid = renderInWrapper(, { containerWidth: width }); - expect( - getComputedStyle(grid.exampleRoot).gridTemplateColumns.split(' ').filter(Boolean).length, - ).toBe(gridColumns); - - const autoGrid = renderInWrapper(, { containerWidth: width }); - expect( - getComputedStyle(autoGrid.exampleRoot).gridTemplateColumns.split(' ').filter(Boolean).length, - ).toBe(autoGridColumns); - } -}); - function comparisonFixture() { return ( diff --git a/docs/TESTING.md b/docs/TESTING.md index 3eef85a5..3188bcde 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -20,6 +20,17 @@ Assert observable public behaviour. Prefer roles and accessible names, `userEven Do not test private functions, implementation details, or computed appearance. Use computed styles only when layout is the contract. +Do not use `test.each`, `it.each`, or `describe.each`. Parameterise with a `for…of` loop that calls +`test()` (or `describe()`) inside, and put the distinguishing value in the title: + +```ts +for (const ratio of ratios) { + test(`locks the frame to ${ratio}`, () => { + // … + }); +} +``` + Shared assertions take concrete elements and values. Keep the test, fixture, and contract choice in the component's test file. diff --git a/packages/@luke-ui/playground-core/src/compiler.test.ts b/packages/@luke-ui/playground-core/src/compiler.test.ts index 7e6aa56f..be8399af 100644 --- a/packages/@luke-ui/playground-core/src/compiler.test.ts +++ b/packages/@luke-ui/playground-core/src/compiler.test.ts @@ -16,7 +16,7 @@ test('compileComponent default-exports a function component from scope', () => { expect(typeof Component).toBe('function'); }); -test.each([ +const acceptedComponents = [ [ 'memo', [ @@ -40,20 +40,28 @@ test.each([ 'export default lazy(() => Promise.resolve({ default: () => null }));', ].join('\n'), ], -])('compileComponent accepts a %s component', (_name, code) => { - const { compileComponent } = createPlaygroundCompiler(reactScope); - const Component = compileComponent(code); - expect(Component).not.toBeNull(); - expect(typeof Component).toBe('object'); -}); +] as const; + +for (const [name, code] of acceptedComponents) { + test(`compileComponent accepts a ${name} component`, () => { + const { compileComponent } = createPlaygroundCompiler(reactScope); + const Component = compileComponent(code); + expect(Component).not.toBeNull(); + expect(typeof Component).toBe('object'); + }); +} -test.each([ +const rejectedExports = [ ['no default export', 'export const value = 1;'], ['a default export of null', 'export default null;'], -])('compileComponent rejects %s', (_name, code) => { - const { compileComponent } = createPlaygroundCompiler({}); - expect(() => compileComponent(code)).toThrow(/default-export a React component/); -}); +] as const; + +for (const [name, code] of rejectedExports) { + test(`compileComponent rejects ${name}`, () => { + const { compileComponent } = createPlaygroundCompiler({}); + expect(() => compileComponent(code)).toThrow(/default-export a React component/); + }); +} test('createPlaygroundCompiler throws for unknown specifiers', () => { const { compileComponent } = createPlaygroundCompiler({ react: {} }); diff --git a/packages/@luke-ui/playground-core/src/protocol.test.ts b/packages/@luke-ui/playground-core/src/protocol.test.ts index 314ce216..adbbc456 100644 --- a/packages/@luke-ui/playground-core/src/protocol.test.ts +++ b/packages/@luke-ui/playground-core/src/protocol.test.ts @@ -161,44 +161,52 @@ test('a trusted message after a resync-before-ready unblocks later postCode call const COMPILE_ERROR = 'Playground code must default-export a React component.'; -test.each([ +const onResultCases = [ ['a success', () => ({ ok: true }) as const, { type: 'success' } as const], [ 'a compilation error', () => ({ message: COMPILE_ERROR, ok: false }) as const, { message: COMPILE_ERROR, type: 'error' } as const, ], -])('%s reaches onResult', (_name, compile, expected) => { - const bus = createMessageBus(); - const results: Array = []; - attachFakePreview(bus, compile); - const session = createPlaygroundPageSession({ - getCode: () => VALID_CODE, - getPorts: () => bus.ports, - onResult: (result) => results.push(result), +] as const; + +for (const [name, compile, expected] of onResultCases) { + test(`${name} reaches onResult`, () => { + const bus = createMessageBus(); + const results: Array = []; + attachFakePreview(bus, compile); + const session = createPlaygroundPageSession({ + getCode: () => VALID_CODE, + getPorts: () => bus.ports, + onResult: (result) => results.push(result), + }); + bus.listenPage((event) => session.handleMessage(event)); + session.resync(); + expect(results).toEqual([expected]); }); - bus.listenPage((event) => session.handleMessage(event)); - session.resync(); - expect(results).toEqual([expected]); -}); +} -test.each([ +const ignoredMessageCases = [ ['an untrusted origin', { origin: 'https://other.test', sourceIsPreview: true }], [ 'a same-origin message from a source that is not the preview', { origin: ORIGIN, sourceIsPreview: false }, ], -])('ignores %s', (_name, { origin, sourceIsPreview }) => { - const { previewWindow, results, session } = createRecordingSession(); +] as const; - session.handleMessage({ - data: { type: 'playground:success' }, - origin, - source: sourceIsPreview ? previewWindow : { role: 'other' }, - }); +for (const [name, { origin, sourceIsPreview }] of ignoredMessageCases) { + test(`ignores ${name}`, () => { + const { previewWindow, results, session } = createRecordingSession(); - expect(results).toEqual([]); -}); + session.handleMessage({ + data: { type: 'playground:success' }, + origin, + source: sourceIsPreview ? previewWindow : { role: 'other' }, + }); + + expect(results).toEqual([]); + }); +} test('a missing preview window is a no-op for postCode and resync', () => { const session = createPlaygroundPageSession({ diff --git a/packages/@luke-ui/react/src/core/aspect-ratio/aspect-ratio.browser.test.tsx b/packages/@luke-ui/react/src/core/aspect-ratio/aspect-ratio.browser.test.tsx index c095b3e5..ebb3dfac 100644 --- a/packages/@luke-ui/react/src/core/aspect-ratio/aspect-ratio.browser.test.tsx +++ b/packages/@luke-ui/react/src/core/aspect-ratio/aspect-ratio.browser.test.tsx @@ -1,27 +1,9 @@ import { AspectRatio } from '@luke-ui/react/aspect-ratio'; import { vars } from '@luke-ui/react/theme'; -import { createRef } from 'react'; import { test, expect } from 'vite-plus/test'; -import { - expectForwardsDomProps, - expectHtmlElement, - forwardedDomProps, -} from '../test-utils/forwarding.js'; import { render, visualAppearances } from '../test-utils/render.js'; import { captureVisualAppearance } from '../test-utils/visual.js'; -test('AspectRatio forwards className, data attributes, id, and ref to its element', () => { - const ref = createRef(); - const { container } = render( - - Content - , - ); - const target = expectHtmlElement(container.firstElementChild, 'Expected AspectRatio element.'); - - expectForwardsDomProps(target, ref); -}); - const ratios = ['1 / 1', '4 / 3', '3 / 2', '16 / 9', '21 / 9'] as const; const blankPixel = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=='; @@ -92,29 +74,6 @@ test('applies an explicit objectFit value to the media child', () => { expect(getComputedStyle(media).objectFit).toBe('contain'); }); -test('applies root layout props and ignores unsupported Box utilities from an object spread', () => { - const props = { - borderStyle: 'solid', - borderWidth: 'thick', - inlineSize: '10rem', - ratio: '16 / 9', - } as const; - const { locator } = render( -
- -
, - ); - const parent = locator.getByTestId('parent').element(); - const element = locator.getByTestId('ratio').element(); - if (!(parent instanceof HTMLElement) || !(element instanceof HTMLElement)) { - throw new Error('Expected AspectRatio elements.'); - } - - expect(element.getBoundingClientRect().width).toBe(160); - expect(element.getBoundingClientRect().width).toBeLessThan(parent.getBoundingClientRect().width); - expect(element.offsetWidth).toBe(element.clientWidth); -}); - test('applies the chosen ratio to a caller-owned root', () => { const { locator } = render( { - const ref = createRef(); - const { container } = render( - - Content - , - ); - const target = expectHtmlElement(container.firstElementChild, 'Expected AutoGrid element.'); - - expectForwardsDomProps(target, ref); -}); - afterEach(async () => { await page.viewport(1024, 800); }); @@ -242,108 +224,31 @@ test('does not let long unbreakable content expand auto-fit tracks', () => { expect(long.getBoundingClientRect().width).toBeCloseTo(short.getBoundingClientRect().width, 1); }); -test('keeps the inline axis under RTL and vertical writing mode', () => { +test('keeps the inline axis under RTL', () => { const { locator } = render( -
-
- - - - - -
-
- - - - - -
+
+ + + + +
, ); const rtl = locator.getByTestId('grid-rtl').element(); - const vertical = locator.getByTestId('grid-vertical').element(); const rtlFirst = locator.getByTestId('rtl-first').element(); const rtlSecond = locator.getByTestId('rtl-second').element(); - const verticalFirst = locator.getByTestId('vertical-first').element(); - const verticalSecond = locator.getByTestId('vertical-second').element(); if ( !(rtl instanceof HTMLElement) || - !(vertical instanceof HTMLElement) || !(rtlFirst instanceof HTMLElement) || - !(rtlSecond instanceof HTMLElement) || - !(verticalFirst instanceof HTMLElement) || - !(verticalSecond instanceof HTMLElement) + !(rtlSecond instanceof HTMLElement) ) { throw new Error('Expected AutoGrid elements.'); } expect(getComputedStyle(rtl).display).toBe('grid'); - expect(getComputedStyle(vertical).display).toBe('grid'); expect(rtlFirst.getBoundingClientRect().left).toBeGreaterThan( rtlSecond.getBoundingClientRect().left, ); - expect(verticalSecond.getBoundingClientRect().top).toBeGreaterThan( - verticalFirst.getBoundingClientRect().top, - ); -}); - -test('applies root layout props and ignores unsupported Box utilities from an object spread', () => { - const props = { - borderStyle: 'solid', - borderWidth: 'thick', - gap: 'sp8', - inlineSize: '10rem', - minColumnInlineSize: '6rem', - padding: 'sp16', - } as const; - const { locator } = render( -
- - - -
, - ); - const container = locator.getByTestId('container').element(); - const element = locator.getByTestId('grid').element(); - if (!(container instanceof HTMLElement) || !(element instanceof HTMLElement)) { - throw new Error('Expected AutoGrid elements.'); - } - - expect(element.getBoundingClientRect().width).toBeLessThan( - container.getBoundingClientRect().width, - ); - expect(element.offsetWidth).toBe(element.clientWidth); -}); - -test('renders semantic elements and a consumer-owned render prop', () => { - const ref = createRef(); - const semanticResult = render( - -
  • First
  • -
  • Second
  • -
    , - ); - const list = semanticResult.locator.getByRole('list', { name: 'Items' }); - expect(list.element().tagName).toBe('UL'); - - const customResult = render( -
    } - > - One - Two - , - ); - const section = customResult.locator.getByTestId('auto-grid-render').element(); - if (!(section instanceof HTMLElement)) throw new Error('Expected render callback element.'); - - expect(section.tagName).toBe('SECTION'); - expect(ref.current).toBe(section); - expect(getComputedStyle(section).display).toBe('grid'); }); const itemStyle = { diff --git a/packages/@luke-ui/react/src/core/blockquote/blockquote.browser.test.tsx b/packages/@luke-ui/react/src/core/blockquote/blockquote.browser.test.tsx deleted file mode 100644 index 3e634406..00000000 --- a/packages/@luke-ui/react/src/core/blockquote/blockquote.browser.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { Blockquote } from '@luke-ui/react/blockquote'; -import { createRef } from 'react'; -import { test } from 'vite-plus/test'; -import { - expectForwardsDomProps, - expectHtmlElement, - forwardedDomProps, -} from '../test-utils/forwarding.js'; -import { render } from '../test-utils/render.js'; - -test('Blockquote forwards className, data attributes, id, and ref to its element', () => { - const ref = createRef(); - const { container } = render( -
    - Quoted text -
    , - ); - const target = expectHtmlElement(container.firstElementChild, 'Expected a Blockquote element.'); - - expectForwardsDomProps(target, ref); -}); diff --git a/packages/@luke-ui/react/src/core/button/button.browser.test.tsx b/packages/@luke-ui/react/src/core/button/button.browser.test.tsx index d92b9ecb..2e2e0508 100644 --- a/packages/@luke-ui/react/src/core/button/button.browser.test.tsx +++ b/packages/@luke-ui/react/src/core/button/button.browser.test.tsx @@ -1,12 +1,11 @@ import { Button } from '@luke-ui/react/button'; import { Icon } from '@luke-ui/react/icon'; import { Text } from '@luke-ui/react/text'; -import { act, createRef } from 'react'; +import { act } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { expect, test } from 'vite-plus/test'; -import { page, userEvent } from 'vite-plus/test/context'; +import { page } from 'vite-plus/test/context'; import { expectNoAxeViolations } from '../test-utils/axe.js'; -import { expectForwardsDomProps, forwardedDomProps } from '../test-utils/forwarding.js'; import { render, visualAppearances } from '../test-utils/render.js'; import { captureVisual, @@ -56,18 +55,6 @@ function ButtonScene() { ); } -test('Button forwards className, data attributes, id, and ref to the button element', () => { - const ref = createRef(); - const { locator } = render( - , - ); - const button = locator.getByRole('button').element(); - - expectForwardsDomProps(button, ref); -}); - test('pressing a Button runs its onPress handler', async () => { let pressed = false; const { locator, user } = render(); @@ -82,33 +69,6 @@ test('the Button scene has no axe violations', async () => { await expectNoAxeViolations(container); }); -test('a text-appearance Button keeps button semantics and runs onPress', async () => { - let pressed = false; - const { locator, user } = render( - , - ); - const button = locator.getByRole('button', { name: 'Action' }).element(); - - expect(button.tagName).toBe('BUTTON'); - - await user.click(locator.getByRole('button', { name: 'Action' })); - expect(pressed).toBe(true); -}); - -test('a pending text Button shows a spinner', () => { - const { locator } = render( - , - ); - const button = locator.getByRole('button', { name: 'Save' }); - - expect(button.element().getAttribute('data-pending')).toBe('true'); - expect(button.element().querySelector('[role="status"]')).not.toBeNull(); -}); - test('a text Button has the same layout styles as inline Text without control padding or sizing', () => { const { locator } = render(
    @@ -186,149 +146,6 @@ test('a text Button in a flex parent is not collapsed to a single character per expect(button.getBoundingClientRect().width).toBeGreaterThan(20); }); -test('hover inverts the text Button underline, and low prominence reverses the rest state', () => { - const { locator } = render( -
    - - - -
    , - ); - const low = locator.getByRole('button', { name: 'Low' }).element(); - const standard = locator.getByRole('button', { name: 'Standard' }).element(); - const high = locator.getByRole('button', { name: 'High' }).element(); - - for (const button of [low, standard, high]) { - button.style.transition = 'none'; - } - - expect(getComputedStyle(low).textDecorationLine).toBe('none'); - expect(getComputedStyle(standard).textDecorationLine).toBe('underline'); - expect(getComputedStyle(high).textDecorationLine).toBe('underline'); - - low.setAttribute('data-hovered', 'true'); - standard.setAttribute('data-hovered', 'true'); - high.setAttribute('data-hovered', 'true'); - - expect(getComputedStyle(low).textDecorationLine).toBe('underline'); - expect(getComputedStyle(standard).textDecorationLine).toBe('none'); - expect(getComputedStyle(high).textDecorationLine).toBe('none'); -}); - -test('focus-visible keeps the underline over hover and pressed', () => { - const { locator } = render( -
    - - -
    , - ); - const low = locator.getByRole('button', { name: 'Low' }).element(); - const standard = locator.getByRole('button', { name: 'Standard' }).element(); - const lowLabel = low.querySelector('span'); - const standardLabel = standard.querySelector('span'); - if (!(lowLabel instanceof HTMLElement) || !(standardLabel instanceof HTMLElement)) { - throw new Error('Expected text labels.'); - } - - for (const button of [low, standard]) { - button.style.transition = 'none'; - } - - low.setAttribute('data-focus-visible', 'true'); - low.setAttribute('data-hovered', 'true'); - standard.setAttribute('data-focus-visible', 'true'); - standard.setAttribute('data-hovered', 'true'); - - expect(getComputedStyle(low).textDecorationLine).toBe('underline'); - expect(getComputedStyle(lowLabel).textDecorationLine).toBe('underline'); - expect(getComputedStyle(standard).textDecorationLine).toBe('underline'); - expect(getComputedStyle(standardLabel).textDecorationLine).toBe('underline'); - - low.removeAttribute('data-hovered'); - standard.removeAttribute('data-hovered'); - low.setAttribute('data-pressed', 'true'); - standard.setAttribute('data-pressed', 'true'); - - expect(getComputedStyle(low).textDecorationLine).toBe('underline'); - expect(getComputedStyle(lowLabel).textDecorationLine).toBe('underline'); - expect(getComputedStyle(standard).textDecorationLine).toBe('underline'); - expect(getComputedStyle(standardLabel).textDecorationLine).toBe('underline'); -}); - -test('the text Button label paints the underline set on the button', () => { - const { locator } = render( - , - ); - const button = locator.getByRole('button', { name: 'Save' }).element(); - const label = button.querySelector('span'); - if (!(label instanceof HTMLElement)) throw new Error('Expected a text label.'); - - // `text-decoration` does not inherit, so a label that sets its own value paints over the button's. - expect(getComputedStyle(label).textDecorationLine).toBe('underline'); -}); - -test('pressed text Buttons shift to a perceptibly different foreground colour per tone', () => { - const { locator } = render( -
    - - - -
    , - ); - const neutral = locator.getByRole('button', { name: 'Neutral' }).element(); - const critical = locator.getByRole('button', { name: 'Critical' }).element(); - const accent = locator.getByRole('button', { name: 'Accent' }).element(); - - for (const button of [neutral, critical, accent]) { - // A computed colour read mid-transition returns the rest value. - button.style.transition = 'none'; - } - - const neutralRest = getComputedStyle(neutral).color; - const criticalRest = getComputedStyle(critical).color; - const accentRest = getComputedStyle(accent).color; - - neutral.setAttribute('data-pressed', 'true'); - critical.setAttribute('data-pressed', 'true'); - accent.setAttribute('data-pressed', 'true'); - - expect(getComputedStyle(neutral).color).not.toBe(neutralRest); - expect(getComputedStyle(critical).color).not.toBe(criticalRest); - expect(getComputedStyle(accent).color).not.toBe(accentRest); -}); - -test('disabled Buttons expose disabled state in either appearance', () => { - const { locator } = render( -
    - - -
    , - ); - - const buttonAppearance = locator.getByRole('button', { name: 'Button appearance' }).element(); - const textAppearance = locator.getByRole('button', { name: 'Text appearance' }).element(); - expect(buttonAppearance).toHaveAttribute('data-disabled', 'true'); - expect(textAppearance).toHaveAttribute('data-disabled', 'true'); -}); - test('runs onPress before pressAction and tracks Action pending', async () => { const order: Array = []; let release!: () => void; @@ -484,21 +301,15 @@ test('kitchen sink', { tags: ['visual'] }, async () => { } }); -test('interactive states', { tags: ['visual'] }, async () => { +test('focus-visible state', { tags: ['visual'] }, async () => { const { locator } = render(); const button = page.getByRole('button', { name: 'Action' }); - await userEvent.hover(button); - await captureVisual(locator, 'button/hover'); - await userEvent.unhover(button); await focusViaKeyboard(button); await captureVisual(locator, 'button/focus-visible'); - await userEvent.keyboard('{Space>}'); - await captureVisual(locator, 'button/pressed'); - await userEvent.keyboard('{/Space}'); }); -test('forced-colors states', { tags: ['visual'] }, async () => { +test('forced-colors resting', { tags: ['visual'] }, async () => { await emulateForcedColors('active'); try { @@ -509,17 +320,7 @@ test('forced-colors states', { tags: ['visual'] }, async () => { , ); - const action = page.getByRole('button', { name: 'Action' }); - await captureVisual(locator, 'button/forced-colors-resting'); - await userEvent.hover(action); - await captureVisual(locator, 'button/forced-colors-hover'); - await userEvent.unhover(action); - await focusViaKeyboard(action); - await captureVisual(locator, 'button/forced-colors-focus-visible'); - await userEvent.keyboard('{Space>}'); - await captureVisual(locator, 'button/forced-colors-pressed'); - await userEvent.keyboard('{/Space}'); } finally { await emulateForcedColors('none'); } diff --git a/packages/@luke-ui/react/src/core/checkbox/checkbox.browser.test.tsx b/packages/@luke-ui/react/src/core/checkbox/checkbox.browser.test.tsx index 86de8adc..36924d31 100644 --- a/packages/@luke-ui/react/src/core/checkbox/checkbox.browser.test.tsx +++ b/packages/@luke-ui/react/src/core/checkbox/checkbox.browser.test.tsx @@ -1,10 +1,8 @@ import { Checkbox } from '@luke-ui/react/checkbox'; import { Text } from '@luke-ui/react/text'; -import { typeStyles } from '@luke-ui/react/theme'; import { createRef } from 'react'; import { expect, test } from 'vite-plus/test'; -import type { Locator } from 'vite-plus/test/context'; -import { cdp, page, userEvent } from 'vite-plus/test/context'; +import { cdp, page } from 'vite-plus/test/context'; import { expectNoAxeViolations } from '../test-utils/axe.js'; import { render, visualAppearances } from '../test-utils/render.js'; import { @@ -54,13 +52,11 @@ function CheckboxScene() { Email notifications - {typeStyles.map((typography) => ( - - - {typography}: This label wraps to show that the control aligns with its first line. - - - ))} + + + heading3: This label wraps to show that the control aligns with its first line. + + Standalone control This label wraps onto a second line so the control should sit on the first line, not float @@ -88,38 +84,37 @@ function CheckboxScene() { ); } -// RAC puts `id` on the control, not the root. -test('Checkbox forwards className and data attributes to its root, and id to the DOM', () => { - const { container } = render( - - Terms - , - ); - const root = container.firstElementChild; - if (!(root instanceof HTMLElement)) throw new Error('Expected a Checkbox root.'); - - expect(root).toHaveClass('forwarded-class'); - expect(root).toHaveAttribute('data-forwarded', 'true'); - expect(container.querySelector('#forwarded-id')).not.toBeNull(); -}); - -test('Checkbox resolves inputRef to the control, participates in a form, and fires onBlur', () => { +// Luke UI widens RAC's `inputRef` to accept React Hook Form's callback ref. +test('Checkbox resolves object and callback inputRef to the control, participates in a form, and fires onBlur', () => { const inputRef = createRef(); + const callbackResolved: Array = []; let blurred = false; const { container, locator } = render( - { - blurred = true; - }} - > - Terms - , + <> + { + blurred = true; + }} + > + Terms + + { + callbackResolved.push(node); + }} + name="terms-callback" + > + Callback + + , ); const control = locator.getByRole('checkbox', { name: 'Terms' }).element(); + const callbackControl = locator.getByRole('checkbox', { name: 'Callback' }).element(); expect(inputRef.current).toBe(control); + expect(callbackResolved.at(-1)).toBe(callbackControl); const form = document.createElement('form'); container.replaceWith(form); @@ -140,34 +135,6 @@ test('Checkbox resolves inputRef to the control, participates in a form, and fir form.remove(); }); -// Luke UI widens RAC's `inputRef` to accept React Hook Form's callback ref. -test('Checkbox resolves a callback inputRef to the control', () => { - const resolved: Array = []; - const { locator } = render( - { - resolved.push(node); - }} - name="terms" - > - Terms - , - ); - const control = locator.getByRole('checkbox', { name: 'Terms' }).element(); - - expect(resolved.at(-1)).toBe(control); -}); - -test('clicking a Checkbox label selects it', async () => { - let selected = false; - const { locator, user } = render( - (selected = isSelected)}>Terms, - ); - - await user.click(locator.getByText('Terms')); - expect(selected).toBe(true); -}); - test('the Checkbox scene has no axe violations', async () => { const { container } = render(); @@ -262,17 +229,6 @@ async function getAccessibilityNode(nodeId: DomNode['nodeId']) { return axNode; } -function checkboxLabel(checkbox: Locator): HTMLElement { - const label = checkbox.element().closest('label'); - if (label == null) throw new Error('Expected the checkbox content label.'); - return label; -} - -async function pressCheckbox(checkbox: Locator): Promise { - checkbox.element().focus(); - await userEvent.keyboard('{Space>}'); -} - test('kitchen sink', { tags: ['visual'] }, async () => { for (const appearance of visualAppearances) { const { locator } = render(, { appearance }); @@ -286,43 +242,7 @@ test('keyboard focus ring', { tags: ['visual'] }, async () => { await captureVisual(locator, 'checkbox/focus-visible'); }); -test('interactive states', { tags: ['visual'] }, async () => { - const { locator } = render( - - - Invalid - - - Invalid selected - - - Invalid indeterminate - - , - ); - const unchecked = page.getByRole('checkbox', { exact: true, name: 'Invalid' }); - const selected = page.getByRole('checkbox', { exact: true, name: 'Invalid selected' }); - const indeterminate = page.getByRole('checkbox', { - exact: true, - name: 'Invalid indeterminate', - }); - - for (const [name, checkbox] of [ - ['unchecked', unchecked], - ['selected', selected], - ['indeterminate', indeterminate], - ] as const) { - const label = checkboxLabel(checkbox); - await userEvent.hover(label); - await captureVisual(locator, `checkbox/invalid-hover-${name}`); - await userEvent.unhover(label); - await pressCheckbox(checkbox); - await captureVisual(locator, `checkbox/invalid-pressed-${name}`); - await userEvent.keyboard('{/Space}'); - } -}); - -test('forced-colors states', { tags: ['visual'] }, async () => { +test('forced-colors resting', { tags: ['visual'] }, async () => { await emulateForcedColors('active'); try { @@ -332,9 +252,6 @@ test('forced-colors states', { tags: ['visual'] }, async () => { Selected - - Indeterminate - Disabled @@ -343,7 +260,7 @@ test('forced-colors states', { tags: ['visual'] }, async () => { , ); - await captureVisual(locator, 'checkbox/forced-colors-states'); + await captureVisual(locator, 'checkbox/forced-colors-resting'); } finally { await emulateForcedColors('none'); } diff --git a/packages/@luke-ui/react/src/core/cluster/cluster.browser.test.tsx b/packages/@luke-ui/react/src/core/cluster/cluster.browser.test.tsx index dc8d0d90..f9b1da8d 100644 --- a/packages/@luke-ui/react/src/core/cluster/cluster.browser.test.tsx +++ b/packages/@luke-ui/react/src/core/cluster/cluster.browser.test.tsx @@ -1,29 +1,11 @@ import { Cluster } from '@luke-ui/react/cluster'; import { vars } from '@luke-ui/react/theme'; -import { createRef } from 'react'; import { afterEach, expect, test } from 'vite-plus/test'; import { page } from 'vite-plus/test/context'; import { breakpoints } from '../../theme/breakpoints.js'; -import { - expectForwardsDomProps, - expectHtmlElement, - forwardedDomProps, -} from '../test-utils/forwarding.js'; import { render, visualAppearances } from '../test-utils/render.js'; import { captureVisualAppearance } from '../test-utils/visual.js'; -test('Cluster forwards className, data attributes, id, and ref to its element', () => { - const ref = createRef(); - const { container } = render( - - Content - , - ); - const target = expectHtmlElement(container.firstElementChild, 'Expected Cluster element.'); - - expectForwardsDomProps(target, ref); -}); - afterEach(async () => { await page.viewport(1024, 800); }); @@ -46,29 +28,6 @@ test('flows children on the inline axis and wraps by default', () => { expect(getComputedStyle(element).alignItems).toBe('center'); }); -test('accepts flexWrap overrides including wrap-reverse', () => { - const nowrapResult = render( - - First - Second - , - ); - const wrapReverseResult = render( - - First - Second - , - ); - const nowrap = nowrapResult.locator.getByTestId('cluster-nowrap').element(); - const wrapReverse = wrapReverseResult.locator.getByTestId('cluster-wrap-reverse').element(); - if (!(nowrap instanceof HTMLElement) || !(wrapReverse instanceof HTMLElement)) { - throw new Error('Expected Cluster elements.'); - } - - expect(getComputedStyle(nowrap).flexWrap).toBe('nowrap'); - expect(getComputedStyle(wrapReverse).flexWrap).toBe('wrap-reverse'); -}); - test('nowrap disables wrapping without changing child shrink behaviour', () => { const { locator } = render( @@ -115,20 +74,6 @@ test('uses no gap by default and accepts alignment overrides', () => { expect(second.getBoundingClientRect().left - first.getBoundingClientRect().right).toBe(0); }); -test('emits no gap utility when gap is omitted', () => { - const { locator } = render( - - - - , - ); - const element = locator.getByTestId('cluster').element(); - if (!(element instanceof HTMLElement)) throw new Error('Expected Cluster element.'); - - expect(element.className).not.toMatch(/gap/); - expect(getComputedStyle(element).gap).toBe('normal'); -}); - test('wraps when children exceed the inline size', () => { const { locator } = render( @@ -146,51 +91,29 @@ test('wraps when children exceed the inline size', () => { expect(second.offsetTop).toBeGreaterThan(first.offsetTop); }); -test('keeps the inline axis under RTL and vertical writing mode', () => { +test('keeps the inline axis under RTL', () => { const { locator } = render( -
    -
    - - - - -
    -
    - - - - -
    +
    + + + +
    , ); const rtl = locator.getByTestId('cluster-rtl').element(); - const vertical = locator.getByTestId('cluster-vertical').element(); - if (!(rtl instanceof HTMLElement) || !(vertical instanceof HTMLElement)) { - throw new Error('Expected Cluster elements.'); - } + if (!(rtl instanceof HTMLElement)) throw new Error('Expected Cluster element.'); expect(getComputedStyle(rtl).flexDirection).toBe('row'); expect(getComputedStyle(rtl).flexWrap).toBe('wrap'); - expect(getComputedStyle(vertical).flexDirection).toBe('row'); - expect(getComputedStyle(vertical).flexWrap).toBe('wrap'); const [rtlFirst, rtlSecond] = rtl.children; - const [verticalFirst, verticalSecond] = vertical.children; - if ( - !(rtlFirst instanceof HTMLElement) || - !(rtlSecond instanceof HTMLElement) || - !(verticalFirst instanceof HTMLElement) || - !(verticalSecond instanceof HTMLElement) - ) { + if (!(rtlFirst instanceof HTMLElement) || !(rtlSecond instanceof HTMLElement)) { throw new Error('Expected Cluster children.'); } expect(rtlFirst.getBoundingClientRect().left).toBeGreaterThan( rtlSecond.getBoundingClientRect().left, ); - expect(verticalSecond.getBoundingClientRect().top).toBeGreaterThan( - verticalFirst.getBoundingClientRect().top, - ); }); test('keeps Cluster defaults below sparse responsive alignment overrides', async () => { @@ -226,65 +149,6 @@ test('keeps Cluster defaults below sparse responsive alignment overrides', async expect(second.getBoundingClientRect().left).toBeGreaterThan(first.getBoundingClientRect().right); }); -test('applies root layout props and ignores unsupported Box utilities from an object spread', () => { - const props = { - borderStyle: 'solid', - borderWidth: 'thick', - gap: 'sp8', - inlineSize: '10rem', - padding: 'sp16', - } as const; - const { locator } = render( -
    - - - -
    , - ); - const container = locator.getByTestId('container').element(); - const element = locator.getByTestId('cluster').element(); - if (!(container instanceof HTMLElement) || !(element instanceof HTMLElement)) { - throw new Error('Expected Cluster elements.'); - } - const child = element.firstElementChild; - if (!(child instanceof HTMLElement)) throw new Error('Expected Cluster child.'); - - expect(element.getBoundingClientRect().width).toBeLessThan( - container.getBoundingClientRect().width, - ); - expect(child.getBoundingClientRect().left).toBeGreaterThan(element.getBoundingClientRect().left); - expect(element.offsetWidth).toBe(element.clientWidth); -}); - -test('renders semantic elements and a consumer-owned render prop', () => { - const ref = createRef(); - const semanticResult = render( - -
  • Open
  • -
  • Closed
  • -
    , - ); - const list = semanticResult.locator.getByRole('list', { name: 'Filters' }); - expect(list.element().tagName).toBe('UL'); - - const customResult = render( -