diff --git a/apps/website/next-env.d.ts b/apps/website/next-env.d.ts index c4b7818fb..fdbfe5258 100644 --- a/apps/website/next-env.d.ts +++ b/apps/website/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./../../dist/apps/website/.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/website/src/components/landing/DemoShowcase.spec.tsx b/apps/website/src/components/landing/DemoShowcase.spec.tsx new file mode 100644 index 000000000..ba023e52b --- /dev/null +++ b/apps/website/src/components/landing/DemoShowcase.spec.tsx @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +// @vitest-environment jsdom +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { DemoShowcase } from './DemoShowcase'; + +const trackCtaClickMock = vi.hoisted(() => vi.fn()); +vi.mock('../../lib/analytics/client', () => ({ + trackCtaClick: trackCtaClickMock, + trackExternalLinkClick: vi.fn(), + track: vi.fn(), +})); + +vi.mock('../ui/Container', () => ({ + Container: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock('../ui/Section', () => ({ + Section: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +/** + * This section had no tests and an incomplete ARIA tabs pattern: it announced + * `role="tablist"` while offering no `aria-controls`, no roving tabindex, and no + * keyboard handling. These pin the behaviour the roles promise. + */ +describe('DemoShowcase', () => { + it('offers a tab per runtime', () => { + render(); + + const tabs = screen.getAllByRole('tab'); + expect(tabs.map((t) => t.textContent)).toEqual(['LangGraph', 'AG-UI']); + expect(tabs[0].getAttribute('aria-selected')).toBe('true'); + }); + + it('pairs each tab with the panel it controls', () => { + render(); + + const tab = screen.getAllByRole('tab')[0]; + const panel = screen.getByRole('tabpanel'); + expect(tab.getAttribute('aria-controls')).toBe(panel.getAttribute('id')); + expect(panel.getAttribute('aria-labelledby')).toBe(tab.getAttribute('id')); + }); + + it('moves focus with the selection on arrow keys', () => { + // The defect this section shipped with: selection moved, focus did not, so + // the next Tab press skipped the tablist entirely. + render(); + + fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowRight' }); + + const tabs = screen.getAllByRole('tab'); + expect(tabs[1].getAttribute('aria-selected')).toBe('true'); + expect(document.activeElement).toBe(tabs[1]); + }); + + it('mounts only the active runtime clip', () => { + // Two autoplaying videos in one section would fetch both on load. + const { container } = render(); + + expect(container.querySelectorAll('video')).toHaveLength(1); + expect(container.querySelector('source')?.getAttribute('src')).toMatch(/langgraph-demo/); + }); + + it('swaps the clip when the other runtime is selected', () => { + const { container } = render(); + + fireEvent.click(screen.getAllByRole('tab')[1]); + + expect(container.querySelectorAll('video')).toHaveLength(1); + expect(container.querySelector('source')?.getAttribute('src')).toMatch(/ag-ui-demo/); + }); + + it('reports the runtime whose demo was launched', () => { + trackCtaClickMock.mockClear(); + render(); + + fireEvent.click(screen.getAllByRole('tab')[1]); + fireEvent.click(screen.getByRole('button', { name: /launch ag-ui live demo/i })); + + expect(trackCtaClickMock).toHaveBeenCalledWith( + expect.objectContaining({ surface: 'home_demo', cta_id: 'home_demo_launch_ag_ui' }), + ); + }); +}); diff --git a/apps/website/src/components/landing/DemoShowcase.tsx b/apps/website/src/components/landing/DemoShowcase.tsx index 1ed353a31..0c302052f 100644 --- a/apps/website/src/components/landing/DemoShowcase.tsx +++ b/apps/website/src/components/landing/DemoShowcase.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { tokens } from '@threadplane/design-tokens'; import { BrowserFrame } from '../ui/BrowserFrame'; +import { TabGroup } from '../ui/TabGroup'; import { Button } from '../ui/Button'; import { DemoCtaPair } from './DemoCtaPair'; import { DemoModal } from './DemoModal'; @@ -30,9 +31,9 @@ const MEDIA: DemoMedia[] = [ export function DemoShowcase() { const [active, setActive] = useState('langgraph'); const [modalOpen, setModalOpen] = useState(false); - const media = MEDIA.find((m) => m.key === active)!; - const launch = () => { - trackCtaClick({ surface: 'home_demo', destination_url: media.href, cta_id: `home_demo_launch_${active.replace(/-/g, '_')}`, cta_text: 'Launch live demo' }); + const launch = (media: DemoMedia) => { + setActive(media.key); + trackCtaClick({ surface: 'home_demo', destination_url: media.href, cta_id: `home_demo_launch_${media.key.replace(/-/g, '_')}`, cta_text: 'Launch live demo' }); setModalOpen(true); }; @@ -46,34 +47,39 @@ export function DemoShowcase() { The identical Threadplane chat surface, running live against a LangGraph backend and an AG-UI backend. Switch tabs to compare — the front end never changes.

-
- {MEDIA.map((m) => { - const on = m.key === active; - return ( - - ); - })} -
- - -
- - -
-
+ {/* + Runtime tabs, not medium tabs — this section's whole argument is that the + SAME front end runs on two backends. `TabGroup` supplies the ARIA tabs + pattern (roving tabindex, arrow/Home/End keys, focus following + selection); previously this rendered tab roles with none of that + behaviour, which promised assistive tech a widget that did not respond. + */} + ({ + id: m.key, + label: m.tabLabel, + content: ( + +
+ + +
+
+ ), + }))} + onSelect={(pane) => setActive(pane.id as TabKey)} + />
diff --git a/apps/website/src/components/landing/MediumSwitcher.tsx b/apps/website/src/components/landing/MediumSwitcher.tsx index 36111dc25..398fc45fb 100644 --- a/apps/website/src/components/landing/MediumSwitcher.tsx +++ b/apps/website/src/components/landing/MediumSwitcher.tsx @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT 'use client'; -import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'; -import { tokens } from '@threadplane/design-tokens'; +import { type ReactNode } from 'react'; +import { TabGroup, type TabPane } from '../ui/TabGroup'; import { trackCtaClick } from '../../lib/analytics/client'; export interface MediumPane { @@ -24,91 +24,29 @@ interface MediumSwitcherProps { panes: MediumPane[]; } +/** + * A homepage section's medium picker: the same claim as a video, as code, or as + * a live embed. + * + * The tabs pattern itself lives in `TabGroup` — this adds only the medium + * semantics and the analytics, so `DemoShowcase` can share the mechanics + * without inheriting a `cta_id` shape that means nothing for runtime tabs. + */ export function MediumSwitcher({ sectionId, panes }: MediumSwitcherProps) { - // Call sites pass a static `panes` array, so the index cannot go stale. If a - // caller ever makes a medium conditional, this needs a clamp. - const [active, setActive] = useState(0); - const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); - - // One medium needs no control surface; chrome around a single option is noise. - if (panes.length <= 1) { - return <>{panes[0]?.content ?? null}; - } - - const tabId = (id: string) => `${sectionId}-tab-${id}`; - const panelId = (id: string) => `${sectionId}-panel-${id}`; - - const select = (index: number) => { - setActive(index); - trackCtaClick({ - surface: 'home_medium_switcher', - cta_id: `medium_${sectionId}_${panes[index].key}`, - cta_text: panes[index].label, - }); - }; - - const onKeyDown = (event: ReactKeyboardEvent) => { - const last = panes.length - 1; - let next: number; - if (event.key === 'ArrowRight') next = (active + 1) % panes.length; - else if (event.key === 'ArrowLeft') next = (active - 1 + panes.length) % panes.length; - else if (event.key === 'Home') next = 0; - else if (event.key === 'End') next = last; - else return; - - event.preventDefault(); - select(next); - tabRefs.current[next]?.focus(); - }; + const byMedium = new Map(panes.map((pane) => [pane.id, pane.key])); return ( -
-
- {panes.map((pane, index) => { - const selected = index === active; - return ( - - ); - })} -
- -
- {panes[active].content} -
-
+ + trackCtaClick({ + surface: 'home_medium_switcher', + cta_id: `medium_${sectionId}_${byMedium.get(pane.id)}`, + cta_text: pane.label, + }) + } + /> ); } diff --git a/apps/website/src/components/ui/TabGroup.tsx b/apps/website/src/components/ui/TabGroup.tsx new file mode 100644 index 000000000..629fc132a --- /dev/null +++ b/apps/website/src/components/ui/TabGroup.tsx @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +'use client'; +import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react'; +import { tokens } from '@threadplane/design-tokens'; + +export interface TabPane { + /** Unique within a group — used for React keys and DOM ids. */ + id: string; + label: string; + content: ReactNode; +} + +interface TabGroupProps { + /** Namespaces the tab/panel ids so two groups on one page cannot collide. */ + groupId: string; + /** Names this group for screen-reader users jumping by role. */ + label: string; + panes: TabPane[]; + /** + * Called when the reader picks a tab — NOT on first render, since arriving at + * a default is not a choice. Analytics belongs to the caller: this primitive + * has no opinion about what a tab means. + */ + onSelect?: (pane: TabPane, index: number) => void; +} + +/** + * The ARIA tabs pattern, done once. + * + * Extracted because the homepage grew two tab widgets and only one implemented + * the pattern properly. `DemoShowcase` announced `role="tablist"` with no + * `aria-controls`, no roving tabindex, and no keyboard handling — which is + * worse than plain buttons, because the roles promise assistive technology a + * widget that then does not respond to arrow keys. + * + * Two properties are easy to get wrong and are pinned by tests: + * + * - **Only the active pane is mounted.** Inactive panes are absent from the + * DOM, not hidden with CSS. Callers put videos and iframes in panes, so a + * CSS-toggled implementation would fetch every one of them on page load. + * - **Focus follows selection.** Arrow keys move DOM focus along with + * `aria-selected`. Without that the newly-inactive tab keeps focus while + * holding `tabIndex={-1}`, so the next Tab press skips the whole tablist. + */ +export function TabGroup({ groupId, label, panes, onSelect }: TabGroupProps) { + // Call sites pass a static `panes` array, so the index cannot go stale. If a + // caller ever makes a pane conditional, this needs a clamp. + const [active, setActive] = useState(0); + const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); + + // One pane needs no control surface; chrome around a single option is noise. + if (panes.length <= 1) { + return <>{panes[0]?.content ?? null}; + } + + const tabId = (id: string) => `${groupId}-tab-${id}`; + const panelId = (id: string) => `${groupId}-panel-${id}`; + + const select = (index: number) => { + setActive(index); + onSelect?.(panes[index], index); + }; + + const onKeyDown = (event: ReactKeyboardEvent) => { + const last = panes.length - 1; + let next: number; + if (event.key === 'ArrowRight') next = (active + 1) % panes.length; + else if (event.key === 'ArrowLeft') next = (active - 1 + panes.length) % panes.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = last; + else return; + + event.preventDefault(); + select(next); + tabRefs.current[next]?.focus(); + }; + + return ( +
+
+ {panes.map((pane, index) => { + const selected = index === active; + return ( + + ); + })} +
+ +
+ {panes[active].content} +
+
+ ); +}