- Accordion
- Alert
- Alert Dialog
- Aspect Ratio
- Attachment
- Autocomplete
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Chart
- Checkbox
- Collapsible
- Combobox
- Command
- Context Menu
- Data Table
- Date Picker
- Dialog
- Drawer
- Dropdown Menu
- Empty
- Field
- Hover Card
- Input Group
- Input OTP
- Input
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Native Select
- Navigation Menu
- Pagination
- Popover
- Progress
- Questionnaire
- Radio Group
- Resizable
- Scroll Area
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Sonner (Toast)
- Spinner
- Switch
- Table
- Tabs
- Textarea
- Toggle
- Toggle Group
- Tooltip
Questionnaire
A multi-step questionnaire with single-choice, multiple-choice, freeform, and skippable questions.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField, FormRoot, required } from '@angular/forms/signals';
import type { BrnQuestionnaireItemDefinition } from '@spartan-ng/brain/questionnaire';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmQuestionnaireImports } from '@spartan-ng/helm/questionnaire';
import { answerLabel } from './questionnaire.shared';
@Component({
selector: 'spartan-questionnaire-preview',
imports: [FormRoot, FormField, HlmQuestionnaireImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full justify-center py-6',
},
template: `
<form
hlmQuestionnaire
class="mx-auto max-w-md"
[formRoot]="form"
[items]="items"
defaultItem="direction"
shortcuts="letters"
>
<div hlmQuestionnaireProgress></div>
<fieldset hlmQuestionnaireItem name="direction" required [formField]="form.direction">
<legend hlmQuestionnaireTitle>What should the agent build next?</legend>
<p hlmQuestionnaireDescription>Choose a direction or describe another task.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="tool-calls">
<span class="font-medium">Tool call timeline</span>
<span hlmQuestionnaireChoiceDescription>Show what the agent ran and what came back.</span>
</label>
<label hlmQuestionnaireChoice value="approvals">
<span class="font-medium">Approval checkpoints</span>
<span hlmQuestionnaireChoiceDescription>Ask before sensitive or destructive actions.</span>
</label>
<label hlmQuestionnaireChoice value="handoffs">
<span class="font-medium">Sub-agent handoffs</span>
<span hlmQuestionnaireChoiceDescription>Make delegated work and results easier to follow.</span>
</label>
<input hlmQuestionnaireInput aria-label="Another agent feature" placeholder="Describe another feature…" />
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset hlmQuestionnaireItem name="signals" multiple [formField]="form.signals">
<legend hlmQuestionnaireTitle>What should every progress update include?</legend>
<p hlmQuestionnaireDescription>Select all that apply, or skip this question.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="progress">Progress</label>
<label hlmQuestionnaireChoice value="decisions">Decisions</label>
<label hlmQuestionnaireChoice value="risks">Risks</label>
<label hlmQuestionnaireChoice value="next-step">Next step</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset hlmQuestionnaireItem name="timing" required [formField]="form.timing">
<legend hlmQuestionnaireTitle>When should work begin?</legend>
<p hlmQuestionnaireDescription>Choose when the agent should begin the work.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="now">Start now</label>
<label hlmQuestionnaireChoice value="next-cycle">Next development cycle</label>
<label hlmQuestionnaireChoice value="backlog">Add it to the backlog</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<div hlmQuestionnaireActions>
<button hlmQuestionnairePrevious>Previous</button>
<button hlmQuestionnaireSkip>Skip</button>
<button hlmQuestionnaireNext>Next</button>
<button hlmQuestionnaireSubmit>Save plan</button>
</div>
</form>
`,
})
export class QuestionnairePreview {
public readonly items: readonly BrnQuestionnaireItemDefinition[] = [
{
name: 'direction',
required: true,
choices: [{ value: 'tool-calls' }, { value: 'approvals' }, { value: 'handoffs' }],
},
{
name: 'signals',
required: false,
choices: [{ value: 'progress' }, { value: 'decisions' }, { value: 'risks' }, { value: 'next-step' }],
},
{
name: 'timing',
required: true,
choices: [{ value: 'now' }, { value: 'next-cycle' }, { value: 'backlog' }],
},
];
protected readonly _model = signal({
direction: '',
signals: [] as string[],
timing: '',
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.direction);
required(schemaPath.timing);
},
{
submission: {
action: async () => {
const answers = this._model();
toast('Agent plan saved', {
description: `Direction: ${answerLabel(answers.direction)} · Progress signals: ${answerLabel(answers.signals)} · Timing: ${answerLabel(answers.timing)}`,
});
},
},
},
);
}Installation
ng g @spartan-ng/cli:ui questionnairenx g @spartan-ng/cli:ui questionnaireimport { DestroyRef, ElementRef, HostAttributeToken, Injector, PLATFORM_ID, effect, inject, makeEnvironmentProviders, runInInjectionContext, type EnvironmentProviders } from '@angular/core';
import { OVERLAY_DEFAULT_CONFIG } from '@angular/cdk/overlay';
import { clsx, type ClassValue } from 'clsx';
import { isPlatformBrowser } from '@angular/common';
import { provideSpartanHlm } from '@spartan-ng/helm/utils';
import { twMerge } from 'tailwind-merge';
export function hlm(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Global map to track class managers per element
const elementClassManagers = new WeakMap<HTMLElement, ElementClassManager>();
// Global mutation observer for all elements
let globalObserver: MutationObserver | null = null;
const observedElements = new Set<HTMLElement>();
interface ElementClassManager {
element: HTMLElement;
sources: Map<number, { classes: Set<string>; order: number }>;
baseClasses: Set<string>;
isUpdating: boolean;
nextOrder: number;
hasInitialized: boolean;
restoreRafId: number | null;
/** Transitions are suppressed until the first effect writes correct classes */
transitionsSuppressed: boolean;
/** Original inline transition value to restore after suppression (empty string = none was set) */
previousTransition: string;
/** Original inline transition priority to preserve !important when restoring */
previousTransitionPriority: string;
}
let sourceCounter = 0;
/**
* This function dynamically adds and removes classes for a given element without requiring
* the a class binding (e.g. `[class]="..."`) which may interfere with other class bindings.
*
* 1. This will merge the existing classes on the element with the new classes.
* 2. It will also remove any classes that were previously added by this function but are no longer present in the new classes.
* 3. Multiple calls to this function on the same element will be merged efficiently.
*/
export function classes(computed: () => ClassValue[] | string, options: ClassesOptions = {}) {
runInInjectionContext(options.injector ?? inject(Injector), () => {
const elementRef = options.elementRef ?? inject(ElementRef);
const platformId = inject(PLATFORM_ID);
const destroyRef = inject(DestroyRef);
const baseClasses = inject(new HostAttributeToken('class'), { optional: true });
const element = elementRef.nativeElement;
// Create unique identifier for this source
const sourceId = sourceCounter++;
// Get or create the class manager for this element
let manager = elementClassManagers.get(element);
if (!manager) {
// Initialize base classes from variation (host attribute 'class')
const initialBaseClasses = new Set<string>();
if (baseClasses) {
toClassList(baseClasses).forEach((cls) => initialBaseClasses.add(cls));
}
manager = {
element,
sources: new Map(),
baseClasses: initialBaseClasses,
isUpdating: false,
nextOrder: 0,
hasInitialized: false,
restoreRafId: null,
transitionsSuppressed: false,
previousTransition: '',
previousTransitionPriority: '',
};
elementClassManagers.set(element, manager);
// Setup global observer if needed and register this element
setupGlobalObserver(platformId);
observedElements.add(element);
// Suppress transitions until the first effect writes correct classes and
// the browser has painted them. This prevents CSS transition animations
// during hydration when classes change from SSR state to client state.
if (isPlatformBrowser(platformId)) {
manager.previousTransition = element.style.getPropertyValue('transition');
manager.previousTransitionPriority = element.style.getPropertyPriority('transition');
element.style.setProperty('transition', 'none', 'important');
manager.transitionsSuppressed = true;
}
}
// Assign order once at registration time
const sourceOrder = manager.nextOrder++;
function updateClasses(): void {
// Get the new classes from the computed function
const newClasses = toClassList(computed());
// Update this source's classes, keeping the original order
manager!.sources.set(sourceId, {
classes: new Set(newClasses),
order: sourceOrder,
});
// Update the element
updateElement(manager!);
// Re-enable transitions after the first effect writes correct classes.
// Deferred to next animation frame so the browser paints the class change
// with transitions disabled first, then re-enables them.
if (manager!.transitionsSuppressed) {
manager!.transitionsSuppressed = false;
manager!.restoreRafId = requestAnimationFrame(() => {
manager!.restoreRafId = null;
restoreTransitionSuppression(manager!);
});
}
}
// Register cleanup with DestroyRef
destroyRef.onDestroy(() => {
if (manager!.restoreRafId !== null) {
cancelAnimationFrame(manager!.restoreRafId);
manager!.restoreRafId = null;
}
if (manager!.transitionsSuppressed) {
manager!.transitionsSuppressed = false;
restoreTransitionSuppression(manager!);
}
// Remove this source from the manager
manager!.sources.delete(sourceId);
// If no more sources, clean up the manager
if (manager!.sources.size === 0) {
cleanupManager(element);
} else {
// Update element without this source's classes
updateElement(manager!);
}
});
/**
* We need this effect to track changes to the computed classes. Ideally, we would use
* afterRenderEffect here, but that doesn't run in SSR contexts, so we use a standard
* effect which works in both browser and SSR.
*/
effect(updateClasses);
});
}
function restoreTransitionSuppression(manager: ElementClassManager): void {
const prev = manager.previousTransition;
if (prev) {
manager.element.style.setProperty('transition', prev, manager.previousTransitionPriority || undefined);
} else {
manager.element.style.removeProperty('transition');
}
}
// eslint-disable-next-line @typescript-eslint/no-wrapper-object-types
function setupGlobalObserver(platformId: Object): void {
if (isPlatformBrowser(platformId) && !globalObserver) {
// Create single global observer that watches the entire document
globalObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const element = mutation.target as HTMLElement;
const manager = elementClassManagers.get(element);
// Only process elements we're managing
if (manager && observedElements.has(element)) {
if (manager.isUpdating) continue; // Ignore changes we're making
// Update base classes to include any externally added classes
const currentClasses = toClassList(element.className);
const allSourceClasses = new Set<string>();
// Collect all classes from all sources
for (const source of manager.sources.values()) {
for (const className of source.classes) {
allSourceClasses.add(className);
}
}
// Any classes not from sources become new base classes
manager.baseClasses.clear();
for (const className of currentClasses) {
if (!allSourceClasses.has(className)) {
manager.baseClasses.add(className);
}
}
updateElement(manager);
}
}
}
});
// Start observing the entire document for class attribute changes
globalObserver.observe(document, {
attributes: true,
attributeFilter: ['class'],
subtree: true, // Watch all descendants
});
}
}
function updateElement(manager: ElementClassManager): void {
if (manager.isUpdating) return; // Prevent recursive updates
manager.isUpdating = true;
// Handle initialization: capture base classes after first source registration
if (!manager.hasInitialized && manager.sources.size > 0) {
// Get current classes on element (may include SSR classes)
const currentClasses = toClassList(manager.element.className);
// Get all classes that will be applied by sources
const allSourceClasses = new Set<string>();
for (const source of manager.sources.values()) {
source.classes.forEach((className) => allSourceClasses.add(className));
}
// Only consider classes as "base" if they're not produced by any source
// This prevents SSR-rendered classes from being preserved as base classes
currentClasses.forEach((className) => {
if (!allSourceClasses.has(className)) {
manager.baseClasses.add(className);
}
});
manager.hasInitialized = true;
}
// Get classes from all sources, sorted by registration order (later takes precedence)
const sortedSources = Array.from(manager.sources.entries()).sort(([, a], [, b]) => a.order - b.order);
const allSourceClasses: string[] = [];
for (const [, source] of sortedSources) {
allSourceClasses.push(...source.classes);
}
// Combine base classes with all source classes, ensuring base classes take precedence
const classesToApply =
allSourceClasses.length > 0 || manager.baseClasses.size > 0
? hlm([...allSourceClasses, ...manager.baseClasses])
: '';
// Apply the classes to the element
if (manager.element.className !== classesToApply) {
manager.element.className = classesToApply;
}
manager.isUpdating = false;
}
function cleanupManager(element: HTMLElement): void {
// Remove from global tracking
observedElements.delete(element);
elementClassManagers.delete(element);
// If no more elements being tracked, cleanup global observer
if (observedElements.size === 0 && globalObserver) {
globalObserver.disconnect();
globalObserver = null;
}
}
interface ClassesOptions {
elementRef?: ElementRef<HTMLElement>;
injector?: Injector;
}
// Cache for parsed class lists to avoid repeated string operations
const classListCache = new Map<string, string[]>();
function toClassList(className: string | ClassValue[]): string[] {
// For simple string inputs, use cache to avoid repeated parsing
if (typeof className === 'string' && classListCache.has(className)) {
return classListCache.get(className)!;
}
const result = clsx(className)
.split(' ')
.filter((c) => c.length > 0);
// Cache string results, but limit cache size to prevent memory growth
if (typeof className === 'string' && classListCache.size < 1000) {
classListCache.set(className, result);
}
return result;
}
/**
* Provides default configuration for Spartan Helm components.
*
* This utility configures the Angular CDK overlay to disable the `usePopover`
* behavior introduced in Angular 21, which causes CDK overlay-based components
* (sheets, dialogs, tooltips, etc.) to render above `position: fixed` elements
* like `<hlm-toaster>`.
*
* @returns {EnvironmentProviders} Environment providers to be added to the application config.
*
* @example
* ```ts
* // app.config.ts
*
*
* export const appConfig: ApplicationConfig = {
* providers: [
* provideSpartanHlm(),
* // ... other providers
* ],
* };
* ```
*/
export function provideSpartanHlm(): EnvironmentProviders {
return makeEnvironmentProviders([
{
provide: OVERLAY_DEFAULT_CONFIG,
useValue: { usePopover: false },
},
]);
}import { BrnQuestionnaire, BrnQuestionnaireChoice, BrnQuestionnaireChoiceInput, BrnQuestionnaireChoiceLabel, BrnQuestionnaireChoiceShortcut, BrnQuestionnaireChoices, BrnQuestionnaireDescription, BrnQuestionnaireError, BrnQuestionnaireInput, BrnQuestionnaireItem, BrnQuestionnaireNext, BrnQuestionnairePrevious, BrnQuestionnaireProgress, BrnQuestionnaireSkip, BrnQuestionnaireSubmit, BrnQuestionnaireTitle } from '@spartan-ng/brain/questionnaire';
import { ChangeDetectionStrategy, Component, Directive, inject, input } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { buttonVariants, type ButtonVariants } from '@spartan-ng/helm/button';
import { classes } from '@spartan-ng/helm/utils';
import { lucideCheck } from '@ng-icons/lucide';
@Directive({
selector: '[hlmQuestionnaireActions],hlm-questionnaire-actions',
exportAs: 'hlmQuestionnaireActions',
host: {
'data-slot': 'questionnaire-actions',
},
})
export class HlmQuestionnaireActions {
constructor() {
classes(
() => 'gap-2 sm:min-h-8 grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center',
);
}
}
@Directive({
selector: '[hlmQuestionnaireChoiceDescription]',
exportAs: 'hlmQuestionnaireChoiceDescription',
host: {
'data-slot': 'questionnaire-choice-description',
},
})
export class HlmQuestionnaireChoiceDescription {
constructor() {
classes(() => 'text-muted-foreground');
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector on native label
selector: 'label[hlmQuestionnaireChoice]',
exportAs: 'hlmQuestionnaireChoice',
imports: [BrnQuestionnaireChoiceInput, BrnQuestionnaireChoiceLabel, BrnQuestionnaireChoiceShortcut, NgIcon],
viewProviders: [provideIcons({ lucideCheck })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnQuestionnaireChoice,
inputs: ['value', 'disabled', 'defaultChecked', 'checked'],
outputs: ['checkedChange'],
},
],
host: {
'data-slot': 'questionnaire-choice',
},
template: `
<input
brnQuestionnaireChoiceInput
data-slot="questionnaire-choice-input"
class="absolute inset-0 z-10 size-full cursor-pointer opacity-0"
/>
<span aria-hidden="true" data-slot="questionnaire-choice-indicator" class="border-input group-data-checked/questionnaire-choice:border-primary group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground dark:bg-input/30 dark:group-data-checked/questionnaire-choice:bg-primary pointer-events-none relative flex size-4 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-[4px] border group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 group-data-[type=radio]/questionnaire-choice:rounded-full">
@if (_choice.checked() && _choice.type() === 'radio') {
<span data-slot="questionnaire-choice-indicator-dot" class="bg-primary-foreground size-2 rounded-full"></span>
}
@if (_choice.checked() && _choice.type() === 'checkbox') {
<ng-icon
name="lucideCheck"
data-slot="questionnaire-choice-indicator-check"
class="text-[length:--spacing(3.5)]"
/>
}
</span>
<span
brnQuestionnaireChoiceLabel
data-slot="questionnaire-choice-label"
class="gap-0.5 flex min-w-0 flex-1 flex-col leading-snug"
>
<ng-content />
</span>
@if (_choice.shortcut(); as shortcut) {
<span
brnQuestionnaireChoiceShortcut
data-slot="questionnaire-choice-shortcut"
class="border-input bg-background text-muted-foreground pointer-events-none ms-auto inline-flex size-5 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-md border font-mono text-[0.625rem] leading-none font-medium group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5"
>
{{ shortcut }}
</span>
}
`,
})
export class HlmQuestionnaireChoice {
protected readonly _choice = inject(BrnQuestionnaireChoice);
constructor() {
classes(
() =>
'border-input hover:bg-muted/50 has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-ring/50 data-invalid:border-destructive dark:bg-input/20 data-checked:border-primary/40 data-checked:bg-muted dark:data-checked:bg-muted gap-2.5 rounded-lg border bg-transparent px-3 py-2.5 text-sm transition-colors has-[>input:focus-visible]:ring-3 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start text-start outline-none select-none',
);
}
}
@Directive({
selector: '[hlmQuestionnaireChoices]',
exportAs: 'hlmQuestionnaireChoices',
hostDirectives: [{ directive: BrnQuestionnaireChoices }],
host: {
'data-slot': 'questionnaire-choices',
},
})
export class HlmQuestionnaireChoices {
constructor() {
classes(() => 'gap-2 group/questionnaire-choices grid min-w-0');
}
}
@Directive({
selector: '[hlmQuestionnaireDescription]',
exportAs: 'hlmQuestionnaireDescription',
hostDirectives: [
{
directive: BrnQuestionnaireDescription,
inputs: ['id'],
},
],
host: {
'data-slot': 'questionnaire-description',
},
})
export class HlmQuestionnaireDescription {
constructor() {
classes(() => 'text-sm text-muted-foreground text-pretty');
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector matching brain API
selector: '[hlmQuestionnaireError]',
exportAs: 'hlmQuestionnaireError',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnQuestionnaireError,
inputs: ['id', 'requiredMessage', 'optionalMessage'],
},
],
host: {
'data-slot': 'questionnaire-error',
},
template: `
{{ message() ?? _error.defaultMessage() }}
`,
})
export class HlmQuestionnaireError {
protected readonly _error = inject(BrnQuestionnaireError);
/** Custom error copy; falls back to the built-in required/optional message when unset. */
public readonly message = input<string | undefined>(undefined);
constructor() {
classes(() => 'mt-2 text-sm text-destructive');
}
}
@Directive({
selector: 'input[hlmQuestionnaireInput]',
exportAs: 'hlmQuestionnaireInput',
hostDirectives: [
{
directive: BrnQuestionnaireInput,
inputs: ['type', 'disabled', 'value', 'defaultValue'],
},
],
host: {
'data-slot': 'questionnaire-input',
},
})
export class HlmQuestionnaireInput {
constructor() {
classes(() => [
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base focus-visible:ring-3 aria-invalid:ring-3 md:text-sm min-h-11 w-full min-w-0 transition-[color,box-shadow,background-color] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-0',
'selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground',
]);
}
}
@Directive({
selector: 'fieldset[hlmQuestionnaireItem]',
exportAs: 'hlmQuestionnaireItem',
hostDirectives: [
{
directive: BrnQuestionnaireItem,
inputs: ['name', 'multiple', 'required', 'disabled', 'itemInvalid', 'aria-describedby', 'aria-keyshortcuts'],
outputs: ['statusChange'],
},
],
host: {
'data-slot': 'questionnaire-item',
},
})
export class HlmQuestionnaireItem {
constructor() {
classes(() => 'gap-4 flex min-w-0 flex-col border-0 p-0 outline-none');
}
}
@Directive({
selector: 'button[hlmQuestionnaireNext]',
exportAs: 'hlmQuestionnaireNext',
hostDirectives: [
{
directive: BrnQuestionnaireNext,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-next',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireNext {
public readonly variant = input<ButtonVariants['variant']>('default');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'button[hlmQuestionnairePrevious]',
exportAs: 'hlmQuestionnairePrevious',
hostDirectives: [
{
directive: BrnQuestionnairePrevious,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-previous',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnairePrevious {
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-1 row-start-1 min-h-11 justify-self-start sm:min-h-0',
]);
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector matching brain API
selector: '[hlmQuestionnaireProgress]',
exportAs: 'hlmQuestionnaireProgress',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnQuestionnaireProgress, inputs: ['aria-label', 'valueText'] }],
host: {
'data-slot': 'questionnaire-progress',
},
template: `
<ng-content>
{{ _progress.label() }}
</ng-content>
`,
})
export class HlmQuestionnaireProgress {
protected readonly _progress = inject(BrnQuestionnaireProgress);
/** The current step index (1-based within the active collection). */
public readonly current = this._progress.current;
/** The total number of enabled steps. */
public readonly total = this._progress.total;
/** Whether the current step is the first one. */
public readonly first = this._progress.first;
/** Whether the current step is the last one. */
public readonly last = this._progress.last;
/** The computed progression label, e.g. "Question 2 of 5". */
public readonly label = this._progress.label;
/** Boolean flags marking which segments of the progress bar are filled. */
public readonly segments = this._progress.segments;
constructor() {
classes(
() => 'text-xs text-muted-foreground min-h-lh w-fit min-w-[14ch] font-medium tabular-nums',
);
}
}
@Directive({
selector: 'button[hlmQuestionnaireSkip]',
exportAs: 'hlmQuestionnaireSkip',
hostDirectives: [
{
directive: BrnQuestionnaireSkip,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-skip',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireSkip {
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-2 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'button[hlmQuestionnaireSubmit]',
exportAs: 'hlmQuestionnaireSubmit',
hostDirectives: [
{
directive: BrnQuestionnaireSubmit,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-submit',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireSubmit {
public readonly variant = input<ButtonVariants['variant']>('default');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'legend[hlmQuestionnaireTitle]',
exportAs: 'hlmQuestionnaireTitle',
hostDirectives: [{ directive: BrnQuestionnaireTitle }],
host: {
'data-slot': 'questionnaire-title',
},
})
export class HlmQuestionnaireTitle {
constructor() {
classes(() => 'text-base leading-snug font-medium [&:not(:has(~[data-slot=questionnaire-description]))]:mb-4 text-pretty');
}
}
@Directive({
selector: 'form[hlmQuestionnaire]',
exportAs: 'hlmQuestionnaire',
hostDirectives: [
{
directive: BrnQuestionnaire,
inputs: ['items', 'defaultItem', 'item', 'shortcuts', 'noValidate'],
outputs: ['itemChange'],
},
],
host: {
'data-slot': 'questionnaire',
},
})
export class HlmQuestionnaire {
constructor() {
classes(() => 'gap-4 flex w-full min-w-0 flex-col');
}
}
export const HlmQuestionnaireImports = [
HlmQuestionnaire,
HlmQuestionnaireProgress,
HlmQuestionnaireItem,
HlmQuestionnaireTitle,
HlmQuestionnaireDescription,
HlmQuestionnaireChoices,
HlmQuestionnaireChoice,
HlmQuestionnaireChoiceDescription,
HlmQuestionnaireInput,
HlmQuestionnaireError,
HlmQuestionnaireActions,
HlmQuestionnairePrevious,
HlmQuestionnaireSkip,
HlmQuestionnaireNext,
HlmQuestionnaireSubmit,
] as const;import { BrnQuestionnaire, BrnQuestionnaireChoice, BrnQuestionnaireChoiceInput, BrnQuestionnaireChoiceLabel, BrnQuestionnaireChoiceShortcut, BrnQuestionnaireChoices, BrnQuestionnaireDescription, BrnQuestionnaireError, BrnQuestionnaireInput, BrnQuestionnaireItem, BrnQuestionnaireNext, BrnQuestionnairePrevious, BrnQuestionnaireProgress, BrnQuestionnaireSkip, BrnQuestionnaireSubmit, BrnQuestionnaireTitle } from '@spartan-ng/brain/questionnaire';
import { ChangeDetectionStrategy, Component, Directive, inject, input } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { buttonVariants, type ButtonVariants } from '@spartan-ng/helm/button';
import { classes } from '@spartan-ng/helm/utils';
import { lucideCheck } from '@ng-icons/lucide';
@Directive({
selector: '[hlmQuestionnaireActions],hlm-questionnaire-actions',
exportAs: 'hlmQuestionnaireActions',
host: {
'data-slot': 'questionnaire-actions',
},
})
export class HlmQuestionnaireActions {
constructor() {
classes(
() => 'gap-2 sm:min-h-9 grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center',
);
}
}
@Directive({
selector: '[hlmQuestionnaireChoiceDescription]',
exportAs: 'hlmQuestionnaireChoiceDescription',
host: {
'data-slot': 'questionnaire-choice-description',
},
})
export class HlmQuestionnaireChoiceDescription {
constructor() {
classes(() => 'text-muted-foreground');
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector on native label
selector: 'label[hlmQuestionnaireChoice]',
exportAs: 'hlmQuestionnaireChoice',
imports: [BrnQuestionnaireChoiceInput, BrnQuestionnaireChoiceLabel, BrnQuestionnaireChoiceShortcut, NgIcon],
viewProviders: [provideIcons({ lucideCheck })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnQuestionnaireChoice,
inputs: ['value', 'disabled', 'defaultChecked', 'checked'],
outputs: ['checkedChange'],
},
],
host: {
'data-slot': 'questionnaire-choice',
},
template: `
<input
brnQuestionnaireChoiceInput
data-slot="questionnaire-choice-input"
class="absolute inset-0 z-10 size-full cursor-pointer opacity-0"
/>
<span aria-hidden="true" data-slot="questionnaire-choice-indicator" class="border-input group-data-checked/questionnaire-choice:border-primary group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground dark:bg-input/30 dark:group-data-checked/questionnaire-choice:bg-primary pointer-events-none relative flex size-4 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-[4px] border group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 group-data-[type=radio]/questionnaire-choice:rounded-full">
@if (_choice.checked() && _choice.type() === 'radio') {
<span data-slot="questionnaire-choice-indicator-dot" class="bg-primary-foreground size-2 rounded-full"></span>
}
@if (_choice.checked() && _choice.type() === 'checkbox') {
<ng-icon
name="lucideCheck"
data-slot="questionnaire-choice-indicator-check"
class="text-[length:--spacing(3.5)]"
/>
}
</span>
<span
brnQuestionnaireChoiceLabel
data-slot="questionnaire-choice-label"
class="gap-1 flex min-w-0 flex-1 flex-col leading-snug"
>
<ng-content />
</span>
@if (_choice.shortcut(); as shortcut) {
<span
brnQuestionnaireChoiceShortcut
data-slot="questionnaire-choice-shortcut"
class="border-input bg-background text-muted-foreground pointer-events-none ms-auto inline-flex size-5 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-md border font-mono text-[0.625rem] leading-none font-medium shadow-xs group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5"
>
{{ shortcut }}
</span>
}
`,
})
export class HlmQuestionnaireChoice {
protected readonly _choice = inject(BrnQuestionnaireChoice);
constructor() {
classes(
() =>
'border-input hover:bg-muted/50 has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-ring/50 data-invalid:border-destructive dark:bg-input/20 data-checked:border-primary/40 data-checked:bg-muted dark:data-checked:bg-muted gap-3 rounded-md border bg-transparent px-4 py-3.5 text-sm shadow-xs transition-colors has-[>input:focus-visible]:ring-3 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start text-start outline-none select-none',
);
}
}
@Directive({
selector: '[hlmQuestionnaireChoices]',
exportAs: 'hlmQuestionnaireChoices',
hostDirectives: [{ directive: BrnQuestionnaireChoices }],
host: {
'data-slot': 'questionnaire-choices',
},
})
export class HlmQuestionnaireChoices {
constructor() {
classes(() => 'gap-3 group/questionnaire-choices grid min-w-0');
}
}
@Directive({
selector: '[hlmQuestionnaireDescription]',
exportAs: 'hlmQuestionnaireDescription',
hostDirectives: [
{
directive: BrnQuestionnaireDescription,
inputs: ['id'],
},
],
host: {
'data-slot': 'questionnaire-description',
},
})
export class HlmQuestionnaireDescription {
constructor() {
classes(() => 'text-sm text-muted-foreground text-pretty');
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector matching brain API
selector: '[hlmQuestionnaireError]',
exportAs: 'hlmQuestionnaireError',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnQuestionnaireError,
inputs: ['id', 'requiredMessage', 'optionalMessage'],
},
],
host: {
'data-slot': 'questionnaire-error',
},
template: `
{{ message() ?? _error.defaultMessage() }}
`,
})
export class HlmQuestionnaireError {
protected readonly _error = inject(BrnQuestionnaireError);
/** Custom error copy; falls back to the built-in required/optional message when unset. */
public readonly message = input<string | undefined>(undefined);
constructor() {
classes(() => 'text-sm text-destructive');
}
}
@Directive({
selector: 'input[hlmQuestionnaireInput]',
exportAs: 'hlmQuestionnaireInput',
hostDirectives: [
{
directive: BrnQuestionnaireInput,
inputs: ['type', 'disabled', 'value', 'defaultValue'],
},
],
host: {
'data-slot': 'questionnaire-input',
},
})
export class HlmQuestionnaireInput {
constructor() {
classes(() => [
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-md border bg-transparent px-2.5 py-1 text-base shadow-xs focus-visible:ring-3 aria-invalid:ring-3 md:text-sm min-h-11 w-full min-w-0 transition-[color,box-shadow,background-color] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-0',
'selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground',
]);
}
}
@Directive({
selector: 'fieldset[hlmQuestionnaireItem]',
exportAs: 'hlmQuestionnaireItem',
hostDirectives: [
{
directive: BrnQuestionnaireItem,
inputs: ['name', 'multiple', 'required', 'disabled', 'itemInvalid', 'aria-describedby', 'aria-keyshortcuts'],
outputs: ['statusChange'],
},
],
host: {
'data-slot': 'questionnaire-item',
},
})
export class HlmQuestionnaireItem {
constructor() {
classes(() => 'gap-5 flex min-w-0 flex-col border-0 p-0 outline-none');
}
}
@Directive({
selector: 'button[hlmQuestionnaireNext]',
exportAs: 'hlmQuestionnaireNext',
hostDirectives: [
{
directive: BrnQuestionnaireNext,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-next',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireNext {
public readonly variant = input<ButtonVariants['variant']>('default');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'button[hlmQuestionnairePrevious]',
exportAs: 'hlmQuestionnairePrevious',
hostDirectives: [
{
directive: BrnQuestionnairePrevious,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-previous',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnairePrevious {
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-1 row-start-1 min-h-11 justify-self-start sm:min-h-0',
]);
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector matching brain API
selector: '[hlmQuestionnaireProgress]',
exportAs: 'hlmQuestionnaireProgress',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnQuestionnaireProgress, inputs: ['aria-label', 'valueText'] }],
host: {
'data-slot': 'questionnaire-progress',
},
template: `
<ng-content>
{{ _progress.label() }}
</ng-content>
`,
})
export class HlmQuestionnaireProgress {
protected readonly _progress = inject(BrnQuestionnaireProgress);
/** The current step index (1-based within the active collection). */
public readonly current = this._progress.current;
/** The total number of enabled steps. */
public readonly total = this._progress.total;
/** Whether the current step is the first one. */
public readonly first = this._progress.first;
/** Whether the current step is the last one. */
public readonly last = this._progress.last;
/** The computed progression label, e.g. "Question 2 of 5". */
public readonly label = this._progress.label;
/** Boolean flags marking which segments of the progress bar are filled. */
public readonly segments = this._progress.segments;
constructor() {
classes(
() => 'text-xs text-muted-foreground min-h-lh w-fit min-w-[14ch] font-medium tabular-nums',
);
}
}
@Directive({
selector: 'button[hlmQuestionnaireSkip]',
exportAs: 'hlmQuestionnaireSkip',
hostDirectives: [
{
directive: BrnQuestionnaireSkip,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-skip',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireSkip {
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-2 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'button[hlmQuestionnaireSubmit]',
exportAs: 'hlmQuestionnaireSubmit',
hostDirectives: [
{
directive: BrnQuestionnaireSubmit,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-submit',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireSubmit {
public readonly variant = input<ButtonVariants['variant']>('default');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'legend[hlmQuestionnaireTitle]',
exportAs: 'hlmQuestionnaireTitle',
hostDirectives: [{ directive: BrnQuestionnaireTitle }],
host: {
'data-slot': 'questionnaire-title',
},
})
export class HlmQuestionnaireTitle {
constructor() {
classes(() => 'text-base font-semibold [&:not(:has(~[data-slot=questionnaire-description]))]:mb-5 text-pretty');
}
}
@Directive({
selector: 'form[hlmQuestionnaire]',
exportAs: 'hlmQuestionnaire',
hostDirectives: [
{
directive: BrnQuestionnaire,
inputs: ['items', 'defaultItem', 'item', 'shortcuts', 'noValidate'],
outputs: ['itemChange'],
},
],
host: {
'data-slot': 'questionnaire',
},
})
export class HlmQuestionnaire {
constructor() {
classes(() => 'gap-6 flex w-full min-w-0 flex-col');
}
}
export const HlmQuestionnaireImports = [
HlmQuestionnaire,
HlmQuestionnaireProgress,
HlmQuestionnaireItem,
HlmQuestionnaireTitle,
HlmQuestionnaireDescription,
HlmQuestionnaireChoices,
HlmQuestionnaireChoice,
HlmQuestionnaireChoiceDescription,
HlmQuestionnaireInput,
HlmQuestionnaireError,
HlmQuestionnaireActions,
HlmQuestionnairePrevious,
HlmQuestionnaireSkip,
HlmQuestionnaireNext,
HlmQuestionnaireSubmit,
] as const;import { BrnQuestionnaire, BrnQuestionnaireChoice, BrnQuestionnaireChoiceInput, BrnQuestionnaireChoiceLabel, BrnQuestionnaireChoiceShortcut, BrnQuestionnaireChoices, BrnQuestionnaireDescription, BrnQuestionnaireError, BrnQuestionnaireInput, BrnQuestionnaireItem, BrnQuestionnaireNext, BrnQuestionnairePrevious, BrnQuestionnaireProgress, BrnQuestionnaireSkip, BrnQuestionnaireSubmit, BrnQuestionnaireTitle } from '@spartan-ng/brain/questionnaire';
import { ChangeDetectionStrategy, Component, Directive, inject, input } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { buttonVariants, type ButtonVariants } from '@spartan-ng/helm/button';
import { classes } from '@spartan-ng/helm/utils';
import { lucideCheck } from '@ng-icons/lucide';
@Directive({
selector: '[hlmQuestionnaireActions],hlm-questionnaire-actions',
exportAs: 'hlmQuestionnaireActions',
host: {
'data-slot': 'questionnaire-actions',
},
})
export class HlmQuestionnaireActions {
constructor() {
classes(
() => 'gap-1.5 sm:min-h-8 grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center',
);
}
}
@Directive({
selector: '[hlmQuestionnaireChoiceDescription]',
exportAs: 'hlmQuestionnaireChoiceDescription',
host: {
'data-slot': 'questionnaire-choice-description',
},
})
export class HlmQuestionnaireChoiceDescription {
constructor() {
classes(() => 'text-muted-foreground');
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector on native label
selector: 'label[hlmQuestionnaireChoice]',
exportAs: 'hlmQuestionnaireChoice',
imports: [BrnQuestionnaireChoiceInput, BrnQuestionnaireChoiceLabel, BrnQuestionnaireChoiceShortcut, NgIcon],
viewProviders: [provideIcons({ lucideCheck })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnQuestionnaireChoice,
inputs: ['value', 'disabled', 'defaultChecked', 'checked'],
outputs: ['checkedChange'],
},
],
host: {
'data-slot': 'questionnaire-choice',
},
template: `
<input
brnQuestionnaireChoiceInput
data-slot="questionnaire-choice-input"
class="absolute inset-0 z-10 size-full cursor-pointer opacity-0"
/>
<span aria-hidden="true" data-slot="questionnaire-choice-indicator" class="border-input group-data-checked/questionnaire-choice:border-primary group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground pointer-events-none relative flex size-4 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-none border group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 group-data-[type=radio]/questionnaire-choice:rounded-full">
@if (_choice.checked() && _choice.type() === 'radio') {
<span data-slot="questionnaire-choice-indicator-dot" class="bg-primary-foreground size-2 rounded-full"></span>
}
@if (_choice.checked() && _choice.type() === 'checkbox') {
<ng-icon
name="lucideCheck"
data-slot="questionnaire-choice-indicator-check"
class="text-[length:--spacing(3.5)]"
/>
}
</span>
<span
brnQuestionnaireChoiceLabel
data-slot="questionnaire-choice-label"
class="gap-0.5 flex min-w-0 flex-1 flex-col leading-snug"
>
<ng-content />
</span>
@if (_choice.shortcut(); as shortcut) {
<span
brnQuestionnaireChoiceShortcut
data-slot="questionnaire-choice-shortcut"
class="border-input bg-background text-muted-foreground pointer-events-none ms-auto inline-flex size-4 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-none border font-mono text-[0.625rem] leading-none font-medium group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5"
>
{{ shortcut }}
</span>
}
`,
})
export class HlmQuestionnaireChoice {
protected readonly _choice = inject(BrnQuestionnaireChoice);
constructor() {
classes(
() =>
'border-input hover:bg-muted/50 has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-ring/50 data-invalid:border-destructive data-checked:border-foreground/30 data-checked:bg-muted gap-2.5 rounded-none border bg-transparent px-3 py-2.5 text-xs transition-colors has-[>input:focus-visible]:ring-1 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start text-start outline-none select-none',
);
}
}
@Directive({
selector: '[hlmQuestionnaireChoices]',
exportAs: 'hlmQuestionnaireChoices',
hostDirectives: [{ directive: BrnQuestionnaireChoices }],
host: {
'data-slot': 'questionnaire-choices',
},
})
export class HlmQuestionnaireChoices {
constructor() {
classes(() => 'gap-2 group/questionnaire-choices grid min-w-0');
}
}
@Directive({
selector: '[hlmQuestionnaireDescription]',
exportAs: 'hlmQuestionnaireDescription',
hostDirectives: [
{
directive: BrnQuestionnaireDescription,
inputs: ['id'],
},
],
host: {
'data-slot': 'questionnaire-description',
},
})
export class HlmQuestionnaireDescription {
constructor() {
classes(() => 'text-xs/relaxed text-muted-foreground text-pretty');
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector matching brain API
selector: '[hlmQuestionnaireError]',
exportAs: 'hlmQuestionnaireError',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnQuestionnaireError,
inputs: ['id', 'requiredMessage', 'optionalMessage'],
},
],
host: {
'data-slot': 'questionnaire-error',
},
template: `
{{ message() ?? _error.defaultMessage() }}
`,
})
export class HlmQuestionnaireError {
protected readonly _error = inject(BrnQuestionnaireError);
/** Custom error copy; falls back to the built-in required/optional message when unset. */
public readonly message = input<string | undefined>(undefined);
constructor() {
classes(() => 'mt-2 text-xs text-destructive');
}
}
@Directive({
selector: 'input[hlmQuestionnaireInput]',
exportAs: 'hlmQuestionnaireInput',
hostDirectives: [
{
directive: BrnQuestionnaireInput,
inputs: ['type', 'disabled', 'value', 'defaultValue'],
},
],
host: {
'data-slot': 'questionnaire-input',
},
})
export class HlmQuestionnaireInput {
constructor() {
classes(() => [
'dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-none border bg-transparent px-2.5 py-1 text-xs focus-visible:ring-1 aria-invalid:ring-1 md:text-xs min-h-11 w-full min-w-0 transition-[color,box-shadow,background-color] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-0',
'selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground',
]);
}
}
@Directive({
selector: 'fieldset[hlmQuestionnaireItem]',
exportAs: 'hlmQuestionnaireItem',
hostDirectives: [
{
directive: BrnQuestionnaireItem,
inputs: ['name', 'multiple', 'required', 'disabled', 'itemInvalid', 'aria-describedby', 'aria-keyshortcuts'],
outputs: ['statusChange'],
},
],
host: {
'data-slot': 'questionnaire-item',
},
})
export class HlmQuestionnaireItem {
constructor() {
classes(() => 'gap-4 flex min-w-0 flex-col border-0 p-0 outline-none');
}
}
@Directive({
selector: 'button[hlmQuestionnaireNext]',
exportAs: 'hlmQuestionnaireNext',
hostDirectives: [
{
directive: BrnQuestionnaireNext,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-next',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireNext {
public readonly variant = input<ButtonVariants['variant']>('default');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'button[hlmQuestionnairePrevious]',
exportAs: 'hlmQuestionnairePrevious',
hostDirectives: [
{
directive: BrnQuestionnairePrevious,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-previous',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnairePrevious {
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-1 row-start-1 min-h-11 justify-self-start sm:min-h-0',
]);
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector matching brain API
selector: '[hlmQuestionnaireProgress]',
exportAs: 'hlmQuestionnaireProgress',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnQuestionnaireProgress, inputs: ['aria-label', 'valueText'] }],
host: {
'data-slot': 'questionnaire-progress',
},
template: `
<ng-content>
{{ _progress.label() }}
</ng-content>
`,
})
export class HlmQuestionnaireProgress {
protected readonly _progress = inject(BrnQuestionnaireProgress);
/** The current step index (1-based within the active collection). */
public readonly current = this._progress.current;
/** The total number of enabled steps. */
public readonly total = this._progress.total;
/** Whether the current step is the first one. */
public readonly first = this._progress.first;
/** Whether the current step is the last one. */
public readonly last = this._progress.last;
/** The computed progression label, e.g. "Question 2 of 5". */
public readonly label = this._progress.label;
/** Boolean flags marking which segments of the progress bar are filled. */
public readonly segments = this._progress.segments;
constructor() {
classes(
() => 'text-xs text-muted-foreground min-h-lh w-fit min-w-[14ch] font-medium tabular-nums',
);
}
}
@Directive({
selector: 'button[hlmQuestionnaireSkip]',
exportAs: 'hlmQuestionnaireSkip',
hostDirectives: [
{
directive: BrnQuestionnaireSkip,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-skip',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireSkip {
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-2 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'button[hlmQuestionnaireSubmit]',
exportAs: 'hlmQuestionnaireSubmit',
hostDirectives: [
{
directive: BrnQuestionnaireSubmit,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-submit',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireSubmit {
public readonly variant = input<ButtonVariants['variant']>('default');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'legend[hlmQuestionnaireTitle]',
exportAs: 'hlmQuestionnaireTitle',
hostDirectives: [{ directive: BrnQuestionnaireTitle }],
host: {
'data-slot': 'questionnaire-title',
},
})
export class HlmQuestionnaireTitle {
constructor() {
classes(() => 'text-sm font-medium [&:not(:has(~[data-slot=questionnaire-description]))]:mb-4 text-pretty');
}
}
@Directive({
selector: 'form[hlmQuestionnaire]',
exportAs: 'hlmQuestionnaire',
hostDirectives: [
{
directive: BrnQuestionnaire,
inputs: ['items', 'defaultItem', 'item', 'shortcuts', 'noValidate'],
outputs: ['itemChange'],
},
],
host: {
'data-slot': 'questionnaire',
},
})
export class HlmQuestionnaire {
constructor() {
classes(() => 'gap-4 flex w-full min-w-0 flex-col');
}
}
export const HlmQuestionnaireImports = [
HlmQuestionnaire,
HlmQuestionnaireProgress,
HlmQuestionnaireItem,
HlmQuestionnaireTitle,
HlmQuestionnaireDescription,
HlmQuestionnaireChoices,
HlmQuestionnaireChoice,
HlmQuestionnaireChoiceDescription,
HlmQuestionnaireInput,
HlmQuestionnaireError,
HlmQuestionnaireActions,
HlmQuestionnairePrevious,
HlmQuestionnaireSkip,
HlmQuestionnaireNext,
HlmQuestionnaireSubmit,
] as const;import { BrnQuestionnaire, BrnQuestionnaireChoice, BrnQuestionnaireChoiceInput, BrnQuestionnaireChoiceLabel, BrnQuestionnaireChoiceShortcut, BrnQuestionnaireChoices, BrnQuestionnaireDescription, BrnQuestionnaireError, BrnQuestionnaireInput, BrnQuestionnaireItem, BrnQuestionnaireNext, BrnQuestionnairePrevious, BrnQuestionnaireProgress, BrnQuestionnaireSkip, BrnQuestionnaireSubmit, BrnQuestionnaireTitle } from '@spartan-ng/brain/questionnaire';
import { ChangeDetectionStrategy, Component, Directive, inject, input } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { buttonVariants, type ButtonVariants } from '@spartan-ng/helm/button';
import { classes } from '@spartan-ng/helm/utils';
import { lucideCheck } from '@ng-icons/lucide';
@Directive({
selector: '[hlmQuestionnaireActions],hlm-questionnaire-actions',
exportAs: 'hlmQuestionnaireActions',
host: {
'data-slot': 'questionnaire-actions',
},
})
export class HlmQuestionnaireActions {
constructor() {
classes(
() => 'gap-2 sm:min-h-9 grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center',
);
}
}
@Directive({
selector: '[hlmQuestionnaireChoiceDescription]',
exportAs: 'hlmQuestionnaireChoiceDescription',
host: {
'data-slot': 'questionnaire-choice-description',
},
})
export class HlmQuestionnaireChoiceDescription {
constructor() {
classes(() => 'text-muted-foreground');
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector on native label
selector: 'label[hlmQuestionnaireChoice]',
exportAs: 'hlmQuestionnaireChoice',
imports: [BrnQuestionnaireChoiceInput, BrnQuestionnaireChoiceLabel, BrnQuestionnaireChoiceShortcut, NgIcon],
viewProviders: [provideIcons({ lucideCheck })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnQuestionnaireChoice,
inputs: ['value', 'disabled', 'defaultChecked', 'checked'],
outputs: ['checkedChange'],
},
],
host: {
'data-slot': 'questionnaire-choice',
},
template: `
<input
brnQuestionnaireChoiceInput
data-slot="questionnaire-choice-input"
class="absolute inset-0 z-10 size-full cursor-pointer opacity-0"
/>
<span aria-hidden="true" data-slot="questionnaire-choice-indicator" class="border-input group-data-checked/questionnaire-choice:border-primary group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground dark:bg-input/30 dark:group-data-checked/questionnaire-choice:bg-primary pointer-events-none relative flex size-4 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-[6px] border group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 group-data-[type=radio]/questionnaire-choice:rounded-full">
@if (_choice.checked() && _choice.type() === 'radio') {
<span data-slot="questionnaire-choice-indicator-dot" class="bg-primary-foreground size-2 rounded-full"></span>
}
@if (_choice.checked() && _choice.type() === 'checkbox') {
<ng-icon
name="lucideCheck"
data-slot="questionnaire-choice-indicator-check"
class="text-[length:--spacing(3.5)]"
/>
}
</span>
<span
brnQuestionnaireChoiceLabel
data-slot="questionnaire-choice-label"
class="gap-1 flex min-w-0 flex-1 flex-col leading-snug"
>
<ng-content />
</span>
@if (_choice.shortcut(); as shortcut) {
<span
brnQuestionnaireChoiceShortcut
data-slot="questionnaire-choice-shortcut"
class="border-input bg-background/80 text-muted-foreground pointer-events-none ms-auto inline-flex size-5 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-full border font-mono text-[0.625rem] leading-none font-medium group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5"
>
{{ shortcut }}
</span>
}
`,
})
export class HlmQuestionnaireChoice {
protected readonly _choice = inject(BrnQuestionnaireChoice);
constructor() {
classes(
() =>
'border-input bg-input/20 hover:bg-input/40 has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-ring/50 data-invalid:border-destructive data-checked:border-primary/40 data-checked:bg-primary/10 gap-3 rounded-4xl border px-4 py-3.5 text-sm transition-colors has-[>input:focus-visible]:ring-[3px] data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start text-start outline-none select-none',
);
}
}
@Directive({
selector: '[hlmQuestionnaireChoices]',
exportAs: 'hlmQuestionnaireChoices',
hostDirectives: [{ directive: BrnQuestionnaireChoices }],
host: {
'data-slot': 'questionnaire-choices',
},
})
export class HlmQuestionnaireChoices {
constructor() {
classes(() => 'gap-3 group/questionnaire-choices grid min-w-0');
}
}
@Directive({
selector: '[hlmQuestionnaireDescription]',
exportAs: 'hlmQuestionnaireDescription',
hostDirectives: [
{
directive: BrnQuestionnaireDescription,
inputs: ['id'],
},
],
host: {
'data-slot': 'questionnaire-description',
},
})
export class HlmQuestionnaireDescription {
constructor() {
classes(() => 'text-sm text-muted-foreground text-pretty');
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector matching brain API
selector: '[hlmQuestionnaireError]',
exportAs: 'hlmQuestionnaireError',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnQuestionnaireError,
inputs: ['id', 'requiredMessage', 'optionalMessage'],
},
],
host: {
'data-slot': 'questionnaire-error',
},
template: `
{{ message() ?? _error.defaultMessage() }}
`,
})
export class HlmQuestionnaireError {
protected readonly _error = inject(BrnQuestionnaireError);
/** Custom error copy; falls back to the built-in required/optional message when unset. */
public readonly message = input<string | undefined>(undefined);
constructor() {
classes(() => 'mt-2 text-sm text-destructive');
}
}
@Directive({
selector: 'input[hlmQuestionnaireInput]',
exportAs: 'hlmQuestionnaireInput',
hostDirectives: [
{
directive: BrnQuestionnaireInput,
inputs: ['type', 'disabled', 'value', 'defaultValue'],
},
],
host: {
'data-slot': 'questionnaire-input',
},
})
export class HlmQuestionnaireInput {
constructor() {
classes(() => [
'bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-4xl border px-3 py-1 text-base focus-visible:ring-[3px] aria-invalid:ring-[3px] md:text-sm min-h-11 w-full min-w-0 transition-[color,box-shadow,background-color] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-0',
'selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground',
]);
}
}
@Directive({
selector: 'fieldset[hlmQuestionnaireItem]',
exportAs: 'hlmQuestionnaireItem',
hostDirectives: [
{
directive: BrnQuestionnaireItem,
inputs: ['name', 'multiple', 'required', 'disabled', 'itemInvalid', 'aria-describedby', 'aria-keyshortcuts'],
outputs: ['statusChange'],
},
],
host: {
'data-slot': 'questionnaire-item',
},
})
export class HlmQuestionnaireItem {
constructor() {
classes(() => 'gap-5 flex min-w-0 flex-col border-0 p-0 outline-none');
}
}
@Directive({
selector: 'button[hlmQuestionnaireNext]',
exportAs: 'hlmQuestionnaireNext',
hostDirectives: [
{
directive: BrnQuestionnaireNext,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-next',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireNext {
public readonly variant = input<ButtonVariants['variant']>('default');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'button[hlmQuestionnairePrevious]',
exportAs: 'hlmQuestionnairePrevious',
hostDirectives: [
{
directive: BrnQuestionnairePrevious,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-previous',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnairePrevious {
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-1 row-start-1 min-h-11 justify-self-start sm:min-h-0',
]);
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector matching brain API
selector: '[hlmQuestionnaireProgress]',
exportAs: 'hlmQuestionnaireProgress',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnQuestionnaireProgress, inputs: ['aria-label', 'valueText'] }],
host: {
'data-slot': 'questionnaire-progress',
},
template: `
<ng-content>
{{ _progress.label() }}
</ng-content>
`,
})
export class HlmQuestionnaireProgress {
protected readonly _progress = inject(BrnQuestionnaireProgress);
/** The current step index (1-based within the active collection). */
public readonly current = this._progress.current;
/** The total number of enabled steps. */
public readonly total = this._progress.total;
/** Whether the current step is the first one. */
public readonly first = this._progress.first;
/** Whether the current step is the last one. */
public readonly last = this._progress.last;
/** The computed progression label, e.g. "Question 2 of 5". */
public readonly label = this._progress.label;
/** Boolean flags marking which segments of the progress bar are filled. */
public readonly segments = this._progress.segments;
constructor() {
classes(
() => 'text-xs text-muted-foreground min-h-lh w-fit min-w-[14ch] font-medium tabular-nums',
);
}
}
@Directive({
selector: 'button[hlmQuestionnaireSkip]',
exportAs: 'hlmQuestionnaireSkip',
hostDirectives: [
{
directive: BrnQuestionnaireSkip,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-skip',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireSkip {
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-2 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'button[hlmQuestionnaireSubmit]',
exportAs: 'hlmQuestionnaireSubmit',
hostDirectives: [
{
directive: BrnQuestionnaireSubmit,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-submit',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireSubmit {
public readonly variant = input<ButtonVariants['variant']>('default');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'legend[hlmQuestionnaireTitle]',
exportAs: 'hlmQuestionnaireTitle',
hostDirectives: [{ directive: BrnQuestionnaireTitle }],
host: {
'data-slot': 'questionnaire-title',
},
})
export class HlmQuestionnaireTitle {
constructor() {
classes(() => 'text-base font-semibold [&:not(:has(~[data-slot=questionnaire-description]))]:mb-5 text-pretty');
}
}
@Directive({
selector: 'form[hlmQuestionnaire]',
exportAs: 'hlmQuestionnaire',
hostDirectives: [
{
directive: BrnQuestionnaire,
inputs: ['items', 'defaultItem', 'item', 'shortcuts', 'noValidate'],
outputs: ['itemChange'],
},
],
host: {
'data-slot': 'questionnaire',
},
})
export class HlmQuestionnaire {
constructor() {
classes(() => 'gap-6 flex w-full min-w-0 flex-col');
}
}
export const HlmQuestionnaireImports = [
HlmQuestionnaire,
HlmQuestionnaireProgress,
HlmQuestionnaireItem,
HlmQuestionnaireTitle,
HlmQuestionnaireDescription,
HlmQuestionnaireChoices,
HlmQuestionnaireChoice,
HlmQuestionnaireChoiceDescription,
HlmQuestionnaireInput,
HlmQuestionnaireError,
HlmQuestionnaireActions,
HlmQuestionnairePrevious,
HlmQuestionnaireSkip,
HlmQuestionnaireNext,
HlmQuestionnaireSubmit,
] as const;import { BrnQuestionnaire, BrnQuestionnaireChoice, BrnQuestionnaireChoiceInput, BrnQuestionnaireChoiceLabel, BrnQuestionnaireChoiceShortcut, BrnQuestionnaireChoices, BrnQuestionnaireDescription, BrnQuestionnaireError, BrnQuestionnaireInput, BrnQuestionnaireItem, BrnQuestionnaireNext, BrnQuestionnairePrevious, BrnQuestionnaireProgress, BrnQuestionnaireSkip, BrnQuestionnaireSubmit, BrnQuestionnaireTitle } from '@spartan-ng/brain/questionnaire';
import { ChangeDetectionStrategy, Component, Directive, inject, input } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { buttonVariants, type ButtonVariants } from '@spartan-ng/helm/button';
import { classes } from '@spartan-ng/helm/utils';
import { lucideCheck } from '@ng-icons/lucide';
@Directive({
selector: '[hlmQuestionnaireActions],hlm-questionnaire-actions',
exportAs: 'hlmQuestionnaireActions',
host: {
'data-slot': 'questionnaire-actions',
},
})
export class HlmQuestionnaireActions {
constructor() {
classes(
() => 'gap-1.5 sm:min-h-7 grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center',
);
}
}
@Directive({
selector: '[hlmQuestionnaireChoiceDescription]',
exportAs: 'hlmQuestionnaireChoiceDescription',
host: {
'data-slot': 'questionnaire-choice-description',
},
})
export class HlmQuestionnaireChoiceDescription {
constructor() {
classes(() => 'text-muted-foreground');
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector on native label
selector: 'label[hlmQuestionnaireChoice]',
exportAs: 'hlmQuestionnaireChoice',
imports: [BrnQuestionnaireChoiceInput, BrnQuestionnaireChoiceLabel, BrnQuestionnaireChoiceShortcut, NgIcon],
viewProviders: [provideIcons({ lucideCheck })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnQuestionnaireChoice,
inputs: ['value', 'disabled', 'defaultChecked', 'checked'],
outputs: ['checkedChange'],
},
],
host: {
'data-slot': 'questionnaire-choice',
},
template: `
<input
brnQuestionnaireChoiceInput
data-slot="questionnaire-choice-input"
class="absolute inset-0 z-10 size-full cursor-pointer opacity-0"
/>
<span aria-hidden="true" data-slot="questionnaire-choice-indicator" class="border-input group-data-checked/questionnaire-choice:border-primary group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground dark:bg-input/30 dark:group-data-checked/questionnaire-choice:bg-primary pointer-events-none relative flex size-4 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-[4px] border group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 group-data-[type=radio]/questionnaire-choice:rounded-full">
@if (_choice.checked() && _choice.type() === 'radio') {
<span data-slot="questionnaire-choice-indicator-dot" class="bg-primary-foreground size-2 rounded-full"></span>
}
@if (_choice.checked() && _choice.type() === 'checkbox') {
<ng-icon
name="lucideCheck"
data-slot="questionnaire-choice-indicator-check"
class="text-[length:--spacing(3.5)]"
/>
}
</span>
<span
brnQuestionnaireChoiceLabel
data-slot="questionnaire-choice-label"
class="gap-0.5 flex min-w-0 flex-1 flex-col leading-snug"
>
<ng-content />
</span>
@if (_choice.shortcut(); as shortcut) {
<span
brnQuestionnaireChoiceShortcut
data-slot="questionnaire-choice-shortcut"
class="border-input bg-background/80 text-muted-foreground pointer-events-none ms-auto inline-flex size-4 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-sm border font-mono text-[0.5625rem] leading-none font-medium group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5"
>
{{ shortcut }}
</span>
}
`,
})
export class HlmQuestionnaireChoice {
protected readonly _choice = inject(BrnQuestionnaireChoice);
constructor() {
classes(
() =>
'border-input bg-input/20 hover:bg-input/40 has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-ring/30 data-invalid:border-destructive data-checked:border-primary/40 data-checked:bg-primary/10 gap-2.5 rounded-xl border px-3 py-2.5 text-xs/relaxed transition-colors has-[>input:focus-visible]:ring-2 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start text-start outline-none select-none',
);
}
}
@Directive({
selector: '[hlmQuestionnaireChoices]',
exportAs: 'hlmQuestionnaireChoices',
hostDirectives: [{ directive: BrnQuestionnaireChoices }],
host: {
'data-slot': 'questionnaire-choices',
},
})
export class HlmQuestionnaireChoices {
constructor() {
classes(() => 'gap-1.5 group/questionnaire-choices grid min-w-0');
}
}
@Directive({
selector: '[hlmQuestionnaireDescription]',
exportAs: 'hlmQuestionnaireDescription',
hostDirectives: [
{
directive: BrnQuestionnaireDescription,
inputs: ['id'],
},
],
host: {
'data-slot': 'questionnaire-description',
},
})
export class HlmQuestionnaireDescription {
constructor() {
classes(() => 'text-xs/relaxed text-muted-foreground text-pretty');
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector matching brain API
selector: '[hlmQuestionnaireError]',
exportAs: 'hlmQuestionnaireError',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnQuestionnaireError,
inputs: ['id', 'requiredMessage', 'optionalMessage'],
},
],
host: {
'data-slot': 'questionnaire-error',
},
template: `
{{ message() ?? _error.defaultMessage() }}
`,
})
export class HlmQuestionnaireError {
protected readonly _error = inject(BrnQuestionnaireError);
/** Custom error copy; falls back to the built-in required/optional message when unset. */
public readonly message = input<string | undefined>(undefined);
constructor() {
classes(() => 'mt-2 text-xs/relaxed text-destructive');
}
}
@Directive({
selector: 'input[hlmQuestionnaireInput]',
exportAs: 'hlmQuestionnaireInput',
hostDirectives: [
{
directive: BrnQuestionnaireInput,
inputs: ['type', 'disabled', 'value', 'defaultValue'],
},
],
host: {
'data-slot': 'questionnaire-input',
},
})
export class HlmQuestionnaireInput {
constructor() {
classes(() => [
'bg-input/20 dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-7 rounded-md border px-2 py-0.5 text-sm focus-visible:ring-2 aria-invalid:ring-2 md:text-xs/relaxed min-h-11 w-full min-w-0 transition-[color,box-shadow,background-color] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-0',
'selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground',
]);
}
}
@Directive({
selector: 'fieldset[hlmQuestionnaireItem]',
exportAs: 'hlmQuestionnaireItem',
hostDirectives: [
{
directive: BrnQuestionnaireItem,
inputs: ['name', 'multiple', 'required', 'disabled', 'itemInvalid', 'aria-describedby', 'aria-keyshortcuts'],
outputs: ['statusChange'],
},
],
host: {
'data-slot': 'questionnaire-item',
},
})
export class HlmQuestionnaireItem {
constructor() {
classes(() => 'gap-3 flex min-w-0 flex-col border-0 p-0 outline-none');
}
}
@Directive({
selector: 'button[hlmQuestionnaireNext]',
exportAs: 'hlmQuestionnaireNext',
hostDirectives: [
{
directive: BrnQuestionnaireNext,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-next',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireNext {
public readonly variant = input<ButtonVariants['variant']>('default');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'button[hlmQuestionnairePrevious]',
exportAs: 'hlmQuestionnairePrevious',
hostDirectives: [
{
directive: BrnQuestionnairePrevious,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-previous',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnairePrevious {
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-1 row-start-1 min-h-11 justify-self-start sm:min-h-0',
]);
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector matching brain API
selector: '[hlmQuestionnaireProgress]',
exportAs: 'hlmQuestionnaireProgress',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnQuestionnaireProgress, inputs: ['aria-label', 'valueText'] }],
host: {
'data-slot': 'questionnaire-progress',
},
template: `
<ng-content>
{{ _progress.label() }}
</ng-content>
`,
})
export class HlmQuestionnaireProgress {
protected readonly _progress = inject(BrnQuestionnaireProgress);
/** The current step index (1-based within the active collection). */
public readonly current = this._progress.current;
/** The total number of enabled steps. */
public readonly total = this._progress.total;
/** Whether the current step is the first one. */
public readonly first = this._progress.first;
/** Whether the current step is the last one. */
public readonly last = this._progress.last;
/** The computed progression label, e.g. "Question 2 of 5". */
public readonly label = this._progress.label;
/** Boolean flags marking which segments of the progress bar are filled. */
public readonly segments = this._progress.segments;
constructor() {
classes(
() => 'text-[0.625rem] text-muted-foreground min-h-lh w-fit min-w-[14ch] font-medium tabular-nums',
);
}
}
@Directive({
selector: 'button[hlmQuestionnaireSkip]',
exportAs: 'hlmQuestionnaireSkip',
hostDirectives: [
{
directive: BrnQuestionnaireSkip,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-skip',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireSkip {
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-2 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'button[hlmQuestionnaireSubmit]',
exportAs: 'hlmQuestionnaireSubmit',
hostDirectives: [
{
directive: BrnQuestionnaireSubmit,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-submit',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireSubmit {
public readonly variant = input<ButtonVariants['variant']>('default');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'legend[hlmQuestionnaireTitle]',
exportAs: 'hlmQuestionnaireTitle',
hostDirectives: [{ directive: BrnQuestionnaireTitle }],
host: {
'data-slot': 'questionnaire-title',
},
})
export class HlmQuestionnaireTitle {
constructor() {
classes(() => 'text-sm font-semibold [&:not(:has(~[data-slot=questionnaire-description]))]:mb-3 text-pretty');
}
}
@Directive({
selector: 'form[hlmQuestionnaire]',
exportAs: 'hlmQuestionnaire',
hostDirectives: [
{
directive: BrnQuestionnaire,
inputs: ['items', 'defaultItem', 'item', 'shortcuts', 'noValidate'],
outputs: ['itemChange'],
},
],
host: {
'data-slot': 'questionnaire',
},
})
export class HlmQuestionnaire {
constructor() {
classes(() => 'gap-4 flex w-full min-w-0 flex-col');
}
}
export const HlmQuestionnaireImports = [
HlmQuestionnaire,
HlmQuestionnaireProgress,
HlmQuestionnaireItem,
HlmQuestionnaireTitle,
HlmQuestionnaireDescription,
HlmQuestionnaireChoices,
HlmQuestionnaireChoice,
HlmQuestionnaireChoiceDescription,
HlmQuestionnaireInput,
HlmQuestionnaireError,
HlmQuestionnaireActions,
HlmQuestionnairePrevious,
HlmQuestionnaireSkip,
HlmQuestionnaireNext,
HlmQuestionnaireSubmit,
] as const;import { BrnQuestionnaire, BrnQuestionnaireChoice, BrnQuestionnaireChoiceInput, BrnQuestionnaireChoiceLabel, BrnQuestionnaireChoiceShortcut, BrnQuestionnaireChoices, BrnQuestionnaireDescription, BrnQuestionnaireError, BrnQuestionnaireInput, BrnQuestionnaireItem, BrnQuestionnaireNext, BrnQuestionnairePrevious, BrnQuestionnaireProgress, BrnQuestionnaireSkip, BrnQuestionnaireSubmit, BrnQuestionnaireTitle } from '@spartan-ng/brain/questionnaire';
import { ChangeDetectionStrategy, Component, Directive, inject, input } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { buttonVariants, type ButtonVariants } from '@spartan-ng/helm/button';
import { classes } from '@spartan-ng/helm/utils';
import { lucideCheck } from '@ng-icons/lucide';
@Directive({
selector: '[hlmQuestionnaireActions],hlm-questionnaire-actions',
exportAs: 'hlmQuestionnaireActions',
host: {
'data-slot': 'questionnaire-actions',
},
})
export class HlmQuestionnaireActions {
constructor() {
classes(
() => 'gap-2 sm:min-h-9 grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto_auto] items-center',
);
}
}
@Directive({
selector: '[hlmQuestionnaireChoiceDescription]',
exportAs: 'hlmQuestionnaireChoiceDescription',
host: {
'data-slot': 'questionnaire-choice-description',
},
})
export class HlmQuestionnaireChoiceDescription {
constructor() {
classes(() => 'text-muted-foreground');
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector on native label
selector: 'label[hlmQuestionnaireChoice]',
exportAs: 'hlmQuestionnaireChoice',
imports: [BrnQuestionnaireChoiceInput, BrnQuestionnaireChoiceLabel, BrnQuestionnaireChoiceShortcut, NgIcon],
viewProviders: [provideIcons({ lucideCheck })],
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnQuestionnaireChoice,
inputs: ['value', 'disabled', 'defaultChecked', 'checked'],
outputs: ['checkedChange'],
},
],
host: {
'data-slot': 'questionnaire-choice',
},
template: `
<input
brnQuestionnaireChoiceInput
data-slot="questionnaire-choice-input"
class="absolute inset-0 z-10 size-full cursor-pointer opacity-0"
/>
<span aria-hidden="true" data-slot="questionnaire-choice-indicator" class="bg-input/90 group-data-checked/questionnaire-choice:border-primary group-data-checked/questionnaire-choice:bg-primary group-data-checked/questionnaire-choice:text-primary-foreground dark:group-data-checked/questionnaire-choice:bg-primary pointer-events-none relative flex size-4 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-[5px] border border-transparent group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5 group-data-[type=radio]/questionnaire-choice:rounded-full">
@if (_choice.checked() && _choice.type() === 'radio') {
<span data-slot="questionnaire-choice-indicator-dot" class="bg-primary-foreground size-2 rounded-full dark:size-2.5"></span>
}
@if (_choice.checked() && _choice.type() === 'checkbox') {
<ng-icon
name="lucideCheck"
data-slot="questionnaire-choice-indicator-check"
class="text-[length:--spacing(3.5)]"
/>
}
</span>
<span
brnQuestionnaireChoiceLabel
data-slot="questionnaire-choice-label"
class="gap-1 flex min-w-0 flex-1 flex-col leading-snug"
>
<ng-content />
</span>
@if (_choice.shortcut(); as shortcut) {
<span
brnQuestionnaireChoiceShortcut
data-slot="questionnaire-choice-shortcut"
class="border-primary/10 bg-background/80 text-muted-foreground pointer-events-none ms-auto inline-flex size-5 shrink-0 translate-y-[--spacing(0.45)] items-center justify-center rounded-full border font-mono text-[0.625rem] leading-none font-medium group-has-data-[slot=questionnaire-choice-description]/questionnaire-choice:translate-y-0.5"
>
{{ shortcut }}
</span>
}
`,
})
export class HlmQuestionnaireChoice {
protected readonly _choice = inject(BrnQuestionnaireChoice);
constructor() {
classes(
() =>
'border-input bg-input/20 hover:bg-input/40 has-[>input:focus-visible]:border-ring has-[>input:focus-visible]:ring-ring/50 data-invalid:border-destructive data-checked:border-primary/40 data-checked:bg-primary/10 gap-2.5 rounded-3xl border px-4 py-3 text-sm transition-colors has-[>input:focus-visible]:ring-3 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 group/questionnaire-choice relative flex min-h-11 cursor-pointer items-start text-start outline-none select-none',
);
}
}
@Directive({
selector: '[hlmQuestionnaireChoices]',
exportAs: 'hlmQuestionnaireChoices',
hostDirectives: [{ directive: BrnQuestionnaireChoices }],
host: {
'data-slot': 'questionnaire-choices',
},
})
export class HlmQuestionnaireChoices {
constructor() {
classes(() => 'gap-3 group/questionnaire-choices grid min-w-0');
}
}
@Directive({
selector: '[hlmQuestionnaireDescription]',
exportAs: 'hlmQuestionnaireDescription',
hostDirectives: [
{
directive: BrnQuestionnaireDescription,
inputs: ['id'],
},
],
host: {
'data-slot': 'questionnaire-description',
},
})
export class HlmQuestionnaireDescription {
constructor() {
classes(() => 'text-sm text-muted-foreground text-pretty');
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector matching brain API
selector: '[hlmQuestionnaireError]',
exportAs: 'hlmQuestionnaireError',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [
{
directive: BrnQuestionnaireError,
inputs: ['id', 'requiredMessage', 'optionalMessage'],
},
],
host: {
'data-slot': 'questionnaire-error',
},
template: `
{{ message() ?? _error.defaultMessage() }}
`,
})
export class HlmQuestionnaireError {
protected readonly _error = inject(BrnQuestionnaireError);
/** Custom error copy; falls back to the built-in required/optional message when unset. */
public readonly message = input<string | undefined>(undefined);
constructor() {
classes(() => 'mt-2 text-sm text-destructive');
}
}
@Directive({
selector: 'input[hlmQuestionnaireInput]',
exportAs: 'hlmQuestionnaireInput',
hostDirectives: [
{
directive: BrnQuestionnaireInput,
inputs: ['type', 'disabled', 'value', 'defaultValue'],
},
],
host: {
'data-slot': 'questionnaire-input',
},
})
export class HlmQuestionnaireInput {
constructor() {
classes(() => [
'bg-input/50 focus-visible:border-ring focus-visible:ring-ring/30 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 rounded-3xl border border-transparent px-3 py-1 text-base focus-visible:ring-3 aria-invalid:ring-3 md:text-sm min-h-11 w-full min-w-0 transition-[color,box-shadow,background-color] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-0',
'selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground',
]);
}
}
@Directive({
selector: 'fieldset[hlmQuestionnaireItem]',
exportAs: 'hlmQuestionnaireItem',
hostDirectives: [
{
directive: BrnQuestionnaireItem,
inputs: ['name', 'multiple', 'required', 'disabled', 'itemInvalid', 'aria-describedby', 'aria-keyshortcuts'],
outputs: ['statusChange'],
},
],
host: {
'data-slot': 'questionnaire-item',
},
})
export class HlmQuestionnaireItem {
constructor() {
classes(() => 'gap-5 flex min-w-0 flex-col border-0 p-0 outline-none');
}
}
@Directive({
selector: 'button[hlmQuestionnaireNext]',
exportAs: 'hlmQuestionnaireNext',
hostDirectives: [
{
directive: BrnQuestionnaireNext,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-next',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireNext {
public readonly variant = input<ButtonVariants['variant']>('default');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'button[hlmQuestionnairePrevious]',
exportAs: 'hlmQuestionnairePrevious',
hostDirectives: [
{
directive: BrnQuestionnairePrevious,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-previous',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnairePrevious {
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-1 row-start-1 min-h-11 justify-self-start sm:min-h-0',
]);
}
}
@Component({
// eslint-disable-next-line @angular-eslint/component-selector -- attribute selector matching brain API
selector: '[hlmQuestionnaireProgress]',
exportAs: 'hlmQuestionnaireProgress',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [{ directive: BrnQuestionnaireProgress, inputs: ['aria-label', 'valueText'] }],
host: {
'data-slot': 'questionnaire-progress',
},
template: `
<ng-content>
{{ _progress.label() }}
</ng-content>
`,
})
export class HlmQuestionnaireProgress {
protected readonly _progress = inject(BrnQuestionnaireProgress);
/** The current step index (1-based within the active collection). */
public readonly current = this._progress.current;
/** The total number of enabled steps. */
public readonly total = this._progress.total;
/** Whether the current step is the first one. */
public readonly first = this._progress.first;
/** Whether the current step is the last one. */
public readonly last = this._progress.last;
/** The computed progression label, e.g. "Question 2 of 5". */
public readonly label = this._progress.label;
/** Boolean flags marking which segments of the progress bar are filled. */
public readonly segments = this._progress.segments;
constructor() {
classes(
() => 'text-xs text-muted-foreground min-h-lh w-fit min-w-[14ch] font-medium tabular-nums',
);
}
}
@Directive({
selector: 'button[hlmQuestionnaireSkip]',
exportAs: 'hlmQuestionnaireSkip',
hostDirectives: [
{
directive: BrnQuestionnaireSkip,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-skip',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireSkip {
public readonly variant = input<ButtonVariants['variant']>('outline');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-2 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'button[hlmQuestionnaireSubmit]',
exportAs: 'hlmQuestionnaireSubmit',
hostDirectives: [
{
directive: BrnQuestionnaireSubmit,
inputs: ['disabled'],
},
],
host: {
'data-slot': 'questionnaire-submit',
'[attr.data-size]': 'size()',
'[attr.data-variant]': 'variant()',
},
})
export class HlmQuestionnaireSubmit {
public readonly variant = input<ButtonVariants['variant']>('default');
public readonly size = input<ButtonVariants['size']>('default');
constructor() {
classes(() => [
buttonVariants({ variant: this.variant(), size: this.size() }),
'col-start-3 row-start-1 min-h-11 justify-self-end sm:min-h-0',
]);
}
}
@Directive({
selector: 'legend[hlmQuestionnaireTitle]',
exportAs: 'hlmQuestionnaireTitle',
hostDirectives: [{ directive: BrnQuestionnaireTitle }],
host: {
'data-slot': 'questionnaire-title',
},
})
export class HlmQuestionnaireTitle {
constructor() {
classes(() => 'text-base font-semibold [&:not(:has(~[data-slot=questionnaire-description]))]:mb-5 text-pretty');
}
}
@Directive({
selector: 'form[hlmQuestionnaire]',
exportAs: 'hlmQuestionnaire',
hostDirectives: [
{
directive: BrnQuestionnaire,
inputs: ['items', 'defaultItem', 'item', 'shortcuts', 'noValidate'],
outputs: ['itemChange'],
},
],
host: {
'data-slot': 'questionnaire',
},
})
export class HlmQuestionnaire {
constructor() {
classes(() => 'gap-6 flex w-full min-w-0 flex-col');
}
}
export const HlmQuestionnaireImports = [
HlmQuestionnaire,
HlmQuestionnaireProgress,
HlmQuestionnaireItem,
HlmQuestionnaireTitle,
HlmQuestionnaireDescription,
HlmQuestionnaireChoices,
HlmQuestionnaireChoice,
HlmQuestionnaireChoiceDescription,
HlmQuestionnaireInput,
HlmQuestionnaireError,
HlmQuestionnaireActions,
HlmQuestionnairePrevious,
HlmQuestionnaireSkip,
HlmQuestionnaireNext,
HlmQuestionnaireSubmit,
] as const;Usage
import { form, FormField, FormRoot, required } from '@angular/forms/signals';
import { HlmQuestionnaireImports } from '@spartan-ng/helm/questionnaire';<form hlmQuestionnaire [formRoot]="form" class="mx-auto max-w-md" [items]="items" defaultItem="direction" shortcuts="letters">
<div hlmQuestionnaireProgress></div>
<fieldset hlmQuestionnaireItem name="direction" required [formField]="form.direction">
<legend hlmQuestionnaireTitle>What should the agent build next?</legend>
<p hlmQuestionnaireDescription>Choose a direction or describe another task.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="tool-calls">
<span class="font-medium">Tool call timeline</span>
<span hlmQuestionnaireChoiceDescription>Show what the agent ran and what came back.</span>
</label>
<input hlmQuestionnaireInput aria-label="Another agent feature" placeholder="Describe another feature…" />
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<div hlmQuestionnaireActions>
<button hlmQuestionnairePrevious>Previous</button>
<button hlmQuestionnaireSkip>Skip</button>
<button hlmQuestionnaireNext>Next</button>
<button hlmQuestionnaireSubmit>Save plan</button>
</div>
</form> Pass items for ordered progress, shortcuts, and navigation. Bind each item with [formField] and read answers from the signal model on submit. Native FormData still works because the inputs stay in the form.
Examples
Multiple Selection
Use multiple for an item that accepts more than one fixed answer.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField, FormRoot, required } from '@angular/forms/signals';
import type { BrnQuestionnaireItemDefinition } from '@spartan-ng/brain/questionnaire';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmQuestionnaireImports } from '@spartan-ng/helm/questionnaire';
import { answerLabel } from './questionnaire.shared';
@Component({
selector: 'spartan-questionnaire-multiple-preview',
imports: [FormRoot, FormField, HlmQuestionnaireImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full justify-center py-6',
},
template: `
<form hlmQuestionnaire class="mx-auto max-w-md" [formRoot]="form" [items]="items" shortcuts="letters">
<fieldset hlmQuestionnaireItem name="context" multiple required [formField]="form.context">
<legend hlmQuestionnaireTitle>What context should the agent inspect?</legend>
<p hlmQuestionnaireDescription>Select every source that may affect the implementation.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="source">Relevant source files</label>
<label hlmQuestionnaireChoice value="tests">Existing tests</label>
<label hlmQuestionnaireChoice value="docs">Architecture documentation</label>
<label hlmQuestionnaireChoice value="history">Recent commit history</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<div hlmQuestionnaireActions>
<button hlmQuestionnaireSubmit>Share context</button>
</div>
</form>
`,
})
export class QuestionnaireMultiplePreview {
public readonly items: readonly BrnQuestionnaireItemDefinition[] = [
{
name: 'context',
required: true,
choices: [{ value: 'source' }, { value: 'tests' }, { value: 'docs' }, { value: 'history' }],
},
];
protected readonly _model = signal({
context: [] as string[],
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.context);
},
{
submission: {
action: async () => {
toast('Context selected', {
description: `Context: ${answerLabel(this._model().context)}`,
});
},
},
},
);
}Freeform Answer
Compose hlmQuestionnaireInput with fixed choices when the user can provide another answer.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField, FormRoot, required } from '@angular/forms/signals';
import type { BrnQuestionnaireItemDefinition } from '@spartan-ng/brain/questionnaire';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmQuestionnaireImports } from '@spartan-ng/helm/questionnaire';
import { answerLabel } from './questionnaire.shared';
@Component({
selector: 'spartan-questionnaire-freeform-preview',
imports: [FormRoot, FormField, HlmQuestionnaireImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full justify-center py-6',
},
template: `
<form hlmQuestionnaire class="mx-auto max-w-md" [formRoot]="form" [items]="items" shortcuts="letters">
<fieldset hlmQuestionnaireItem name="approach" required [formField]="form.approach">
<legend hlmQuestionnaireTitle>How should the agent approach this refactor?</legend>
<p hlmQuestionnaireDescription>Choose a strategy or write a more specific instruction.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="incremental">Make the smallest safe change</label>
<label hlmQuestionnaireChoice value="module">Refactor one module at a time</label>
<label hlmQuestionnaireChoice value="rewrite">Replace the implementation completely</label>
<input
hlmQuestionnaireInput
aria-label="Another refactoring approach"
placeholder="Describe another approach…"
/>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<div hlmQuestionnaireActions>
<button hlmQuestionnaireSubmit>Use this approach</button>
</div>
</form>
`,
})
export class QuestionnaireFreeformPreview {
public readonly items: readonly BrnQuestionnaireItemDefinition[] = [
{
name: 'approach',
required: true,
choices: [{ value: 'incremental' }, { value: 'module' }, { value: 'rewrite' }],
},
];
protected readonly _model = signal({
approach: '',
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.approach);
},
{
submission: {
action: async () => {
toast('Approach selected', {
description: `Approach: ${answerLabel(this._model().approach)}`,
});
},
},
},
);
}Explicit Skip
Add hlmQuestionnaireSkip when an optional item may be intentionally left unanswered.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField, FormRoot, required } from '@angular/forms/signals';
import type { BrnQuestionnaireItemDefinition } from '@spartan-ng/brain/questionnaire';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmQuestionnaireImports } from '@spartan-ng/helm/questionnaire';
import { answerLabel, type QuestionnaireItemStatus } from './questionnaire.shared';
@Component({
selector: 'spartan-questionnaire-skip-preview',
imports: [FormRoot, FormField, HlmQuestionnaireImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full justify-center py-6',
},
template: `
<form hlmQuestionnaire class="mx-auto max-w-md" [formRoot]="form" [items]="items" defaultItem="task">
<div hlmQuestionnaireProgress></div>
<fieldset hlmQuestionnaireItem name="task" required [formField]="form.task">
<legend hlmQuestionnaireTitle>What kind of change is this?</legend>
<p hlmQuestionnaireDescription>Choose the category that best describes the work.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="feature">New feature</label>
<label hlmQuestionnaireChoice value="fix">Bug fix</label>
<label hlmQuestionnaireChoice value="refactor">Refactor</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset
hlmQuestionnaireItem
name="constraints"
[formField]="form.constraints"
(statusChange)="onConstraintStatus($event)"
>
<legend hlmQuestionnaireTitle>Are there any implementation constraints?</legend>
<p hlmQuestionnaireDescription>Answer if needed, or intentionally skip this question.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="no-dependencies">Do not add dependencies</label>
<label hlmQuestionnaireChoice value="no-migrations">Do not change the database</label>
<label hlmQuestionnaireChoice value="preserve-api">Preserve the public API</label>
<input
hlmQuestionnaireInput
aria-label="Another implementation constraint"
placeholder="Describe another constraint…"
/>
</div>
</fieldset>
<fieldset hlmQuestionnaireItem name="review" required [formField]="form.review">
<legend hlmQuestionnaireTitle>How should the work be reviewed?</legend>
<p hlmQuestionnaireDescription>Choose the checks the agent should complete before handoff.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="tests">Run the test suite</label>
<label hlmQuestionnaireChoice value="diff">Review the final diff</label>
<label hlmQuestionnaireChoice value="both">Tests and diff review</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<div hlmQuestionnaireActions>
<button hlmQuestionnairePrevious>Previous</button>
<button hlmQuestionnaireSkip>Skip</button>
<button hlmQuestionnaireNext>Next</button>
<button hlmQuestionnaireSubmit>Submit brief</button>
</div>
</form>
`,
})
export class QuestionnaireSkipPreview {
public readonly items: readonly BrnQuestionnaireItemDefinition[] = [
{ name: 'task', required: true },
{ name: 'constraints' },
{ name: 'review', required: true },
];
private readonly _constraintStatus = signal<QuestionnaireItemStatus>('unanswered');
protected readonly _model = signal({
task: '',
constraints: '',
review: '',
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.task);
required(schemaPath.review);
},
{
submission: {
action: async () => {
const answers = this._model();
const constraints = this._constraintStatus() === 'skipped' ? 'Skipped' : answerLabel(answers.constraints);
toast('Agent brief submitted', {
description: `Task: ${answerLabel(answers.task)} · Constraints: ${constraints} · Review: ${answerLabel(answers.review)}`,
});
},
},
},
);
protected onConstraintStatus(status: QuestionnaireItemStatus): void {
this._constraintStatus.set(status);
}
}Shortcuts
Assign a letter or number key to each answer with shortcuts .
import { ChangeDetectionStrategy, Component, computed, ElementRef, signal, viewChild } from '@angular/core';
import { form, FormField, FormRoot, required } from '@angular/forms/signals';
import type { BrnQuestionnaireItemDefinition } from '@spartan-ng/brain/questionnaire';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmQuestionnaireImports } from '@spartan-ng/helm/questionnaire';
import { HlmSelectImports } from '@spartan-ng/helm/select';
import { answerLabel, type QuestionnaireShortcutMode } from './questionnaire.shared';
type ShortcutSelectValue = 'none' | 'letters' | 'numbers';
@Component({
selector: 'spartan-questionnaire-shortcuts-preview',
imports: [FormRoot, FormField, HlmQuestionnaireImports, HlmSelectImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'relative flex min-h-[350px] w-full flex-1 self-stretch justify-center py-6',
// Clicking the preview (not the mode select) focuses the questionnaire so A/B/C keys work.
'(pointerdown)': 'onPreviewPointerDown($event)',
},
template: `
<div class="relative mx-auto flex h-full min-h-[300px] w-full max-w-md flex-col">
<label class="sr-only" for="questionnaire-shortcut-style">Shortcut style</label>
<hlm-select
class="absolute end-0 top-0 z-10"
[value]="shortcutsSelectValue()"
[itemToString]="shortcutLabel"
(valueChange)="onShortcutsChange($event)"
>
<hlm-select-trigger class="w-36" buttonId="questionnaire-shortcut-style">
<hlm-select-value placeholder="Shortcuts" />
</hlm-select-trigger>
<hlm-select-content *hlmSelectPortal>
<hlm-select-group>
@for (option of shortcutOptions; track option.value) {
<hlm-select-item [value]="option.value">{{ option.label }}</hlm-select-item>
}
</hlm-select-group>
</hlm-select-content>
</hlm-select>
<form
#questionnaireForm
hlmQuestionnaire
class="mt-auto"
[formRoot]="form"
[items]="items"
defaultItem="action"
[shortcuts]="shortcuts()"
>
<fieldset hlmQuestionnaireItem name="action" required [formField]="form.action">
<legend hlmQuestionnaireTitle>What should the agent do next?</legend>
<p hlmQuestionnaireDescription>Use the displayed shortcut or navigate with the keyboard.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="inspect">Inspect the implementation</label>
<label hlmQuestionnaireChoice value="tests">Run the relevant tests</label>
<label hlmQuestionnaireChoice value="patch">Prepare the patch</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<div hlmQuestionnaireActions>
<button hlmQuestionnaireSubmit>Confirm action</button>
</div>
</form>
</div>
`,
})
export class QuestionnaireShortcutsPreview {
private readonly _questionnaireEl = viewChild<ElementRef<HTMLFormElement>>('questionnaireForm');
public readonly items: readonly BrnQuestionnaireItemDefinition[] = [
{
name: 'action',
required: true,
choices: [{ value: 'inspect' }, { value: 'tests' }, { value: 'patch' }],
},
];
public readonly shortcutOptions: readonly { value: ShortcutSelectValue; label: string }[] = [
{ value: 'none', label: 'No shortcuts' },
{ value: 'letters', label: 'Letters' },
{ value: 'numbers', label: 'Numbers' },
];
public readonly shortcuts = signal<QuestionnaireShortcutMode>('letters');
public readonly shortcutsSelectValue = computed<ShortcutSelectValue>(() => this.shortcuts() ?? 'none');
protected readonly _model = signal({
action: '',
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.action);
},
{
submission: {
action: async () => {
toast('Next action selected', {
description: `Action: ${answerLabel(this._model().action)} · Shortcuts: ${this.shortcuts() ?? 'none'}`,
});
},
},
},
);
protected readonly shortcutLabel = (value: ShortcutSelectValue) =>
this.shortcutOptions.find((option) => option.value === value)?.label ?? '';
protected onShortcutsChange(value: ShortcutSelectValue | null | undefined): void {
if (value === 'letters' || value === 'numbers') {
this.shortcuts.set(value);
} else {
this.shortcuts.set(null);
}
// Return focus to the questionnaire so the next keypress is a shortcut, not lost on the select.
queueMicrotask(() => this.focusQuestionnaire());
}
protected onPreviewPointerDown(event: PointerEvent): void {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
// Let the mode select keep focus while open / being used.
if (target.closest('[data-slot="select"], [data-slot="select-trigger"], [data-slot="select-content"]')) {
return;
}
this.focusQuestionnaire();
}
private focusQuestionnaire(): void {
this._questionnaireEl()?.nativeElement.focus({ preventScroll: true });
}
}Custom Validation
Use Signal Forms required() and validate() to return to an invalid item and present its error.
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { form, FormField, FormRoot, required, validate } from '@angular/forms/signals';
import { type BrnQuestionnaireItemDefinition } from '@spartan-ng/brain/questionnaire';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmQuestionnaireImports } from '@spartan-ng/helm/questionnaire';
const items: readonly BrnQuestionnaireItemDefinition[] = [
{ name: 'detail', required: true },
{ name: 'audience', required: true },
];
@Component({
selector: 'spartan-questionnaire-validation-preview',
imports: [FormRoot, FormField, HlmQuestionnaireImports, HlmCardImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full justify-center py-6',
},
template: `
<form hlmQuestionnaire class="mx-auto max-w-md" [formRoot]="form" [items]="items" [(item)]="item">
<hlm-card class="w-full">
<fieldset
hlmQuestionnaireItem
name="detail"
required
[formField]="form.detail"
[itemInvalid]="_detailInvalid()"
>
<hlm-card-header>
<legend hlmQuestionnaireTitle>How much detail should the answer include?</legend>
<p hlmQuestionnaireDescription>Choose the response depth.</p>
<div hlmCardAction>
<div hlmQuestionnaireProgress class="min-w-0" valueText="%current / %total"></div>
</div>
</hlm-card-header>
<div hlmCardContent>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="summary">Concise summary</label>
<label hlmQuestionnaireChoice value="complete">Complete answer</label>
</div>
<p hlmQuestionnaireError [message]="_detailError()"></p>
</div>
</fieldset>
<fieldset
hlmQuestionnaireItem
name="audience"
required
[formField]="form.audience"
[itemInvalid]="_audienceInvalid()"
>
<hlm-card-header>
<legend hlmQuestionnaireTitle>Who will read the answer?</legend>
<p hlmQuestionnaireDescription>Public answers require complete context.</p>
<div hlmCardAction>
<div hlmQuestionnaireProgress class="min-w-0" valueText="%current / %total"></div>
</div>
</hlm-card-header>
<div hlmCardContent>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="team">My team</label>
<label hlmQuestionnaireChoice value="public">Public audience</label>
</div>
<p hlmQuestionnaireError [message]="_audienceError()"></p>
</div>
</fieldset>
<hlm-card-footer>
<div hlmQuestionnaireActions>
<button hlmQuestionnairePrevious>Previous</button>
<button hlmQuestionnaireNext>Next</button>
<button hlmQuestionnaireSubmit>Validate answers</button>
</div>
</hlm-card-footer>
</hlm-card>
</form>
`,
})
export class QuestionnaireValidationPreview {
public readonly items = items;
public readonly item = signal('detail');
protected readonly _model = signal({
detail: '',
audience: '',
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.detail, { message: 'Choose how much detail the answer should include.' });
required(schemaPath.audience, { message: 'Choose who will read the answer.' });
validate(schemaPath.detail, ({ valueOf }) => {
if (valueOf(schemaPath.audience) === 'public' && valueOf(schemaPath.detail) === 'summary') {
return {
kind: 'incomplete',
message: 'Public answers need enough context. Choose a complete answer.',
};
}
return undefined;
});
},
{
submission: {
action: async () => {
const answers = this._model();
toast('Agent response configured', {
description: `Detail: ${answers.detail} · Audience: ${answers.audience}`,
});
},
},
},
);
protected readonly _detailInvalid = computed(() => {
const field = this.form.detail();
return field.touched() && field.invalid();
});
protected readonly _audienceInvalid = computed(() => {
const field = this.form.audience();
return field.touched() && field.invalid();
});
protected readonly _detailError = computed(() => {
const field = this.form.detail();
if (!field.touched() || !field.invalid()) {
return undefined;
}
return field.errors()[0]?.message;
});
protected readonly _audienceError = computed(() => {
const field = this.form.audience();
if (!field.touched() || !field.invalid()) {
return undefined;
}
return field.errors()[0]?.message;
});
}Controlled
Control the active item from host state with [(item)] .
Current checkpoint: Change scope
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { form, FormField, FormRoot, required } from '@angular/forms/signals';
import type { BrnQuestionnaireItemDefinition } from '@spartan-ng/brain/questionnaire';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmQuestionnaireImports } from '@spartan-ng/helm/questionnaire';
import { answerLabel } from './questionnaire.shared';
const itemLabels: Record<string, string> = {
scope: 'Change scope',
checks: 'Verification',
output: 'Final output',
};
@Component({
selector: 'spartan-questionnaire-controlled-preview',
imports: [FormRoot, FormField, HlmQuestionnaireImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'relative flex w-full justify-center py-6',
},
template: `
<div class="relative mx-auto flex h-full w-full max-w-md flex-col">
<p class="text-muted-foreground absolute end-0 top-0 text-sm" role="status">
Current checkpoint: {{ currentLabel() }}
</p>
<form hlmQuestionnaire class="mt-auto" [formRoot]="form" [items]="items" [(item)]="item">
<div hlmQuestionnaireProgress></div>
<fieldset hlmQuestionnaireItem name="scope" required [formField]="form.scope">
<legend hlmQuestionnaireTitle>What may the agent change?</legend>
<p hlmQuestionnaireDescription>The host stores the active checkpoint while Questionnaire navigates.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="component">Only the target component</label>
<label hlmQuestionnaireChoice value="tests">Component and related tests</label>
<label hlmQuestionnaireChoice value="feature">The complete feature area</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset hlmQuestionnaireItem name="checks" required [formField]="form.checks">
<legend hlmQuestionnaireTitle>Which verification level should it use?</legend>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="targeted">Targeted tests</label>
<label hlmQuestionnaireChoice value="package">Package tests and typecheck</label>
<label hlmQuestionnaireChoice value="full">Full workspace verification</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset hlmQuestionnaireItem name="output" required [formField]="form.output">
<legend hlmQuestionnaireTitle>What should the agent return when finished?</legend>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="summary">Concise summary</label>
<label hlmQuestionnaireChoice value="diff">Summary with changed files</label>
<label hlmQuestionnaireChoice value="handoff">Detailed implementation handoff</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<div hlmQuestionnaireActions>
<button hlmQuestionnairePrevious>Previous</button>
<button hlmQuestionnaireNext>Next</button>
<button hlmQuestionnaireSubmit>Save workflow</button>
</div>
</form>
</div>
`,
})
export class QuestionnaireControlledPreview {
public readonly items: readonly BrnQuestionnaireItemDefinition[] = [
{ name: 'scope', required: true },
{ name: 'checks', required: true },
{ name: 'output', required: true },
];
public readonly item = signal('scope');
public readonly currentLabel = computed(() => itemLabels[this.item()] ?? this.item());
protected readonly _model = signal({
scope: '',
checks: '',
output: '',
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.scope);
required(schemaPath.checks);
required(schemaPath.output);
},
{
submission: {
action: async () => {
const answers = this._model();
toast('Agent workflow configured', {
description: `Scope: ${answerLabel(answers.scope)} · Verification: ${answerLabel(answers.checks)} · Output: ${answerLabel(answers.output)}`,
});
},
},
},
);
}Resume
Restore a saved active item and default answers, then reset changes back to that saved state.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField, FormRoot, required } from '@angular/forms/signals';
import type { BrnQuestionnaireItemDefinition } from '@spartan-ng/brain/questionnaire';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmQuestionnaireImports } from '@spartan-ng/helm/questionnaire';
import { answerLabel } from './questionnaire.shared';
@Component({
selector: 'spartan-questionnaire-resume-preview',
imports: [FormRoot, FormField, HlmQuestionnaireImports, HlmButtonImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full justify-center py-6',
},
template: `
<form
hlmQuestionnaire
class="mx-auto max-w-md"
[formRoot]="form"
[items]="items"
defaultItem="verification"
(reset)="onReset()"
>
<div hlmQuestionnaireProgress></div>
<fieldset hlmQuestionnaireItem name="change" required [formField]="form.change">
<legend hlmQuestionnaireTitle>What kind of migration is this?</legend>
<p hlmQuestionnaireDescription>This answer was saved during the previous session.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="incremental" defaultChecked>Incremental migration</label>
<label hlmQuestionnaireChoice value="cutover">Single cutover</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset hlmQuestionnaireItem name="verification" multiple required [formField]="form.verification">
<legend hlmQuestionnaireTitle>How should the migration be verified?</legend>
<p hlmQuestionnaireDescription>These checks were selected during the previous session.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="tests" defaultChecked>Run migration tests</label>
<label hlmQuestionnaireChoice value="typecheck" defaultChecked>Run the typecheck</label>
<label hlmQuestionnaireChoice value="manual">Perform a manual smoke test</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset hlmQuestionnaireItem name="notes" [formField]="form.notes">
<legend hlmQuestionnaireTitle>Anything else the agent should remember?</legend>
<p hlmQuestionnaireDescription>This note was saved with the draft.</p>
<input
hlmQuestionnaireInput
aria-label="Saved migration note"
[defaultValue]="noteDefault"
[attr.defaultValue]="noteDefault"
/>
</fieldset>
<div hlmQuestionnaireActions>
<button hlmBtn type="reset" variant="outline">Reset changes</button>
<button hlmQuestionnairePrevious>Previous</button>
<button hlmQuestionnaireNext>Next</button>
<button hlmQuestionnaireSubmit>Update draft</button>
</div>
</form>
`,
})
export class QuestionnaireResumePreview {
public readonly items: readonly BrnQuestionnaireItemDefinition[] = [
{ name: 'change', required: true },
{ name: 'verification', required: true },
{ name: 'notes' },
];
public readonly noteDefault = 'Keep the existing public API stable.';
protected readonly _model = signal({
change: 'incremental',
verification: ['tests', 'typecheck'] as string[],
notes: 'Keep the existing public API stable.',
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.change);
required(schemaPath.verification);
},
{
submission: {
action: async () => {
const answers = this._model();
toast('Draft updated', {
description: `Migration: ${answerLabel(answers.change)} · Verification: ${answerLabel(answers.verification)} · Notes: ${answerLabel(answers.notes)}`,
});
},
},
},
);
protected onReset(): void {
toast('Saved answers restored');
}
}Conditional Items
Disable items that do not apply to the user's earlier answers.
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { form, FormField, FormRoot, required } from '@angular/forms/signals';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmQuestionnaireImports } from '@spartan-ng/helm/questionnaire';
import { answerLabel } from './questionnaire.shared';
@Component({
selector: 'spartan-questionnaire-conditional-preview',
imports: [FormRoot, FormField, HlmQuestionnaireImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full justify-center py-6',
},
template: `
<form hlmQuestionnaire class="mx-auto max-w-md" [formRoot]="form" [items]="items()" defaultItem="runtime">
<div hlmQuestionnaireProgress></div>
<fieldset hlmQuestionnaireItem name="runtime" required [formField]="form.runtime">
<legend hlmQuestionnaireTitle>Where should the agent run?</legend>
<p hlmQuestionnaireDescription>Cloud runs add an environment question to this flow.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="local">Local workspace</label>
<label hlmQuestionnaireChoice value="cloud">Cloud workspace</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset
hlmQuestionnaireItem
name="environment"
required
[formField]="form.environment"
[disabled]="_model().runtime !== 'cloud'"
>
<legend hlmQuestionnaireTitle>Which cloud environment should it use?</legend>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="preview">Preview</label>
<label hlmQuestionnaireChoice value="staging">Staging</label>
<label hlmQuestionnaireChoice value="isolated">Isolated sandbox</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset hlmQuestionnaireItem name="approval" required [formField]="form.approval">
<legend hlmQuestionnaireTitle>When should the agent request approval?</legend>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="writes">Before writing files</label>
<label hlmQuestionnaireChoice value="commands">Before running commands</label>
<label hlmQuestionnaireChoice value="sensitive">Only for sensitive actions</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<div hlmQuestionnaireActions>
<button hlmQuestionnairePrevious>Previous</button>
<button hlmQuestionnaireNext>Next</button>
<button hlmQuestionnaireSubmit>Save execution plan</button>
</div>
</form>
`,
})
export class QuestionnaireConditionalPreview {
protected readonly _model = signal({
runtime: 'local',
environment: '',
approval: '',
});
public readonly items = computed(() => [
{ name: 'runtime', required: true },
{
name: 'environment',
required: true,
disabled: this._model().runtime !== 'cloud',
},
{ name: 'approval', required: true },
]);
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.runtime);
required(schemaPath.approval);
},
{
submission: {
action: async () => {
const answers = this._model();
toast('Execution plan saved', {
description: `Runtime: ${answerLabel(answers.runtime)} · Environment: ${answerLabel(answers.environment, 'Not applicable')} · Approval: ${answerLabel(answers.approval)}`,
});
},
},
},
);
}Navigation State
Read item status from statusChange to opt into disabled navigation and custom action styling.
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { form, FormField, FormRoot, required } from '@angular/forms/signals';
import type { BrnQuestionnaireItemDefinition } from '@spartan-ng/brain/questionnaire';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmQuestionnaireImports } from '@spartan-ng/helm/questionnaire';
import { answerLabel, type QuestionnaireItemStatus } from './questionnaire.shared';
type ItemName = 'permission' | 'verification';
@Component({
selector: 'spartan-questionnaire-navigation-state-preview',
imports: [FormRoot, FormField, HlmQuestionnaireImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full justify-center py-6',
},
template: `
<form hlmQuestionnaire class="mx-auto max-w-md" [formRoot]="form" [items]="items" [(item)]="item">
<div hlmQuestionnaireProgress></div>
<fieldset
hlmQuestionnaireItem
name="permission"
required
[formField]="form.permission"
(statusChange)="setStatus('permission', $event)"
>
<legend hlmQuestionnaireTitle>What may the agent modify?</legend>
<p hlmQuestionnaireDescription>Next is intentionally disabled until an answer is selected.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="files">Project files</label>
<label hlmQuestionnaireChoice value="tests">Project files and tests</label>
<label hlmQuestionnaireChoice value="config">Files, tests, and configuration</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset
hlmQuestionnaireItem
name="verification"
required
[formField]="form.verification"
(statusChange)="setStatus('verification', $event)"
>
<legend hlmQuestionnaireTitle>What must pass before completion?</legend>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="tests">Tests</label>
<label hlmQuestionnaireChoice value="types">Tests and types</label>
<label hlmQuestionnaireChoice value="all">Tests, types, and visual QA</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<div hlmQuestionnaireActions>
<button hlmQuestionnairePrevious>Previous</button>
<button
hlmQuestionnaireNext
variant="secondary"
class="data-[status=unanswered]:opacity-50"
[attr.data-status]="activeStatus()"
[disabled]="unanswered()"
>
Next
</button>
<button hlmQuestionnaireSubmit [disabled]="unanswered()">Save permissions</button>
</div>
</form>
`,
})
export class QuestionnaireNavigationStatePreview {
public readonly items: readonly BrnQuestionnaireItemDefinition[] = [
{ name: 'permission', required: true },
{ name: 'verification', required: true },
];
public readonly item = signal<ItemName>('permission');
private readonly _statuses = signal<Record<ItemName, QuestionnaireItemStatus>>({
permission: 'unanswered',
verification: 'unanswered',
});
public readonly activeStatus = computed(() => this._statuses()[this.item()]);
public readonly unanswered = computed(() => this.activeStatus() === 'unanswered');
protected readonly _model = signal({
permission: '',
verification: '',
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.permission);
required(schemaPath.verification);
},
{
submission: {
action: async () => {
const answers = this._model();
toast('Permissions saved', {
description: `Permission: ${answerLabel(answers.permission)} · Verification: ${answerLabel(answers.verification)}`,
});
},
},
},
);
protected setStatus(name: ItemName, status: QuestionnaireItemStatus): void {
this._statuses.update((current) => ({ ...current, [name]: status }));
}
}Custom Progress
Use progress state to build a custom progress indicator.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField, FormRoot, required } from '@angular/forms/signals';
import type { BrnQuestionnaireItemDefinition } from '@spartan-ng/brain/questionnaire';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmQuestionnaireImports } from '@spartan-ng/helm/questionnaire';
import { answerLabel } from './questionnaire.shared';
@Component({
selector: 'spartan-questionnaire-progress-preview',
imports: [FormRoot, FormField, HlmQuestionnaireImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full justify-center py-6',
},
template: `
<form hlmQuestionnaire class="mx-auto max-w-md" [formRoot]="form" [items]="items" defaultItem="scope">
<div hlmQuestionnaireProgress #progress="hlmQuestionnaireProgress" class="w-full">
<div class="mb-2 flex gap-1.5" aria-hidden="true">
@for (filled of progress.segments(); track $index) {
<span
[attr.data-filled]="filled || null"
class="data-filled:bg-primary bg-muted h-1.5 flex-1 rounded-full transition-colors"
></span>
}
</div>
<span>Checkpoint {{ progress.current() }} of {{ progress.total() }}</span>
</div>
<fieldset hlmQuestionnaireItem name="scope" required [formField]="form.scope">
<legend hlmQuestionnaireTitle>How large is the change?</legend>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="small">Small patch</label>
<label hlmQuestionnaireChoice value="medium">Feature-sized change</label>
<label hlmQuestionnaireChoice value="large">Cross-package change</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset hlmQuestionnaireItem name="strategy" required [formField]="form.strategy">
<legend hlmQuestionnaireTitle>How should commits be organized?</legend>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="single">Single commit</label>
<label hlmQuestionnaireChoice value="logical">Logical commits</label>
<label hlmQuestionnaireChoice value="squash">Squash before review</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset hlmQuestionnaireItem name="tests" required [formField]="form.tests">
<legend hlmQuestionnaireTitle>Which tests should run?</legend>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="targeted">Targeted tests</label>
<label hlmQuestionnaireChoice value="package">Package suite</label>
<label hlmQuestionnaireChoice value="workspace">Full workspace</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset hlmQuestionnaireItem name="delivery" required [formField]="form.delivery">
<legend hlmQuestionnaireTitle>How should the work be delivered?</legend>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="patch">Patch only</label>
<label hlmQuestionnaireChoice value="commit">Committed locally</label>
<label hlmQuestionnaireChoice value="branch">Push a review branch</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<div hlmQuestionnaireActions>
<button hlmQuestionnairePrevious>Previous</button>
<button hlmQuestionnaireNext>Next</button>
<button hlmQuestionnaireSubmit>Finish plan</button>
</div>
</form>
`,
})
export class QuestionnaireProgressPreview {
public readonly items: readonly BrnQuestionnaireItemDefinition[] = [
{ name: 'scope', required: true },
{ name: 'strategy', required: true },
{ name: 'tests', required: true },
{ name: 'delivery', required: true },
];
protected readonly _model = signal({
scope: '',
strategy: '',
tests: '',
delivery: '',
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.scope);
required(schemaPath.strategy);
required(schemaPath.tests);
required(schemaPath.delivery);
},
{
submission: {
action: async () => {
const answers = this._model();
toast('Pull request plan ready', {
description: `Scope: ${answerLabel(answers.scope)} · Commits: ${answerLabel(answers.strategy)} · Tests: ${answerLabel(answers.tests)} · Delivery: ${answerLabel(answers.delivery)}`,
});
},
},
},
);
}Animated Items
Animate the active item while keeping progress and navigation stationary.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField, FormRoot, required } from '@angular/forms/signals';
import type { BrnQuestionnaireItemDefinition } from '@spartan-ng/brain/questionnaire';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmQuestionnaireImports } from '@spartan-ng/helm/questionnaire';
import { answerLabel } from './questionnaire.shared';
const itemClassName =
'data-active:animate-in data-active:fade-in-0 data-active:slide-in-from-bottom-2 data-active:duration-300 motion-reduce:animate-none';
@Component({
selector: 'spartan-questionnaire-animated-preview',
imports: [FormRoot, FormField, HlmQuestionnaireImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full justify-center py-6',
},
template: `
<form hlmQuestionnaire class="mx-auto max-w-md" [formRoot]="form" [items]="items" defaultItem="task">
<div hlmQuestionnaireProgress></div>
<fieldset hlmQuestionnaireItem class="${itemClassName}" name="task" required [formField]="form.task">
<legend hlmQuestionnaireTitle>What should the agent do?</legend>
<p hlmQuestionnaireDescription>Choose the task for this run.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="implement">Implement the requested change</label>
<label hlmQuestionnaireChoice value="debug">Debug the current behavior</label>
<label hlmQuestionnaireChoice value="review">Review the implementation</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset hlmQuestionnaireItem class="${itemClassName}" name="review" required [formField]="form.review">
<legend hlmQuestionnaireTitle>How should the work be reviewed?</legend>
<p hlmQuestionnaireDescription>Select the verification depth.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="targeted">Targeted checks</label>
<label hlmQuestionnaireChoice value="complete">Complete test suite</label>
<label hlmQuestionnaireChoice value="manual">Tests and manual QA</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset hlmQuestionnaireItem class="${itemClassName}" name="delivery" required [formField]="form.delivery">
<legend hlmQuestionnaireTitle>How should the result be delivered?</legend>
<p hlmQuestionnaireDescription>Choose the final handoff format.</p>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="summary">Concise summary</label>
<label hlmQuestionnaireChoice value="diff">Summary and changed files</label>
<label hlmQuestionnaireChoice value="handoff">Detailed review handoff</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<div hlmQuestionnaireActions>
<button hlmQuestionnairePrevious>Previous</button>
<button hlmQuestionnaireNext>Next</button>
<button hlmQuestionnaireSubmit>Save workflow</button>
</div>
</form>
`,
})
export class QuestionnaireAnimatedPreview {
public readonly items: readonly BrnQuestionnaireItemDefinition[] = [
{ name: 'task', required: true },
{ name: 'review', required: true },
{ name: 'delivery', required: true },
];
protected readonly _model = signal({
task: '',
review: '',
delivery: '',
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.task);
required(schemaPath.review);
required(schemaPath.delivery);
},
{
submission: {
action: async () => {
const answers = this._model();
toast('Agent workflow saved', {
description: `Task: ${answerLabel(answers.task)} · Review: ${answerLabel(answers.review)} · Delivery: ${answerLabel(answers.delivery)}`,
});
},
},
},
);
}Card
Compose Questionnaire with Card slots while keeping the question title and description semantic.
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormField, FormRoot, required } from '@angular/forms/signals';
import type { BrnQuestionnaireItemDefinition } from '@spartan-ng/brain/questionnaire';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmQuestionnaireImports } from '@spartan-ng/helm/questionnaire';
import { answerLabel } from './questionnaire.shared';
@Component({
selector: 'spartan-questionnaire-card-preview',
imports: [FormRoot, FormField, HlmQuestionnaireImports, HlmCardImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full justify-center py-6',
},
template: `
<form
hlmQuestionnaire
class="mx-auto max-w-md"
[formRoot]="form"
[items]="items"
defaultItem="task"
shortcuts="numbers"
>
<hlm-card>
<fieldset hlmQuestionnaireItem name="task" required aria-labelledby="task-title" [formField]="form.task">
<hlm-card-header>
<legend hlmQuestionnaireTitle hlmCardTitle id="task-title">What should the agent work on?</legend>
<p hlmQuestionnaireDescription hlmCardDescription>Choose the task that should be handled next.</p>
<div hlmCardAction>
<div hlmQuestionnaireProgress></div>
</div>
</hlm-card-header>
<div hlmCardContent>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="fix">Fix the failing tests</label>
<label hlmQuestionnaireChoice value="refactor">Refactor the data layer</label>
<label hlmQuestionnaireChoice value="docs">Update the integration guide</label>
</div>
<p hlmQuestionnaireError></p>
</div>
</fieldset>
<fieldset hlmQuestionnaireItem name="output" required aria-labelledby="output-title" [formField]="form.output">
<hlm-card-header>
<legend hlmQuestionnaireTitle hlmCardTitle id="output-title">What should the final handoff include?</legend>
<p hlmQuestionnaireDescription hlmCardDescription>Pick the level of detail needed for review.</p>
<div hlmCardAction>
<div hlmQuestionnaireProgress></div>
</div>
</hlm-card-header>
<div hlmCardContent>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="summary">Summary only</label>
<label hlmQuestionnaireChoice value="files">Summary and changed files</label>
<label hlmQuestionnaireChoice value="review">Full review handoff</label>
</div>
<p hlmQuestionnaireError></p>
</div>
</fieldset>
<hlm-card-footer>
<div hlmQuestionnaireActions class="w-full">
<button hlmQuestionnairePrevious>Previous</button>
<button hlmQuestionnaireNext>Next</button>
<button hlmQuestionnaireSubmit>Create task</button>
</div>
</hlm-card-footer>
</hlm-card>
</form>
`,
})
export class QuestionnaireCardPreview {
public readonly items: readonly BrnQuestionnaireItemDefinition[] = [
{
name: 'task',
required: true,
choices: [{ value: 'fix' }, { value: 'refactor' }, { value: 'docs' }],
},
{
name: 'output',
required: true,
choices: [{ value: 'summary' }, { value: 'files' }, { value: 'review' }],
},
];
protected readonly _model = signal({
task: '',
output: '',
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.task);
required(schemaPath.output);
},
{
submission: {
action: async () => {
const answers = this._model();
toast('Agent task created', {
description: `Task: ${answerLabel(answers.task)} · Handoff: ${answerLabel(answers.output)}`,
});
},
},
},
);
}Dialog
Compose Questionnaire inside a Dialog while keeping cancellation and dismissal host-owned.
import { ChangeDetectionStrategy, Component, signal, viewChild } from '@angular/core';
import { form, FormField, FormRoot, required } from '@angular/forms/signals';
import { BrnDialog } from '@spartan-ng/brain/dialog';
import type { BrnQuestionnaireItemDefinition } from '@spartan-ng/brain/questionnaire';
import { toast } from '@spartan-ng/brain/sonner';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmDialogImports } from '@spartan-ng/helm/dialog';
import { HlmQuestionnaireImports } from '@spartan-ng/helm/questionnaire';
import { answerLabel } from './questionnaire.shared';
@Component({
selector: 'spartan-questionnaire-dialog-preview',
imports: [FormRoot, FormField, HlmQuestionnaireImports, HlmDialogImports, HlmButtonImports],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'flex w-full justify-center py-6',
},
template: `
<hlm-dialog>
<button hlmDialogTrigger hlmBtn variant="outline">Open clarification</button>
<hlm-dialog-content *hlmDialogPortal="let ctx">
<form hlmQuestionnaire [formRoot]="form" [items]="items" defaultItem="scope">
<fieldset hlmQuestionnaireItem name="scope" required [formField]="form.scope">
<hlm-dialog-header>
<div hlmQuestionnaireProgress></div>
<legend hlmQuestionnaireTitle hlmDialogTitle>Which files are in scope?</legend>
<p hlmQuestionnaireDescription hlmDialogDescription>
Choose how broadly the agent can update the workspace.
</p>
</hlm-dialog-header>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="component">Component only</label>
<label hlmQuestionnaireChoice value="feature">Complete feature directory</label>
<label hlmQuestionnaireChoice value="workspace">Any related workspace file</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<fieldset hlmQuestionnaireItem name="tests" required [formField]="form.tests">
<hlm-dialog-header>
<div hlmQuestionnaireProgress></div>
<legend hlmQuestionnaireTitle hlmDialogTitle>How much verification is needed?</legend>
<p hlmQuestionnaireDescription hlmDialogDescription>
Choose the checks the agent should run before handoff.
</p>
</hlm-dialog-header>
<div hlmQuestionnaireChoices>
<label hlmQuestionnaireChoice value="targeted">Targeted tests</label>
<label hlmQuestionnaireChoice value="package">Package tests</label>
<label hlmQuestionnaireChoice value="full">Full workspace verification</label>
</div>
<p hlmQuestionnaireError></p>
</fieldset>
<hlm-dialog-footer>
<button hlmBtn type="button" variant="outline" hlmDialogClose>Cancel</button>
<div hlmQuestionnaireActions>
<button hlmQuestionnairePrevious>Previous</button>
<button hlmQuestionnaireNext>Next</button>
<button hlmQuestionnaireSubmit>Send answer</button>
</div>
</hlm-dialog-footer>
</form>
</hlm-dialog-content>
</hlm-dialog>
`,
})
export class QuestionnaireDialogPreview {
private readonly _dialog = viewChild(BrnDialog);
public readonly items: readonly BrnQuestionnaireItemDefinition[] = [
{ name: 'scope', required: true },
{ name: 'tests', required: true },
];
protected readonly _model = signal({
scope: '',
tests: '',
});
public readonly form = form(
this._model,
(schemaPath) => {
required(schemaPath.scope);
required(schemaPath.tests);
},
{
submission: {
action: async () => {
const answers = this._model();
this._dialog()?.close({});
toast('Clarification sent', {
description: `Scope: ${answerLabel(answers.scope)} · Verification: ${answerLabel(answers.tests)}`,
});
},
},
},
);
}Brain API
BrnQuestionnaireChoiceInput
Selector: input[brnQuestionnaireChoiceInput]
ExportAs: brnQuestionnaireChoiceInput
BrnQuestionnaireChoiceLabel
Selector: [brnQuestionnaireChoiceLabel]
ExportAs: brnQuestionnaireChoiceLabel
BrnQuestionnaireChoiceShortcut
Selector: [brnQuestionnaireChoiceShortcut]
ExportAs: brnQuestionnaireChoiceShortcut
BrnQuestionnaireChoice
Selector: label[brnQuestionnaireChoice]
ExportAs: brnQuestionnaireChoice
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| value* (required) | string | - | - |
| disabled | boolean | false | - |
| defaultChecked | boolean | false | - |
| checked | boolean | undefined | undefined | - |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| checkedChange | Event | - | - |
BrnQuestionnaireChoices
Selector: [brnQuestionnaireChoices]
ExportAs: brnQuestionnaireChoices
BrnQuestionnaireDescription
Selector: [brnQuestionnaireDescription]
ExportAs: brnQuestionnaireDescription
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | undefined | undefined | - |
BrnQuestionnaireError
Selector: [brnQuestionnaireError]
ExportAs: brnQuestionnaireError
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | undefined | undefined | - |
| requiredMessage | string | 'Choose an answer to continue.' | - |
| optionalMessage | string | 'Choose an answer or skip this question.' | - |
BrnQuestionnaireInput
Selector: input[brnQuestionnaireInput]
ExportAs: brnQuestionnaireInput
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| type | BrnQuestionnaireInputType | 'text' | - |
| disabled | boolean | false | - |
| value | string | undefined | undefined | - |
| defaultValue | string | undefined | undefined | - |
BrnQuestionnaireItem
Selector: fieldset[brnQuestionnaireItem]
ExportAs: brnQuestionnaireItem
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| name* (required) | string | - | `[formField]` overwrites `name` / `required` / `disabled` with field state. Prefer the host attributes so questionnaire identity and flow stay stable. |
| multiple | boolean | false | - |
| required | boolean | false | - |
| disabled | boolean | false | - |
| itemInvalid | boolean | false | Do not alias this to `invalid` — `[formField]` would bind field.invalid() and mark empty required items invalid before the user interacts. |
| aria-describedby | string | undefined | undefined | - |
| aria-keyshortcuts | string | undefined | undefined | - |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| statusChange | BrnQuestionnaireItemStatus | - | - |
BrnQuestionnaireNext
Selector: button[brnQuestionnaireNext]
ExportAs: brnQuestionnaireNext
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| disabled | boolean | false | - |
BrnQuestionnairePrevious
Selector: button[brnQuestionnairePrevious]
ExportAs: brnQuestionnairePrevious
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| disabled | boolean | false | - |
BrnQuestionnaireProgress
Selector: [brnQuestionnaireProgress]
ExportAs: brnQuestionnaireProgress
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-label | string | 'Questionnaire progress' | - |
| valueText | string | 'Question %current of %total' | - |
BrnQuestionnaireSkip
Selector: button[brnQuestionnaireSkip]
ExportAs: brnQuestionnaireSkip
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| disabled | boolean | false | - |
BrnQuestionnaireSubmit
Selector: button[brnQuestionnaireSubmit]
ExportAs: brnQuestionnaireSubmit
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| disabled | boolean | false | - |
BrnQuestionnaireTitle
Selector: legend[brnQuestionnaireTitle]
ExportAs: brnQuestionnaireTitle
BrnQuestionnaire
Selector: form[brnQuestionnaire]
ExportAs: brnQuestionnaire
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| items | readonly BrnQuestionnaireItemDefinition[] | undefined | undefined | - |
| defaultItem | string | undefined | undefined | - |
| shortcuts | BrnQuestionnaireShortcutMode | null | null | - |
| noValidate | boolean | true | - |
| item | string | null | null | - |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| itemChange | string | null | null | - |
Helm API
HlmQuestionnaireActions
Selector: [hlmQuestionnaireActions],hlm-questionnaire-actions
ExportAs: hlmQuestionnaireActions
HlmQuestionnaireChoiceDescription
Selector: [hlmQuestionnaireChoiceDescription]
ExportAs: hlmQuestionnaireChoiceDescription
HlmQuestionnaireChoice
Selector: label[hlmQuestionnaireChoice]
ExportAs: hlmQuestionnaireChoice
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| value* (required) | string | - | - |
| disabled | boolean | false | - |
| defaultChecked | boolean | false | - |
| checked | boolean | undefined | undefined | - |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| checkedChange | Event | - | - |
HlmQuestionnaireChoices
Selector: [hlmQuestionnaireChoices]
ExportAs: hlmQuestionnaireChoices
HlmQuestionnaireDescription
Selector: [hlmQuestionnaireDescription]
ExportAs: hlmQuestionnaireDescription
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | undefined | undefined | - |
HlmQuestionnaireError
Selector: [hlmQuestionnaireError]
ExportAs: hlmQuestionnaireError
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| message | string | undefined | undefined | Custom error copy; falls back to the built-in required/optional message when unset. |
| id | string | undefined | undefined | - |
| requiredMessage | string | 'Choose an answer to continue.' | - |
| optionalMessage | string | 'Choose an answer or skip this question.' | - |
HlmQuestionnaireInput
Selector: input[hlmQuestionnaireInput]
ExportAs: hlmQuestionnaireInput
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| type | BrnQuestionnaireInputType | 'text' | - |
| disabled | boolean | false | - |
| value | string | undefined | undefined | - |
| defaultValue | string | undefined | undefined | - |
HlmQuestionnaireItem
Selector: fieldset[hlmQuestionnaireItem]
ExportAs: hlmQuestionnaireItem
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| name* (required) | string | - | `[formField]` overwrites `name` / `required` / `disabled` with field state. Prefer the host attributes so questionnaire identity and flow stay stable. |
| multiple | boolean | false | - |
| required | boolean | false | - |
| disabled | boolean | false | - |
| itemInvalid | boolean | false | Do not alias this to `invalid` — `[formField]` would bind field.invalid() and mark empty required items invalid before the user interacts. |
| aria-describedby | string | undefined | undefined | - |
| aria-keyshortcuts | string | undefined | undefined | - |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| statusChange | BrnQuestionnaireItemStatus | - | - |
HlmQuestionnaireNext
Selector: button[hlmQuestionnaireNext]
ExportAs: hlmQuestionnaireNext
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | ButtonVariants['variant'] | 'default' | - |
| size | ButtonVariants['size'] | 'default' | - |
| disabled | boolean | false | - |
HlmQuestionnairePrevious
Selector: button[hlmQuestionnairePrevious]
ExportAs: hlmQuestionnairePrevious
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | ButtonVariants['variant'] | 'outline' | - |
| size | ButtonVariants['size'] | 'default' | - |
| disabled | boolean | false | - |
HlmQuestionnaireProgress
Selector: [hlmQuestionnaireProgress]
ExportAs: hlmQuestionnaireProgress
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-label | string | 'Questionnaire progress' | - |
| valueText | string | 'Question %current of %total' | - |
HlmQuestionnaireSkip
Selector: button[hlmQuestionnaireSkip]
ExportAs: hlmQuestionnaireSkip
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | ButtonVariants['variant'] | 'outline' | - |
| size | ButtonVariants['size'] | 'default' | - |
| disabled | boolean | false | - |
HlmQuestionnaireSubmit
Selector: button[hlmQuestionnaireSubmit]
ExportAs: hlmQuestionnaireSubmit
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | ButtonVariants['variant'] | 'default' | - |
| size | ButtonVariants['size'] | 'default' | - |
| disabled | boolean | false | - |
HlmQuestionnaireTitle
Selector: legend[hlmQuestionnaireTitle]
ExportAs: hlmQuestionnaireTitle
HlmQuestionnaire
Selector: form[hlmQuestionnaire]
ExportAs: hlmQuestionnaire
Inputs
| Prop | Type | Default | Description |
|---|---|---|---|
| items | readonly BrnQuestionnaireItemDefinition[] | undefined | undefined | - |
| defaultItem | string | undefined | undefined | - |
| item | string | null | null | - |
| shortcuts | BrnQuestionnaireShortcutMode | null | null | - |
| noValidate | boolean | true | - |
Outputs
| Prop | Type | Default | Description |
|---|---|---|---|
| itemChange | string | null | - | - |
On This Page