Skip to content

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 Orientationvertical (default) or horizontal with automatic wrapping
  • Validation Strategy — configure when validation runs: on submit, on blur, or on every change
  • 🎨 Uniform Styling — set variant and size once instead of on every individual field
  • 📡 Context Propagationdisabled, size, variant, and validateOn automatically apply to all child form fields
  • 📤 Submit / Reset Events — intercepts native events and emits submit with FormData and reset
  • 🔒 Bulk Disable — set disabled once to freeze every field while a submit request is in flight
  • 🔗 Native <form> — renders a real <form> in the shadow DOM so submit buttons and FormData work correctly
  • 🚫 No-Validate Mode — suppress native browser validation popups with novalidate

Source Code

View Source Code
ts
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.

html
<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.

PreviewCode
RTL

Bulk Disable

Set disabled to freeze all child fields and buttons — useful during an async submit request to prevent double-submission.

PreviewCode
RTL
js
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.

ValueBehaviour
submit (default)Validate only when the form is submitted
blurValidate each field as soon as it loses focus
changeValidate on every value change for immediate feedback
html
<!-- 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.

PreviewCode
RTL

Handling Submit

The submit event exposes formData and the original SubmitEvent.

js
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

js
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.

html
<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

AttributeTypeDefaultDescription
disabledbooleanfalseDisable 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
novalidatebooleanfalseDisable native browser validation popups
validate-on'submit' | 'blur' | 'change''submit'When child fields trigger validation feedback

Slots

SlotDescription
(default)Form content — bit-input, bit-select, bit-button, and other fields

Events

EventDetailDescription
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

PropertyDescriptionDefault
--form-gapSpacing between child form controlsvar(--size-4, 1rem)

Context Provided

bit-form provides a FORM_CTX context object consumed by all descendant bit-* form fields:

KeyTypeDescription
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

  • Tab navigates between child form fields.

Screen Readers

  • Renders a native <form> in the shadow root, preserving semantic form behaviour.
  • aria-disabled reflects the disabled state on the <form>.
  • Each child field manages its own aria-labelledby, aria-describedby, and aria-errormessage independently.
  • Use novalidate with explicit error attributes on fields to avoid conflicting browser validation popups.

Best Practices

Do:

  • Use bit-form whenever multiple fields share the same size or variant.
  • Toggle disabled during async operations to prevent double-submission.
  • Listen to the submit event on bit-formformData is 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-form elements inside each other.
  • Rely on the native submit event directly — always use the custom submit event emitted by bit-form.
  • Use bit-form for single-field scenarios — a standalone field with its own listener is simpler.