import { NgClass } from '@angular/common';
import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
computed,
forwardRef,
inject,
input,
output,
signal,
} from '@angular/core';
import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { AfricaniesIconComponent } from '@africanies/africanies-icons';
import { ModeColorService } from '@africanies/africanies-theme';
import { FORM_ERROR_CLASS, FORM_HINT_CLASS } from '../form-field.classes';
let nextToggleId = 0;
/**
* Boolean switch following the shared AFRICANIES form field pattern.
*
* **Prefix/suffix slots are omitted** — a toggle is a binary control; affix
* projection does not apply.
*
* On-state fill follows {@link ModeColorService} (SFN green / STN orange).
*
* The thumb follows {@link value} (or the CVA model). A click emits the next
* boolean and does not flip locally — so `[(value)]` stays instant while
* one-way `[value]` + `(valueChange)` waits for the parent to commit. Bind
* {@link loading} for API-backed switches: the control stays on the last
* committed value, shows a spinner, and ignores further clicks.
*
* @example
* ```html
*
*
*
* ```
*/
@Component({
selector: 'africanies-toggle',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [AfricaniesIconComponent, NgClass],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ToggleComponent),
multi: true,
},
],
template: `
@if (error(); as err) {
{{ err }}
} @else if (hint(); as h) {
{{ h }}
}
`,
})
/**
* Boolean switch following the shared AFRICANIES form field pattern.
*/
export class ToggleComponent implements ControlValueAccessor {
private readonly modeColor = inject(ModeColorService);
protected readonly controlId = `africanies-toggle-${++nextToggleId}`;
protected readonly hintId = `${this.controlId}-hint`;
protected readonly errorId = `${this.controlId}-error`;
protected readonly hintClass = FORM_HINT_CLASS;
protected readonly errorClass = FORM_ERROR_CLASS;
/** Visible label beside the switch. */
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);
/** On/off state (`[(value)]` + CVA). */
readonly value = input(false);
/** Emitted with the intended next state. The thumb flips when {@link value} changes. */
readonly valueChange = output();
/**
* In-flight mutation. Keeps the last committed {@link value}, shows a
* spinner on the thumb, and treats the switch as non-interactive.
*
* Plain boolean (no `booleanAttribute`): `[loading]="saving()"` bindings
* must pass through `true` without attribute-string coercion.
*/
readonly loading = input(false);
/** Host disable flag (in addition to CVA). */
readonly disabledInput = input(false, {
alias: 'disabled',
transform: booleanAttribute,
});
protected readonly cvaDisabled = signal(false);
/**
* Form-attached value. `undefined` until {@link writeValue} runs so
* template `[value]` stays the source of truth outside reactive forms.
*/
private readonly cvaValue = signal(undefined);
protected readonly disabled = computed(
() => this.disabledInput() || this.cvaDisabled(),
);
protected readonly inactive = computed(
() => this.disabled() || this.loading(),
);
protected readonly on = computed(() => {
const fromCva = this.cvaValue();
return fromCva !== undefined ? fromCva : this.value();
});
/** On → mode primary fill; off → neutral track. */
protected readonly trackClass = computed(() =>
this.on()
? this.modeColor.classes().bg
: 'bg-neutral-300 dark:bg-neutral-600',
);
protected readonly describedBy = computed(() => {
if (this.error()) {
return this.errorId;
}
if (this.hint()) {
return this.hintId;
}
return null;
});
private onChange: (value: boolean) => void = () => undefined;
private onTouched: () => void = () => undefined;
/** @param value - Form model; non-boolean coerces via `!!`. */
writeValue(value: boolean | null): void {
this.cvaValue.set(!!value);
}
/** @param fn - User toggle callback. */
registerOnChange(fn: (value: boolean) => 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 toggle(): void {
if (this.inactive()) {
return;
}
const next = !this.on();
this.onChange(next);
this.valueChange.emit(next);
// Forms do not writeValue back after onChange — keep the thumb in sync.
if (this.cvaValue() !== undefined) {
this.cvaValue.set(next);
}
}
protected onBlur(): void {
this.onTouched();
}
}