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 @@ -Consumer Financial Protection Bureau \ 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 @@ +Oficina paralaProtecciónFinancieradelConsumidor \ 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 ( + {logoAltText[language]} + ); +} 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 ( - CFPB Logo + ); } @@ -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'} - +