Theme
Version
GitHub PyPI Discord
On this page

Form Collection

Use CFormCollection for an ordered set of repeated fields or repeated multi-field groups. It never creates a nested form. Put it inside your normal form or CForm, and keep names, parsing, records, and persistence in the application.

Repeat one field

Each CFormCollectionItem has a stable value, a visible label, and ordinary form controls in its default slot.

Collect several email addresses
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class FormCollectionAtAGlance(Component):
    template = """
      <form>
        <c-CFormCollection
          label="Email addresses"
          c-allow_add="False"
          c-allow_remove="False"
          c-allow_reorder="False"
        >
          <c-CFormCollectionItem value="primary" label="Primary email">
            <label>Email <input name="emails[primary]" type="email" value="ada@example.com" /></label>
          </c-CFormCollectionItem>
          <c-CFormCollectionItem value="backup" label="Backup email">
            <label>Email <input name="emails[backup]" type="email" /></label>
          </c-CFormCollectionItem>
        </c-CFormCollection>
      </form>
    """


preview = FormCollectionAtAGlance()
preview  # noqa: B018

The component does not rewrite indexes or bracketed names. Choose stable keys when an edit must survive a keyed server rerender.

Repeat a multi-field group

An Item may contain any coherent set of fields. All controls remain direct members of the one outer form for native validation, autofill, reset, and FormData.

Edit several contacts
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class FormCollectionFieldGroups(Component):
    template = """
      <form>
        <c-CFormCollection
          label="Escalation contacts"
          description="Contacts are notified in shown order."
          c-allow_add="False"
          c-allow_remove="False"
          c-allow_reorder="False"
        >
          <c-CFormCollectionItem value="primary" label="Primary contact">
            <label>Name <input name="contacts[primary][name]" value="Ada Lovelace" /></label>
            <label>Email <input name="contacts[primary][email]" type="email" value="ada@example.com" /></label>
          </c-CFormCollectionItem>
          <c-CFormCollectionItem value="secondary" label="Secondary contact">
            <label>Name <input name="contacts[secondary][name]" value="Grace Hopper" /></label>
            <label>Email <input name="contacts[secondary][email]" type="email" value="grace@example.com" /></label>
          </c-CFormCollectionItem>
        </c-CFormCollection>
      </form>
    """


preview = FormCollectionFieldGroups()
preview  # noqa: B018

Nested collections may be placed inside Item content, but the first release does not coordinate their action protocols or focus policy.

Handle requests with Citry Events

Set action_name to turn Add, Remove, Move up, and Move down into real named submit buttons. Each uses formnovalidate, so an incomplete new row does not block a collection mutation. The server reads the activated button's value and returns a keyed rerender.

Send collection actions through the outer form
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class FormCollectionServerActions(Component):
    template = """
      <form
        method="post"
        action="/team"
        x-data
        @submit.prevent="collectionStatus = 'Saved locally'"
      >
        <c-CFormCollection
          label="Team members"
          action_name="team_action"
          c-max_items="1"
          $c-props="{onAction: applyCollectionAction}"
        >
          <c-CFormCollectionItem value="member-17" label="Ada" remove_value="delete:member-17">
            <input type="hidden" name="members[member-17][id]" value="17" />
            <label>Role <input name="members[member-17][role]" value="Owner" required /></label>
          </c-CFormCollectionItem>
        </c-CFormCollection>
        <button type="submit">Save team</button>
        <output aria-live="polite" x-text="collectionStatus">Order: Ada</output>
      </form>
    """

    js = """
      $component(({ scope, els }) => {
        const collection = els[0].querySelector('[data-citry-ui-part="form-collection"]');
        const list = collection.querySelector(':scope > [data-citry-ui-part="items"]');
        const parking = document.createElement('fieldset');
        const parked = document.createElement('ol');
        const maximum = list.children.length;
        parking.disabled = true;
        parking.hidden = true;
        parking.append(parked);
        collection.append(parking);

        const items = () => [...list.querySelectorAll(':scope > [data-citry-form-collection-item]')];
        const sync = () => {
          const current = items();
          collection.dataset.count = String(current.length);
          current.forEach((item, index) => {
            item.toggleAttribute('data-first', index === 0);
            item.toggleAttribute('data-last', index === current.length - 1);
            for (const button of item.querySelectorAll('[data-citry-form-collection-action]')) {
              const fixed = item.hasAttribute('data-citry-form-collection-item-disabled');
              const action = button.dataset.citryFormCollectionAction;
              const unavailable = fixed
                || (action === 'move-up' && index === 0)
                || (action === 'move-down' && index === current.length - 1);
              button.disabled = unavailable;
              button.dataset.citryInitiallyDisabled = String(unavailable);
            }
          });
          const add = collection.querySelector('[data-citry-ui-part="add"]');
          add.disabled = current.length >= maximum || parked.children.length === 0;
          add.dataset.citryInitiallyDisabled = String(add.disabled);
          scope.collectionStatus = `Order: ${current.map(item => item.dataset.label).join(', ') || 'No items'}`;
        };

        scope.collectionStatus = 'Order: Ada';
        scope.applyCollectionAction = (detail) => {
          // Static docs accept the named request locally; a real server returns a keyed rerender.
          detail.sourceEvent.preventDefault();
          const button = detail.sourceEvent.target.closest('[data-citry-form-collection-action]');
          let item = button?.closest('[data-citry-form-collection-item]');
          if (detail.action === 'move-up' && item?.previousElementSibling) item.previousElementSibling.before(item);
          else if (detail.action === 'move-down' && item?.nextElementSibling) item.nextElementSibling.after(item);
          else if (detail.action === 'remove' && item) parked.append(item);
          else if (detail.action === 'add') {
            item = parked.lastElementChild;
            if (item) list.append(item);
          }
          sync();
          requestAnimationFrame(() => {
            const focusTarget = item?.isConnected && !parked.contains(item)
              ? item.querySelector('button:not(:disabled), input:not(:disabled)')
              : collection.querySelector('[data-citry-ui-part="add"]');
            focusTarget?.focus();
          });
        };
        sync();
      });
    """


preview = FormCollectionServerActions()
preview  # noqa: B018

The docs preview accepts those named requests locally because this static page has no application endpoint. In an application, let the named submit continue and return the updated keyed collection from the server.

Defaults encode add, remove:<value>, move-up:<value>, and move-down:<value>. Override each value when your server protocol differs. For example, remove_value="delete:member-17" makes the Remove button submit team_action=delete:member-17. The colon has no Citry-specific meaning; the whole string is simply the application-defined value of the activated submit button.

Handle requests in Alpine

Without action_name, controls use type=button. Pass onAction through $c-props to receive {action, value, index, toIndex, sourceEvent} and update application state or send a Citry Event.

Apply client collection requests
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class FormCollectionClientActions(Component):
    template = """
      <section x-data>
        <c-CFormCollection
          label="Phone numbers"
          $c-props="{onAction: applyCollectionAction}"
        >
          <c-CFormCollectionItem value="mobile" label="Mobile">
            <label>Number <input name="phones[mobile]" /></label>
          </c-CFormCollectionItem>
          <c-CFormCollectionItem value="office" label="Office">
            <label>Number <input name="phones[office]" /></label>
          </c-CFormCollectionItem>
        </c-CFormCollection>
        <output aria-live="polite" x-text="collectionStatus">Order: Mobile, Office</output>
      </section>
    """

    js = """
      $component({
        init: ({ scope, els, i18n }) => {
        const collection = els[0].querySelector('[data-citry-ui-part="form-collection"]');
        const list = collection.querySelector(':scope > [data-citry-ui-part="items"]');
        const parking = document.createElement('fieldset');
        const parked = document.createElement('ol');
        const bindings = [];
        let nextSequence = 3;
        parking.disabled = true;
        parking.hidden = true;
        parking.append(parked);
        collection.append(parking);

        const bindActionLabel = (button, action, label) => {
          let binding = null;
          if (action === 'move-up') {
            button.setAttribute('aria-label', `Move ${label} up`);
            binding = i18n?.bind({
              message: 'citry-ui-form-collection-move-up',
              values: () => ({ item: label }),
              onChange: value => button.setAttribute('aria-label', value),
            });
          } else if (action === 'move-down') {
            button.setAttribute('aria-label', `Move ${label} down`);
            binding = i18n?.bind({
              message: 'citry-ui-form-collection-move-down',
              values: () => ({ item: label }),
              onChange: value => button.setAttribute('aria-label', value),
            });
          } else {
            button.setAttribute('aria-label', `Remove ${label}`);
            binding = i18n?.bind({
              message: 'citry-ui-form-collection-remove',
              values: () => ({ item: label }),
              onChange: value => button.setAttribute('aria-label', value),
            });
          }
          if (binding) bindings.push(binding);
        };

        const makeAction = (action, symbol, label) => {
          const button = document.createElement('button');
          button.type = 'button';
          button.dataset.citryFormCollectionAction = action;
          button.textContent = symbol;
          bindActionLabel(button, action, label);
          return button;
        };

        const makeItem = () => {
          const sequence = nextSequence;
          nextSequence += 1;
          const value = `phone-${sequence}`;
          const label = `Phone ${sequence}`;
          const labelId = `client-phone-${sequence}-label`;
          const item = document.createElement('li');
          item.className = 'cui-form-collection__item';
          item.dataset.value = value;
          item.dataset.label = label;
          item.dataset.citryFormCollectionItem = '';
          item.dataset.citryUiPart = 'item';

          const group = document.createElement('section');
          group.setAttribute('role', 'group');
          group.setAttribute('aria-labelledby', labelId);
          const header = document.createElement('header');
          header.dataset.citryUiPart = 'item-header';
          const heading = document.createElement('h3');
          heading.id = labelId;
          heading.dataset.citryUiPart = 'item-label';
          heading.textContent = label;
          const actions = document.createElement('div');
          actions.dataset.citryUiPart = 'item-actions';
          actions.append(
            makeAction('move-up', '↑', label),
            makeAction('move-down', '↓', label),
            makeAction('remove', '\u00d7', label),
          );
          header.append(heading, actions);

          const content = document.createElement('div');
          content.dataset.citryUiPart = 'item-content';
          const field = document.createElement('label');
          const input = document.createElement('input');
          input.name = `phones[${value}]`;
          field.append('Number ', input);
          content.append(field);
          group.append(header, content);
          item.append(group);
          return item;
        };

        const items = () => [...list.querySelectorAll(':scope > [data-citry-form-collection-item]')];
        const sync = () => {
          const current = items();
          collection.dataset.count = String(current.length);
          current.forEach((item, index) => {
            item.toggleAttribute('data-first', index === 0);
            item.toggleAttribute('data-last', index === current.length - 1);
            for (const button of item.querySelectorAll('[data-citry-form-collection-action]')) {
              const fixed = item.hasAttribute('data-citry-form-collection-item-disabled');
              const action = button.dataset.citryFormCollectionAction;
              const unavailable = fixed
                || (action === 'move-up' && index === 0)
                || (action === 'move-down' && index === current.length - 1);
              button.disabled = unavailable;
              button.dataset.citryInitiallyDisabled = String(unavailable);
            }
          });
          const add = collection.querySelector('[data-citry-ui-part="add"]');
          add.disabled = false;
          add.dataset.citryInitiallyDisabled = 'false';
          scope.collectionStatus = `Order: ${current.map(item => item.dataset.label).join(', ') || 'No items'}`;
        };

        scope.collectionStatus = 'Order: Mobile, Office';
        scope.applyCollectionAction = (detail) => {
          // The static preview owns this local record set; production owners rerender their own state.
          detail.sourceEvent.preventDefault();
          const button = detail.sourceEvent.target.closest('[data-citry-form-collection-action]');
          let item = button?.closest('[data-citry-form-collection-item]');
          if (detail.action === 'move-up' && item?.previousElementSibling) item.previousElementSibling.before(item);
          else if (detail.action === 'move-down' && item?.nextElementSibling) item.nextElementSibling.after(item);
          else if (detail.action === 'remove' && item) parked.append(item);
          else if (detail.action === 'add') {
            item = parked.lastElementChild || makeItem();
            list.append(item);
          }
          sync();
          requestAnimationFrame(() => {
            const focusTarget = item?.isConnected && !parked.contains(item)
              ? item.querySelector('button:not(:disabled), input:not(:disabled)')
              : collection.querySelector('[data-citry-ui-part="add"]');
            focusTarget?.focus();
          });
        };
        sync();
        return () => bindings.forEach(binding => binding.dispose());
        },
      });
    """


preview = FormCollectionClientActions()
preview  # noqa: B018

The component deliberately does not clone existing component DOM. The owner must add, remove, or reorder records and render the resulting keyed Items. The preview emulates that owner locally. It creates stable records for new phone fields and keeps removed Items connected while applying the same callback details, so Add remains unbounded and edits survive reorder and restoration on this static page.

Limit available actions

min_items and max_items guard Remove and Add controls. Root disabled disables mutation controls without silently disabling consumer fields. removable, movable, and Item disabled refine one group.

Keep required and fixed groups
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class FormCollectionLimits(Component):
    template = """
      <c-CFormCollection label="Approvers" c-min_items="1" c-max_items="2">
        <c-CFormCollectionItem value="owner" label="Account owner" c-removable="False" c-movable="False">
          <label>Email <input name="approvers[owner]" value="owner@example.com" /></label>
        </c-CFormCollectionItem>
        <c-CFormCollectionItem value="security" label="Security reviewer" c-disabled="True">
          <label>Email <input name="approvers[security]" value="security@example.com" /></label>
        </c-CFormCollectionItem>
      </c-CFormCollection>
    """


preview = FormCollectionLimits()
preview  # noqa: B018

If the entire form group must stop submitting, disable its actual native controls or an application-owned ancestor fieldset too.

Preserve edits and choose focus

Citry keyed rerenders can retain surviving native controls, their browser-owned edits, selection, and focus while Items reorder. After adding or removing an Item, the application chooses the new focus target because it owns the new record and business policy.

Label repeated shipping addresses
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class FormCollectionAccessibility(Component):
    template = """
      <form>
        <c-CFormCollection
          label="Shipping addresses"
          description="The first address is used by default."
          c-allow_add="False"
          c-allow_remove="False"
          c-allow_reorder="False"
        >
          <c-CFormCollectionItem value="home" label="Home address">
            <label>Street <input name="addresses[home][street]" autocomplete="street-address" /></label>
            <label>City <input name="addresses[home][city]" autocomplete="address-level2" /></label>
          </c-CFormCollectionItem>
          <c-CFormCollectionItem value="office" label="Office address">
            <label>Street <input name="addresses[office][street]" /></label>
            <label>City <input name="addresses[office][city]" /></label>
          </c-CFormCollectionItem>
        </c-CFormCollection>
      </form>
    """


preview = FormCollectionAccessibility()
preview  # noqa: B018

The fieldset, legend, grouped Items, and native buttons provide the semantic baseline. Action labels come from Citry UI catalog messages; application Item labels and fields retain their own locale and direction.

API reference

Inputs

CFormCollection server inputs

Server inputs are passed in a template through <c-CFormCollection ... /> or in Python through CFormCollection(...).

InputTypeDefaultEffect
labelstrrequiredSupplies the visible native legend and collection name.
idstr | NonegeneratedSets the fieldset ID and bases stable Item label IDs.
descriptionstr | NoneNoneAdds plain described-by guidance below the legend.
action_namestr | NoneNoneWhen supplied actions are named submit buttons; otherwise they are client-only Buttons.
add_valuestr"add"Sets the Add submit-button value.
allow_addboolTrueIncludes the Add control.
allow_removeboolTrueIncludes permitted Item Remove controls.
allow_reorderboolTrueIncludes permitted Move controls.
min_itemsint0Rejects fewer rendered Items and disables Remove at the minimum.
max_itemsint | NoneNoneRejects more rendered Items and disables Add at the maximum.
disabledboolFalseDisables collection mutation controls without disabling consumer fields.
sizeCFormCollectionSize (CFormCollectionSize)"md"Selects collection spacing density.
add_labelstr"Add item"Overrides the localized Add text.
remove_labelstr"Remove {item}"Overrides localized Remove names and must retain item.
move_up_labelstr"Move {item} up"Overrides localized Move up names and must retain item.
move_down_labelstr"Move {item} down"Overrides localized Move down names and must retain item.
class_CClassValue | None (CClassValue)NoneAdds classes to the fieldset.
styleCStyleValue | None (CStyleValue)NoneAdds styles to the fieldset.
attrsMapping[str, object] | NoneNoneAdds copied allowed fieldset attributes.

CFormCollection client inputs

Client inputs are passed in the browser through the $c-props="{ ... }" attribute on <c-CFormCollection />.

InputTypeOmitted behaviorEffect
disabledbooleanUses the server value.Reactively disables mutation controls.
onActionfunctionNo component callback runs.Receives Add Remove Move up and Move down requests.

CFormCollectionItem server inputs

Server inputs are passed in a template through <c-CFormCollectionItem ... /> or in Python through CFormCollectionItem(...).

InputTypeDefaultEffect
valuestrrequiredSupplies unique stable Item identity and default action-value suffix.
labelstrrequiredSupplies the visible group heading and action-name interpolation.
remove_valuestr | NonegeneratedOverrides the default remove colon value action protocol.
move_up_valuestr | NonegeneratedOverrides the default move-up colon value action protocol.
move_down_valuestr | NonegeneratedOverrides the default move-down colon value action protocol.
removableboolTrueIncludes this Item's Remove control when root removal is allowed.
movableboolTrueIncludes this Item's Move controls when root reorder is allowed.
disabledboolFalseDisables this Item's collection actions only.
class_CClassValue | None (CClassValue)NoneAdds classes to the Item group.
styleCStyleValue | None (CStyleValue)NoneAdds styles to the Item group.
attrsMapping[str, object] | NoneNoneAdds copied allowed Item-group attributes.

Slots

Slots are passed as nested content or <c-fill> tags in a template, or through the slots={...} argument in Python.

CFormCollection slots

SlotRequiredDataFallback
defaultno{} (CFormCollectionDefaultSlotData)Empty collection.

CFormCollectionItem slots

SlotRequiredDataFallback
defaultyes{value, label, index, count, is_first, is_last, disabled} (CFormCollectionItemSlotData)None; contains the actual repeated fields.

Events

Component events are callback inputs supplied through $c-props. Native browser events remain available through Alpine @... attributes.

CFormCollection events

EventSignatureTrigger and timingDetailControlled and cancellation behavior
onAction(detail: CFormCollectionActionDetail) => void (CFormCollectionActionDetail)An enabled collection action Button is activated.{action, value, index, toIndex, sourceEvent} (CFormCollectionActionDetail)Reports a request and never mutates the collection DOM.

Methods

-

CSS

CSS variables to theme the components. Set them on an ancestor or the component itself.

CFormCollection CSS variables

Apply these variables to CFormCollection or one of its ancestors.

VariableTypePurposeDefault
--cui-form-collection-gaplengthSpace between Item groups and Add.0.875rem
--cui-form-collection-item-surfacecolorItem group surface.Canvas
--cui-form-collection-item-bordercomplete borderItem and header boundary.Adaptive 1px neutral
--cui-form-collection-item-radiuslengthItem corners.0.75rem
--cui-form-collection-action-gaplengthGap between mutation controls.0.375rem
--cui-form-collection-focuscolorMutation-control focus ring.Highlight
--cui-form-collection-disabled-opacitynumberDisabled collection and control opacity.0.55

Attributes

HTML attributes defined on the components that you can refer to for CSS, inspection, and testing. Read-only.

CFormCollection attributes

AttributeElementTypeMeaning
data-countRootnonnegative integer stringReflects rendered Item count.
data-sizeRootCFormCollectionSize (CFormCollectionSize)Reflects spacing density.
data-disabledRoot and Itempresent | absentReflects unavailable collection actions.
data-firstItempresent | absentMarks first current Item.
data-lastItempresent | absentMarks last current Item.
data-valueItemstringExposes stable Item identity.

Selectors

Selectors for the DOM nodes in the components that you can use for CSS, inspection, and testing.

CFormCollection selectors

SelectorElementPurpose
[data-citry-ui-part="form-collection"]Native fieldset rootSemantic theme and reflected-state destination.
[data-citry-ui-part="legend"]Native legendVisible collection name.
[data-citry-ui-part="description"]Optional paragraphCollection guidance.
[data-citry-ui-part="items"]Ordered listCurrent server Item order.
[data-citry-ui-part="item"]Grouped list itemStable repeated group.
[data-citry-ui-part="item-header"]HeaderGroups label and actions.
[data-citry-ui-part="item-label"]HeadingVisible Item group name.
[data-citry-ui-part="item-actions"]Action containerMove and Remove controls.
[data-citry-ui-part="item-content"]Content divActual repeated fields.
[data-citry-ui-part="add"]Native ButtonAdd request.

Interfaces

Aliases and data shapes referenced above.

Input type aliases

InterfaceDefinition
CFormCollectionSizeLiteral["sm", "md", "lg"]
CFormCollectionActionLiteral["add", "remove", "move-up", "move-down"]
CClassValuestr | Mapping[str, bool] | Sequence[CClassValue]
CStyleValuestr | Mapping[str, object] | Sequence[CStyleValue]

CFormCollectionDefaultSlotData

Empty dataclass: {}.

CFormCollectionItemSlotData

FieldTypeDefaultMeaning
valuestr-Stable Item identity.
labelstr-Plain application-owned Item label.
indexint-Zero-based current server index.
countint-Current rendered Item count.
is_firstbool-Whether this is the first Item.
is_lastbool-Whether this is the last Item.
disabledbool-Initial effective collection-action disabled state.

CFormCollectionActionDetail

FieldTypeDefaultMeaning
actionCFormCollectionAction (CFormCollectionAction)-Requested mutation.
valuestr | None-Item value or null for Add.
indexint | None-Current Item index or null for Add.
toIndexint | None-Requested adjacent destination or null.
sourceEventobject-Native click Event.

Translation keys

Catalog keys used by this family. An explicit component input or slot listed in Override takes precedence over the catalog for that instance.

CFormCollection translation keys

KeyPurposeVariablesOverrideBrowser updates
citry-ui-form-collection-addLabels the Add control.None.add_labelStable $c-tr text.
citry-ui-form-collection-removeNames an Item Remove control.item: strremove_label with {item}Stable reactive $c-tr attribute.
citry-ui-form-collection-move-upNames an Item Move up control.item: strmove_up_label with {item}Stable reactive $c-tr attribute.
citry-ui-form-collection-move-downNames an Item Move down control.item: strmove_down_label with {item}Stable reactive $c-tr attribute.