-
+ {/*
+ 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: (
+
+
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 (
-
+
+ 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 (
+