StatifierBlocks.Editor.Drawer (StatifierBlocks v0.31.0)

Copy Markdown View Source

The full-width drawer row (ADR-0005, the 2026-08-29 shell amendment, rulings 1A and 2A).

1A says what belongs here and the test is two words: tabular and document-level. Content that is a grid of rows about the whole document goes in the drawer; content about one block does not, whatever its shape.

Six tabs ship. Truth tables were first. The document-level findings list joined them under operator ruling R4 (2026-08-29), which retired the text block that used to sit under the canvas: a list of findings is a grid of rows about the whole document, so 1A's test admits it and the canvas gets its height back. Declarations joined them under the 2026-09-01 amendment (clause 2i), which took ADR-0001 11i's named door: the document's own datamodel roots are a grid of rows about the envelope, which is 1A's test again. Fixture runs joined under sb-4yze: one row per fixture row in the document, each carrying the outcome slot it expected against the one the compiled chart actually took (StatifierBlocks.Runtime.FixtureRuns does the driving; this module only draws the table). The read-only declared-path view took the last reserved place: one row per path the three declaring surfaces name, with the surfaces that named it and the shape the ADR-0006 projection carries, which is a grid of rows about the whole document and admitted by 1A for the same reason the four before it were. It is read-only on purpose - the editable half is the Declarations tab beside it, over the document's own roots - and no reserved place remains behind it. A path whose ADR-0006 entry carries a one_of draws that enumeration in a Values column beside its shape, cut at eight values with the remainder counted ("+3 more") so that a path declaring forty does not turn one row into a paragraph. It is the same enumeration a value picker is fed from, drawn here for the same reason the shape is: "where did this picklist come from" is then answered by a row in this table rather than by opening a condition.

The Source listing is the sixth and came behind no reservation at all, because none was left: one numbered line per line of the chart the document was last compiled to, each carrying the block that emitted its bytes (StatifierBlocks.SourceView does the reading; this module only draws it). That is a grid of rows about the whole document, so 1A admits it on its own merits, which is the only way anything joins the drawer now.

The measurable reason the drawer exists at all: a truth table for a branch in a credit-card processing document is one row per case and one column per bound input plus the verdicts, and at the inspector's width it either scrolls sideways or inverts its column order to keep the answers on screen. The spike did the second and filed the inversion as a readability defect. A drawer is as wide as the editor is, which is the axis the table needs.

Never open-or-gone

2A: collapsed, the drawer is a strip carrying a title and a count - a small-caps label beside a chip, "Truth tables 3" - and that is what makes the content discoverable from any state rather than only from the affordance that opens it. Opening it with no table on the selected block shows the index page: the blocks that do own one, each of them a jump. That is the whole of the spike's cold-start gap, closed.

The strip reports the active tab, and an author who has not picked one gets the first tab that actually holds something (StatifierBlocks.Shell.drawer_view/1 resolves it). That is 2A's own reasoning applied to a second tab: a strip is worth having because it says what is in the drawer, and one that names an empty tab while the other has four rows in it says the opposite.

The count is the document's, not the selection's. A strip reading 0 because nothing happens to be selected would tell an author something false about their document.

A host's own tab

8A's split is that the package ships the editing surface and the host ships what surrounds it. A host tab is that reaching the drawer: the editor's drawer_tabs assign carries a label, a count and a function component, the function is called here when its tab is the active one, and the package learns nothing about what it draws.

This admits no new package tab. 1A's test - tabular, and about the whole document - governs the tabs shipped here, unchanged and unweakened; for a tab the host contributes, that test transfers to the host as its own obligation. The 2026-08-30 amendment recording the drawer's tab strip as a host seam is where that split is written down.

It is called the way HEEx calls <.tab /> rather than by applying it to a bare map, which is StatifierBlocks.Editor.Icons' rule (sb-b8g) and the same one for the same reason: a host component that derives a value with assign/3, which is what an ordinary component does, raises on an assigns map with no change-tracking key in it.

A function rather than a slot, for the reason StatifierBlocks.Editor's own moduledoc gives: the first thing a host wants a tab for is a feed of something happening now, and a slot body reading a host assign does not redraw when that assign moves, because the editor is a LiveComponent and a slot body is not one of the assigns it was passed. A pushed descriptor is.

Host data enters the panel through assign/2

The content function is called with the two assigns this seam has - %{id: ..., count: ...} - and everything else the panel draws comes from the host, out of the closure the descriptor was built with. Getting it from the closure into the markup is one line, and which line it is matters:

content: fn assigns -> MyApp.run_feed(assign(assigns, :events, events)) end

Map.merge/2 and Map.put/3 write the same key and are the trap. The assigns map that arrives carries a __changed__ naming the two keys this call passed, id and count, because the call is change-tracked like any other component call. A merged key is absent from that map, so every part of the template that reads it is treated as unchanged and never reaches the diff: the panel draws once, holding whatever the closure held when the tab was opened, and then stands still while the count on the tab beside it keeps climbing. Nothing raises and nothing warns, because nothing is missing - the value is in the assigns, and change tracking was only never told about it.

Phoenix.Component.assign/2 is what tells it. It writes the key and records the key in __changed__, and that record is the whole of the difference between the two lines.

The resize is a command, not a hook

Decision 7 ships exactly one JavaScript hook and this section adds none. The resize is a native range control inside a phx-change form: the new height crosses as an ordinary event, StatifierBlocks.Shell.clamp_height/1 bounds it, and the host stores it - one round trip, and the state that matters lives where state already lives. 2A is explicit that the height is remembered per viewer and that the package has no viewer, so nothing here persists anything.

The arrow keys are the server's

The strip is a role="tablist" with a roving tabindex, so the tabs are one stop on the Tab sequence and all but the active one are reached with the arrow keys or not at all - and once sb-mtak made the strip scroll at the narrow breakpoint with its scrollbar hidden, "not at all" also meant not visible. WAI-ARIA's pattern closes that: Left and Right move one tab and wrap, Home and End go to the ends.

The server owns the movement, and nothing here is a JavaScript hook. Decision 7 caps the package at two hooks and says in as many words that adding another requires amending that record, which is a deliberately high bar and the wrong bar to clear for a keystroke that already has a server-side route: phx-keydown on the tablist carries the key, the editor's "drawer-tab-key" handler picks the neighbour out of the same ordered tab list the strip draws from, and the pick is stored in the same assign a click stores. So the tab order lives in one place, the arrow keys and the pointer reach it the same way, and ExUnit can drive the whole behaviour.

Activation is automatic - moving focus picks the tab - which is what lets the server know which tab is focused without being told: under a roving tabindex the focused tab is the active one, so phx-value-tab on the tablist is enough and no per-tab binding is needed. WAI-ARIA recommends automatic activation when showing a panel is cheap, and these panels are already re-rendered on every document change.

Moving DOM focus is the one part a re-render cannot do by itself, and it is done without a hook either. The @focus_tab span carries the newly active tab in its id, so a pick that moves the tab replaces the element rather than patching it, and the phx-mounted on the replacement runs JS.focus/1 at the new tab. focus() scrolls its element into view, which is what makes a tab past the clipped edge reachable at the narrow breakpoint. The editor clears focus_tab on every other route into the drawer - a click, an open, a close, a document switch - so the span is absent unless a key press just asked for the focus to move, and opening the drawer never steals it.

Summary

Functions

The drawer row: a strip when collapsed, tabs and a table when open.

Functions

drawer(assigns)

The drawer row: a strip when collapsed, tabs and a table when open.

Attributes

  • view (:map) (required) - StatifierBlocks.Shell.drawer_view/1's value.

  • read_only (:boolean) - Whether this mount edits. The only panel it changes is the declarations list, which is the drawer's one editing surface; every other tab here is a reading already (ADR-0005's 2026-09-07 profile amendment).

    Defaults to false.

  • height (:float) (required)

  • root (StatifierBlocks.ViewModel.Node) (required)

  • target (:any) (required)

  • class (:string) - Defaults to nil.

  • focus_tab (:any) - The tab the strip must put DOM focus on, or nil for "leave focus alone". The editor sets it when an arrow key moved the active tab and clears it on every other route into the drawer - see the moduledoc's "The arrow keys are the server's".

    Defaults to nil.

  • declarations (:list) - the entries the Declarations tab draws - the document's, or an author's draft. Defaults to [].

  • declaration_refusal (:string) - the sentence for a refused declaration edit, or nil. Defaults to nil.

  • fixture_runs (:any) - StatifierBlocks.Runtime.FixtureRuns.t() for the Fixtures tab, or nil. Defaults to nil.

  • declared_view (:list) - StatifierBlocks.Datamodel.declared_view/3's rows for the Datamodel tab. Defaults to [].

  • declared_values (:map) - The enumerations the datamodel document declares per path, as StatifierBlocks.Datamodel.value_candidates/1 projects them - the declaration's own values, with no host map merged over them. Drawn beside the declared path they belong to; a path absent from the map declares none.

    Defaults to %{}.

  • declared_types (:list) - StatifierBlocks.Datamodel.declared_types/1's rows for the Datamodel tab. Defaults to [].

  • environment_view (:any) - What the environment holds at the selected block's position, as %{path:, type:} rows, or nil when nothing is selected - ADR-0011 decision 9's "what is known here". nil and [] say different things: nothing selected, and nothing known there.

    Defaults to nil.

  • run? (:boolean) - Whether a run is seated in the editor. The Datamodel tab's "what is known here" table draws its held-value column only while one is - see StatifierBlocks.Runtime.RunValues.

    Defaults to false.

  • source_view (:any) - StatifierBlocks.SourceView.t() for the Source tab, or nil before the editor has compiled anything. The editor refreshes it only while the drawer is open on that tab.

    Defaults to nil.

  • selected_id (:any) - the selected block's id, which is what the Source tab highlights by. Defaults to nil.

  • host_tabs (:list) - The host's tab descriptors, already through StatifierBlocks.Shell.host_tabs/1. The active one's content is called for the panel; the strip draws them all from @view.tabs, where their labels and counts already are.

    content is called with %{id: ..., count: ...} and with change tracking on those two keys only, so a host value the function adds for its own markup is added with Phoenix.Component.assign/2 and never merged in - see the moduledoc.

    Defaults to [].