Editors were typing machine names to choose an icon
The story behind canvas_icon_picker — from a broken editorial step to a contrib module built on core's Icon API, and the enum bug that closed this week.
An editor sent me a screenshot of a card with a hole in it. Where the icon should have been there was nothing — correct spacing, correct alignment, no glyph. The value saved into the prop was arrow-right. The icon in the pack was arrow-right-short. Nothing errored, nothing logged, and the only way to discover which of the eighty-odd valid strings was the right one was to open the theme in a code editor.
That is the whole problem stated once: choosing an icon for a component prop in Drupal Canvas means typing an identifier by hand unless something renders a visual chooser. People type it wrong in exactly the way people always type things wrong from memory. The cost lands on the content team, and it lands quietly — nobody files a ticket for an icon that is merely the wrong icon.
Why the prop got a textfield in the first place
SDC props are JSON Schema, and Canvas builds its prop controls from that schema. A prop declared type: string is a string, so it gets a textfield. That is the correct behaviour. The schema said nothing about icons because there is no way for a plain string to say “I am one of a known, previewable set of things”.
The obvious next move is enum, which gets you a select element. Better than free text, and it is where I started.
The version that did not survive
My first attempt generated the enum. A small build step read the theme’s SVG sprite, extracted every symbol id, and wrote the list into each component’s schema.
props: type: object properties: icon: type: string title: Icon enum: - arrow-right-short - arrow-left-short - calendar-event # ...eighty more, in every component that takes an iconIt worked, in the sense that invalid values became impossible. It failed on everything else.
The icon set now lived in sixty copies. Adding one icon meant regenerating sixty schema files and getting them all through review. Removing one silently invalidated saved content, because an enum constrains new input and says nothing about what is already in the database. And the actual failure mode — the editor picking the wrong icon — did not move at all, because a select list of eighty machine names sorted in sprite order is a worse interface than a textfield with a colleague to ask. The editor still could not see the arrow.
The wrong turn was treating the icon set as component data. It is site data, and it already had a home.
Core already knows what the icons are
Drupal 11.1’s Icon API is the piece that made this worth building rather than working around. Icon packs are declared in *.icons.yml, extractors discover the individual icons, every icon has a stable pack_id:icon_id full identifier, and core ships an icon_autocomplete form element plus an icon() Twig function.
So the source of truth exists, it is discoverable at runtime, and it is not the theme build. The job shrinks to something much smaller: recognise that a given prop means “an icon”, and hand Canvas a picker bound to the Icon API instead of a textfield.
Checking whether anyone else had this
Before writing a line of the module I went through the Canvas queue, the ui_icons work, and the SDC issues, on the assumption that a problem this ordinary was already being discussed. It was, in three or four different vocabularies — people asking about “icon fields in components”, people asking whether prop widgets are pluggable, people asking why their select of icon names had grown unusable.
TODO(fact): link the specific issue-queue threads found during that search.
None of them were quite the same request, which is usually the signal that the shared thing underneath is worth naming. That is the point where a workaround becomes a module.
What had to become configurable
Three assumptions had to come out before a stranger could install it.
How the module knows a prop is an icon. My internal version matched on the prop name icon, which is fine until a component has icon_before and icon_after, or until someone’s design system calls it glyph. Naming conventions are a tax on everyone who names things differently. The module uses an explicit annotation on the prop instead, deliberately boring:
props: type: object properties: icon: type: string title: Icon x-canvas-icon-picker: packs: - ui_icons_bootstrapWhich packs are offered. Site-wide config sets the default allowed packs; the annotation narrows it per prop. A site running one pack never configures anything.
What gets stored. The saved value is the full pack_id:icon_id string and the prop stays type: string. That was the decision I am most glad about. The component validates identically with or without the module, and if the picker is uninstalled the prop degrades to a textfield containing a value that still renders. A prop control should be a better way to produce a value, never a new kind of value.
Rendering stays core’s:
{% if icon %} {{ icon(icon|split(':')[0], icon|split(':')[1], { size: 24 }) }}{% endif %}The enum collision
The first properly external bug landed this week, and it is more interesting than it looks. Someone had done the sensible thing — annotated the prop for the picker and declared an enum, to restrict that particular component to the four icons the design allowed. They got the select. The annotation was ignored, with no message anywhere (#3619510).
The cause is that prop widget resolution is first-match on a single constraint. The enum branch is evaluated before the extension branch, it matched, and resolution stopped. My annotation never got asked.
The cheap fix is to reorder so the annotation wins. I did not want that fix, because it trades one silent loss for another — the enum would then be dropped, and a component that promised four icons would offer eight hundred. What the two constraints actually express are different things: the enum is a filter on the set, the annotation is a statement about the control. So the picker now claims the prop, then intersects the available icons with the enum, and renders a chooser containing exactly those four.
Which points at something larger, and I think this is the part worth arguing about. Prop widget resolution in Canvas assumes a prop has one constraint that determines one widget. Props are increasingly multi-constraint — a type, an enum, a format, a vendor extension, all describing the same value from different angles. First-match handles that by discarding information, silently, which is the failure mode hardest to notice and most annoying to debug. Widget resolution should behave more like a negotiation: widgets declare what they can handle, one wins the control, and the remaining constraints are passed to it as filters rather than thrown away. That is a bigger change than a contrib module can make on its own, and I would rather be wrong about it in public than keep patching around it. If someone can show me a prop shape where a filter chain produces a worse control than first-match, I will drop the argument.
Still unresolved
Icon set scoping has no good middle layer. Site-wide config and a per-prop annotation are the two ends; what a large design system actually wants sits between them — this section of the site uses this pack, that editorial group uses that one — and I have not found a modelling of it that does not turn into a permissions system nobody asked for.
The one that bothers me more is referential integrity. A saved value is pack_id:icon_id in a text prop. Uninstall the pack, rename the extractor’s source directory, and every reference goes dangling — the icon disappears, the layout stays intact, and nothing tells anyone. That is precisely the bug I started with, arriving by a different route. Entity references solved this decades ago; a string that names something outside the database has not. If you have shipped a workable answer for validating non-entity references at config or content save time, I would like to see it.
Next I want to take the same question into Canvas Builder, where the prop controls are richer and the failure is louder — because if widget resolution has to negotiate for icons, it has to negotiate for everything.