Icon
v0.3.8StableA hand-authored, stroke-based SVG icon set behind a single `Icon` component — the same idea as `@ant-design/icons`/`@mui/icons-material`, but self-contained (no new dependency). Icons use `stroke="currentColor"`, so they inherit color from whatever text color is in scope.
registry/behivetech/atoms/icon
create-icon.tsx
1import { type ReactNode, type SVGAttributes } from "react";2import { getClassName } from "@bht-component-registry/class-names";3import styles from "./icon.module.scss";45export type IconSize = "sm" | "md" | "lg" | "xl";67export interface IconBaseProps extends SVGAttributes<SVGSVGElement> {8 /** Controls width/height; defaults to md */9 size?: IconSize;10 /** Additional class names to merge with the component root element */11 className?: string;12}1314export type IconComponent = (props: IconBaseProps) => ReactNode;1516/**17 * Builds a single icon component from its glyph geometry.18 *19 * This is the only module that imports the stylesheet, so the per-icon modules20 * stay free of side effects and a consumer importing one icon pulls in one21 * glyph rather than the whole set.22 */23export function createIcon(24 displayName: string,25 children: ReactNode,26): IconComponent {27 const Component = ({ size = "md", className, ...rest }: IconBaseProps) => {28 // `rootClass` is "BHT__Icon", not "BHT__CreateIcon": this file is the29 // factory, not a component, and every glyph it builds renders as an Icon.30 // A conventions audit keying off the file name will flag this — it is31 // correct as is.32 const [rootClass] = getClassName({33 className,34 rootClass: "BHT__Icon",35 modifiers: { [size]: true },36 styles,37 });3839 return (40 <svg41 {...rest}42 className={rootClass}43 viewBox="0 0 24 24"44 fill="none"45 stroke="currentColor"46 strokeWidth={2}47 strokeLinecap="round"48 strokeLinejoin="round"49 aria-hidden="true"50 >51 {children}52 </svg>53 );54 };5556 Component.displayName = displayName;57 return Component;58}
icon.tsx
1import { type IconBaseProps } from "./create-icon.js";2import { iconRegistry } from "./icons/registry.js";34export type IconName = keyof typeof iconRegistry;56export interface IconProps extends Omit<IconBaseProps, "name"> {7 /** Which icon to render */8 name: IconName;9}1011/**12 * Renders any icon by name.13 *14 * Convenient when the icon is chosen at runtime — a toolbar built from config,15 * say — but the lookup is dynamic, so a bundler cannot know which glyphs are16 * reachable and this pulls in the entire set. Import the icon you want17 * (`import { BoxIcon } from "@behivetech/atoms.icon"`, or18 * `"@behivetech/atoms.icon/box"`) wherever the name is known at build time.19 */20export const Icon = ({ name, ...rest }: IconProps) => {21 const Glyph = iconRegistry[name];22 return <Glyph {...rest} />;23};
icon.module.scss
1.BHT__Icon {2 display: inline-block;3 flex-shrink: 0;45 &--sm {6 width: 1rem;7 height: 1rem;8 }910 &--md {11 width: 1.25rem;12 height: 1.25rem;13 }1415 &--lg {16 width: 1.5rem;17 height: 1.5rem;18 }1920 &--xl {21 width: 2.5rem;22 height: 2.5rem;23 }24}
index.ts
1// Per-icon components — the tree-shakeable path. Each lives in its own module2// and is built with a /*#__PURE__*/ annotation, so importing one glyph costs3// one glyph.4export * from "./icons/index.js";56export { createIcon } from "./create-icon.js";7export type { IconBaseProps, IconComponent, IconSize } from "./create-icon.js";89// Name-keyed convenience API. Pulls in every glyph; see the note on `Icon`.10export { Icon } from "./icon.js";11export type { IconProps, IconName } from "./icon.js";
icon.example.tsx
1import { BoxIcon, Icon, TrashIcon, type IconName } from "./index.js";23const names: IconName[] = [4 "close",5 "chevron-up",6 "chevron-down",7 "pencil",8 "trash",9 "box",10 "upload",11 "bold",12 "italic",13 "list",14 "link",15 "image",16 "file-text",17 "menu",18 "arrow-left",19 "arrow-right",20 "external-link",21 "cart",22 "printer",23 "check",24 "list-ordered",25 "quote",26 "wifi",27 "key",28 "clock",29 "waves",30 "car",31 "flame",32 "thermometer",33 "info",34];3536const mapEditorNames: IconName[] = [37 "ellipse",38 "rectangle",39 "polygon",40 "cursor",41 "label",42 "camp-border",43 "structure",44 "road",45 "restroom",46];4748export const AllIcons = () => (49 <div style={{ display: "flex", gap: "0.75rem" }}>50 {names.map((name) => (51 <Icon key={name} name={name} />52 ))}53 </div>54);5556export const MapEditorIcons = () => (57 <div style={{ display: "flex", gap: "0.75rem" }}>58 {mapEditorNames.map((name) => (59 <Icon key={name} name={name} />60 ))}61 </div>62);6364/** The size these are actually used at in an icon-only toolbar. */65export const MapEditorIconsAtToolbarSize = () => (66 <div style={{ display: "flex", gap: "0.75rem" }}>67 {mapEditorNames.map((name) => (68 <Icon key={name} name={name} size="sm" />69 ))}70 </div>71);7273export const SmallIcon = () => <Icon name="trash" size="sm" />;74export const LargeIcon = () => <Icon name="trash" size="lg" />;7576/** The tree-shakeable form: import the glyph, not the set. */77export const DirectIconImports = () => (78 <div style={{ display: "flex", gap: "0.75rem" }}>79 <BoxIcon />80 <TrashIcon size="lg" />81 </div>82);
icon.spec.tsx
1import { render } from "@testing-library/react";2import {3 ArrowLeftIcon,4 ArrowRightIcon,5 BoxIcon,6 CartIcon,7 CheckIcon,8 CarIcon,9 CloseIcon,10 ClockIcon,11 ExternalLinkIcon,12 FlameIcon,13 InfoIcon,14 KeyIcon,15 ThermometerIcon,16 WavesIcon,17 WifiIcon,18 Icon,19 ListOrderedIcon,20 MenuIcon,21 PrinterIcon,22 QuoteIcon,23 TrashIcon,24 type IconName,25} from "./index.js";26import { iconRegistry } from "./icons/registry.js";2728const mapEditorNames: IconName[] = [29 "ellipse",30 "rectangle",31 "polygon",32 "cursor",33 "label",34 "camp-border",35 "structure",36 "road",37 "restroom",38];3940const generalNames: IconName[] = [41 "menu",42 "arrow-left",43 "arrow-right",44 "external-link",45 "cart",46 "printer",47 "check",48 "list-ordered",49 "quote",50 "wifi",51 "key",52 "clock",53 "waves",54 "car",55 "flame",56 "thermometer",57 "info",58];5960const newIcons = [61 { name: "menu", Component: MenuIcon, displayName: "MenuIcon" },62 {63 name: "arrow-left",64 Component: ArrowLeftIcon,65 displayName: "ArrowLeftIcon",66 },67 {68 name: "arrow-right",69 Component: ArrowRightIcon,70 displayName: "ArrowRightIcon",71 },72 {73 name: "external-link",74 Component: ExternalLinkIcon,75 displayName: "ExternalLinkIcon",76 },77 { name: "cart", Component: CartIcon, displayName: "CartIcon" },78 { name: "printer", Component: PrinterIcon, displayName: "PrinterIcon" },79 { name: "check", Component: CheckIcon, displayName: "CheckIcon" },80 {81 name: "list-ordered",82 Component: ListOrderedIcon,83 displayName: "ListOrderedIcon",84 },85 { name: "quote", Component: QuoteIcon, displayName: "QuoteIcon" },86 { name: "wifi", Component: WifiIcon, displayName: "WifiIcon" },87 { name: "key", Component: KeyIcon, displayName: "KeyIcon" },88 { name: "clock", Component: ClockIcon, displayName: "ClockIcon" },89 { name: "waves", Component: WavesIcon, displayName: "WavesIcon" },90 { name: "car", Component: CarIcon, displayName: "CarIcon" },91 { name: "flame", Component: FlameIcon, displayName: "FlameIcon" },92 {93 name: "thermometer",94 Component: ThermometerIcon,95 displayName: "ThermometerIcon",96 },97 { name: "info", Component: InfoIcon, displayName: "InfoIcon" },98] as const;99100const allNames = Object.keys(iconRegistry) as IconName[];101102describe("Icon", () => {103 it("renders an svg with the correct viewBox", () => {104 const { container } = render(<Icon name="close" />);105 const svg = container.querySelector("svg");106 expect(svg).toBeTruthy();107 expect(svg?.getAttribute("viewBox")).toBe("0 0 24 24");108 });109110 it("applies the size modifier class", () => {111 const { container } = render(<Icon name="close" size="lg" />);112 expect(container.querySelector("svg")?.className.baseVal).toContain(113 "BHT__Icon--lg",114 );115 });116117 it("defaults to the md size", () => {118 const { container } = render(<Icon name="close" />);119 expect(container.querySelector("svg")?.className.baseVal).toContain(120 "BHT__Icon--md",121 );122 });123124 it("applies additional className", () => {125 const { container } = render(<Icon name="close" className="custom" />);126 expect(container.querySelector("svg")?.className.baseVal).toContain(127 "custom",128 );129 });130131 it("renders different markup for different icon names", () => {132 const { container: closeContainer } = render(<Icon name="close" />);133 const { container: trashContainer } = render(<Icon name="trash" />);134 expect(closeContainer.querySelector("svg")?.innerHTML).not.toBe(135 trashContainer.querySelector("svg")?.innerHTML,136 );137 });138139 it("gives every icon in the set distinct geometry", () => {140 const markup = allNames.map((name) => {141 const { container } = render(<Icon name={name} />);142 return container.querySelector("svg")?.innerHTML;143 });144 expect(new Set(markup).size).toBe(allNames.length);145 });146});147148describe("per-icon components", () => {149 it("renders the same markup as the name-keyed API", () => {150 const { container: direct } = render(<BoxIcon />);151 const { container: byName } = render(<Icon name="box" />);152 expect(direct.querySelector("svg")?.innerHTML).toBe(153 byName.querySelector("svg")?.innerHTML,154 );155 });156157 it("accepts size and className like Icon does", () => {158 const { container } = render(<TrashIcon size="sm" className="custom" />);159 const cls = container.querySelector("svg")?.className.baseVal;160 expect(cls).toContain("BHT__Icon--sm");161 expect(cls).toContain("custom");162 });163164 it("carries a displayName for devtools", () => {165 expect(CloseIcon.displayName).toBe("CloseIcon");166 });167168 it("exports a component for every registry entry", () => {169 expect(allNames.length).toBe(39);170 for (const name of allNames) {171 expect(typeof iconRegistry[name]).toBe("function");172 }173 });174});175176describe.each([...mapEditorNames, ...generalNames])("icon %s", (name) => {177 it.each(["sm", "md", "lg", "xl"] as const)("renders at %s", (size) => {178 const { container } = render(<Icon name={name} size={size} />);179 const svg = container.querySelector("svg");180 // CSS modules hash each class in tests (`_BHT__Icon_57d73a`), so match181 // whole tokens: the root must be present on its own, not only inside182 // the `--size` modifier.183 const tokens = [...(svg?.classList ?? [])];184 expect(tokens.some((c) => /^_?BHT__Icon(_\w+)?$/.test(c))).toBe(true);185 expect(186 tokens.some((c) => new RegExp(`^_?BHT__Icon--${size}(_\\w+)?$`).test(c)),187 ).toBe(true);188 // Non-empty geometry: a typo'd path key would still render an <svg>.189 expect(svg?.innerHTML.length).toBeGreaterThan(0);190 });191192 it("inherits currentColor and paints no fill", () => {193 const { container } = render(<Icon name={name} />);194 const svg = container.querySelector("svg");195 expect(svg?.getAttribute("stroke")).toBe("currentColor");196 expect(svg?.getAttribute("fill")).toBe("none");197 });198});199200describe.each(newIcons)(201 "named export $displayName",202 ({ name, Component, displayName }) => {203 it("renders the same markup as the name-keyed API", () => {204 const { container: direct } = render(<Component />);205 const { container: byName } = render(<Icon name={name} />);206 expect(direct.innerHTML).toBe(byName.innerHTML);207 });208209 it("carries its displayName", () => {210 expect(Component.displayName).toBe(displayName);211 });212 },213);