From 8efe4da8654f90fa08a02e4db1af0d1b9b504ac7 Mon Sep 17 00:00:00 2001
From: Richard Dinh <1038306+flacoman91@users.noreply.github.com>
Date: Mon, 27 Apr 2026 16:12:49 -0700
Subject: [PATCH] adding icon as WC
---
.storybook/main.js | 14 ++++-
src/components/Icon/icon.stories.tsx | 8 ++-
src/components/Icon/icon.test.tsx | 83 ++++++++++++++++++----------
src/components/Icon/icon.tsx | 28 +++++++---
src/hooks/use-icon-svg.tsx | 72 ------------------------
src/types/cfpb-design-system.d.ts | 7 +++
src/types/custom-elements.d.ts | 22 ++++++++
7 files changed, 119 insertions(+), 115 deletions(-)
delete mode 100644 src/hooks/use-icon-svg.tsx
create mode 100644 src/types/custom-elements.d.ts
diff --git a/.storybook/main.js b/.storybook/main.js
index 9c5ff305b0..23719b3ace 100644
--- a/.storybook/main.js
+++ b/.storybook/main.js
@@ -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',
diff --git a/src/components/Icon/icon.stories.tsx b/src/components/Icon/icon.stories.tsx
index fab54da915..4b0e95bfa2 100644
--- a/src/components/Icon/icon.stories.tsx
+++ b/src/components/Icon/icon.stories.tsx
@@ -131,9 +131,11 @@ export const IconWithText: Story = {
return (
- | Text element |
- Icon with background |
- Icon without background |
+
+ | Text element |
+ Icon with background |
+ Icon without background |
+
{acceptableLevels.map(({ type, text }) => (
diff --git a/src/components/Icon/icon.test.tsx b/src/components/Icon/icon.test.tsx
index 40dffa805b..8ffa365ee1 100644
--- a/src/components/Icon/icon.test.tsx
+++ b/src/components/Icon/icon.test.tsx
@@ -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();
+ const { container } = render();
- // 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();
+ const { container } = render();
- // 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();
+ const { container } = render();
- // 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();
+ const { container } = render();
- // 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();
+ const { container } = render();
- // 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();
+ const { container } = render();
- 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();
+
+ expect(screen.getByRole('img', { name: 'Approved' })).toHaveProperty(
+ 'name',
+ 'approved',
);
});
+
+ it('Can be presentational', () => {
+ const { container } = render();
+
+ // 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');
+ });
});
diff --git a/src/components/Icon/icon.tsx b/src/components/Icon/icon.tsx
index 998e071e20..a39883fa34 100644
--- a/src/components/Icon/icon.tsx
+++ b/src/components/Icon/icon.tsx
@@ -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 = {
h1: '34px',
@@ -51,7 +53,8 @@ const getShapeModifier = (name: string, withBg: boolean): string => {
return '-round';
};
-interface IconProperties extends Omit, 'name'> {
+interface IconProperties
+ extends Omit, 'color' | 'size'> {
name: string;
alt?: string;
ariaLabel?: string;
@@ -60,6 +63,8 @@ interface IconProperties extends Omit, 'name'> {
isPresentational?: boolean;
withBg?: boolean;
size?: string;
+ color?: string;
+ spin?: boolean;
}
/**
@@ -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 (
- >;
-}
-
-/**
- * Dynamically import an SVG as a React Component
- *
- * @param fileName Canonical name of icon
- * @returns ReactComponent | null
- */
-export const useIconSvg = (
- fileName: string,
-): FC> | null => {
- const [iconComponent, setIconComponent] = useState<
- SVGModule['default'] | null
- >(null);
-
- useEffect(() => {
- const isTest =
- typeof process !== 'undefined' && process.env?.NODE_ENV === 'test';
-
- let isMounted = true;
-
- const importSvg = async (): Promise => {
- try {
- const importedIcon = (await import(
- `@cfpb/cfpb-design-system/src/components/cfpb-icons/icons/${fileName}.svg?react`
- )) as SVGModule;
-
- if (isMounted) {
- if (isTest) {
- // React warns that this is deprecated, but importing from 'react' breaks builds
- // because act is not a runtime export. Keep test-only usage here.
- const { act } = await import('react-dom/test-utils');
- act(() => {
- setIconComponent(() => importedIcon.default);
- });
- } else {
- setIconComponent(() => importedIcon.default);
- }
- }
- } catch {
- const errorIcon = (await import(
- `@cfpb/cfpb-design-system/src/components/cfpb-icons/icons/error.svg?react`
- )) as SVGModule;
- if (isMounted) {
- if (isTest) {
- // React warns that this is deprecated, but importing from 'react' breaks builds
- // because act is not a runtime export. Keep test-only usage here.
- const { act } = await import('react-dom/test-utils');
- act(() => {
- setIconComponent(() => errorIcon.default);
- });
- } else {
- setIconComponent(() => errorIcon.default);
- }
- }
- }
- };
-
- void importSvg();
-
- return () => {
- isMounted = false;
- };
- }, [fileName]);
-
- return iconComponent;
-};
diff --git a/src/types/cfpb-design-system.d.ts b/src/types/cfpb-design-system.d.ts
index d36ea7233f..d850ac5f8f 100644
--- a/src/types/cfpb-design-system.d.ts
+++ b/src/types/cfpb-design-system.d.ts
@@ -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;
diff --git a/src/types/custom-elements.d.ts b/src/types/custom-elements.d.ts
new file mode 100644
index 0000000000..bd31e06b7b
--- /dev/null
+++ b/src/types/custom-elements.d.ts
@@ -0,0 +1,22 @@
+import type { DetailedHTMLProps, HTMLAttributes } from 'react';
+
+declare module 'react' {
+ namespace JSX {
+ interface IntrinsicElements {
+ 'cfpb-icon': DetailedHTMLProps<
+ HTMLAttributes,
+ HTMLElement
+ > & {
+ name?: string;
+ color?: string;
+ spin?: boolean;
+ };
+ 'cfpb-tagline': DetailedHTMLProps<
+ HTMLAttributes,
+ HTMLElement
+ > & {
+ isLarge?: boolean;
+ };
+ }
+ }
+}