For the complete documentation index, see llms.txt. This page is also available as Markdown.

Collection-Driven Dropzones

Generate repeating layouts with user-managed dropzones from collections

This is the pattern behind almost every tabbed, accordion, or slider-style component in Elements: a collection defines how many regions exist, a @dropzone inside an @each loop gives each region its own child-component area, and your hooks stitch the iterations together with ids and ARIA attributes. The user adds a tab in the inspector — and a whole new panel appears in the editor, ready to receive dropped components. This guide walks the pattern end to end, from collection schema to accessible markup.

From Static Dropzones to Loops

A static dropzone is a fixed hole in your template — you decide at design time that your component has a "header" area and a "content" area, and every instance of the component has exactly those two. The @dropzone reference covers this form, including its title and horizontal parameters and a short section on dropzones inside loops.

Collection-driven dropzones invert the ownership. Instead of you deciding how many content areas exist, the user decides — by adding, removing, and reordering items in a collection panel in the inspector. Your template declares the dropzone once, inside a loop over the collection, and Elements creates one real dropzone per iteration. Three pieces cooperate:

  1. The collection (collections/{name}/) defines the repeatable item and its per-item fields — a tab's title, a slide's caption, a column's width.

  2. The hooks file reads the collection from rw.collections, computes anything the template shouldn't have to (active index, per-item ids, precomputed booleans), and passes the processed array to templates with rw.setProps.

  3. The template loops the array with @each and places @dropzone (and any ARIA wiring) inside the loop body.

Worked Example: The Core Tabs Component

The Collection Defines How Many Panels Exist

The Tabs component's collection lives at collections/tabs/ and consists of three small JSON files. First, info.json gives the collection an identity:

// collections/tabs/info.json
{
    "identifier": "com.realmacsoftware.tabs.collections.tabs",
    "title": "Tabs"
}

Second, properties.json defines the inspector fields each individual tab carries — here just a title and an optional SVG icon:

Third, defaults.json seeds every freshly added Tabs component with three starter tabs, so users see a working tabbed layout immediately instead of an empty shell:

Finally, the component's main properties.json registers the collection with a collection control that references the identifier above, under the property name tabs — the name you read it back with in hooks and templates. See Collections in properties.json for the registration syntax.

Nothing in these files mentions dropzones. The collection's only job is to model the repeatable item; the count of items is the count of panels, and that connection is made in the next two files. See Collections for the full schema reference.

Hooks Prepare One Item Per Iteration

The hooks file turns the raw collection into a template-ready array. Here is the relevant excerpt from hooks.source.js:

Three decisions here carry the whole pattern:

  • The active index is clamped, not trusted. defaultActiveTab is a 1-based number the user types into the inspector, so the hook converts it to 0-based and clamps it to the actual number of tabs — deleting tabs can otherwise leave the "active" index pointing at nothing. In edit mode a separate editorActiveTab property drives the preview instead, so authors can flip between panels without changing what visitors see.

  • Every item gets a stable identity. Each processed item carries its index and an id derived from the component's node id (tab-${id}-${index}). The node id keeps ids unique when two Tabs components share a page; the index keys everything else — Alpine state, ARIA relationships, panel visibility.

  • Booleans are precomputed. hideInEditor folds edit && !isActive into a single flag because @if in templates takes exactly one condition — no && or ==. Whenever a template needs a compound test, compute it here.

The Template Renders a Dropzone Per Panel

The template loops tabItems twice over the same data: once to build the row of tab buttons, once to build the panels. Each panel iteration contains the @dropzone:

Walk the stitching. Every button computes an id of tab-btn-{componentId}-{index} and points its aria-controls at tab-panel-{componentId}-{index}; every panel computes the matching id and points aria-labelledby back at the button. Because both loops iterate the same tabItems array, iteration n of the button loop and iteration n of the panel loop always agree — the hook's per-item index is the thread that ties the two passes together. Screen readers get a fully wired tablist/tab/tabpanel structure, and the shared Alpine behaviour (registered once in templates/alpine.html) drives activeTab from clicks and arrow keys.

Notice what @dropzone("tabContent", …) does not do: it never mentions the index. The name is the same static literal in every iteration, yet each tab gets its own independent dropzone with its own child components. That is Elements' job, not yours — which brings us to the rules.

Rules and Gotchas

  • Dropzone names are static literals. You cannot write @dropzone("panel-{{tab.index}}") or interpolate any template value into the name or title. Declare the name once, as plain text.

  • Elements uniquifies per iteration. One @dropzone("tabContent") declaration inside @each becomes one real, independent dropzone per iteration — each with its own dropped children, persisted against that collection item. This works through nested loops too: the Table component's single @dropzone("cell") sits inside a rows loop and a columns loop, producing a distinct dropzone for every row–column pair. Distinct regions still need distinct names, though — Tabs uses "tabAboveContent" for its optional above-tab-list panels and "tabContent" for the main panels.

  • Loop metadata is available when you need it. Inside @each, the template language provides ::index, ::isFirst, and ::isLast on the loop variable (for example tab::index) — see the @each reference. Tabs doesn't use them because its hook already bakes index into each item; prefer that approach when the index feeds many expressions, and the :: variables for one-off cases like first/last styling.

  • Deleting an item deletes its dropzones. When the user removes a collection item, the dropzones generated for that iteration disappear — along with every child component the user dropped into them.

Choosing Dropzone or Content Per Item

Sometimes a region shouldn't always be a dropzone. The core Table component lets users decide, column by column, whether cells hold plain editable text or a full dropzone. The choice is a segmented control in the columns collection's properties.json — a cellMode property whose two values are "text" (the default) and "dropzone". The hook converts that string into booleans the template can test directly — again because @if takes a single condition, never a comparison:

Note the two different axes: columns come from a collection, but rows are just an array the hook builds from a rowCount number property. @each doesn't care where the array came from — a collection, a hook-built array, or a resource folder all loop the same way.

The template then switches per cell:

A text-mode column renders an inline-editable @text region in every cell — fast to fill in, impossible to break the layout with. Flipping the column's Cell Mode to Dropzone swaps every cell in that column to a dropzone that accepts buttons, images, or any other component. The per-item switch means one Table serves both a simple pricing grid and a feature-comparison matrix with rich cells — without two components or a global either/or setting.

Paired Dropzones Per Iteration

A collection item can own more than one dropzone. A common shape — used by slider-style components that pair each slide with a custom thumbnail — is to loop the same collection twice, declaring a different dropzone name in each pass. Here is a generic card deck (com.example.cardDeck) that gives every card a user-composed face and a matching detail panel:

Each collection item now owns two dropzones — "cardFace" from the first pass and "cardDetail" from the second — and the hook-generated faceId/detailId pair wires them together with aria-controls/aria-labelledby, exactly as Tabs does. Because the hook precomputes the full id strings, the template can use plain id="{{card.faceId}}" attributes instead of Alpine-bound expressions. In edit mode the x-show is omitted, so every detail panel stays visible and users can drop content into each one; the edit-mode guide covers refinements like previewing a single active panel.

Adding one card in the inspector creates both of its dropzones at once. Deleting it removes both — and everything inside them.

Nested Item Data

Collection items aren't limited to flat fields — an item can carry list-like data that your template renders with a nested @each. Since collection schemas define single values per property, the idiomatic trick is a multi-line textArea that hooks split into an array. Here is a generic pricing table (com.example.pricing) whose tiers each carry a feature list:

The nested loop follows the same shape as the @each reference's nested-loops example: the inner @each iterates an array carried by the outer loop's item, and each level gets its own descriptive variable name. The hook maps each line to an object with a label field so the template reads it with the same dot notation as every other collection field. Users edit the feature list as plain lines of text in the inspector — one line per feature, no markup to learn — while the "tierAction" dropzone still gives each tier a fully composable button area. Structured where it pays off, free-form where it doesn't.

Last updated

Was this helpful?