Form
Use CForm for native submission, validation, reset, and FormData. It renders one <form> and an internal <fieldset>, shares disabled and read-only defaults with supporting Citry UI controls, and can guard duplicate submits without removing successful controls from the payload.
Form at a glance
The Form owns coordination and layout. Field, Input, and Button keep their own visual treatment.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class FormAtAGlance(Component):
template = """
<section class="form-glance">
<article class="form-glance__card">
<header>
<p>Night observation</p>
<h2>Reserve telescope time</h2>
</header>
<c-CForm @submit.prevent="void 0">
<c-CField required>
<c-fill name="label">
Target name
</c-fill>
<c-fill name="default">
<c-CInput
name="target"
value="Andromeda Galaxy"
/>
</c-fill>
<c-fill name="description">
Use a catalog or common name.
</c-fill>
</c-CField>
<c-CButton type="submit">
Request a window
</c-CButton>
</c-CForm>
</article>
<article class="form-glance__card">
<header>
<p>Calibration queue</p>
<h2>Exposure sequence</h2>
</header>
<c-CForm submitting @submit.prevent="void 0">
<c-CField readonly>
<c-fill name="label">
Filter sequence
</c-fill>
<c-fill name="default">
<c-CInput
name="filters"
value="L ยท R ยท G ยท B"
/>
</c-fill>
</c-CField>
<c-CButton type="submit" loading>
Sending sequence
</c-CButton>
</c-CForm>
</article>
</section>
"""
css = """
:where(.form-glance) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
gap: 1rem;
max-width: 64rem;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.form-glance__card) {
min-width: 0;
padding: 1.25rem;
border: 1px solid light-dark(#c7c9e8, #45486f);
border-radius: 0.875rem;
background: Canvas;
box-shadow: 0 0.75rem 2rem rgb(15 23 42 / 10%);
}
:where(.form-glance__card header) {
margin-block-end: 1rem;
}
:where(.form-glance__card h2, .form-glance__card p) {
margin-block: 0;
}
:where(.form-glance__card header p) {
margin-block-end: 0.35rem;
color: light-dark(#5b4bc4, #a9a2ff);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
:where(.form-glance__card [data-citry-ui-part="button"]) {
justify-self: start;
}
"""
preview = FormAtAGlance()
preview # noqa: B018
Build a native Form
Set common native attributes directly on CForm. Named controls provide the submission data.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class ComposeForm(Component):
template = """
<section class="orbit-request" x-data="{ saved: '' }">
<header>
<p>Orbital survey</p>
<h2>Queue a tracking request</h2>
</header>
<c-CForm
id="orbit-request-form"
action="/tracking-requests"
method="post"
autocomplete="off"
@submit.prevent="saved = new FormData($el).get('object')"
>
<c-CField required>
<c-fill name="label">
Object designation
</c-fill>
<c-fill name="default">
<c-CInput
name="object"
value="2024 YR4"
/>
</c-fill>
</c-CField>
<c-CButton type="submit">
Queue tracking
</c-CButton>
</c-CForm>
<p aria-live="polite" x-show="saved">
Queued <strong x-text="saved"></strong>
</p>
</section>
"""
css = """
:where(.orbit-request) {
max-width: 38rem;
padding: 1.25rem;
border: 1px solid light-dark(#c7c9e8, #45486f);
border-radius: 0.875rem;
background: Canvas;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.orbit-request header) {
margin-block-end: 1rem;
}
:where(.orbit-request h2, .orbit-request p) {
margin-block: 0;
}
:where(.orbit-request header p) {
color: light-dark(#5b4bc4, #a9a2ff);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
:where(.orbit-request form > fieldset > [data-citry-ui-part="button"]) {
justify-self: start;
}
:where(.orbit-request > p) {
margin-block-start: 1rem;
color: light-dark(#175c43, #7be0b5);
}
"""
preview = ComposeForm()
preview # noqa: B018
<c-CForm
action="/tracking-requests"
method="post"
@submit="queueTracking($event)"
>
<c-CField required>
<c-fill name="label">
Object designation
</c-fill>
<c-fill name="default">
<c-CInput name="object" />
</c-fill>
</c-CField>
<c-CButton type="submit">
Queue tracking
</c-CButton>
</c-CForm>
Compose the same Form in Python:
from citry_ui import CForm
tracking_form = CForm(
action="/tracking-requests",
method="post",
slots={"default": fields},
)
Use method="post" and enctype="multipart/form-data" for file uploads. target, autocomplete, and novalidate map directly to their native Form attributes. method="dialog" retains native Dialog submission behavior.
Less-common native, ARIA, data-*, and Alpine attributes go through attrs. Common native attributes have direct inputs and cannot also be supplied through attrs. Prefer top-level class_ and style; class and style values retained in attrs merge with them.
Configure shared behavior
Server inputs are passed in Python through <c-CForm ... /> attributes or a CForm(...) composition call. Client inputs are passed in the browser through the $c-props="{...}" attribute.
Customize example
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class FormConfiguration(Component):
template = """
<section
class="form-configurator"
x-data="{
disabled: false,
readonly: false,
submitting: false,
gap: '1rem',
}"
@citry-ui-preview-controls.window="Object.assign($data, $event.detail)"
:style="{'--cui-form-gap': gap}"
>
<header>
<p>Deep-sky planner</p>
<h2>Configure Form state</h2>
</header>
<c-CForm
class_="form-configurator__form"
$c-props="{
disabled,
readonly,
submitting,
}"
@submit.prevent="void 0"
>
<c-CField>
<c-fill name="label">
Observation notes
</c-fill>
<c-fill name="default">
<c-CInput
name="notes"
value="Track the nebula after moonset."
/>
</c-fill>
</c-CField>
<c-CButton
type="submit"
>
Save plan
</c-CButton>
</c-CForm>
<p class="form-configurator__status" aria-live="polite">
<span
x-text="
disabled
? 'disabled'
: readonly
? 'read-only'
: submitting
? 'submitting'
: 'editable'
"
></span>
</p>
</section>
"""
css = """
:where(.form-configurator) {
max-width: 42rem;
padding: 1.25rem;
border: 1px solid light-dark(#c7c9e8, #45486f);
border-radius: 0.875rem;
background: Canvas;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.form-configurator header) {
margin-block-end: 1rem;
}
:where(.form-configurator h2, .form-configurator p) {
margin-block: 0;
}
:where(.form-configurator header p) {
color: light-dark(#5b4bc4, #a9a2ff);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
:where(.form-configurator__form [data-citry-ui-part="button"]) {
justify-self: start;
}
:where(.form-configurator__status) {
margin-block-start: 0.85rem;
color: color-mix(in srgb, currentColor 68%, transparent);
font-size: 0.8125rem;
}
"""
preview_controls = (
{
"name": "disabled",
"label": "Disable Form",
"type": "checkbox",
"default": False,
},
{
"name": "readonly",
"label": "Use read-only defaults",
"type": "checkbox",
"default": False,
},
{
"name": "submitting",
"label": "Show submitting state",
"type": "checkbox",
"default": False,
},
{
"name": "gap",
"label": "Content spacing",
"type": "select",
"default": "1rem",
"options": (("0.5rem", "Compact"), ("1rem", "Default"), ("1.5rem", "Spacious")),
},
)
preview = FormConfiguration()
preview # noqa: B018
<c-CForm
$c-props="{
disabled: accessClosed,
readonly: reviewMode,
submitting: requestPending,
}"
>
...
</c-CForm>
A valid client Boolean wins over its server input. Removing it restores the server value. Invalid client values report one diagnostic per invalid episode and use that field's server value.
disabled uses the internal native fieldset. Physical descendant controls are disabled and excluded from submission, even when a child requests disabled=False. readonly is a default for supporting Citry UI controls; ordinary native controls are unchanged. submitting affects only the Form's busy marker and submit guard, so controls stay focusable and successful.
Read native submission data
Handle the native submit event. Call preventDefault() only when browser code or Citry Events owns transport.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class NativeSubmission(Component):
template = """
<section
class="transient-report"
x-data="{ submitted: '', submitter: '' }"
>
<header>
<p>Transient watch</p>
<h2>Report a changing object</h2>
</header>
<c-CForm
action="/transients"
method="post"
@submit.prevent="
submitted = JSON.stringify(
Object.fromEntries(new FormData($el, $event.submitter))
);
submitter = $event.submitter?.value ?? '';
"
>
<c-CField required>
<c-fill name="label">
Object
</c-fill>
<c-fill name="default">
<c-CInput name="object" value="AT 2026lmn" />
</c-fill>
</c-CField>
<c-CField>
<c-fill name="label">
Brightness
</c-fill>
<c-fill name="default">
<c-CInput
name="magnitude"
value="17.4"
inputmode="decimal"
/>
</c-fill>
</c-CField>
<div class="transient-report__actions">
<c-CButton
type="submit"
c-attrs="{'name': 'intent', 'value': 'report'}"
>
Report object
</c-CButton>
<c-CButton
type="reset"
variant="ghost"
intent="neutral"
>
Reset
</c-CButton>
</div>
</c-CForm>
<output aria-live="polite" x-show="submitted">
Submitter: <strong x-text="submitter"></strong><br />
FormData: <code x-text="submitted"></code>
</output>
</section>
"""
css = """
:where(.transient-report) {
max-width: 42rem;
padding: 1.25rem;
border: 1px solid light-dark(#c7c9e8, #45486f);
border-radius: 0.875rem;
background: Canvas;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.transient-report header) {
margin-block-end: 1rem;
}
:where(.transient-report h2, .transient-report p) {
margin-block: 0;
}
:where(.transient-report header p) {
color: light-dark(#5b4bc4, #a9a2ff);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
:where(.transient-report__actions) {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
}
:where(.transient-report output) {
display: block;
margin-block-start: 1rem;
padding: 0.75rem;
border-radius: 0.5rem;
background: light-dark(#f3f1ff, #25243d);
overflow-wrap: anywhere;
}
"""
preview = NativeSubmission()
preview # noqa: B018
const data = new FormData(event.currentTarget, event.submitter)
const submitter = event.submitter
Submit, reset, Enter submission, constraint validation, successful-control rules, and submitter selection remain browser-native. requestSubmit() follows validation and dispatches submit. Direct form.submit() bypasses both.
Controls named submit, reset, or another Form property can shadow that property. Choose a different name or call the method from HTMLFormElement's prototype.
Use browser validation
Put native constraints on controls. The browser owns complete Form validity, invalid focus, and submission blocking.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class NativeValidation(Component):
template = """
<section
class="instrument-booking"
x-data="{ accepted: false }"
>
<header>
<p>Instrument desk</p>
<h2>Book the spectrograph</h2>
</header>
<c-CForm
@submit.prevent="accepted = true"
@input="accepted = false"
>
<c-CField required>
<c-fill name="label">
Contact email
</c-fill>
<c-fill name="default">
<c-CInput
name="email"
type="email"
placeholder="observer@example.org"
/>
</c-fill>
<c-fill name="description">
The browser checks the address before submission.
</c-fill>
</c-CField>
<c-CField required>
<c-fill name="label">
Observation date
</c-fill>
<c-fill name="default" data="{ control_attrs }">
<input
class="instrument-booking__date"
type="date"
name="date"
c-bind="control_attrs"
/>
</c-fill>
</c-CField>
<c-CButton type="submit">
Check availability
</c-CButton>
</c-CForm>
<p
class="instrument-booking__success"
aria-live="polite"
x-show="accepted"
>
The request is ready to send.
</p>
</section>
"""
css = """
:where(.instrument-booking) {
max-width: 42rem;
padding: 1.25rem;
border: 1px solid light-dark(#c7c9e8, #45486f);
border-radius: 0.875rem;
background: Canvas;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.instrument-booking header) {
margin-block-end: 1rem;
}
:where(.instrument-booking h2, .instrument-booking p) {
margin-block: 0;
}
:where(.instrument-booking header p) {
color: light-dark(#5b4bc4, #a9a2ff);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
:where(.instrument-booking__date) {
inline-size: 100%;
box-sizing: border-box;
padding: 0.625rem 0.75rem;
border: 1px solid light-dark(#9498bd, #686c96);
border-radius: 0.5rem;
background: Canvas;
color: CanvasText;
font: inherit;
}
:where(.instrument-booking form[data-validation-attempted] :invalid) {
border-color: light-dark(#b42318, #ff8a80);
}
:where(.instrument-booking [data-citry-ui-part="button"]) {
justify-self: start;
}
:where(.instrument-booking__success) {
margin-block-start: 1rem;
color: light-dark(#175c43, #7be0b5);
}
"""
preview = NativeValidation()
preview # noqa: B018
After a physical descendant dispatches invalid, CForm exposes data-validation-attempted for application styling. This includes invalid events caused by checkValidity() or reportValidity().
CForm does not expose a parallel validity callback or valid attribute. Native controls, external form=id controls, third-party controls, and programmatic changes must all agree on whether the Form can submit; the browser is the one complete authority.
Server validation remains authoritative. Error text does not change native validity by itself. Use native constraints or setCustomValidity() when a server condition must block a later native submission.
Reset values
A native reset Button restores each control's authored default. CForm clears data-validation-attempted only after the reset event finishes uncanceled.
Customize example
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class FormReset(Component):
template = """
<section
class="exposure-reset"
x-data="{ cancel_reset: false, status: 'Edit either value, then reset.' }"
@citry-ui-preview-controls.window="Object.assign($data, $event.detail)"
>
<header>
<p>Exposure plan</p>
<h2>Restore authored defaults</h2>
</header>
<c-CForm
@submit.prevent="void 0"
@reset="
if (cancel_reset) {
$event.preventDefault();
status = 'Reset canceled; edits preserved.';
} else {
setTimeout(() => status = 'Defaults restored.', 0);
}
"
>
<c-CField>
<c-fill name="label">
Exposure
</c-fill>
<c-fill name="default">
<c-CInput name="exposure" value="120 seconds" />
</c-fill>
</c-CField>
<c-CField>
<c-fill name="label">
Frames
</c-fill>
<c-fill name="default">
<c-CInput
name="frames"
value="24"
inputmode="numeric"
/>
</c-fill>
</c-CField>
<c-CButton
type="reset"
variant="outline"
intent="neutral"
>
Restore defaults
</c-CButton>
</c-CForm>
<p
class="exposure-reset__status"
aria-live="polite"
x-text="status"
></p>
</section>
"""
css = """
:where(.exposure-reset) {
max-width: 42rem;
padding: 1.25rem;
border: 1px solid light-dark(#c7c9e8, #45486f);
border-radius: 0.875rem;
background: Canvas;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.exposure-reset header) {
margin-block-end: 1rem;
}
:where(.exposure-reset h2, .exposure-reset p) {
margin-block: 0;
}
:where(.exposure-reset header p) {
color: light-dark(#5b4bc4, #a9a2ff);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
:where(.exposure-reset [data-citry-ui-part="button"]) {
justify-self: start;
}
:where(.exposure-reset__status) {
margin-block-start: 1rem;
color: color-mix(in srgb, currentColor 68%, transparent);
}
"""
preview_controls = (
{
"name": "cancel_reset",
"label": "Cancel the reset event",
"type": "checkbox",
"default": False,
},
)
preview = FormReset()
preview # noqa: B018
If any reset listener calls preventDefault(), values and the attempted marker remain unchanged. Application-owned server errors, dirty state, or request state are separate and must be reset by their owner.
Guard duplicate submission
Set the client submitting input after accepting the first submit. Later submit events are canceled at CForm's capture listener while the value remains true.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class SubmittingGuard(Component):
template = """
<section
class="plate-solve"
x-data="{ submitting: false, attempts: 0, snapshot: '' }"
>
<header>
<p>Astrometry pipeline</p>
<h2>Solve a star field</h2>
</header>
<c-CForm
$c-props="{ submitting }"
@submit.prevent="
attempts += 1;
snapshot = JSON.stringify(Object.fromEntries(new FormData($el)));
submitting = true;
"
>
<c-CField>
<c-fill name="label">
Frame ID
</c-fill>
<c-fill name="default">
<c-CInput name="frame" value="M42-L-0084" />
</c-fill>
</c-CField>
<div class="plate-solve__actions">
<c-CButton
type="submit"
$c-props="{ loading: submitting }"
>
Solve frame
</c-CButton>
<c-CButton
type="button"
variant="outline"
intent="neutral"
@click="submitting = false"
>
Finish request
</c-CButton>
</div>
</c-CForm>
<p class="plate-solve__status" aria-live="polite">
Accepted submits: <strong x-text="attempts"></strong>
<span x-show="snapshot"> ยท FormData <code x-text="snapshot"></code></span>
</p>
</section>
"""
css = """
:where(.plate-solve) {
max-width: 44rem;
padding: 1.25rem;
border: 1px solid light-dark(#c7c9e8, #45486f);
border-radius: 0.875rem;
background: Canvas;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.plate-solve header) {
margin-block-end: 1rem;
}
:where(.plate-solve h2, .plate-solve p) {
margin-block: 0;
}
:where(.plate-solve header p) {
color: light-dark(#5b4bc4, #a9a2ff);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
:where(.plate-solve__actions) {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
}
:where(.plate-solve__status) {
margin-block-start: 1rem;
overflow-wrap: anywhere;
color: color-mix(in srgb, currentColor 72%, transparent);
}
"""
preview = SubmittingGuard()
preview # noqa: B018
The first event already passed the guard and reaches the application handler. Submitting does not disable controls, so FormData retains their values. The application owns clearing the value after success or failure.
This is a client-side duplicate guard, not request idempotency. Earlier ancestor capture listeners, same-node capture listeners registered first, and direct form.submit() can still observe or bypass it.
Use multiple submitters
Native submitter attributes let one Form expose different actions without a component-specific callback.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class MultipleSubmitters(Component):
template = """
<section
class="observation-draft"
x-data="{ status: 'Choose how to save the observation.' }"
>
<header>
<p>Observation log</p>
<h2>Save a lunar transit</h2>
</header>
<c-CForm
action="/observations"
method="post"
@submit.prevent="
status = $event.submitter.value
+ ' via '
+ ($event.submitter.formMethod || $event.currentTarget.method).toUpperCase()
+ ' to '
+ ($event.submitter.formAction || $event.currentTarget.action)
"
>
<c-CField required>
<c-fill name="label">
Summary
</c-fill>
<c-fill name="default">
<c-CInput name="summary" value="Io crossed Jupiter at 02:14 UTC" />
</c-fill>
</c-CField>
<div class="observation-draft__actions">
<c-CButton
type="submit"
variant="outline"
intent="neutral"
c-attrs="{
'name': 'intent',
'value': 'draft',
'formaction': '/observations/drafts',
'formnovalidate': True,
}"
>
Save draft
</c-CButton>
<c-CButton
type="submit"
c-attrs="{
'name': 'intent',
'value': 'publish',
'formaction': '/observations/publish',
'formmethod': 'post',
}"
>
Publish
</c-CButton>
</div>
</c-CForm>
<p
class="observation-draft__status"
aria-live="polite"
x-text="status"
></p>
</section>
"""
css = """
:where(.observation-draft) {
max-width: 46rem;
padding: 1.25rem;
border: 1px solid light-dark(#c7c9e8, #45486f);
border-radius: 0.875rem;
background: Canvas;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.observation-draft header) {
margin-block-end: 1rem;
}
:where(.observation-draft h2, .observation-draft p) {
margin-block: 0;
}
:where(.observation-draft header p) {
color: light-dark(#5b4bc4, #a9a2ff);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
:where(.observation-draft__actions) {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
}
:where(.observation-draft__status) {
margin-block-start: 1rem;
color: light-dark(#175c43, #7be0b5);
}
"""
preview = MultipleSubmitters()
preview # noqa: B018
Pass name, value, formaction, formenctype, formmethod, formnovalidate, and formtarget through each action CButton's server attrs. Read the accepted control from SubmitEvent.submitter.
Associate an external control
Give CForm a unique id, then set a standalone native control's form attribute to that ID. The browser includes it in form.elements, validation, reset, submission, and FormData.
Customize example
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class ExternalControls(Component):
template = """
<section
class="proposal-form"
x-data="{ disabled: false, result: '' }"
@citry-ui-preview-controls.window="Object.assign($data, $event.detail)"
>
<header>
<p>Time allocation</p>
<h2>Submit a telescope proposal</h2>
</header>
<c-CForm
id="proposal-form"
$c-props="{ disabled }"
@submit.prevent="
result = JSON.stringify(
Object.fromEntries(new FormData($el, $event.submitter))
)
"
>
<c-CField required>
<c-fill name="label">
Proposal title
</c-fill>
<c-fill name="default">
<c-CInput name="title" value="Atmospheres of nearby super-Earths" />
</c-fill>
</c-CField>
<c-CButton type="submit">
Submit proposal
</c-CButton>
</c-CForm>
<div class="proposal-form__external">
<label for="allocation-code">External allocation code</label>
<c-CInput
id="allocation-code"
name="allocation"
value="Q4-NORTH"
c-attrs="{'form': 'proposal-form'}"
/>
<small>Owned by the Form, but outside its disabled fieldset.</small>
<c-CButton
type="submit"
variant="outline"
intent="neutral"
c-attrs="{
'form': 'proposal-form',
'name': 'intent',
'value': 'external',
}"
>
Submit from outside
</c-CButton>
</div>
<output
aria-live="polite"
x-show="result"
x-text="result"
></output>
</section>
"""
css = """
:where(.proposal-form) {
display: grid;
gap: 1rem;
max-width: 46rem;
padding: 1.25rem;
border: 1px solid light-dark(#c7c9e8, #45486f);
border-radius: 0.875rem;
background: Canvas;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.proposal-form h2, .proposal-form p) {
margin-block: 0;
}
:where(.proposal-form header p) {
color: light-dark(#5b4bc4, #a9a2ff);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
:where(.proposal-form [data-citry-ui-part="button"]) {
justify-self: start;
}
:where(.proposal-form__external) {
display: grid;
gap: 0.4rem;
padding: 0.875rem;
border-inline-start: 0.25rem solid light-dark(#6d5bd0, #a9a2ff);
background: light-dark(#f7f6ff, #24233b);
}
:where(.proposal-form__external label) {
font-weight: 650;
}
:where(.proposal-form__external small) {
color: color-mix(in srgb, currentColor 68%, transparent);
}
:where(.proposal-form__external [data-citry-ui-part="button"]) {
justify-self: start;
}
:where(.proposal-form output) {
overflow-wrap: anywhere;
}
"""
preview_controls = (
{
"name": "disabled",
"label": "Disable internal controls",
"type": "checkbox",
"default": False,
},
)
preview = ExternalControls()
preview # noqa: B018
An external control is not a physical descendant of CForm's fieldset, so Form disabled does not disable it and its non-bubbling invalid event does not set CForm's attempted marker. Standalone CInput can receive form through attrs. Compound controls such as CCombobox reject external redirection until their visible validation and submitted-value elements can be associated together.
Show server errors
Render server feedback through Field. The application decides when a message clears and whether it also sets custom native validity.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class ServerErrors(Component):
template = """
<section
class="account-request"
x-data="{
error: 'That observer handle is already registered.',
}"
>
<header>
<p>Observer network</p>
<h2>Request an observatory account</h2>
</header>
<c-CForm @submit.prevent="void 0">
<c-CField
$c-props="{
invalid: Boolean(
Alpine.$data($root.closest('.account-request')).error
),
}"
>
<c-fill name="label">
Observer handle
</c-fill>
<c-fill name="default">
<c-CInput
name="handle"
value="night-heron"
@input="Alpine.$data($root.closest('.account-request')).error = ''"
/>
</c-fill>
<c-fill name="error">
<span
x-text="Alpine.$data($root.closest('.account-request')).error"
></span>
</c-fill>
</c-CField>
<c-CButton type="submit">
Request account
</c-CButton>
</c-CForm>
<p class="account-request__hint">
The application clears this server message when the rejected field changes.
</p>
</section>
"""
css = """
:where(.account-request) {
max-width: 42rem;
padding: 1.25rem;
border: 1px solid light-dark(#c7c9e8, #45486f);
border-radius: 0.875rem;
background: Canvas;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.account-request header) {
margin-block-end: 1rem;
}
:where(.account-request h2, .account-request p) {
margin-block: 0;
}
:where(.account-request header p) {
color: light-dark(#5b4bc4, #a9a2ff);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
:where(.account-request [data-citry-ui-part="button"]) {
justify-self: start;
}
:where(.account-request__hint) {
margin-block-start: 1rem;
color: color-mix(in srgb, currentColor 68%, transparent);
font-size: 0.875rem;
}
"""
preview = ServerErrors()
preview # noqa: B018
CForm does not own an error map, schema, touched state, or validation rules. Those concerns can compose around the native Form without changing its browser contract.
Add and reorder controls
Use stable application keys when controls are repeated. The browser's live form.elements and FormData define current membership and document order.
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class DynamicFields(Component):
template = """
<section
class="filter-sequence"
x-data="{
rows: [
{ id: 1, value: 'Luminance' },
{ id: 2, value: 'Hydrogen-alpha' },
{ id: 3, value: 'Oxygen III' },
],
nextId: 4,
result: '',
}"
>
<header>
<p>Filter wheel</p>
<h2>Build an exposure sequence</h2>
</header>
<c-CForm @submit.prevent="result = JSON.stringify(new FormData($el).getAll('filter'))">
<div class="filter-sequence__rows">
<template x-for="(row, index) in rows" :key="row.id">
<div class="filter-sequence__row">
<label
:for="`filter-${row.id}`"
x-text="`Exposure ${index + 1}`"
></label>
<input
:id="`filter-${row.id}`"
name="filter"
x-model="row.value"
/>
<button type="button" @click="rows.splice(index, 1)">Remove</button>
</div>
</template>
</div>
<div class="filter-sequence__actions">
<c-CButton
type="button"
variant="outline"
intent="neutral"
@click="rows.push({ id: nextId++, value: 'New filter' })"
>
Add exposure
</c-CButton>
<c-CButton
type="button"
variant="ghost"
intent="neutral"
@click="rows.length > 1 && rows.unshift(rows.pop())"
>
Rotate order
</c-CButton>
<c-CButton type="submit">
Read FormData
</c-CButton>
</div>
</c-CForm>
<output
aria-live="polite"
x-show="result"
x-text="result"
></output>
</section>
"""
css = """
:where(.filter-sequence) {
max-width: 48rem;
padding: 1.25rem;
border: 1px solid light-dark(#c7c9e8, #45486f);
border-radius: 0.875rem;
background: Canvas;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.filter-sequence header) {
margin-block-end: 1rem;
}
:where(.filter-sequence h2, .filter-sequence p) {
margin-block: 0;
}
:where(.filter-sequence header p) {
color: light-dark(#5b4bc4, #a9a2ff);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
:where(.filter-sequence__rows) {
display: grid;
gap: 0.625rem;
}
:where(.filter-sequence__row) {
display: grid;
grid-template-columns: minmax(6rem, 0.45fr) minmax(0, 1fr) auto;
align-items: center;
gap: 0.625rem;
}
:where(.filter-sequence__row input) {
min-width: 0;
padding: 0.55rem 0.7rem;
border: 1px solid light-dark(#9498bd, #686c96);
border-radius: 0.45rem;
background: Canvas;
color: CanvasText;
font: inherit;
}
:where(.filter-sequence__row button) {
border: 0;
background: transparent;
color: light-dark(#9b2c24, #ff9d94);
cursor: pointer;
}
:where(.filter-sequence__actions) {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
}
:where(.filter-sequence output) {
display: block;
margin-block-start: 1rem;
overflow-wrap: anywhere;
}
@media (max-width: 34rem) {
:where(.filter-sequence__row) {
grid-template-columns: minmax(0, 1fr) auto;
}
:where(.filter-sequence__row label) {
grid-column: 1 / -1;
}
}
"""
preview = DynamicFields()
preview # noqa: B018
CForm stores no participant registry, so removed controls cannot remain in a parallel validity or submission list. Native repeated, bracketed, and dotted names serialize exactly as authored; server code owns higher-level parsing.
Theme and customize Form
CForm inherits typography, color, and color-scheme. Set --cui-form-gap on an ancestor or Form root to change spacing between direct children. Use the public Form and fieldset part selectors for targeted layout.
Customize example
Show code
import citry_ui
from citry import Component, citry
citry.register_library(citry_ui)
class FormThemeCustomization(Component):
template = """
<section
class="night-checklist"
x-data="{ gap: '0.75rem', scheme: 'light', compact: false }"
@citry-ui-preview-controls.window="Object.assign($data, $event.detail)"
:style="{ colorScheme: scheme, '--cui-form-gap': gap }"
:data-compact="compact"
>
<header>
<p>Night checklist</p>
<h2>Prepare the observatory</h2>
</header>
<c-CForm
class_="night-checklist__form"
@submit.prevent="void 0"
>
<label>
<input
type="checkbox"
name="task"
value="dome"
/>
Open the dome shutters
</label>
<label>
<input
type="checkbox"
name="task"
value="cooling"
/>
Start detector cooling
</label>
<label>
<input
type="checkbox"
name="task"
value="weather"
/>
Confirm weather limits
</label>
<c-CButton type="submit" size="sm">
Begin session
</c-CButton>
</c-CForm>
</section>
"""
css = """
:where(.night-checklist) {
max-width: 40rem;
padding: 1.25rem;
border: 1px solid light-dark(#c7c9e8, #45486f);
border-radius: 0.875rem;
background: Canvas;
color: CanvasText;
font-family: ui-sans-serif, system-ui, sans-serif;
}
:where(.night-checklist[data-compact]) {
max-width: 28rem;
}
:where(.night-checklist header) {
margin-block-end: 1rem;
}
:where(.night-checklist h2, .night-checklist p) {
margin-block: 0;
}
:where(.night-checklist header p) {
color: light-dark(#5b4bc4, #a9a2ff);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.07em;
text-transform: uppercase;
}
:where(.night-checklist__form[data-citry-ui-part="form"]) {
padding: 1rem;
border: 1px solid light-dark(#d7d8ea, #3c3f63);
border-radius: 0.625rem;
background: light-dark(#fafaff, #1c1c2d);
}
:where(.night-checklist__form [data-citry-ui-part="fieldset"]) {
align-items: start;
}
:where(.night-checklist__form label) {
display: flex;
align-items: center;
gap: 0.5rem;
}
:where(.night-checklist__form [data-citry-ui-part="button"]) {
justify-self: start;
}
"""
preview_controls = (
{
"name": "gap",
"label": "Form spacing",
"type": "select",
"default": "0.75rem",
"options": (("0.4rem", "Tight"), ("0.75rem", "Default"), ("1.25rem", "Open")),
},
{
"name": "scheme",
"label": "Color scheme",
"type": "select",
"default": "light",
"options": (("light", "Light"), ("dark", "Dark")),
},
{
"name": "compact",
"label": "Narrow container",
"type": "checkbox",
"default": False,
},
)
preview = FormThemeCustomization()
preview # noqa: B018
.compact-observation {
--cui-form-gap: 0.625rem;
}
.compact-observation [data-citry-ui-part="fieldset"] {
align-items: start;
}
The documented variable, parts, and reflected attributes are public CSS API. .cui-* classes and --_cui-* variables are private.
Accessibility and native boundaries
CForm adds no role. The native Form supplies submission, validation, reset, keyboard, focus, autofill, and assistive-technology behavior. Keep source order aligned with visual order and use a visible heading or aria-labelledby when the surrounding page needs an accessible Form name.
The internal fieldset begins with a private hidden legend. It reserves HTML's first-legend disabled exemption so user controls cannot accidentally remain enabled. Put visible group legends inside their own nested fieldsets; do not place a direct legend in CForm's default slot.
Controls outside the Form may associate through form=id, but they do not inherit the physical fieldset's disabled behavior. A Form inside CDialog may use method="dialog"; never nest one native Form inside another.
API reference
Inputs
CForm server inputs
Server inputs are passed in a template through <c-CForm ... /> or in Python through CForm(...).
| Input | Type | Default | Effect |
|---|---|---|---|
id | str | None | generated | Sets unique native Form identity and an external control ownership target. |
action | str | None | None | Sets the native submission destination; omission preserves browser current-URL behavior. |
method | "get" | "post" | "dialog" | None (CFormMethod) | None | Sets the native submission method; omission uses the browser default. |
enctype | "application/x-www-form-urlencoded" | "multipart/form-data" | "text/plain" | None (CFormEnctype) | None | Sets native submission encoding. |
target | str | None | None | Sets the native browsing-context target. |
autocomplete | "on" | "off" | None (CFormAutocomplete) | None | Sets the native Form autocomplete hint. |
disabled | bool | False | Disables physical descendant controls through the internal fieldset and supplies inherited Citry disabled state. |
readonly | bool | False | Supplies a read-only default to supporting Citry controls; ordinary native controls are unaffected. |
submitting | bool | False | Exposes Form busy state and stops later submit handlers reached after CForm's capture listener; it is not inherited by descendants. |
novalidate | bool | False | Maps to native novalidate. |
class_ | str | Mapping[str, bool] | Sequence[CClassValue] | None (CClassValue) | None | Adds native Form classes from a string, conditional mapping, or nested sequence and merges them with attrs. |
style | str | Mapping[str, str | int | float | bool | None] | Sequence[CStyleValue] | None (CStyleValue) | None | Adds native Form inline styles from CSS text, a property mapping, or nested sequence and merges them with attrs. |
attrs | Mapping[str, object] | None | None | Adds allowed less-common native Form, ARIA, Alpine, and data attributes; direct native inputs, public parts, and reflected attributes cannot also be supplied here. Class and style values merge with the top-level inputs. |
CForm client inputs
Client inputs are passed in the browser through the $c-props="{ ... }" attribute on <c-CForm />.
| Input | Type | Omitted behavior | Effect |
|---|---|---|---|
disabled | boolean | Uses the server input. | Controls the fieldset, inherited Citry disabled state, and data-disabled. |
readonly | boolean | Uses the server input. | Controls inherited Citry read-only state and data-readonly. |
submitting | boolean | Uses the server input. | Controls Form busy state, its submit guard, and data-submitting; descendants remain enabled unless disabled separately. |
Slots
Slots are passed as nested content or <c-fill> tags in a template, or through the slots={...} argument in Python.
CForm slots
| Slot | Required | Data | Fallback |
|---|---|---|---|
default | yes | {} (CFormDefaultSlotData) | none |
Events
-
Methods
-
CSS
CSS variables to theme the components. Set them on an ancestor or the component itself.
CForm CSS variables
Apply these variables to CForm or one of its ancestors.
| Variable | Type | Purpose | Default |
|---|---|---|---|
--cui-form-gap | length | Spacing between direct children of the internal fieldset. | 1rem |
Attributes
HTML attributes defined on the components that you can refer to for CSS, inspection, and testing. Read-only.
CForm attributes
| Attribute | Element | Type | Meaning |
|---|---|---|---|
data-disabled | Native Form | present | absent | Mirrors effective disabled state. |
data-readonly | Native Form | present | absent | Mirrors effective read-only state. |
data-submitting | Native Form | present | absent | Mirrors effective submitting state. |
data-validation-attempted | Native Form | present | absent | Appears after a physical descendant dispatches native invalid, including through validation methods, and clears after an uncanceled reset. |
Selectors
Selectors for the DOM nodes in the components that you can use for CSS, inspection, and testing.
CForm selectors
| Selector | Element | Purpose |
|---|---|---|
[data-citry-ui-part="form"] | Native Form | Root and attrs destination. |
[data-citry-ui-part="fieldset"] | Native fieldset | Native disabled-group and direct-child layout boundary. |
Interfaces
Aliases and data shapes referenced above.
Input type aliases
| Interface | Definition |
|---|---|
CClassValue | str | Mapping[str, bool] | Sequence[CClassValue] |
CStyleValue | str | Mapping[str, str | int | float | bool | None] | Sequence[CStyleValue] |
CFormMethod | Literal["get", "post", "dialog"] |
CFormEnctype | Literal["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"] |
CFormAutocomplete | Literal["on", "off"] |
CFormDefaultSlotData
Empty dataclass: {}.
Translation keys
-