Behive Tech Component Registry

Icon

v0.3.8Stable

A 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.

pnpm add @behivetech/atoms.icon

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";
4
5export type IconSize = "sm" | "md" | "lg" | "xl";
6
7export 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}
13
14export type IconComponent = (props: IconBaseProps) => ReactNode;
15
16/**
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 modules
20 * stay free of side effects and a consumer importing one icon pulls in one
21 * 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 the
29 // 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 is
31 // correct as is.
32 const [rootClass] = getClassName({
33 className,
34 rootClass: "BHT__Icon",
35 modifiers: { [size]: true },
36 styles,
37 });
38
39 return (
40 <svg
41 {...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 };
55
56 Component.displayName = displayName;
57 return Component;
58}
icon.tsx
1import { type IconBaseProps } from "./create-icon.js";
2import { iconRegistry } from "./icons/registry.js";
3
4export type IconName = keyof typeof iconRegistry;
5
6export interface IconProps extends Omit<IconBaseProps, "name"> {
7 /** Which icon to render */
8 name: IconName;
9}
10
11/**
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 are
16 * reachable and this pulls in the entire set. Import the icon you want
17 * (`import { BoxIcon } from "@behivetech/atoms.icon"`, or
18 * `"@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;
4
5 &--sm {
6 width: 1rem;
7 height: 1rem;
8 }
9
10 &--md {
11 width: 1.25rem;
12 height: 1.25rem;
13 }
14
15 &--lg {
16 width: 1.5rem;
17 height: 1.5rem;
18 }
19
20 &--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 module
2// and is built with a /*#__PURE__*/ annotation, so importing one glyph costs
3// one glyph.
4export * from "./icons/index.js";
5
6export { createIcon } from "./create-icon.js";
7export type { IconBaseProps, IconComponent, IconSize } from "./create-icon.js";
8
9// 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";
2
3const 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];
35
36const mapEditorNames: IconName[] = [
37 "ellipse",
38 "rectangle",
39 "polygon",
40 "cursor",
41 "label",
42 "camp-border",
43 "structure",
44 "road",
45 "restroom",
46];
47
48export const AllIcons = () => (
49 <div style={{ display: "flex", gap: "0.75rem" }}>
50 {names.map((name) => (
51 <Icon key={name} name={name} />
52 ))}
53 </div>
54);
55
56export const MapEditorIcons = () => (
57 <div style={{ display: "flex", gap: "0.75rem" }}>
58 {mapEditorNames.map((name) => (
59 <Icon key={name} name={name} />
60 ))}
61 </div>
62);
63
64/** 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);
72
73export const SmallIcon = () => <Icon name="trash" size="sm" />;
74export const LargeIcon = () => <Icon name="trash" size="lg" />;
75
76/** 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";
27
28const mapEditorNames: IconName[] = [
29 "ellipse",
30 "rectangle",
31 "polygon",
32 "cursor",
33 "label",
34 "camp-border",
35 "structure",
36 "road",
37 "restroom",
38];
39
40const 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];
59
60const 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;
99
100const allNames = Object.keys(iconRegistry) as IconName[];
101
102describe("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 });
109
110 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 });
116
117 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 });
123
124 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 });
130
131 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 });
138
139 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});
147
148describe("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 });
156
157 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 });
163
164 it("carries a displayName for devtools", () => {
165 expect(CloseIcon.displayName).toBe("CloseIcon");
166 });
167
168 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});
175
176describe.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 match
181 // whole tokens: the root must be present on its own, not only inside
182 // 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 });
191
192 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});
199
200describe.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 });
208
209 it("carries its displayName", () => {
210 expect(Component.displayName).toBe(displayName);
211 });
212 },
213);