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 +1 -1
- package/ui.data-table/UTable.vue +31 -0
- package/ui.data-table/UTableRow.vue +1 -55
- package/ui.data-table/tests/UTable.test.ts +47 -0
- package/ui.data-table/tests/UTableRow.test.ts +0 -55
- package/ui.form-input-counter/tests/UInputCounter.test.ts +6 -3
- package/ui.form-input-number/UInputNumber.vue +28 -17
- package/ui.form-input-number/tests/UInputNumber.test.ts +138 -6
- package/ui.form-input-number/useFormatNumber.ts +40 -13
- package/ui.text-block/config.ts +2 -2
package/package.json
CHANGED
package/ui.data-table/UTable.vue
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
function currentValue() {
|
|
122
|
+
return props.valueType === "number"
|
|
123
|
+
? getFixedNumber(parseFloat(rawValue.value), props.maxFractionDigits || 10)
|
|
124
|
+
: rawValue.value || "";
|
|
125
|
+
}
|
|
115
126
|
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
40
|
-
inputElement.
|
|
55
|
+
inputElement.removeEventListener("input", onInput);
|
|
56
|
+
inputElement.removeEventListener("keydown", onKeydown);
|
|
41
57
|
}
|
|
42
|
-
});
|
|
43
58
|
|
|
44
|
-
|
|
59
|
+
inputElement = element;
|
|
60
|
+
|
|
45
61
|
if (inputElement) {
|
|
46
|
-
inputElement.
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
const
|
|
149
|
+
// The bound element, not `event.target`, which may be released after the await.
|
|
150
|
+
const input = inputElement;
|
|
124
151
|
|
|
125
|
-
const
|
|
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;
|
package/ui.text-block/config.ts
CHANGED
|
@@ -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-
|
|
10
|
+
[&_ul]:list-outside [&_ol]:list-outside
|
|
11
11
|
[&_ul]:list-disc [&_ol]:list-decimal
|
|
12
|
-
[&_ul]:
|
|
12
|
+
[&_ul]:ps-4 [&_ol]:ps-4
|
|
13
13
|
`,
|
|
14
14
|
variants: {
|
|
15
15
|
variant: {
|