Form
A smart <form> wrapper that propagates disabled, size, variant, and validateOn context to all child sg-* form fields. Intercepts native submit/reset events and assembles FormData so you never wire up individual field listeners.
Features
Layout Orientation — vertical(default) orhorizontalwith automatic wrappingValidation Strategy — configure when validation runs: on submit, onblur, or on everychangeUniform Styling — set variantandsizeonce instead of on every individual fieldContext Propagation — disabled,size,variant, andvalidateOnautomatically apply to all child form fieldsSubmit / Reset Events — intercepts native events and emits submitwithFormDataandresetBulk Disable — set disabledonce to freeze every field while a submit request is in flightNative <form>— renders a real<form>in the shadow DOM so submit buttons andFormDatawork correctlyNo-Validate Mode — suppress native browser validation popups with novalidate
Source Code
View Source Code
import { define, html, prop } from '@vielzeug/craft';
import { computed } from '@vielzeug/ripple';
import type { ValidationTrigger } from '../../headless';
import type { ComponentSize, VisualVariant } from '../../types';
import { FORM_CTX } from '../shared/form-context';
import componentStyles from './form.css?inline';
/** Form component properties */
export type SgFormProps = {
/** Disabled state */
disabled?: boolean;
/** No validate */
novalidate?: boolean;
/** Layout orientation for child fields */
orientation?: 'horizontal' | 'vertical';
/** Form size preset */
size?: ComponentSize;
/** Validate on: 'submit' | 'change' | 'blur' | 'input' */
validateOn?: ValidationTrigger;
/** Form visual variant — propagated to all child form fields via FormContext */
variant?: VisualVariant;
};
/** Events emitted by the form component */
export type SgFormEvents = {
/** Emitted when the form is reset */
reset: { originalEvent: Event };
/** Emitted when the form is submitted */
submit: { formData: FormData; originalEvent: SubmitEvent };
};
/**
* A wrapper for standard HTML form that provides context to child sg-* form fields.
* Manages shared state like size, variant, and validation timing.
*
* @element sg-form
*
* @attr {boolean} disabled - Disable all child fields
* @attr {boolean} novalidate - Disable native browser validation
* @attr {string} validate-on - When to trigger validation: 'submit' | 'change' | 'blur' | 'input' (default: 'submit')
*
* @fires submit - detail: { formData, originalEvent }
* @fires reset - detail: { originalEvent }
*
* @slot - Form controls and content rendered inside the form element.
* @cssprop --form-gap - Gap between form control rows
* @part form - Form root element.
* @example
* ```html
* <sg-form @submit=${(e) => console.log(e.detail.formData)}>
* <sg-input name="username" label="Username" required></sg-input>
* <sg-select name="role" label="Role">
* <option value="user">User</option>
* <option value="admin">Admin</option>
* </sg-select>
* <sg-button type="submit">Submit</sg-button>
* </sg-form>
* ```
*/
export const FORM_TAG = 'sg-form' as const;
define<SgFormProps, SgFormEvents>(FORM_TAG, {
props: {
disabled: prop.bool(false),
novalidate: prop.bool(false),
orientation: prop.oneOf(['horizontal', 'vertical'] as const, 'vertical'),
size: prop.string<ComponentSize>(),
validateOn: prop.oneOf(['submit', 'change', 'blur', 'input'] as const, 'submit'),
variant: prop.string<VisualVariant>(),
},
setup(props, { bind, el, emit, provide }) {
const shadowRoot = el.shadowRoot;
// Reflect orientation to host so CSS and tests can read it
bind({ attr: { orientation: props.orientation } });
// Provide context to all child sg-* form fields
provide(FORM_CTX, {
disabled: computed(() => Boolean(props.disabled.value)),
size: props.size,
validateOn: computed(() => props.validateOn.value ?? 'submit'),
variant: props.variant,
});
function handleSubmit(e: Event) {
const submitEvent = e as SubmitEvent;
const formEl = shadowRoot?.querySelector('form');
if (!formEl) return;
e.preventDefault();
const formData = new FormData(formEl);
emit('submit', { formData, originalEvent: submitEvent });
}
function handleReset(e: Event) {
emit('reset', { originalEvent: e });
}
return html`
<form
part="form"
:novalidate="${props.novalidate}"
:aria-disabled="${() => (props.disabled.value ? 'true' : null)}"
@submit="${handleSubmit}"
@reset="${handleReset}">
<slot></slot>
</form>
`;
},
shadow: { delegatesFocus: false },
styles: [componentStyles],
});Basic Usage
Wrap your form fields in sg-form. A sg-button type="submit" triggers the form submit event.
<sg-form id="my-form">
<sg-input name="email" label="Email" type="email" required></sg-input>
<sg-select name="role" label="Role">
<option value="admin">Admin</option>
<option value="editor">Editor</option>
</sg-select>
<sg-button type="submit">Submit</sg-button>
</sg-form>
<script type="module">
import '@vielzeug/sigil/form';
import '@vielzeug/sigil/input';
import '@vielzeug/sigil/select';
import '@vielzeug/sigil/button';
document.getElementById('my-form').addEventListener('submit', (e) => {
console.log([...e.detail.formData.entries()]);
});
</script>Uniform Styling
Set size and variant once on sg-form to propagate them to all child fields.
Bulk Disable
Set disabled to freeze all child fields and buttons — useful during an async submit request to prevent double-submission.
const form = document.getElementById('async-form');
form.addEventListener('submit', async (e) => {
form.disabled = true;
try {
await fetch('/api/submit', { method: 'POST', body: e.detail.formData });
} finally {
form.disabled = false;
}
});Validation Strategy
Use validate-on to control when field-level validation feedback appears.
| Value | Behaviour |
|---|---|
submit (default) | Validate only when the form is submitted |
blur | Validate each field as soon as it loses focus |
change | Validate on every value change for immediate feedback |
<!-- immediate feedback as the user types -->
<sg-form validate-on="change">
<sg-input name="email" type="email" label="Email" required></sg-input>
<sg-button type="submit">Continue</sg-button>
</sg-form>Layout: Horizontal
Set orientation="horizontal" to arrange fields in a flex row with wrapping.
Handling Submit
The submit event exposes formData and the original SubmitEvent.
document.querySelector('sg-form').addEventListener('submit', (e) => {
const data = e.detail.formData;
console.log('email:', data.get('email'));
console.log('role:', data.get('role'));
console.log('submitter:', e.detail.originalEvent.submitter);
});Handling Reset
document.querySelector('sg-form').addEventListener('reset', (e) => {
console.log('Form was reset', e.detail.originalEvent);
});No-Validate Mode
Add novalidate to suppress native browser validation popups. Pair with error attributes on individual fields for custom validation UX.
<sg-form novalidate>
<sg-input name="email" label="Email" type="email" required error="Please enter a valid email address."> </sg-input>
<sg-button type="submit">Submit</sg-button>
</sg-form>API Reference
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Disable all child form fields |
size | 'sm' | 'md' | 'lg' | — | Default size propagated to all child form fields |
variant | 'solid' | 'flat' | 'bordered' | 'outline' | 'ghost' | — | Default visual variant for all child form fields |
orientation | 'vertical' | 'horizontal' | 'vertical' | Layout direction of child controls |
novalidate | boolean | false | Disable native browser validation popups |
validate-on | 'submit' | 'blur' | 'change' | 'submit' | When child fields trigger validation feedback |
Slots
| Slot | Description |
|---|---|
| (default) | Form content — sg-input, sg-select, sg-button, and other fields |
Events
| Event | Detail | Description |
|---|---|---|
submit | { formData: FormData, originalEvent: SubmitEvent } | Fired on form submit. formData includes all named field values. |
reset | { originalEvent: Event } | Fired when the form is reset. |
CSS Custom Properties
| Property | Description | Default |
|---|---|---|
--form-gap | Gap between form control rows | var(--size-4) |
Context Provided
sg-form provides a FORM_CTX context object consumed by all descendant sg-* form fields:
| Key | Type | Description |
|---|---|---|
disabled | { readonly value: boolean } | Whether all fields should be disabled |
size | { readonly value: 'sm' | 'md' | 'lg' | undefined } | Default size for all fields |
variant | { readonly value: VisualVariant | undefined } | Default variant for all fields |
validateOn | { readonly value: 'submit' | 'blur' | 'change' } | When validation should trigger |
Accessibility
The form component follows WCAG 2.1 Level AA standards.
sg-form
Tabnavigates between child form fields.
- Renders a native
<form>in the shadow root, preserving semantic form behaviour. aria-disabledreflects the disabled state on the<form>.- Each child field manages its own
aria-labelledby,aria-describedby, andaria-errormessageindependently. - Use
novalidatewith expliciterrorattributes on fields to avoid conflicting browser validation popups.
Best Practices
Do:
- Use
sg-formwhenever multiple fields share the samesizeorvariant. - Toggle
disabledduring async operations to prevent double-submission. - Listen to the
submitevent onsg-form—formDatais already assembled for you. - Use
validate-on="blur"for long forms to give users early feedback without interrupting them as they type.
Don't:
- Nest
sg-formelements inside each other. - Rely on the native
submitevent directly — always use the customsubmitevent emitted bysg-form. - Use
sg-formfor single-field scenarios — a standalone field with its own listener is simpler.