vueless 1.5.0 → 1.5.1-beta.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vueless",
3
- "version": "1.5.0",
3
+ "version": "1.5.1-beta.0",
4
4
  "description": "Vue Styleless UI Component Library, powered by Tailwind CSS.",
5
5
  "author": "Johnny Grid <hello@vueless.com> (https://vueless.com)",
6
6
  "homepage": "https://vueless.com",
@@ -4,7 +4,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
4
  import UInputCounter from "../UInputCounter.vue";
5
5
  import UButton from "../../ui.button/UButton.vue";
6
6
  import UInputNumber from "../../ui.form-input-number/UInputNumber.vue";
7
- import UInput from "../../ui.form-input/UInput.vue";
8
7
 
9
8
  import type { Props } from "../types";
10
9
 
@@ -363,7 +362,11 @@ describe("UInputCounter.vue", () => {
363
362
 
364
363
  await flushPromises();
365
364
 
366
- await component.getComponent(UInputNumber).findComponent(UInput).trigger("keyup");
365
+ const input = component.get("input");
366
+
367
+ input.element.value = "43";
368
+ await input.trigger("input", { data: "3" });
369
+ await flushPromises();
367
370
 
368
371
  expect(component.emitted("update:modelValue")).toBeUndefined();
369
372
 
@@ -371,7 +374,7 @@ describe("UInputCounter.vue", () => {
371
374
  await flushPromises();
372
375
 
373
376
  expect(component.emitted("update:modelValue")).toBeDefined();
374
- expect(component.emitted("update:modelValue")![0][0]).toBe(initialValue);
377
+ expect(component.emitted("update:modelValue")![0][0]).toBe(43);
375
378
  });
376
379
  });
377
380
  });
@@ -58,14 +58,20 @@ const numberInputRef = useTemplateRef<InstanceType<typeof UInput>>("numberInput"
58
58
 
59
59
  const elementId = props.id || useId();
60
60
 
61
- const { formattedValue, rawValue, setValue } = useFormatNumber(elementId, () => ({
62
- minFractionDigits: props.minFractionDigits,
63
- maxFractionDigits: props.maxFractionDigits,
64
- decimalSeparator: props.decimalSeparator,
65
- thousandsSeparator: props.thousandsSeparator,
66
- positiveOnly: props.positiveOnly,
67
- prefix: props.prefix,
68
- }));
61
+ const input = computed(() => numberInputRef.value?.inputRef || null);
62
+
63
+ const { formattedValue, rawValue, setValue } = useFormatNumber(
64
+ input,
65
+ () => ({
66
+ minFractionDigits: props.minFractionDigits,
67
+ maxFractionDigits: props.maxFractionDigits,
68
+ decimalSeparator: props.decimalSeparator,
69
+ thousandsSeparator: props.thousandsSeparator,
70
+ positiveOnly: props.positiveOnly,
71
+ prefix: props.prefix,
72
+ }),
73
+ () => emit("update:modelValue", currentValue()),
74
+ );
69
75
 
70
76
  const localValue = computed({
71
77
  get: () => props.modelValue ?? "",
@@ -79,10 +85,6 @@ const localLabel = computed(() => {
79
85
  return `${props.label}${comma} ${currency}`.trim();
80
86
  });
81
87
 
82
- const input = computed(() => {
83
- return numberInputRef.value?.inputRef || null;
84
- });
85
-
86
88
  const stringLocalValue = computed(() => {
87
89
  if (Object.is(localValue.value, -0)) return "-0";
88
90
 
@@ -94,10 +96,17 @@ const stringLocalValue = computed(() => {
94
96
  : String(localValue.value);
95
97
  });
96
98
 
99
+ /** Compares by numeric value, so `"1"` and `"1.00"` match. */
100
+ function isSameNumber(a: string, b: string) {
101
+ if (a === b) return true;
102
+
103
+ return parseFloat(a) === parseFloat(b);
104
+ }
105
+
97
106
  watch(
98
107
  () => props.modelValue,
99
108
  () => {
100
- if (stringLocalValue.value !== String(rawValue.value)) {
109
+ if (!isSameNumber(stringLocalValue.value, String(rawValue.value))) {
101
110
  setValue(stringLocalValue.value);
102
111
  }
103
112
  },
@@ -109,11 +118,13 @@ onMounted(() => {
109
118
  }
110
119
  });
111
120
 
112
- function onKeyup(event: KeyboardEvent) {
113
- const numberValue = getFixedNumber(parseFloat(rawValue.value), props.maxFractionDigits || 10);
114
- const value = props.valueType === "number" ? numberValue : rawValue.value || "";
121
+ function currentValue() {
122
+ return props.valueType === "number"
123
+ ? getFixedNumber(parseFloat(rawValue.value), props.maxFractionDigits || 10)
124
+ : rawValue.value || "";
125
+ }
115
126
 
116
- emit("update:modelValue", value);
127
+ function onKeyup(event: KeyboardEvent) {
117
128
  emit("keyup", event);
118
129
  }
119
130
 
@@ -6,8 +6,11 @@ import UInput from "../../ui.form-input/UInput.vue";
6
6
  import ULabel from "../../ui.form-label/ULabel.vue";
7
7
  import UIcon from "../../ui.image-icon/UIcon.vue";
8
8
 
9
+ import type { DOMWrapper } from "@vue/test-utils";
9
10
  import type { Props } from "../types";
10
11
 
12
+ type InputWrapper = Omit<DOMWrapper<HTMLInputElement>, "exists">;
13
+
11
14
  describe("UInputNumber.vue", () => {
12
15
  describe("Props", () => {
13
16
  it("Model Value – set correct value with number type", async () => {
@@ -22,9 +25,7 @@ describe("UInputNumber.vue", () => {
22
25
 
23
26
  await flushPromises();
24
27
 
25
- await component.getComponent(UInput).trigger("keyup");
26
-
27
- expect(component.emitted("update:modelValue")![0][0]).toBe(initialValue);
28
+ expect(component.vm.rawValue).toBe("12345678.23");
28
29
  });
29
30
 
30
31
  it("Model Value – set correct value with string type", async () => {
@@ -39,9 +40,7 @@ describe("UInputNumber.vue", () => {
39
40
 
40
41
  await flushPromises();
41
42
 
42
- await component.getComponent(UInput).trigger("keyup");
43
-
44
- expect(component.emitted("update:modelValue")![0][0]).toBe(initialValue);
43
+ expect(component.vm.rawValue).toBe(initialValue);
45
44
  });
46
45
 
47
46
  it("Min Fraction Digit – set fraction digit when it was not provided", async () => {
@@ -488,4 +487,137 @@ describe("UInputNumber.vue", () => {
488
487
  expect(component.vm.input!.tagName).toBe("INPUT");
489
488
  });
490
489
  });
490
+
491
+ describe("Events", () => {
492
+ async function mountInput(props: Partial<Props> = {}, options = {}) {
493
+ const component = mount(UInputNumber, {
494
+ props: { modelValue: "", valueType: "string", ...props },
495
+ ...options,
496
+ });
497
+
498
+ await flushPromises();
499
+
500
+ return component;
501
+ }
502
+
503
+ async function typeInto(input: InputWrapper, value: string, data?: string) {
504
+ input.element.value = value;
505
+ await input.trigger("input", data ? { data } : {});
506
+ await flushPromises();
507
+ }
508
+
509
+ // Paste, drag-drop, autofill and IME commits produce no keyup, but must still reach the model.
510
+ it("Update Model Value – emits on input without a trailing keyup", async () => {
511
+ const component = await mountInput();
512
+
513
+ await typeInto(component.get("input"), "13");
514
+
515
+ expect(component.emitted("update:modelValue")!.at(-1)![0]).toBe("13");
516
+
517
+ component.unmount();
518
+ });
519
+
520
+ it("Update Model Value – emits a number-typed value without a trailing keyup", async () => {
521
+ const component = await mountInput({ valueType: "number" });
522
+
523
+ await typeInto(component.get("input"), "13");
524
+
525
+ expect(component.emitted("update:modelValue")!.at(-1)![0]).toBe(13);
526
+
527
+ component.unmount();
528
+ });
529
+
530
+ it("Update Model Value – emits 0 as a real value", async () => {
531
+ const component = await mountInput();
532
+
533
+ await typeInto(component.get("input"), "0");
534
+
535
+ expect(component.emitted("update:modelValue")!.at(-1)![0]).toBe("0");
536
+
537
+ component.unmount();
538
+ });
539
+
540
+ it("Update Model Value – emits once per change, not twice on keyup", async () => {
541
+ const component = await mountInput();
542
+ const input = component.get("input");
543
+
544
+ await typeInto(input, "13");
545
+ await input.trigger("keyup");
546
+
547
+ expect(component.emitted("update:modelValue")).toHaveLength(1);
548
+
549
+ component.unmount();
550
+ });
551
+
552
+ it("Update Model Value – stays silent when the model already matches the input", async () => {
553
+ const component = await mountInput({ modelValue: "13" });
554
+
555
+ await component.get("input").trigger("blur");
556
+
557
+ expect(component.emitted("update:modelValue")).toBeFalsy();
558
+
559
+ component.unmount();
560
+ });
561
+
562
+ it("Update Model Value – stays silent when the model pushes a value in", async () => {
563
+ const component = await mountInput({ modelValue: "13" });
564
+
565
+ await component.setProps({ modelValue: "42" });
566
+ await flushPromises();
567
+
568
+ expect(component.emitted("update:modelValue")).toBeFalsy();
569
+
570
+ component.unmount();
571
+ });
572
+
573
+ it("Formats input mounted inside a shadow root", async () => {
574
+ const host = document.createElement("div");
575
+ const shadowRoot = host.attachShadow({ mode: "open" });
576
+ const container = document.createElement("div");
577
+
578
+ shadowRoot.appendChild(container);
579
+ document.body.appendChild(host);
580
+
581
+ const component = await mountInput({}, { attachTo: container });
582
+ const input = component.get("input");
583
+
584
+ // Behind a shadow boundary, so a document lookup would leave the input unbound.
585
+ expect(document.getElementById(input.element.id)).toBeNull();
586
+
587
+ await typeInto(input, "42");
588
+
589
+ expect(component.emitted("update:modelValue")!.at(-1)![0]).toBe("42");
590
+
591
+ component.unmount();
592
+ host.remove();
593
+ });
594
+
595
+ it("Strips non-numeric characters from typed input", async () => {
596
+ const component = await mountInput();
597
+ const input = component.get("input");
598
+
599
+ for (const char of "13232fffffrege") {
600
+ await typeInto(input, input.element.value + char, char);
601
+ }
602
+
603
+ // Displayed with the default thousands separator; the model keeps the raw digits.
604
+ expect(input.element.value).toBe("13 232");
605
+ expect(component.emitted("update:modelValue")!.at(-1)![0]).toBe("13232");
606
+
607
+ component.unmount();
608
+ });
609
+
610
+ it("Stops listening to the input once unmounted", async () => {
611
+ const component = await mountInput();
612
+ const element = component.get("input").element;
613
+
614
+ component.unmount();
615
+
616
+ element.value = "77";
617
+ element.dispatchEvent(new Event("input"));
618
+ await flushPromises();
619
+
620
+ expect(component.emitted("update:modelValue")).toBeFalsy();
621
+ });
622
+ });
491
623
  });
@@ -4,15 +4,22 @@ import { getRawValue, getFormattedValue } from "./utilFormat";
4
4
 
5
5
  import { RAW_DECIMAL_MARK } from "./constants";
6
6
 
7
+ import type { MaybeRefOrGetter } from "vue";
7
8
  import type { FormatOptions } from "./types";
8
9
 
9
10
  const digitSet = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
10
11
  const comma = ",";
11
12
  const minus = "-";
12
13
 
14
+ /**
15
+ * Formats an input as a number. Takes the element itself, so inputs in a shadow root
16
+ * or detached at mount still bind, unlike a `document.getElementById` lookup.
17
+ * `onChange` fires for user input only, never for a programmatic `setValue`.
18
+ */
13
19
  export default function useFormatNumber(
14
- elementId: string = "",
20
+ inputSource: MaybeRefOrGetter<HTMLInputElement | null | undefined>,
15
21
  formatOptions: (() => FormatOptions) | FormatOptions,
22
+ onChange?: () => void,
16
23
  ) {
17
24
  let inputElement: HTMLInputElement | null = null;
18
25
 
@@ -31,22 +38,31 @@ export default function useFormatNumber(
31
38
  { deep: true },
32
39
  );
33
40
 
41
+ // Follow the element: it can appear after mount or be swapped out entirely.
42
+ watch(() => toValue(inputSource) ?? null, bindInput, { flush: "post" });
43
+
34
44
  onMounted(() => {
35
45
  validateOptions();
36
- inputElement = document.getElementById(elementId) as HTMLInputElement;
46
+ bindInput(toValue(inputSource) ?? null);
47
+ });
48
+
49
+ onBeforeUnmount(() => bindInput(null));
50
+
51
+ function bindInput(element: HTMLInputElement | null) {
52
+ if (element === inputElement) return;
37
53
 
38
54
  if (inputElement) {
39
- inputElement.addEventListener("input", onInput);
40
- inputElement.addEventListener("keydown", onKeydown);
55
+ inputElement.removeEventListener("input", onInput);
56
+ inputElement.removeEventListener("keydown", onKeydown);
41
57
  }
42
- });
43
58
 
44
- onBeforeUnmount(() => {
59
+ inputElement = element;
60
+
45
61
  if (inputElement) {
46
- inputElement.removeEventListener("input", onInput);
62
+ inputElement.addEventListener("input", onInput);
47
63
  inputElement.addEventListener("keydown", onKeydown);
48
64
  }
49
- });
65
+ }
50
66
 
51
67
  function validateOptions() {
52
68
  const warnMessages = [];
@@ -115,19 +131,30 @@ export default function useFormatNumber(
115
131
  }
116
132
 
117
133
  async function onInput(event: Event) {
118
- if (!event.target || !inputElement) return;
134
+ const previousRawValue = rawValue.value;
135
+
136
+ await handleInput(event);
137
+
138
+ if (rawValue.value !== previousRawValue) onChange?.();
139
+ }
140
+
141
+ async function handleInput(event: Event) {
142
+ if (!inputElement) return;
143
+
144
+ // Read `data` before awaiting — the event is spent once dispatch returns.
145
+ const eventData = (event as InputEvent).data || "";
119
146
 
120
147
  await nextTick();
121
148
 
122
- const cursorStart = inputElement.selectionStart || 0;
123
- const cursorEnd = inputElement.selectionEnd || 0;
149
+ // The bound element, not `event.target`, which may be released after the await.
150
+ const input = inputElement;
124
151
 
125
- const input = event.target as HTMLInputElement;
152
+ const cursorStart = input.selectionStart || 0;
153
+ const cursorEnd = input.selectionEnd || 0;
126
154
 
127
155
  let value = input.value || "";
128
156
 
129
157
  const prevCursorPosition = cursorEnd - 1;
130
- const eventData = (event as InputEvent).data || "";
131
158
 
132
159
  if (value === minus) {
133
160
  formattedValue.value = minus;