Skip to content
Draft
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
14 changes: 13 additions & 1 deletion .storybook/main.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import path from 'node:path';
import turbosnap from 'vite-plugin-turbosnap';

const __dirname = import.meta.dirname;

export default {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
staticDirs: ['../src/assets/'],
staticDirs: [
'../src/assets/',
{
from: path.resolve(
__dirname,
'../node_modules/@cfpb/cfpb-design-system/src/components/cfpb-icons/icons',
),
to: '/icons',
},
],

addons: [
'@storybook/addon-links',
Expand Down
8 changes: 5 additions & 3 deletions src/components/Icon/icon.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,11 @@ export const IconWithText: Story = {
return (
<table>
<thead>
<th>Text element</th>
<th>Icon with background</th>
<th>Icon without background</th>
<tr>
<th>Text element</th>
<th>Icon with background</th>
<th>Icon without background</th>
</tr>
</thead>
<tbody>
{acceptableLevels.map(({ type, text }) => (
Expand Down
83 changes: 53 additions & 30 deletions src/components/Icon/icon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,64 +3,87 @@ import { render, screen } from '@testing-library/react';
import { Icon } from './icon';

describe('Icon', () => {
it('Renders a plain icon', async () => {
it('Renders a plain icon', () => {
const name = 'error';

render(<Icon name={name} withBg />);
const { container } = render(<Icon name={name} />);

// Need to wait for icon to load - include hidden elements
const icon = await screen.findByRole('img', { hidden: true });
expect(icon.getAttribute('class')).toMatch(`cf-icon-svg--${name}`);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const icon = container.querySelector('cfpb-icon');
expect(icon).toHaveProperty('name', name);
expect(icon).toHaveClass(`cf-icon-svg--${name}`);
});

it('Renders a round icon', async () => {
it('Renders a round icon', () => {
const name = 'error';

render(<Icon name={name} withBg />);
const { container } = render(<Icon name={name} withBg />);

// Need to wait for icon to load - include hidden elements
const icon = await screen.findByRole('img', { hidden: true });
expect(icon.getAttribute('class')).toMatch(`cf-icon-svg--${name}`);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const icon = container.querySelector('cfpb-icon');
expect(icon).toHaveProperty('name', `${name}-round`);
expect(icon).toHaveClass(`cf-icon-svg--${name}-round`);
});

it('Renders a square icon', async () => {
it('Renders a square icon', () => {
const name = 'youtube';

render(<Icon name={name} withBg />);
const { container } = render(<Icon name={name} withBg />);

// Need to wait for icon to load - include hidden elements
const icon = await screen.findByRole('img', { hidden: true });
expect(icon.getAttribute('class')).toMatch(`cf-icon-svg--${name}`);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const icon = container.querySelector('cfpb-icon');
expect(icon).toHaveProperty('name', `${name}-square`);
expect(icon).toHaveClass(`cf-icon-svg--${name}-square`);
});

it('Renders an open number icon', async () => {
it('Renders an open number icon', () => {
const name = 'four';

render(<Icon name={name} />);
const { container } = render(<Icon name={name} />);

// Need to wait for icon to load - include hidden elements
const icon = await screen.findByRole('img', { hidden: true });
expect(icon.getAttribute('class')).toMatch(`cf-icon-svg--${name}-open`);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const icon = container.querySelector('cfpb-icon');
expect(icon).toHaveProperty('name', `${name}-open`);
expect(icon).toHaveClass(`cf-icon-svg--${name}-open`);
});

it('Renders a closed number icon', async () => {
it('Renders a closed number icon', () => {
const name = 'four';

render(<Icon name={name} withBg />);
const { container } = render(<Icon name={name} withBg />);

// Need to wait for icon to load - include hidden elements
const icon = await screen.findByRole('img', { hidden: true });
expect(icon.getAttribute('class')).toMatch(`cf-icon-svg--${name}-closed`);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const icon = container.querySelector('cfpb-icon');
expect(icon).toHaveProperty('name', `${name}-closed`);
expect(icon).toHaveClass(`cf-icon-svg--${name}-closed`);
});

it('Returns error icon when icon is unknown', async () => {
it('Renders the requested icon name when icon is unknown', () => {
const name = 'unknown';

render(<Icon name={name} />);
const { container } = render(<Icon name={name} />);

const notFound = await screen.findByRole('img', { hidden: true });
expect(notFound.getAttribute('class')).toMatch(
'cf-icon-svg cf-icon-svg--unknown',
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const icon = container.querySelector('cfpb-icon');
expect(icon).toHaveProperty('name', name);
expect(icon).toHaveClass('cf-icon-svg--unknown');
});

it('Applies accessibility attributes to the cfpb-icon host', () => {
render(<Icon name='approved' alt='Approved' />);

expect(screen.getByRole('img', { name: 'Approved' })).toHaveProperty(
'name',
'approved',
);
});

it('Can be presentational', () => {
const { container } = render(<Icon name='approved' isPresentational />);

// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const icon = container.querySelector('cfpb-icon');
expect(icon).toHaveAttribute('aria-hidden', 'true');
expect(icon).not.toHaveAttribute('role');
});
});
28 changes: 19 additions & 9 deletions src/components/Icon/icon.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { CfpbIcon } from '@cfpb/cfpb-design-system';
import classNames from 'classnames';
import type { SVGProps } from 'react';
import { useIconSvg } from '../../hooks/use-icon-svg';
import type { CSSProperties, HTMLAttributes } from 'react';
import type { JSXElement } from '../../types/jsx-element';
import { numberIcons } from './icon-lists';

CfpbIcon.init();

// Design System font sizes for HTML elements
const sizeMap: Record<string, string> = {
h1: '34px',
Expand Down Expand Up @@ -51,7 +53,8 @@ const getShapeModifier = (name: string, withBg: boolean): string => {
return '-round';
};

interface IconProperties extends Omit<SVGProps<SVGSVGElement>, 'name'> {
interface IconProperties
extends Omit<HTMLAttributes<HTMLElement>, 'color' | 'size'> {
name: string;
alt?: string;
ariaLabel?: string;
Expand All @@ -60,6 +63,8 @@ interface IconProperties extends Omit<SVGProps<SVGSVGElement>, 'name'> {
isPresentational?: boolean;
withBg?: boolean;
size?: string;
color?: string;
spin?: boolean;
}

/**
Expand All @@ -86,21 +91,26 @@ export const Icon = ({
isPresentational = false,
withBg = false,
size = 'inherit',
className,
style,
...others
}: IconProperties): JSXElement => {
const shapeModifier = getShapeModifier(name, withBg);
const fileName = `${name}${shapeModifier}`;
const IconComponent = useIconSvg(fileName);

if (!IconComponent) return null;

const classes = classNames('cf-icon-svg', `cf-icon-svg--${fileName}`);
const classes = classNames(
'cf-icon-svg',
`cf-icon-svg--${fileName}`,
className,
);
const fontSize = sizeMap[size] || size;
const iconStyle: CSSProperties = { ...style, fontSize };

return (
<IconComponent
<cfpb-icon
name={fileName}
className={classes}
style={{ fontSize }}
style={iconStyle}
role={isPresentational ? undefined : 'img'}
aria-label={ariaLabel || (isPresentational ? undefined : (alt ?? name))}
aria-labelledby={ariaLabelledby || undefined}
Expand Down
72 changes: 0 additions & 72 deletions src/hooks/use-icon-svg.tsx

This file was deleted.

7 changes: 7 additions & 0 deletions src/types/cfpb-design-system.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
declare module '@cfpb/cfpb-design-system' {
export class CfpbIcon extends HTMLElement {
static init(): void;
name: string;
color?: string;
spin?: boolean;
}

export class CfpbTagline extends HTMLElement {
static init(): void;
isLarge: boolean;
Expand Down
22 changes: 22 additions & 0 deletions src/types/custom-elements.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { DetailedHTMLProps, HTMLAttributes } from 'react';

declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'cfpb-icon': DetailedHTMLProps<
HTMLAttributes<HTMLElement>,
HTMLElement
> & {
name?: string;
color?: string;
spin?: boolean;
};
'cfpb-tagline': DetailedHTMLProps<
HTMLAttributes<HTMLElement>,
HTMLElement
> & {
isLarge?: boolean;
};
}
}
}
Loading