import { booleanAttribute, ChangeDetectionStrategy, Component, computed, forwardRef, inject, input, model, signal, } from '@angular/core'; import { type ControlValueAccessor, NG_VALUE_ACCESSOR, } from '@angular/forms'; import { ModeColorService } from '@africanies/africanies-theme'; import { FORM_ERROR_CLASS, FORM_FOCUS_VISIBLE_PEER_CLASS, FORM_HINT_CLASS, FORM_LABEL_CLASS, } from '../form-field.classes'; /** * Option row for {@link RadioComponent}. * * @typeParam T - Value type bound through CVA / `value`. */ export interface RadioOption { /** Visible label for the radio. */ label: string; /** Value written when this option is selected. */ value: T; /** When true, the option cannot be chosen. */ disabled?: boolean; } let nextRadioId = 0; /** * Radio group following the shared AFRICANIES form field pattern. * * **Prefix/suffix slots are omitted** — radios are mutually exclusive options * with labels; affix projection does not apply. * * Selected ring and center disc follow {@link ModeColorService} * (SFN green / STN orange). * * @typeParam T - Option value type. * * @example * ```html * * ``` */ @Component({ selector: 'africanies-radio', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'inline-flex max-w-full', }, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioComponent), multi: true, }, ], template: `
{{ label() }}
@for (opt of options(); track trackOption(opt)) { }
@if (error(); as err) {

{{ err }}

} @else if (hint(); as h) {

{{ h }}

}
`, }) /** * Radio group following the shared AFRICANIES form field pattern. */ export class RadioComponent implements ControlValueAccessor { private readonly modeColor = inject(ModeColorService); protected readonly groupName = `africanies-radio-${++nextRadioId}`; protected readonly hintId = `${this.groupName}-hint`; protected readonly errorId = `${this.groupName}-error`; protected readonly labelClass = FORM_LABEL_CLASS; protected readonly hintClass = FORM_HINT_CLASS; protected readonly errorClass = FORM_ERROR_CLASS; /** Group legend / label. */ readonly label = input(''); /** Helper copy. Hidden while {@link error} is set. */ readonly hint = input(undefined); /** Field-level validation message. Distinct from `ErrorStateComponent`. */ readonly error = input(null); /** Options rendered as radios. */ readonly options = input[]>([]); /** Selected option value (`[(value)]` + CVA). */ readonly value = model(null); /** Host disable flag (in addition to CVA). */ readonly disabledInput = input(false, { alias: 'disabled', transform: booleanAttribute, }); protected readonly cvaDisabled = signal(false); protected readonly disabled = computed( () => this.disabledInput() || this.cvaDisabled(), ); protected readonly describedBy = computed(() => { if (this.error()) { return this.errorId; } if (this.hint()) { return this.hintId; } return null; }); private onChange: (value: T | null) => void = () => undefined; private onTouched: () => void = () => undefined; protected trackOption(opt: RadioOption): string { return String(opt.value); } protected isSelected(opt: RadioOption): boolean { return Object.is(this.value(), opt.value); } protected markClass(opt: RadioOption): string { const base = 'pointer-events-none flex size-4 items-center justify-center rounded-full border-2 bg-transparent transition-colors ' + FORM_FOCUS_VISIBLE_PEER_CLASS; const err = this.error() ? ' !border-danger dark:!border-danger' : ''; if (this.isSelected(opt)) { // Ring uses mode accent; fill stays transparent so the center dot reads clearly. return `${base} ${this.modeColor.classes().border}${err}`; } return `${base} border-neutral-400 dark:border-white${err}`; } /** Selected inner disc — SFN green / STN orange. */ protected readonly dotClass = computed( () => `block size-1.5 rounded-full ${this.modeColor.classes().bg}`, ); /** @param value - Selected option value or `null`. */ writeValue(value: T | null): void { this.value.set(value); } /** @param fn - Selection change callback. */ registerOnChange(fn: (value: T | null) => void): void { this.onChange = fn; } /** @param fn - Blur callback. */ registerOnTouched(fn: () => void): void { this.onTouched = fn; } /** @param isDisabled - Blocks interaction when true. */ setDisabledState(isDisabled: boolean): void { this.cvaDisabled.set(isDisabled); } protected select(opt: RadioOption): void { if (this.disabled() || opt.disabled) { return; } this.value.set(opt.value); this.onChange(opt.value); } protected onBlur(): void { this.onTouched(); } }