Skip to content

Add Icon registration API and IconPicker input - #4760

Open
amcclain wants to merge 14 commits into
developfrom
icon-picker
Open

amcclain wants to merge 14 commits into
developfrom
icon-picker

Conversation

@amcclain

@amcclain amcclain commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Apps that need an icon outside Hoist's built-in set must call library.add() and wrap Icon.icon({iconName}) by hand. A request for a weight the app never imported renders blank. This PR adds a supported registration API, a catalog of every known icon, and a desktop input that lets users pick an icon.

Toolbox demo: xh/toolbox#923

Changes

  • Registration: Icon.register() and Icon.registerAll() add FA definitions to the library, install a factory on Icon, and return that factory. A request for an unimported weight falls back to the icon's default variant.
    • A name conflict without replace: true logs a console warning and keeps the existing icon, so a later Hoist built-in never stops an app from starting. Registering the same name and glyph again, for example on hot reload, updates quietly.
    • Registering an existing glyph under a new name adds an alias. The glyph keeps its own label and plain factory.
  • Catalog: Icon.get(), getFactory(), exists(), getCatalogEntry() and getCatalog() resolve icons by name. Hoist's built-ins come from the factories in Icon.ts, so there is no second list to keep in sync. Apps that iterate Object.keys(Icon) must switch to Icon.getCatalog().
  • IconPicker: a desktop input that opens a searchable grid with keyboard navigation. It stores the FA name by default. With valueField: 'name' it stores Icon names instead, so an app can store semantic names such as businessRule and point them at another glyph later.
  • faName: IconProps.faName replaces iconName, which still works with a deprecation warning until v91. Rendered icon elements carry props.faName.
  • Spinner takes icon, either a catalog name or an icon element. Its iconName prop and default are deprecated.
  • Alert banners render through the catalog. The admin editor uses IconPicker over Hoist's built-in set. Stored banner specs do not change.
  • Tests: icon/Icon.spec.ts covers registration, name conflicts, the weight fallback, name lookup, and the catalog.

The CHANGELOG lists the two breaking changes: listing icons by key, and the element prop rename.

Verify

Run Toolbox from its icon-picker branch with pnpm startWithHoist.

  1. Open Forms + Inputs > IconPicker. Try the Value field switch in the playground, and the Semantic names example.
  2. Open Other > Icons to browse the catalog.
  3. Open Admin > General > Alert Banner. Pick an icon and check the preview.

claude and others added 8 commits September 4, 2026 02:08
Reduces the FontAwesome boilerplate apps carry today, and adds a control that lets end users pick
an icon from whatever the app has registered.

Icon registry
* `Icon.register()` / `Icon.registerAll()` take imported FA definitions, add them to the FA
  library, install a factory on `Icon`, and return that factory for direct export - replacing the
  manual `library.add()` + hand-rolled `Icon.icon({iconName})` wrapper pattern.
* Multiple weight variants register as one icon. A request for a weight the app never imported now
  falls back to the icon's default variant rather than rendering blank - the most common trap when
  wiring up a custom icon.
* `replace: true` supports overriding any existing factory, including Hoist's semantic aliases.
  Required, so a name collision throws rather than silently clobbering a built-in.
* `Icon.get()`, `Icon.getFactory()`, `Icon.exists()`, `Icon.getCatalogEntry()` and
  `Icon.getCatalog()` resolve icons by name, for dynamic and persisted icon values.

New `icon/impl/IconRegistry.ts` holds the catalog. Hoist's own icons are cataloged lazily by
calling each factory in `Icon.ts` and reading the icon it renders, so there is no parallel list to
maintain as icons are added there. To support that, the glyph and alias factories move into
exported `iconFactories` / `aliasFactories` maps, spread back onto the `Icon` singleton - no change
to the public API.

IconPicker
* New desktop input rendering a trigger button that opens a searchable grid of icons, with
  keyboard navigation, a hover/highlight name in the footer, and an optional clear action.
* Options come from `Icon.getCatalog()`, so app-registered icons appear with no extra wiring, and
  `keywords` supplied at registration are searchable.
* Value is the icon's FA name - a stable identifier to persist and render back via `Icon.get()`.
Apps now decide what the picker stores rather than taking Hoist's default. `valueField: 'iconName'`
(default) persists the FA name of the glyph - unambiguous, pinned to what the user actually saw.
`valueField: 'name'` persists the `Icon` factory name - more readable in stored data, and follows
the app if it later re-points that factory at a different glyph.

Both forms round-trip through `Icon.get()` and both are accepted as an incoming value regardless
of the setting, so switching does not orphan already-persisted values.

Supporting change: `IconCatalogEntry` gains `name`, the icon's primary `Icon` factory name -
Hoist's own name for a built-in, or the registered name for a custom icon. Where several factories
render the same glyph (`folder` and `tab`, `gridPanel` and `table`, ...), the one whose name
matches the FA name wins, so the picker no longer labelled the folder icon "Tab".

Verified against the full built-in catalog: every entry has a name, all 217 are unique, and both
`name` and `iconName` resolve back to their own entry.
The configurable identifier was a false choice. An icon's `Icon` factory name belongs to the app,
so persisting it couples stored data to a name the app is free to change: rename a registration
from `invoice` to `invoiceIcon` and every value written under the old name is silently dead. That
risk is worst exactly where custom icons live. The FA name comes from FontAwesome and is
unaffected by anything an app does to its own factories, so it is the only identifier worth
offering as a default - and having offered it, there is no case for the alternative.

Apps that do want their own names can still convert on the way out via
`Icon.getCatalogEntry(iconName).name`.

`IconCatalogEntry.name` is retained - it remains the icon's primary factory name, and drives the
display name shown in pickers.
Catches the branch up across the v88 major release (TC39 decorators, MobX 7) and 88.1.0.

- CHANGELOG: kept develop's history and moved the Icon registry / IconPicker entries to the 89.0.0-SNAPSHOT New Features section.
- desktop/cmp/input/index.ts: export both IconPicker and develop's new IntentInput.
- IconPicker: migrated to TC39 decorators via the v88 codemods (`accessor` fields, no makeObservable constructor).
- IconPicker: spread `domAttrs` onto the trigger button, matching Picker and the other inputs since develop added the prop to HoistInputProps.
…icons

- React ignores autoFocus on a div, so with enableFilter: false keyboard nav did nothing until the user clicked or tabbed in. The menu is now focused via onOpened.
- Icons given in the icons prop that resolve to the same glyph (e.g. 'gear' and 'cog') no longer render twice under a duplicate key.
Icon.invoice() does not compile in app code, as Icon is a const object. Point apps at the factory returned by Icon.register() and explain why runtime installation still matters for replacing built-ins.
The Icon singleton now holds lookup and registration methods next to its factories, so iterating Object.keys(Icon) no longer yields only icons. Noted as an 89 breaking change, and the icon README now points listing use cases at Icon.getCatalog().
Applied the clear-writing house style to the icon README, the IconPicker section of the desktop README, and this branch's CHANGELOG entries: no em dashes in prose, sentences under the length cap, active voice where an actor exists, and changelog bullets of three lines or fewer.
… iconName to faName

Registration (from app-perspective review):
- A name conflict without replace: true now logs a warning and keeps the existing icon instead of throwing, so a later Hoist built-in can never stop an app from starting. Names of Icon's own methods still throw.
- Re-registering the same name and glyph (e.g. hot reload) updates quietly.
- Registering an existing glyph under a new name adds an alias. The glyph keeps its name, label, weight, visibility and plain factory, so baked-in props never leak into FA-name lookups or pickers.

IconPicker:
- valueField: 'faName' (default) | 'name'. Name mode offers each listed name as its own option, so semantic aliases such as businessRule can be stored and later re-pointed app-wide.

Naming:
- IconProps.faName replaces iconName, which still works with a deprecation warning until v91. Registration config, IconCatalogEntry and rendered icon elements use faName.
- Internal IconRegistry renamed IconCatalog. Public Icon.getCatalog() unchanged.

Consumers:
- Spinner takes icon: a catalog name or icon element. SpinnerProps.iconName and Spinner.defaults.iconName are deprecated.
- Alert banners render via the catalog and the admin editor uses IconPicker over Hoist's built-in set. Stored specs keep their iconName field unchanged.
@amcclain
amcclain marked this pull request as ready for review October 6, 2026 00:48
Resolved the CHANGELOG conflict by keeping both sides: develop's app option preset and aggregator entries, plus this branch's icon entries, with sections in the documented order.
Comment thread icon/Icon.ts Outdated
Comment thread icon/Icon.ts Outdated
Comment thread icon/Icon.ts Outdated
- Renamed hidden to hideFromPicker on IconRegistrationConfig and IconCatalogEntry. A picker still offers such an icon when it is listed in icons.
- Removed IconPicker.includeHidden - it read as a contradiction next to hideFromPicker, and an explicit icons list covers the case.
- Replaced IconCatalogEntry.isCustom with source: 'hoist' | 'app'. "Custom" suggested a bespoke glyph, not one the app registered.
- Clarified the replace docs: a clash with one of Hoist's own icons or an earlier app registration keeps the existing icon and warns.
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

✅ Unit tests passed - 1,178 tests

1,178 tests · 96 spec files · 32 packages · 145.6s · commit 27fd6d86 · icon-picker
36 known bugs documented

🐞 36 known bugs documented by it.fails tests

Each test asserts the correct behavior and is expected to fail until the bug is fixed.

📊 Full report and all tests · interactive HTML report attached to the run

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants