diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html
index 88b473fa0b..886f719d04 100644
--- a/.storybook/preview-head.html
+++ b/.storybook/preview-head.html
@@ -30,4 +30,4 @@
}
document.head.appendChild(style);
})();
-
\ No newline at end of file
+
diff --git a/.storybook/preview.js b/.storybook/preview.js
index 92f5474c61..2cf84c0fd7 100644
--- a/.storybook/preview.js
+++ b/.storybook/preview.js
@@ -1,4 +1,6 @@
import React from 'react';
+import { buildArgsParam } from 'storybook/internal/router';
+import { useArgs, useGlobals } from 'storybook/preview-api';
import '../src/assets/styles/_shared.scss';
import themeCFPB from './themeCFPB';
@@ -65,7 +67,23 @@ const shouldRenderSinglePreview = (context) => {
);
};
-const getPreviewSource = (storyId, nestedCanvasPaddingMode) => {
+/**
+ * Build `args` / `globals` query strings for nested iframes (same encoding as Storybook manager).
+ *
+ * @param {Record} initialValues
+ * @param {Record} currentValues
+ * @returns {string}
+ */
+const buildNestedQueryParam = (initialValues, currentValues) =>
+ buildArgsParam(initialValues ?? {}, currentValues ?? {});
+
+/**
+ * @param {string} storyId
+ * @param {'focus' | 'flush'} nestedCanvasPaddingMode
+ * @param {{ argsParam: string, globalsParam: string }} queryParams
+ * @returns {string}
+ */
+const getPreviewSource = (storyId, nestedCanvasPaddingMode, queryParams) => {
const url = new URL(globalThis.location.href);
url.search = '';
@@ -77,6 +95,14 @@ const getPreviewSource = (storyId, nestedCanvasPaddingMode) => {
url.searchParams.set(nestedCanvasPaddingQueryParameter, 'flush');
}
+ if (queryParams.argsParam) {
+ url.searchParams.set('args', queryParams.argsParam);
+ }
+
+ if (queryParams.globalsParam) {
+ url.searchParams.set('globals', queryParams.globalsParam);
+ }
+
return url.toString();
};
@@ -118,7 +144,7 @@ const getFrameHeight = (frame) => {
);
};
-const ResponsivePreviewFrame = ({ storyId, viewport, nestedCanvasPaddingMode }) => {
+const ResponsivePreviewFrame = ({ previewSrc, viewport }) => {
const [height, setHeight] = React.useState(64);
const updateHeight = React.useCallback((frame) => {
@@ -152,7 +178,7 @@ const ResponsivePreviewFrame = ({ storyId, viewport, nestedCanvasPaddingMode })
if (storyRoot) observer.observe(storyRoot);
}
},
- src: getPreviewSource(storyId, nestedCanvasPaddingMode),
+ src: previewSrc,
title: `${viewport.name} preview`,
style: {
background: 'white',
@@ -168,12 +194,20 @@ const ResponsivePreviewFrame = ({ storyId, viewport, nestedCanvasPaddingMode })
});
};
-const renderResponsivePreviews = (Story, context) => {
- if (shouldRenderSinglePreview(context)) {
- return React.createElement(Story);
- }
-
- const nestedCanvasPaddingMode = getNestedCanvasPaddingMode(context.parameters);
+/**
+ * All-viewports grid: nested iframes are separate documents; args/globals come from the decorator
+ * (`useArgs` / `useGlobals` must run there, not in this child component).
+ *
+ * @param {{ context: import('storybook/internal/types').StoryContext, nestedCanvasPaddingMode: 'focus' | 'flush', args: Record, globals: Record }} props
+ */
+const AllViewportsPreviews = ({ context, nestedCanvasPaddingMode, args, globals }) => {
+ const argsParam = buildNestedQueryParam(context.initialArgs, args);
+ const globalsParam = buildNestedQueryParam(context.initialGlobals, globals);
+ const previewSrc = getPreviewSource(context.id, nestedCanvasPaddingMode, {
+ argsParam,
+ globalsParam,
+ });
+ const iframeCacheKey = `${argsParam}|${globalsParam}`;
return React.createElement(
'div',
@@ -193,7 +227,6 @@ const renderResponsivePreviews = (Story, context) => {
key,
style: {
display: 'grid',
- // gap: '15px',
justifyItems: 'start',
},
},
@@ -208,15 +241,33 @@ const renderResponsivePreviews = (Story, context) => {
`${viewport.name}`,
),
React.createElement(ResponsivePreviewFrame, {
- storyId: context.id,
+ key: `${key}-${iframeCacheKey}`,
+ previewSrc,
viewport,
- nestedCanvasPaddingMode,
}),
),
),
);
};
+const renderResponsivePreviews = (Story, context) => {
+ const [args] = useArgs();
+ const [globals] = useGlobals();
+
+ if (shouldRenderSinglePreview(context)) {
+ return React.createElement(Story);
+ }
+
+ const nestedCanvasPaddingMode = getNestedCanvasPaddingMode(context.parameters);
+
+ return React.createElement(AllViewportsPreviews, {
+ context,
+ nestedCanvasPaddingMode,
+ args,
+ globals,
+ });
+};
+
/** Storybook body classes applied by `parameters.layout` (see prepareForStory / WebView). */
const STORYBOOK_LAYOUT_BODY_CLASSES = [
'sb-main-padded',
diff --git a/src/assets/images/cfpb_logo.svg b/src/assets/images/cfpb_logo.svg
index bdc9ecccaf..be8764fa6f 100644
--- a/src/assets/images/cfpb_logo.svg
+++ b/src/assets/images/cfpb_logo.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/assets/images/cfpb_logo_es.svg b/src/assets/images/cfpb_logo_es.svg
new file mode 100644
index 0000000000..d77fe15689
--- /dev/null
+++ b/src/assets/images/cfpb_logo_es.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/Header/header.stories.tsx b/src/components/Header/header.stories.tsx
index 231672d23a..717432e302 100644
--- a/src/components/Header/header.stories.tsx
+++ b/src/components/Header/header.stories.tsx
@@ -3,10 +3,16 @@ import { Header } from '~/src/index';
import { ExampleLinks } from './responsive-menu';
const meta: Meta = {
- title: 'Components (Draft)/Header',
+ title: 'Components (Draft)/Headers',
tags: ['autodocs'],
component: Header,
- argTypes: {},
+ argTypes: {
+ lang: {
+ control: 'select',
+ options: ['en', 'es'],
+ description: 'Logo language (English or Spanish)',
+ },
+ },
parameters: {
sbNestedCanvasPadding: 'flush',
},
@@ -21,6 +27,7 @@ export const Default: Story = {
render: (properties) => ,
args: {
links: ExampleLinks,
+ lang: 'en',
},
};
diff --git a/src/components/Header/header.test.tsx b/src/components/Header/header.test.tsx
index e6113bbf49..3685060e85 100644
--- a/src/components/Header/header.test.tsx
+++ b/src/components/Header/header.test.tsx
@@ -26,4 +26,18 @@ describe('Header', () => {
'o-header bottom-border',
);
});
+
+ it('renders the English logo by default', () => {
+ render();
+ expect(screen.getByAltText('CFPB Logo')).toBeInTheDocument();
+ });
+
+ it('renders the Spanish logo when lang is es', () => {
+ render();
+ expect(
+ screen.getByAltText(
+ 'Oficina para la Protección Financiera del Consumidor',
+ ),
+ ).toBeInTheDocument();
+ });
});
diff --git a/src/components/Header/header.tsx b/src/components/Header/header.tsx
index 7830fa4121..eb89b64ca6 100644
--- a/src/components/Header/header.tsx
+++ b/src/components/Header/header.tsx
@@ -1,26 +1,39 @@
import classnames from 'classnames';
import { JSX } from 'react';
import { Banner } from '../Banner/banner';
+import type { LogoLanguage } from './logo';
import ResponsiveMenu from './responsive-menu';
import './header.scss';
export interface HeaderProperties {
links?: JSX.Element[];
href?: string;
+ lang?: LogoLanguage;
}
/**
- * A header helps users identify where they are and provides a quick, organized way to reach the main sections of a website.
+ * The header component is a primary user interface element at the top of a
+ * webpage that helps visitors identify their location and provides organized,
+ * high-level navigation across the site.
*
*/
-export const Header = ({ links, href }: HeaderProperties): JSX.Element => {
+export const Header = ({
+ links,
+ href,
+ lang = 'en',
+}: HeaderProperties): JSX.Element => {
const headerClasses = ['o-header', 'bottom-border'];
+ const taglineText =
+ lang === 'es'
+ ? 'Un sitio web oficial del gobierno federal de los Estados Unidos'
+ : 'An official website of the United States government';
+
return (
);
diff --git a/src/components/Header/logo.test.tsx b/src/components/Header/logo.test.tsx
new file mode 100644
index 0000000000..346a75c77c
--- /dev/null
+++ b/src/components/Header/logo.test.tsx
@@ -0,0 +1,33 @@
+import '@testing-library/jest-dom';
+import { render, screen } from '@testing-library/react';
+import CfpbLogoEn from '../../assets/images/cfpb_logo.svg?url';
+import CfpbLogoEs from '../../assets/images/cfpb_logo_es.svg?url';
+import { Logo } from './logo';
+
+describe('Logo', () => {
+ it('renders the English logo by default', () => {
+ render();
+
+ const image = screen.getByRole('img', { name: 'CFPB Logo' });
+ expect(image).toHaveAttribute('src', CfpbLogoEn);
+ expect(image).toHaveClass('o-header__logo-img');
+ });
+
+ it('renders the English logo when language is en', () => {
+ render();
+
+ expect(screen.getByRole('img', { name: 'CFPB Logo' })).toHaveAttribute(
+ 'src',
+ CfpbLogoEn,
+ );
+ });
+
+ it('renders the Spanish logo when language is es', () => {
+ render();
+
+ const image = screen.getByRole('img', {
+ name: 'Oficina para la Protección Financiera del Consumidor',
+ });
+ expect(image).toHaveAttribute('src', CfpbLogoEs);
+ });
+});
diff --git a/src/components/Header/logo.tsx b/src/components/Header/logo.tsx
new file mode 100644
index 0000000000..97acb852b9
--- /dev/null
+++ b/src/components/Header/logo.tsx
@@ -0,0 +1,33 @@
+import type { JSX } from 'react';
+import CfpbLogoEn from '../../assets/images/cfpb_logo.svg?url';
+import CfpbLogoEs from '../../assets/images/cfpb_logo_es.svg?url';
+
+export type LogoLanguage = 'en' | 'es';
+
+const logoSources: Record = {
+ en: CfpbLogoEn,
+ es: CfpbLogoEs,
+};
+
+const logoAltText: Record = {
+ en: 'CFPB Logo',
+ es: 'Oficina para la Protección Financiera del Consumidor',
+};
+
+export interface LogoProperties {
+ language?: LogoLanguage;
+ className?: string;
+}
+
+export function Logo({
+ language = 'en',
+ className = 'o-header__logo-img',
+}: LogoProperties): JSX.Element {
+ return (
+
+ );
+}
diff --git a/src/components/Header/responsive-menu.test.tsx b/src/components/Header/responsive-menu.test.tsx
index a00cf58326..b7cc639bee 100644
--- a/src/components/Header/responsive-menu.test.tsx
+++ b/src/components/Header/responsive-menu.test.tsx
@@ -112,4 +112,14 @@ describe('ResponsiveMenu', () => {
const logoLink = screen.getByTestId('CfpbLogoLink');
expect(logoLink).toHaveAttribute('href', customHref);
});
+
+ it('renders the Spanish logo when lang is es', () => {
+ renderWithScope();
+
+ expect(
+ screen.getByAltText(
+ 'Oficina para la Protección Financiera del Consumidor',
+ ),
+ ).toBeInTheDocument();
+ });
});
diff --git a/src/components/Header/responsive-menu.tsx b/src/components/Header/responsive-menu.tsx
index 31b24e84ac..fb459bdaf6 100644
--- a/src/components/Header/responsive-menu.tsx
+++ b/src/components/Header/responsive-menu.tsx
@@ -1,18 +1,20 @@
import React, { JSX, useCallback, useState } from 'react';
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react';
-import CFPBLogo from '../../assets/images/cfpb_logo.svg?url';
import { Button } from '../Buttons/button';
import { Icon } from '../Icon/icon';
import Link from '../Link/link';
import type { JSXElement } from '../../types/jsx-element';
+import { Logo, type LogoLanguage } from './logo';
import './responsive-menu.scss';
interface CfpbLogoProperties {
href?: string;
+ language?: LogoLanguage;
}
export function CfpbLogo({
href = 'https://www.consumerfinance.gov',
+ language = 'en',
}: CfpbLogoProperties): JSX.Element {
return (
-
+
);
}
@@ -64,11 +66,13 @@ const Links = ({
interface ResponsiveMenuProperties {
links?: ReactNode[];
href?: string;
+ lang?: LogoLanguage;
}
export default function ResponsiveMenu({
links,
href,
+ lang = 'en',
}: ResponsiveMenuProperties): JSX.Element {
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -94,7 +98,7 @@ export default function ResponsiveMenu({
return (
);
@@ -127,7 +131,7 @@ export default function ResponsiveMenu({
{isMenuOpen ? 'Close menu' : 'Open menu'}
-
+