Skip to content

Native HTML forms, progressively enhanced.

Combat UI keeps real <form>, <input>, <select>, and <textarea> elements in light DOM. CSS classes style them; cui-field wires labels, help, errors, and ARIA; cui-form orchestrates validation and submit. Strings — labels, help, error messages — stay yours and drop in as slots.

Quick start

Put a native <form> inside <cui-form>, wrap each control in <cui-field>, and style controls with the .cui-input family. The cui-field wrapper handles ARIA wiring, :user-invalid styling, and error rendering. cui-form blocks submission until validation passes and exposes the FormData to your handler.

We never share your address. Please enter your email. That doesn't look like a valid email address. At least 8 characters.

Form classes

The .cui-input family styles native <input>, <select>, and <textarea> elements. They work standalone — no JavaScript required — and integrate with cui-field for validity affordances. CSS-only forms still get correct :user-invalid styling.

Notifications
Plan
Shipping address

cui-field

cui-field wraps a single native control and owns the surrounding affordances: a clickable label, descriptive help, an ARIA-live error region, aria-describedby, aria-invalid, and aria-required. It listens for blur and invalid and runs the validation pipeline against the slotted control.

Errors only render after the field is touched — a blur, an invalid event, or a form submission attempt. That matches the platform's :user-invalid behavior and avoids shouting at people mid-typing.

Slot Purpose
(default) A single <input>, <select>, or <textarea>.
label Wrapped in a shadow <label for="…">; clicking focuses the control.
help Descriptive text. ID is auto-generated and referenced from aria-describedby.
error-valueMissing · error-typeMismatch · error-patternMismatch · error-tooShort · error-tooLong · error-rangeUnderflow · error-rangeOverflow · error-stepMismatch · error-badInput · error-customError One pre-localized message per ValidityState flag. Wins over the generic slot when its key matches.
error Generic error display. Fill its content from a cui-field-invalid listener.
Attribute / property Purpose
required Forwards required + aria-required to the slotted control.
data-invalid Set automatically when an error is shown. CSS hook.
field.control Reference to the slotted control element.
field.name The control's name attribute.
field.addValidator(fn) Register a sync or async validator. Returns an unsubscribe function. Signature: (value, control) => string | null | Promise<string|null>.
field.setError(msg) Show a server- or otherwise-sourced error string. Pass null to clear.
field.markTouched() Force the touched state so errors render immediately.
field.reset() Clears the touched state and any custom validity.
field.validate() Returns Promise<boolean>. Runs native checks then registered validators.
Event Detail
cui-field-invalid { key, message, params: { min, max, type }, validity, control }. Use to render translated strings into slot="error".
cui-field-valid { control }. Fired when an invalid field becomes valid.

cui-form

cui-form wraps a native <form> and intercepts its submit event. On submit it marks every contained cui-field as touched, runs field.validate() in parallel, focuses the first invalid one if any fail, otherwise dispatches a cancelable cui-form-submit event and awaits the registered submit handler with the busy attribute toggled.

Attribute / property Purpose
busy Set while the submit handler is awaited. Dims pointer events.
form.setSubmitHandler(fn) Register an async handler: (data, form) => void | Promise<void>.
form.validate() Promise<boolean>. Runs every field's validate in parallel.
form.reset() Resets the native form and clears each field's error state.
form.fields() Returns the contained cui-field elements.
form.formElement() Returns the contained native <form>.
Event Detail / behavior
cui-form-submit Cancelable. { data, form }. Call preventDefault() to skip the registered handler.
cui-form-success Fired after the handler resolves. { data, form }.
cui-form-error Fired if the handler throws. { error, form }.
cui-form-invalid Fired when validation blocks submission.

Validation pipeline

Three layers run, in order, per field:

  1. Native constraintsrequired, type=email|url|number…, pattern, min / max, minlength / maxlength, step. Surfaced via HTMLInputElement.validity.
  2. Custom validators — anything registered with field.addValidator(fn). Run sequentially after native checks pass. The first one to return a non-null string fails the field and surfaces that string via the platform's customError flag.
  3. External errorsfield.setError("…") for server-side or otherwise externally sourced errors. field.setError(null) clears.
3–16 lowercase letters, digits, or underscores. Try admin to trigger the custom rule.

Errors & i18n via slots

Combat UI does not ship an i18n provider. Strings stay with your i18n framework. cui-field resolves the visible message in this order:

  1. A per-key error slot matching the failing ValidityState flag — slot="error-valueMissing", slot="error-typeMismatch", etc.
  2. A generic slot="error", which you fill yourself from a cui-field-invalid listener — perfect for plugging into i18next, FormatJS, or any translation function you already have.
  3. Browser fallback — when nothing is slotted, the field renders control.validationMessage, which the browser already localizes to the user's UA locale.
Please enter your email. That doesn't look like a valid email address.

Pre-localized strings. No JavaScript wiring required. Best when your i18n framework renders strings at build or server-render time.

A single slot="error" + an event listener. Plug in any translation function. Switching locale calls field.validate() to refresh the visible message immediately.

No slots, no JavaScript. The browser's localized validationMessage is rendered into the field's internal error region. The cheapest path to an accessible form.

Async & server errors

Custom validators may return a Promise<string | null>. They run after native validity passes — cheap constraints fail fast and you only pay for an HTTP round-trip when the input is otherwise valid. Server errors returned from the submit handler land via field.setError(message).

Try taken to trigger the async error. Any other value submits; the demo server replies with a 409 to show the setError path.

Cross-field rules

Cross-field validation lives on the dependent field. The validator reads the other control's value when it runs, no extra bookkeeping required. Re-validate the dependent field from an input listener on its peer so it doesn't stay stale.

Tokens

Per-instance overrides via CSS custom properties.

Variable Default
--cui-input-bg --cui-color-surface-raised
--cui-input-border --cui-color-border
--cui-input-color --cui-color
--cui-input-placeholder --cui-color-muted
--cui-input-radius --cui-control-radius
--cui-input-padding-block 0.625rem
--cui-input-padding-inline 0.875rem
--cui-switch-bg --cui-color-surface-muted
--cui-switch-bg-on --cui-color-accent
--cui-switch-knob --cui-color-surface-raised

See Theming → Component tokens for the underlying --cui-color-* palette.