Skip to content

chocosd/angular-template-signal-forms

Repository files navigation

Signal Template Forms

npm version

A modern Angular form library built from the ground up with Signals β€” flexible, type-safe, and fully themeable.

Born from an itch to reimagine template-driven forms, signal-template-forms gives you a clean, declarative API powered by Angular Signals and full control over layout, styling, and behavior.

⚠️ Built with Angular 19.2.

Features

  • 🎯 Type-safe: Full TypeScript support with intelligent autocompletion
  • ⚑ Signal-based: Reactive forms using Angular signals for optimal performance
  • πŸ”§ Rich field types: Text, number, select, autocomplete, date, file upload, and more
  • βœ… Validation: Field-level, cross-field, and async validation support
  • 🎨 Customizable: CSS variables for easy theming and styling
  • πŸ“± Responsive: Built-in responsive design patterns
  • πŸ”„ Unit conversion: Advanced number fields with automatic unit conversions
  • πŸ“Š Word counting: Text fields with character/word count display
  • πŸ§™ Stepped forms: Multi-step form wizard support

Installation

npm install signal-template-forms

Quick Start

import { SignalFormBuilder } from "signal-template-forms";

@Component({
  // ...
})
export class MyComponent {
  form = SignalFormBuilder.createForm({
    model: { name: "", email: "", age: 0 },
    fields: [
      { name: "name", label: "Full Name", type: FormFieldType.TEXT },
      { name: "email", label: "Email", type: FormFieldType.TEXT },
      { name: "age", label: "Age", type: FormFieldType.NUMBER },
    ],
    onSave: (value) => console.log("Form saved:", value),
  });
}
<signal-form [form]="form" />

Form Container API

The form container provides these reactive properties and methods:

Properties

  • status: WritableSignal<FormStatus> - Current form status (Idle, Submitting, Success, Error)
  • value: Signal<TModel> - Current form values (excluding disabled fields)
  • rawValue: Signal<TModel> - All form values including disabled fields
  • anyTouched: Signal<boolean> - True if any field has been touched
  • anyDirty: Signal<boolean> - True if any field has been modified
  • saveButtonDisabled: Signal<boolean> - Whether save button should be disabled
  • fields: SignalFormField<TModel>[] - Array of all form fields

Methods

  • getField<K extends keyof TModel>(key: K) - Get a specific field instance
  • getValue(): TModel - Get current form values
  • getRawValue(): TModel - Get all form values including disabled
  • getErrors(): ErrorMessage<TModel>[] - Get all validation errors
  • validateForm(): boolean - Validate entire form
  • setValue(model: TModel): void - Set complete form values
  • patchValue(partial: DeepPartial<TModel>): void - Update specific fields
  • reset(): void - Reset form to initial state
  • save(): void - Save form (runs validation first)

Field Access

Fields are signals that can be accessed and modified directly:

// Get a field
const nameField = form.getField("name");

// Access field signals
const currentValue = nameField.value();
const hasError = nameField.error();
const isTouched = nameField.touched();
const isDirty = nameField.dirty();
const hasFocus = nameField.focus();

// Modify field signals
nameField.value.set("New Value");
nameField.disabled.set(true);
nameField.disabled.update((current) => !current);
nameField.focus.set(true);
nameField.error.set("Custom error message");

// Reactive field interactions
const isSubmitDisabled = computed(() => form.getField("email").error() || !form.getField("terms").value());

// Set field value based on another field
effect(() => {
  const country = form.getField("country").value();
  if (country === "US") {
    form.getField("currency").value.set("USD");
  }
});

Field Types Reference

Text Fields

{ name: 'username', label: 'Username', type: FormFieldType.TEXT }
{ name: 'description', label: 'Description', type: FormFieldType.TEXTAREA }
{ name: 'password', label: 'Password', type: FormFieldType.PASSWORD }

Number Fields with Unit Conversion

// Standard number
{ name: 'quantity', label: 'Quantity', type: FormFieldType.NUMBER }

// Currency formatting
{
  name: 'price',
  label: 'Price',
  type: FormFieldType.NUMBER,
  config: {
    inputType: NumberInputType.CURRENCY,
    currencyCode: 'USD'
  }
}

// Percentage
{
  name: 'discount',
  label: 'Discount',
  type: FormFieldType.NUMBER,
  config: { inputType: NumberInputType.PERCENTAGE }
}

// Unit conversion (weight)
{
  name: 'weight',
  label: 'Weight',
  type: FormFieldType.NUMBER,
  config: {
    inputType: NumberInputType.UNIT_CONVERSION,
    unitConversions: ConversionUtils.createWeightConfig('kg', 1)
  }
}

Selection Fields

// Select dropdown
{
  name: 'country',
  label: 'Country',
  type: FormFieldType.SELECT,
  options: [
    { label: 'United States', value: 'US' },
    { label: 'Canada', value: 'CA' }
  ]
}

// Radio buttons
{
  name: 'size',
  label: 'Size',
  type: FormFieldType.RADIO,
  options: [
    { label: 'Small', value: 'S' },
    { label: 'Medium', value: 'M' },
    { label: 'Large', value: 'L' }
  ]
}

// Multi-select
{
  name: 'skills',
  label: 'Skills',
  type: FormFieldType.MULTISELECT,
  options: [
    { label: 'JavaScript', value: 'js' },
    { label: 'TypeScript', value: 'ts' },
    { label: 'Angular', value: 'angular' }
  ]
}

Autocomplete Fields

// Static options autocomplete
{
  name: 'city',
  label: 'City',
  type: FormFieldType.AUTOCOMPLETE,
  loadOptions: (search: string) => {
    const cities = [
      { label: 'New York', value: 'ny' },
      { label: 'Los Angeles', value: 'la' },
      { label: 'Chicago', value: 'chi' }
    ];
    return of(cities.filter((city) =>
      city.label.toLowerCase().includes(search.toLowerCase())
    ));
  },
  config: {
    debounceMs: 300,
    minChars: 2
  }
}

// Observable-based autocomplete with HTTP service
{
  name: 'country',
  label: 'Country',
  type: FormFieldType.AUTOCOMPLETE,
  loadOptions: (search: string) =>
    this.httpService.searchCountries(search).pipe(
      map((countries) => countries.map(country => ({
        label: country.name,
        value: country.code
      })))
    ),
  config: {
    debounceMs: 200,
    minChars: 1
  }
}

Boolean Fields

{ name: 'agreeToTerms', label: 'I agree to terms', type: FormFieldType.CHECKBOX }
{ name: 'enableNotifications', label: 'Notifications', type: FormFieldType.SWITCH }

Advanced Fields

{ name: 'birthDate', label: 'Birth Date', type: FormFieldType.DATETIME, config: {
  format: 'YYYY-MM-DD'
} }
{ name: 'favoriteColor', label: 'Color', type: FormFieldType.COLOR, config: { view: 'pickerWithInput' } }
{ name: 'volume', label: 'Volume', type: FormFieldType.SLIDER, config: { min: 0, max: 100 } }
{ name: 'rating', label: 'Rating', type: FormFieldType.RATING, config: { max: 5 } }
{ name: 'avatar', label: 'Profile Picture', type: FormFieldType.FILE, { config: {
  accept: ['jpg', 'png'],
  maxSizeMb: 10,
  multiple: false,
  uploadText: 'upload your jpegs or pngs here'
}} }

Word Count Feature

{
  name: 'description',
  label: 'Description',
  type: FormFieldType.TEXTAREA,
  config: {
    wordCount: {
      enabled: true,
      maxWords: 150,
      showCharacters: true
    }
  }
}

Validation

Field-Level Validation

import { SignalValidators } from 'signal-template-forms';

{
  name: 'email',
  label: 'Email',
  type: FormFieldType.TEXT,
  validators: [
    SignalValidators.required(),
    SignalValidators.email(),
    SignalValidators.minLength(5)
  ]
}

Cross-Field Validation

{
  name: 'confirmPassword',
  label: 'Confirm Password',
  type: FormFieldType.PASSWORD,
  validators: [
    SignalValidators.required(),
    (value, form) => {
      const password = form.getField('password').value();
      return value === password ? null : 'Passwords must match';
    }
  ]
}

Async Validation

{
  name: 'username',
  label: 'Username',
  type: FormFieldType.TEXT,
  asyncValidators: [
    (value: string) =>
      this.userService.checkUsername(value).pipe(
        map((isAvailable) => isAvailable ? null : 'Username taken')
      )
  ]
}

Stepped Forms (Wizards)

const steppedForm = SignalFormBuilder.createSteppedForm({
  model: { personal: {}, contact: {}, preferences: {} },
  steps: [
    {
      title: "Personal Information",
      fields: [
        { name: "firstName", label: "First Name", type: FormFieldType.TEXT },
        { name: "lastName", label: "Last Name", type: FormFieldType.TEXT },
      ],
    },
    {
      title: "Contact Details",
      fields: [
        { name: "email", label: "Email", type: FormFieldType.TEXT },
        { name: "phone", label: "Phone", type: FormFieldType.TEXT },
      ],
    },
  ],
  onSave: (value) => this.submitForm(value),
});
<signal-form-stepper [form]="steppedForm" (afterSaveCompletes)="handleAfterSaveHasFinished()" />

Form Actions

Setting Field Values

// Set individual field values
form.getField("name").value.set("John Doe");
form.getField("email").value.set("john@example.com");

// Set field state
form.getField("email").disabled.set(true);
form.getField("name").error.set("Custom error");
form.getField("description").focus.set(true);

// Update field values reactively
form.getField("quantity").value.update((current) => current + 1);
form.getField("enabled").disabled.update((current) => !current);

Form-Level Actions

// Set complete form values (requires full model)
form.setValue({
  name: "John Doe",
  email: "john@example.com",
  age: 30,
});

// Patch partial form values
form.patchValue({
  email: "newemail@example.com",
  age: 31,
});

// Reset form to initial state
form.reset();

// Validate and save
if (form.validateForm()) {
  form.save();
}

Conditional Fields

{
  name: 'reason',
  label: 'Reason for leaving',
  type: FormFieldType.TEXTAREA,
  hidden: (form) => form.getField('isStaying').value() === true
}

{
  name: 'managerEmail',
  label: 'Manager Email',
  type: FormFieldType.TEXT,
  disabled: (form) => form.getField('hasManager').value() === false
}

Styling

CSS Color System

Signal Template Forms includes a comprehensive color system with automatic dark mode support:

:root {
  /* Base colors - customize these to rebrand your entire form library */
  --signal-forms-primary: #3b82f6; /* Blue */
  --signal-forms-accent: #8b5cf6; /* Purple */
  --signal-forms-success: #10b981; /* Green */
  --signal-forms-warning: #f59e0b; /* Amber */
  --signal-forms-info: #0ea5e9; /* Sky */
  --signal-forms-danger: #ef4444; /* Red */
}

Each color automatically generates a complete scale (50-950) using CSS color-mix():

  • 50-400: Light tints (mixed with white)
  • 500: Base color (your custom value)
  • 600-950: Dark shades (mixed with black)
/* These are automatically generated from your base colors */
--signal-forms-primary-50: color-mix(in srgb, var(--signal-forms-primary) 5%, white);
--signal-forms-primary-100: color-mix(in srgb, var(--signal-forms-primary) 10%, white);
/* ... up to 950 */

Dark Mode Support

to enable forms to use dark-mode simply use this provider within your apps config.

provideSignalFormsTheme({ darkMode: true }),

this allows you to decide if you want the forms to use dark mode or not. if you want to default the theme we can also use defaultTheme as an argument withing the provideSignalFormsTheme provider, this allows us to choose between 'light' | 'dark' | 'auto' and originally defaults to auto.

Theming Variables

Customize form appearance with CSS variables:

:root {
  /* Layout */
  --signal-form-padding: 1rem;
  --signal-form-border-radius: 4px;
  --signal-form-max-width: 600px;

  /* Colors (use semantic color tokens) */
  --signal-form-bg: var(--signal-forms-neutral-50);
  --signal-form-text: var(--signal-forms-neutral-700);
  --signal-form-border-color: var(--signal-forms-neutral-300);
  --signal-form-outline-focus: var(--signal-forms-primary-500);
  --signal-form-error-color: var(--signal-forms-danger-600);

  /* Typography */
  --signal-form-font-size-base: 1rem;
  --signal-form-font-size-sm: 0.75rem;

  /* Buttons */
  --signal-form-button-primary-bg: var(--signal-forms-primary-500);
  --signal-form-button-primary-bg-hover: var(--signal-forms-primary-600);

  /* Form spacing */
  --signal-form-fields-gap: 1rem;
  --signal-form-group-gap: 1rem;
}

Custom Brand Theme Example

:root {
  /* Custom brand colors */
  --signal-forms-primary: #ff6b35; /* Orange brand */
  --signal-forms-accent: #6c5ce7; /* Purple accent */
  --signal-forms-success: #2ecc71; /* Custom green */

  /* Adjust layout for your design */
  --signal-form-border-radius: 8px;
  --signal-form-padding: 1.5rem;
  --signal-form-max-width: 800px;
}

Layout Configurations

Standard Layouts

Configure form layout using the view property:

// Stacked layout (default)
{
  config: {
    view: "stacked";
  }
}

// Row layout (horizontal)
{
  config: {
    view: "row";
  }
}

// Collapsable sections
{
  config: {
    view: "collapsable";
  }
}

CSS Grid Layout with gridArea 🎯

The most powerful layout feature allows you to create custom CSS Grid layouts using gridArea:

const form = SignalFormBuilder.createForm({
  model: { name: "", email: "", phone: "", address: "", city: "", zip: "" },
  fields: [
    { name: "name", label: "Full Name", type: FormFieldType.TEXT },
    { name: "email", label: "Email", type: FormFieldType.TEXT },
    { name: "phone", label: "Phone", type: FormFieldType.TEXT },
    { name: "address", label: "Address", type: FormFieldType.TEXT },
    { name: "city", label: "City", type: FormFieldType.TEXT },
    { name: "zip", label: "ZIP Code", type: FormFieldType.TEXT },
  ],
  config: {
    layout: "grid-area",
    gridArea: [
      ["name", "name", "email"], // Row 1: name spans 2 cols, email 1 col
      ["phone", "phone", "phone"], // Row 2: phone spans all 3 cols
      ["address", "address", "address"], // Row 3: address spans all 3 cols
      ["city", "city", "zip"], // Row 4: city spans 2 cols, zip 1 col
    ],
  },
});

This creates a CSS Grid with:

  • 3 columns (based on array length)
  • 4 rows (based on gridArea array length)
  • Each field automatically gets grid-area: fieldName

Advanced Grid Examples

Complex Dashboard Layout:

config: {
  layout: 'grid-area',
  gridArea: [
    ['title', 'title', 'status', 'priority'],
    ['description', 'description', 'description', 'tags'],
    ['startDate', 'endDate', 'assignee', 'tags'],
    ['budget', 'category', 'assignee', 'tags'],
    ['notes', 'notes', 'notes', 'notes'],
  ]
}

Responsive Contact Form:

config: {
  layout: 'grid-area',
  gridArea: [
    ['firstName', 'lastName'],         // 2-column row
    ['email', 'phone'],               // 2-column row
    ['company', 'jobTitle'],          // 2-column row
    ['message', 'message'],           // Full-width message
    ['newsletter', 'submit'],         // Checkbox + submit
  ]
}

Use Empty Slots for Spacing:

config: {
  layout: 'grid-area',
  gridArea: [
    ['name', '.', 'email'],           // '.' creates empty grid cell
    ['address', 'address', 'address'],
    ['.', 'submit', '.'],             // Center the submit button
  ]
}

Grid Layout Benefits

  • βœ… Pixel-perfect layouts - No flexbox guesswork
  • βœ… Responsive by design - CSS Grid handles mobile gracefully
  • βœ… Visual layout definition - See your layout in the array structure
  • βœ… Automatic field positioning - No manual CSS grid-area declarations needed
  • βœ… Empty cell support - Use '.' for intentional spacing
  • βœ… Type-safe field names - TypeScript ensures field names exist

Custom Grid Styling

The grid container automatically gets:

.grid {
  display: grid;
  grid-template-areas: "name name email" "phone phone phone" /* ... */;
  gap: var(--signal-form-fields-gap);
  grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
}

Form Outputs

Template Output Pattern

<signal-form [form]="form" (onSave)="handleSave($event)" />

Callback Pattern

import { SignalFormBuilder } from "signal-template-forms";

form = SignalFormBuilder.createForm({
  model: myModel,
  fields: myFields,
  onSave: (value) => {
    console.log("Form saved with:", value);
    this.apiService.saveData(value);
  },
});

License

MIT @ Steven Dix

About

A template first signal forms approach built from the ground up

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published