19.2.2 • Published 5 months ago

@mmstack/form-material v19.2.2

Weekly downloads
-
License
MIT
Repository
github
Last release
5 months ago

@mmstack/form-material: Angular Material Components & bindings for @mmstack signal forms

If you're using @mmstack/form-core and Angular Material, this library is the quickest way to build your forms.

npm version License

A library to bridge the gap between the declarative, signal-based form state management provided by @mmstack/form-core and @mmstack/form-adapters, and the rich UI components of Angular Material.

It offers a collection of reusable, standalone Angular components (e.g., <mm-string-field>, <mm-select-field>, <mm-date-field>) designed to directly consume the state objects (StringState, SelectState, DateState, etc.) from @mmstack/form-adapters.

Features:

  • Effortless Binding: Directly bind your form state signals (value, label, disabled, error, etc.) to Material components.
  • Material Styling: Uses standard Angular Material components (mat-form-field, matInput, matSelect, etc.) ensuring theme consistency.
  • Reduced Boilerplate: Focus on your form logic, not the UI wiring.

Manual Installation

  1. Install @mmstack/form-core.

    npm install @mmstack/form-core
  2. Add global styles to styles in angular.json

    "targets": {
      "build": {
        "styles": [
          "node_modules/@mmstack/form-material/styles/globals.css"
        ]
      }
    }

Core primitives

This library utilizes & re-exports @mmstack/form-core. This core library provides fully signal-based & type-safe form primitives, which you can use to build your own forms or control components. You can read more about the philosophy of these primitives & why we created them here: Fun-grained reactivity in Angular: Part 2 – Forms

@mmstack/form-core provides the foundational building blocks for the @mmstack signal-based forms ecosystem. It addresses the challenges of maintaining a predictable reactive graph when dealing with forms, especially with nested objects/arrays and the limitations of [(ngModel)] in a signal-based, zoneless, future.

The library focuses on ensuring that changes to any part of the form state properly trigger updates throughout the signal dependency graph, enabling fine-grained reactivity and avoiding unexpected mutations.

derived<T, U>(
  source: WritableSignal<T>,
  options: {
    from: (v: T) => U,
    onChange: (newValue: U) => void
  }
): DerivedSignal<T, U>

There is also a helpful overload for pure objects/arrays:

const value = signal({ name: 'John' });
const derivation = derived(value, 'name'); // WritableSignal<string>

This function creates a special WritableSignal (a DerivedSignal) that represents a piece of data (U) derived from a parent WritableSignal (source).

from: Extracts the child value (U) from the parent value (T). onChange: Defines how to update the parent signal (source) when the derived signal's value is set. Crucially, derived establishes a bi-directional reactive link:

Changes to the source signal automatically update the derived signal's value (like a computed). Setting the derived signal's value uses the onChange function to update the source signal immutably, ensuring the change propagates correctly through the reactive graph.

This primitive is essential for creating form structures where changes to individual field controls reliably update the overall form state signal.

Core Form Primitives (@mmstack/form-core)

Building upon derived and standard Angular Signals, the library offers three main primitives for structuring form state:

formControl<T, TParent = undefined>

This is the most basic building block, representing the state of a single input field. It wraps a value T, which can be a plain value/signal or, crucially, a DerivedSignal<TParent, T> linking it to a parent state. It manages the core form state signals like value. When initialized with a DerivedSignal, it ensures changes flow reactively between the control and its parent. This primitive serves as the foundation for more specialized field types defined in adapter libraries.

formGroup<T extends object, TDerivations extends Record<string, FormControlSignal<any, T, any, any>>, TParent = undefined>

Use formGroup to manage a structured collection of named controls, representing an object T (which is often derived from a TParent signal). It holds a children signal containing a map (TDerivations) where each value is a FormControlSignal. These child controls must be derived from the group's value T. The formGroup aggregates status (like valid, dirty, touched) while also controlling its own state. It efficiently propagates actions like markAllAsTouched or reconcile down to its children, utilizing the from property (inherited from their DerivedSignal inputs) to correctly update or reset them based on changes to the group's value.

formArray<T[], TIndividualState extends FormControlSignal<T, any, any, any>, TParent = undefined>

This primitive manages a dynamic list of controls, allowing controls to be added or removed at runtime. It takes an initial array value (plain or DerivedSignal<TParent, T>) and a factory function. This factory is key: it receives a DerivedSignal<T[], T> representing a single element's value and position within the array signal, and it returns the corresponding child FormControlSignal (TIndividualState). This ensures each child control is reactively and correctly linked to its specific element. formArray aggregates status, provides array manipulation methods (push, remove), signals like canAdd/canRemove, and features an optimized reconciliation mechanism that reuses existing child control instances when the array changes.

Validation (@mmstack/form-validation)

Forms aren't complete without validation. @mmstack/form-material provides a built-in, type-safe, and localizable validation system, by re-exporting the generic @mmstack/form-validation library.

The validation library provides a way to generate type-safe & consisten error messages accross the various components for example:

import { injectValidators } from '@mmstack/form-material';

export class DemoComponent {
  private readonly validators = injectValidators();

  demo1 = validators.general.required(); // validator which returns "Field is required" when called with null/undefined/empty value
  demo2 = validators.general.required('Name'); // validator which returns "Name is required" when called with null/undefined/empty value
  demo3 = validators.number.min(3); // validators which returns "Must be at least 3" when called with number less than 3
}

If you require localized messages, or would like to modify defaults you can do so easily by providing the message creation functions in your app.config.ts

import { provideValidatorConfig } from '@mmstack/form-material';

export const appConfig: ApplicationConfig = {
  providers: [
    // ..rest

    // injects LOCALE_ID
    provideValidatorConfig<DateTime>(
      (locale) => {
        switch (locale) {
          case 'sl-SI':
            return {
              general: {
                required: () => 'To polje je obvezno', // provide localized validator
              },
            };
          default: {
            return {
              general: {
                // label variable is fully type-safe
                required: (label) => `This ${label} is required`,
              },
            };
          }
        }
      },
      // provide a custom toDate function if you're using non-date objects like Luxon's DateTime or Moment
      (dateTime) => dateTime.toJSDate();
    ),
  ],
};

Form adapters (@mmstack/form-adapters)

The @mmstack/form-adapters library plays a crucial role in decoupling specific form field logic from any particular UI library implementation. It allows us to generalize state adapters so functions like createStringState are applicable whether you're using Angular Material, PrimeNG, Bootstrap, or your own custom components. These adapter primitives are available directly in the @mmstack/form-adapters package but are also conveniently re-exported by @mmstack/form-material.

Purpose

Adapters take the foundational primitives from @mmstack/form-core (primarily formControl) and enhance them to create standardized, type-specific state objects for common form field types (e.g., StringState, NumberState, SelectState, DateState).

These state objects bundle the core FormControlSignal properties (value, error, touched, etc.) with additional UI-relevant signals and configurations tailored to the field type, such as:

  • placeholder (for text inputs)
  • options, valueLabel, equal (for select/autocomplete)
  • min, max (for date/number)
  • rows, autosize (for textarea)
  • A type discriminator (e.g., 'string', 'select') to allow us to dynamically assert control types in our logic/templates

By defining these common state shapes, UI integration libraries (like @mmstack/form-material) can simply consume these adapters, knowing exactly what properties and signals are available for binding, regardless of the underlying UI components being used.

createXState (e.g., createStringState)

  • This is the pure, low-level function for creating the adapter state.
  • It does not use Angular's Dependency Injection.
  • It requires you to manually provide the fully configured options, including the final validator function itself (e.g., validator: () => validators.string.minLength(5)). You are responsible for accessing validators and constructing the validation logic.
  • Use Case: Useful when Dependency Injection is not readily available (e.g., outside of Angular's injection context) or when you need absolute control over validator creation and configuration.
template: `
  <mm-string-field [state]="state" />
`;
export class DemoComponent {
  state = createStringState('hello world!', {
    label: () => 'Greeting',
  });
}

injectCreateXState (e.g., injectCreateStringState)

  • This function utilizes Angular's Dependency Injection (specifically injectValidators).
  • It offers a more convenient, higher-level API for creating state with integrated validation.
  • Instead of a raw validator function, it accepts a validation option which is typically a function returning a configuration object specific to the validator type (e.g., validation: () => ({ required: true, minLength: 5 }) which uses StringValidatorOptions).
  • It automatically uses the injected validators service (e.g., calling validators.string.all(...) internally) based on the validation options provided.
  • It also typically handles deriving the required flag automatically from the validation options.
  • Use Case: This is generally the recommended approach when working within an Angular application that uses the @mmstack/form-validation library. It mirrors our internal usage, reduces boilerplate, and simplifies integrating standard validation rules.
function injectDemoState() {
  const stringFactory = injectCreateStringState();

  return stringFactory('hello world!', {
    label: () => 'Greeting',
    validation: () => ({
      required: true,
      minLength: 255,
      //...other string validator options
    }),
  });
}

template: `
  <mm-string-field [state]="state" />
`;
export class DemoComponent {
  state = injectDemoState();
}

Adapters

Here's a summary of the core form state adapters provided by @mmstack/form-adapters:

Adapter TypeState TypeValue TypeKey UI Properties/SignalsCreation Functions
BooleanBooleanStatebooleanlabelPositioncreateBooleanState, injectCreateBooleanState
Toggle (Boolean)ToggleStatebooleanlabelPosition (inherits from Boolean)createToggleState, injectCreateToggleState
DateDateState<TDate = Date>TDate \| nullmin, max, placeholdercreateDateState, injectCreateDateState
TimeTimeState<TDate = Date>TDate \| nullmin, max, placeholder, interval, optionscreateTimeState, injectCreateTimeState
DateTimeDateTimeState<TDate = Date>TDate \| nullmin, max, placeholder, timeControl, dateControlcreateDateTimeState, injectCreateDateTimeState
Date RangeDateRangeState<TDate = Date>DateRange<TDate>min, max, placeholder, childrencreateDateRangeState, injectCreateDateRangeState
NumberNumberStatenumber \| nullplaceholder, stepcreateNumberState, injectCreateNumberState
StringStringStatestring \| nullplaceholder, autocomplete (HTML attr)createStringState, injectCreateStringState
Autocomplete (String)AutocompleteStatestring \| nullplaceholder, options, panelWidth, displayWithcreateAutocompleteState, injectCreateAutocompleteState
Textarea (String)TextareaStatestring \| nullplaceholder, rows, minRows, maxRows, autosizecreateTextareaState, injectCreateTextareaState
Select (Single)SelectState<T>Tplaceholder, options, valueLabel, identify, display, equalcreateSelectState, injectCreateSelectState
Multi-SelectMultiSelectState<T extends any>T (e.g., string)placeholder, options, identify, display, equal (for items)createMultiSelectState, injectCreateMultiSelectState
Button Group (Select)ButtonGroupState<T>Toptions, identify, display, equal, hideSingleSelectionIndicator, verticalcreateButtonGroupState, injectCreateButtonGroupState
Search (Async Select)SearchState<T>Tplaceholder, searchPlaceholder, query, request, identify, displayWith, equalcreateSearchState, injectCreateSearchState

Simple example

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { createStringState, StringFieldComponent } from '@mmstack/form-material';

@Component({
  selector: 'app-input-demo',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [StringFieldComponent],
  template: ` <mm-string-field [state]="state" /> `,
  styles: ``,
})
export class FormComponent {
  protected readonly state = createStringState('hello world!', {
    label: () => 'Greeting',
    required: () => true,
    validator: () => (value) => (value === 'hello world!' ? '' : 'Must be "hello world!"'),
  });
}

Build your own form sub-components easily with formGroup & adapter primitives

No body likes 1 giant form component :) @mmstack/form-material & related libraries are made to create re-usable & nicely divided from state logic & components

import { ChangeDetectionStrategy, Component, input, isSignal, signal } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { derived, DerivedSignal, formGroup, FormGroupSignal, injectCreateStringState, injectCreateTextareaState, StringFieldComponent, StringState, TextareaFieldComponent, TextareaState } from '@mmstack/form-material';

export type Note = {
  title: string;
  body: string;
};

type NoteState<TParent = undefined> = FormGroupSignal<
  Note,
  {
    title: StringState<Note>;
    body: TextareaState<Note>;
  },
  TParent
>;

export function injectCreateNoteState() {
  const stringFactory = injectCreateStringState();
  const textareaFactory = injectCreateTextareaState();
  return <TParent = undefined>(value: Note | DerivedSignal<TParent, Note>): NoteState<TParent> => {
    const valueSignal = isSignal(value) ? value : signal(value);

    const title = stringFactory(derived(valueSignal, 'title'), {
      label: () => 'Subject',
      validation: () => ({
        required: true,
        trimmed: true,
        maxLength: 100,
      }),
    });

    return formGroup(valueSignal, {
      title,
      body: textareaFactory(derived(valueSignal, 'body'), {
        label: () => 'Note',
        // The validation options function re-runs when dependencies like title.value() change,
        // ensuring validators like 'not' use the latest values.
        validation: () => ({
          required: true,
          trimmed: true,
          maxLength: 1000,
          not: title.value(), // cant be the same as title
        }),
      }),
    });
  };
}

@Component({
  selector: 'app-note',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [MatCardModule, StringFieldComponent, TextareaFieldComponent],
  template: `
    <mat-card>
      <mat-card-header>
        <mat-card-title>{{ state().value().title }}</mat-card-title>
      </mat-card-header>
      <mat-card-content>
        <mm-string-field [state]="state().children().title" />
        <mm-textarea-field [state]="state().children().body" />
      </mat-card-content>
    </mat-card>
  `,
  styles: ``,
})
export class NoteComponent<TParent = undefined> {
  readonly state = input.required<NoteState<TParent>>();
}

Available components

Here's a simple table show casing all components. They are 1-1 matched with the state adapters.

Component SelectorRequired State Input ([state])Core Material UI Element(s)Description
<mm-string-field>StringStatematInputStandard text input field.
<mm-textarea-field>TextareaStatetextarea[matInput], cdkTextareaAutosizeText area input field, supports auto-sizing.
<mm-number-field>NumberStateinput[type=number][matInput]Input field specifically for numeric values.
<mm-boolean-field>BooleanStatematCheckboxCheckbox for boolean values. (Uses custom layout for hint/error).
<mm-toggle>ToggleStateMatSlideToggleToggle switch for boolean values. (Uses custom layout for hint/error).
<mm-date-field>DateStatematInput, matDatepickerInput field with a date picker integration.
<mm-date-range-field>DateRangeStatematInput, matDateRangePickerInput fields with a date-range picker integration
<mm-select-field>SelectState<T>matSelect, matOptionDropdown select for choosing a single option from a static list.
<mm-multi-select-field>MultiSelectState<T>matSelect[multiple], matOptionDropdown select for choosing multiple options from a static list.
<mm-button-group>ButtonGroupState<T>MatButtonToggleGroup, MatButtonToggleGroup of toggle buttons for selecting a single option from a static list.
<mm-autocomplete-field>AutocompleteStatematInput, matAutocomplete, matOptionText input with typeahead suggestions based on a static list of options.
<mm-search-field>SearchState<T>matSelect, matOption, matInputDropdown select populated via an asynchronous request, with built-in search/filter input.
19.2.2

5 months ago

19.2.1

5 months ago

19.2.0

5 months ago

19.1.11

5 months ago

19.1.10

5 months ago

19.1.9

5 months ago

19.1.8

5 months ago

19.1.7

5 months ago

19.1.6

6 months ago

19.1.5

6 months ago

19.1.4

6 months ago

19.1.3

6 months ago

19.1.2

7 months ago

19.1.1

7 months ago

19.1.0

7 months ago

19.0.18

7 months ago

19.0.17

7 months ago

19.0.16

7 months ago

19.0.15

7 months ago

19.0.14

7 months ago

19.0.13

7 months ago

19.0.12

7 months ago

19.0.11

7 months ago

19.0.10

7 months ago

19.0.9

7 months ago

19.0.8

7 months ago

19.0.7

7 months ago

19.0.6

7 months ago

19.0.5

7 months ago

19.0.4

7 months ago

19.0.3

7 months ago

19.0.2

7 months ago

19.0.1

7 months ago

19.0.0

7 months ago