diff --git a/.gitignore b/.gitignore index 8c672a20e9..73e7161059 100644 --- a/.gitignore +++ b/.gitignore @@ -122,3 +122,11 @@ tests/__image_snapshots__/__diff_output__ !.yarn/versions *.env.* !.env.yarn.example + +# --- Adversarial coding pipeline (project-local .kiro) --- +# Committed: .kiro/agents, .kiro/skills, .kiro/hooks, .kiro/scripts, +# .kiro/steering, .kiro/settings/mcp.json +# Ignored (ephemeral run state + user-supplied intake assets): +.pipeline/ +.intake/* +!.intake/.gitkeep diff --git a/packages/unity-bootstrap-theme/src/scss/_unity-bootstrap-theme-extends.scss b/packages/unity-bootstrap-theme/src/scss/_unity-bootstrap-theme-extends.scss index 55ad80a7bd..8e9468b35f 100644 --- a/packages/unity-bootstrap-theme/src/scss/_unity-bootstrap-theme-extends.scss +++ b/packages/unity-bootstrap-theme/src/scss/_unity-bootstrap-theme-extends.scss @@ -18,6 +18,7 @@ @import 'extends/pager'; @import 'extends/tabbed-panels'; @import 'extends/heroes'; +@import 'extends/heroes-expandable'; @import 'extends/breadcrumb'; @import 'extends/sidebar'; // @import 'extends/typography'; diff --git a/packages/unity-bootstrap-theme/src/scss/extends/_heroes-expandable.scss b/packages/unity-bootstrap-theme/src/scss/extends/_heroes-expandable.scss new file mode 100644 index 0000000000..4c5e7491d1 --- /dev/null +++ b/packages/unity-bootstrap-theme/src/scss/extends/_heroes-expandable.scss @@ -0,0 +1,270 @@ +/*-------------------------------------------------------------- +# ExpandableHeroes +# Extends the hero strip layout for the APG Tabs manual-activation pattern. +# Depends on _heroes.scss being imported first (for $uds-hero-gradient-overlay). +# +# LITERAL AUDIT CONTRACT: +# The ONLY hardcoded numeric literals in this file are the two pane width +# percentages: 15% (collapsed) and 70% (active). +# Every other value uses a $uds-* token from _custom-asu-variables.scss. +# +# DOM STRUCTURE (amended after cycle 5: Option C dual-render tabpanels. +# The outer .uds-expandable-heroes-container wraps the tablist AND three +# semantic tabpanels placed as DOM siblings of the tablist. This satisfies +# axe-core 4.10.2 aria-required-children (the tablist's own DOM descendants +# are only role=tab buttons; in-tablist __panel divs carry aria-hidden=true). +# Inside __panel divs are decorative (no role, no ARIA semantics) for sighted +# layout. The outside tabpanels carry role=tabpanel and are visually-hidden +# (Bootstrap .visually-hidden utility) when active, fully hidden (HTML hidden +# attr) when inactive. +# +# .uds-expandable-heroes-container ← NEW outer wrapper (block) +# .uds-expandable-heroes [role=tablist] ← unchanged flex container +# .uds-expandable-heroes__item.is-active ← flex item (70%) +# .uds-expandable-heroes__pane.is-active ← button [role=tab] +# .uds-expandable-heroes__rotated-title (hidden when active) +# .uds-expandable-heroes__panel [aria-hidden] ← decorative, in-flow +# +# .uds-expandable-heroes__item ← flex item (15%) +# .uds-expandable-heroes__pane--collapsed ← button [role=tab] +# .uds-expandable-heroes__rotated-title +# .uds-expandable-heroes__panel.is-hidden [aria-hidden] ← decorative, hidden +# .uds-expandable-heroes__item ← flex item (15%) +# … +# div[role=tabpanel]#expandable-heroes-panel-0 ← semantic tabpanel (active) +# class="visually-hidden" → off-screen, AT-perceivable +# +# div[role=tabpanel]#expandable-heroes-panel-1 ← semantic tabpanel (inactive) +# hidden attr → removed from a11y tree +# div[role=tabpanel]#expandable-heroes-panel-2 ← semantic tabpanel (inactive) +# hidden attr → removed from a11y tree +# +# LAYOUT CHOICE: Option β — in-flow panel at ≥lg (cycle-3, preserved in cycle-5). +# The __item is display:flex; flex-direction:column. The active __panel is in +# flow inside the 70% column; the Hero's image drives item height, which drives +# the row height via align-items:stretch. Collapsed items stretch to match the +# row height automatically. Their panels are display:none so the button fills +# the full item area. +# +# MARGIN NOTE (cycle-5): margin-bottom stays on .uds-expandable-heroes (the +# tablist), not moved to the container. This preserves visual fidelity vs the +# cycle-4 VISUAL_PASS reference. The container is a plain block wrapper with +# no intrinsic spacing. +--------------------------------------------------------------*/ + +/*-------------------------------------------------------------- +0. Outer container — wraps tablist + semantic tabpanels (Option C, cycle-5) + Pure block wrapper. No ARIA role. No intrinsic spacing (margin stays on + .uds-expandable-heroes to preserve cycle-4 visual fidelity). +--------------------------------------------------------------*/ + +.uds-expandable-heroes-container { + display: block; + width: 100%; +} +/*-------------------------------------------------------------- +1. Root tablist container +--------------------------------------------------------------*/ + +.uds-expandable-heroes { + display: block; // mobile-first: stacked column + position: relative; + width: 100%; + overflow-x: hidden; + margin-bottom: $uds-size-spacing-8; + + @include media-breakpoint-up(lg) { + display: flex; + flex-direction: row; + align-items: stretch; + gap: 0; + } +} + +/*-------------------------------------------------------------- +2. Wrapper item — the flex item carrying width logic + (amended after cycle 2: width/flex logic moved from __pane to __item) +--------------------------------------------------------------*/ + +.uds-expandable-heroes__item { + display: block; // mobile-first: each item is a stacked block + + @include media-breakpoint-up(lg) { + display: flex; + flex-direction: column; // button on top (0 height, hidden bg), panel below (flex:1) + flex-grow: 0; + flex-shrink: 0; + flex-basis: 15%; // ← LITERAL 1 (locked: collapsed item width) + + @media (prefers-reduced-motion: no-preference) { + transition: flex-basis $uds-time-transition-base ease-in-out; + } + + &.is-active { + flex-basis: 70%; // ← LITERAL 2 (locked: active item width) + } + } +} + +/*-------------------------------------------------------------- +3. Individual pane button (tab) +--------------------------------------------------------------*/ + +.uds-expandable-heroes__pane { + display: block; + position: relative; + width: 100%; + padding: 0; + border: 0; + background-color: $asu-gray-1; + background-size: cover; + background-position: center center; + background-repeat: no-repeat; + cursor: pointer; + color: $uds-color-base-white; + font-family: $uds-font-family-base; + text-align: left; + overflow: hidden; + + // Focus ring (visible, not focus-within) + &:focus-visible { + outline: solid $uds-color-base-bluefocus; + outline-width: $uds-size-spacing-1; // P3 deferred: spec §6 says thin focus ring; no thin-border token exists + outline-offset: $uds-size-spacing-half; + } + + @include media-breakpoint-up(lg) { + // In collapsed items, the button fills the full item height (panel is hidden). + // In the active item, the button has 0 intrinsic height (panel is in flow below it). + flex: 0 0 auto; + width: 100%; + + &.is-active { + // Active button contributes 0 height; the panel (flex:1) drives item height. + // The rotated title is hidden when active (Hero shows the full heading). + flex-basis: 0; + height: 0; + overflow: hidden; + pointer-events: none; // panel overlays; Hero content is interactive + } + + &:not(.is-active) { + // Collapsed button fills the available height left after its panel (none, display:none). + flex: 1 1 auto; + } + + &.is-preview { + filter: brightness(1.1); + } + } +} + +/*-------------------------------------------------------------- +4. Gradient overlay on collapsed strips +--------------------------------------------------------------*/ + +.uds-expandable-heroes__pane--collapsed::before { + content: ""; + position: absolute; + inset: 0; + background: $uds-hero-gradient-overlay; + z-index: 1; + pointer-events: none; +} + +/*-------------------------------------------------------------- +5. Rotated vertical title (collapsed strip label) +--------------------------------------------------------------*/ + +.uds-expandable-heroes__rotated-title { + display: none; // hidden in mobile-stack mode + + @include media-breakpoint-up(lg) { + display: inline-block; + position: absolute; + bottom: $uds-size-spacing-2; + left: 50%; + writing-mode: vertical-rl; + transform: translateX(-50%) rotate(180deg); // reads bottom-to-top per design-doc §7 + transform-origin: center center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 90%; // proportional, not a numeric px/rem literal + font-family: $uds-font-family-base; + font-size: $uds-size-font-large; + font-weight: $uds-font-weight-bold; + color: $uds-color-base-white; + padding: $uds-size-spacing-1 $uds-size-spacing-2; + z-index: 2; + pointer-events: none; + + @include media-breakpoint-up(xl) { + font-size: $uds-size-font-xl; + } + } +} + +// Hide rotated title when pane is active (the Hero shows the full heading) +.uds-expandable-heroes__pane.is-active .uds-expandable-heroes__rotated-title { + display: none; +} + +/*-------------------------------------------------------------- +6. Tab panel (Hero container) +--------------------------------------------------------------*/ + +.uds-expandable-heroes__panel { + display: block; // mobile-first: all panels visible in stack mode + + @include media-breakpoint-up(lg) { + display: none; // collapsed items: panel hidden; button fills item + } + + &:focus-visible { + outline: $uds-size-spacing-1 solid $uds-color-base-bluefocus; + outline-offset: $uds-size-spacing-half; + } +} + +// Active item's panel is in-flow (option β). +// The Hero's image drives this panel's height, which drives the item's height, +// which (via align-items:stretch) drives the entire flex row's height. +.uds-expandable-heroes__item.is-active .uds-expandable-heroes__panel { + @include media-breakpoint-up(lg) { + display: block; + flex: 1 1 auto; // takes remaining height inside the column flex item + pointer-events: auto; + } +} + +// CSS visibility control: .is-hidden class used for inactive panels in stack mode +.uds-expandable-heroes__panel.is-hidden { + @include media-breakpoint-down(md) { + display: none; + } +} + +/*-------------------------------------------------------------- +7. Forced-colors mode fallback +--------------------------------------------------------------*/ + +@media (forced-colors: active) { + .uds-expandable-heroes__pane { + border: solid CanvasText; + + &:focus-visible { + outline: solid Highlight; + } + } + + .uds-expandable-heroes__rotated-title { + background: Canvas; + color: CanvasText; + } + + .uds-expandable-heroes__pane--collapsed::before { + // Let the overlay fade in forced-colors; background image remains visible + background: none; + } +} diff --git a/packages/unity-react-core/package.json b/packages/unity-react-core/package.json index 7705944f6f..1ac558d3f2 100644 --- a/packages/unity-react-core/package.json +++ b/packages/unity-react-core/package.json @@ -41,6 +41,7 @@ "postbuild": "cp ./types/main.d.ts ./dist/main.d.ts", "build:stats": "webpack -c webpack/webpack.prod.js --profile --json=compilation-stats.json", "storybook": "storybook dev -p 9200", + "test-storybook": "test-storybook --url http://localhost:9200", "build-storybook": "storybook build -o ../../build/$npm_package_name", "jsdoc": "jsdoc -c jsdoc.config.js", "predocs": "mkdir -p ./docs", diff --git a/packages/unity-react-core/src/components/ExpandableHeroes/ExpandableHeroes.jsx b/packages/unity-react-core/src/components/ExpandableHeroes/ExpandableHeroes.jsx new file mode 100644 index 0000000000..bc286a7aaf --- /dev/null +++ b/packages/unity-react-core/src/components/ExpandableHeroes/ExpandableHeroes.jsx @@ -0,0 +1,318 @@ +// @ts-check +import PropTypes from "prop-types"; +import React, { useCallback, useRef, useState } from "react"; + +import { Hero } from "../Hero/Hero"; +import { + contentPropType, + imagePropType, +} from "../../core/models/shared-prop-types"; + +/** + * @typedef {import('../../core/types/expandable-heroes-types').ExpandableHeroesProps} ExpandableHeroesProps + */ + +/** @type {PropTypes.Requireable} */ +const panePropType = PropTypes.shape({ + image: imagePropType, + title: contentPropType, + subTitle: contentPropType, + contents: PropTypes.arrayOf(contentPropType), + contentsColor: PropTypes.oneOf(["white", "black"]), +}); + +const tabId = i => `expandable-heroes-tab-${i}`; +const panelId = i => `expandable-heroes-panel-${i}`; + +/** + * Push a GA event directly to window.dataLayer. + * + * Direct push is used instead of for two reasons: + * 1. GaEventWrapper calls trackGAEvent() from @asu/shared, which always + * appends `type: type.toLowerCase()` to the payload — emitting `type: ""` + * which is explicitly forbidden by design-doc §0 Q5 ("OMIT type"). + * 2. The `action` key must be dynamic ("click" vs "keypress") determined at + * event-handler time, not at render time. GaEventWrapper accepts only a + * static `gaData` prop and has no callback/function-prop API to support + * this. Migration would lose the keypress distinction. + * HTML-parity is preserved: data-ga-* attributes are rendered on the button + * elements (see §8 HTML-parity GA) and will be picked up by the + * unity-bootstrap-theme cookie-consent global listener. + * This deviation is flagged for architect re-approval (see impl-report.md). + * + * Exact keys from design-doc §8; no `type` key. + * @param {{event:string, action:string, component:string, region:string, section:string, text:string}} data + */ +const pushGaEvent = data => { + const { dataLayer } = window; + if (dataLayer) { + dataLayer.push({ + event: data.event, + action: data.action, + component: data.component, + region: data.region, + section: data.section, + text: data.text, + }); + } +}; + +/** + * @param {ExpandableHeroesProps} props + * @returns {JSX.Element|null} + */ +const ExpandableHeroes = ({ + panes, + initialActiveIndex = 0, + onPaneChange, + gaRegion = "main content", + gaSection = "hero", +}) => { + // ── Runtime guards ────────────────────────────────────────────────────────── + if (!panes || panes.length !== 3) { + // eslint-disable-next-line no-console + console.error( + "ExpandableHeroes: 'panes' prop must be an array of exactly 3 pane objects." + ); + return null; + } + + const clampedInitial = (() => { + if (initialActiveIndex < 0 || initialActiveIndex > 2) { + // eslint-disable-next-line no-console + console.warn( + `ExpandableHeroes: initialActiveIndex ${initialActiveIndex} is out of range [0,2]; clamping to ${Math.min(Math.max(initialActiveIndex, 0), 2)}.` + ); + return Math.min(Math.max(initialActiveIndex, 0), 2); + } + return initialActiveIndex; + })(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const [activeIndex, setActiveIndex] = useState(clampedInitial); + // eslint-disable-next-line react-hooks/rules-of-hooks + const [previewIndex, setPreviewIndex] = useState( + /** @type {number|null} */ (null) + ); + // focusIndex tracks roving tabindex — on mount equals activeIndex + // eslint-disable-next-line react-hooks/rules-of-hooks + const [focusIndex, setFocusIndex] = useState(clampedInitial); + // eslint-disable-next-line react-hooks/rules-of-hooks + const tabRefs = useRef( + /** @type {(HTMLButtonElement|null)[]} */ ([null, null, null]) + ); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const commit = useCallback( + (index, method) => { + setActiveIndex(index); + setFocusIndex(index); + setPreviewIndex(null); + const pane = panes[index]; + pushGaEvent({ + event: "link", + action: method === "keyboard" ? "keypress" : "click", + component: "expandable-heroes", + region: gaRegion, + section: gaSection, + text: pane.title?.text ?? "", + }); + if (onPaneChange) { + onPaneChange(index, pane); + } + }, + [onPaneChange, panes, gaRegion, gaSection] + ); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const moveFocus = useCallback(newIndex => { + setFocusIndex(newIndex); + setPreviewIndex(newIndex); + tabRefs.current[newIndex]?.focus(); + }, []); + + const handleKeyDown = (e, index) => { + const count = 3; + switch (e.key) { + case "Enter": + case " ": + // preventDefault for both Enter and Space: + // - Space: prevents page scroll (per §6 keyboard map) + // - Enter: suppresses the browser-synthesized click that follows a + // keydown on a + {/* 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 */} + + + {/* Item 1 — COLLAPSED */} +
+ + {/* Decorative in-tablist panel — aria-hidden, sighted layout only */} + + + {/* Item 2 — COLLAPSED */} +
+ + {/* Decorative in-tablist panel — aria-hidden, sighted layout only */} + +
+ + {/* 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 */} +
+
+
+ Hero image one +
+ Subtitle One +
+

+ Pane One Title +

+
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

+
+
+
+ + {/* Panel 1 — INACTIVE */} +