Stepper
A step-by-step process: a list of step headers plus the body of the active one. Steps come from projected children, from a data-driven steps array, or both.
There is no WAI-ARIA APG pattern for a stepper, so the semantics are a decision rather than an inheritance: an ordered list of <button> headers carrying aria-current="step", each body a role="group" labelled by its header — group rather than region on purpose, because a landmark per step would push a five-step wizard past the handful the APG asks a page to keep. That is one semantic in both orientations — Angular Material instead emits role="tablist" when horizontal and aria-current when vertical, so the same widget reads as two different things to a screen reader, and a tablist claims panels may be browsed freely, which is exactly what linear forbids.
Commands
The built-in Back / Next bar becomes Finish on the last step. None of Angular Material, Kendo or PrimeNG ships navigation buttons at all — every one of them makes you hand-roll a wizard's most predictable part. icon takes SVG path data and replaces the step number.
Account fields…
Shipping fields…
Confirm and submit…
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeStepper, OgeStep } from '@oge-ui/navigation';
@Component({
selector: 'demo-root',
imports: [OgeStepper, OgeStep],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- Steps come from projected children, from a [steps] array, or
both (children first). The built-in Back / Next bar is opt-in — none of
the reference steppers ships one at all. icon takes SVG path data and
replaces the step number in the indicator. -->
<oge-stepper [(activeIndex)]="step" [showNavigation]="true">
<oge-step label="Account" description="Who you are" [icon]="userIcon">
Account fields…
</oge-step>
<oge-step label="Shipping" description="Where it goes" [optional]="true">
Shipping fields…
</oge-step>
<oge-step label="Review" description="One last look">
Confirm and submit…
</oge-step>
</oge-stepper>
`,
})
export class Demo {
protected readonly step = signal(0);
protected readonly userIcon =
'M8 7.5A2.75 2.75 0 1 0 8 2a2.75 2.75 0 0 0 0 5.5ZM2.5 14c0-3 2.5-4.5 5.5-4.5s5.5 1.5 5.5 4.5Z';
}Linear flow
linear blocks moving past a step that is neither completed nor optional; editable: false blocks coming back. Every refusal emits stepBlocked with the reason — Material refuses silently and its own docs tell you to add a live region yourself.
last refusal → —
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeStepper, OgeStep } from '@oge-ui/navigation';
import type { OgeStepBlockedEvent } from '@oge-ui/navigation';
@Component({
selector: 'demo-root',
imports: [OgeStepper, OgeStep],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- linear blocks moving past a step that is neither completed
nor optional, and editable:false blocks coming back. Every refusal says
WHY — Angular Material refuses silently and tells you to add your own
live region. -->
<oge-stepper
[(activeIndex)]="step"
[linear]="true"
[showNavigation]="true"
(stepBlocked)="onBlocked($event)"
>
<oge-step label="Account" [completed]="accountDone()" [editable]="false" />
<oge-step label="Payment" [completed]="paymentDone()" />
<oge-step label="Review" />
</oge-stepper>
`,
})
export class Demo {
protected readonly step = signal(0);
protected readonly accountDone = signal(false);
protected readonly paymentDone = signal(false);
protected onBlocked(event: OgeStepBlockedEvent): void {
console.log('refused because', event.reason);
}
}Step states
The indicator state is derived from the step's own flags. error outranks done, so a completed step that later fails still reads as needing attention. The glyph is aria-hidden, so the state is announced in text as well.
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { OgeStepper, OgeStep } from '@oge-ui/navigation';
@Component({
selector: 'demo-root',
imports: [OgeStepper, OgeStep],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- The indicator state is derived: error outranks done, so a
completed step that later fails still reads as needing attention. The
glyph is aria-hidden, so the state is also announced in text. -->
<oge-stepper [activeIndex]="0" display="full">
<oge-step label="Active" description="the current step" />
<oge-step label="Done" [completed]="true" />
<oge-step label="Error" [completed]="true" [invalid]="true" />
<oge-step label="Upcoming" />
</oge-stepper>
`,
})
export class Demo {}Leave guard
stepGuard runs when the user leaves a step, inside the same pipeline the headers use. false, a throw and a rejection all veto; a promise reports changePending and a second gesture meanwhile is dropped. It gates the finish on the last step too.
Details…
Done…
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeStepper, OgeStep } from '@oge-ui/navigation';
@Component({
selector: 'demo-root',
imports: [OgeStepper, OgeStep],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- stepGuard runs when the user LEAVES a step, inside the same
pipeline the headers use. false, a throw and a rejection all veto; a
promise reports changePending and a second gesture meanwhile is dropped.
It gates the finish on the last step too. -->
<oge-stepper [(activeIndex)]="step" [showNavigation]="true">
<oge-step label="Details" [stepGuard]="confirmLeave">Details…</oge-step>
<oge-step label="Done">Done…</oge-step>
</oge-stepper>
`,
})
export class Demo {
protected readonly step = signal(0);
protected readonly dirty = signal(true);
protected readonly confirmLeave = (): boolean =>
!this.dirty() || confirm('Discard your changes?');
}Orientation
Vertical stacks the bodies under their own headers. The ARIA model is identical either way — the headers stay buttons with aria-current='step', so a screen reader hears the same widget.
First body…
Second body…
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeStepper, OgeStep } from '@oge-ui/navigation';
import type { OgeStepperOrientation } from '@oge-ui/navigation';
@Component({
selector: 'demo-root',
imports: [OgeStepper, OgeStep],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- The ARIA semantics do NOT change with the orientation: it is
an ordered list of buttons with aria-current="step" either way. Angular
Material swaps to role="tablist" when horizontal, so the same widget
reads as two different things to a screen reader. -->
<oge-stepper [orientation]="orientation()" [(activeIndex)]="step">
<oge-step label="One">First body…</oge-step>
<oge-step label="Two">Second body…</oge-step>
</oge-stepper>
`,
})
export class Demo {
protected readonly step = signal(0);
protected readonly orientation = signal<OgeStepperOrientation>('vertical');
}Navigation buttons
The directives route through the same pipeline the headers use, so linear and stepGuard still apply. They find the stepper by DI when written inside it, or take one explicitly from outside — which Material's equivalents cannot do.
First body…
Second body…
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeStepper, OgeStep, OgeStepperNext, OgeStepperPrevious } from '@oge-ui/navigation';
@Component({
selector: 'demo-root',
imports: [OgeStepper, OgeStep, OgeStepperNext, OgeStepperPrevious],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- The directives route through the same pipeline the headers
use, so linear and stepGuard still apply. They work inside the stepper by
DI, or bound to it from outside — which Material's cannot do. -->
<oge-stepper #wizard [(activeIndex)]="step">
<oge-step label="One">
First body…
<button type="button" ogeStepperNext>Continue</button>
</oge-step>
<oge-step label="Two">Second body…</oge-step>
</oge-stepper>
<button type="button" ogeStepperPrevious [ogeStepperTarget]="wizard">Back</button>
`,
})
export class Demo {
protected readonly step = signal(0);
}Inside a form
wraps the stepper the way wraps the tabs. Step completion comes from the form's own per-step error rollup, so it behaves identically with [fieldTree], [formGroup] and [(formData)] — and leaving a step touches only that step's fields, so the steps ahead stay quiet instead of turning red.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { OgeForm, OgeFormItem, OgeFormGroup, OgeFormSteps } from '@oge-ui/forms';
@Component({
selector: 'demo-root',
imports: [OgeForm, OgeFormItem, OgeFormGroup, OgeFormSteps],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- <oge-form-steps> wraps the stepper the way <oge-form-tabs>
wraps the tabs. Step completion comes from the form's own per-step error
rollup, so it works identically with [fieldTree], [formGroup] and
[(formData)] — and leaving a step touches only THAT step's fields, so the
steps ahead stay quiet instead of turning red. -->
<oge-form [(formData)]="order">
<oge-form-steps [linear]="true">
<oge-form-group caption="Account">
<oge-form-item field="email" label="E-mail" [isRequired]="true" />
</oge-form-group>
<oge-form-group caption="Payment">
<oge-form-item field="card" label="Card" [isRequired]="true" />
</oge-form-group>
</oge-form-steps>
</oge-form>
`,
})
export class Demo {
protected readonly order = signal({ email: '', card: '' });
}Configuration
Every user-facing string — including the two announced only to screen readers, because the indicator glyph is aria-hidden — lives in the messages interface.
import { provideOgeStepperConfig } from '@oge-ui/navigation';
bootstrapApplication(App, {
providers: [
provideOgeStepperConfig({
linear: true,
messages: { next: 'İleri', previous: 'Geri', finish: 'Bitir' },
}),
],
});