Onboarding
The tSM onboarding library is a guided tour framework for application flows. It can highlight real UI elements, render an explanatory overlay, wait for real user interaction, preserve progress, and continue across route changes when the host is placed in the application layout.
Use onboarding for flows such as:
- first-run walkthroughs of the application shell,
- tutorials for complex modules,
- release notes that point at new UI controls,
- guided setup flows that must wait for a click, focus, input, or hover.
Runtime Architecture
The library is exported from @tsm/framework/onboarding.
The important pieces are:
| Piece | Purpose |
|---|---|
OnboardingService | Starts and stops onboarding runs. It also exposes the active run and runtime state as signals. |
OnboardingRootHostComponent | Layout-level host that renders the backdrop, target highlight, and overlay card. |
OnboardingHostComponent | Runs the scenario engine, resolves overlay content, listens to document events, and emits state changes. |
OnboardingScenarioRegistration | Registers a scenario, its custom components/templates, guards, and optional adapter. |
OnboardingProgressAdapter | Persists or ignores progress. The default adapter stores progress in localStorage. |
tsmPreventOverlayClose | Directive for controls inside the onboarding overlay/card that must not close application overlays by bubbling click/pointer events. |
In the standard TSM layout the root host is already mounted in:
<tsm-onboarding-root-host></tsm-onboarding-root-host>
Because the host lives in the application layout, a scenario can survive router navigation. Do not host it inside a feature page if the tour should continue after leaving that page.
Basic Setup
Global onboarding options are registered with provideOnboarding.
import { inject } from "@angular/core";
import { getTenantId } from "@tsm/framework/functions";
import { provideOnboarding } from "@tsm/framework/onboarding";
import { RuntimeService } from "@tsm/runtime-info";
export const appProviders = [
provideOnboarding({
progressKeyBuilder: ({ scenarioId, flowId }) => {
const runtime = inject(RuntimeService);
return [
getTenantId() ?? "default",
runtime.getCurrent()?.id ?? "anonymous",
scenarioId,
flowId,
].join(":");
},
}),
];
The progress key builder runs inside an Angular injection context when the
default local-storage adapter is used, so it can call inject(). Put user,
tenant, module, or entity identifiers into the key when the same scenario can
run independently for different contexts.
The default key is:
`${scenarioId}:${flowId}`;
Progress Key Builder
The progress key decides where the default LocalStorageOnboardingProgressAdapter
stores a snapshot. It receives only onboarding identifiers:
interface OnboardingProgressKeyContext {
scenarioId: string;
scenarioVersion: number;
flowId: string;
}
Anything else, such as the current user, tenant, application, entity, or module,
must be resolved by the builder itself. The builder can call inject() because
the default adapter executes it inside Angular's injection context.
import { inject } from "@angular/core";
import { getTenantId } from "@tsm/framework/functions";
import { provideOnboarding } from "@tsm/framework/onboarding";
import { RuntimeService } from "@tsm/runtime-info";
export const appProviders = [
provideOnboarding({
progressKeyBuilder: ({ scenarioId, scenarioVersion, flowId }) => {
const runtime = inject(RuntimeService);
return [
"tsm-onboarding",
getTenantId() ?? "default",
runtime.getCurrent()?.id ?? "anonymous",
scenarioId,
scenarioVersion,
flowId,
].join(":");
},
}),
];
Use flowId for runtime separation of the same scenario. For example, a single
scenario id can be reused for many user tasks:
this.onboarding.start("ticket-detail-tour", {
flowId: `ticket:${ticketId}`,
});
With the key builder above, the stored progress is unique for:
- tenant,
- user,
- scenario id,
- scenario version,
- runtime flow id.
If a scenario uses a custom OnboardingProgressAdapter, that adapter owns its
own keying rules. The global progressKeyBuilder is used only by the default
local-storage adapter unless the custom adapter explicitly calls it.
Injection Context
Onboarding intentionally exposes injection context in places where scenario configuration needs application services.
| Place | Can call inject() | Injector used |
|---|---|---|
progressKeyBuilder passed to provideOnboarding | Yes | Application EnvironmentInjector used by OnboardingService. |
scenario factory in OnboardingScenarioRegistration | Yes | start(..., {injector}) when provided, otherwise the OnboardingService injector. |
preActions and postActions | Yes | Same action injector as the scenario factory. The default host also runs them inside NgZone. |
| custom step component | Yes | Normal Angular component injection. |
| guard predicate invocation | No direct injection context | Capture injected services in a factory/component first, then close over them in the guard. |
This is useful when a lazy feature owns route-scoped services. Start the scenario with the feature injector when the factory or step actions must see those providers:
import { Injector, inject } from "@angular/core";
import { OnboardingService } from "@tsm/framework/onboarding";
export class FeaturePageComponent {
private readonly injector = inject(Injector);
private readonly onboarding = inject(OnboardingService);
startFeatureTour(): void {
this.onboarding.start("feature-tour", {
injector: this.injector,
flowId: "feature-tour:default",
});
}
}
Guards are different: the engine calls a guard as a plain function when an interaction happens. If a guard needs a service, resolve that service while a context exists and close over it:
import { Injector, inject } from "@angular/core";
import { OnboardingService } from "@tsm/framework/onboarding";
export class FeaturePageComponent {
private readonly injector = inject(Injector);
private readonly onboarding = inject(OnboardingService);
private readonly permissions = inject(PermissionsService);
startFeatureTour(): void {
this.onboarding.start("feature-tour", {
injector: this.injector,
guards: {
canContinue: () => this.permissions.can("feature.use"),
},
});
}
}
The scenario can then reference the guard by name:
{
id: 'welcome',
overlay: {
content: {
kind: 'text',
text: 'Welcome.',
},
},
interactions: [
{
selector: '[data-cy="continue"]',
eventType: 'click',
condition: {
guard: 'canContinue',
},
action: {kind: 'next'},
},
],
}
Registering Scenarios
Scenarios can be registered globally through provideOnboarding({scenarios}).
In TSM feature libraries and lazy plugins, prefer the multi-provider token so a
plugin can contribute scenarios without central wiring.
import { Plugin } from "@tsm/framework/plugin";
import {
OnboardingScenarioRegistration,
TSM_ONBOARDING_SCENARIO_REGISTRATIONS,
} from "@tsm/framework/onboarding";
const onboardingScenarios: OnboardingScenarioRegistration[] = [
{
id: "hello-layout-tour",
scenario: {
id: "hello-layout-tour",
flowId: "default",
version: 1,
entryStepId: "welcome",
steps: [
{
id: "welcome",
target: {
selector: '[data-cy="onboarding-demo-panel"]',
paddingPx: 8,
},
overlay: {
content: {
kind: "text",
text: "This panel starts the onboarding demo.",
},
},
},
],
},
},
];
export const examplePlugin: Plugin = {
providers: [
{
provide: TSM_ONBOARDING_SCENARIO_REGISTRATIONS,
multi: true,
useValue: onboardingScenarios,
},
],
};
Starting a Tour
Inject OnboardingService from the component or service that owns the start
action.
import { inject } from "@angular/core";
import {
InMemoryOnboardingProgressAdapter,
OnboardingService,
} from "@tsm/framework/onboarding";
const SCENARIO_ID = "hello-layout-tour";
const FLOW_ID = "hello-layout-tour:v1";
export class HelloComponent {
private readonly onboarding = inject(OnboardingService);
startOnboardingDemo(): void {
this.onboarding.start(SCENARIO_ID, {
flowId: FLOW_ID,
adapter: new InMemoryOnboardingProgressAdapter(),
});
}
}
The in-memory adapter is useful for demos and tests. In production, omit
adapter unless the scenario needs special persistence. The default
LocalStorageOnboardingProgressAdapter will be used.
Example: Layout Tour
The example below is a shortened version of the hello-world demo. It shows the
main patterns:
- CDK-style connected positions for precise overlay placement,
- injected services inside a scenario factory,
- a custom overlay component,
- steps that wait for real clicks,
- a final step that waits for an
inputevent with a specific value.
import { DOCUMENT } from "@angular/common";
import { inject } from "@angular/core";
import { Router } from "@angular/router";
import { TranslocoService } from "@jsverse/transloco";
import { LayoutTsmMenuService } from "@tsm/layout/service";
import {
OnboardingConnectedPosition,
OnboardingScenarioRegistration,
} from "@tsm/framework/onboarding";
const gap = 16;
const belowTargetPositions: OnboardingConnectedPosition[] = [
{
originX: "center",
originY: "bottom",
overlayX: "center",
overlayY: "top",
offsetY: gap,
},
{
originX: "center",
originY: "top",
overlayX: "center",
overlayY: "bottom",
offsetY: -gap,
},
];
export const helloWorldTour: OnboardingScenarioRegistration = {
id: "hello-layout-tour",
guards: {
hasAhojInputValue: ({ event }) => {
const target = event?.target;
return target instanceof HTMLInputElement && target.value === "ahoj";
},
},
scenario: () => {
const document = inject(DOCUMENT);
const router = inject(Router);
const transloco = inject(TranslocoService);
const menu = inject(LayoutTsmMenuService);
const text = (key: string) => ({
kind: "text" as const,
text: transloco.translate(`helloWorld.onboarding.${key}`),
});
const waitForSelector = (selector: string): Promise<void> =>
new Promise((resolve) => {
const check = () => {
if (document.querySelector(selector)) {
resolve();
return;
}
requestAnimationFrame(check);
};
requestAnimationFrame(check);
});
return {
id: "hello-layout-tour",
flowId: "hello-layout-tour:v1",
version: 1,
entryStepId: "layout-overview",
componentRegistry: {
layoutOverview: {
loadComponent: () =>
import("@tsm/example/hello-world").then(
(module) => module.HelloOnboardingLayoutOverviewComponent,
),
},
},
steps: [
{
id: "layout-overview",
target: {
selector: '[data-cy="app-layout"]',
paddingPx: 8,
},
overlay: {
content: {
kind: "component",
componentKey: "layoutOverview",
},
showPrevious: false,
showNext: false,
},
},
{
id: "left-menu",
preActions: [() => menu.openMenu()],
target: {
selector: '[data-cy="app-left-menu"]',
paddingPx: 8,
},
overlay: {
positions: belowTargetPositions,
content: text("leftMenuText"),
},
},
{
id: "collapse-left-menu",
target: {
selector: '[data-cy="body-button-open-side-bar-menu"]',
paddingPx: 8,
},
overlay: {
positions: belowTargetPositions,
content: text("collapseLeftMenuText"),
showNext: false,
},
interactions: [
{
selector: '[data-cy="body-button-open-side-bar-menu"]',
eventType: "click",
action: { kind: "next" },
},
],
},
{
id: "return-to-hello",
preActions: [
() =>
router
.navigate(["/devel/hello"])
.then(() =>
waitForSelector('[data-cy="onboarding-demo-panel"]'),
),
],
target: {
selector: '[data-cy="onboarding-demo-panel"]',
paddingPx: 8,
},
overlay: {
positions: belowTargetPositions,
content: text("returnToHelloText"),
},
},
{
id: "type-ahoj",
target: {
selector: '[data-cy="onboarding-demo-ahoj-input"]',
paddingPx: 8,
},
overlay: {
positions: belowTargetPositions,
content: text("typeAhojText"),
showFinish: false,
showNext: false,
},
interactions: [
{
selector: '[data-cy="onboarding-demo-ahoj-input"]',
eventType: "input",
condition: {
guard: "hasAhojInputValue",
},
action: { kind: "next" },
},
],
},
],
};
},
};
The type-ahoj step has no visible Next or Finish button. It completes only
after the highlighted input emits a real input event and the registered guard
returns true.
Custom Step Component
Use kind: 'component' when a step needs real UI instead of plain text. The
default host renders the component through NgComponentOutlet and passes a
single context input.
The context contains navigation helpers and step-data helpers:
| Method | Use |
|---|---|
next() | Move to the next step. |
previous() | Move to the previous step. |
close() | Abort the flow. |
restart() | Restart from entryStepId. |
resume() | Resume current or saved progress. |
goTo(stepId) | Jump to a specific step. |
setStepData(key, value) | Store data for the current step and persist progress. |
getStepData(key) | Read data stored for the current step. |
nextWithData(stepData) | Merge data into the current step and then move next. |
Example component:
import {
ChangeDetectionStrategy,
Component,
computed,
input,
signal,
} from "@angular/core";
import {
OnboardingTemplateContext,
PreventOverlayCloseDirective,
} from "@tsm/framework/onboarding";
import { ButtonModule } from "primeng/button";
import { InputTextModule } from "primeng/inputtext";
@Component({
selector: "tsm-profile-menu-onboarding-step",
imports: [ButtonModule, InputTextModule, PreventOverlayCloseDirective],
templateUrl: "./profile-menu-onboarding-step.component.html",
styleUrl: "./profile-menu-onboarding-step.component.scss",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProfileMenuOnboardingStepComponent {
readonly context = input.required<OnboardingTemplateContext>();
readonly note = signal("");
readonly title = computed(() => this.context().step?.title ?? "Profile menu");
onNoteInput(event: Event): void {
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return;
}
this.note.set(target.value);
this.context().setStepData("profileMenuNote", target.value);
}
continue(): void {
this.context().nextWithData({
profileMenuConfirmed: true,
profileMenuNote: this.note(),
});
}
skipToInputStep(): void {
this.context().goTo("type-ahoj");
}
close(): void {
this.context().close();
}
}
<section class="profile-menu-onboarding-step" tsmPreventOverlayClose>
<h4>{{ title() }}</h4>
<p>
This custom component can store data, jump between steps, close the flow, or
advance the flow without using the default Next button.
</p>
<input
pInputText
data-cy="profile-menu-onboarding-note"
type="text"
[value]="note()"
placeholder="Optional note"
(input)="onNoteInput($event)"
/>
<div class="profile-menu-onboarding-step__actions">
<p-button
type="button"
severity="secondary"
label="Skip"
(onClick)="skipToInputStep()"
/>
<p-button
type="button"
severity="secondary"
label="Close"
(onClick)="close()"
/>
<p-button type="button" label="Continue" (onClick)="continue()" />
</div>
</section>
Register the component under a key and reference that key from the step:
export const helloWorldTour: OnboardingScenarioRegistration = {
id: "hello-layout-tour",
scenario: {
id: "hello-layout-tour",
flowId: "hello-layout-tour:v1",
version: 1,
entryStepId: "profile-menu",
componentRegistry: {
profileMenuStep: {
loadComponent: () =>
import("./profile-menu-onboarding-step.component").then(
(module) => module.ProfileMenuOnboardingStepComponent,
),
},
},
steps: [
{
id: "profile-menu",
title: "Profile menu",
target: {
selector: '[data-cy="profile-menu"]',
paddingPx: 8,
},
overlay: {
content: {
kind: "component",
componentKey: "profileMenuStep",
},
showNext: false,
showFinish: false,
},
},
],
},
};
The important detail is showNext: false: the custom component owns the step
transition by calling context().next(), context().nextWithData(...), or
context().goTo(...).
Custom Host
Most flows should use the default host. A custom host is useful only when an application needs a different shell around the onboarding engine, custom telemetry, or a different overlay implementation.
A custom host is registered through provideOnboarding({hostComponent}).
import { provideOnboarding } from "@tsm/framework/onboarding";
export const appProviders = [
provideOnboarding({
hostComponent: {
loadComponent: () =>
import("./brand-onboarding-host.component").then(
(module) => module.BrandOnboardingHostComponent,
),
},
}),
];
The host outlet sets these inputs on the host component:
scenario,flowId,adapter,guards,templateRegistry,componentRegistry,actionInjector.
The host component should emit:
stateChangewithOnboardingRuntimeState,lifecycleEventwithOnboardingLifecycleEvent.
The simplest custom host wraps the default OnboardingHostComponent and adds a
project-specific container:
import {
ChangeDetectionStrategy,
Component,
Injector,
input,
output,
} from "@angular/core";
import {
OnboardingComponentRegistry,
OnboardingConditionPredicate,
OnboardingHostComponent,
OnboardingLifecycleEvent,
OnboardingProgressAdapter,
OnboardingRuntimeState,
OnboardingScenario,
OnboardingTemplateRegistry,
PreventOverlayCloseDirective,
} from "@tsm/framework/onboarding";
@Component({
selector: "tsm-brand-onboarding-host",
imports: [OnboardingHostComponent, PreventOverlayCloseDirective],
templateUrl: "./brand-onboarding-host.component.html",
styleUrl: "./brand-onboarding-host.component.scss",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BrandOnboardingHostComponent {
readonly scenario = input.required<OnboardingScenario>();
readonly flowId = input("");
readonly adapter = input<OnboardingProgressAdapter | undefined>(undefined);
readonly guards = input<Record<string, OnboardingConditionPredicate>>({});
readonly templateRegistry = input<OnboardingTemplateRegistry>({});
readonly componentRegistry = input<OnboardingComponentRegistry>({});
readonly actionInjector = input<Injector | null>(null);
readonly stateChange = output<OnboardingRuntimeState>();
readonly lifecycleEvent = output<OnboardingLifecycleEvent>();
onStateChange(state: OnboardingRuntimeState): void {
this.stateChange.emit(state);
}
onLifecycleEvent(event: OnboardingLifecycleEvent): void {
this.lifecycleEvent.emit(event);
}
}
<div class="brand-onboarding-host" tsmPreventOverlayClose>
<tsm-onboarding-host
[scenario]="scenario()"
[flowId]="flowId()"
[adapter]="adapter()"
[guards]="guards()"
[templateRegistry]="templateRegistry()"
[componentRegistry]="componentRegistry()"
[actionInjector]="actionInjector()"
(stateChange)="onStateChange($event)"
(lifecycleEvent)="onLifecycleEvent($event)"
/>
</div>
This wrapper still uses the standard engine, document interaction handling,
content resolution, and injection-context behavior. A fully custom host can
replace those pieces, but then it must preserve the same input/output contract
so OnboardingRootHostComponent and OnboardingService can coordinate the run.
Scenario Options
provideOnboarding(options)
| Option | Type | Description |
|---|---|---|
hostComponent | OnboardingComponentRegistration | Overrides the default OnboardingHostComponent. Use only when a project needs a custom onboarding host implementation. |
progressKeyBuilder | (context) => string | Promise<string> | Builds the storage key for the default local-storage adapter. Runs in an Angular injection context, so it can use inject(). |
scenarios | OnboardingScenarioRegistration[] | Registers scenarios during bootstrap. Plugin-based registration through TSM_ONBOARDING_SCENARIO_REGISTRATIONS is usually better for lazy feature libraries. |
OnboardingService.start(scenarioId, options)
| Option | Type | Description |
|---|---|---|
adapter | OnboardingProgressAdapter | Per-run progress adapter. Overrides the scenario adapter and the default local-storage adapter. |
componentRegistry | OnboardingComponentRegistry | Additional custom components for kind: 'component' overlay content. Merged after scenario and registration registries. |
flowId | string | Runtime flow key. Use it to separate repeated runs of the same scenario, for example per entity, per user task, or per feature. Defaults to scenario.flowId or scenario.id. |
guards | Record<string, OnboardingConditionPredicate> | Per-run condition predicates. Merged after registered guards. |
injector | Injector | Injector used to resolve scenario factories and step actions. Useful when a scenario is started from a scoped feature injector. |
scenario | OnboardingScenario | Inline scenario used instead of a registered scenario. Useful for tests or one-off dynamic flows. |
templateRegistry | OnboardingTemplateRegistry | Additional Angular templates for kind: 'template' overlay content. Merged after scenario and registration registries. |
OnboardingScenarioRegistration
| Option | Type | Description |
|---|---|---|
id | string | Registration id used by OnboardingService.start(id). |
scenario | OnboardingScenario | OnboardingScenarioFactory | Static scenario object or a factory. Factories run in injection context and may call inject(). |
componentRegistry | OnboardingComponentRegistry | Components available to all runs of this registration. |
templateRegistry | OnboardingTemplateRegistry | Templates available to all runs of this registration. |
guards | Record<string, OnboardingConditionPredicate> | Named predicates used by step interaction conditions. |
adapter | OnboardingProgressAdapter | Scenario-specific progress adapter. Overridden by start(..., {adapter}). |
OnboardingScenario
| Option | Type | Description |
|---|---|---|
id | string | Stable scenario id. |
flowId | string | Optional default flow id. Can be overridden when starting the scenario. |
version | number | Positive scenario version. Increment when step ids or progress semantics change. Saved progress is restored only when compatible with this version. |
entryStepId | string | First step id. Must exist in steps. |
steps | OnboardingScenarioStep[] | Ordered list of steps. |
componentRegistry | OnboardingComponentRegistry | Scenario-local component registry. |
metadata | Record<string, unknown> | Free-form scenario metadata for custom hosts or analytics. |
OnboardingScenarioStep
| Option | Type | Description |
|---|---|---|
id | string | Stable step id. Required and unique within the scenario. |
title | string | Title shown in the overlay header. |
subtitle | string | Subtitle shown below the title. |
description | string | Free-form description for custom consumers. The default overlay does not render it. |
target | OnboardingTargetDescriptor | Element to highlight and position against. |
overlay | OnboardingStepOverlay | Overlay content, placement, and button visibility. Required. |
interactions | OnboardingInteraction[] | DOM interactions that can execute actions. |
preActions | OnboardingStepAction[] | Actions run before entering the step. They run in Angular injection context and may return a promise. |
postActions | OnboardingStepAction[] | Actions run when leaving the step. They run in Angular injection context and may return a promise. |
nextStepId | string | Explicit next step id. Without it, the next item in steps is used. |
metadata | Record<string, unknown> | Free-form step metadata. |
target
| Option | Type | Description |
|---|---|---|
selector | string | CSS selector for the highlighted element. Use stable selectors such as data-cy. |
paddingPx | number | Highlight padding. The root host currently clamps this to at least 4px; default is 10px. |
fallbackToCenter | boolean | Reserved in the type layer. The current root host already centers the overlay if no target rect exists. |
highlightStrategy | 'clip-path' | 'outline' | Reserved in the type layer. The current root host uses the built-in outline/highlight style. |
waitForMs | number | Reserved in the type layer. For now use a preActions helper such as waitForSelector(...) when the target is rendered asynchronously. |
scrollBehavior | 'always' | 'ifNeeded' | Reserved in the type layer. The current host does not perform automatic scrolling. |
overlay
| Option | Type | Description |
|---|---|---|
position | 'center' | 'start' | 'end' | 'before' | 'after' | 'auto' | Simple fallback placement. Used when positions is not provided or no connected position fits. |
positions | OnboardingConnectedPosition[] | Preferred placement list compatible with Angular CDK connected-position concepts. The first fitting position wins; otherwise the first position is clamped into the viewport. |
title | string | Reserved for custom hosts. The default overlay reads the step title. |
subtitle | string | Reserved for custom hosts. The default overlay reads the step subtitle. |
content | OnboardingOverlayContentDescriptor | Required overlay body content. |
allowClose | boolean | Set to false to disable close behavior. |
showClose | boolean | Set to false to hide the close button. |
showFinish | boolean | Set to false to hide Finish on the terminal step. |
showPrevious | boolean | Set to false to hide Previous. |
showNext | boolean | Set to false to hide Next. Use this for steps that wait for a real interaction. |
content
| Kind | Options | Description |
|---|---|---|
text | {kind: 'text', text} | Plain text rendered in the overlay body. |
markdown | {kind: 'markdown', text} | Currently rendered like plain text by the default overlay. Use html, template, or component for rich content. |
html | {kind: 'html', html} | Sanitized HTML rendered through Angular DomSanitizer. |
template | {kind: 'template', templateKey} | Renders a template from templateRegistry with OnboardingTemplateContext. |
component | {kind: 'component', componentKey} | Renders a component from componentRegistry. The component receives context input. |
OnboardingConnectedPosition
| Option | Type | Description |
|---|---|---|
originX | 'start' | 'center' | 'end' | Horizontal anchor point on the target. |
originY | 'top' | 'center' | 'bottom' | Vertical anchor point on the target. |
overlayX | 'start' | 'center' | 'end' | Horizontal anchor point on the overlay. |
overlayY | 'top' | 'center' | 'bottom' | Vertical anchor point on the overlay. |
offsetX | number | Horizontal offset in pixels. |
offsetY | number | Vertical offset in pixels. |
panelClass | string | string[] | Reserved for custom host implementations. The current root host does not apply it. |
interactions
| Option | Type | Description |
|---|---|---|
selector | string | CSS selector that must match the event target or one of its ancestors. |
eventType | 'click' | 'focus' | 'input' | 'hover' | 'manual' | Event to listen for. The default host maps document click, focusin, input, and mouseover to these values. manual is available for custom hosts or direct engine dispatching. |
action | OnboardingAction | Action executed when the selector and condition match. |
condition | OnboardingCondition | Optional guard and step-data requirement. |
preventDefault | boolean | Calls event.preventDefault() before dispatching the action. |
condition
| Option | Type | Description |
|---|---|---|
requiredStepData | string[] | Requires keys to be present in current step data before the interaction can run. |
guard | string | Name of a registered predicate. Guards receive {condition, state, step, event}. |
action
| Kind | Required fields | Behavior |
|---|---|---|
next | - | Move to the next step or complete the scenario on the terminal step. |
previous | - | Move to the previous step. |
close | - | Abort the flow. The root host then removes the active run. |
jump | stepId | Jump to a specific step. |
restart | - | Reset state and enter the scenario's entryStepId. |
resume | - | Resume saved or current progress. |
custom | eventName, payload optional | Emits a lifecycle event with metadata. It does not change steps by itself. |
pause | - | Reserved for future support. Current engine emits a blocked event. |
start | - | Reserved in the action type. Start flows through OnboardingService.start(...), not through scenario interactions. |
Progress Adapters
The progress adapter interface is:
export interface OnboardingProgressAdapter {
loadProgress(
request,
): OnboardingRuntimeState | null | Promise<OnboardingRuntimeState | null>;
saveProgress(snapshot): void | Promise<void>;
deleteProgress(request): void | Promise<void>;
}
Built-in adapters:
| Adapter | Use |
|---|---|
LocalStorageOnboardingProgressAdapter | Default adapter. Stores snapshots in browser localStorage. Uses progressKeyBuilder. |
NullOnboardingProgressAdapter | Does not load, save, or delete progress. Use when progress must never persist. |
InMemoryOnboardingProgressAdapter | Keeps progress in memory. Useful for demos and tests. |
Progress state stores:
scenarioId,scenarioVersion,flowId,- current status,
- current step id/index,
- completed step ids,
- per-step data,
- transition history,
- update timestamp.
Step Actions and Injection
Scenario factories and step actions run in an Angular injection context.
{
id: 'open-profile-detail',
preActions: [
() => {
const router = inject(Router);
return router.navigate(['/user-management/user/current']);
},
],
target: {
selector: '[data-cy="user-detail-tabs"]',
},
overlay: {
content: {
kind: 'text',
text: 'The user detail is organized into tabs.',
},
},
}
Use preActions for work that must happen before the step is displayed:
opening a menu, waiting for a selector, navigating to a route, or closing a
temporary overlay. Use postActions for cleanup after leaving a step.
Working With Application Overlays
The root host card uses tsmPreventOverlayClose, which stops click, mouse,
pointer, and touch events from bubbling out of the onboarding card. This is
important when a step is explaining an application overlay such as the profile
menu. Clicking Next in the onboarding card should not close the application
overlay unless the scenario explicitly asks for that.
If you create a custom onboarding host or custom content that should preserve an open application overlay, apply the directive to the container that receives the click.
<div tsmPreventOverlayClose>
<!-- custom onboarding controls -->
</div>
Styling
The default root host exposes TSM CSS variables and falls back to Prime/TSM theme tokens:
| Variable | Default |
|---|---|
--tsm-onboarding-primary | var(--p-primary-color, #2563eb) |
--tsm-onboarding-text | var(--p-text-color, #111827) |
--tsm-onboarding-muted | var(--p-text-muted-color, #64748b) |
--tsm-onboarding-surface | var(--p-content-background, #fff) |
--tsm-onboarding-border | var(--p-content-border-color, rgb(17 19 24 / 0.1)) |
--onboarding-root-host-panel-max-width | min(26rem, calc(100vw - 2rem)) |
Override these variables at application or theme level to tune the onboarding appearance.
Validation
Use validateOnboardingScenario in tests or development utilities to catch
common mistakes before a scenario is registered.
import { validateOnboardingScenario } from "@tsm/framework/onboarding";
const result = validateOnboardingScenario(scenario);
if (!result.ok) {
console.table(result.issues);
}
The validator checks required scenario fields, positive versions, duplicate
step ids, missing entry steps, empty content, missing nextStepId targets,
invalid interaction events, invalid interaction actions, missing selectors, and
jump actions without stepId.
Recommended Conventions
- Use stable
data-cyselectors for targets and interactions. - Put the root host in the application layout, not in a routed page.
- Use
positionsfor real tours. Avoidposition: 'center'for targeted steps unless a centered overlay is intentional. - Hide Next/Finish for steps that must wait for a real user event.
- Keep scenario text in the normal Transloco/i18n structure of the owning feature or plugin.
- Increment
scenario.versionwhen you rename step ids or change progress semantics. - Use
flowIdto separate multiple independent runs of the same scenario. - Prefer plugin registration for scenarios owned by lazy feature libraries.