2026-09-05 02:13:39 +03:30
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { nextTick, ref, watch } from 'vue';
|
2026-10-18 19:39:59 +03:30
|
|
|
|
import { Input as AInput } from 'ant-design-vue';
|
2026-09-05 02:13:39 +03:30
|
|
|
|
import {
|
|
|
|
|
|
caretIndexForDigitCount,
|
|
|
|
|
|
countDigitChars,
|
|
|
|
|
|
formatGroupedInput,
|
|
|
|
|
|
parseMoneyNumber,
|
|
|
|
|
|
} from '@/lib/money';
|
|
|
|
|
|
|
|
|
|
|
|
const model = defineModel<string | number | null>({ default: '' });
|
|
|
|
|
|
|
|
|
|
|
|
const props = withDefaults(
|
|
|
|
|
|
defineProps<{
|
|
|
|
|
|
id?: string;
|
|
|
|
|
|
placeholder?: string;
|
|
|
|
|
|
required?: boolean;
|
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
|
readonly?: boolean;
|
|
|
|
|
|
/** حداکثر رقم اعشار؛ پیشفرض ۲ */
|
|
|
|
|
|
decimals?: number;
|
|
|
|
|
|
/** اگر true باشد مقدار عددی emit میشود (نه رشته) */
|
|
|
|
|
|
asNumber?: boolean;
|
|
|
|
|
|
dir?: 'ltr' | 'rtl';
|
2026-11-08 16:53:45 +03:30
|
|
|
|
addonAfter?: string;
|
2026-09-05 02:13:39 +03:30
|
|
|
|
}>(),
|
|
|
|
|
|
{
|
|
|
|
|
|
required: false,
|
|
|
|
|
|
disabled: false,
|
|
|
|
|
|
readonly: false,
|
|
|
|
|
|
decimals: 2,
|
|
|
|
|
|
asNumber: false,
|
|
|
|
|
|
dir: 'ltr',
|
2026-11-08 16:53:45 +03:30
|
|
|
|
addonAfter: '',
|
2026-09-05 02:13:39 +03:30
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const text = ref('');
|
|
|
|
|
|
const focused = ref(false);
|
2026-10-18 19:39:59 +03:30
|
|
|
|
const inputRef = ref<{ input?: HTMLInputElement } | HTMLInputElement | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
function nativeInput(): HTMLInputElement | null {
|
|
|
|
|
|
const el = inputRef.value;
|
|
|
|
|
|
if (!el) return null;
|
|
|
|
|
|
if (el instanceof HTMLInputElement) return el;
|
|
|
|
|
|
return el.input ?? null;
|
|
|
|
|
|
}
|
2026-09-05 02:13:39 +03:30
|
|
|
|
|
|
|
|
|
|
function readModelNumber(): number | null {
|
|
|
|
|
|
if (model.value === '' || model.value == null) return null;
|
|
|
|
|
|
const n = typeof model.value === 'number' ? model.value : Number(model.value);
|
|
|
|
|
|
return Number.isFinite(n) ? n : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function writeModel(n: number | null) {
|
|
|
|
|
|
if (props.asNumber || typeof model.value === 'number') {
|
|
|
|
|
|
model.value = n == null ? (props.asNumber ? 0 : null) : n;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
model.value = n == null ? '' : String(n);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function syncDisplayFromModel() {
|
|
|
|
|
|
if (focused.value) return;
|
|
|
|
|
|
const n = readModelNumber();
|
|
|
|
|
|
text.value = n == null ? '' : formatGroupedInput(n, props.decimals);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(model, syncDisplayFromModel, { immediate: true });
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => props.decimals,
|
|
|
|
|
|
() => syncDisplayFromModel(),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
function onFocus() {
|
|
|
|
|
|
focused.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onBlur() {
|
|
|
|
|
|
focused.value = false;
|
|
|
|
|
|
const n = parseMoneyNumber(text.value, props.decimals);
|
|
|
|
|
|
writeModel(n);
|
|
|
|
|
|
text.value = n == null ? '' : formatGroupedInput(n, props.decimals);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onInput(event: Event) {
|
|
|
|
|
|
const el = event.target as HTMLInputElement;
|
|
|
|
|
|
const raw = el.value;
|
|
|
|
|
|
const caret = el.selectionStart ?? raw.length;
|
|
|
|
|
|
const digitsBefore = countDigitChars(raw.slice(0, caret));
|
|
|
|
|
|
|
|
|
|
|
|
const endsWithSep = /[.\u066B]$/.test(normalizeSep(raw));
|
|
|
|
|
|
const n = parseMoneyNumber(raw, props.decimals);
|
|
|
|
|
|
|
|
|
|
|
|
if (n == null) {
|
|
|
|
|
|
const cleaned = raw.replace(/[^\d۰-۹٠-٩.\u066B٬,،\s-]/g, '');
|
|
|
|
|
|
text.value = cleaned;
|
|
|
|
|
|
writeModel(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let display = formatGroupedInput(n, props.decimals);
|
|
|
|
|
|
if (endsWithSep && props.decimals > 0 && Number.isInteger(n)) {
|
|
|
|
|
|
display += '٫';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
text.value = display;
|
|
|
|
|
|
writeModel(n);
|
|
|
|
|
|
|
|
|
|
|
|
await nextTick();
|
2026-10-18 19:39:59 +03:30
|
|
|
|
const input = nativeInput();
|
2026-09-05 02:13:39 +03:30
|
|
|
|
if (!input) return;
|
|
|
|
|
|
const nextCaret = caretIndexForDigitCount(display, digitsBefore);
|
|
|
|
|
|
input.setSelectionRange(nextCaret, nextCaret);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeSep(raw: string): string {
|
|
|
|
|
|
return raw.replace(/٫/g, '.');
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<a-input
|
2026-09-05 02:13:39 +03:30
|
|
|
|
:id="id"
|
|
|
|
|
|
ref="inputRef"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
inputmode="decimal"
|
|
|
|
|
|
autocomplete="off"
|
|
|
|
|
|
:dir="dir"
|
|
|
|
|
|
:placeholder="placeholder"
|
|
|
|
|
|
:disabled="disabled"
|
|
|
|
|
|
:readonly="readonly"
|
|
|
|
|
|
:value="text"
|
2026-11-08 16:53:45 +03:30
|
|
|
|
:addon-after="addonAfter || undefined"
|
2026-10-18 19:39:59 +03:30
|
|
|
|
style="width: 100%; font-variant-numeric: tabular-nums"
|
2026-09-05 02:13:39 +03:30
|
|
|
|
@focus="onFocus"
|
|
|
|
|
|
@blur="onBlur"
|
|
|
|
|
|
@input="onInput"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|