+
+ {panes.map((pane, i) => {
+ const isActive = i === activeIndex;
+ const isPreview = i === previewIndex && !isActive;
+
+ return (
+
+
+ {/* Decorative in-tablist panel — aria-hidden so AT only uses the
+ outside semantic tabpanel. The Hero here is for sighted layout.
+ Option C: no role, no id, no aria-labelledby on this div. */}
+
+
+
+
+ );
+ })}
+
+
+ {/* Semantic tabpanels: DOM siblings of the tablist, outside the tablist.
+ axe-core 4.10.2 aria-required-children rule only sees DOM descendants
+ of the tablist — placing tabpanels here means the tablist's own children
+ are only role=tab buttons, satisfying WCAG 1.3.1.
+ Active panel: no hidden attr; class visually-hidden → off-screen but AT-perceivable.
+ Inactive panels: HTML hidden attr → fully removed from a11y tree. */}
+ {panes.map((pane, i) => {
+ const isActive = i === activeIndex;
+ return (
+
+
+
+ );
+ })}
+
+ );
+};
+
+ExpandableHeroes.propTypes = {
+ panes: PropTypes.arrayOf(panePropType).isRequired,
+ initialActiveIndex: PropTypes.number,
+ onPaneChange: PropTypes.func,
+ gaRegion: PropTypes.string,
+ gaSection: PropTypes.string,
+};
+
+ExpandableHeroes.defaultProps = {
+ initialActiveIndex: 0,
+ gaRegion: "main content",
+ gaSection: "hero",
+};
+
+export { ExpandableHeroes };
diff --git a/packages/unity-react-core/src/components/ExpandableHeroes/ExpandableHeroes.stories.jsx b/packages/unity-react-core/src/components/ExpandableHeroes/ExpandableHeroes.stories.jsx
new file mode 100644
index 0000000000..02f3651743
--- /dev/null
+++ b/packages/unity-react-core/src/components/ExpandableHeroes/ExpandableHeroes.stories.jsx
@@ -0,0 +1,440 @@
+// @ts-check
+import { imageAny } from "@asu/shared";
+import React from "react";
+
+import { ExpandableHeroes } from "./ExpandableHeroes";
+
+// ── Fixtures ──────────────────────────────────────────────────────────────────
+
+const IMG1 = imageAny();
+const IMG2 = imageAny();
+const IMG3 = imageAny();
+
+/** @type {import('./ExpandableHeroes').ExpandableHeroesProps['panes']} */
+const samplePanes = [
+ {
+ image: { url: IMG1, altText: "Hero image one", size: "large" },
+ title: { text: "Pane One Title", color: "white" },
+ subTitle: { text: "Subtitle One", color: "white" },
+ contents: [
+ { text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit." },
+ ],
+ contentsColor: "white",
+ },
+ {
+ image: { url: IMG2, altText: "Hero image two", size: "large" },
+ title: { text: "Pane Two Title", color: "white" },
+ subTitle: { text: "Subtitle Two", color: "white" },
+ contents: [
+ {
+ text: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
+ },
+ ],
+ contentsColor: "white",
+ },
+ {
+ image: { url: IMG3, altText: "Hero image three", size: "large" },
+ title: {
+ text: "A Very Long Pane Three Title That Tests Ellipsis Overflow Behavior",
+ color: "white",
+ },
+ subTitle: { text: "Subtitle Three", color: "white" },
+ contents: [
+ { text: "Ut enim ad minim veniam, quis nostrud exercitation ullamco." },
+ ],
+ contentsColor: "white",
+ },
+];
+
+export default {
+ title: "Components/ExpandableHeroes",
+ component: ExpandableHeroes,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ "Three-pane expandable hero implementing the APG Tabs (manual activation) pattern. " +
+ "Click / Enter / Space commits a pane. Hover and focus show a non-committing preview. " +
+ "Arrow Left/Right/Home/End navigate without committing. Below lg breakpoint all panes stack.",
+ },
+ },
+ },
+};
+
+// ── React Story ───────────────────────────────────────────────────────────────
+
+export const Default = {
+ args: {
+ panes: samplePanes,
+ initialActiveIndex: 0,
+ gaRegion: "main content",
+ gaSection: "hero",
+ },
+ parameters: {
+ docs: {
+ description: { story: "Default React story — pane 0 active on mount." },
+ },
+ },
+};
+
+// ── HTML-Parity Story ─────────────────────────────────────────────────────────
+
+/**
+ * Hand-written JSX-as-HTML literal mirroring the exact DOM tree emitted by the
+ * React component with panes=samplePanes, initialActiveIndex=0.
+ * CSS-class and attribute structure MUST stay in sync with ExpandableHeroes.jsx.
+ * (amended after cycle 5: Option C dual-render DOM — outer container wraps the
+ * tablist + three semantic tabpanels as DOM siblings of the tablist.
+ * In-tablist __panel divs are decorative: aria-hidden=true, no role/id.
+ * Outside tabpanels carry role=tabpanel; active has visually-hidden class;
+ * inactive have HTML hidden attribute set.)
+ */
+export const HtmlParity = {
+ render: () => (
+
+
+ {/* Item 0 — ACTIVE */}
+
+
+ {/* Decorative in-tablist panel — aria-hidden, sighted layout only */}
+
+
+
+

+
+ Subtitle One
+
+
+ Pane One Title
+
+
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+
+
+
+
+ {/* Item 1 — COLLAPSED */}
+
+
+ {/* Decorative in-tablist panel — aria-hidden, sighted layout only */}
+
+
+
+

+
+ Subtitle Two
+
+
+ Pane Two Title
+
+
+
+ Sed do eiusmod tempor incididunt ut labore et dolore magna
+ aliqua.
+
+
+
+
+
+
+ {/* Item 2 — COLLAPSED */}
+
+
+ {/* Decorative in-tablist panel — aria-hidden, sighted layout only */}
+
+
+
+

+
+ Subtitle Three
+
+
+
+ A Very Long Pane Three Title That Tests Ellipsis Overflow
+ Behavior
+
+
+
+
+ Ut enim ad minim veniam, quis nostrud exercitation ullamco.
+
+
+
+
+
+
+
+ {/* Outside semantic tabpanels — DOM siblings of the tablist.
+ AT-perceivable; role=tabpanel satisfies WAI-ARIA tabs contract.
+ Active: visually-hidden class → off-screen but in a11y tree.
+ Inactive: HTML hidden attr → fully removed from a11y tree. */}
+
+ {/* Panel 0 — ACTIVE */}
+
+
+
+

+
+ Subtitle One
+
+
+ Pane One Title
+
+
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+
+
+
+ {/* Panel 1 — INACTIVE */}
+
+
+
+

+
+ Subtitle Two
+
+
+ Pane Two Title
+
+
+
+ Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+
+
+
+
+
+ {/* Panel 2 — INACTIVE */}
+
+
+
+

+
+ Subtitle Three
+
+
+
+ A Very Long Pane Three Title That Tests Ellipsis Overflow Behavior
+
+
+
+
Ut enim ad minim veniam, quis nostrud exercitation ullamco.
+
+
+
+
+ ),
+};
+
+HtmlParity.parameters = {
+ docs: {
+ description: {
+ story:
+ "HTML-parity story: hand-written JSX-as-HTML literal matching the React component DOM tree " +
+ "(panes[0] active). Option C dual-render DOM: outer container wraps tablist + 3 semantic " +
+ "tabpanels as DOM siblings. In-tablist __panel divs are decorative (aria-hidden=true). " +
+ "Used to verify CMS authors can copy-paste markup with identical CSS hooks. " +
+ "T23: axe scans this story. T28: snapshot comparison asserts structural equivalence.",
+ },
+ },
+};
+
+// ── Viewport 320px Story (T20) ───────────────────────────────────────────────
+
+/**
+ * All three panes should stack vertically and render as full heroes at 320px.
+ * No horizontal scroll. Rotated titles hidden. Used by T20 manual/automated check.
+ */
+export const Viewport320 = {
+ args: {
+ panes: samplePanes,
+ initialActiveIndex: 0,
+ gaRegion: "main content",
+ gaSection: "hero",
+ },
+ parameters: {
+ viewport: { defaultViewport: "mobile1" },
+ docs: {
+ description: {
+ story:
+ "T20: At 320px viewport all three Hero panels stack vertically, " +
+ "no horizontal scroll, rotated titles hidden. Verify via storybook viewport addon.",
+ },
+ },
+ },
+};
+
+// ── Prefers-Reduced-Motion Story (T19) ───────────────────────────────────────
+
+/**
+ * Verify CSS-only reduced-motion gate: transition should be absent (instant snap).
+ * T19: run with DevTools > Rendering > prefers-reduced-motion: reduce.
+ */
+export const ReducedMotion = {
+ args: {
+ panes: samplePanes,
+ initialActiveIndex: 0,
+ gaRegion: "main content",
+ gaSection: "hero",
+ },
+ parameters: {
+ chromatic: { prefersReducedMotion: "reduce" },
+ docs: {
+ description: {
+ story:
+ "T19: With prefers-reduced-motion: reduce the pane width transition is absent " +
+ "(instant snap). CSS-only gate via @media (prefers-reduced-motion: no-preference). " +
+ "Verify via DevTools Rendering panel or Chromatic.",
+ },
+ },
+ },
+};
+
+// ── Forced-Colors Story (T24) ─────────────────────────────────────────────────
+
+/**
+ * Verify forced-colors mode fallback: border, outline, background use system colors.
+ * T24: run with DevTools > Rendering > emulate CSS forced-colors: active.
+ */
+export const ForcedColors = {
+ args: {
+ panes: samplePanes,
+ initialActiveIndex: 0,
+ gaRegion: "main content",
+ gaSection: "hero",
+ },
+ parameters: {
+ chromatic: { forcedColors: "active" },
+ docs: {
+ description: {
+ story:
+ "T24: Under forced-colors mode the collapsed strips have a CanvasText border, " +
+ "focus rings use Highlight, and rotated titles fall back to Canvas/CanvasText. " +
+ "Verify via DevTools Rendering panel or Chromatic.",
+ },
+ },
+ },
+};
diff --git a/packages/unity-react-core/src/components/ExpandableHeroes/ExpandableHeroes.test.jsx b/packages/unity-react-core/src/components/ExpandableHeroes/ExpandableHeroes.test.jsx
new file mode 100644
index 0000000000..1f1dbfa4db
--- /dev/null
+++ b/packages/unity-react-core/src/components/ExpandableHeroes/ExpandableHeroes.test.jsx
@@ -0,0 +1,818 @@
+// @ts-check
+import { render, fireEvent } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import React from "react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { ExpandableHeroes } from "./ExpandableHeroes";
+
+// ── Shared fixtures ────────────────────────────────────────────────────────────
+
+const IMG1 = "https://example.com/img1.jpg";
+const IMG2 = "https://example.com/img2.jpg";
+const IMG3 = "https://example.com/img3.jpg";
+
+const makePane = (n, imgUrl) => ({
+ image: {
+ url: imgUrl || `https://example.com/img${n}.jpg`,
+ altText: `Alt ${n}`,
+ },
+ title: { text: `Pane ${n} Title` },
+});
+
+const THREE_PANES = [makePane(1), makePane(2), makePane(3)];
+
+const FIXED_PANES = [makePane(1, IMG1), makePane(2, IMG2), makePane(3, IMG3)];
+
+// ── T16: panes.length !== 3 → console.error + renders null ────────────────────
+
+describe("T16 — length validation", () => {
+ let errorSpy;
+ beforeEach(() => {
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ });
+ afterEach(() => {
+ errorSpy.mockRestore();
+ });
+
+ it("renders null and logs error when panes.length !== 3", () => {
+ const { container } = render(
+
+
+ {/* Item 0 — ACTIVE */}
+
+
+
+
+ {/* Item 1 — COLLAPSED */}
+
+
+
+
+ {/* Item 2 — COLLAPSED */}
+
+
+
+
+
+
+ {/* Outside semantic tabpanels — siblings of the tablist */}
+ {/* Panel 0 — ACTIVE: no hidden attr, class visually-hidden */}
+
+ {/* Panel 1 — INACTIVE: hidden attr set */}
+
+ {/* Panel 2 — INACTIVE: hidden attr set */}
+
+
+ );
+ const { container: htmlContainer } = render(htmlParityTree);
+
+ // ── Compare outer container ────────────────────────────────────────────
+ const reactOuterContainer = reactContainer.firstElementChild;
+ const htmlOuterContainer = htmlContainer.firstElementChild;
+
+ expect(reactOuterContainer?.tagName).toBe("DIV");
+ expect(htmlOuterContainer?.tagName).toBe("DIV");
+ expect(reactOuterContainer).toHaveClass("uds-expandable-heroes-container");
+ expect(htmlOuterContainer).toHaveClass("uds-expandable-heroes-container");
+
+ // ── Outer container has 4 children: 1 tablist + 3 semantic tabpanels ──
+ expect(reactOuterContainer?.children.length).toBe(4);
+ expect(htmlOuterContainer?.children.length).toBe(4);
+
+ // ── First child is the tablist ─────────────────────────────────────────
+ const reactTablist = reactOuterContainer?.children[0];
+ const htmlTablist = htmlOuterContainer?.children[0];
+ expect(reactTablist?.getAttribute("role")).toBe("tablist");
+ expect(htmlTablist?.getAttribute("role")).toBe("tablist");
+
+ // Tablist has 3 __item children
+ expect(reactTablist?.children.length).toBe(3);
+ expect(htmlTablist?.children.length).toBe(3);
+
+ // Each __item: 1 tab button + 1 decorative __panel (aria-hidden, no role)
+ Array.from(reactTablist?.children ?? []).forEach((item, i) => {
+ expect(item).toHaveClass("uds-expandable-heroes__item");
+ const htmlItem = htmlTablist?.children[i];
+ expect(item.children.length).toBe(2);
+ expect(htmlItem?.children.length).toBe(2);
+ // First child is tab button
+ expect(item.children[0].getAttribute("role")).toBe("tab");
+ expect(htmlItem?.children[0].getAttribute("role")).toBe("tab");
+ // Second child is decorative __panel: aria-hidden=true, no role
+ expect(item.children[1]).toHaveAttribute("aria-hidden", "true");
+ expect(item.children[1]).not.toHaveAttribute("role");
+ expect(htmlItem?.children[1]).toHaveAttribute("aria-hidden", "true");
+ expect(htmlItem?.children[1]).not.toHaveAttribute("role");
+ });
+
+ // ── Children 1-3 of outer container are semantic tabpanels ────────────
+ for (let i = 1; i <= 3; i++) {
+ const reactPanel = reactOuterContainer?.children[i];
+ const htmlPanel = htmlOuterContainer?.children[i];
+ expect(reactPanel?.getAttribute("role")).toBe("tabpanel");
+ expect(htmlPanel?.getAttribute("role")).toBe("tabpanel");
+ expect(reactPanel).toHaveAttribute(
+ "id",
+ `expandable-heroes-panel-${i - 1}`
+ );
+ expect(htmlPanel).toHaveAttribute(
+ "id",
+ `expandable-heroes-panel-${i - 1}`
+ );
+ }
+ });
+});
+
+// ── T09b: Enter fires dataLayer.push EXACTLY once (no double-fire) ────────────
+
+describe("T09b — Enter key fires GA exactly once (no double-fire)", () => {
+ it("userEvent.keyboard Enter on focused tab calls dataLayer.push exactly once", async () => {
+ window.dataLayer = [];
+ const user = userEvent.setup();
+ const { getAllByRole } = render(