Questionnaire Since v4.1.0

A stepped set of questions asked inside an assistant message.

Questionnaire is a recipe and adds no Core code. A small component extends the CDK CdkStepper to render one cdk-step at a time with a progress label and Back, Next and Submit buttons. Each step holds Material radio buttons, checkboxes or a textarea bound with signal forms, and its validity decides whether Next is enabled.

Example

The assistant asks before it recommends. Submitting replaces the form with a summary of the answers, which is also what the next turn sends back to the model.

Help me pick a plan for my team.

A few questions so I can recommend the right plan.

1 of 3

How big is your team?

Count everyone who needs a seat.

<div buiMessage>
<div buiMessageContent>
<p>A few questions so I can recommend the right plan.</p>
<QuestionnaireStepper linear (submitted)="submit()">
<cdk-step [stepControl]="sizeForm" [completed]="sizeForm().valid()">
<div>
<p class="font-semibold">How big is your team?</p>
<p class="text-neutral-a11">Count everyone who needs a seat.</p>
</div>
<mat-radio-group
class="flex flex-col items-start gap-y-2"
[formField]="sizeForm.size"
>
@for (size of teamSizes; track size) {
<mat-radio-button [value]="size">{{ size }}</mat-radio-button>
}
</mat-radio-group>
</cdk-step>
<cdk-step [stepControl]="needsForm" [completed]="needsForm().valid()">
<div>
<p class="font-semibold">What do you need?</p>
<p class="text-neutral-a11">Pick at least one.</p>
</div>
<div class="flex flex-col items-start gap-y-2">
@for (need of needs; track need) {
<mat-checkbox [formField]="needsForm[need]">{{ need }}</mat-checkbox>
}
</div>
</cdk-step>
<cdk-step [stepControl]="notesForm" [completed]="true">
<div>
<p class="font-semibold">Anything else we should know?</p>
<p class="text-neutral-a11">Optional.</p>
</div>
<mat-form-field class="w-full">
<textarea
matInput
placeholder="Billing, timeline, integrations"
[formField]="notesForm.notes"
></textarea>
</mat-form-field>
</cdk-step>
</QuestionnaireStepper>
</div>
</div>

Stepper

The stepper renders the selected step's content and its own controls, so it carries no Material stepper header. With linear, Next stays disabled until the step is completed, and the last step shows Submit instead.

import {
CdkStepper,
CdkStepperNext,
CdkStepperPrevious,
} from '@angular/cdk/stepper';
import { NgTemplateOutlet } from '@angular/common';
import { Component, output } from '@angular/core';
import { MatButton } from '@angular/material/button';

@Component({
selector: 'QuestionnaireStepper',
imports: [NgTemplateOutlet, MatButton, CdkStepperNext, CdkStepperPrevious],
providers: [{ provide: CdkStepper, useExisting: QuestionnaireStepper }],
host: {
class: 'flex flex-col gap-y-4',
},
template: `
<div class="text-sm font-medium text-neutral-a11 tabular-nums">
{{ selectedIndex + 1 }} of {{ steps.length }}
</div>
<ng-container [ngTemplateOutlet]="selected?.content" />
<div class="flex justify-end gap-2">
@if (selectedIndex > 0) {
<button matButton type="button" class="small" cdkStepperPrevious>
Back
</button>
}
@if (selectedIndex < steps.length - 1) {
<button
matButton
type="button"
class="small primary"
cdkStepperNext
[disabled]="!selected?.completed"
>
Next
</button>
} @else {
<button
matButton
type="button"
class="small primary"
(click)="submitted.emit()"
>
Submit
</button>
}
</div>
`,
})
export class QuestionnaireStepper extends CdkStepper {
readonly submitted = output();
}

Data

Each step is its own signal form, so its validity gates that step alone. Required choices use required, and a group of checkboxes uses validate to ask for at least one. On submit, collect the models into the answer the next turn sends.

import {
form,
required,
requiredError,
validate,
} from '@angular/forms/signals';

const needs = ['SSO', 'Audit log', 'Priority support', 'Custom roles'];

readonly sizeModel = signal({ size: '' });
readonly needsModel = signal(
Object.fromEntries(needs.map((need) => [need, false]))
);
readonly notesModel = signal({ notes: '' });

readonly sizeForm = form(this.sizeModel, (schema) => {
required(schema.size);
});
readonly needsForm = form(this.needsModel, (schema) => {
validate(schema, ({ value }) =>
Object.values(value()).some(Boolean) ? undefined : requiredError()
);
});
readonly notesForm = form(this.notesModel);

submit() {
const answers = {
size: this.sizeModel().size,
needs: needs.filter((need) => this.needsModel()[need]),
notes: this.notesModel().notes,
};
// Send answers with the next turn
}