Add a collection slot
Add a repeating list whose items the editor selects and edits one at a time.
Add a repeating list — a unit table, agent cards, a feature list, a gallery — whose items the editor selects and edits one at a time.
Prerequisites
Section titled “Prerequisites”- A section to add the list to — Install scaffolds one.
- Its
fixtures.tsbuilt from a scenario, so the array resolves in preview — Pick a fixture scenario.
Everything multi-item is one slot type, declared with one builder: list.of(item, options). The item is the whole declaration — a single slot for a flat list,
or a record of slots for a row that repeats — and everything downstream reads it:
which editor the slot gets, which fill decisions it can take, how its value
resolves. There is no collection type to choose.
Starting from a section you already have, add a unit table: a record item, bound
whole-array to a list fact with fact.property.units. The array is baked from the
property, so it needs no fill decision, exactly like a fact-bound scalar
(bind-property-fact.md). Each field binds to a per-row
fact under fact.unit — resolved against that row, not the primary unit.
Add list, number and fact to section.ts’s existing kit import, and
bindItems and SlotGroup to Renderer.tsx‘s — the scaffolded files’ import
lists don’t carry them, so all five are missing until you do.
// section.ts — a repeating list of units, after the `headline` slotunits: list.of( { unit_label: text.short({ label: "Unit", source: fact.unit.unit_label, writeback: true }), beds: number({ label: "Beds", source: fact.unit.beds, writeback: true }), baths: number({ label: "Baths", source: fact.unit.baths, writeback: true }), sqft: number({ label: "Square feet", source: fact.unit.sqft, writeback: true }), price: text.short({ label: "Price", source: fact.unit.price, writeback: true }), }, { source: fact.property.units, writeback: false, label: "Units", title: "unit_label", },),Each option declares once, on the thing it describes. The first argument is
the item, so anything about one row’s fields lives there; the second is the list,
so it carries only facts about the collection — its label, its bounds, its
source, its fill. A crop belongs on the image inside the item, never on a
list of images.
Two labels, two jobs. Each field’s own label is the name the editor puts on that
field inside a row, so give every one of them a label — a row of unlabelled fields
is a column of anonymous inputs. title is the list’s, and it names which field
titles the row itself in the editor’s accordion; it takes a key of the record, so a
renamed field is a compile error rather than a row that silently loses its heading.
Every entry in the record is a builder call in its own right, so a field takes the
same label, cap, format and source options its top-level twin would.
// Renderer.tsx — Slot.List marks the whole array; SlotGroup marks each row
// bindItems yields one ItemBinding per row; its bindings are typed from the record// declared above — `unit.fields.beds` is a binding whose value is// `number | null`, `unit.fields.unit_label` one whose value is `string` — so there// is no cast to write.
<Slot.List slot={slot.units} as="ul" className="mt-4 flex w-full flex-col divide-y"> {bindItems(slot.units).map((unit) => ( <SlotGroup slot={unit} as="li" key={unit.index} className="flex justify-between py-2"> <Slot.Text slot={unit.fields.unit_label} as="span" /> <span> <Slot.Number slot={unit.fields.beds} as="span" format={(beds) => `${beds} bd · `} /> <Slot.Number slot={unit.fields.baths} as="span" format={(baths) => `${baths} ba · `} /> <Slot.Number slot={unit.fields.sqft} as="span" format={(sqft) => `${sqft} sqft · `} /> <Slot.Text slot={unit.fields.price} as="span" format={(price) => (/^\d+$/.test(price) ? `$${Number(price).toLocaleString("en-US")}` : price)} /> </span> </SlotGroup> ))}</Slot.List>Formatting is the render site’s job, not the schema’s. Every one of those fields
is displayed through a format callback — a pure function from the field’s value to
what should appear. There is nothing to declare on the slot: the kit ships no catalog
of format names, because how a price or a measurement should read is a decision your
template makes. Two properties of the callback are worth knowing. It runs only on a
filled field, so nothing inside it is handed a blank to interpolate — an unfilled
beds renders the empty element and the callback never fires. And it withholds the
editor’s text leaf, so an edit to a formatted field round-trips through a re-render
instead of updating as the user types; leave a field raw where live typing matters
more than the formatting. A price is the case that makes the shape obvious: the stored
value is bare digits when it is numeric and verbatim text ("Sold") when it is not, so
the callback decides between them rather than a declaration doing it blindly.
The index SlotGroup stamps on each row is that row’s position in the array
(unit.index), because that index is the address the editor writes edits back through —
there is no separate index to pass and drift out of sync with it. A record item’s rows
are always selected individually, so each row renders inside its own SlotGroup. A
single-slot item is edited as one unit — the editor treats the whole gallery or feature
list as one card from its side panel — and iteration is a plain .map with no per-row
marker. Nothing declares that difference: it follows from the item.
fixtures.ts gets no entry for this slot, and cannot: a source-bound list
resolves its array straight from the scenario, so the key is subtracted from the
authorable ones and stating it is a compile error. The lever on what the list
previews as is therefore which scenario the fixture starts from — pick one whose
units are the shape you want to see (pick a scenario), rather
than writing rows into the fixture.
Contracts to know before you widen this:
- A
source-bound list binds only a registry array —fact.property.units,fact.property.contactsorfact.property.comparables(the list sources). For any other repeating data, drop thesourcebinding and give the slot its ownfill:instead (see the fill-spec decision types). - A comparables list names its row members and binds none of them. Units and
contacts rows carry per-row facts, so each field binds one (
fact.unit.bedsabove). A comparable’s members are not separate facts — the row arrives whole — so each field’s own key names the member it shows (address,price,sqft,photo_url) and carries nosource;checkrefuses one that does. No field of them takeswritebackeither, and that is not the same as saying the rows are read-only: an agent edits a comparable’sprice,sale_date,beds,bathsandsqftin the editor and the edit reaches the property, because which members propagate is the catalog’s answer rather than one you declare per section. The rest — the address, its coordinates, the distance and the marketstatus— are set together or computed, and no editor cell writes them. Every market figure a comps slide states — a median, a$/SF, a price band — is acompute()over the rows, not a platform helper. - A bound list’s row bounds are the registry’s, not yours — how many rows
a
fact.property.*array admits is declared once, on the array itself, and the platform stamps it onto the slot. Leavemin/maxoff.checkrejects a bound equal to the array’s (it restates a number you do not own) and one wider than it (the editor would offer rows the server refuses); an explicit bound is legal only as a strict narrowing — say,max: 6on a list the platform caps at 12. - A record needs more than one field, or a non-scalar one — a lone scalar field is rejected; declare the item as that scalar directly instead.
- A record field may be any slot except another record — a row cannot hold a
second row collection, and a nested one is a compile error in
defineSchema. A flat list inside a row is legal (features: list.of(text.short({ … }), { … })) and edits as a unit within that row; model a second level of rows as a sibling list slot instead (the slot types).
Change the item and everything follows. A gallery of framed photos is
list.of(image({ crop }), { … }), several videos are list.of(video({ playback }), { … }),
a flat feature list is list.of(text.short({ cap: 80 }), { … }), neighborhood points
are list.of(poi(), { … }), and a captioned photo is a record of the two. Same
Slot.List + bindItems render pattern throughout; the only difference is what
bindItems hands back — a record item’s member carries fields (one binding per
declared field), every other item carries its raw value on item.value.
Complete diff
Section titled “Complete diff”Two additions — one in section.ts, one in Renderer.tsx — plus list,
number and fact on section.ts’s kit import and bindItems and SlotGroup
on Renderer.tsx’s. fixtures.ts is untouched:
// section.ts — in `defineSchema`'s `slots`units: list.of( { unit_label: text.short({ label: "Unit", source: fact.unit.unit_label, writeback: true }), beds: number({ label: "Beds", source: fact.unit.beds, writeback: true }), baths: number({ label: "Baths", source: fact.unit.baths, writeback: true }), sqft: number({ label: "Square feet", source: fact.unit.sqft, writeback: true }), price: text.short({ label: "Price", source: fact.unit.price, writeback: true }), }, { source: fact.property.units, writeback: false, label: "Units", title: "unit_label", },),// Renderer.tsx — wherever the list belongs in the markup<Slot.List slot={slot.units} as="ul" className="mt-4 flex w-full flex-col divide-y"> {bindItems(slot.units).map((unit) => ( <SlotGroup slot={unit} as="li" key={unit.index} className="flex justify-between py-2"> <Slot.Text slot={unit.fields.unit_label} as="span" /> <span> <Slot.Number slot={unit.fields.beds} as="span" format={(beds) => `${beds} bd · `} /> <Slot.Number slot={unit.fields.baths} as="span" format={(baths) => `${baths} ba · `} /> <Slot.Number slot={unit.fields.sqft} as="span" format={(sqft) => `${sqft} sqft · `} /> <Slot.Text slot={unit.fields.price} as="span" format={(price) => (/^\d+$/.test(price) ? `$${Number(price).toLocaleString("en-US")}` : price)} /> </span> </SlotGroup> ))}</Slot.List>Verify
Section titled “Verify”Verify with the author loop.
Rules that can fire
Section titled “Rules that can fire”schema-invalid— the record holds a single scalar field, nests another record, or the fact bound withsourceis not a registry list.missing-slot-marker— the list, one of its rows, or one of its declared row fields never reaches the rendered DOM with its marker.sidebar-order— the list is declared in one position in the schema and rendered in another.
See also
Section titled “See also”- Slot primitives — both markers a list needs, and why the index must be the row’s array position.
- The slot types — every slot type and the
fact.propertylist sources. - The fill decision types — the
decisions a list can declare as its
fill:instead of binding to a fact. - The fill-spec declaration — the contract those decisions are declared in.
- Bind slot to fact — the
factpattern this extends from a scalar to a whole array.