Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | 21x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 1x 1x 1x 6x 6x 6x 5x 1x 1x 5x 5x 5x 5x 5x 5x 3x 3x 5x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 1x 1x 7x 7x 7x 7x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 3x 3x 3x 3x 3x 3x 3x 3x 1x 1x 18x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x | <script setup lang="ts">
/**
* FzCurrencyInput Component
*
* Specialized currency input built on FzInput with number formatting, validation,
* and step controls. Formats values using Intl.NumberFormat with locale-aware separators.
* Supports min/max constraints, step quantization, and intelligent paste parsing
* that detects decimal/thousand separators automatically.
*
* @component
* @example
* <FzCurrencyInput label="Amount" v-model:amount="value" :min="0" :max="1000" />
*/
import { computed, nextTick, onMounted, ref, watch } from "vue";
import FzInput from "./FzInput.vue";
import { FzCurrencyInputProps } from "./types";
import { roundTo, useCurrency } from "@fiscozen/composables";
import { FzIcon } from "@fiscozen/icons";
const fzInputRef = ref<InstanceType<typeof FzInput>>();
const fzInputModel = ref();
const containerRef = computed(() => fzInputRef.value?.containerRef);
const inputRef = computed(() => fzInputRef.value?.inputRef);
const props = withDefaults(defineProps<FzCurrencyInputProps>(), {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const {
inputRef: currencyInputRef,
setValue,
emitAmount,
parse,
format,
} = useCurrency({
minimumFractionDigits: props.minimumFractionDigits,
maximumFractionDigits: props.maximumFractionDigits,
min: props.min,
max: props.max,
step: props.step,
});
defineEmits(["update:amount"]);
/**
* Handles paste events with intelligent separator detection
*
* Parses pasted text by detecting decimal and thousand separators using heuristics:
* - Multiple different separators: rightmost is decimal
* - Multiple same separators: thousand separator
* - Single separator with <3 digits after: decimal separator
* - Single separator with 3+ digits after: ambiguous, uses default formatting
*
* Normalizes to dot decimal separator before parsing, then formats using locale settings.
*/
const onPaste = (e: ClipboardEvent) => {
e.preventDefault();
if (props.readonly) {
return;
}
let rawPastedText;
if (e.clipboardData && e.clipboardData.getData) {
rawPastedText = e.clipboardData.getData("text/plain");
} else {
throw "invalid paste value";
}
// Fix for firefox paste handling on `contenteditable` elements where `e.target` is the text node, not the element
let eventTarget;
if ((!e.target as any)?.tagName) {
eventTarget = (e as any).explicitOriginalTarget;
} else {
eventTarget = e.target;
}
let isNegative = rawPastedText.slice(0, 1) === "-";
const separatorRegex = /[,.]/g;
const separators: string[] = [...rawPastedText.matchAll(separatorRegex)].map(
(regexRes) => regexRes[0]
);
const uniqueSeparators = new Set(separators);
let decimalSeparator = ".";
let thousandSeparator = "";
let unknownSeparator;
// case 1: there are 2 different separators pasted, therefore we can assume the rightmost is the decimal separator
if (uniqueSeparators.size > 1) {
decimalSeparator = separators[separators.length - 1];
thousandSeparator = separators[0];
}
// case 2: there are multiple instances of the same separator, therefore it must be the thousand separator
if (uniqueSeparators.size === 1) {
if (separators.length > 1) {
thousandSeparator = separators[0];
}
// case 3: there is only one instance of a separator with < 3 digits afterwards (must be decimal separator)
unknownSeparator = separators[0];
const splitted = rawPastedText.split(unknownSeparator);
if (splitted[1].length !== 3) {
decimalSeparator = unknownSeparator;
}
}
// case 3: there is only one instance of a separator with 3 digits afterwards. Here we cannot make assumptions
// we will format based on settings
//@ts-ignore
let safeText = rawPastedText.replaceAll(thousandSeparator, "").trim();
safeText = safeText.replaceAll(decimalSeparator, ".").trim();
const safeNum = parse(safeText);
safeText = format(safeNum);
setValue(safeText);
emitAmount(safeNum);
};
onMounted(() => {
currencyInputRef.value = inputRef.value;
nextTick(() => {
fzInputModel.value = inputRef.value?.value;
});
});
const model = defineModel<number>("amount");
/**
* Increments or decrements value by step amount
*
* When forceStep is true, rounds current value to nearest step before applying increment.
* Formats result using locale settings and updates both display and model value.
*/
const stepUpDown = (amount: number) => {
if (!props.step) {
return;
}
const value = model.value || 0;
let stepVal = props.forceStep ? roundTo(props.step, value) : value;
stepVal += amount;
const safeText = format(stepVal);
setValue(safeText);
emitAmount(stepVal);
};
watch(model, (newVal) => {
fzInputModel.value = newVal;
});
defineExpose({
inputRef,
containerRef,
});
</script>
<template>
<FzInput
ref="fzInputRef"
v-bind="props"
:modelValue="fzInputModel"
type="text"
@paste="onPaste"
>
<template #right-icon v-if="step">
<div class="flex flex-col justify-between items-center">
<FzIcon
name="angle-up"
size="xs"
class="fz__currencyinput__arrowup cursor-pointer"
@click="stepUpDown(step)"
></FzIcon>
<FzIcon
name="angle-down"
size="xs"
class="fz__currencyinput__arrowdown cursor-pointer"
@click="stepUpDown(-step)"
></FzIcon>
</div>
</template>
<template #label>
<slot name="label"></slot>
</template>
</FzInput>
</template>
|