diff --git a/.storybook/main.js b/.storybook/main.js index 9c5ff305b..23719b3ac 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 fab54da91..4b0e95bfa 100644 --- a/src/components/Icon/icon.stories.tsx +++ b/src/components/Icon/icon.stories.tsx @@ -131,9 +131,11 @@ export const IconWithText: Story = { return ( - - - + + + + + {acceptableLevels.map(({ type, text }) => ( diff --git a/src/components/Icon/icon.test.tsx b/src/components/Icon/icon.test.tsx index 40dffa805..8ffa365ee 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 998e071e2..a39883fa3 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 d36ea7233..d850ac5f8 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 000000000..bd31e06b7 --- /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; + }; + } + } +}
Text elementIcon with backgroundIcon without background
Text elementIcon with backgroundIcon without background