vueless 1.5.0 → 1.5.1-beta.1

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.1",
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",
@@ -851,6 +851,36 @@ function onBodyDoubleClick(event: MouseEvent) {
851
851
  onDoubleClickRow(rowData);
852
852
  }
853
853
 
854
+ // Cells are always clipped (`truncate`), so show the full value in a native tooltip when it is.
855
+ const OVERFLOW_THRESHOLD_PX = 1;
856
+
857
+ function isElementOverflown(element: HTMLElement) {
858
+ return (
859
+ element.scrollWidth - element.clientWidth > OVERFLOW_THRESHOLD_PX ||
860
+ element.scrollHeight - element.clientHeight > OVERFLOW_THRESHOLD_PX
861
+ );
862
+ }
863
+
864
+ /** Content may be clipped by the cell itself or by any wrapper inside it (slot content included). */
865
+ function isCellContentTruncated(cell: HTMLElement) {
866
+ return [cell, ...cell.querySelectorAll<HTMLElement>("*")].some(isElementOverflown);
867
+ }
868
+
869
+ function onBodyPointerOver(event: PointerEvent) {
870
+ const target = event.target as HTMLElement;
871
+ const cell = target.closest("td");
872
+ const previousCell = (event.relatedTarget as HTMLElement | null)?.closest("td");
873
+
874
+ // Moving between elements of the same cell needs no re-measure.
875
+ if (!cell || cell === previousCell) return;
876
+
877
+ if (isCellContentTruncated(cell)) {
878
+ cell.setAttribute("title", cell.textContent?.trim() || "");
879
+ } else {
880
+ cell.removeAttribute("title");
881
+ }
882
+ }
883
+
854
884
  // Detect double-tap on touch devices and dispatch a native `dblclick`,
855
885
  // which bubbles to the tbody and triggers onBodyDoubleClick.
856
886
  const DOUBLE_TAP_DELAY = 300;
@@ -1469,6 +1499,7 @@ const BodyRows = () =>
1469
1499
  @click="onBodyClick"
1470
1500
  @dblclick="onBodyDoubleClick"
1471
1501
  @touchend="onBodyTouchEnd"
1502
+ @pointerover="onBodyPointerOver"
1472
1503
  >
1473
1504
  <tr
1474
1505
  v-if="hasBeforeFirstRowSlot"
@@ -1,18 +1,7 @@
1
1
  <script setup lang="ts">
2
- import {
3
- Comment,
4
- Fragment,
5
- Text,
6
- computed,
7
- h,
8
- onMounted,
9
- useAttrs,
10
- useSlots,
11
- useTemplateRef,
12
- } from "vue";
2
+ import { Comment, Fragment, Text, computed, h, useAttrs, useSlots } from "vue";
13
3
 
14
4
  import { useUI } from "../composables/useUI";
15
- import { useMutationObserver } from "../composables/useMutationObserver";
16
5
 
17
6
  import { isEmptyValue } from "../utils/helper";
18
7
  import { cx } from "../utils/ui";
@@ -39,17 +28,6 @@ const props = defineProps<UTableRowProps>();
39
28
  const slots = useSlots();
40
29
  const attrs = useAttrs();
41
30
 
42
- const cellRef = useTemplateRef<HTMLDivElement>("cell");
43
-
44
- if (props.textEllipsis) {
45
- useMutationObserver(cellRef, setCellTitle, {
46
- subtree: true,
47
- childList: true,
48
- characterData: true,
49
- attributes: false,
50
- });
51
- }
52
-
53
31
  const toggleIconConfig = computed(() => {
54
32
  const nestedRow = props.row?.row;
55
33
  let isShown = false;
@@ -65,12 +43,6 @@ const toggleIconConfig = computed(() => {
65
43
  : props.attrs.bodyCellNestedExpandIconAttrs.value;
66
44
  });
67
45
 
68
- onMounted(() => {
69
- if (cellRef.value) {
70
- setElementTitle(cellRef.value);
71
- }
72
- });
73
-
74
46
  function getToggleIconName() {
75
47
  return props.isExpanded
76
48
  ? props.config?.defaults?.collapseIcon
@@ -110,30 +82,6 @@ function getNestedCheckboxShift() {
110
82
  return { transform: `translateX(${props.nestedLevel * LAST_NESTED_ROW_SHIFT_REM}rem)` };
111
83
  }
112
84
 
113
- function setCellTitle(mutations: MutationRecord[]) {
114
- mutations.forEach((mutation) => {
115
- const { target } = mutation;
116
-
117
- setElementTitle(target as HTMLElement);
118
- });
119
- }
120
-
121
- function isElementOverflown(element: HTMLElement) {
122
- return element.clientWidth < element.scrollWidth || element.clientHeight < element.scrollHeight;
123
- }
124
-
125
- function setElementTitle(element: HTMLElement) {
126
- const isOverflown = isElementOverflown(element);
127
-
128
- if (isOverflown) {
129
- element.setAttribute("title", String(element.textContent));
130
- }
131
-
132
- if (!isOverflown && element.hasAttribute("title")) {
133
- element.removeAttribute("title");
134
- }
135
- }
136
-
137
85
  function getRowClasses(row: Row) {
138
86
  const rowClasses = row?.class || "";
139
87
 
@@ -307,7 +255,6 @@ function renderCellContent(value: Cell, key: string, cellIndex: number): VNode |
307
255
  // Render cell wrapper with highlighted HTML
308
256
  if (shouldRenderCellWrapper(props.row, keyStr)) {
309
257
  return h("div", {
310
- ref: cellRef,
311
258
  ...props.attrs.bodyCellContentAttrs.value,
312
259
  class: cx([
313
260
  props.attrs.bodyCellContentAttrs.value.class,
@@ -380,7 +327,6 @@ function renderNestedFirstCell(value: Cell, key: string, cellIndex: number): VNo
380
327
  if (shouldRenderCellWrapper(props.row, keyStr)) {
381
328
  return [
382
329
  h("div", {
383
- ref: cellRef,
384
330
  ...props.attrs.bodyCellContentAttrs.value,
385
331
  class: cx([
386
332
  props.attrs.bodyCellContentAttrs.value.class,
@@ -84,6 +84,11 @@ describe("UTable.vue", () => {
84
84
  });
85
85
  }
86
86
 
87
+ function setElementSize(element: Element, size: { clientWidth: number; scrollWidth: number }) {
88
+ Object.defineProperty(element, "clientWidth", { value: size.clientWidth, configurable: true });
89
+ Object.defineProperty(element, "scrollWidth", { value: size.scrollWidth, configurable: true });
90
+ }
91
+
87
92
  describe("Props", () => {
88
93
  it("Columns – renders table headers correctly", () => {
89
94
  const component = mountUTable(getDefaultProps());
@@ -1054,6 +1059,48 @@ describe("UTable.vue", () => {
1054
1059
  });
1055
1060
  });
1056
1061
 
1062
+ describe("Cell Title", () => {
1063
+ it("Cell Title – sets title with the full value when cell content is truncated", async () => {
1064
+ const component = mountUTable(getDefaultProps());
1065
+
1066
+ const cell = component.find("tbody td");
1067
+
1068
+ setElementSize(cell.element, { clientWidth: 100, scrollWidth: 200 });
1069
+
1070
+ await cell.trigger("pointerover");
1071
+
1072
+ expect(cell.attributes("title")).toBe("John Doe");
1073
+ });
1074
+
1075
+ it("Cell Title – removes title when cell content is not truncated", async () => {
1076
+ const component = mountUTable(getDefaultProps());
1077
+
1078
+ const cell = component.find("tbody td");
1079
+
1080
+ cell.element.setAttribute("title", "John Doe");
1081
+ setElementSize(cell.element, { clientWidth: 200, scrollWidth: 200 });
1082
+
1083
+ await cell.trigger("pointerover");
1084
+
1085
+ expect(cell.attributes("title")).toBeUndefined();
1086
+ });
1087
+
1088
+ it("Cell Title – skips re-measure when moving within the same cell", async () => {
1089
+ const component = mountUTable(getDefaultProps());
1090
+
1091
+ const cell = component.find("tbody td");
1092
+
1093
+ setElementSize(cell.element, { clientWidth: 100, scrollWidth: 200 });
1094
+
1095
+ cell.element.dispatchEvent(
1096
+ new MouseEvent("pointerover", { bubbles: true, relatedTarget: cell.element }),
1097
+ );
1098
+ await nextTick();
1099
+
1100
+ expect(cell.attributes("title")).toBeUndefined();
1101
+ });
1102
+ });
1103
+
1057
1104
  describe("Virtual Scroll with Search", () => {
1058
1105
  it("Virtual Scroll with Search – renders only visible rows with search", async () => {
1059
1106
  const manyRows = Array.from({ length: 100 }, (_, i) => ({
@@ -383,61 +383,6 @@ describe("UTableRow.vue", () => {
383
383
  });
384
384
 
385
385
  describe("Functionality", () => {
386
- it("Element Title – sets title attribute when element is overflown", async () => {
387
- // Mock element properties to simulate overflow
388
- const mockElement = {
389
- clientWidth: 100,
390
- scrollWidth: 200,
391
- clientHeight: 50,
392
- scrollHeight: 50,
393
- textContent: "Very long text that overflows",
394
- setAttribute: vi.fn(),
395
- removeAttribute: vi.fn(),
396
- hasAttribute: vi.fn().mockReturnValue(false),
397
- };
398
-
399
- const component = mount(UTableRow, {
400
- props: getDefaultProps(),
401
- });
402
-
403
- // Access the component's internal methods
404
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
405
- const vm = component.vm as any;
406
-
407
- vm.setElementTitle(mockElement);
408
-
409
- expect(mockElement.setAttribute).toHaveBeenCalledWith(
410
- "title",
411
- "Very long text that overflows",
412
- );
413
- });
414
-
415
- it("Element Title – removes title attribute when element is not overflown", async () => {
416
- // Mock element properties to simulate no overflow
417
- const mockElement = {
418
- clientWidth: 200,
419
- scrollWidth: 100,
420
- clientHeight: 50,
421
- scrollHeight: 50,
422
- textContent: "Short text",
423
- setAttribute: vi.fn(),
424
- removeAttribute: vi.fn(),
425
- hasAttribute: vi.fn().mockReturnValue(true),
426
- };
427
-
428
- const component = mount(UTableRow, {
429
- props: getDefaultProps(),
430
- });
431
-
432
- // Access the component's internal methods
433
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
434
- const vm = component.vm as any;
435
-
436
- vm.setElementTitle(mockElement);
437
-
438
- expect(mockElement.removeAttribute).toHaveBeenCalledWith("title");
439
- });
440
-
441
386
  it("Icon Width – calculates correct icon width", async () => {
442
387
  const expandableRow: FlatRow = {
443
388
  ...defaultRow,
@@ -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;
@@ -7,9 +7,9 @@ export default /*tw*/ {
7
7
  [&_a:not([class]):hover]:no-underline
8
8
  [&_ul]:font-normal [&_ol]:font-normal
9
9
  [&_ul]:leading-normal [&_ol]:leading-normal
10
- [&_ul]:list-inside [&_ol]:list-inside
10
+ [&_ul]:list-outside [&_ol]:list-outside
11
11
  [&_ul]:list-disc [&_ol]:list-decimal
12
- [&_ul]:ml-2 [&_ol]:ml-2
12
+ [&_ul]:ps-4 [&_ol]:ps-4
13
13
  `,
14
14
  variants: {
15
15
  variant: {