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

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.