Angular

Step-by-step integration of the components into an Angular application.

Angular needs one schema flag to accept the tags. After that its property bindings and event bindings map onto the components directly, with no wrapper library and no event-name translation.

1. Install

npm i tuix-webcomponents

Add the design tokens to the global styles:

angular.json
"styles": [
    "node_modules/tuix-webcomponents/dist/assets/tuix-styles.css",
    "src/styles.scss"
]

2. Register the elements

Importing the library registers every tag. Do it once, in main.ts, before the app bootstraps:

src/main.ts
import { bootstrapApplication } from '@angular/platform-browser'
import 'tuix-webcomponents'
import { AppComponent } from './app/app.component'
import { appConfig } from './app/app.config'
 
bootstrapApplication(AppComponent, appConfig).catch((error) => console.error(error))

With server-side rendering, that import has to stay out of the server bundle, because the elements extend HTMLElement, which the server does not have:

src/app/app.component.ts
import { Component, inject, PLATFORM_ID } from '@angular/core'
import { isPlatformBrowser } from '@angular/common'
 
@Component({/* … */})
export class AppComponent {
    constructor() {
        if (isPlatformBrowser(inject(PLATFORM_ID))) {
            void import('tuix-webcomponents')
        }
    }
}

3. Allow the tags

src/app/invoice-form.component.ts
import { CUSTOM_ELEMENTS_SCHEMA, Component } from '@angular/core'
 
@Component({
    selector: 'app-invoice-form',
    standalone: true,
    schemas: [CUSTOM_ELEMENTS_SCHEMA],
    templateUrl: './invoice-form.component.html',
})
export class InvoiceFormComponent {}

Without the schema Angular reports 'tuix-button' is not a known element.

4. Use them

[prop] sets a DOM property, so objects and arrays work as they are, and (event) listens with the exact name, including camelCase ones:

src/app/invoice-form.component.html
<tuix-input
    title="Company name"
    [value]="name"
    [iconRight]="IconName.BUILDING"
    [description]="error"
    [descriptionType]="error ? DescriptionType.ERROR : DescriptionType.TEXT"
    (on-change)="onNameChange($event)"
></tuix-input>
 
<tuix-dropdown title="Country" [options]="countries" [value]="selected" (select)="onSelect($event)"></tuix-dropdown>
 
<tuix-button [variant]="MainVariant.PRIMARY" [iconLeft]="IconName.SAVE" (customClick)="save()">Save</tuix-button>
import { Component } from '@angular/core'
import { DescriptionType, IconName, MainVariant } from 'tuix-webcomponents'
 
@Component({/* … */})
export class InvoiceFormComponent {
    readonly DescriptionType = DescriptionType
    readonly IconName = IconName
    readonly MainVariant = MainVariant
 
    readonly countries = [
        { name: 'Switzerland', value: 'CH' },
        { name: 'Germany', value: 'DE' },
    ]
 
    name = ''
    country?: string
    error?: string
 
    get selected() {
        return this.countries.find((option) => option.value === this.country)
    }
 
    onNameChange(event: Event) {
        this.name = (event as CustomEvent<{ value: string }>).detail.value
        this.error = undefined
    }
 
    onSelect(event: Event) {
        this.country = String((event as CustomEvent<{ value: string }>).detail.value)
    }
 
    save() {
        if (!this.name) {
            this.error = 'A company name is required'
            return
        }
 
        console.log(this.name, this.country)
    }
}

Enums used in a template have to be exposed as a field (readonly IconName = IconName), or written as plain strings: iconright="building".

5. Forms

The elements are not form controls, so formControlName needs a ControlValueAccessor. One small directive per element type is enough, and it is reusable across the app:

src/app/tuix/tuix-input.value-accessor.ts
import { Directive, ElementRef, forwardRef, inject } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
 
type TuixInputElement = HTMLElement & { value: string; disabled: boolean }
 
@Directive({
    selector: 'tuix-input[formControl], tuix-input[formControlName], tuix-input[ngModel]',
    standalone: true,
    host: {
        '(on-change)': 'onInput($event)',
        '(on-blur)': 'onTouched()',
    },
    providers: [
        {
            provide: NG_VALUE_ACCESSOR,
            useExisting: forwardRef(() => TuixInputValueAccessor),
            multi: true,
        },
    ],
})
export class TuixInputValueAccessor implements ControlValueAccessor {
    private readonly element = inject(ElementRef<TuixInputElement>).nativeElement
    private onChange: (value: string) => void = () => {}
 
    onTouched: () => void = () => {}
 
    writeValue(value: string | null) {
        this.element.value = value ?? ''
    }
 
    registerOnChange(fn: (value: string) => void) {
        this.onChange = fn
    }
 
    registerOnTouched(fn: () => void) {
        this.onTouched = fn
    }
 
    setDisabledState(isDisabled: boolean) {
        this.element.disabled = isDisabled
    }
 
    onInput(event: Event) {
        this.onChange((event as CustomEvent<{ value: string }>).detail.value)
    }
}

Import the directive into the component and the element behaves like any other control:

<form [formGroup]="form">
    <tuix-input title="Company name" formControlName="name"></tuix-input>
    <tuix-checkbox label="Send by email" formControlName="byEmail"></tuix-checkbox>
</form>

For the checkbox and the toggle, the same directive shape applies with (change) as the host listener and checked as the property.

6. Change detection

The events are plain DOM events, so Angular's zone picks them up and a template bound to the updated field re-renders on its own. With OnPush and a signal or an async pipe, nothing extra is needed either. A listener registered inside ngZone.runOutsideAngular is the one exception: it has to re-enter the zone with ngZone.run().

Troubleshooting

SymptomCause
'tuix-button' is not a known elementCUSTOM_ELEMENTS_SCHEMA is missing from the component or module
HTMLElement is not defined on the serverThe library is imported in code that runs during SSR; guard it with isPlatformBrowser
Can't bind to 'options'…The schema is missing; with it, unknown bindings are set as DOM properties
The control stays empty with formControlNameNo ControlValueAccessor; add the directive from step 5
IconName is not defined in a templateEnums must be exposed as a component field

On this page