@fiscozen/input 3.4.3 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi } from "vitest";
2
2
  import { mount } from "@vue/test-utils";
3
3
  import { FzInput } from "..";
4
4
 
@@ -2044,4 +2044,746 @@ describe("FzInput", () => {
2044
2044
  expect(errAlert.attributes("style") || "").not.toMatch(/width:/);
2045
2045
  });
2046
2046
  });
2047
+
2048
+ describe('type="currency"', () => {
2049
+ it("renders a text input with step controls", async () => {
2050
+ const wrapper = mount(FzInput, {
2051
+ props: {
2052
+ label: "Amount",
2053
+ type: "currency",
2054
+ },
2055
+ slots: {},
2056
+ });
2057
+
2058
+ expect(wrapper.find("input").attributes("type")).toBe("text");
2059
+ expect(wrapper.find(".fz__input__arrowup").exists()).toBe(true);
2060
+ expect(wrapper.find(".fz__input__arrowdown").exists()).toBe(true);
2061
+ // Retro-compatible class names kept for FzCurrencyInput consumers
2062
+ expect(wrapper.find(".fz__currencyinput__arrowup").exists()).toBe(true);
2063
+ expect(wrapper.find(".fz__currencyinput__arrowdown").exists()).toBe(true);
2064
+ });
2065
+
2066
+ it("formats the initial numeric v-model for display", async () => {
2067
+ const wrapper = mount(FzInput, {
2068
+ props: {
2069
+ label: "Amount",
2070
+ type: "currency",
2071
+ modelValue: 1234.56,
2072
+ },
2073
+ slots: {},
2074
+ });
2075
+
2076
+ await wrapper.vm.$nextTick();
2077
+ expect(wrapper.find("input").element.value).toBe("1.234,56");
2078
+ });
2079
+
2080
+ it("emits numbers while typing and formats on blur", async () => {
2081
+ let modelValue: number | undefined = undefined;
2082
+ const wrapper = mount(FzInput, {
2083
+ props: {
2084
+ label: "Amount",
2085
+ type: "currency",
2086
+ modelValue,
2087
+ "onUpdate:modelValue": (value: number | null | undefined) => {
2088
+ modelValue = value as number | undefined;
2089
+ wrapper.setProps({ modelValue });
2090
+ },
2091
+ },
2092
+ slots: {},
2093
+ });
2094
+
2095
+ const inputElement = wrapper.find("input");
2096
+ await inputElement.trigger("focus");
2097
+ await inputElement.setValue("123,4");
2098
+ expect(modelValue).toBe(123.4);
2099
+
2100
+ await inputElement.trigger("blur");
2101
+ await wrapper.vm.$nextTick();
2102
+ expect(inputElement.element.value).toBe("123,40");
2103
+ });
2104
+
2105
+ it("steps the value with the arrow controls clamping to min/max", async () => {
2106
+ let modelValue: number | undefined = 9.5;
2107
+ const wrapper = mount(FzInput, {
2108
+ props: {
2109
+ label: "Amount",
2110
+ type: "currency",
2111
+ modelValue,
2112
+ step: 1,
2113
+ max: 10,
2114
+ "onUpdate:modelValue": (value: number | null | undefined) => {
2115
+ modelValue = value as number | undefined;
2116
+ wrapper.setProps({ modelValue });
2117
+ },
2118
+ },
2119
+ slots: {},
2120
+ });
2121
+
2122
+ await wrapper.find(".fz__input__arrowup").trigger("click");
2123
+ expect(modelValue).toBe(10);
2124
+
2125
+ await wrapper.find(".fz__input__arrowdown").trigger("click");
2126
+ await wrapper.find(".fz__input__arrowdown").trigger("click");
2127
+ expect(modelValue).toBe(8);
2128
+ });
2129
+
2130
+ it("clears to the configured empty value and emits fzinput:clear", async () => {
2131
+ let modelValue: number | null | undefined = 42;
2132
+ const wrapper = mount(FzInput, {
2133
+ props: {
2134
+ label: "Amount",
2135
+ type: "currency",
2136
+ clearable: true,
2137
+ nullOnEmpty: true,
2138
+ modelValue,
2139
+ "onUpdate:modelValue": (value: number | null | undefined) => {
2140
+ modelValue = value;
2141
+ wrapper.setProps({ modelValue });
2142
+ },
2143
+ },
2144
+ slots: {},
2145
+ });
2146
+
2147
+ await wrapper.vm.$nextTick();
2148
+ await wrapper.find('[aria-label="Cancella"]').trigger("click");
2149
+
2150
+ expect(modelValue).toBe(null);
2151
+ expect(wrapper.emitted("fzinput:clear")).toBeTruthy();
2152
+ expect(wrapper.find("input").element.value).toBe("");
2153
+ });
2154
+
2155
+ it("applies custom aria-labels to the step controls", async () => {
2156
+ const wrapper = mount(FzInput, {
2157
+ props: {
2158
+ label: "Amount",
2159
+ type: "currency",
2160
+ stepUpAriaLabel: "Su",
2161
+ stepDownAriaLabel: "Giù",
2162
+ },
2163
+ slots: {},
2164
+ });
2165
+
2166
+ expect(wrapper.find(".fz__input__arrowup").attributes("aria-label")).toBe(
2167
+ "Su",
2168
+ );
2169
+ expect(
2170
+ wrapper.find(".fz__input__arrowdown").attributes("aria-label"),
2171
+ ).toBe("Giù");
2172
+ });
2173
+
2174
+ describe("Paste handling", () => {
2175
+ /**
2176
+ * Mounts a currency-mode FzInput wired with the usual v-model harness.
2177
+ * Returns the wrapper and an accessor for the latest model value.
2178
+ */
2179
+ const mountCurrency = (
2180
+ props: Record<string, unknown> = {},
2181
+ attrs: Record<string, unknown> = {},
2182
+ ) => {
2183
+ let modelValue = props.modelValue as number | null | undefined;
2184
+ const wrapper = mount(FzInput, {
2185
+ props: {
2186
+ label: "Amount",
2187
+ type: "currency",
2188
+ ...props,
2189
+ "onUpdate:modelValue": (value: number | null | undefined) => {
2190
+ modelValue = value;
2191
+ wrapper.setProps({ modelValue });
2192
+ },
2193
+ },
2194
+ attrs,
2195
+ slots: {},
2196
+ });
2197
+ return { wrapper, model: () => modelValue };
2198
+ };
2199
+
2200
+ /** Dispatches a paste event carrying `text` as the plain-text clipboard payload */
2201
+ const paste = async (
2202
+ wrapper: ReturnType<typeof mountCurrency>["wrapper"],
2203
+ text: string,
2204
+ ) => {
2205
+ await wrapper.find("input").trigger("paste", {
2206
+ clipboardData: { getData: () => text },
2207
+ });
2208
+ await wrapper.vm.$nextTick();
2209
+ };
2210
+
2211
+ describe("accepted clipboard formats", () => {
2212
+ it.each([
2213
+ {
2214
+ label: "Italian format with grouping",
2215
+ text: "1.234,56",
2216
+ expected: 1234.56,
2217
+ },
2218
+ {
2219
+ label: "comma decimal without grouping",
2220
+ text: "1234,56",
2221
+ expected: 1234.56,
2222
+ },
2223
+ {
2224
+ label: "multiple thousand groups",
2225
+ text: "1.234.567,89",
2226
+ expected: 1234567.89,
2227
+ },
2228
+ { label: "plain integer", text: "1234", expected: 1234 },
2229
+ {
2230
+ label: "dot as decimal separator (no comma)",
2231
+ text: "1234.56",
2232
+ expected: 1234.56,
2233
+ },
2234
+ {
2235
+ label: "negative Italian format",
2236
+ text: "-1.234,56",
2237
+ expected: -1234.56,
2238
+ },
2239
+ { label: "zero", text: "0", expected: 0 },
2240
+ {
2241
+ label: "surrounding whitespace and newline",
2242
+ text: " 1234,56\n",
2243
+ expected: 1234.56,
2244
+ },
2245
+ {
2246
+ label: "scientific notation (parseFloat)",
2247
+ text: "1e3",
2248
+ expected: 1000,
2249
+ },
2250
+ {
2251
+ label: "numeric prefix of mixed text (parseFloat)",
2252
+ text: "123abc",
2253
+ expected: 123,
2254
+ },
2255
+ ])("parses $label: $text", async ({ text, expected }) => {
2256
+ const { wrapper, model } = mountCurrency();
2257
+
2258
+ await paste(wrapper, text);
2259
+
2260
+ expect(model()).toBe(expected);
2261
+ });
2262
+ });
2263
+
2264
+ describe("ignored clipboard content", () => {
2265
+ it.each([
2266
+ { label: "non-numeric text", text: "abc" },
2267
+ { label: "currency symbol prefix", text: "€ 1.234,56" },
2268
+ { label: "lone minus sign", text: "-" },
2269
+ { label: "whitespace only", text: " " },
2270
+ { label: "empty string", text: "" },
2271
+ { label: "Infinity (non-finite)", text: "Infinity" },
2272
+ ])(
2273
+ "ignores $label: model and display stay unchanged",
2274
+ async ({ text }) => {
2275
+ const { wrapper, model } = mountCurrency({ modelValue: 99 });
2276
+ const input = wrapper.find("input");
2277
+ await input.trigger("focus");
2278
+ const displayBefore = input.element.value;
2279
+
2280
+ await paste(wrapper, text);
2281
+
2282
+ expect(model()).toBe(99);
2283
+ expect(input.element.value).toBe(displayBefore);
2284
+ },
2285
+ );
2286
+
2287
+ it("ignores paste when clipboardData is unavailable", async () => {
2288
+ const { wrapper, model } = mountCurrency({ modelValue: 42 });
2289
+
2290
+ await wrapper.find("input").trigger("paste");
2291
+ await wrapper.vm.$nextTick();
2292
+
2293
+ expect(model()).toBe(42);
2294
+ });
2295
+
2296
+ it("does not treat an invalid paste as empty input (zeroOnEmpty untouched)", async () => {
2297
+ const { wrapper, model } = mountCurrency({
2298
+ modelValue: 5,
2299
+ zeroOnEmpty: true,
2300
+ });
2301
+
2302
+ await paste(wrapper, "abc");
2303
+
2304
+ expect(model()).toBe(5);
2305
+ });
2306
+ });
2307
+
2308
+ describe("display value and v-model interplay", () => {
2309
+ it("shows the normalized raw value while focused and formats on blur", async () => {
2310
+ const { wrapper, model } = mountCurrency();
2311
+ const input = wrapper.find("input");
2312
+ await input.trigger("focus");
2313
+
2314
+ await paste(wrapper, "1.234,56");
2315
+
2316
+ expect(model()).toBe(1234.56);
2317
+ // While focused the pasted value is shown raw (no grouping), ready for editing
2318
+ expect(input.element.value).toBe("1234,56");
2319
+
2320
+ await input.trigger("blur");
2321
+ await wrapper.vm.$nextTick();
2322
+ expect(input.element.value).toBe("1.234,56");
2323
+ });
2324
+
2325
+ it("replaces the previous value entirely", async () => {
2326
+ const { wrapper, model } = mountCurrency({ modelValue: 99 });
2327
+ const input = wrapper.find("input");
2328
+ await input.trigger("focus");
2329
+
2330
+ await paste(wrapper, "55,5");
2331
+
2332
+ expect(model()).toBe(55.5);
2333
+ expect(input.element.value).toBe("55,5");
2334
+ });
2335
+
2336
+ it("truncates (not rounds) decimals to maximumFractionDigits", async () => {
2337
+ const { wrapper, model } = mountCurrency();
2338
+
2339
+ await paste(wrapper, "1234,5699");
2340
+
2341
+ expect(model()).toBe(1234.56);
2342
+ });
2343
+
2344
+ it("honors a custom maximumFractionDigits", async () => {
2345
+ const { wrapper, model } = mountCurrency({
2346
+ maximumFractionDigits: 4,
2347
+ });
2348
+
2349
+ await paste(wrapper, "1,23456");
2350
+
2351
+ expect(model()).toBe(1.2345);
2352
+ });
2353
+
2354
+ it("does not clamp to min/max on paste; clamping happens on blur", async () => {
2355
+ const { wrapper, model } = mountCurrency({ min: 0, max: 100 });
2356
+ const input = wrapper.find("input");
2357
+ await input.trigger("focus");
2358
+
2359
+ await paste(wrapper, "200");
2360
+ expect(model()).toBe(200);
2361
+
2362
+ await input.trigger("blur");
2363
+ await wrapper.vm.$nextTick();
2364
+ expect(model()).toBe(100);
2365
+ expect(input.element.value).toBe("100,00");
2366
+ });
2367
+ });
2368
+
2369
+ describe("readonly and disabled guards", () => {
2370
+ it("ignores paste when readonly and leaves the native event untouched", async () => {
2371
+ const onPaste = vi.fn();
2372
+ const { wrapper, model } = mountCurrency(
2373
+ { modelValue: 10, readonly: true },
2374
+ { onPaste },
2375
+ );
2376
+
2377
+ await paste(wrapper, "1234,56");
2378
+
2379
+ expect(model()).toBe(10);
2380
+ // The guard returns before preventDefault: native flow is left alone
2381
+ expect(onPaste.mock.calls[0][0].defaultPrevented).toBe(false);
2382
+ });
2383
+
2384
+ it("ignores paste when disabled", async () => {
2385
+ const { wrapper, model } = mountCurrency({
2386
+ modelValue: 10,
2387
+ disabled: true,
2388
+ });
2389
+
2390
+ // Manual dispatch: test-utils' trigger() skips events on disabled
2391
+ // elements, which would leave the component guard unexercised
2392
+ const event = new Event("paste", { bubbles: true, cancelable: true });
2393
+ Object.defineProperty(event, "clipboardData", {
2394
+ value: { getData: () => "1234,56" },
2395
+ });
2396
+ wrapper.find("input").element.dispatchEvent(event);
2397
+ await wrapper.vm.$nextTick();
2398
+
2399
+ expect(model()).toBe(10);
2400
+ expect(event.defaultPrevented).toBe(false);
2401
+ });
2402
+ });
2403
+
2404
+ describe("event forwarding", () => {
2405
+ it("keeps notifying consumer @paste listeners in currency mode", async () => {
2406
+ const onPaste = vi.fn();
2407
+ const { wrapper, model } = mountCurrency({}, { onPaste });
2408
+
2409
+ await paste(wrapper, "1234,56");
2410
+
2411
+ expect(model()).toBe(1234.56);
2412
+ expect(onPaste).toHaveBeenCalledTimes(1);
2413
+ // Currency mode takes over the paste: default is prevented
2414
+ expect(onPaste.mock.calls[0][0].defaultPrevented).toBe(true);
2415
+ });
2416
+
2417
+ it('does not intercept paste for type="text"', async () => {
2418
+ const onPaste = vi.fn();
2419
+ let modelValue: string | undefined = "hello";
2420
+ const wrapper = mount(FzInput, {
2421
+ props: {
2422
+ label: "Label",
2423
+ type: "text",
2424
+ modelValue,
2425
+ "onUpdate:modelValue": (value: string | undefined) => {
2426
+ modelValue = value;
2427
+ wrapper.setProps({ modelValue });
2428
+ },
2429
+ },
2430
+ attrs: { onPaste },
2431
+ slots: {},
2432
+ });
2433
+
2434
+ await wrapper.find("input").trigger("paste", {
2435
+ clipboardData: { getData: () => "1.234,56" },
2436
+ });
2437
+ await wrapper.vm.$nextTick();
2438
+
2439
+ // The internal paste handler is a no-op for text inputs: the model is
2440
+ // not rewritten and the event reaches consumer listeners unprevented
2441
+ expect(modelValue).toBe("hello");
2442
+ expect(onPaste).toHaveBeenCalledTimes(1);
2443
+ expect(onPaste.mock.calls[0][0].defaultPrevented).toBe(false);
2444
+ });
2445
+ });
2446
+
2447
+ describe("known parse() limitations (documents current behavior)", () => {
2448
+ // These pin down how the shared parse() currently interprets ambiguous
2449
+ // clipboard content. They are NOT desired-behavior specs: if parse()
2450
+ // gets smarter about these formats, update the assertions deliberately.
2451
+
2452
+ it('reads English-grouped "1,234.56" with comma as the decimal separator', async () => {
2453
+ const { wrapper, model } = mountCurrency();
2454
+
2455
+ await paste(wrapper, "1,234.56");
2456
+
2457
+ // Dots stripped as grouping, first comma is the decimal: 1.23456 -> truncated
2458
+ expect(model()).toBe(1.23);
2459
+ });
2460
+
2461
+ it('reads Italian-grouped "1.234.567" (no decimals) as a dot-decimal number', async () => {
2462
+ const { wrapper, model } = mountCurrency();
2463
+
2464
+ await paste(wrapper, "1.234.567");
2465
+
2466
+ // No comma -> parseFloat stops at the second dot: 1.234 -> truncated
2467
+ expect(model()).toBe(1.23);
2468
+ });
2469
+
2470
+ it('reads ambiguous "1.234" as a decimal, not as Italian thousands', async () => {
2471
+ const { wrapper, model } = mountCurrency();
2472
+
2473
+ await paste(wrapper, "1.234");
2474
+
2475
+ expect(model()).toBe(1.23);
2476
+ });
2477
+
2478
+ it('reads space-grouped "12 000" as the leading number only', async () => {
2479
+ const { wrapper, model } = mountCurrency();
2480
+
2481
+ await paste(wrapper, "12 000");
2482
+
2483
+ // parseFloat stops at the space
2484
+ expect(model()).toBe(12);
2485
+ });
2486
+ });
2487
+ });
2488
+ });
2489
+
2490
+ describe('type="number" step controls', () => {
2491
+ it("renders step controls and hides the native spinners", async () => {
2492
+ const wrapper = mount(FzInput, {
2493
+ props: {
2494
+ label: "Quantity",
2495
+ type: "number",
2496
+ },
2497
+ slots: {},
2498
+ });
2499
+
2500
+ expect(wrapper.find("input").attributes("type")).toBe("number");
2501
+ expect(wrapper.find(".fz__input__arrowup").exists()).toBe(true);
2502
+ expect(wrapper.find(".fz__input__arrowdown").exists()).toBe(true);
2503
+ expect(wrapper.find("input").classes().join(" ")).toContain("appearance");
2504
+ });
2505
+
2506
+ it("does not render step controls for text inputs", async () => {
2507
+ const wrapper = mount(FzInput, {
2508
+ props: {
2509
+ label: "Label",
2510
+ },
2511
+ slots: {},
2512
+ });
2513
+
2514
+ expect(wrapper.find(".fz__input__arrowup").exists()).toBe(false);
2515
+ expect(wrapper.find(".fz__input__arrowdown").exists()).toBe(false);
2516
+ });
2517
+
2518
+ it("steps the value with the arrow controls respecting min/max/step", async () => {
2519
+ // Note: Vue's v-model on native number inputs casts values to number at
2520
+ // runtime, so the emitted values are numbers (pre-existing FzInput behavior).
2521
+ let modelValue: string | number = "4";
2522
+ const wrapper = mount(FzInput, {
2523
+ props: {
2524
+ label: "Quantity",
2525
+ type: "number",
2526
+ step: 2,
2527
+ min: 0,
2528
+ max: 6,
2529
+ modelValue,
2530
+ "onUpdate:modelValue": (value: string | number | undefined) => {
2531
+ modelValue = value ?? "";
2532
+ wrapper.setProps({ modelValue });
2533
+ },
2534
+ },
2535
+ slots: {},
2536
+ });
2537
+
2538
+ await wrapper.find(".fz__input__arrowup").trigger("click");
2539
+ expect(modelValue).toBe(6);
2540
+
2541
+ // Already at max: stays at 6
2542
+ await wrapper.find(".fz__input__arrowup").trigger("click");
2543
+ expect(modelValue).toBe(6);
2544
+
2545
+ await wrapper.find(".fz__input__arrowdown").trigger("click");
2546
+ expect(modelValue).toBe(4);
2547
+ });
2548
+
2549
+ it("emits default aria-labels based on the step value", async () => {
2550
+ const wrapper = mount(FzInput, {
2551
+ props: {
2552
+ label: "Quantity",
2553
+ type: "number",
2554
+ step: 5,
2555
+ },
2556
+ slots: {},
2557
+ });
2558
+
2559
+ expect(wrapper.find(".fz__input__arrowup").attributes("aria-label")).toBe(
2560
+ "Incrementa di 5",
2561
+ );
2562
+ expect(
2563
+ wrapper.find(".fz__input__arrowdown").attributes("aria-label"),
2564
+ ).toBe("Decrementa di 5");
2565
+ });
2566
+
2567
+ it("does not step when disabled or readonly", async () => {
2568
+ let modelValue = "4";
2569
+ const wrapper = mount(FzInput, {
2570
+ props: {
2571
+ label: "Quantity",
2572
+ type: "number",
2573
+ disabled: true,
2574
+ modelValue,
2575
+ "onUpdate:modelValue": (value: string | undefined) => {
2576
+ modelValue = value ?? "";
2577
+ wrapper.setProps({ modelValue });
2578
+ },
2579
+ },
2580
+ slots: {},
2581
+ });
2582
+
2583
+ const arrowUp = wrapper.find(".fz__input__arrowup");
2584
+ expect(arrowUp.attributes("aria-disabled")).toBe("true");
2585
+ await arrowUp.trigger("click");
2586
+ expect(modelValue).toBe("4");
2587
+ });
2588
+ });
2589
+
2590
+ describe('type="number" paste rescue', () => {
2591
+ /** Mounts a number-mode FzInput wired with the usual v-model harness */
2592
+ const mountNumber = (
2593
+ props: Record<string, unknown> = {},
2594
+ attrs: Record<string, unknown> = {},
2595
+ ) => {
2596
+ let modelValue = props.modelValue as string | undefined;
2597
+ const wrapper = mount(FzInput, {
2598
+ props: {
2599
+ label: "Quantity",
2600
+ type: "number",
2601
+ ...props,
2602
+ "onUpdate:modelValue": (value: string | undefined) => {
2603
+ modelValue = value;
2604
+ wrapper.setProps({ modelValue });
2605
+ },
2606
+ },
2607
+ attrs,
2608
+ slots: {},
2609
+ });
2610
+ return { wrapper, model: () => modelValue };
2611
+ };
2612
+
2613
+ /**
2614
+ * Dispatches a paste event carrying `text` as the plain-text clipboard
2615
+ * payload. Returns the event so tests can inspect defaultPrevented
2616
+ * (manual dispatch: test-utils' trigger() skips disabled elements).
2617
+ */
2618
+ const paste = async (
2619
+ wrapper: ReturnType<typeof mountNumber>["wrapper"],
2620
+ text: string,
2621
+ ) => {
2622
+ const event = new Event("paste", { bubbles: true, cancelable: true });
2623
+ Object.defineProperty(event, "clipboardData", {
2624
+ value: { getData: () => text },
2625
+ });
2626
+ wrapper.find("input").element.dispatchEvent(event);
2627
+ await wrapper.vm.$nextTick();
2628
+ return event;
2629
+ };
2630
+
2631
+ describe("natively-valid content is left to the browser", () => {
2632
+ it.each([
2633
+ { label: "dot decimal", text: "1234.56" },
2634
+ { label: "negative integer", text: "-12" },
2635
+ { label: "scientific notation", text: "1e3" },
2636
+ { label: "digit fragment (cursor insertion)", text: "34" },
2637
+ ])("$label: $text is not intercepted", async ({ text }) => {
2638
+ const { wrapper, model } = mountNumber({ modelValue: "7" });
2639
+
2640
+ const event = await paste(wrapper, text);
2641
+
2642
+ // Default not prevented: the browser performs its normal insertion
2643
+ // (jsdom has no default paste action, so the model stays as-is here)
2644
+ expect(event.defaultPrevented).toBe(false);
2645
+ expect(model()).toBe("7");
2646
+ });
2647
+
2648
+ it("ignores an empty clipboard without preventing default", async () => {
2649
+ const { wrapper, model } = mountNumber({ modelValue: "7" });
2650
+
2651
+ const event = await paste(wrapper, "");
2652
+
2653
+ expect(event.defaultPrevented).toBe(false);
2654
+ expect(model()).toBe("7");
2655
+ });
2656
+ });
2657
+
2658
+ describe("rescued content (native input would reject it)", () => {
2659
+ it.each([
2660
+ {
2661
+ label: "Italian format with grouping",
2662
+ text: "1.234,56",
2663
+ expected: "1234.56",
2664
+ },
2665
+ {
2666
+ label: "comma decimal without grouping",
2667
+ text: "1234,56",
2668
+ expected: "1234.56",
2669
+ },
2670
+ {
2671
+ label: "multiple thousand groups",
2672
+ text: "1.234.567,89",
2673
+ expected: "1234567.89",
2674
+ },
2675
+ {
2676
+ label: "negative Italian format",
2677
+ text: "-1.234,56",
2678
+ expected: "-1234.56",
2679
+ },
2680
+ {
2681
+ label: "padded spreadsheet copy",
2682
+ text: " 1234.56 \n",
2683
+ expected: "1234.56",
2684
+ },
2685
+ {
2686
+ label: "bare leading dot (outside native grammar)",
2687
+ text: ".5",
2688
+ expected: "0.5",
2689
+ },
2690
+ {
2691
+ label: "explicit plus sign (outside native grammar)",
2692
+ text: "+5",
2693
+ expected: "5",
2694
+ },
2695
+ ])(
2696
+ "$label: $text is normalized to $expected",
2697
+ async ({ text, expected }) => {
2698
+ const { wrapper, model } = mountNumber();
2699
+
2700
+ const event = await paste(wrapper, text);
2701
+
2702
+ expect(event.defaultPrevented).toBe(true);
2703
+ expect(model()).toBe(expected);
2704
+ },
2705
+ );
2706
+
2707
+ it("replaces the previous value entirely when rescuing", async () => {
2708
+ const { wrapper, model } = mountNumber({ modelValue: "100" });
2709
+
2710
+ await paste(wrapper, "1.234,56");
2711
+
2712
+ expect(model()).toBe("1234.56");
2713
+ expect(wrapper.find("input").element.value).toBe("1234.56");
2714
+ });
2715
+
2716
+ it("does not truncate decimals (unlike currency mode)", async () => {
2717
+ const { wrapper, model } = mountNumber();
2718
+
2719
+ await paste(wrapper, "1234,5699");
2720
+
2721
+ expect(model()).toBe("1234.5699");
2722
+ });
2723
+
2724
+ it("does not clamp to min/max (validation stays native)", async () => {
2725
+ const { wrapper, model } = mountNumber({ min: 0, max: 100 });
2726
+
2727
+ await paste(wrapper, "1.234,56");
2728
+ expect(model()).toBe("1234.56");
2729
+
2730
+ await wrapper.find("input").trigger("blur");
2731
+ await wrapper.vm.$nextTick();
2732
+ expect(model()).toBe("1234.56");
2733
+ });
2734
+
2735
+ it("keeps notifying consumer @paste listeners", async () => {
2736
+ const onPaste = vi.fn();
2737
+ const { wrapper, model } = mountNumber({}, { onPaste });
2738
+
2739
+ await paste(wrapper, "1234,56");
2740
+
2741
+ expect(model()).toBe("1234.56");
2742
+ expect(onPaste).toHaveBeenCalledTimes(1);
2743
+ });
2744
+ });
2745
+
2746
+ describe("ignored clipboard content", () => {
2747
+ it.each([
2748
+ { label: "non-numeric text", text: "abc" },
2749
+ { label: "currency symbol prefix", text: "€ 1.234,56" },
2750
+ { label: "Infinity (non-finite)", text: "Infinity" },
2751
+ ])("ignores $label: previous value is kept", async ({ text }) => {
2752
+ const { wrapper, model } = mountNumber({ modelValue: "99" });
2753
+
2754
+ const event = await paste(wrapper, text);
2755
+
2756
+ // Default is prevented (content is not natively valid either) but
2757
+ // the model keeps its previous value instead of being blanked out
2758
+ expect(event.defaultPrevented).toBe(true);
2759
+ expect(model()).toBe("99");
2760
+ });
2761
+ });
2762
+
2763
+ describe("readonly and disabled guards", () => {
2764
+ it("ignores paste when readonly and leaves the native event untouched", async () => {
2765
+ const { wrapper, model } = mountNumber({
2766
+ modelValue: "10",
2767
+ readonly: true,
2768
+ });
2769
+
2770
+ const event = await paste(wrapper, "1.234,56");
2771
+
2772
+ expect(event.defaultPrevented).toBe(false);
2773
+ expect(model()).toBe("10");
2774
+ });
2775
+
2776
+ it("ignores paste when disabled", async () => {
2777
+ const { wrapper, model } = mountNumber({
2778
+ modelValue: "10",
2779
+ disabled: true,
2780
+ });
2781
+
2782
+ const event = await paste(wrapper, "1.234,56");
2783
+
2784
+ expect(event.defaultPrevented).toBe(false);
2785
+ expect(model()).toBe("10");
2786
+ });
2787
+ });
2788
+ });
2047
2789
  });