2025-12-04 21:07:18 +03:30
|
|
|
|
import {
|
|
|
|
|
|
Directive,
|
|
|
|
|
|
ElementRef,
|
|
|
|
|
|
HostListener,
|
|
|
|
|
|
Input,
|
2025-12-09 20:17:00 +03:30
|
|
|
|
OnChanges,
|
2025-12-04 21:07:18 +03:30
|
|
|
|
OnInit,
|
|
|
|
|
|
Optional,
|
|
|
|
|
|
Renderer2,
|
|
|
|
|
|
Self,
|
2025-12-09 20:17:00 +03:30
|
|
|
|
SimpleChanges,
|
2025-12-04 21:07:18 +03:30
|
|
|
|
} from '@angular/core';
|
|
|
|
|
|
import { NgControl } from '@angular/forms';
|
|
|
|
|
|
|
|
|
|
|
|
@Directive({
|
|
|
|
|
|
selector: '[appPriceMask]',
|
|
|
|
|
|
standalone: true,
|
|
|
|
|
|
})
|
2025-12-09 20:17:00 +03:30
|
|
|
|
export class PriceMaskDirective implements OnInit, OnChanges {
|
|
|
|
|
|
/** Optional value to render/format when bound like [appPriceMask]="price" */
|
|
|
|
|
|
@Input('appPriceMask') priceValue: number | string | null | undefined;
|
2025-12-04 21:07:18 +03:30
|
|
|
|
@Input('appPriceMaskLocale') locale: string = 'fa-IR';
|
|
|
|
|
|
@Input('appPriceMaskFraction') fraction = 0;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* When true, force comma (",") as thousands separator by using `en-US` formatting.
|
|
|
|
|
|
* If false, uses the provided `appPriceMaskLocale`.
|
|
|
|
|
|
*/
|
|
|
|
|
|
@Input('appPriceMaskUseComma') useComma = false;
|
2025-12-09 20:17:00 +03:30
|
|
|
|
/** Optional currency code to append for non-input hosts (e.g., "ریال" | "تومان") */
|
|
|
|
|
|
@Input('appPriceMaskCurrency') currency: string | null = 'ریال';
|
2025-12-04 21:07:18 +03:30
|
|
|
|
|
|
|
|
|
|
private inputEl!: HTMLInputElement | null;
|
|
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
|
private el: ElementRef,
|
|
|
|
|
|
private renderer: Renderer2,
|
|
|
|
|
|
@Optional() @Self() private ngControl: NgControl | null,
|
|
|
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
|
|
ngOnInit(): void {
|
|
|
|
|
|
// p-inputNumber renders an input inside its host element; try to locate it
|
|
|
|
|
|
const host: HTMLElement = this.el.nativeElement;
|
|
|
|
|
|
this.inputEl =
|
|
|
|
|
|
host.tagName.toLowerCase() === 'input'
|
|
|
|
|
|
? (host as HTMLInputElement)
|
|
|
|
|
|
: host.querySelector('input');
|
2025-12-09 20:17:00 +03:30
|
|
|
|
|
|
|
|
|
|
// If a value is bound initially (e.g., span with [appPriceMask]), render it
|
|
|
|
|
|
if (this.priceValue !== undefined) {
|
|
|
|
|
|
this.renderFromBoundValue();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ngOnChanges(changes: SimpleChanges): void {
|
|
|
|
|
|
if (changes['priceValue'] && this.priceValue !== undefined) {
|
|
|
|
|
|
this.renderFromBoundValue();
|
|
|
|
|
|
}
|
2025-12-04 21:07:18 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private toArabicDigitsAwareNumber(str: string): string {
|
|
|
|
|
|
if (!str) return '';
|
|
|
|
|
|
// Map Arabic-Indic and Extended Arabic-Indic digits to ASCII digits
|
|
|
|
|
|
const map: Record<string, string> = {
|
|
|
|
|
|
'٠': '0',
|
|
|
|
|
|
'١': '1',
|
|
|
|
|
|
'٢': '2',
|
|
|
|
|
|
'٣': '3',
|
|
|
|
|
|
'٤': '4',
|
|
|
|
|
|
'٥': '5',
|
|
|
|
|
|
'٦': '6',
|
|
|
|
|
|
'٧': '7',
|
|
|
|
|
|
'٨': '8',
|
|
|
|
|
|
'٩': '9',
|
|
|
|
|
|
'۰': '0',
|
|
|
|
|
|
'۱': '1',
|
|
|
|
|
|
'۲': '2',
|
|
|
|
|
|
'۳': '3',
|
|
|
|
|
|
'۴': '4',
|
|
|
|
|
|
'۵': '5',
|
|
|
|
|
|
'۶': '6',
|
|
|
|
|
|
'۷': '7',
|
|
|
|
|
|
'۸': '8',
|
|
|
|
|
|
'۹': '9',
|
|
|
|
|
|
};
|
|
|
|
|
|
return str.replace(/[٠-٩۰-۹]/g, (d) => map[d] ?? d);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private cleanNumericString(raw: string): string {
|
|
|
|
|
|
// Convert localized digits to ASCII, then remove everything except digits, dot and minus
|
|
|
|
|
|
const ascii = this.toArabicDigitsAwareNumber(raw);
|
|
|
|
|
|
return ascii.replace(/[^0-9.\-]/g, '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-09 20:17:00 +03:30
|
|
|
|
private normalizeToNumber(raw: number | string | null | undefined): number | null {
|
|
|
|
|
|
if (raw === null || raw === undefined) return null;
|
|
|
|
|
|
if (typeof raw === 'number') return isFinite(raw) ? raw : null;
|
|
|
|
|
|
const cleaned = this.cleanNumericString(String(raw));
|
|
|
|
|
|
if (cleaned === '') return null;
|
|
|
|
|
|
const num = Number(cleaned);
|
|
|
|
|
|
return isNaN(num) ? null : num;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-04 21:07:18 +03:30
|
|
|
|
private formatNumber(value: number): string {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const fmtLocale = this.useComma ? 'en-US' : this.locale;
|
|
|
|
|
|
return new Intl.NumberFormat(fmtLocale, {
|
|
|
|
|
|
maximumFractionDigits: this.fraction,
|
|
|
|
|
|
minimumFractionDigits: 0,
|
|
|
|
|
|
useGrouping: true,
|
|
|
|
|
|
}).format(value);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
return String(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-09 20:17:00 +03:30
|
|
|
|
private formatWithCurrency(num: number | null, isInputHost: boolean): string {
|
|
|
|
|
|
if (num === null) return '';
|
|
|
|
|
|
const formatted = this.formatNumber(num);
|
|
|
|
|
|
// For inputs we should NOT append currency to avoid polluting numeric entry
|
|
|
|
|
|
if (isInputHost) return formatted;
|
|
|
|
|
|
if (this.currency && this.currency.trim()) {
|
|
|
|
|
|
return `${formatted} ${this.currency.trim()}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return formatted;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-04 21:07:18 +03:30
|
|
|
|
@HostListener('input', ['$event'])
|
|
|
|
|
|
onInput(ev: Event) {
|
|
|
|
|
|
if (!this.inputEl) return;
|
|
|
|
|
|
const raw = (ev.target as HTMLInputElement).value || this.inputEl.value || '';
|
|
|
|
|
|
const cleaned = this.cleanNumericString(raw);
|
|
|
|
|
|
if (cleaned === '') {
|
|
|
|
|
|
// clear control
|
|
|
|
|
|
if (this.ngControl?.control) this.ngControl.control.setValue(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const asNumber = Number(cleaned);
|
|
|
|
|
|
if (isNaN(asNumber)) return;
|
|
|
|
|
|
|
|
|
|
|
|
const formatted = this.formatNumber(asNumber);
|
|
|
|
|
|
|
|
|
|
|
|
// preserve caret position roughly: compute delta and adjust
|
|
|
|
|
|
const prevPos = this.inputEl.selectionStart ?? raw.length;
|
|
|
|
|
|
const prevLen = raw.length;
|
|
|
|
|
|
|
|
|
|
|
|
// Update FormControl value with numeric value first (suppress events)
|
|
|
|
|
|
if (this.ngControl?.control) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
this.ngControl.control.setValue(asNumber, { emitEvent: false });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// fallback: ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update the displayed value (override any writeValue from control)
|
|
|
|
|
|
this.renderer.setProperty(this.inputEl, 'value', formatted);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const newLen = formatted.length;
|
|
|
|
|
|
let newPos = prevPos + (newLen - prevLen);
|
|
|
|
|
|
if (newPos < 0) newPos = 0;
|
|
|
|
|
|
if (newPos > newLen) newPos = newLen;
|
|
|
|
|
|
this.inputEl.setSelectionRange(newPos, newPos);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// ignore selection errors
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-09 20:17:00 +03:30
|
|
|
|
|
|
|
|
|
|
/** Render when bound via [appPriceMask] on non-input hosts (e.g., span) or to set initial value. */
|
|
|
|
|
|
private renderFromBoundValue() {
|
|
|
|
|
|
const num = this.normalizeToNumber(this.priceValue);
|
|
|
|
|
|
|
|
|
|
|
|
// If host has an input element, set both control value (if any) and displayed value
|
|
|
|
|
|
if (this.inputEl) {
|
|
|
|
|
|
if (this.ngControl?.control) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
this.ngControl.control.setValue(num, { emitEvent: false });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// ignore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const formatted = this.formatWithCurrency(num, true);
|
|
|
|
|
|
this.renderer.setProperty(this.inputEl, 'value', formatted);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Otherwise, render to host text (e.g., span/div)
|
|
|
|
|
|
const formatted = this.formatWithCurrency(num, false);
|
|
|
|
|
|
this.renderer.setProperty(this.el.nativeElement, 'textContent', formatted);
|
|
|
|
|
|
}
|
2025-12-04 21:07:18 +03:30
|
|
|
|
}
|