Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .storybook/preview-head.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@
}
document.head.appendChild(style);
})();
</script>
</script>
75 changes: 63 additions & 12 deletions .storybook/preview.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<string, unknown>} initialValues
* @param {Record<string, unknown>} 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 = '';
Expand All @@ -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();
};

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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',
Expand All @@ -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<string, unknown>, globals: Record<string, unknown> }} 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',
Expand All @@ -193,7 +227,6 @@ const renderResponsivePreviews = (Story, context) => {
key,
style: {
display: 'grid',
// gap: '15px',
justifyItems: 'start',
},
},
Expand All @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/assets/images/cfpb_logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/images/cfpb_logo_es.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 9 additions & 2 deletions src/components/Header/header.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@ import { Header } from '~/src/index';
import { ExampleLinks } from './responsive-menu';

const meta: Meta<typeof Header> = {
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',
},
Expand All @@ -21,6 +27,7 @@ export const Default: Story = {
render: (properties) => <Header {...properties} />,
args: {
links: ExampleLinks,
lang: 'en',
},
};

Expand Down
14 changes: 14 additions & 0 deletions src/components/Header/header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,18 @@ describe('Header', () => {
'o-header bottom-border',
);
});

it('renders the English logo by default', () => {
render(<Header />);
expect(screen.getByAltText('CFPB Logo')).toBeInTheDocument();
});

it('renders the Spanish logo when lang is es', () => {
render(<Header lang='es' />);
expect(
screen.getByAltText(
'Oficina para la Protección Financiera del Consumidor',
),
).toBeInTheDocument();
});
});
21 changes: 17 additions & 4 deletions src/components/Header/header.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className='o-header-scope'>
<header className={classnames(headerClasses)}>
<Banner tagline='An official website of the United States government' />
<ResponsiveMenu links={links} href={href} />
<Banner tagline={taglineText} />
<ResponsiveMenu links={links} href={href} lang={lang} />
</header>
</div>
);
Expand Down
33 changes: 33 additions & 0 deletions src/components/Header/logo.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Logo />);

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(<Logo language='en' />);

expect(screen.getByRole('img', { name: 'CFPB Logo' })).toHaveAttribute(
'src',
CfpbLogoEn,
);
});

it('renders the Spanish logo when language is es', () => {
render(<Logo language='es' />);

const image = screen.getByRole('img', {
name: 'Oficina para la Protección Financiera del Consumidor',
});
expect(image).toHaveAttribute('src', CfpbLogoEs);
});
});
33 changes: 33 additions & 0 deletions src/components/Header/logo.tsx
Original file line number Diff line number Diff line change
@@ -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<LogoLanguage, string> = {
en: CfpbLogoEn,
es: CfpbLogoEs,
};

const logoAltText: Record<LogoLanguage, string> = {
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 (
<img
className={className}
src={logoSources[language]}
alt={logoAltText[language]}
/>
);
}
10 changes: 10 additions & 0 deletions src/components/Header/responsive-menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ResponsiveMenu lang='es' />);

expect(
screen.getByAltText(
'Oficina para la Protección Financiera del Consumidor',
),
).toBeInTheDocument();
});
});
Loading
Loading