Icon
v0.3.0A 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 · depends on @behivetech/get-class-name
create-icon.tsx
1import { type ReactNode, type SVGAttributes } from "react";2import { getClassName } from "@behivetech/get-class-name";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.composition.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];1819const mapEditorNames: IconName[] = [20 "ellipse",21 "rectangle",22 "polygon",23 "cursor",24 "label",25 "camp-border",26 "structure",27 "road",28 "restroom",29];3031export const AllIcons = () => (32 <div style={{ display: "flex", gap: "0.75rem" }}>33 {names.map((name) => (34 <Icon key={name} name={name} />35 ))}36 </div>37);3839export const MapEditorIcons = () => (40 <div style={{ display: "flex", gap: "0.75rem" }}>41 {mapEditorNames.map((name) => (42 <Icon key={name} name={name} />43 ))}44 </div>45);4647/** The size these are actually used at in an icon-only toolbar. */48export const MapEditorIconsAtToolbarSize = () => (49 <div style={{ display: "flex", gap: "0.75rem" }}>50 {mapEditorNames.map((name) => (51 <Icon key={name} name={name} size="sm" />52 ))}53 </div>54);5556export const SmallIcon = () => <Icon name="trash" size="sm" />;57export const LargeIcon = () => <Icon name="trash" size="lg" />;5859/** The tree-shakeable form: import the glyph, not the set. */60export const DirectIconImports = () => (61 <div style={{ display: "flex", gap: "0.75rem" }}>62 <BoxIcon />63 <TrashIcon size="lg" />64 </div>65);
icon.spec.tsx
1import { render } from "@testing-library/react";2import { BoxIcon, CloseIcon, Icon, TrashIcon, type IconName } from "./index.js";3import { iconRegistry } from "./icons/registry.js";45const mapEditorNames: IconName[] = [6 "ellipse",7 "rectangle",8 "polygon",9 "cursor",10 "label",11 "camp-border",12 "structure",13 "road",14 "restroom",15];1617const allNames = Object.keys(iconRegistry) as IconName[];1819describe("Icon", () => {20 it("renders an svg with the correct viewBox", () => {21 const { container } = render(<Icon name="close" />);22 const svg = container.querySelector("svg");23 expect(svg).toBeTruthy();24 expect(svg?.getAttribute("viewBox")).toBe("0 0 24 24");25 });2627 it("applies the size modifier class", () => {28 const { container } = render(<Icon name="close" size="lg" />);29 expect(container.querySelector("svg")?.className.baseVal).toContain(30 "BHT__Icon--lg",31 );32 });3334 it("defaults to the md size", () => {35 const { container } = render(<Icon name="close" />);36 expect(container.querySelector("svg")?.className.baseVal).toContain(37 "BHT__Icon--md",38 );39 });4041 it("applies additional className", () => {42 const { container } = render(<Icon name="close" className="custom" />);43 expect(container.querySelector("svg")?.className.baseVal).toContain(44 "custom",45 );46 });4748 it("renders different markup for different icon names", () => {49 const { container: closeContainer } = render(<Icon name="close" />);50 const { container: trashContainer } = render(<Icon name="trash" />);51 expect(closeContainer.querySelector("svg")?.innerHTML).not.toBe(52 trashContainer.querySelector("svg")?.innerHTML,53 );54 });5556 it("gives every icon in the set distinct geometry", () => {57 const markup = allNames.map((name) => {58 const { container } = render(<Icon name={name} />);59 return container.querySelector("svg")?.innerHTML;60 });61 expect(new Set(markup).size).toBe(allNames.length);62 });63});6465describe("per-icon components", () => {66 it("renders the same markup as the name-keyed API", () => {67 const { container: direct } = render(<BoxIcon />);68 const { container: byName } = render(<Icon name="box" />);69 expect(direct.querySelector("svg")?.innerHTML).toBe(70 byName.querySelector("svg")?.innerHTML,71 );72 });7374 it("accepts size and className like Icon does", () => {75 const { container } = render(<TrashIcon size="sm" className="custom" />);76 const cls = container.querySelector("svg")?.className.baseVal;77 expect(cls).toContain("BHT__Icon--sm");78 expect(cls).toContain("custom");79 });8081 it("carries a displayName for devtools", () => {82 expect(CloseIcon.displayName).toBe("CloseIcon");83 });8485 it("exports a component for every registry entry", () => {86 expect(allNames.length).toBe(22);87 for (const name of allNames) {88 expect(typeof iconRegistry[name]).toBe("function");89 }90 });91});9293describe.each(mapEditorNames)("map editor icon %s", (name) => {94 it.each(["sm", "md", "lg"] as const)("renders at %s", (size) => {95 const { container } = render(<Icon name={name} size={size} />);96 const svg = container.querySelector("svg");97 expect(svg?.className.baseVal).toContain(`BHT__Icon--${size}`);98 // Non-empty geometry: a typo'd path key would still render an <svg>.99 expect(svg?.innerHTML.length).toBeGreaterThan(0);100 });101102 it("inherits currentColor and paints no fill", () => {103 const { container } = render(<Icon name={name} />);104 const svg = container.querySelector("svg");105 expect(svg?.getAttribute("stroke")).toBe("currentColor");106 expect(svg?.getAttribute("fill")).toBe("none");107 });108});