Form
A smart <form> wrapper that propagates disabled, size, variant, and validateOn context to all child bit-* 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 wrapping - ✅ Validation Strategy — configure when validation runs: on
submit, onblur, or on everychange - 🎨 Uniform Styling — set
variantandsizeonce instead of on every individual field - 📡 Context Propagation —
disabled,size,variant, andvalidateOnautomatically apply to all child form fields - 📤 Submit / Reset Events — intercepts native events and emits
submitwithFormDataandreset - 🔒 Bulk Disable — set
disabledonce to freeze every field while a submit request is in flight - 🔗 Native
<form>— renders a real<form>in the shadow DOM so submit buttons andFormDatawork correctly - 🚫 No-Validate Mode — suppress native browser validation popups with
novalidate
Source Code
View Source Code
import { computed, define, html, prop, provide } from '@vielzeug/craftit';
import { FORM_CTX } from '../shared/form-context';
import componentStyles from './form.css?inline';
/** Form component properties */
export type BitFormProps = {
/** Disabled state */
disabled?: boolean;
/** No validate */
novalidate?: boolean;
/** Layout orientation for child fields */
orientation?: 'horizontal' | 'vertical';
/** Form size preset */
size?: string;
/** Validate on: 'submit' | 'change' | 'blur' | 'input' */
validateOn?: 'submit' | 'change' | 'blur' | 'input';
/** Form visual variant */
variant?: string;
};
/** Events emitted by the form component */
export type BitFormEvents = {
/** 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 bit-* form fields.
* Manages shared state like size, variant, and validation timing.
*
* @element bit-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 - Form layout/styling token.
* @cssprop --size-4 - Spacing/sizing token.
* @part form - Form root element.
* @example
* ```html
* <bit-form @submit=${(e) => console.log(e.detail.formData)}>
* <bit-input name="username" label="Username" required></bit-input>
* <bit-select name="role" label="Role">
* <option value="user">User</option>
* <option value="admin">Admin</option>
* </bit-select>
* <bit-button type="submit">Submit</bit-button>
* </bit-form>
* ```
*/
export const FORM_TAG = define<BitFormProps, BitFormEvents>('bit-form', {
props: {
disabled: false,
novalidate: false,
orientation: prop.oneOf(['horizontal', 'vertical'] as const, 'vertical'),
size: undefined,
validateOn: prop.oneOf(['submit', 'change', 'blur', 'input'] as const, 'submit'),
variant: undefined,
},
setup(props, { emit, host }) {
const shadowRoot = host.el.shadowRoot;
// Reflect orientation to host so CSS and tests can read it
host.bind({ attr: { orientation: props.orientation } });
// Provide context to all child bit-* form fields
provide(FORM_CTX, {
disabled: computed(() => Boolean(props.disabled.value)),
size: props.size as any,
validateOn: computed(() => props.validateOn.value ?? 'submit') as any,
variant: props.variant as any,
});
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 bit-form. A bit-button type="submit" triggers the form submit event.
<bit-form id="my-form">
<bit-input name="email" label="Email" type="email" required></bit-input>
<bit-select name="role" label="Role">
<option value="admin">Admin</option>
<option value="editor">Editor</option>
</bit-select>
<bit-button type="submit">Submit</bit-button>
</bit-form>
<script type="module">
import '@vielzeug/buildit/form';
import '@vielzeug/buildit/input';
import '@vielzeug/buildit/select';
import '@vielzeug/buildit/button';
document.getElementById('my-form').addEventListener('submit', (e) => {
console.log([...e.detail.formData.entries()]);
});
</script>Uniform Styling
Set size and variant once on bit-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 -->
<bit-form validate-on="change">
<bit-input name="email" type="email" label="Email" required></bit-input>
<bit-button type="submit">Continue</bit-button>
</bit-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('bit-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('bit-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.
<bit-form novalidate>
<bit-input name="email" label="Email" type="email" required error="Please enter a valid email address."> </bit-input>
<bit-button type="submit">Submit</bit-button>
</bit-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 — bit-input, bit-select, bit-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 | Spacing between child form controls | var(--size-4, 1rem) |
Context Provided
bit-form provides a FORM_CTX context object consumed by all descendant bit-* 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.
bit-form
✅ Keyboard Navigation
Tabnavigates between child form fields.
✅ Screen Readers
- 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
bit-formwhenever multiple fields share the samesizeorvariant. - Toggle
disabledduring async operations to prevent double-submission. - Listen to the
submitevent onbit-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
bit-formelements inside each other. - Rely on the native
submitevent directly — always use the customsubmitevent emitted bybit-form. - Use
bit-formfor single-field scenarios — a standalone field with its own listener is simpler.