Skip to content

Custom field kinds

A one-off widget belongs in a custom field. When you find yourself writing the same one in several forms, promote it to a registered kind — it gets a real descriptor type and becomes usable declaratively.

ts
{ name: 'score', kind: 'rating', max: 5 }

Write the field class

Extend Field and implement three members:

ts
import { Field } from 'declarative-forms';
import type { BaseFieldDescriptor } from 'declarative-forms';

export interface RatingFieldDescriptor extends BaseFieldDescriptor {
  kind: 'rating';
  max?: number;
}

export class RatingField extends Field<RatingFieldDescriptor> {
  private value = 0;
  private buttons: HTMLButtonElement[] = [];

  protected createControl(): HTMLElement {
    const container = document.createElement('div');
    container.classList.add('rating-field');

    for (let i = 1; i <= (this.descriptor.max ?? 5); i++) {
      const star = document.createElement('button');
      star.type = 'button';
      star.textContent = '★';
      star.setAttribute('aria-label', `${i} of ${this.descriptor.max ?? 5}`);
      star.onclick = () => {
        this.setValue(i);
        this.requestUpdate();
      };

      this.buttons.push(star);
      container.appendChild(star);
    }

    return container;
  }

  getValue(): number {
    return this.value;
  }

  setValue(value: unknown): void {
    this.value = Number(value) || 0;
    this.buttons.forEach((star, index) => {
      star.classList.toggle('filled', index < this.value);
    });
  }
}

createControl builds the element; the base class handles the wrapper, label, tooltip, active state and tab membership.

Assign in createControl, not a field initializer

Under ES2022 class-field semantics, initializers run after super(). The base class calls createControl from a separate build() step for exactly this reason — but it means anything you declare as private x = … is assigned after the constructor chain, so initialise DOM references inside createControl rather than relying on ordering.

Optional hooks

MemberPurpose
initialize()Async setup — load remote data. May return a promise
onFormUpdate(ctx, triggerName)React to form changes
setLoading(loading)Show a loading state
focus()Move focus into the control
destroy()Release listeners
get wrapperExtraClass()Extra class for the field wrapper
get contributesValue()false for presentational kinds
protected controlIdForLabel()Which element the <label for> targets

Two protected helpers are available:

  • this.requestUpdate(force?) — re-run the form update cycle.
  • this.resolve(value) — unwrap a Reactive<T> option.

The narrow FormContext your field is given exposes ids, tooltips, scheduler, parentData, stackData, isEditingArrayEntry, contextFor(), persistFile() and createSubForm().

Register the kind

Clone the default registry rather than mutating it, so other code is unaffected:

ts
import { defaultFieldRegistry, DeclarativeForm } from 'declarative-forms';

const registry = defaultFieldRegistry.clone();
registry.register('rating', RatingField as never);

Registries are passed through the internal options argument, which nested forms inherit:

ts
const form = new DeclarativeForm(
  { fields: [{ name: 'score', kind: 'rating', max: 5 } as never] },
  { registry },
);

An unknown kind throws at construction with a message naming the field and listing the known kinds — a typo fails loudly rather than rendering nothing.

Typing your descriptor

To get full inference, widen the descriptor union in your own code:

ts
import type { FieldDescriptor } from 'declarative-forms';

type AppField = FieldDescriptor | RatingFieldDescriptor;

const fields: AppField[] = [
  { name: 'title', displayName: 'Title' },
  { name: 'score', kind: 'rating', max: 5 },
];

Respect the DOM contract

If your kind is meant to look at home, follow the existing conventions: the wrapper already carries .dl-form-field-wrapper and #dl-form-field-wrapper-for-<name>, so give your control a class of its own and style it alongside the shipped tokens. See the DOM contract.

Async setup

ts
export class RemoteField extends Field<MyDescriptor> {
  protected createControl(): HTMLElement {
    return document.createElement('div');
  }

  override async initialize(): Promise<void> {
    this.setLoading(true);
    const data = await fetch('/api/thing').then((r) => r.json());
    this.render(data);
    this.setLoading(false);
  }

  // …
}

initialize() is awaited by form.whenReady(), so callers can rely on your field being populated before they read values.

Released under the MIT License.