StatifierBlocks.Editor.PaletteBrowser (StatifierBlocks v0.29.0)

Copy Markdown View Source

The grouped, searchable, filterable palette (ADR-0005 decisions 8, 10, 13).

The entry's icon

An entry renders the tile the card it produces will carry, resolved by the same seam and through the same icon assign - Editor hands this component and Editor.BlockNode the identical value. The tile was declared here and rendered nowhere for the whole of the graduated editor's life (sb-jja), so a host could not put an icon on a palette row at all; a palette that showed no icons above a canvas that did was the visible half of that.

Everything it renders comes from palette_entry/0 through StatifierBlocks.ViewModel, which already applied decision 10's defaults - label to the type name, group to "Other", description to "", icon to nil, keywords to [], order to 0. A block type that implements none of the callback still renders here, and ADR-0002 decision 5 promised it would.

Two filters compose, and they are different in kind:

  • Search is the author's, over label, description, type name and keywords. Purely presentational.
  • Acceptance is the slot's, and it is two sets because the palette has two kinds of row. allowed carries the type names that slot will take, computed by StatifierBlocks.Editor with the same predicate a drag uses - Edit.Targets.droppable_slots_for/3 against a probe block of each candidate type, whose config since sb-1c7g is the entry's default_config, so a type refused for a READ rather than for its kind is filtered here too. allowed_recipes carries the recipe names whose arrangement lands at the armed position, which is the recipe's own question and not a set of type names (ADR-0005 clause 1C): insert/2 at that position, and Recipe.within_reach?/2 over what it answers with. Decision 8 is explicit that the filter uses the same predicate as decision 5, not a parallel implementation, and this component deliberately computes neither of them: it is handed the sets.

What a row says, and what the count line says (parity item 1.3)

A row is the tile, the label, and the one-line description the type declared - three things, not one, because the label alone answers "what is this called" and never "what would it do". The tile is a slot rather than an icon: a type that named none still renders the box, so the column of names lines up whether or not every type in a host's registry got around to declaring a glyph.

Above the groups sits one line of arithmetic. Unfiltered it is the size of the palette; filtered it is how much of the palette is left and why. The "why" matters more than it looks: two different filters can be narrowing this list - the author's query and the slot's acceptance set - and an author who opened the palette from a gap never typed anything, so a line that only ever explained queries would leave the more confusing of the two cases unexplained.

Wherever that line says "block type" or "fit" it is counting types only, with the recipes named in a clause of their own. A recipe is not a block type (ADR-0005 clause 1C) and no acceptance set can answer for its fit, so a single number over both kinds said two untrue things at once: it called a recipe a type, and it counted a recipe among the entries the slot had accepted.

The per-group count is the same idea one level down, and it is the count of what is under that header now, not of the group in the registry.

The strip, below 780 (7A)

The 2026-08-29 shell amendment gives the palette a second shape: below a container width of 780 it collapses to a strip - a label and a "+" - that opens as a sheet over the canvas, so the inspector gets the full row. Both shapes are always in the markup and the stylesheet decides which one is on screen, because the breakpoint is a container query and the server does not know how wide the host gave the editor. What the server owns is whether the sheet is open, which is one boolean and one event; selecting a block or picking an entry closes it, since a sheet left open covers the thing the author just chose.

The pane header, and the collapse (parity item 1.1)

Above the strip and the body sits the pane's own header row: the name of the pane, and a chevron that folds it. It is the palette's half of the frame the spike gives both side panes, and 8A puts it on the package's side of the split - it operates on the document that is open rather than deciding which one is.

The collapse is a server-side command in the same shape everything else in the shell amendment uses: one boolean on the editor, one event, no hook. Collapsed, the body goes with display: none rather than a zero width, so a folded pane is out of the tab order and out of the accessibility tree - a pane an author can still Tab into is a pane that reads as broken to everyone not using a mouse. The chevron stays, because it is the way back.

The header belongs to the wide arrangement. Below 780 the strip (7A) is the palette's whole chrome and the stylesheet puts the header away: two stacked headers is one more than a one-column arrangement has room for, and the collapse has nothing to fold there - the body is already a sheet.

Picking an entry emits an :insert at exactly the position the "+" named, which is the identical command a successful drop would produce. That is what makes the whole insertion path exercisable in LiveViewTest without simulating a drag, and it is why decision 8 is not only an accessibility affordance - though it is that, drag-and-drop being unusable by keyboard and hostile on touch.

The entry is also a drag source (sb-4nep)

A row carries draggable="true" and data-sb-drag-type, which is the whole of this component's part in palette drag-to-insert. Dragging a type onto a gap and picking it at an armed gap produce the same :insert at the same position, so this is a second gesture onto decision 8's one path rather than a second path - the record's command set is untouched, and the keyboard route above is unchanged and still the one the tests drive.

The type name rather than a payload: the server owns what a new block of that type is (Editor.new_block/2 mints the id and the default config at gesture time, decision 2), so what crosses the wire is the name the author reached for and nothing that would have to be trusted.

Summary

Functions

The groups a query and the two acceptance sets leave visible, with empty groups dropped. Pure, so the palette's filtering is asserted directly rather than through markup.

The palette: a header row, a search box, a count line, then a section per entry.group.

Functions

filter(groups, query, allowed, allowed_recipes \\ nil)

The groups a query and the two acceptance sets leave visible, with empty groups dropped. Pure, so the palette's filtering is asserted directly rather than through markup.

allowed_recipes defaults to nil - unfiltered - so a caller that has only the type set, and every existing one did, is unchanged.

palette_browser(assigns)

The palette: a header row, a search box, a count line, then a section per entry.group.

Attributes

  • groups (:list) (required)

  • query (:string) - Defaults to "".

  • allowed (:any) - MapSet of accepted type names, or nil for unfiltered. Defaults to nil.

  • allowed_recipes (:any) - MapSet of the recipe names whose arrangement lands at the armed position, or nil for unfiltered. Independent of allowed: a recipe is not a block type, so the two sets are two namespaces (ADR-0005 clause 1C) and neither answers for the other's kind.

    Defaults to nil.

  • target (:any) (required)

  • icon (:any) - The host's icon component, or nil for StatifierBlocks.Editor.Icons. The same value the canvas cards get, so a type looks the same before and after the pick that puts it in the document.

    Defaults to nil.

  • class (:string) - Defaults to nil.

  • sheet_open (:boolean) - Whether the narrow-layout sheet is open (7A). Ignored above 780. Defaults to false.

  • collapsed (:boolean) - Whether the pane is folded to its header. The wide arrangement's affordance; below 780 the strip is the palette's chrome and this is ignored.

    Defaults to false.

  • insert_target (:any) - Where an armed pick would land, as %{slot: label, parent: title}, or nil when nothing is armed. StatifierBlocks.Shell.insert_target/2 computes it; this component only prints it.

    Defaults to nil.

  • unarmed_pick (:boolean) - Whether the last pick was made with nothing armed, and so did nothing. The visible half of that no-op.

    Defaults to false.