Theme
Version
GitHub PyPI Discord
On this page

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.

Form at a glance
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.

Build a Form
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.

Configure Form
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.

Read native FormData
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.

Use native validation
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.

Reset or cancel reset
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.

Guard duplicate submission
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.

Use multiple submitters
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.

Associate an external control
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 a server error
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.

Change repeated controls
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.

Theme Form
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(...).

InputTypeDefaultEffect
idstr | NonegeneratedSets unique native Form identity and an external control ownership target.
actionstr | NoneNoneSets the native submission destination; omission preserves browser current-URL behavior.
method"get" | "post" | "dialog" | None (CFormMethod)NoneSets the native submission method; omission uses the browser default.
enctype"application/x-www-form-urlencoded" | "multipart/form-data" | "text/plain" | None (CFormEnctype)NoneSets native submission encoding.
targetstr | NoneNoneSets the native browsing-context target.
autocomplete"on" | "off" | None (CFormAutocomplete)NoneSets the native Form autocomplete hint.
disabledboolFalseDisables physical descendant controls through the internal fieldset and supplies inherited Citry disabled state.
readonlyboolFalseSupplies a read-only default to supporting Citry controls; ordinary native controls are unaffected.
submittingboolFalseExposes Form busy state and stops later submit handlers reached after CForm's capture listener; it is not inherited by descendants.
novalidateboolFalseMaps to native novalidate.
class_str | Mapping[str, bool] | Sequence[CClassValue] | None (CClassValue)NoneAdds native Form classes from a string, conditional mapping, or nested sequence and merges them with attrs.
stylestr | Mapping[str, str | int | float | bool | None] | Sequence[CStyleValue] | None (CStyleValue)NoneAdds native Form inline styles from CSS text, a property mapping, or nested sequence and merges them with attrs.
attrsMapping[str, object] | NoneNoneAdds 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 />.

InputTypeOmitted behaviorEffect
disabledbooleanUses the server input.Controls the fieldset, inherited Citry disabled state, and data-disabled.
readonlybooleanUses the server input.Controls inherited Citry read-only state and data-readonly.
submittingbooleanUses 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

SlotRequiredDataFallback
defaultyes{} (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.

VariableTypePurposeDefault
--cui-form-gaplengthSpacing 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

AttributeElementTypeMeaning
data-disabledNative Formpresent | absentMirrors effective disabled state.
data-readonlyNative Formpresent | absentMirrors effective read-only state.
data-submittingNative Formpresent | absentMirrors effective submitting state.
data-validation-attemptedNative Formpresent | absentAppears 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

SelectorElementPurpose
[data-citry-ui-part="form"]Native FormRoot and attrs destination.
[data-citry-ui-part="fieldset"]Native fieldsetNative disabled-group and direct-child layout boundary.

Interfaces

Aliases and data shapes referenced above.

Input type aliases

InterfaceDefinition
CClassValuestr | Mapping[str, bool] | Sequence[CClassValue]
CStyleValuestr | Mapping[str, str | int | float | bool | None] | Sequence[CStyleValue]
CFormMethodLiteral["get", "post", "dialog"]
CFormEnctypeLiteral["application/x-www-form-urlencoded", "multipart/form-data", "text/plain"]
CFormAutocompleteLiteral["on", "off"]

CFormDefaultSlotData

Empty dataclass: {}.

Translation keys

-