Skip to content
HomePagesHomePages template kit

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.

  • A section to add the list to — Install scaffolds one.
  • Its fixtures.ts built 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` slot
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",
},
),

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 arrayfact.property.units, fact.property.contacts or fact.property.comparables (the list sources). For any other repeating data, drop the source binding and give the slot its own fill: 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.beds above). 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 no source; check refuses one that does. No field of them takes writeback either, and that is not the same as saying the rows are read-only: an agent edits a comparable’s price, sale_date, beds, baths and sqft in 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 market status — 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 a compute() 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. Leave min/max off. check rejects 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: 6 on 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.

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 with the author loop.

  • schema-invalid — the record holds a single scalar field, nests another record, or the fact bound with source is 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.