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.
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.
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.
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.
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.
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.
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(...).
| Input | Type | Default | Effect |
|---|---|---|---|
label | str | required | Supplies the visible native legend and collection name. |
id | str | None | generated | Sets the fieldset ID and bases stable Item label IDs. |
description | str | None | None | Adds plain described-by guidance below the legend. |
action_name | str | None | None | When supplied actions are named submit buttons; otherwise they are client-only Buttons. |
add_value | str | "add" | Sets the Add submit-button value. |
allow_add | bool | True | Includes the Add control. |
allow_remove | bool | True | Includes permitted Item Remove controls. |
allow_reorder | bool | True | Includes permitted Move controls. |
min_items | int | 0 | Rejects fewer rendered Items and disables Remove at the minimum. |
max_items | int | None | None | Rejects more rendered Items and disables Add at the maximum. |
disabled | bool | False | Disables collection mutation controls without disabling consumer fields. |
size | CFormCollectionSize (CFormCollectionSize) | "md" | Selects collection spacing density. |
add_label | str | "Add item" | Overrides the localized Add text. |
remove_label | str | "Remove {item}" | Overrides localized Remove names and must retain item. |
move_up_label | str | "Move {item} up" | Overrides localized Move up names and must retain item. |
move_down_label | str | "Move {item} down" | Overrides localized Move down names and must retain item. |
class_ | CClassValue | None (CClassValue) | None | Adds classes to the fieldset. |
style | CStyleValue | None (CStyleValue) | None | Adds styles to the fieldset. |
attrs | Mapping[str, object] | None | None | Adds copied allowed fieldset attributes. |
CFormCollection client inputs
Client inputs are passed in the browser through the $c-props="{ ... }" attribute on <c-CFormCollection />.
| Input | Type | Omitted behavior | Effect |
|---|---|---|---|
disabled | boolean | Uses the server value. | Reactively disables mutation controls. |
onAction | function | No 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(...).
| Input | Type | Default | Effect |
|---|---|---|---|
value | str | required | Supplies unique stable Item identity and default action-value suffix. |
label | str | required | Supplies the visible group heading and action-name interpolation. |
remove_value | str | None | generated | Overrides the default remove colon value action protocol. |
move_up_value | str | None | generated | Overrides the default move-up colon value action protocol. |
move_down_value | str | None | generated | Overrides the default move-down colon value action protocol. |
removable | bool | True | Includes this Item's Remove control when root removal is allowed. |
movable | bool | True | Includes this Item's Move controls when root reorder is allowed. |
disabled | bool | False | Disables this Item's collection actions only. |
class_ | CClassValue | None (CClassValue) | None | Adds classes to the Item group. |
style | CStyleValue | None (CStyleValue) | None | Adds styles to the Item group. |
attrs | Mapping[str, object] | None | None | Adds 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
| Slot | Required | Data | Fallback |
|---|---|---|---|
default | no | {} (CFormCollectionDefaultSlotData) | Empty collection. |
CFormCollectionItem slots
| Slot | Required | Data | Fallback |
|---|---|---|---|
default | yes | {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
| Event | Signature | Trigger and timing | Detail | Controlled 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.
| Variable | Type | Purpose | Default |
|---|---|---|---|
--cui-form-collection-gap | length | Space between Item groups and Add. | 0.875rem |
--cui-form-collection-item-surface | color | Item group surface. | Canvas |
--cui-form-collection-item-border | complete border | Item and header boundary. | Adaptive 1px neutral |
--cui-form-collection-item-radius | length | Item corners. | 0.75rem |
--cui-form-collection-action-gap | length | Gap between mutation controls. | 0.375rem |
--cui-form-collection-focus | color | Mutation-control focus ring. | Highlight |
--cui-form-collection-disabled-opacity | number | Disabled 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
| Attribute | Element | Type | Meaning |
|---|---|---|---|
data-count | Root | nonnegative integer string | Reflects rendered Item count. |
data-size | Root | CFormCollectionSize (CFormCollectionSize) | Reflects spacing density. |
data-disabled | Root and Item | present | absent | Reflects unavailable collection actions. |
data-first | Item | present | absent | Marks first current Item. |
data-last | Item | present | absent | Marks last current Item. |
data-value | Item | string | Exposes stable Item identity. |
Selectors
Selectors for the DOM nodes in the components that you can use for CSS, inspection, and testing.
CFormCollection selectors
| Selector | Element | Purpose |
|---|---|---|
[data-citry-ui-part="form-collection"] | Native fieldset root | Semantic theme and reflected-state destination. |
[data-citry-ui-part="legend"] | Native legend | Visible collection name. |
[data-citry-ui-part="description"] | Optional paragraph | Collection guidance. |
[data-citry-ui-part="items"] | Ordered list | Current server Item order. |
[data-citry-ui-part="item"] | Grouped list item | Stable repeated group. |
[data-citry-ui-part="item-header"] | Header | Groups label and actions. |
[data-citry-ui-part="item-label"] | Heading | Visible Item group name. |
[data-citry-ui-part="item-actions"] | Action container | Move and Remove controls. |
[data-citry-ui-part="item-content"] | Content div | Actual repeated fields. |
[data-citry-ui-part="add"] | Native Button | Add request. |
Interfaces
Aliases and data shapes referenced above.
Input type aliases
| Interface | Definition |
|---|---|
CFormCollectionSize | Literal["sm", "md", "lg"] |
CFormCollectionAction | Literal["add", "remove", "move-up", "move-down"] |
CClassValue | str | Mapping[str, bool] | Sequence[CClassValue] |
CStyleValue | str | Mapping[str, object] | Sequence[CStyleValue] |
CFormCollectionDefaultSlotData
Empty dataclass: {}.
CFormCollectionItemSlotData
| Field | Type | Default | Meaning |
|---|---|---|---|
value | str | - | Stable Item identity. |
label | str | - | Plain application-owned Item label. |
index | int | - | Zero-based current server index. |
count | int | - | Current rendered Item count. |
is_first | bool | - | Whether this is the first Item. |
is_last | bool | - | Whether this is the last Item. |
disabled | bool | - | Initial effective collection-action disabled state. |
CFormCollectionActionDetail
| Field | Type | Default | Meaning |
|---|---|---|---|
action | CFormCollectionAction (CFormCollectionAction) | - | Requested mutation. |
value | str | None | - | Item value or null for Add. |
index | int | None | - | Current Item index or null for Add. |
toIndex | int | None | - | Requested adjacent destination or null. |
sourceEvent | object | - | 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
| Key | Purpose | Variables | Override | Browser updates |
|---|---|---|---|---|
citry-ui-form-collection-add | Labels the Add control. | None. | add_label | Stable $c-tr text. |
citry-ui-form-collection-remove | Names an Item Remove control. | item: str | remove_label with {item} | Stable reactive $c-tr attribute. |
citry-ui-form-collection-move-up | Names an Item Move up control. | item: str | move_up_label with {item} | Stable reactive $c-tr attribute. |
citry-ui-form-collection-move-down | Names an Item Move down control. | item: str | move_down_label with {item} | Stable reactive $c-tr attribute. |