Rich, web-friendly editor UI framework for SolidJS + TypeScript
solid-stage docs icons.md
6.3 kB · 141 lines
Markdown
at main

Icons #

The library ships a small, curated set of Phosphor icons as a tree-shakeable subpath export. Library chrome (disclosure carets, close ×, the modified-tab dot, scroll/step arrows) draws from this set, and you can import any of them for your own buttons.

import { Copy, Lock, Trash } from '@dolphinspired/solid-stage/icons';

<IconButton icon={Copy} title="Copy" onClick={copy} />

Each icon is a Component<{ size?; class? }> carrying a stage-icon stage-icon-<name> class, so it sizes off --stage-icon-size and inherits currentColor. Phosphor glyphs are filled vector shapes (not strokes), so they stay crisp at small chrome sizes. Unused icons drop via tree-shaking.

The IconComponent contract #

Anywhere the library takes an icon — IconButton, IconToggle, Section's headerActions, IconButtonField, etc. — the prop type is the empty-props component shape:

type IconComponent = Component; // = (props: {}) => JSX.Element

i.e. any component renderable with no required props. The library never passes props to an icon — it's sized and coloured entirely in CSS via the .stage-icon class (--stage-icon-size / --stage-color-icon). The contract is deliberately this loose so every common icon shape satisfies it:

  1. A bundled icon — import { Copy } from '@dolphinspired/solid-stage/icons' (no extra dependency; already carries .stage-icon).
  2. An Iconify pack via unplugin-icons — components typed Component<SvgSVGAttributes> (see the recipe below).
  3. Your own icon component — lucide-solid, a hand-rolled SVG, anything.
import { Heart } from 'lucide-solid';
<IconButton icon={Heart} title="Favourite" onClick={fav} />

An earlier contract was Component<{ size? }>, which rejected SVG-attribute icon components (Iconify/unplugin) — their props share no keys with { size? }. Sizing lives in CSS regardless, so the contract dropped the size prop.

"Any icon by name" — Iconify via unplugin-icons #

For the "I just want any Phosphor icon, by name, without hand-maintaining an adapter" case, wire unplugin-icons into your app's build. Tree-shaken SVG (only the icons you import ship), any Iconify pack, themed to match the bundled set. The library ships a pre-configured preset, stageIcons() (the @dolphinspired/solid-stage/vite entry), so you don't hardcode the library's .stage-icon class in your build config:

// vite.config.ts
import { stageIcons } from '@dolphinspired/solid-stage/vite';

plugins: [solid(), stageIcons()],   // = Solid compiler + every icon tagged .stage-icon
// tsconfig.json — type the virtual modules
{ "compilerOptions": { "types": ["unplugin-icons/types/solid"] } }
pnpm add -D unplugin-icons @iconify-json/ph   # the preset's peer + your pack(s)
// then anywhere — weight is a name suffix: -bold / -fill / -duotone / -thin / -light
import HorseBold from '~icons/ph/horse-bold';
<IconButton icon={HorseBold} title="Horse" onClick={…} />   // matches IconComponent

stageIcons(options) forwards any unplugin-icons option (yours override the compiler / defaultClass defaults). Prefer not to take the preset? It's exactly Icons({ compiler: 'solid', defaultClass: 'stage-icon' }) from unplugin-icons/vite — use that directly and skip the peer dependency.

Domain glyphs (a model picker's smileys, media-transport) belong here — see below.

Every icon slot uses this same IconComponent contract — IconButton, IconToggle, Section's headerActions, IconButtonField, ActionButton's icon, ButtonGroupOption's icon, SelectFieldAction's icon. Always pass the component itself (icon={Copy}), not a thunk. (A () => <Copy /> thunk still type-checks — a zero-arg function satisfies the contract — but the component form is the convention.)

Bundled set #

The current set (import names are PascalCase):

Chrome glyphs — Info, Plus, X, CaretUp, CaretDown, CaretLeft, CaretRight, CaretDoubleLeft, CaretDoubleRight, CircleFill, DotsSixVertical, ArrowsIn, ArrowsInSimple.

Common editor icons — Copy, Lock, Stack, FloppyDisk, ArrowCounterClockwise, Trash, Eye, EyeSlash, GridNine, Crosshair, TextT, Hash.

InfoIcon (the field info bubble glyph) is also exported from the main entry.

Styling #

Set the icon size globally via --stage-icon-size (it derives from the size tier: 12 / 16 / 20px at small / normal / large). Target a specific glyph with its class:

.stage-icon-trash { color: var(--stage-color-error); }

Adding to the set (maintainers) #

The set is generated from the @phosphor-icons/core dev dependency into src/icons/generated/ (committed) by scripts/gen-icons.mjs, driven by the scripts/icons.json manifest. Add a name to the manifest and regenerate. The artwork is vendored under Phosphor's MIT licence (retained in NOTICE).

What belongs in the bundled set #

The bundled set is the chrome's own vocabulary — a glyph earns a place only when a solid-stage component renders it (disclosure carets, the drag grip, the arrows-in-simple redock action, close ×, the modified-tab dot, scroll/step arrows). Bundling those guarantees the chrome looks right with no extra dependency, at a consistent weight, tree-shaken.

Domain icons are not bundled — media-transport (play / pause / skip), app-specific marks (a model picker's smileys), or any glyph used only by the consumer's own UI. There's no principled stopping point once we add icons for one consumer's feature (record → eject → upload → folder …), and the result would be a hand-maintained Phosphor subset strictly worse than the consumer importing lucide-solid / @phosphor-icons/web directly. The IconComponent contract already covers this: any Component<{ size? }> works, so consumers bring their own domain glyphs with zero maintenance cost to the library. If a glyph later becomes one a library component renders, it earns its place then — not before.