Skip to content

Custom type controls

A playground has built-in controls for strings, numbers, booleans and unions of literals. For an input of any other type, such as an interface, write a type control: a small form component that edits a value of that type. Once you register it, every playground uses it for inputs of that type.

 See it

FloatingCircleComponent places a circle with its position input, an object with top and left:

100% ร— auto

NgDoc can't edit a FloatingCirclePosition by itself, so a playground of this component would skip the input. With a type control for the type, the inspector shows two fields instead:

Playground

Settings

 Use it

  1. Write a component that implements NgDocTypeControl. It is a ControlValueAccessor: the playground writes the value of the input with writeValue(), and the control reports changes through the function it gets in registerOnChange(). The control of this page is below.

  2. Register it for the type with provideTypeControl, in the providers of your application:

    app.config.tsTypeScript
    import { ApplicationConfig } from '@angular/core';
    import { provideTypeControl } from '@ng-doc/app';
    
    import { FloatingCirclePositionControlComponent } from './floating-circle-position-control.component';
    
    export const appConfig: ApplicationConfig = {
      providers: [
        provideTypeControl('FloatingCirclePosition', FloatingCirclePositionControlComponent, {
          hideLabel: true,
        }),
      ],
    };

    When only one page uses the control, you can register it in the providers of that page instead, as this page does.

  3. Render a playground of a component with an input of that type (Playgrounds).

The control of the playground above:

import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { NgDocTypeControl } from '@ng-doc/app';
import { EMPTY_FUNCTION } from '@ng-doc/core';
import {
  NgDocInputStringDirective,
  NgDocInputWrapperComponent,
  NgDocLabelComponent,
} from '@ng-doc/ui-kit';

import { FloatingCirclePosition } from '../floating-circle/floating-circle.component';

@Component({
  selector: 'ng-doc-floating-circle-position-control',
  templateUrl: './floating-circle-position-control.component.html',
  styleUrls: ['./floating-circle-position-control.component.css'],
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    ReactiveFormsModule,
    NgDocLabelComponent,
    NgDocInputWrapperComponent,
    NgDocInputStringDirective,
  ],
})
export class FloatingCirclePositionControlComponent
  implements NgDocTypeControl<FloatingCirclePosition>
{
  /** The default value of the input, set by the playground. */
  readonly default = input<FloatingCirclePosition>();

  protected readonly model = new FormGroup({
    top: new FormControl<string | null>(null),
    left: new FormControl<string | null>(null),
  });

  private touched: () => void = EMPTY_FUNCTION;
  private changed: (value: FloatingCirclePosition | null) => void = EMPTY_FUNCTION;

  constructor() {
    this.model.valueChanges
      .pipe(takeUntilDestroyed())
      .subscribe((value: FloatingCirclePosition) => this.changed(value));
  }

  writeValue(value: FloatingCirclePosition | null): void {
    // `null` means no value: show the default of the input.
    this.model.patchValue(
      { top: null, left: null, ...(value ?? this.default()) },
      { emitEvent: false },
    );
  }

  registerOnChange(fn: (value: FloatingCirclePosition | null) => void): void {
    this.changed = fn;
  }

  registerOnTouched(fn: () => void): void {
    this.touched = fn;
  }

  protected blur(): void {
    this.touched();
  }
}

Fields of a type control

Before the control renders, the playground sets these fields of NgDocTypeControl. Declare the ones you need, as signal inputs or as plain fields:

FieldTypeValue
namestringThe name of the input, or its label from the controls of the playground.
descriptionstringThe description of the input, from its doc comment, as HTML.
defaultthe type of the controlThe default value of the input.
optionsArray<string | NgDocPlaygroundOption>The members of the input's union type: code, such as "'small'", or an enum member's label and value.
isManualbooleantrue when the input comes from controls in the configuration of a playground.

resolvePlaygroundOption(option, isManual) from @ng-doc/app turns an option into the value it sets and the text that shows it.

The playground sets a field that the control declares as an input, a signal input or an @Input(), with setInput(), so ngOnChanges() reports it. It assigns a plain field. With signal inputs, read the fields by calling them, such as this.default().

Type the value of an input

The value that a control edits is the value the input accepts. InputType from @ng-doc/core resolves it from the component: for a signal input, it is the type the input is set with, before any transform; for an @Input() property, it is the property type.

position-control.component.tsTypeScript
import { NgDocTypeControl } from '@ng-doc/app';
import { InputType } from '@ng-doc/core';

import { FloatingCircleComponent } from './floating-circle.component';

// FloatingCirclePosition, read from `position = input<FloatingCirclePosition>(...)`
type Position = InputType<FloatingCircleComponent, 'position'>;

export class PositionControlComponent implements NgDocTypeControl<Position> {
  // ...
}

The control of this page names FloatingCirclePosition directly. InputType keeps a control in step with the input it edits when the input's type changes.

Registering types

NgDoc finds a type control by the text of the input's type, as TypeScript prints it. For a signal input, that is the type argument of input() or model(), required or not. So these inputs need different registrations:

floating-circle.component.tsTypeScript
// Registered as 'FloatingCirclePosition'
readonly position = input<FloatingCirclePosition>({ top: '10px', left: '10px' });
readonly position = input.required<FloatingCirclePosition>();

// Registered as 'FloatingCirclePosition | undefined'
readonly position = input<FloatingCirclePosition>();
@Input() position?: FloatingCirclePosition;

One control can serve several types. Make sure it handles every value of each type, here undefined:

app.config.tsTypeScript
providers: [
  provideTypeControl('FloatingCirclePosition', FloatingCirclePositionControlComponent, {
    hideLabel: true,
  }),
  provideTypeControl('FloatingCirclePosition | undefined', FloatingCirclePositionControlComponent, {
    hideLabel: true,
  }),
],

The built-in string, number and boolean controls need no such registration: they also edit optional and nullable inputs of their type, such as @Input() label?: string, input<string>(), model<number>() or input<boolean | null>(null). Your own types match by their exact text only.

In development, the browser console names the type of every input that a playground skips, so you can copy the text to register.

Controls for array inputs

An array input, such as tags = input<string[]>([]), has no built-in control. Register a control for its type text, string[], like for any other type. This one edits the array as comma-separated text:

string-list-control.component.tsTypeScript
import { ChangeDetectionStrategy, Component, input, signal } from '@angular/core';
import { NgDocTypeControl } from '@ng-doc/app';

@Component({
  selector: 'app-string-list-control',
  template: `<input [value]="text()" (input)="update($event)" (blur)="touched()" />`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StringListControlComponent implements NgDocTypeControl<string[]> {
  readonly default = input<string[]>();

  protected readonly text = signal('');
  protected touched: () => void = () => {};
  private changed: (value: string[]) => void = () => {};

  writeValue(value: string[] | null): void {
    // `null` means no value: show the default of the input.
    this.text.set((value ?? this.default() ?? []).join(', '));
  }

  registerOnChange(fn: (value: string[]) => void): void {
    this.changed = fn;
  }

  registerOnTouched(fn: () => void): void {
    this.touched = fn;
  }

  protected update(event: Event): void {
    const text = (event.target as HTMLInputElement).value;

    this.text.set(text);
    this.changed(
      text
        .split(',')
        .map((item: string) => item.trim())
        .filter(Boolean),
    );
  }
}
app.config.tsTypeScript
providers: [
  provideTypeControl('string[]', StringListControlComponent),
  // For `input<string[]>()` and `@Input() tags?: string[]`.
  provideTypeControl('string[] | undefined', StringListControlComponent),
],

The same works for arrays of other types, such as number[] or Item[]: the control parses the text, or offers a form row per item, and reports the new array.

 Options

The third argument of provideTypeControl takes NgDocTypeControlProviderOptions:

OptionTypeDefaultDescription
hideLabelbooleanfalseHides the row's label, with the input's name and type chip, for a control that has its own.
ordernumberโ€“The position of the control's rows in the inspector, lowest first.
labelWrapperbooleantrueWraps the row in a <label>. Set false for a control with several interactive parts.

The inspector lists the inputs whose controls have an order first, by that order, and then the others by name. The built-in controls have these orders:

ControlTypeOrder
A list of the values of a unionNgDocTypeAlias10
A text fieldstring20
A number fieldnumber30
A checkboxboolean40

Controls with several interactive parts

Each row of the inspector is a <label> around the control. A click anywhere in a label also clicks the first field inside it, so a control with several parts, such as a list with a filter field or a set of buttons, reacts to clicks it shouldn't. Set labelWrapper: false and the row becomes a <div> instead, named by its caption for assistive technology:

app.config.tsTypeScript
provideTypeControl('Items', ItemsControlComponent, { labelWrapper: false }),

The built-in controls keep their label.

Appearance

Build the control from the components of @ng-doc/ui-kit, as the built-in controls do, so it matches the inspector: NgDocInputWrapperComponent with NgDocInputStringDirective or NgDocInputNumberDirective for fields, NgDocLabelComponent for labels, and NgDocCheckboxComponent for checkboxes. The inspector sets these variables:

VariableDefaultDescription
--ng-doc-playground-inspector-width312pxThe width of the inspector beside the demo.
--ng-doc-playground-input-border--ng-doc-input-borderThe border of the inputs in the inspector.

 Gotchas

Warning

Type names are compared as text, so a registration of FloatingCirclePosition|undefined, without the spaces that TypeScript prints, matches no input.

๐Ÿ’ก Tip

A control registered in the providers of a page is registered when the code of that page loads. Register controls that the playgrounds of several pages use in the providers of your application.

Next: Generate API pages

Edit this page