import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
computed,
ElementRef,
forwardRef,
input,
model,
signal,
viewChild,
} from '@angular/core';
import {
type ControlValueAccessor,
NG_VALUE_ACCESSOR,
} from '@angular/forms';
import {
FORM_AFFIX_CLASS,
FORM_CONTROL_INNER_CLASS,
FORM_DISABLED_CLASS,
FORM_ERROR_CLASS,
FORM_FIELD_CLASS,
FORM_FIELD_ERROR_CLASS,
FORM_HINT_CLASS,
FORM_LABEL_CLASS,
} from '../form-field.classes';
let nextNumberInputId = 0;
const numberFormatter = new Intl.NumberFormat('en-US');
/**
* Formats a raw field string with grouping commas while preserving a trailing
* decimal point / partial fraction so mid-typing stays natural.
*
* @param raw - Whatever is currently in the input (may already contain commas).
* @returns Display text plus a parseable number (or `null` / `undefined` when
* the draft is not yet a complete number — `undefined` means "do not emit").
*/
function formatWhileTyping(raw: string): {
display: string;
numeric: number | null | undefined;
} {
const trimmed = raw.trim();
if (trimmed === '' || trimmed === '-') {
return { display: trimmed === '-' ? '-' : '', numeric: null };
}
const negative = trimmed.startsWith('-');
const unsigned = negative ? trimmed.slice(1) : trimmed;
const dotIndex = unsigned.indexOf('.');
const hasDot = dotIndex !== -1;
const intRaw = hasDot ? unsigned.slice(0, dotIndex) : unsigned;
const fracRaw = hasDot ? unsigned.slice(dotIndex + 1).replace(/[^\d]/g, '') : '';
const intDigits = intRaw.replace(/[^\d]/g, '');
// Leading "." → treat as "0."
const intFormatted =
intDigits === ''
? hasDot
? '0'
: ''
: numberFormatter.format(Number(intDigits));
let display = `${negative ? '-' : ''}${intFormatted}`;
if (hasDot) {
display += `.${fracRaw}`;
}
if (intDigits === '' && !hasDot) {
return { display, numeric: null };
}
// Trailing "." alone is partial — keep display, skip emit until more digits.
if (hasDot && fracRaw === '' && unsigned.endsWith('.')) {
return { display, numeric: undefined };
}
const parsed = Number(`${negative ? '-' : ''}${intDigits || '0'}${hasDot ? `.${fracRaw}` : ''}`);
if (Number.isNaN(parsed)) {
return { display, numeric: undefined };
}
return { display, numeric: parsed };
}
/**
* Numeric field following the shared AFRICANIES form field pattern.
*
* The public contract is always a plain `number | null` — comma grouping is
* **display-only** via `Intl.NumberFormat('en-US')`, applied **as the user types**
* (caret is restored by digit-count so commas do not jump the cursor).
*
* @example
* ```html
*
*
* $
*
* ```
* ```ts
* amount = signal(1250); // UI shows "1,250"
* ```
*/
@Component({
selector: 'africanies-number-input',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => NumberInputComponent),
multi: true,
},
],
template: `
@if (error(); as err) {
{{ err }}
} @else if (hint(); as h) {
{{ h }}
}
`,
})
/**
* Numeric field following the shared AFRICANIES form field pattern.
*/
export class NumberInputComponent implements ControlValueAccessor {
protected readonly controlId = `africanies-number-input-${++nextNumberInputId}`;
protected readonly hintId = `${this.controlId}-hint`;
protected readonly errorId = `${this.controlId}-error`;
protected readonly labelClass = FORM_LABEL_CLASS;
protected readonly affixClass = FORM_AFFIX_CLASS;
protected readonly innerClass = FORM_CONTROL_INNER_CLASS;
protected readonly hintClass = FORM_HINT_CLASS;
protected readonly errorClass = FORM_ERROR_CLASS;
private readonly inputEl = viewChild>('inputEl');
/** Visible field 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);
/** Native placeholder when empty. */
readonly placeholder = input('');
/**
* Plain number (or `null`). Never a comma-formatted string.
*/
readonly value = model(null);
/** Host disable flag (in addition to CVA). */
readonly disabledInput = input(false, {
alias: 'disabled',
transform: booleanAttribute,
});
/**
* WHY a separate draft: while typing we may show "1," / "1,250." before the
* model can round-trip through `Intl.NumberFormat` without fighting the caret.
*/
private readonly draftText = signal(null);
protected readonly cvaDisabled = signal(false);
protected readonly disabled = computed(
() => this.disabledInput() || this.cvaDisabled(),
);
protected readonly displayText = computed(() => {
const draft = this.draftText();
if (draft !== null) {
return draft;
}
const v = this.value();
if (v === null || v === undefined) {
return '';
}
return numberFormatter.format(v);
});
protected readonly shellClass = computed(() => {
let classes = FORM_FIELD_CLASS;
if (this.error()) {
classes += ` ${FORM_FIELD_ERROR_CLASS}`;
}
if (this.disabled()) {
classes += ` ${FORM_DISABLED_CLASS}`;
}
return classes;
});
protected readonly describedBy = computed(() => {
if (this.error()) {
return this.errorId;
}
if (this.hint()) {
return this.hintId;
}
return null;
});
private onChange: (value: number | null) => void = () => undefined;
private onTouched: () => void = () => undefined;
/** @param value - Plain number or `null` (never a formatted string). */
writeValue(value: number | null): void {
this.draftText.set(null);
this.value.set(value);
}
/** @param fn - Numeric change callback. */
registerOnChange(fn: (value: number | null) => void): void {
this.onChange = fn;
}
/** @param fn - Blur callback. */
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
/** @param isDisabled - Blocks editing when true. */
setDisabledState(isDisabled: boolean): void {
this.cvaDisabled.set(isDisabled);
}
protected onInput(event: Event): void {
const el = event.target as HTMLInputElement;
const raw = el.value;
const caret = el.selectionStart ?? raw.length;
const digitsBeforeCaret = countDigits(raw.slice(0, caret));
const caretAfterDecimal =
raw.indexOf('.') !== -1 && caret > raw.indexOf('.');
const fracDigitsBeforeCaret = caretAfterDecimal
? countDigits(raw.slice(raw.indexOf('.') + 1, caret))
: 0;
const { display, numeric } = formatWhileTyping(raw);
this.draftText.set(display);
// Native value must be written — `[value]` does not reliably overwrite mid-input.
el.value = display;
if (numeric !== undefined) {
this.value.set(numeric);
this.onChange(numeric);
}
// Restore caret by digit identity so inserting commas does not jump the cursor.
const caretPos = caretFromDigitCount(
display,
digitsBeforeCaret,
caretAfterDecimal,
fracDigitsBeforeCaret,
);
queueMicrotask(() => {
const input = this.inputEl()?.nativeElement ?? el;
input.setSelectionRange(caretPos, caretPos);
});
}
protected onBlur(): void {
this.draftText.set(null);
this.onTouched();
}
}
/**
* @param text - Source string.
* @returns Count of digit characters.
*/
function countDigits(text: string): number {
return (text.match(/\d/g) ?? []).length;
}
/**
* Maps a digit-based caret intent back onto a comma-formatted string.
*
* @param display - Formatted field text.
* @param intDigitsWanted - Digits left of caret in the integer portion (or total when no decimal).
* @param afterDecimal - Whether the caret sits in the fraction.
* @param fracDigitsWanted - Digits left of caret in the fraction.
* @returns Caret index into `display`.
*/
function caretFromDigitCount(
display: string,
intDigitsWanted: number,
afterDecimal: boolean,
fracDigitsWanted: number,
): number {
const dot = display.indexOf('.');
if (!afterDecimal || dot === -1) {
let seen = 0;
for (let i = 0; i < display.length; i++) {
const ch = display[i];
if (ch !== undefined && /\d/.test(ch)) {
seen++;
if (seen >= intDigitsWanted) {
return i + 1;
}
}
}
return dot === -1 ? display.length : dot;
}
let seen = 0;
for (let i = dot + 1; i < display.length; i++) {
const ch = display[i];
if (ch !== undefined && /\d/.test(ch)) {
seen++;
if (seen >= fracDigitsWanted) {
return i + 1;
}
}
}
return display.length;
}