diff --git a/apps/website/e2e/demo-modal.spec.ts b/apps/website/e2e/demo-modal.spec.ts new file mode 100644 index 000000000..56ed975a6 --- /dev/null +++ b/apps/website/e2e/demo-modal.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from '@playwright/test'; + +test('homepage demo: launch opens a modal, Esc closes it', async ({ page }) => { + await page.goto('/'); + const launch = page.getByRole('button', { name: /launch .* live demo/i }).first(); + await launch.scrollIntoViewIfNeeded(); + await launch.click(); + + const dialog = page.getByRole('dialog', { name: /live demo/i }); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('iframe')).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await expect(launch).toBeFocused(); +}); + +test('homepage demo: in-modal tabs switch the runtime', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /launch .* live demo/i }).first().click(); + const dialog = page.getByRole('dialog', { name: /live demo/i }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('tab', { name: 'AG-UI' }).click(); + await expect(dialog.getByText('ag-ui.threadplane.ai')).toBeVisible(); +}); diff --git a/apps/website/src/components/landing/DemoModal.tsx b/apps/website/src/components/landing/DemoModal.tsx new file mode 100644 index 000000000..0d073c82c --- /dev/null +++ b/apps/website/src/components/landing/DemoModal.tsx @@ -0,0 +1,130 @@ +// apps/website/src/components/landing/DemoModal.tsx +'use client'; +import { useEffect, useRef } from 'react'; +import { tokens } from '@threadplane/design-tokens'; +import { trackExternalLinkClick } from '../../lib/analytics/client'; + +type TabKey = 'langgraph' | 'ag-ui'; + +export interface DemoModalTab { + key: TabKey; + tabLabel: string; + url: string; + href: string; +} + +interface DemoModalProps { + open: boolean; + onClose: () => void; + tabs: DemoModalTab[]; + active: TabKey; + onActive: (key: TabKey) => void; +} + +export function DemoModal({ open, onClose, tabs, active, onActive }: DemoModalProps) { + const frameRef = useRef(null); + const closeBtnRef = useRef(null); + const tab = tabs.find((t) => t.key === active) ?? tabs[0]; + + // While open: Esc to close, focus trap, body scroll lock, restore focus on close. + useEffect(() => { + if (!open) return; + const prevFocus = document.activeElement as HTMLElement | null; + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + closeBtnRef.current?.focus(); + + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { onClose(); return; } + if (e.key !== 'Tab') return; + const f = frameRef.current?.querySelectorAll( + 'a[href], button:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])', + ); + if (!f || f.length === 0) return; + const first = f[0]; + const last = f[f.length - 1]; + if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } + }; + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('keydown', onKey); + document.body.style.overflow = prevOverflow; + prevFocus?.focus?.(); + }; + }, [open, onClose]); + + if (!open) return null; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: 'fixed', inset: 0, zIndex: 100, + display: 'flex', alignItems: 'center', justifyContent: 'center', + background: 'rgba(16,18,32,.55)', + animation: 'demoModalBackdrop .16s ease-out', + }} + > + + +
+
+ +
+ {tabs.map((t) => { + const on = t.key === active; + return ( + + ); + })} +
+ {tab.url} + +
+ +
+