Behive Tech Registry

Icon

v0.3.0

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 · 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";
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.composition.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];
18
19const mapEditorNames: IconName[] = [
20 "ellipse",
21 "rectangle",
22 "polygon",
23 "cursor",
24 "label",
25 "camp-border",
26 "structure",
27 "road",
28 "restroom",
29];
30
31export const AllIcons = () => (
32 <div style={{ display: "flex", gap: "0.75rem" }}>
33 {names.map((name) => (
34 <Icon key={name} name={name} />
35 ))}
36 </div>
37);
38
39export const MapEditorIcons = () => (
40 <div style={{ display: "flex", gap: "0.75rem" }}>
41 {mapEditorNames.map((name) => (
42 <Icon key={name} name={name} />
43 ))}
44 </div>
45);
46
47/** 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);
55
56export const SmallIcon = () => <Icon name="trash" size="sm" />;
57export const LargeIcon = () => <Icon name="trash" size="lg" />;
58
59/** 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";
4
5const mapEditorNames: IconName[] = [
6 "ellipse",
7 "rectangle",
8 "polygon",
9 "cursor",
10 "label",
11 "camp-border",
12 "structure",
13 "road",
14 "restroom",
15];
16
17const allNames = Object.keys(iconRegistry) as IconName[];
18
19describe("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 });
26
27 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 });
33
34 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 });
40
41 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 });
47
48 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 });
55
56 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});
64
65describe("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 });
73
74 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 });
80
81 it("carries a displayName for devtools", () => {
82 expect(CloseIcon.displayName).toBe("CloseIcon");
83 });
84
85 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});
92
93describe.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 });
101
102 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});