The shared builder and stylesheet for every `@behivetech/icons.*` package: `createIcon`, the factory each icon package builds its component with, and the stylesheet that sizes them. It holds no glyphs; each one is its own package, `@behivetech/icons.<name>`, so an app loads only the icons it renders.
registry/behivetech/icons/base
create-icon.tsx
1import { type ReactNode, type Ref, 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 /**13 * Accessible name of the icon, rendered as the svg's `<title>`. When set14 * the icon is exposed as an image with this name; without it (and without15 * `aria-label` or `aria-labelledby`) the icon is decorative and hidden from16 * assistive technology. Pass an already translated string.17 */18 titleAccess?: string;19 /** Ref to the root svg element */20 ref?: Ref<SVGSVGElement>;21}2223export type IconComponent = (props: IconBaseProps) => ReactNode;2425/**26 * Builds an icon component from its glyph geometry, the way MUI's27 * `createSvgIcon` does. Each icon package (`@scope/icons.<name>`) calls this28 * once, and lists this package as a dependency (`workspace:^`), so an app has one29 * factory and one stylesheet however many icons it uses. Mark each call `PURE`30 * (a `#__PURE__` comment) so a bundler can drop an icon nothing uses.31 *32 * @param displayName The component's name in devtools, e.g. `CheckIcon`33 * @param children The svg's geometry: one element or a fragment of them, on a 24 by 24 grid34 * @returns A component taking `IconBaseProps`35 */36export function createIcon(37 displayName: string,38 children: ReactNode,39): IconComponent {40 // React 19 passes `ref` to a function component as an ordinary prop, so41 // there is no `forwardRef` wrapper here; the ref lands on the root svg. Nor is42 // there a `memo`: the glyph is one element built once, and re-rendering an43 // svg of a few nodes costs less than comparing its props.44 const Component = ({45 size = "md",46 className,47 titleAccess,48 ref,49 ...rest50 }: IconBaseProps) => {51 // `rootClass` is "BHT__Icon", not "BHT__CreateIcon": this file is the52 // factory, not a component, and every glyph it builds renders as an Icon.53 // A conventions audit keying off the file name will flag this — it is54 // correct as is.55 const [rootClass] = getClassName({56 className,57 rootClass: "BHT__Icon",58 modifiers: { [size]: true },59 styles,60 });61 const isLabelled = Boolean(62 titleAccess || rest["aria-label"] || rest["aria-labelledby"],63 );6465 // `rest` comes after the defaults so a caller can override any of them66 // (`strokeWidth`, `viewBox`, `aria-hidden`), as with MUI's SvgIcon.67 return (68 <svg69 ref={ref}70 viewBox="0 0 24 24"71 fill="none"72 stroke="currentColor"73 strokeWidth={2}74 strokeLinecap="round"75 strokeLinejoin="round"76 focusable="false"77 aria-hidden={isLabelled ? undefined : true}78 role={isLabelled ? "img" : undefined}79 {...rest}80 className={rootClass}81 >82 {children}83 {titleAccess ? <title>{titleAccess}</title> : null}84 </svg>85 );86 };8788 Component.displayName = displayName;89 return Component;90}
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
1export { createIcon } from "./create-icon.js";2export type { IconBaseProps, IconComponent, IconSize } from "./create-icon.js";
icon.example.tsx
1import { createIcon } from "./create-icon.js";23const SquareIcon = createIcon(4 "SquareIcon",5 <rect x="4" y="4" width="16" height="16" rx="2" />,6);78/** An icon built with `createIcon`, at each size. Real icons come from the `@behivetech/icons.*` packages. */9export const IconSizes = () => (10 <>11 <SquareIcon size="sm" />12 <SquareIcon />13 <SquareIcon size="lg" />14 <SquareIcon size="xl" />15 </>16);1718/** Named with `titleAccess`, so it is announced as an image. */19export const NamedIcon = () => <SquareIcon titleAccess="Square" />;
create-icon.spec.tsx
1import { createRef } from "react";2import { render } from "@testing-library/react";3import { createIcon } from "./create-icon.js";45const TestIcon = createIcon("TestIcon", <path d="M0 0h24v24H0z" />);67const renderSvg = (props: Parameters<typeof TestIcon>[0] = {}) => {8 const { container } = render(<TestIcon {...props} />);9 return container.querySelector("svg") as SVGSVGElement;10};1112describe("createIcon accessibility", () => {13 it("hides a decorative icon from assistive technology", () => {14 const svg = renderSvg();15 expect(svg.getAttribute("aria-hidden")).toBe("true");16 expect(svg.hasAttribute("role")).toBe(false);17 expect(svg.querySelector("title")).toBeNull();18 });1920 it("stays out of the tab order in browsers that make svg focusable", () => {21 expect(renderSvg().getAttribute("focusable")).toBe("false");22 });2324 it("exposes titleAccess as an image named by a <title>", () => {25 const svg = renderSvg({ titleAccess: "Delete" });26 expect(svg.getAttribute("role")).toBe("img");27 expect(svg.hasAttribute("aria-hidden")).toBe(false);28 expect(svg.querySelector("title")?.textContent).toBe("Delete");29 });3031 it("keeps the glyph next to the title", () => {32 const svg = renderSvg({ titleAccess: "Delete" });33 expect(svg.querySelector("path")).not.toBeNull();34 });3536 it("exposes an icon named by aria-label as an image", () => {37 const svg = renderSvg({ "aria-label": "Delete" });38 expect(svg.getAttribute("role")).toBe("img");39 expect(svg.hasAttribute("aria-hidden")).toBe(false);40 expect(svg.getAttribute("aria-label")).toBe("Delete");41 expect(svg.querySelector("title")).toBeNull();42 });4344 it("exposes an icon named by aria-labelledby as an image", () => {45 const svg = renderSvg({ "aria-labelledby": "label-id" });46 expect(svg.getAttribute("role")).toBe("img");47 expect(svg.hasAttribute("aria-hidden")).toBe(false);48 });4950 it("lets a caller override aria-hidden", () => {51 const svg = renderSvg({ "aria-hidden": false });52 expect(svg.getAttribute("aria-hidden")).toBe("false");53 });54});5556describe("createIcon element", () => {57 it("forwards a ref to the root svg", () => {58 const ref = createRef<SVGSVGElement>();59 const { container } = render(<TestIcon ref={ref} />);60 expect(ref.current).toBe(container.querySelector("svg"));61 });6263 it("sets displayName", () => {64 expect(TestIcon.displayName).toBe("TestIcon");65 });6667 it("defaults to a 24 grid and lets a caller change it", () => {68 expect(renderSvg().getAttribute("viewBox")).toBe("0 0 24 24");69 expect(renderSvg({ viewBox: "0 0 16 16" }).getAttribute("viewBox")).toBe(70 "0 0 16 16",71 );72 });7374 it("keeps the BEM root class when a caller passes className", () => {75 const tokens = [...renderSvg({ className: "custom" }).classList];76 expect(tokens).toContain("custom");77 expect(tokens.some((token) => /^_?BHT__Icon(_\w+)?$/.test(token))).toBe(78 true,79 );80 });81});