@scania-nl/tegel-angular-extensions
v0.0.10
Published
   
- Runtime-required key enforcement (production only)
- Strong TypeScript inference for dev and prod static environments
runtimeKeysis optional; defaults to no runtime-required keys
- Toast management
- Signal-based
ToastServicefor displaying toasts - Customizable toast appearance and behavior
- Signal-based
- Modal management
- Strongly typed modal API
- Dynamic body content (string, template, or component)
- Configurable actions and lifecycle callbacks
- App update management
- Automatic service worker update detection and polling
- Signal-based update state (
updatePending,updateAvailable) - Built-in toast and dialog handlers
- Silent update detection (skipWaiting + clients.claim)
- Custom handler API for your own notification UI
- Standalone components and directives
- Drop-in UI integrations for Tegel Angular
- Object utilities
- Type-safe wrappers for
Object.keys,Object.entries, andReflect.ownKeys
- Type-safe wrappers for
- Default Nginx config
All features require no Angular modules — everything is provided via Angular's standalone DI system, fully typed, and built for Angular 19+.
Table of Contents
- @scania-nl/tegel-angular-extensions
- Table of Contents
- Installation
- Environment Configuration Overview
- Logger
- Toasts
- Modals
- App Update
- Components & Directives
- RxJS Utilities
- Object Utilities
- Appendix
- License
Installation
npm install @scania-nl/tegel-angular-extensions @scania/tegel-angular-17 @traversable/zod zodIf you are using the App Update feature, also install @angular/service-worker (or run ng add @angular/pwa which installs it automatically):
npm install @angular/service-workerNote:
@scania/tegel-angular-17,@traversable/zod,zod, and@angular/service-workerare peer dependencies and must be installed separately.
When creating an Angular project, the following dependencies already should have been installed:
{
"@angular/common": ">=19",
"@angular/core": ">=19",
"@angular/router": ">=19",
"rxjs": ">=7.8.0"
}
Environment Configuration Overview
The runtime-config system provides:
- Type-safe configuration via Zod schemas
- Static environment validation
- Runtime overrides via .env files (shell scripts included)
- Guaranteed config availability before app bootstrap
Defining your schema with createEnvKit
Create a local file: src/environments/environment-config.ts
import { InjectionToken } from '@angular/core';
import { createEnvKit } from '@scania-nl/tegel-angular-extensions';
import { z } from 'zod';
export const EnvKit = createEnvKit({
schema: z
.object({
envType: z.enum(['dev', 'staging', 'prod']),
production: z.boolean(),
backendUrl: z.string().url(),
feature: z
.object({ enabled: z.boolean(), timeout: z.number() })
.optional(),
})
.strict(),
// runtimeKeys is optional; omit it entirely if all fields come from the static env.
// Only list the keys that must come from the runtime .env file in production.
runtimeKeys: {
backendUrl: true, // must come from the runtime .env file in production
feature: { timeout: true }, // only feature.timeout is runtime-required; feature.enabled stays from the static env
},
});
// Per-branch static env types; use these to type environment.ts files
export type DevStaticEnv = typeof EnvKit.types.DevStaticEnv;
export type ProdStaticEnv = typeof EnvKit.types.ProdStaticEnv;
// Union type; use when a single type covering both branches is needed
export type StaticEnv = typeof EnvKit.types.StaticEnv;
// Injection token for the fully-validated runtime config
export const ENV_CONFIG = new InjectionToken<typeof EnvKit.types.EnvConfig>(
'ENV_CONFIG',
);taeSchema
Add tae: taeSchema.optional() to your env schema to control the library's own internal behaviour (log levels, colors) from your environment config. taeSchema is the library's own config block — it extends withLogConfig so tae.logLevel and tae.logColor cascade to all library-internal tae:* loggers (tae:env, tae:appUpdate, tae:barcodeScanner, tae:hardRefresh).
import {
createEnvKit,
taeSchema,
withLogConfig,
} from '@scania-nl/tegel-angular-extensions';
export const EnvKit = createEnvKit({
schema: z
.strictObject({
backendUrl: z.string().url(),
tae: taeSchema.optional(),
})
.extend(withLogConfig.shape),
runtimeKeys: { backendUrl: true },
});Nested runtimeKeys
runtimeKeys mirrors the schema shape and can be specified at any depth. Marking a key true makes the entire field runtime-required. Providing a nested map instead lets you mark only specific sub-keys; the rest of the object is still taken from the static env.
runtimeKeys: {
backendUrl: true, // entire field must come from .env in prod
feature: { timeout: true }, // only feature.timeout from .env; feature.enabled stays from static env
}Partial override semantics
The runtime .env file only needs to supply the keys it overrides. Everything else is taken from the static env and left unchanged. For example, if feature is defined in the static env as { enabled: true, timeout: 5000 } and the .env file only sets feature__timeout=3000, the merged result is { enabled: true, timeout: 3000 } with enabled preserved from the static env.
This means the dev environment.development.ts always acts as the complete baseline: every field has a value, and the runtime .env file only overrides what needs to differ per deployment.
Runtime .env key convention
Nested schema fields are represented in the .env file using __ (double underscore) as a nesting delimiter. Keys in the .env file must match the schema field names exactly and are case-sensitive.
| .env key | Schema field |
| ------------------- | ----------------- |
| backendUrl | backendUrl |
| feature__timeout | feature.timeout |
| service__db__port | service.db.port |
Example .env file:
# Flat key
backendUrl=https://api.example.com
# Nested key (feature.timeout in schema)
feature__timeout=3000
# Deeply nested (service.db.port in schema)
service__db__port=5432Docker env var convention: The
extract-env-vars.shscript strips theNG__prefix from Docker environment variables and writes the remainder verbatim as keys. To producebackendUrl=...in the.envfile, set the Docker env var toNG__backendUrl=.... The casing after the prefix must match the schema field name exactly.
nullvalues: SettingbackendUrl=null(the string"null") in the.envfile signals an explicitly providednullvalue and satisfies the runtime-required check. An absent key (not written in the.envat all) is treated asundefinedand triggers a missing-key error in production for any field listed inruntimeKeys.
Defining Static Environments (Dev/Prod)
Initialize the environments using Nx:
nx g environmentsThis will create two files: environment.ts for the production environment configuration and environment.development.ts for the local development environment configuration.
environment.development.ts: all schema fields must be provided (complete, locally-runnable configuration):
import { DevStaticEnv } from './environment-config';
export const environment = {
envType: 'dev',
production: false,
backendUrl: 'https://localhost:3000',
feature: { enabled: true, timeout: 5000 },
} satisfies DevStaticEnv;environment.ts: runtime-required keys must be omitted and are supplied by the container at startup:
import { ProdStaticEnv } from './environment-config';
export const environment = {
envType: 'prod',
production: true,
feature: { enabled: true }, // backendUrl and feature.timeout omitted; must come from .env
} satisfies ProdStaticEnv;
DevStaticEnvrequires all schema fields.ProdStaticEnvforbids any field markedtrueinruntimeKeys; TypeScript will error if you accidentally include one. UseStaticEnv(the union type) only when a single type covering both branches is needed, for example as the parameter type of a helper function.
Providing Runtime Configuration
Add to app.config.ts. Use .thenProvide() to co-register providers that depend on the config being ready:
import { ApplicationConfig } from '@angular/core';
import {
provideRuntimeConfig,
provideLogger,
} from '@scania-nl/tegel-angular-extensions';
import { EnvKit, ENV_CONFIG } from '../environments/environment-config';
import { environment } from '../environments/environment';
export const appConfig: ApplicationConfig = {
providers: [
// ...
provideRuntimeConfig(EnvKit, environment, {
envPath: '/env/runtime.env', // default
debug: isDevMode(), // activates tae:env debug namespace at startup
stopOnError: true, // when false, boots with static env on error
token: ENV_CONFIG,
}).thenProvide(
// providers placed here automatically await the config before running
provideLogger(ENV_CONFIG),
),
],
};Internal logging:
provideRuntimeConfiglogs load-pipeline activity to thetae:envloglevel namespace (exported asENV_DEBUG_NAMESPACE). Enable it at runtime viadebug: true,getLogger('tae:env').setLevel('debug')in the browser console, or by includingtae.logLevelin your environment schema and configuring it viaprovideLogger.
Type:
provideRuntimeConfigreturns aRuntimeConfigProvidersobject (exported from the package). This extendsEnvironmentProvidersso it can be placed directly in theprovidersarray, and adds the.thenProvide()method.
Using the ENV_CONFIG Token
import { inject } from '@angular/core';
import { ENV_CONFIG } from '../environments/environment-config';
@Injectable()
export class BackendService {
private readonly config = inject(ENV_CONFIG);
getBaseUrl(): string {
return this.config.backendUrl;
}
}At startup the config is loaded from /env/runtime.env, merged with the static environment, and validated. The result is exposed via the ENV_CONFIG injection token. Runtime-required keys are enforced only in production — the development environment always provides all fields. The runtime .env file is generated by the container on startup using the shell scripts included in this package.
Runtime Configuration Binaries
This package ships with two lightweight shell scripts used to generate a runtime configuration file inside the container. They enable true runtime configurability without rebuilding the Angular image. Additionally, the package contains a default nginx.conf optimized for Angular application. The files are located in the /docker directory.
docker-entrypoint.sh
- Entry point executed every time the container starts
- Calls the
extract-env-vars.shto generate a freshruntime.env - Lastly, executes the provided Dockerfile
CMD
extract-env-vars.sh
- Reads all container environment variables matching a prefix (default:
NG__) - Strips the prefix and writes cleaned
KEY=VALUEpairs toruntime.env - Supports nested keys via
__delimiter (e.g.NG__tae__logLevel→tae.logLevelin the schema) - Defaults output to
/usr/share/nginx/html/env/runtime.env
nginx.conf
A default Nginx configuration optimized for Angular applications. It provides:
- Performance tuning for static file serving
- Browser caching of compiled assets
- Gzip compression where supported
- Automatic fallback to
index.htmlfor client-side routing
Example Docker Commands
# Copy the shell scripts to /usr/local/bin
COPY --from=build /app/node_modules/@scania-nl/tegel-angular-extensions/docker/*.sh /usr/local/bin/
# Ensure they shell scripts are executable
RUN chmod +x /usr/local/bin/*
# Use the shared entrypoint from the @scania-nl/tegel-angular-extensions npm package
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
# Start the nginx web server in the foreground and keep it running
CMD ["nginx", "-g", "daemon off;"]For a complete Dockerfile example with the shipped nginx.conf config, refer to Runtime Config Dockerfile Example
Logger
The library wraps loglevel with config-driven log levels and colors per namespace, a [name] prefix on every message, and correct routing to console.debug/info/warn/error for accurate devtools level filtering. This means you control log verbosity per feature from your environment files, with no code changes needed when switching between dev and prod.
A namespace is a string identifier for a logger, typically matching your feature or service name. Nested namespaces use : as a separator (e.g. payments:retry).
Quick start
1. Add withLogConfig to your env schema so features can be configured with a log level and color:
// environment-config.ts
import {
createEnvKit,
withLogConfig,
taeSchema,
} from '@scania-nl/tegel-angular-extensions';
export const EnvKit = createEnvKit({
schema: z
.strictObject({
apiUrl: z.string(),
payments: withLogConfig.extend({ endpoint: z.string() }),
tae: taeSchema.optional(),
})
.extend(withLogConfig.shape), // adds logLevel + logColor to the root config
runtimeKeys: { apiUrl: true },
});2. Set log levels in your static environment file:
// environment.development.ts
export const environment = {
apiUrl: 'http://localhost:3000',
logLevel: 'warn', // root default for all loggers
payments: {
endpoint: '/api/payments',
logLevel: 'debug', // payments and all payments:* loggers get debug
logColor: '#4CAF50', // green prefix in devtools
},
tae: { logLevel: 'warn' },
} satisfies DevStaticEnv;3. Register provideLogger inside .thenProvide() in app.config.ts:
provideRuntimeConfig(EnvKit, environment, { token: ENV_CONFIG })
.thenProvide(
provideLogger(ENV_CONFIG),
),4. Create loggers in your services and components:
import { getLogger } from '@scania-nl/tegel-angular-extensions';
@Injectable({ providedIn: 'root' })
export class PaymentsService {
readonly #log = getLogger('payments');
fetch() {
this.#log.debug('fetching payments'); // → [payments] fetching payments
this.#log.warn('rate limit hit'); // → [payments] rate limit hit
}
}That's it. getLogger('payments:retry') will also automatically inherit the debug level from payments with no extra config needed.
Schemas
These are the Zod building blocks you add to your environment schema to enable logger configuration per feature.
withLogConfig
A Zod object schema with two optional fields:
| Field | Type | Description |
| ---------- | --------------------------------------------------------------- | ------------------------------------------------------------- |
| logLevel | 'trace' \| 'debug' \| 'info' \| 'warn' \| 'error' \| 'silent' | Log level for this namespace. 'silent' disables all output. |
| logColor | any CSS color string | Colors the [namespace] prefix in the browser devtools. |
Use it in your environment schema:
import { withLogConfig } from '@scania-nl/tegel-angular-extensions';
import type { LogConfig } from '@scania-nl/tegel-angular-extensions';
// LogConfig = { logLevel?: LogLevel; logColor?: string }import { withLogConfig } from '@scania-nl/tegel-angular-extensions';
export const EnvKit = createEnvKit({
schema: z
.strictObject({
// Feature with logging support only:
scanner: withLogConfig.optional(),
// Feature with logging + its own fields.
// Two equivalent ways — pick one:
payments: withLogConfig.extend({ endpoint: z.string() }),
// or, starting from your own schema:
// payments: z.object({ endpoint: z.string() }).extend(withLogConfig.shape),
// Root config supporting logLevel + logColor at the top level:
})
.extend(withLogConfig.shape),
});provideLogger
Call provideLogger in app.config.ts alongside your other providers. It runs automatically at startup, reads logLevel and logColor fields from the injected config, and configures the corresponding loglevel loggers. Uses setDefaultLevel throughout, so any level you've explicitly set in the browser console is never overwritten on page reload.
Level and color cascade
A namespace's logLevel and logColor cascade to all child namespaces that have no explicit config, at any depth. Loggers created after bootstrap (e.g. inside Angular services) automatically inherit the right level the first time getLogger() is called, so you don't need to do anything extra.
// environment.development.ts
export const environment = {
logLevel: 'warn', // root default for everything
payments: {
logLevel: 'debug', // payments:retry and payments:retry:fallback also get debug
logColor: '#4CAF50', // color cascades too
retry: { logLevel: 'info' }, // overrides the cascade for payments:retry only
},
tae: {
logLevel: 'debug', // tae:env, tae:appUpdate, etc. all get debug
logColor: '#8B5CF6',
appUpdate: { logColor: '#F59E0B' }, // overrides color for tae:appUpdate only
},
} satisfies DevStaticEnv;So in code, getLogger('payments:retry') gets level info (explicit), while getLogger('payments:other') gets level debug (inherited from payments), even though neither is instantiated at bootstrap time.
Wiring it up
// environment-config.ts
export const EnvKit = createEnvKit({
schema: z.strictObject({
apiUrl: z.string(),
payments: withLogConfig.extend({ endpoint: z.string() }),
tae: taeSchema.optional(),
}).extend(withLogConfig.shape),
runtimeKeys: { apiUrl: true },
});
// app.config.ts
provideRuntimeConfig(EnvKit, environment, { token: ENV_CONFIG })
.thenProvide(
provideLogger(ENV_CONFIG),
),Sequencing with provideRuntimeConfig
provideLogger automatically awaits RUNTIME_CONFIG_READY when placed inside .thenProvide(), ensuring it reads the fully merged config (static + runtime overrides) rather than static-only values.
Internal logging:
provideRuntimeConfiglogs its load pipeline to thetae:envnamespace (exported asENV_DEBUG_NAMESPACE). Enable it viadebug: trueonprovideRuntimeConfig, by settingtae.logLevelin your env config, or by callinggetLogger('tae:env').setLevel('debug')in the browser console.
Developer overrides
Levels and colors follow this precedence, from lowest to highest:
| Priority | Source |
| ----------- | ------------------------------------------------------------------------------- |
| 1 | Root default (logLevel at the top of your env config, falls back to 'warn') |
| 2 | Cascade from parent namespace |
| 3 | Explicit per-namespace config |
| 4 | setLogColor() / .setColor() called at runtime |
| 5 (highest) | getLogger('ns').setLevel(...) in the browser console |
Browser console overrides take highest priority because they are written to localStorage and survive page reloads. This is useful when debugging a specific namespace without touching config files.
// In the browser console (persisted across reloads):
getLogger('payments').setLevel('debug');
// Clear the override and revert to the configured default:
getLogger('payments').resetLevel();Creating loggers
getLogger
Use getLogger from the package instead of log.getLogger from loglevel directly. It is a drop-in replacement that adds two things:
- Inherited level: if no level is explicitly configured for this namespace,
getLoggerwalks up the namespace hierarchy (payments:retry->payments-> root) and applies the nearest ancestor'slogLevelautomatically. .setColor(color): registers a CSS color for the[namespace]prefix and returnsthisfor chaining.
All standard loglevel methods (setLevel, getLevel, resetLevel, debug, info, warn, error, trace) remain fully accessible.
import { getLogger } from '@scania-nl/tegel-angular-extensions';
@Injectable({ providedIn: 'root' })
export class PaymentsService {
// Color is optional. Omit .setColor() if you don't need it:
readonly #log = getLogger('payments').setColor('#4CAF50');
fetch() {
this.#log.debug('fetching', { url }); // → [payments] fetching { url: '...' }
this.#log.warn('retry attempt', { n }); // → [payments] retry attempt { n: 1 }
}
}setLogColor
Registers a color for a namespace without creating a logger. Useful for ad hoc debugging or one-off scripts. Only the specified namespace is affected; other namespaces keep their colors.
import { setLogColor } from '@scania-nl/tegel-angular-extensions';
setLogColor('myFeature', '#F59E0B');
Toasts
A lightweight, standalone toast system that integrates seamlessly with Tegel Angular. Provides configurable, signal-driven notifications for success, error, warning, and information messages.
Quick Start
Add Providers
In your app.config.ts, specify the provider with provideToast():
// app.config.ts
import { provideToast } from '@scania-nl/tegel-angular-extensions';
export const appConfig: ApplicationConfig = {
providers: [
provideToast({
type: 'information', // Default toast type
title: 'Notification', // Default title
description: '', // Default description
duration: 7500, // Auto-dismiss delay (ms)
closeDuration: 300, // Fade-out animation duration (ms)
closable: true, // Show a close button
}),
],
};Note: The configuration is optional, all values shown above are the default settings.
Use in components
In any standalone component:
@Component({
standalone: true,
selector: 'my-toast-demo',
template: `<button (click)="showToast()">Show Toast</button>`,
})
export class MyToastDemoComponent {
private readonly toastService = inject(ToastService);
showToast() {
this.toastService.create({
type: 'success',
title: 'Hello Toast',
description: 'Toast created successfully!',
});
}
}
Toast Configuration Options
You can configure the default appearance and behavior of toasts by passing a ToastConfig object to provideToast() in your app.config.ts.
All options are optional. Defaults will be applied if values are not provided.
| Property | Type | Default | Description |
| --------------- | ---------------------------------------------------- | ---------------- | -------------------------------------------------------------------------- |
| type | 'information' \| 'success' \| 'warning' \| 'error' | 'information' | Default toast type for create() calls |
| title | string | 'Notification' | Default title text for toasts |
| description | string | '' | Default description text |
| duration | number | 7500 | Duration (ms) before a toast auto-closes (0 = stays until manually closed) |
| closeDuration | number | 300 | Duration (ms) for fade-out animation (0 = remove instantly) |
| closable | boolean | true | Whether a close button is shown |
Note: You can override these defaults per toast when using
create()or convenience methods likesuccess().
ToastService API
The ToastService provides a signal-based API to create, manage, and dismiss toast notifications in Angular standalone apps. It is automatically available after registering provideToast() in your app.config.ts.
ToastService Properties
| Property | Type | Description |
| -------------- | ----------------- | ----------------------------------------------------- |
| toasts | Signal<Toast[]> | Read-only list of all toasts (including closed) |
| activeToasts | Signal<Toast[]> | List of currently active toasts (Open or Closing) |
ToastService Methods
create(toastOptions: Partial<ToastOptions>): number
Creates a custom toast with full control over appearance and behavior.
Example:
toastService.create({
type: 'success',
title: 'Saved!',
description: 'Your changes have been saved.',
duration: 5000,
});Returns the unique toast ID.
Convenience Methods
Creates a toast of a specific type:
toastService.success({ title: 'All good!' });
toastService.error({ title: 'Oops!', description: 'Something went wrong.' });
toastService.warning({ title: 'Heads up!' });
toastService.info({ title: 'FYI' });
getToast(id: number): Toast | undefined
Gets a toast by its ID.
createRandomToast(props?: Partial<ToastOptions>): number
Creates a random toast with random type and title. Useful for testing. Returns the toast's unique ID.
toastService.createRandomToast();
close(id: number): void
Triggers the fade-out animation and schedules removal.
closeAll(): void
Closes all currently open toasts.
remove(id: number): void
Immediately removes a toast (no animation).
removeAll(): void
Force-removes all toasts instantly (no animations).
Toast Lifecycle Hooks
Each toast supports optional lifecycle callbacks:
| Callback | Description |
| ------------------ | -------------------------------------------- |
| onCreated(toast) | Called immediately after toast is created |
| onClose(toast) | Called when toast is closed (before removal) |
| onRemoved(toast) | Called when toast is fully removed |
Example:
toastService.success({
title: 'Logged out',
duration: 5000,
onRemoved: (toast) => console.log(`Toast ${toast.id} removed`),
});
Modals
Standalone modal service + host for Tegel's tds-modal, with strongly‑typed options and dynamic body support.
Quick Start
Add Providers
In your app.config.ts, specify the provider with provideModal():
// app.config.ts
import { provideModal } from '@scania-nl/tegel-angular-extensions';
export const appConfig: ApplicationConfig = {
providers: [
provideModal({
size: 'md', // Modal size
actionsPosition: 'static', // Actions slot behavior
prevent: false, // Prevent overlay click from closing the modal
closable: true, // Show or hide the close [X] button
alertDialog: 'dialog', // ARIA role of the modal component
lazy: false, // Render body only while open
startOpen: true, // Open modal on creation
removeOnClose: true, // Remove modal from DOM when closed
}),
],
};Most of these options are inherited from tds-modal.
Note: The configuration is optional, all values shown above are the default settings.
Use in components
create() returns a ModalRef that can control the modal instance.
import { inject } from '@angular/core';
import { ModalService } from '@scania-nl/tegel-angular-extensions';
export class MyComponent {
private readonly modalService = inject(ModalService);
openModal() {
const ref = this.modalService.create({
header: 'Demo Modal',
body: 'Hello from modal',
startOpen: false,
removeOnClose: false,
});
ref.open(); // Opens the modal
ref.close(); // Closes (hides) the modal
ref.remove(); // Removes the modal from the DOM
}
}You get a ModalRef back from create(), which you can use to control that specific modal instance (open(), close(), remove()).
Modal Configuration Options
| Option | Type | Default | Description |
| ----------------- | ------------------------------ | -------- | --------------------------- |
| size | 'xs' \| 'sm' \| 'md' \| 'lg' | md | Modal size |
| actionsPosition | 'static' \| 'sticky' | static | Actions slot behavior |
| prevent | boolean | false | Prevent overlay close |
| closable | boolean | true | Show/hide close button |
| alertDialog | 'dialog' \| 'alertdialog' | dialog | ARIA role |
| lazy | boolean | false | Render body only while open |
| startOpen | boolean | true | Open immediately on create |
| removeOnClose | boolean | true | Remove modal after close |
Be aware that when
removeOnCloseis set tofalse, the modal content is hidden but not destroyed. If your body uses a component with active subscriptions, timers, or sockets, they will continue to run while the modal is hidden. ConsiderremoveOnClose: truefor component bodies if you want their resources to be disposed on close.
Per-modal options (create)
These options are passed to modalService.create() and override defaults from provideModal() on a per‑modal basis. For shared defaults like size, actionsPosition, prevent, closable, alertDialog, lazy, startOpen, and removeOnClose, see Modal Configuration Options above.
| Option | Type | Description |
| ------------- | ------------------------------------------------------------------- | ----------------------------------------------------------- |
| header | string \| TemplateRef<unknown> | Modal header text or template |
| body | string \| { template, context } \| { component, inputs, outputs } | Modal body content (string, template+context, or component) |
| buttons | ModalButton[] | Action buttons to render in the actions slot |
| selector | string | CSS selector for focus return element |
| referenceEl | HTMLElement | Element to return focus to (preferred over selector) |
| onClosed | () => void \| Promise<void> | Called when the modal is closed |
| onRemoved | () => void \| Promise<void> | Called after the modal is removed |
Note: If you want focus to return to the element that opened the modal, pass either
selectororreferenceElwhen creating the modal. This avoids Tegel's "Missing focus origin" warning.
Body content options
The modal body can be provided as a simple string, a template body, or a component descriptor.
1) String body
this.modalService.create({
header: 'Simple',
body: 'This is a simple modal body',
});
2) Template body (template + optional context)
Signature:
body: {
template: TemplateRef<TContext>;
context?: TContext;
}Example:
<ng-template #bodyTpl let-context>
<p>
<b>{{ context.substance }}<b> boils at
<b>{{ context.value }}</b> degrees {{ context.units }}.
</p>
</ng-template>interface SubstanceTemperature {
substance: string;
value: number;
units: string;
}
const bodyTpl =
viewChild.required<TemplateRef<SubstanceTemperature>>('bodyTpl');
this.modalService.create({
header: 'Template',
body: {
template: bodyTpl(),
context: {
substance: 'Water',
value: 100,
units: 'Celcius',
},
},
});
For typed templates, you can use TypedTemplateDirective to enforce context types in the template:
<ng-template #bodyTpl let-context [typedContext]="this.bodyTpl()">
...
</ng-template>
3) Component body (with inputs/outputs)
Signature:
body: {
component: Type<TComponent>;
inputs: ComponentInputs<TComponent>;
outputs?: ComponentOutputs<TComponent>;
}
When you pass a component as the modal body, the inputs and outputs objects are fully typed. This means you get IntelliSense for signal inputs/outputs, and any input.required(...) fields are enforced at compile time (missing required inputs will fail the build).
export class ExampleComponent {
readonly requiredField = input.required<string>();
readonly optionalField = input<string>();
readonly outputField = output<string>();
}const ref = this.modalService.create({
header: 'Example',
body: {
component: ExampleComponent,
inputs: {
requiredField: 'Required',
// optionalField: 'Optional'
},
outputs: {
outputField: (value: string) => {
console.log('Value:', value);
ref.close();
},
},
},
});
Action buttons
this.modalService.create({
header: 'Confirm',
body: 'Are you sure?',
buttons: [
{ text: 'OK', variant: 'primary', onClick: async () => console.log('OK') },
{ text: 'Cancel', variant: 'secondary', dismiss: true },
],
startOpen: true,
});dismiss: true addsdata-dismiss-modalfor Tegel's built‑in close behavior.onClicksupports sync or async handlers.
ModalButton options
Modal action buttons map directly to Tegel's tds-button configuration. The following options are supported:
| Option | Type | Description |
| ---------- | --------------------------------------------------- | ----------------------------------------------------------- |
| text | string | Button label text |
| variant | 'primary' \| 'secondary' \| 'success' \| 'danger' | Visual variant |
| size | 'xs' \| 'sm' \| 'md' \| 'lg' | Button size |
| disabled | boolean | Disable the button |
| dismiss | boolean | Adds data-dismiss-modal to trigger Tegel's close behavior |
| onClick | () => void \| Promise<void> | Optional click handler (sync or async) |
ModalService API
The ModalService provides a signal-based API to create, manage, and dismiss modal instances. It is available after registering provideModal() in your app.config.ts.
ModalService Properties
| Property | Type | Description |
| -------- | ---------------------------- | -------------------------------------------------- |
| modals | Signal<Map<string, Modal>> | Read-only map of all registered modals keyed by id |
ModalService Methods
create<T>(options?: ModalOptions<T>): ModalRef
Creates and registers a new modal. Returns a ModalRef for controlling that instance.
const ref = modalService.create({
header: 'Hello',
body: 'Modal content',
startOpen: false,
});
ref.open();
ref.close();
ref.remove();
ModalRef
create() returns a ModalRef with open(), close(), and remove() methods to control that specific modal instance.
open(id: string): void
Opens an existing modal by id.
Example:
const ref = modalService.create({
header: 'Example',
body: 'Hello',
startOpen: false,
});
modalService.open(ref.id); // Equivalent to ref.open()
close(id: string): void
Closes (hides) a modal by id. If removeOnClose is true, it is removed from the DOM after closing.
remove(id: string): void
Removes a modal from the registry/DOM. If the modal is open, it is closed first.
closeAll(): void
Closes all registered modals.
removeAll(): void
Removes all registered modals.
Modal Lifecycle Hooks
Each modal supports optional lifecycle callbacks:
| Callback | Description |
| ------------- | ------------------------------------------------------- |
| onClosed() | Called when the modal is closed |
| onRemoved() | Called after the modal is removed from the registry/DOM |
Example:
modalService.create({
header: 'Example',
body: 'Hello',
onClosed: () => console.log('Modal closed'),
onRemoved: () => console.log('Modal removed'),
});
App Update
The library wraps @angular/service-worker's SwUpdate with automatic polling, signal-based update state, and a handler pattern for reacting to update events. You get signals you can bind to in templates, built-in toast and dialog handlers, and silent update detection (when the SW activates via skipWaiting + clients.claim before VERSION_READY is dispatched).
Quick start
1. Install @angular/pwa — the version must match your @angular/cli version:
npm install -D @angular/pwa@<version>Then generate the service worker configuration for your project:
| Environment | Project type | Command |
| ----------- | ------------ | -------------------------------------------- |
| Angular CLI | Standalone | ng add @angular/pwa |
| Angular CLI | Monorepo | ng add @angular/pwa --project <app-name> |
| Nx | Standalone | nx g @angular/pwa:pwa |
| Nx | Monorepo | nx g @angular/pwa:pwa --project <app-name> |
This installs
@angular/service-worker, generatesngsw-config.json, updatesangular.json/project.json, and adds the following to yourapp.config.ts:provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), registrationStrategy: 'registerWhenStable:30000', });Replace this with
provideAppUpdate()— it callsprovideServiceWorkerinternally with the same defaults. Keeping both will register the service worker twice.
2. Register provideAppUpdate in app.config.ts:
Choose one setup based on whether you need UI feedback:
// Option A — signals only, no UI feedback:
import { provideAppUpdate } from '@scania-nl/tegel-angular-extensions';
export const appConfig: ApplicationConfig = {
providers: [provideAppUpdate()],
};// Option B — with built-in toast feedback (requires provideToast):
import {
provideAppUpdate,
provideToast,
withToastHandler,
} from '@scania-nl/tegel-angular-extensions';
export const appConfig: ApplicationConfig = {
providers: [provideToast(), provideAppUpdate(withToastHandler())],
};3. Optionally configure the poll interval:
export const appConfig: ApplicationConfig = {
providers: [
provideAppUpdate({ pollIntervalMs: 60_000 }), // check every minute
],
};4. Optionally react to update state in a component:
import { AppUpdateService } from '@scania-nl/tegel-angular-extensions';
@Component({ ... })
export class AppComponent {
readonly #appUpdate = inject(AppUpdateService);
readonly updateAvailable = this.#appUpdate.updateAvailable;
}@if (updateAvailable()) {
<button (click)="reload()">New version available. Reload</button>
}How it works
After the app has stabilised, AppUpdateService starts polling SwUpdate at the configured interval (default: every 5 minutes). When a new version is detected, updatePending becomes true. Once the new version is fully installed, updateAvailable becomes true. At each lifecycle event (VERSION_DETECTED, VERSION_READY, VERSION_INSTALLATION_FAILED, UNRECOVERABLE_STATE), the configured handler is called. If the handler returns true from onVersionReady or onUnrecoverable, the page reloads automatically.
The service also detects silent updates: cases where the SW activates a new version via skipWaiting + clients.claim before VERSION_READY is dispatched to the client. This is detected by comparing the version hash on NO_NEW_VERSION_DETECTED events against the baseline hash captured at page load.
provideAppUpdate
provideAppUpdate();
provideAppUpdate(withDialogHandler());
provideAppUpdate({ pollIntervalMs: 120_000 });
provideAppUpdate({ pollIntervalMs: 120_000 }, withToastHandler());Options
| Option | Type | Default | Description |
| ---------------------- | --------------------------------------- | ---------------------------- | ------------------------------------------------------------------------- |
| serviceWorkerScript | string | 'ngsw-worker.js' | Path to the service worker script |
| pollIntervalMs | number | 300_000 | How often to check for updates in ms. 0 disables polling |
| enabled | boolean | !isDevMode() | Inherited from SwRegistrationOptions. Set true to force-enable in dev |
| registrationStrategy | string \| (() => Observable<unknown>) | 'registerWhenStable:30000' | Inherited from SwRegistrationOptions |
| scope | string | — | Inherited from SwRegistrationOptions |
To enable tae:appUpdate debug logging, set tae: { appUpdate: { logLevel: 'debug' } } in your environment config or call getLogger('tae:appUpdate').setLevel('debug') in the browser console.
If your custom withHandler implementation needs to inject ENV_CONFIG, place provideAppUpdate inside .thenProvide() so it has access to the fully loaded config:
provideRuntimeConfig(EnvKit, environment, { token: ENV_CONFIG })
.thenProvide(
provideAppUpdate(withHandler(MyConfigAwareHandler)),
),If your handler does not read ENV_CONFIG, place provideAppUpdate outside .thenProvide() as shown in the quick start.
Built-in handlers
withDialogHandler
No external dependencies. Uses native confirm and alert dialogs.
| Event | Dialog | Behaviour |
| -------------- | --------- | -------------------------------------------- |
| Version ready | confirm | OK reloads, Cancel defers |
| Install failed | alert | Informational only, SW retries automatically |
| Unrecoverable | alert | Always reloads after dismissal |
| Silent update | confirm | Same as version ready |
| Option | Default |
| ---------------------------------- | ---------------------------------------------------------------------- |
| versionReadyMessage | 'A new version is available. Reload now?' |
| versionInstallationFailedMessage | 'A new version could not be installed. It will retry automatically.' |
| unrecoverableMessage | 'A critical error occurred. The page will reload.' |
provideAppUpdate(withDialogHandler());
provideAppUpdate(
withDialogHandler({
versionReadyMessage: 'Update ready. Reload?',
}),
);withToastHandler
Requires provideToast() to be registered.
| Event | Toast type | Behaviour |
| -------------- | -------------------- | -------------------------------------- |
| Version ready | Success (persistent) | Shows a reload action link |
| Install failed | Warning | Informational only |
| Unrecoverable | Error (persistent) | Auto-reloads after autoReloadDelayMs |
| Silent update | Success (persistent) | Same as version ready |
| Option | Default |
| -------------------------------------- | --------------------------------------------------------------------------- |
| versionReadyTitle | 'Update available' |
| versionReadyDescription | 'Reload to apply the latest version.' |
| versionReadyActionText | 'Reload' |
| versionInstallationFailedTitle | 'Update failed' |
| versionInstallationFailedDescription | 'A new version could not be installed. It will be retried automatically.' |
| unrecoverableTitle | 'App needs to reload' |
| unrecoverableDescription | 'A critical error occurred. The page will reload shortly.' |
| autoReloadOnUnrecoverable | true |
| autoReloadDelayMs | 7500 |
(provideToast(), provideAppUpdate(withToastHandler()));
provideAppUpdate(
withToastHandler({
versionReadyTitle: 'New version ready',
autoReloadOnUnrecoverable: false,
}),
);Custom handler
Implement AppUpdateHandler and register it with withHandler. All methods are optional.
| Method | Called when | Return value |
| ------------------------------------ | ------------------------------- | ---------------------------------------------- |
| onVersionDetected(event) | New version detected by the SW | void |
| onVersionReady(event) | New version installed and ready | boolean \| Promise<boolean> — true reloads |
| onVersionInstallationFailed(event) | Version install failed | void |
| onUnrecoverable(event) | SW in unrecoverable state | boolean \| Promise<boolean> — true reloads |
| onSilentUpdate() | Silent activation detected | boolean \| Promise<boolean> — true reloads |
@Injectable()
export class MyUpdateHandler implements AppUpdateHandler {
readonly #notification = inject(NotificationService);
onVersionReady(): boolean {
this.#notification.show('Update ready. Reload to apply.');
return false; // let the user decide
}
onUnrecoverable(): boolean {
return true; // always reload on unrecoverable state
}
}
// app.config.ts
provideAppUpdate(withHandler(MyUpdateHandler));AppUpdateService
AppUpdateService is provided by provideAppUpdate and can be injected anywhere in the app.
Signals:
| Signal | Type | Description |
| ----------------- | ----------------- | ----------------------------------------------------------- |
| isEnabled | Signal<boolean> | Whether SwUpdate is enabled (false in dev/SSR by default) |
| updatePending | Signal<boolean> | A new version is downloading |
| updateAvailable | Signal<boolean> | A new version is installed and ready |
Methods:
| Method | Returns | Description |
| ------------------ | ------------------ | ------------------------------------------------ |
| checkForUpdate() | Promise<boolean> | Manually trigger an update check |
| activateUpdate() | Promise<void> | Activate the waiting version and reload the page |
@Injectable({ providedIn: 'root' })
export class MyService {
readonly #appUpdate = inject(AppUpdateService);
async applyUpdate(): Promise<void> {
await this.#appUpdate.activateUpdate(); // activates and reloads
}
}Testing utilities
For development and demo tooling, the package exports AppUpdateTestingService and provideAppUpdateTesting(). Register provideAppUpdateTesting() in dev-only providers (it throws in production) to simulate update lifecycle events without a real service worker.
// app.config.ts
provideAppUpdate(withDialogHandler()),
...(isDevMode() ? [provideAppUpdateTesting()] : []),Simulate events via AppUpdateTestingService.simulate(event):
| Event | Description |
| ----------------- | --------------------------------------- |
| 'detected' | New version detected, download starting |
| 'ready' | New version installed and ready |
| 'failed' | Version installation failed |
| 'unrecoverable' | SW entered unrecoverable state |
| 'silent' | Silent activation detected |
Call reset() to clear updatePending and updateAvailable back to false.
Components & Directives
This library includes a set of standalone UI components and utility directives, all prefixed with tae, designed to extend and complement the Tegel Angular ecosystem. Each piece is lightweight, fully typed, and easy to import into any Angular 19+ application.
Components
DateTime Picker Component (tae-date-time-picker) [BETA]
TaeDateTimePickerComponent is a standalone, form‑compatible picker that supports date, datetime, and time modes. It implements Angular's ControlValueAccessor, so it works with both reactive and template‑driven forms.
Beta: This component is still evolving. Improvements are planned and minor bugs may occur.
Inputs:
| Input | Type | Default | Description |
| --------------- | ------------------------------------- | --------- | ------------------- |
| label | string | '' | Label text |
| size | 'sm' \| 'md' \| 'lg' | lg | Control size |
| labelPosition | 'inside' \| 'outside' \| 'no-label' | outside | Label placement |
| state | 'error' \| undefined | - | Error state styling |
| helper | string | '' | Helper text |
| mode | 'date' \| 'datetime' \| 'time' | date | Picker mode |
Note:
labelPositionandstateare currently not implemented; it will be supported in a future update.
Example (reactive forms):
readonly form = this.fb.group({
date: this.fb.control<string | null>(null),
time: this.fb.control<string | null>(null),
dateTime: this.fb.control<string | null>(null),
});<form [formGroup]="form">
<tae-date-time-picker label="Date" formControlName="date" mode="date" />
<tae-date-time-picker label="Time" formControlName="time" mode="time" />
<tae-date-time-picker
label="DateTime"
formControlName="dateTime"
mode="datetime"
/>
</form>
Footer Component (tae-footer)
TaeFooterComponent is an enhanced footer based on the Tegel TdsFooterComponent. It preserves the same visual appearance while adding:
- Three size variants following the Tegel scale:
'md','sm','xs' - Customisable left-side text (
textLeft), defaulting to the standard copyright notice - Optional right-side text (
textRight) for version strings, build numbers, or any secondary label - Option to hide the Scania wordmark logo (
hideLogo)
Inputs:
| Input | Type | Default | Description |
| ----------- | ---------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------- |
| variant | 'md' \| 'sm' \| 'xs' | 'md' | Size variant. 'sm' reduces padding; 'xs' removes the border-top and uses minimal padding, ideal for kiosk layouts. |
| textLeft | string \| undefined | — | Overrides the default copyright notice on the left side. When omitted, Copyright © {year} Scania is shown. |
| textRight | string \| undefined | — | Optional text between the left text and the logo (e.g. version string). Hidden when omitted. |
| hideLogo | boolean | false | When true, hides the Scania wordmark logo. |
Examples:
<!-- Default copyright, no right-side text -->
<tae-footer />
<!-- Compact with version string -->
<tae-footer variant="sm" textRight="Version: v1.0.0" />
<!-- Fully custom both sides, logo hidden -->
<tae-footer textLeft="My App" textRight="Build 42" [hideLogo]="true" />
Directives
Fullscreen Toggle Directive (taeFullscreenToggle)
FullscreenToggleDirective triggers native browser fullscreen on the host element when clicked. It always fullscreens document.documentElement (the entire page), not the host element itself.
Exposes an isFullscreen readonly signal that stays in sync with the actual fullscreen state - including user-initiated exits via Escape or the browser's own controls. The directive is a no-op in SSR and browsers without Fullscreen API support.
Access the directive instance in templates via exportAs: 'taeFullscreenToggle'.
Example:
<button taeFullscreenToggle #fs="taeFullscreenToggle">
{{ fs.isFullscreen() ? 'Exit fullscreen' : 'Enter fullscreen' }}
</button>
Viewport Scale Directive (taeViewportScale)
ViewportScaleDirective scales the host element to fill the viewport while preserving a fixed logical resolution, centred in any remaining space ("zoom to fit").
Uses position: fixed and transform: scale() to scale the content. Reacts to viewport resizes via ResizeObserver and to targetWidth/targetHeight input changes via Angular signals. Safe to use as a hostDirective on the application shell component.
Inputs:
| Input | Type | Default | Description |
| -------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------ |
| targetWidth | number | 1920 | Logical width in CSS pixels the content is designed for. Defaults to the standard Scania tablet resolution. |
| targetHeight | number | 1200 | Logical height in CSS pixels the content is designed for. Defaults to the standard Scania tablet resolution. |
Example (standalone element):
<div taeViewportScale [targetWidth]="1280" [targetHeight]="800">
<!-- app content -->
</div>Example (host directive on application shell):
@Component({
selector: 'app-root',
hostDirectives: [ViewportScaleDirective],
// …
})
export class AppComponent {}Note: When used as a
hostDirective, all overlay components (modals, toasts, tooltips) must be rendered into<body>outside the host - which is the standard pattern for Angular overlay libraries and how this library'sprovideToast()andprovideModal()work.
Typed Template Directive (typedContext)
TypedTemplateDirective is a compile-time helper that provides typed let- variables inside an ng-template. It has no runtime effect - it only exists to give Angular's template type checker enough information to infer the context type.
Pass the template's own viewChild ref as the [typedContext] value. Angular infers T from TemplateRef<T>, and the directive's ngTemplateContextGuard types let- variables as { $implicit: T }.
Example:
// In the component class:
protected readonly bodyTpl = viewChild.required<TemplateRef<OrderContext>>('bodyTpl');<!-- In the template - let-ctx is now fully typed as OrderContext: -->
<ng-template #bodyTpl let-ctx [typedContext]="bodyTpl()">
{{ ctx.paint }}
<!-- typed as string -->
{{ ctx.qty }}
<!-- typed as number -->
</ng-template>
TypedTemplateDirectivemust be added to the component'simportsarray.
Hard Refresh Directive (taeHardRefresh)
The HardRefreshDirective provides a small UX shortcut: it performs a full page reload when the host element is clicked N times in rapid succession, where each click must occur within clickWindowMs milliseconds of the previous one.
This is especially useful for hard-reloading tablet or mobile applications which are locked in full-screen mode, and thus have no browser buttons like refresh.
Inputs:
| Property | Type | Default | Description |
| ---------------- | -------- | ------- | ---------------------------------------------------------------------- |
| clicksRequired | number | 3 | Number of clicks required within the window to trigger a hard
