Interview question
Build an accessible rating control for Angular reactive forms
Implements a reusable ControlValueAccessor with model-to-view updates, touch and disabled semantics, keyboard behavior, and validation-friendly accessibility.
TL;DR
Implements a reusable ControlValueAccessor with model-to-view updates, touch and disabled semantics, keyboard behavior, and validation-friendly accessibility.
Angular reactive forms, ControlValueAccessor, accessibility, keyboard interaction, disabled state, touched state, and reusable component contracts.
Practice the problem like a real interview: restate, reason, implement, and test.
Implement a standalone 1-to-5 rating control that works with Angular reactive forms through ControlValueAccessor. Programmatic form values must update the UI; user interaction must update the form; blur must mark it touched; disabled state must block changes. Use native buttons, an accessible group label, and arrow-key navigation without emitting duplicate changes.
form.setValue(3) -> stars 1..3 appear selected
click star 5 -> form value becomes 5
ArrowLeft on 5 -> value becomes 4
form.disable() -> controls disabled and no value changes
null for no rating.writeValue without notifying the form.import {Component, forwardRef, signal} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
@Component({
selector: 'app-rating',
providers: [{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => RatingControl),
multi: true,
}],
template: `
<div role="group" aria-label="Rating from one to five">
@for (star of stars; track star) {
<button type="button"
[disabled]="disabled()"
[attr.aria-pressed]="(value() ?? 0) >= star"
[attr.aria-label]="star + ' stars'"
(click)="choose(star)"
(keydown.ArrowLeft)="move(-1, $event)"
(keydown.ArrowRight)="move(1, $event)"
(blur)="onTouched()">ā
</button>
}
</div>
`,
})
export class RatingControl implements ControlValueAccessor {
readonly stars = [1, 2, 3, 4, 5];
readonly value = signal<number | null>(null);
readonly disabled = signal(false);
private onChange: (value: number | null) => void = () => {};
onTouched: () => void = () => {};
writeValue(value: number | null): void {
this.value.set(value != null && value >= 1 && value <= 5 ? value : null);
}
registerOnChange(fn: (value: number | null) => void): void { this.onChange = fn; }
registerOnTouched(fn: () => void): void { this.onTouched = fn; }
setDisabledState(value: boolean): void { this.disabled.set(value); }
choose(value: number): void {
if (this.disabled()) return;
this.value.set(value);
this.onChange(value);
}
move(delta: number, event: Event): void {
if (this.disabled()) return;
event.preventDefault();
const next = Math.max(1, Math.min(5, (this.value() ?? 1) + delta));
this.choose(next);
}
}
Interaction and updates are O(1); the fixed five-button view has constant rendering cost.
writeValue and verify the registered change callback was not invoked.onChange inside writeValue creates a feedback loop.setDisabledState leaves the view interactive while the form is disabled.Move to the linked follow-up, next path step, prerequisite, or deeper variant.
Practice the next layer of the same subject.