diff --git a/e2e/tabs.spec.ts b/e2e/tabs.spec.ts new file mode 100644 index 0000000..6b06a1f --- /dev/null +++ b/e2e/tabs.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from '@playwright/test'; + +// Runs under the `chromium` project (already authenticated via the saved +// storage state). The /tabs demo page is a 2-tab layout whose SECOND tab owns +// a text_input. Committing that input reruns the script server-side, which +// patches the whole tree back into the client. This spec pins the behaviour +// that selection survives that patch: the active tab is uncontrolled Radix +// state on a stable-keyed component, so a rerun triggered from within tab 2 +// must leave the user on tab 2 — not snap them back to tab 1. + +test.describe('tabs survive a rerun', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/tabs'); + await expect(page.getByRole('heading', { name: /^Tabs$/ })).toBeVisible({ + timeout: 10_000, + }); + }); + + test('a rerun ordered from inside tab 2 keeps tab 2 active', async ({ + page, + }) => { + const firstTab = page.getByRole('tab', { name: 'First tab' }); + const secondTab = page.getByRole('tab', { name: 'Second tab' }); + + // First tab is the default selection. + await expect(firstTab).toHaveAttribute('data-state', 'active'); + await expect(secondTab).toHaveAttribute('data-state', 'inactive'); + + // Switch to tab 2 and confirm its content (incl. the input) mounted. + await secondTab.click(); + await expect(secondTab).toHaveAttribute('data-state', 'active'); + const nameInput = page.getByLabel('Your name'); + await expect(nameInput).toBeVisible(); + await expect(page.getByText('Hello, stranger!')).toBeVisible(); + + // Commit a value from inside tab 2 — this is what orders the rerun. + // text_input commits on blur/Enter, not per keystroke. + await nameInput.fill('Ada'); + await nameInput.press('Enter'); + + // The echo updates only after the script reruns and re-renders, so seeing + // it proves the rerun landed. + await expect(page.getByText('Hello, Ada!')).toBeVisible({ + timeout: 10_000, + }); + + // The whole point: the rerun must not have bounced us back to tab 1. + await expect(secondTab).toHaveAttribute('data-state', 'active'); + await expect(firstTab).toHaveAttribute('data-state', 'inactive'); + // Radix unmounts inactive tab content, so the input staying visible is an + // independent confirmation that tab 2 is still the one on screen. + await expect(nameInput).toBeVisible(); + }); +}); diff --git a/examples/demo/src/main.ts b/examples/demo/src/main.ts index 5d9f5a4..2c8b48f 100644 --- a/examples/demo/src/main.ts +++ b/examples/demo/src/main.ts @@ -41,6 +41,7 @@ run( sb.link({ label: '📈 Stats', href: '/stats' }); sb.link({ label: '📐 Columns', href: '/columns' }); sb.link({ label: '🎛️ Widgets', href: '/widgets' }); + sb.link({ label: '🗂️ Tabs', href: '/tabs' }); sb.link({ label: '📁 File Upload', href: '/file-upload' }); sb.write({ body: '---' }); sb.write({ body: `📍 \`${currentPath}\`` }); @@ -54,6 +55,7 @@ run( pages.columns(br.page({ path: '/columns' })); pages.charts(br.page({ path: '/charts' })); pages.widgets(br.page({ path: '/widgets' })); + pages.tabs(br.page({ path: '/tabs' })); // const br = brBase.base({}); br.write({ body: `# Backroad LLM Example\n---` }); diff --git a/examples/demo/src/pages/index.ts b/examples/demo/src/pages/index.ts index c6bb526..83d97da 100644 --- a/examples/demo/src/pages/index.ts +++ b/examples/demo/src/pages/index.ts @@ -7,6 +7,7 @@ import { backroadLLMExample } from './llm'; import { backroadMarkdownExample } from './markdown'; import { backroadSelectExample } from './select'; import { backroadStatsExample } from './stats'; +import { backroadTabsExample } from './tabs'; import { backroadWidgetsExample } from './widgets'; export const pages = { @@ -20,4 +21,5 @@ export const pages = { fileUpload: backroadFileUploadExample, iframe: backroadIframeExample, widgets: backroadWidgetsExample, + tabs: backroadTabsExample, }; diff --git a/examples/demo/src/pages/tabs.ts b/examples/demo/src/pages/tabs.ts new file mode 100644 index 0000000..892548b --- /dev/null +++ b/examples/demo/src/pages/tabs.ts @@ -0,0 +1,25 @@ +import { BackroadNodeManager } from '@backroad/backroad'; + +// A 2-tab layout whose SECOND tab owns a text_input. Changing that input +// commits a value and reruns the script. The point of the demo (and its e2e +// spec) is that the rerun must NOT yank the user back to the first tab: tab +// selection is uncontrolled Radix state living on a stable-keyed component, so +// it has to survive the tree patch. Each tab echoes its own state so the spec +// can prove the rerun actually happened while the active tab held steady. +export const backroadTabsExample = (br: BackroadNodeManager) => { + br.write({ body: '# Tabs' }); + + const [first, second] = br.tabs({ labels: ['First tab', 'Second tab'] }); + + first.write({ body: 'This is the first tab — it has no interactive state.' }); + + second.write({ body: 'This is the second tab.' }); + const name = second.textInput({ + label: 'Your name', + placeholder: 'Type here, then press Enter', + defaultValue: '', + }); + // Echo updates only after the input commits and the script reruns, so the + // spec asserting this text also asserts that a rerun occurred. + second.write({ body: `Hello, ${name || 'stranger'}!` }); +};