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, defineComponent, html, typed, provide } from '@vielzeug/craftit';
import type { ComponentSize, VisualVariant } from '../../types';
import { FORM_CTX } from '../shared/form-context';
// ============================================
// Styles
// ============================================
import componentStyles from './form.css?inline';
// ============================================
// Types
// ============================================
export type BitFormEvents = {
reset: { originalEvent: Event };
submit: { formData: FormData; originalEvent: SubmitEvent };
};
export type BitFormProps = {
/** Disable all child form fields */
disabled?: boolean;
/** Native form novalidate */
novalidate?: boolean;
/** Form layout orientation */
orientation?: 'vertical' | 'horizontal';
/** Default size for all child fields */
size?: ComponentSize;
/**
* When to validate child form controls.
* - `'submit'` (default): validate only when the form is submitted
* - `'blur'`: validate each field as it loses focus
* - `'change'`: validate on every value change (most immediate feedback)
*/
validateOn?: 'submit' | 'blur' | 'change';
/** Default variant for all child fields */
variant?: Exclude<VisualVariant, 'glass' | 'frost' | 'text'>;
};
/**
* `bit-form` — Native `<form>` wrapper that propagates `disabled`, `size`, and `variant`
* context to all child `bit-*` form fields. Intercepts submit/reset events.
*
* @element bit-form
*
* @attr {boolean} disabled - Disable all child form fields
* @attr {string} size - Default size: 'sm' | 'md' | 'lg'
* @attr {string} variant - Default visual variant for child fields
* @attr {string} orientation - Layout direction: 'vertical' | 'horizontal'
* @attr {boolean} novalidate - Skip native browser validation
*
* @fires submit - Fired on form submit; detail contains `formData` and `originalEvent`
* @fires reset - Fired on form reset; detail contains `originalEvent`
*
* @slot - Form content (bit-input, bit-select, etc.)
*
* @cssprop --form-gap - Spacing between child form controls
*
* @example
* ```html
* <bit-form id="my-form" size="sm" variant="flat">
* <bit-input name="email" label="Email" type="email"></bit-input>
* <bit-select name="role" label="Role">
* <option value="admin">Admin</option>
* </bit-select>
* <bit-button type="submit">Submit</bit-button>
* </bit-form>
* ```
*/
export const FORM_TAG = defineComponent<BitFormProps, BitFormEvents>({
props: {
disabled: typed<boolean>(false),
novalidate: typed<boolean>(false),
orientation: typed<BitFormProps['orientation']>('vertical'),
size: typed<BitFormProps['size']>(undefined),
validateOn: typed<BitFormProps['validateOn']>(undefined),
variant: typed<BitFormProps['variant']>(undefined),
},
setup({ emit, host, props }) {
// Provide context to all child bit-* form fields
provide(FORM_CTX, {
disabled: computed(() => Boolean(props.disabled.value)),
size: props.size,
validateOn: computed(() => props.validateOn.value ?? 'submit'),
variant: props.variant,
});
// ── Event handlers ────────────────────────────────────────────────────────
function handleSubmit(e: Event) {
const submitEvent = e as SubmitEvent;
const formEl = host.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.value || null}"
:aria-disabled="${() => (props.disabled.value ? 'true' : null)}"
@submit="${handleSubmit}"
@reset="${handleReset}">
<slot></slot>
</form>
`;
},
shadow: { delegatesFocus: false },
styles: [componentStyles],
tag: 'bit-form',
});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.