diff --git a/src/__testing__/createIcon.test.tsx b/src/__testing__/createIcon.test.tsx new file mode 100644 index 000000000..8cfbad91a --- /dev/null +++ b/src/__testing__/createIcon.test.tsx @@ -0,0 +1,149 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import { createIcon } from '../icons/createIcon'; +import { SistentThemeProviderWithoutBaseLine } from '../theme'; + +function renderWithTheme(ui: React.ReactElement) { + return render( + {ui} + ); +} + +describe('createIcon', () => { + it('creates an icon with d string path definition', () => { + const TestIcon = createIcon({ + displayName: 'TestPathIcon', + d: 'M10 10 H 90 V 90 H 10 Z' + }); + + expect(TestIcon.displayName).toBe('TestPathIcon'); + + const { container } = renderWithTheme(); + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + expect(svg?.getAttribute('viewBox')).toBe('0 0 24 24'); + expect(svg?.getAttribute('width')).toBe('24'); + expect(svg?.getAttribute('height')).toBe('24'); + expect(svg?.getAttribute('fill')).toBe('currentColor'); + + const path = container.querySelector('path'); + expect(path).not.toBeNull(); + expect(path?.getAttribute('d')).toBe('M10 10 H 90 V 90 H 10 Z'); + expect(path?.getAttribute('fill')).toBe('currentColor'); + }); + + it('creates an icon with path element (ReactNode)', () => { + const CustomIcon = createIcon({ + displayName: 'CustomCircleIcon', + viewBox: '0 0 50 50', + path: + }); + + const { container } = renderWithTheme(); + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + expect(svg?.getAttribute('viewBox')).toBe('0 0 50 50'); + + const circle = container.querySelector('circle'); + expect(circle).not.toBeNull(); + expect(circle?.getAttribute('cx')).toBe('25'); + expect(circle?.getAttribute('cy')).toBe('25'); + }); + + it('supports path as a function receiving props', () => { + const DynamicIcon = createIcon({ + displayName: 'DynamicIcon', + path: (props) => ( + + ) + }); + + const { container } = renderWithTheme(); + const rect = container.querySelector('rect'); + expect(rect).not.toBeNull(); + expect(rect?.getAttribute('fill')).toBe('red'); + }); + + it('forwards ref to the underlying svg element', () => { + const RefIcon = createIcon({ + displayName: 'RefIcon', + d: 'M0 0h24v24H0z' + }); + + const ref = React.createRef(); + renderWithTheme(); + + expect(ref.current).toBeInstanceOf(SVGSVGElement); + expect(ref.current?.tagName.toLowerCase()).toBe('svg'); + }); + + it('allows overriding props (width, height, fill, className, style, data-testid)', () => { + const OverridableIcon = createIcon({ + displayName: 'OverridableIcon', + d: 'M0 0h24v24H0z' + }); + + const { container } = renderWithTheme( + + ); + + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + expect(svg?.getAttribute('width')).toBe('48'); + expect(svg?.getAttribute('height')).toBe('3rem'); + expect(svg?.getAttribute('fill')).toBe('#ff00ff'); + expect(svg?.getAttribute('class')).toContain('custom-icon-class'); + expect(svg?.getAttribute('data-testid')).toBe('custom-svg-id'); + expect(svg?.style.opacity).toBe('0.8'); + + const path = container.querySelector('path'); + expect(path).not.toBeNull(); + expect(path?.getAttribute('fill')).toBe('#ff00ff'); + }); + + it('applies defaultProps when provided', () => { + const DefaultedIcon = createIcon({ + displayName: 'DefaultedIcon', + d: 'M0 0h24v24H0z', + defaultProps: { + fill: '#00B39F', + width: 32, + height: 32, + 'data-testid': 'default-test-id' + } + }); + + const { container } = renderWithTheme(); + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + expect(svg?.getAttribute('width')).toBe('32'); + expect(svg?.getAttribute('height')).toBe('32'); + expect(svg?.getAttribute('fill')).toBe('#00B39F'); + expect(svg?.getAttribute('data-testid')).toBe('default-test-id'); + }); + + it('renders element when title prop is provided', () => { + const TitledIcon = createIcon({ + displayName: 'TitledIcon', + d: 'M0 0h24v24H0z' + }); + + const { container } = renderWithTheme(<TitledIcon title="Accessible Title" />); + const title = container.querySelector('title'); + expect(title).not.toBeNull(); + expect(title?.textContent).toBe('Accessible Title'); + }); +}); diff --git a/src/icons/AccessTime/AccessTimeIcon.tsx b/src/icons/AccessTime/AccessTimeIcon.tsx index 82ce8860f..bbceb3a7d 100644 --- a/src/icons/AccessTime/AccessTimeIcon.tsx +++ b/src/icons/AccessTime/AccessTimeIcon.tsx @@ -1,26 +1,8 @@ -import { DEFAULT_FILL_NONE, DEFAULT_HEIGHT, DEFAULT_WIDTH } from '../../constants/constants'; -import { IconProps } from '../types'; +import { createIcon } from '../createIcon'; -export const AccessTimeIcon = ({ - width = DEFAULT_WIDTH, - height = DEFAULT_HEIGHT, - fill = DEFAULT_FILL_NONE, - ...props -}: IconProps): JSX.Element => { - return ( - <svg - width={width} - height={height} - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - {...props} - > - <path - d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2M12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8m.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" - fill={fill} - /> - </svg> - ); -}; +export const AccessTimeIcon = createIcon({ + displayName: 'AccessTimeIcon', + d: 'M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2M12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8m.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z' +}); export default AccessTimeIcon; diff --git a/src/icons/Add/AddIcon.tsx b/src/icons/Add/AddIcon.tsx index 563551257..de85f276b 100644 --- a/src/icons/Add/AddIcon.tsx +++ b/src/icons/Add/AddIcon.tsx @@ -1,24 +1,11 @@ -import { DEFAULT_FILL_NONE, DEFAULT_HEIGHT, DEFAULT_WIDTH } from '../../constants/constants'; -import { IconProps } from '../types'; +import { createIcon } from '../createIcon'; -export const AddIcon = ({ - width = DEFAULT_WIDTH, - height = DEFAULT_HEIGHT, - fill = DEFAULT_FILL_NONE, - ...props -}: IconProps): JSX.Element => { - return ( - <svg - width={width} - height={height} - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - data-testid="add-icon-svg" - {...props} - > - <path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" fill={fill} /> - </svg> - ); -}; +export const AddIcon = createIcon({ + displayName: 'AddIcon', + d: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z', + defaultProps: { + 'data-testid': 'add-icon-svg' + } +}); export default AddIcon; diff --git a/src/icons/createIcon/createIcon.tsx b/src/icons/createIcon/createIcon.tsx new file mode 100644 index 000000000..e1cc9b55b --- /dev/null +++ b/src/icons/createIcon/createIcon.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { DEFAULT_FILL_NONE, DEFAULT_HEIGHT, DEFAULT_WIDTH } from '../../constants/constants'; +import { IconProps } from '../types'; + +export interface CreateIconOptions { + /** + * The icon SVG viewBox. + * @default '0 0 24 24' + */ + viewBox?: string; + + /** + * The SVG path or elements to render inside the SVG. + */ + path?: React.ReactNode | ((props: IconProps) => React.ReactNode); + + /** + * Shorthand SVG path `d` attribute when providing a single path string. + */ + d?: string; + + /** + * The display name of the icon component (for React DevTools and testing). + */ + displayName?: string; + + /** + * Default props to override standard defaults (e.g. custom default fill or dimensions). + */ + defaultProps?: Partial<IconProps> & Record<string, unknown>; +} + +export function createIcon(options: CreateIconOptions): React.ForwardRefExoticComponent< + IconProps & React.RefAttributes<SVGSVGElement> +> { + const { + viewBox = '0 0 24 24', + d: pathDefinition, + path: pathElement, + displayName, + defaultProps = {} + } = options; + + const Comp = React.forwardRef<SVGSVGElement, IconProps>((props, ref) => { + const mergedProps = { ...defaultProps, ...props }; + const { + width = DEFAULT_WIDTH, + height = DEFAULT_HEIGHT, + fill = DEFAULT_FILL_NONE, + title, + ...rest + } = mergedProps; + + let content: React.ReactNode = null; + if (typeof pathElement === 'function') { + content = pathElement(mergedProps); + } else if (pathElement) { + content = pathElement; + } else if (pathDefinition) { + content = <path d={pathDefinition} fill={fill} />; + } + + return ( + <svg + {...rest} + ref={ref} + width={width} + height={height} + fill={fill} + xmlns="http://www.w3.org/2000/svg" + viewBox={viewBox} + > + {title && <title>{title}} + {content} + + ); + }); + + Comp.displayName = displayName || 'Icon'; + + return Comp; +} + +export default createIcon; diff --git a/src/icons/createIcon/index.ts b/src/icons/createIcon/index.ts new file mode 100644 index 000000000..cdb278409 --- /dev/null +++ b/src/icons/createIcon/index.ts @@ -0,0 +1 @@ +export { createIcon, default, type CreateIconOptions } from './createIcon'; diff --git a/src/icons/index.ts b/src/icons/index.ts index 8299cb93e..ee59fd13c 100644 --- a/src/icons/index.ts +++ b/src/icons/index.ts @@ -51,6 +51,7 @@ export * from './ContentClassIcons'; export * from './ContentFilter'; export * from './Copy'; export * from './CreateNew'; +export * from './createIcon'; export * from './Credential'; export * from './CrossCircle'; export * from './Dashboard'; diff --git a/src/index.tsx b/src/index.tsx index 33a25a51f..22b5132df 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -164,3 +164,9 @@ export { type Team as TeamPickerRecord, type TeamSearchFieldProps } from './custom/DashboardWidgets/GettingStartedWidget/TeamSearchField'; + +export { + createIcon, + type CreateIconOptions +} from './icons/createIcon'; +