Skip to content

Commit 90be2eb

Browse files
bloveclaude
andauthored
refactor(website): one tabs implementation, shared by both homepage widgets (#834)
The homepage had two tab widgets and only one implemented the pattern. `DemoShowcase` announced `role="tablist"` and `aria-selected` with no `aria-controls`, no roving tabindex, and no keyboard handling — worse than plain buttons, because the roles promise assistive technology a widget that then does not respond to arrow keys. It also had no tests at all. Extract `TabGroup` with the mechanics `MediumSwitcher` already got right — roving tabindex, arrow/Home/End keys, focus following selection, and active-pane-only mounting — and have both consume it. `MediumSwitcher` keeps only the medium semantics and its analytics, so `DemoShowcase` does not inherit a `cta_id` shape that means nothing for runtime tabs. `DemoShowcase` gains the six tests it never had, including the one that would have caught the original defect: arrow keys must move `document.activeElement`, not just `aria-selected`. All ten existing `MediumSwitcher` tests pass unchanged through the refactor, which is the regression signal that matters here. Active-pane-only now holds for this section too: it rendered one `<video>` before because only one runtime was ever built, and it renders one now because the primitive mounts a single pane. The built homepage carries 5 tablists, 5 `<video>` elements — one per widget — and 0 iframes. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 418036f commit 90be2eb

5 files changed

Lines changed: 270 additions & 117 deletions

File tree

apps/website/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/dev/types/routes.d.ts";
3+
import "./../../dist/apps/website/.next/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// SPDX-License-Identifier: MIT
2+
// @vitest-environment jsdom
3+
import React from 'react';
4+
import { describe, expect, it, vi } from 'vitest';
5+
import { render, screen, fireEvent } from '@testing-library/react';
6+
import { DemoShowcase } from './DemoShowcase';
7+
8+
const trackCtaClickMock = vi.hoisted(() => vi.fn());
9+
vi.mock('../../lib/analytics/client', () => ({
10+
trackCtaClick: trackCtaClickMock,
11+
trackExternalLinkClick: vi.fn(),
12+
track: vi.fn(),
13+
}));
14+
15+
vi.mock('../ui/Container', () => ({
16+
Container: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
17+
}));
18+
vi.mock('../ui/Section', () => ({
19+
Section: ({ children }: { children: React.ReactNode }) => <section>{children}</section>,
20+
}));
21+
22+
/**
23+
* This section had no tests and an incomplete ARIA tabs pattern: it announced
24+
* `role="tablist"` while offering no `aria-controls`, no roving tabindex, and no
25+
* keyboard handling. These pin the behaviour the roles promise.
26+
*/
27+
describe('DemoShowcase', () => {
28+
it('offers a tab per runtime', () => {
29+
render(<DemoShowcase />);
30+
31+
const tabs = screen.getAllByRole('tab');
32+
expect(tabs.map((t) => t.textContent)).toEqual(['LangGraph', 'AG-UI']);
33+
expect(tabs[0].getAttribute('aria-selected')).toBe('true');
34+
});
35+
36+
it('pairs each tab with the panel it controls', () => {
37+
render(<DemoShowcase />);
38+
39+
const tab = screen.getAllByRole('tab')[0];
40+
const panel = screen.getByRole('tabpanel');
41+
expect(tab.getAttribute('aria-controls')).toBe(panel.getAttribute('id'));
42+
expect(panel.getAttribute('aria-labelledby')).toBe(tab.getAttribute('id'));
43+
});
44+
45+
it('moves focus with the selection on arrow keys', () => {
46+
// The defect this section shipped with: selection moved, focus did not, so
47+
// the next Tab press skipped the tablist entirely.
48+
render(<DemoShowcase />);
49+
50+
fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowRight' });
51+
52+
const tabs = screen.getAllByRole('tab');
53+
expect(tabs[1].getAttribute('aria-selected')).toBe('true');
54+
expect(document.activeElement).toBe(tabs[1]);
55+
});
56+
57+
it('mounts only the active runtime clip', () => {
58+
// Two autoplaying videos in one section would fetch both on load.
59+
const { container } = render(<DemoShowcase />);
60+
61+
expect(container.querySelectorAll('video')).toHaveLength(1);
62+
expect(container.querySelector('source')?.getAttribute('src')).toMatch(/langgraph-demo/);
63+
});
64+
65+
it('swaps the clip when the other runtime is selected', () => {
66+
const { container } = render(<DemoShowcase />);
67+
68+
fireEvent.click(screen.getAllByRole('tab')[1]);
69+
70+
expect(container.querySelectorAll('video')).toHaveLength(1);
71+
expect(container.querySelector('source')?.getAttribute('src')).toMatch(/ag-ui-demo/);
72+
});
73+
74+
it('reports the runtime whose demo was launched', () => {
75+
trackCtaClickMock.mockClear();
76+
render(<DemoShowcase />);
77+
78+
fireEvent.click(screen.getAllByRole('tab')[1]);
79+
fireEvent.click(screen.getByRole('button', { name: /launch ag-ui live demo/i }));
80+
81+
expect(trackCtaClickMock).toHaveBeenCalledWith(
82+
expect.objectContaining({ surface: 'home_demo', cta_id: 'home_demo_launch_ag_ui' }),
83+
);
84+
});
85+
});

apps/website/src/components/landing/DemoShowcase.tsx

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { useState } from 'react';
33
import { tokens } from '@threadplane/design-tokens';
44
import { BrowserFrame } from '../ui/BrowserFrame';
5+
import { TabGroup } from '../ui/TabGroup';
56
import { Button } from '../ui/Button';
67
import { DemoCtaPair } from './DemoCtaPair';
78
import { DemoModal } from './DemoModal';
@@ -30,9 +31,9 @@ const MEDIA: DemoMedia[] = [
3031
export function DemoShowcase() {
3132
const [active, setActive] = useState<TabKey>('langgraph');
3233
const [modalOpen, setModalOpen] = useState(false);
33-
const media = MEDIA.find((m) => m.key === active)!;
34-
const launch = () => {
35-
trackCtaClick({ surface: 'home_demo', destination_url: media.href, cta_id: `home_demo_launch_${active.replace(/-/g, '_')}`, cta_text: 'Launch live demo' });
34+
const launch = (media: DemoMedia) => {
35+
setActive(media.key);
36+
trackCtaClick({ surface: 'home_demo', destination_url: media.href, cta_id: `home_demo_launch_${media.key.replace(/-/g, '_')}`, cta_text: 'Launch live demo' });
3637
setModalOpen(true);
3738
};
3839

@@ -46,34 +47,39 @@ export function DemoShowcase() {
4647
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.
4748
</p>
4849

49-
<div role="tablist" aria-label="Demo backend" style={{ display: 'flex', gap: 6, justifyContent: 'center', marginBottom: 12 }}>
50-
{MEDIA.map((m) => {
51-
const on = m.key === active;
52-
return (
53-
<button key={m.key} role="tab" aria-selected={on} onClick={() => setActive(m.key)}
54-
style={{ fontFamily: 'Inter, sans-serif', fontSize: 13, fontWeight: 600, padding: '9px 16px', borderRadius: 8, border: 'none', cursor: 'pointer',
55-
background: on ? tokens.colors.accent : tokens.colors.accentSurface, color: on ? tokens.colors.textInverted : tokens.colors.textMuted }}>
56-
{m.tabLabel}
57-
</button>
58-
);
59-
})}
60-
</div>
61-
62-
<BrowserFrame url={media.url} elevation="lg">
63-
<div style={{ position: 'relative', width: '100%', aspectRatio: '16 / 10', background: '#15161f' }}>
64-
<video key={media.key} autoPlay muted loop playsInline poster={media.poster}
65-
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}>
66-
<source src={media.videoWebm} type="video/webm" />
67-
<source src={media.videoMp4} type="video/mp4" />
68-
</video>
69-
<button onClick={launch} aria-label={`Launch ${media.tabLabel} live demo`}
70-
style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 10,
71-
background: 'linear-gradient(180deg, rgba(16,18,32,.15), rgba(16,18,32,.45))', border: 'none', cursor: 'pointer' }}>
72-
<span style={{ width: 56, height: 56, borderRadius: '50%', background: 'rgba(255,255,255,.95)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#15161f', fontSize: 22 }}>&#9654;</span>
73-
<span style={{ fontFamily: 'Inter, sans-serif', fontWeight: 600, fontSize: 13, color: '#fff', background: 'rgba(0,0,0,.5)', padding: '8px 14px', borderRadius: 8 }}>Launch live demo</span>
74-
</button>
75-
</div>
76-
</BrowserFrame>
50+
{/*
51+
Runtime tabs, not medium tabs — this section's whole argument is that the
52+
SAME front end runs on two backends. `TabGroup` supplies the ARIA tabs
53+
pattern (roving tabindex, arrow/Home/End keys, focus following
54+
selection); previously this rendered tab roles with none of that
55+
behaviour, which promised assistive tech a widget that did not respond.
56+
*/}
57+
<TabGroup
58+
groupId="home-demo"
59+
label="Demo backend"
60+
panes={MEDIA.map((m) => ({
61+
id: m.key,
62+
label: m.tabLabel,
63+
content: (
64+
<BrowserFrame url={m.url} elevation="lg">
65+
<div style={{ position: 'relative', width: '100%', aspectRatio: '16 / 10', background: '#15161f' }}>
66+
<video autoPlay muted loop playsInline poster={m.poster}
67+
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}>
68+
<source src={m.videoWebm} type="video/webm" />
69+
<source src={m.videoMp4} type="video/mp4" />
70+
</video>
71+
<button onClick={() => launch(m)} aria-label={`Launch ${m.tabLabel} live demo`}
72+
style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 10,
73+
background: 'linear-gradient(180deg, rgba(16,18,32,.15), rgba(16,18,32,.45))', border: 'none', cursor: 'pointer' }}>
74+
<span style={{ width: 56, height: 56, borderRadius: '50%', background: 'rgba(255,255,255,.95)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#15161f', fontSize: 22 }}>&#9654;</span>
75+
<span style={{ fontFamily: 'Inter, sans-serif', fontWeight: 600, fontSize: 13, color: '#fff', background: 'rgba(0,0,0,.5)', padding: '8px 14px', borderRadius: 8 }}>Launch live demo</span>
76+
</button>
77+
</div>
78+
</BrowserFrame>
79+
),
80+
}))}
81+
onSelect={(pane) => setActive(pane.id as TabKey)}
82+
/>
7783

7884
<div style={{ display: 'flex', gap: 10, justifyContent: 'center', flexWrap: 'wrap', marginTop: 18 }}>
7985
<DemoCtaPair surface="home_demo" size="lg" />
Lines changed: 23 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// SPDX-License-Identifier: MIT
22
'use client';
3-
import { useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react';
4-
import { tokens } from '@threadplane/design-tokens';
3+
import { type ReactNode } from 'react';
4+
import { TabGroup, type TabPane } from '../ui/TabGroup';
55
import { trackCtaClick } from '../../lib/analytics/client';
66

77
export interface MediumPane {
@@ -24,91 +24,29 @@ interface MediumSwitcherProps {
2424
panes: MediumPane[];
2525
}
2626

27+
/**
28+
* A homepage section's medium picker: the same claim as a video, as code, or as
29+
* a live embed.
30+
*
31+
* The tabs pattern itself lives in `TabGroup` — this adds only the medium
32+
* semantics and the analytics, so `DemoShowcase` can share the mechanics
33+
* without inheriting a `cta_id` shape that means nothing for runtime tabs.
34+
*/
2735
export function MediumSwitcher({ sectionId, panes }: MediumSwitcherProps) {
28-
// Call sites pass a static `panes` array, so the index cannot go stale. If a
29-
// caller ever makes a medium conditional, this needs a clamp.
30-
const [active, setActive] = useState(0);
31-
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
32-
33-
// One medium needs no control surface; chrome around a single option is noise.
34-
if (panes.length <= 1) {
35-
return <>{panes[0]?.content ?? null}</>;
36-
}
37-
38-
const tabId = (id: string) => `${sectionId}-tab-${id}`;
39-
const panelId = (id: string) => `${sectionId}-panel-${id}`;
40-
41-
const select = (index: number) => {
42-
setActive(index);
43-
trackCtaClick({
44-
surface: 'home_medium_switcher',
45-
cta_id: `medium_${sectionId}_${panes[index].key}`,
46-
cta_text: panes[index].label,
47-
});
48-
};
49-
50-
const onKeyDown = (event: ReactKeyboardEvent) => {
51-
const last = panes.length - 1;
52-
let next: number;
53-
if (event.key === 'ArrowRight') next = (active + 1) % panes.length;
54-
else if (event.key === 'ArrowLeft') next = (active - 1 + panes.length) % panes.length;
55-
else if (event.key === 'Home') next = 0;
56-
else if (event.key === 'End') next = last;
57-
else return;
58-
59-
event.preventDefault();
60-
select(next);
61-
tabRefs.current[next]?.focus();
62-
};
36+
const byMedium = new Map(panes.map((pane) => [pane.id, pane.key]));
6337

6438
return (
65-
<div>
66-
<div
67-
role="tablist"
68-
aria-label={`Choose how to view the ${sectionId} section`}
69-
onKeyDown={onKeyDown}
70-
style={{ display: 'flex', gap: 6, marginBottom: 12 }}
71-
>
72-
{panes.map((pane, index) => {
73-
const selected = index === active;
74-
return (
75-
<button
76-
key={pane.id}
77-
ref={(el) => {
78-
tabRefs.current[index] = el;
79-
}}
80-
id={tabId(pane.id)}
81-
role="tab"
82-
type="button"
83-
aria-selected={selected}
84-
aria-controls={panelId(pane.id)}
85-
tabIndex={selected ? 0 : -1}
86-
onClick={() => select(index)}
87-
style={{
88-
fontFamily: 'Inter, sans-serif',
89-
fontSize: 13,
90-
fontWeight: 600,
91-
padding: '8px 14px',
92-
borderRadius: 8,
93-
border: 'none',
94-
cursor: 'pointer',
95-
background: selected ? tokens.colors.accent : tokens.colors.accentSurface,
96-
color: selected ? tokens.colors.textInverted : tokens.colors.textMuted,
97-
}}
98-
>
99-
{pane.label}
100-
</button>
101-
);
102-
})}
103-
</div>
104-
105-
<div
106-
id={panelId(panes[active].id)}
107-
role="tabpanel"
108-
aria-labelledby={tabId(panes[active].id)}
109-
>
110-
{panes[active].content}
111-
</div>
112-
</div>
39+
<TabGroup
40+
groupId={sectionId}
41+
label={`Choose how to view the ${sectionId} section`}
42+
panes={panes satisfies TabPane[]}
43+
onSelect={(pane) =>
44+
trackCtaClick({
45+
surface: 'home_medium_switcher',
46+
cta_id: `medium_${sectionId}_${byMedium.get(pane.id)}`,
47+
cta_text: pane.label,
48+
})
49+
}
50+
/>
11351
);
11452
}

0 commit comments

Comments
 (0)