Forms

Angular's reactive forms, template-driven forms and Signal Forms all work with Lynx's <input> and <textarea>. LYNX_ELEMENTS connects the native fields to Angular's form APIs, so you write the same form code as on the web.

No checkbox, radio or select element

Lynx has only <input> and <textarea>. For the other controls, use the UI components: Checkbox, Radio Group, Select and Switch.

Set up

Import LYNX_ELEMENTS, which includes the value accessors for <input> and <textarea>. Then import the Angular forms API you use:

APIImport
Reactive formsReactiveFormsModule from @angular/forms
Template-driven (ngModel)FormsModule from @angular/forms
Signal FormsFormField from @angular/forms/signals

Reactive forms

src/app/contact.ts
import { Component } from '@angular/core';
import {
  FormControl,
  FormGroup,
  ReactiveFormsModule,
  Validators,
} from '@angular/forms';
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';

@Component({
  selector: 'app-contact',
  imports: [LYNX_ELEMENTS, ReactiveFormsModule],
  template: `
    <view [formGroup]="form">
      <input placeholder="Your name" formControlName="name" />
      @if (form.controls.name.invalid && form.controls.name.touched) {
        <text class="text-destructive">Name is required</text>
      }

      <input type="email" placeholder="Email" formControlName="email" />
      <textarea placeholder="Message" formControlName="message"></textarea>

      <view class="rounded-md bg-primary px-4 py-2" (bindtap)="submit()">
        <text class="text-primary-foreground">Send</text>
      </view>
    </view>
  `,
})
export class Contact {
  readonly form = new FormGroup({
    name: new FormControl('', {
      nonNullable: true,
      validators: Validators.required,
    }),
    email: new FormControl('', {
      nonNullable: true,
      validators: [Validators.required, Validators.email],
    }),
    message: new FormControl('', { nonNullable: true }),
  });

  submit(): void {
    this.form.markAllAsTouched();
    if (this.form.valid) {
      console.log(this.form.getRawValue());
    }
  }
}
  • Put [formGroup] on a <view>. Lynx has no <form> element, and a <view> holds the group as well.
  • Submit from a tap. There's no submit event, so call your method from (bindtap).
  • Write <textarea></textarea> in full. Angular doesn't allow <textarea />.

Template-driven forms

src/app/settings.ts
import { Component, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';

@Component({
  selector: 'app-settings',
  imports: [LYNX_ELEMENTS, FormsModule],
  template: `
    <view>
      <input placeholder="Display name" [(ngModel)]="displayName" />
      <textarea placeholder="Bio" [(ngModel)]="bio"></textarea>
      <text>Preview: {{ displayName() }}</text>
    </view>
  `,
})
export class Settings {
  readonly displayName = signal('');
  readonly bio = signal('');
}

[(ngModel)] can bind straight to a signal.

Signal Forms

src/app/signup.ts
import { Component, signal } from '@angular/core';
import {
  FormField,
  email,
  form,
  required,
  submit,
} from '@angular/forms/signals';
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';

@Component({
  selector: 'app-signup',
  imports: [LYNX_ELEMENTS, FormField],
  template: `
    <view>
      <input type="email" placeholder="Email" [formField]="signup.email" />
      @if (signup.email().touched() && signup.email().errors()[0]; as error) {
        <text class="text-destructive">{{ error.message }}</text>
      }

      <input
        type="password"
        placeholder="Password"
        [formField]="signup.password"
      />

      <view class="rounded-md bg-primary px-4 py-2" (bindtap)="onSubmit()">
        <text class="text-primary-foreground">Sign up</text>
      </view>
    </view>
  `,
})
export class Signup {
  readonly #model = signal({ email: '', password: '' });

  readonly signup = form(this.#model, (s) => {
    required(s.email, { message: 'Email is required' });
    email(s.email, { message: 'Enter a valid email address' });
    required(s.password, { message: 'Password is required' });
  });

  onSubmit(): void {
    submit(this.signup, async () => {
      console.log(this.#model());
    });
  }
}
  • [formField] works on <input> and <textarea>, through the same value accessor as the other APIs.
  • submit() marks every field as touched, so the errors show, and runs your action only when the form is valid.

Show validation errors

src/app/email-field.ts
import { JsonPipe } from '@angular/common';
import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';

@Component({
  selector: 'app-email-field',
  imports: [LYNX_ELEMENTS, ReactiveFormsModule, JsonPipe],
  template: `
    <input type="email" placeholder="Email" [formControl]="email" />
    @if (email.invalid && email.touched) {
      <text class="text-destructive">{{ email.errors | json }}</text>
    }
  `,
})
export class EmailField {
  readonly email = new FormControl('', [Validators.required, Validators.email]);
}
  • A field counts as touched when it loses focus. The value accessor listens for Lynx's bindblur event.
  • disable() and enable() work. They set and remove the native disabled attribute, which also blocks the keyboard.

Set a value from code

setValue(), reset() and [(ngModel)] all update what the field shows, and so does binding [value] on an <input> or <textarea>.

Don't bind [attr.value]. Lynx reads the value attribute only until the user first types, and ignores it after that. The form APIs and [value] call the field's setValue method instead.

On-screen keyboard

src/app/search-box.ts
import { Component, signal } from '@angular/core';
import { LYNX_ELEMENTS } from '@blotch/angular-lynx';

@Component({
  selector: 'app-search-box',
  imports: [LYNX_ELEMENTS],
  template: `
    <input
      placeholder="Search"
      confirm-type="search"
      [class]="focused() ? 'border-2 border-ring' : 'border border-border'"
      (bindfocus)="focused.set(true)"
      (bindblur)="focused.set(false)"
      (bindconfirm)="search($event)"
    />
    <text>Searching for: {{ query() }}</text>
  `,
})
export class SearchBox {
  readonly focused = signal(false);
  readonly query = signal('');

  search(event: Event): void {
    this.query.set((event as CustomEvent<{ value: string }>).detail.value);
  }
}
  • confirm-type sets the keyboard's action key. Use done (the default), next, go, search or send. Pressing it fires (bindconfirm), with the text in detail.value.
  • (bindfocus) and (bindblur) fire when the field gains and loses focus, also with detail.value.
  • type picks the keyboard: text, number, digit, password, tel or email.
  • The keyboard doesn't move the page. Lynx's <input> doesn't scroll out of the keyboard's way, so the keyboard can cover the field being typed in. AngularLynx doesn't handle this yet. Put long forms in a <scroll-view> with extra space at the bottom, so people can scroll a covered field into view.

Limitations

  • type="number" gives you strings. Lynx always sends the text as a string, so a number field's control holds '42', not 42. Convert it with Number() when you read it.
  • No <label for>. Lynx doesn't link labels to fields. Put a <text> next to the field, or use the Label component.