Skip to content
ShineUI
Install

Form

Submit, validated.

<sui-form>Text inputsStable

About

Validated submit for a form of ShineUI controls. The browser already builds the FormData and blocks on required; this element adds the schema pass through resolver, runs it on submit or as the user types, writes each message onto the control it names, and holds the submit until the form is clean. It owns no values.

Usage

<sui-form> <sui-form-layout heading="Record a payment" columns="1"> <sui-input-text label="Customer" name="customer" required></sui-input-text> <sui-input-number label="Amount" name="amount" required></sui-input-number> <sui-button type="submit" variant="brand" label="Record payment"></sui-button> </sui-form-layout> </sui-form> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-form"); el.addEventListener("suiInvalid", (e) => console.log(e.detail)); </script>
import { SuiForm } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { return ( <SuiForm onSuiInvalid={(e) => console.log(e.detail)}> <sui-form-layout heading="Record a payment" columns="1"> <sui-input-text label="Customer" name="customer" required></sui-input-text> <sui-input-number label="Amount" name="amount" required></sui-input-number> <sui-button type="submit" variant="brand" label="Record payment"></sui-button> </sui-form-layout> </SuiForm> ); }
<script setup> import { SuiForm } from "@shineui/vue"; import "@shineui/core/shineui.css"; const onChange = (e) => console.log(e.detail); </script> <template> <SuiForm @sui-invalid="onChange"><sui-form-layout heading="Record a payment" columns="1"> <sui-input-text label="Customer" name="customer" required></sui-input-text> <sui-input-number label="Amount" name="amount" required></sui-input-number> <sui-button type="submit" variant="brand" label="Record payment"></sui-button> </sui-form-layout></SuiForm> </template>
import { Component } from "@angular/core"; import { SuiForm } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiForm], template: `<sui-form (suiInvalid)="onChange($event)"><sui-form-layout heading="Record a payment" columns="1"> <sui-input-text label="Customer" name="customer" required></sui-input-text> <sui-input-number label="Amount" name="amount" required></sui-input-number> <sui-button type="submit" variant="brand" label="Record payment"></sui-button> </sui-form-layout></sui-form>`, }) export class Example { onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

The controls are form-associated, so the browser builds the FormData and blocks on required with no resolver at all. sui-form adds the schema pass and moves focus to the first control that failed.

<sui-form id="f-basic"> <sui-form-layout heading="Record a payment" columns="1"> <sui-input-text label="Customer" name="customer" required></sui-input-text> <sui-input-number label="Amount" name="amount" required></sui-input-number> <sui-button type="submit" variant="brand" label="Record payment"></sui-button> </sui-form-layout> </sui-form>
// setup(root): properties and events setup(root) { root.querySelector('#f-basic').addEventListener('suiSubmit', (e) => { e.preventDefault(); }); }

Resolver

The resolver returns a map of control name to message. sui-form clears the previous run's messages, then writes each new one to that control's error prop, so the field shows it as an author-set error.

Submit with the fields empty to see the messages.
<sui-form id="f-schema"> <sui-form-layout heading="New invoice" columns="2"> <sui-input-text label="Customer" name="customer"></sui-input-text> <sui-input-number label="Amount" name="amount"></sui-input-number> <sui-form-actions primary-type="submit" primary-label="Save invoice" hide-secondary></sui-form-actions> </sui-form-layout> <output id="f-schema-out" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Submit with the fields empty to see the messages.</output> </sui-form>
// setup(root): properties and events setup(root) { const form = root.querySelector('#f-schema'); const out = root.querySelector('#f-schema-out'); form.resolver = (values) => { const errors = required(values, { customer: 'Choose the customer this invoice bills.', amount: 'Enter an amount.', }); const amount = Number(values.get('amount')); if (!errors.amount && amount <= 0) errors.amount = 'The amount must be more than zero.'; return errors; }; form.addEventListener('suiSubmit', (e) => { e.preventDefault(); out.textContent = `Valid. Posting ${[...e.detail].map(([k, v]) => `${k}=${v}`).join(' ')}`; }); }

Validate on blur

validateOn runs the resolver as focus leaves a control (blur) or as its value changes (input), and each step still validates on submit. Opening a list or a calendar is not leaving the control. A run one control triggered writes or clears only that control's message, so a field nobody has touched stays quiet.

<sui-form id="f-blur" validate-on="blur"> <sui-form-layout heading="New customer" columns="2"> <sui-input-text label="Name" name="name"></sui-input-text> <sui-input-text label="Email" name="email"></sui-input-text> <sui-form-actions primary-type="submit" primary-label="Add customer" hide-secondary></sui-form-actions> </sui-form-layout> </sui-form>
// setup(root): properties and events setup(root) { const form = root.querySelector('#f-blur'); form.resolver = (values) => { const errors = required(values, { name: 'Enter the customer name.', email: 'Enter an email address.' }); const email = String(values.get('email') || ''); if (!errors.email && !email.includes('@')) errors.email = 'That does not look like an email address.'; return errors; }; form.addEventListener('suiSubmit', (e) => { e.preventDefault(); }); }

A native input

A plain input joins the FormData like any control, and its message goes onto its own validity, so the browser blocks on it too. Inside sui-input-group the message also shows on the shared error line.

DOC
<sui-form id="f-native"> <sui-form-layout heading="Attach a document" columns="1"> <sui-input-group id="f-native-group" label="Document reference"> <sui-input-group-addon>DOC</sui-input-group-addon> <input name="reference" placeholder="2209-114" data-grow /> </sui-input-group> <sui-form-actions primary-type="submit" primary-label="Attach" hide-secondary></sui-form-actions> </sui-form-layout> </sui-form>
// setup(root): properties and events setup(root) { const form = root.querySelector('#f-native'); form.resolver = (values) => { const ref = String(values.get('reference') || '').trim(); if (!/^\d{4}-\d{3}$/.test(ref)) return { reference: 'Use four digits, a hyphen and three digits.' }; return {}; }; form.addEventListener('suiSubmit', (e) => { e.preventDefault(); }); }

Cross-field rules

A message with no control name is a form-level message. It arrives on suiInvalid under the empty-string key, which is how a rule that spans two fields reports itself.

<sui-form id="f-cross"> <sui-form-layout heading="Reporting period" columns="2"> <sui-date-picker label="From" name="from"></sui-date-picker> <sui-date-picker label="To" name="to"></sui-date-picker> <sui-form-actions primary-type="submit" primary-label="Run report" hide-secondary></sui-form-actions> </sui-form-layout> <sui-message id="f-cross-msg" severity="critical" open="false"><span id="f-cross-text"></span></sui-message> </sui-form>
// setup(root): properties and events setup(root) { const form = root.querySelector('#f-cross'); const msg = root.querySelector('#f-cross-msg'); const text = root.querySelector('#f-cross-text'); form.resolver = (values) => { const from = String(values.get('from') || ''); const to = String(values.get('to') || ''); if (from && to && to < from) return { '': 'The end date must fall on or after the start date.' }; return {}; }; form.addEventListener('suiInvalid', (e) => { const formLevel = e.detail['']; if (formLevel) text.textContent = formLevel; msg.open = !!formLevel; }); form.addEventListener('suiSubmit', (e) => { e.preventDefault(); msg.open = false; }); }

Submit

suiSubmit is cancelable. Call preventDefault to keep the page and post it yourself, which is the usual case. Without the veto the element performs a native submit.

Idle.
<sui-form id="f-fetch"> <sui-form-layout heading="Contact" columns="1"> <sui-input-text label="Email" name="email" required></sui-input-text> <sui-button id="f-fetch-btn" type="submit" variant="brand" label="Save"></sui-button> </sui-form-layout> <output id="f-fetch-out" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Idle.</output> </sui-form>
// setup(root): properties and events setup(root) { const form = root.querySelector('#f-fetch'); const btn = root.querySelector('#f-fetch-btn'); const out = root.querySelector('#f-fetch-out'); form.addEventListener('suiSubmit', async (e) => { e.preventDefault(); btn.loading = true; out.textContent = 'Posting.'; await new Promise((r) => setTimeout(r, 600)); btn.loading = false; out.textContent = 'Saved.'; }); form.addEventListener('suiInvalid', () => { out.textContent = 'Blocked. Fix the fields above.'; }); }

Actions outside the form

The native form inside sui-form takes the host id plus -form, so a bar that sits outside the element, a dialog footer say, reaches it through form=. This bar is a sibling of the form and still submits and resets its fields.

The bar below the card submits f-outside-form.
<div style="display:flex;flex-direction:column;gap:12px"> <sui-form id="f-outside"> <sui-form-layout heading="Workspace" columns="2"> <sui-input-text label="Name" name="name" value="Larkspur" required></sui-input-text> <sui-input-text label="Short code" name="code" value="LRK" required></sui-input-text> </sui-form-layout> </sui-form> <sui-form-actions form="f-outside-form" primary-type="submit" secondary-type="reset" primary-label="Save workspace" secondary-label="Reset"></sui-form-actions> <output id="f-outside-out" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">The bar below the card submits f-outside-form.</output> </div>
// setup(root): properties and events setup(root) { const form = root.querySelector('#f-outside'); const out = root.querySelector('#f-outside-out'); form.addEventListener('suiSubmit', (e) => { e.preventDefault(); out.textContent = `Saved ${[...e.detail].map(([k, v]) => `${k}=${v}`).join(' ')}`; }); }

Events

suiSubmit carries the FormData once the form is clean. suiInvalid carries the messages keyed by control name, and fires for a native validity failure too, with an empty map.

Idle.
<sui-form id="f-events"> <sui-form-layout heading="Journal entry" columns="2"> <sui-input-text label="Memo" name="memo"></sui-input-text> <sui-input-number label="Debit" name="debit"></sui-input-number> <sui-form-actions primary-type="submit" primary-label="Post entry" hide-secondary></sui-form-actions> </sui-form-layout> <span id="f-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Idle.</span> </sui-form>
// setup(root): properties and events setup(root) { const form = root.querySelector('#f-events'); const log = root.querySelector('#f-log'); form.resolver = (values) => required(values, { memo: 'Every entry needs a memo.' }); form.addEventListener('suiSubmit', (e) => { e.preventDefault(); log.textContent = `suiSubmit with ${[...e.detail].length} entries.`; }); form.addEventListener('suiInvalid', (e) => { const names = Object.keys(e.detail).filter(Boolean); log.textContent = names.length ? `suiInvalid on ${names.join(', ')}.` : 'suiInvalid from native validity.'; }); }

API

<sui-form>

Properties

PropertyAttributeTypeDefaultDescription
noFocusOnInvalidno-focus-on-invalidbooleanfalseLeaves focus where it is instead of moving it to the first control that failed. Also keeps the browser's bubble off a native control, since reporting it moves focus.
novalidatenovalidatebooleanfalseSkips the browser's constraint validation, the way novalidate does on a native form.
resolverproperty onlySuiResolver((values: FormData) => SuiFormErrors | Promise<SuiFormErrors>) | undefinedSchema validation, run after the browser's own: a function of (FormData) => Record<name, message>. utils/validate.ts has the type and standardResolver, an adapter for Zod, Valibot and ArkType. Omit it for native validity alone; the element still focuses the first invalid control.
validateOnvalidate-on"blur" | "input" | "submit"'submit'When the resolver runs: on submit alone (the default), also as focus leaves a control (blur), or also as a control's value changes (input): every keystroke in a text field, every pick in a picker. Focus moving from a control into the list or calendar it opened has not left it, wherever that panel sits, so a required select stays quiet while the person is choosing. A run a control triggered validates that control and writes or clears only its message, so a field nobody has touched stays quiet. It needs a resolver, moves no focus, shows no browser bubble, and emits no suiInvalid.

Events

EventDetailReactVueDescription
suiInvalidSuiFormErrors{ [x: string]: string; }onSuiInvalid@sui-invalidFires when a submit was refused, with the messages keyed by control name: the browser's own message for each control that fails its constraints (required and the rest), or the resolver's when the native pass is clean. Each named message is also written to that control's error prop and cleared on the next submit; a bare native control keeps the browser's bubble instead. A message under the empty-string key is form-level and belongs to no single control.
suiSubmitFormDataonSuiSubmit@sui-submitFires with the FormData once the form is valid. Cancelable: call preventDefault() in every handler that sends the data itself, because without it the native form submits and the page navigates to its action, the way a plain form does; that is the usual case for a fetch.

Methods

MethodSignatureDescription
clearErrors()clearErrors() => Promise<void>Clears every message this element wrote, and any author-set error along with them. On a native control it clears the custom validity too, so the control stops blocking the form.
validate()validate() => Promise<boolean>Validates without submitting. Resolves true when the form is clean.

Slots

SlotDescription
(default)The form's fields and its actions. Every field takes disabled as a boolean that follows its property: to switch it off, remove the attribute or set the property to false, and never write disabled="false". A bar that sits outside the element, in a dialog footer say, reaches the form by id: give the host id="settings" and the native form inside it is settings-form, for sui-form-actions form="settings-form" or a native button's form attribute. The host keeps its own id, so scripts still find the element by it.

Types

SuiFormErrors typeControl name to the message to show on it. An empty object means valid.
No members.
SuiResolver typeValidates a submission. Returning a rejected promise is a bug in the resolver, not a validation failure — sui-form reports it rather than silently treating it as valid.
No members.

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

Typography1

Ag--font-sans

Every name above is listed with its value and its role in Foundations.

See also