vueless 1.3.9-beta.13 → 1.3.9-beta.14

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.3.9-beta.13",
3
+ "version": "1.3.9-beta.14",
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,11 +4,13 @@ import {
4
4
  ref,
5
5
  computed,
6
6
  watch,
7
+ watchEffect,
7
8
  useSlots,
8
9
  nextTick,
9
10
  onMounted,
10
11
  onBeforeUnmount,
11
12
  useTemplateRef,
13
+ h,
12
14
  } from "vue";
13
15
  import { isEqual } from "lodash-es";
14
16
 
@@ -30,7 +32,7 @@ import { normalizeColumns, mapRowColumns, getFlatRows, getRowChildrenIds } from
30
32
  import { PX_IN_REM } from "../constants";
31
33
  import { COMPONENT_NAME } from "./constants";
32
34
 
33
- import type { ComputedRef } from "vue";
35
+ import type { ComputedRef, VNode } from "vue";
34
36
  import type { Config as UDividerConfig } from "../ui.container-divider/types";
35
37
  import type {
36
38
  Cell,
@@ -43,6 +45,7 @@ import type {
43
45
  FlatRow,
44
46
  ColumnObject,
45
47
  SearchMatch,
48
+ UTableRowProps,
46
49
  } from "./types";
47
50
  import { StickySide } from "./types";
48
51
 
@@ -310,40 +313,135 @@ const totalSearchMatches = computed(() => {
310
313
  return count;
311
314
  });
312
315
 
313
- watch(totalSearchMatches, (count) => {
314
- emit("search", count);
316
+ const selectedRowIds = computed(() => {
317
+ return new Set(localSelectedRows.value.map((row) => row.id));
315
318
  });
316
319
 
317
- watch(activeMatch, (match) => {
318
- if (!match) return;
320
+ const isSelectedAllRows = computed(() => {
321
+ return localSelectedRows.value.length === flatTableRows.value.length;
322
+ });
319
323
 
320
- if (props.virtualScroll) {
321
- const rowIndex = visibleFlatRows.value.findIndex((row) => row.id === match.rowId);
324
+ // Conditional watchers - only create listeners when their associated features are enabled
325
+ // Using watchEffect to dynamically create/destroy watchers based on feature state
326
+
327
+ // Search-related watchers
328
+ // Note: totalSearchMatches watcher always runs to emit events (even when search is cleared)
329
+ watch(totalSearchMatches, (count) => emit("search", count), { flush: "post" });
330
+
331
+ // activeMatch watcher - only created when search is active
332
+ let stopActiveMatchWatch: (() => void) | null = null;
333
+
334
+ watchEffect(() => {
335
+ if (props.search) {
336
+ // Create watcher if it doesn't exist
337
+ if (!stopActiveMatchWatch) {
338
+ stopActiveMatchWatch = watch(
339
+ activeMatch,
340
+ (match) => {
341
+ if (!match) return;
342
+
343
+ if (props.virtualScroll) {
344
+ const rowIndex = visibleFlatRows.value.findIndex((row) => row.id === match.rowId);
345
+
346
+ if (rowIndex === -1) return;
347
+
348
+ virtualScroll.scrollToIndex(rowIndex);
349
+ } else {
350
+ scrollToRow(match.rowId);
351
+ }
352
+ },
353
+ { flush: "post" },
354
+ );
355
+ }
356
+ } else {
357
+ // Cleanup watcher if it exists
358
+ if (stopActiveMatchWatch) {
359
+ stopActiveMatchWatch();
360
+ stopActiveMatchWatch = null;
361
+ }
362
+ }
363
+ });
364
+
365
+ // Selection-related watchers (only created when selectable is enabled)
366
+ let stopLocalSelectedRowsWatch: (() => void) | null = null;
367
+ let stopSelectedRowsWatch: (() => void) | null = null;
368
+ let stopSelectAllWatch: (() => void) | null = null;
369
+
370
+ watchEffect(() => {
371
+ if (props.selectable) {
372
+ // Create watchers if they don't exist
373
+ if (!stopLocalSelectedRowsWatch) {
374
+ stopLocalSelectedRowsWatch = watch(localSelectedRows, onChangeLocalSelectedRows);
375
+ }
322
376
 
323
- if (rowIndex === -1) return;
377
+ if (!stopSelectedRowsWatch) {
378
+ stopSelectedRowsWatch = watch(() => props.selectedRows, onChangeSelectedRows, {
379
+ immediate: true,
380
+ });
381
+ }
324
382
 
325
- virtualScroll.scrollToIndex(rowIndex);
383
+ if (!stopSelectAllWatch) {
384
+ stopSelectAllWatch = watch(selectAll, onChangeSelectAll);
385
+ }
326
386
  } else {
327
- scrollToRow(match.rowId);
387
+ // Cleanup watchers if they exist
388
+ if (stopLocalSelectedRowsWatch) {
389
+ stopLocalSelectedRowsWatch();
390
+ stopLocalSelectedRowsWatch = null;
391
+ }
392
+
393
+ if (stopSelectedRowsWatch) {
394
+ stopSelectedRowsWatch();
395
+ stopSelectedRowsWatch = null;
396
+ }
397
+
398
+ if (stopSelectAllWatch) {
399
+ stopSelectAllWatch();
400
+ stopSelectAllWatch = null;
401
+ }
328
402
  }
329
403
  });
330
404
 
331
- const selectedRowIds = computed(() => {
332
- return new Set(localSelectedRows.value.map((row) => row.id));
333
- });
405
+ // Expansion watcher (always register as it's a core feature)
406
+ watch(() => props.expandedRows, onChangeExpandedRows, { immediate: true });
334
407
 
335
- const isSelectedAllRows = computed(() => {
336
- return localSelectedRows.value.length === flatTableRows.value.length;
408
+ // Sticky header watcher (only created when stickyHeader is enabled)
409
+ let stopHeaderStickyWatch: (() => void) | null = null;
410
+
411
+ watchEffect(() => {
412
+ if (props.stickyHeader) {
413
+ // Create watcher if it doesn't exist
414
+ if (!stopHeaderStickyWatch) {
415
+ stopHeaderStickyWatch = watch(isHeaderSticky, setHeaderCellWidth);
416
+ }
417
+ } else {
418
+ // Cleanup watcher if it exists
419
+ if (stopHeaderStickyWatch) {
420
+ stopHeaderStickyWatch();
421
+ stopHeaderStickyWatch = null;
422
+ }
423
+ }
337
424
  });
338
425
 
339
- watch(localSelectedRows, onChangeLocalSelectedRows);
340
- watch(() => props.selectedRows, onChangeSelectedRows, { immediate: true });
341
- watch(() => props.expandedRows, onChangeExpandedRows, { immediate: true });
342
- watch(selectAll, onChangeSelectAll);
343
- watch(isHeaderSticky, setHeaderCellWidth);
344
- watch(isFooterSticky, (newValue) =>
345
- newValue ? nextTick(setFooterCellWidth) : setFooterCellWidth(null),
346
- );
426
+ // Sticky footer watcher (only created when stickyFooter is enabled)
427
+ let stopFooterStickyWatch: (() => void) | null = null;
428
+
429
+ watchEffect(() => {
430
+ if (props.stickyFooter) {
431
+ // Create watcher if it doesn't exist
432
+ if (!stopFooterStickyWatch) {
433
+ stopFooterStickyWatch = watch(isFooterSticky, (newValue) =>
434
+ newValue ? nextTick(setFooterCellWidth) : setFooterCellWidth(null),
435
+ );
436
+ }
437
+ } else {
438
+ // Cleanup watcher if it exists
439
+ if (stopFooterStickyWatch) {
440
+ stopFooterStickyWatch();
441
+ stopFooterStickyWatch = null;
442
+ }
443
+ }
444
+ });
347
445
 
348
446
  let resizeObserver: ResizeObserver | null = null;
349
447
  let scrollRafId: number | null = null;
@@ -612,6 +710,85 @@ function onClickCell(cell: Cell, row: Row, key: string | number) {
612
710
  emit("clickCell", cell, row, key);
613
711
  }
614
712
 
713
+ function onBodyClick(event: MouseEvent) {
714
+ const target = event.target as HTMLElement;
715
+
716
+ const row = target.closest("tr");
717
+
718
+ if (!row) return;
719
+
720
+ const rowId = row.getAttribute("data-row-id");
721
+
722
+ if (!rowId) return;
723
+
724
+ const rowData = visibleFlatRows.value.find((r) => String(r.id) === rowId);
725
+
726
+ if (!rowData) return;
727
+
728
+ // Handle checkbox toggle via event delegation.
729
+ // When unchecking, UCheckbox.onIconClick() calls input.click() which dispatches
730
+ // a second (programmatic) click event with target=INPUT and isTrusted=false.
731
+ // Without this guard, onToggleRowCheckbox fires twice (deselect then reselect).
732
+ const checkboxCell = target.closest("td[data-checkbox-id]");
733
+
734
+ if (checkboxCell) {
735
+ if (target.tagName === "INPUT" && !event.isTrusted) return;
736
+
737
+ onToggleRowCheckbox(rowData);
738
+
739
+ return;
740
+ }
741
+
742
+ // Handle expand icon toggle via event delegation
743
+ const expandIconElement = target.closest("[data-expand-icon]");
744
+
745
+ if (expandIconElement) {
746
+ onToggleExpand(rowData);
747
+
748
+ return;
749
+ }
750
+
751
+ // Handle row click via event delegation
752
+ onClickRow(rowData);
753
+
754
+ // Handle cell click via event delegation
755
+ const cell = target.closest("td");
756
+
757
+ if (cell) {
758
+ const cellKey = cell.getAttribute("data-cell-key");
759
+
760
+ if (cellKey) {
761
+ const cellValue = rowData[cellKey];
762
+
763
+ onClickCell(cellValue, rowData, cellKey);
764
+ }
765
+ }
766
+ }
767
+
768
+ function onBodyDoubleClick(event: MouseEvent) {
769
+ const target = event.target as HTMLElement;
770
+
771
+ const row = target.closest("tr");
772
+
773
+ if (!row) return;
774
+
775
+ const rowId = row.getAttribute("data-row-id");
776
+
777
+ if (!rowId) return;
778
+
779
+ const rowData = visibleFlatRows.value.find((r) => String(r.id) === rowId);
780
+
781
+ if (!rowData) return;
782
+
783
+ const selection = window.getSelection();
784
+
785
+ if (selection) {
786
+ selection.removeAllRanges();
787
+ }
788
+
789
+ onDoubleClickRow(rowData);
790
+ }
791
+
615
792
  function onChangeSelectAll(selectAll: boolean) {
616
793
  if (selectAll && canSelectAll.value) {
617
794
  localSelectedRows.value = [...flatTableRows.value];
@@ -631,6 +808,7 @@ function onChangeLocalSelectedRows(selectedRows: Row[]) {
631
808
  nextTick(setHeaderCellWidth);
632
809
  }
633
810
 
811
+ // Set flag to prevent selectAll watcher from triggering
634
812
  selectAll.value = !!selectedRows.length;
635
813
 
636
814
  emit("update:selectedRows", localSelectedRows.value);
@@ -853,6 +1031,135 @@ const tableRowAttrs = {
853
1031
  bodyCellSearchMatchActiveAttrs,
854
1032
  bodyCellSearchMatchTextActiveAttrs,
855
1033
  } as unknown as UTableRowAttrs;
1034
+
1035
+ function renderDateDividerRow(row: FlatRow, rowIndex: number): VNode | null {
1036
+ if (!isShownDateDivider(rowIndex) || !row.rowDate) return null;
1037
+
1038
+ const isSelected = isRowSelectedWithin(rowIndex);
1039
+
1040
+ const propsDateDivider = isSelected
1041
+ ? bodyRowCheckedDateDividerAttrs.value
1042
+ : bodyRowDateDividerAttrs.value;
1043
+
1044
+ const dividerNode = h(UDivider, {
1045
+ label: getDateDividerData(row.rowDate).label,
1046
+ ...(isSelected ? bodySelectedDateDividerAttrs.value : bodyDateDividerAttrs.value),
1047
+ config: getDateDividerConfig(row, isSelected),
1048
+ });
1049
+
1050
+ return h("tr", propsDateDivider, [
1051
+ h(
1052
+ "td",
1053
+ {
1054
+ ...bodyCellDateDividerAttrs.value,
1055
+ colspan: colsCount.value,
1056
+ },
1057
+ [dividerNode],
1058
+ ),
1059
+ ]);
1060
+ }
1061
+
1062
+ function renderTableRowSlots(row: FlatRow, rowIndex: number) {
1063
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
1064
+ const cellSlots: Record<string, Function> = {};
1065
+
1066
+ // Create cell slots
1067
+ Object.entries(mapRowColumns(row, normalizedColumns.value)).forEach(([key], cellIndex) => {
1068
+ cellSlots[`cell-${key}`] = ({
1069
+ value: cellValue,
1070
+ row: cellRow,
1071
+ }: {
1072
+ value: Cell;
1073
+ row: FlatRow;
1074
+ }) => {
1075
+ const hasCellSlot = hasSlotContent(slots[`cell-${key}`], {
1076
+ value: cellValue,
1077
+ row: cellRow,
1078
+ index: rowIndex,
1079
+ cellIndex,
1080
+ });
1081
+
1082
+ if (hasCellSlot) {
1083
+ return slots[`cell-${key}`]?.({
1084
+ value: cellValue,
1085
+ row: cellRow,
1086
+ index: rowIndex,
1087
+ cellIndex,
1088
+ });
1089
+ }
1090
+
1091
+ return null;
1092
+ };
1093
+ });
1094
+
1095
+ // Add expand slot
1096
+ cellSlots.expand = ({ row: expandedRow, expanded }: { row: FlatRow; expanded: boolean }) => {
1097
+ const hasExpandSlot = hasSlotContent(slots.expand, {
1098
+ index: rowIndex,
1099
+ row: expandedRow,
1100
+ expanded,
1101
+ });
1102
+
1103
+ if (hasExpandSlot) {
1104
+ return slots.expand?.({ index: rowIndex, row: expandedRow, expanded });
1105
+ }
1106
+
1107
+ return null;
1108
+ };
1109
+
1110
+ // Add nested-row slot
1111
+ cellSlots["nested-row"] = () => {
1112
+ const hasNestedRowSlot = hasSlotContent(slots["nested-row"], {
1113
+ index: rowIndex,
1114
+ row,
1115
+ nestedLevel: Number(row.nestedLevel || 0),
1116
+ });
1117
+
1118
+ if (hasNestedRowSlot && row) {
1119
+ return slots["nested-row"]?.({
1120
+ index: rowIndex,
1121
+ row,
1122
+ nestedLevel: Number(row.nestedLevel || 0),
1123
+ });
1124
+ }
1125
+
1126
+ return null;
1127
+ };
1128
+
1129
+ return cellSlots;
1130
+ }
1131
+
1132
+ function renderTableRow(row: FlatRow, rowIndex: number): VNode {
1133
+ return h(
1134
+ UTableRow,
1135
+ {
1136
+ selectable: props.selectable,
1137
+ row,
1138
+ columns: normalizedColumns.value,
1139
+ config: config.value,
1140
+ attrs: tableRowAttrs as unknown as UTableRowAttrs,
1141
+ colsCount: colsCount.value,
1142
+ nestedLevel: Number(row.nestedLevel || 0),
1143
+ emptyCellLabel: props.emptyCellLabel,
1144
+ "data-test": getDataTest("row"),
1145
+ "data-row-id": row.id,
1146
+ isExpanded: localExpandedRows.value.includes(row.id),
1147
+ isChecked: isRowSelected(row),
1148
+ columnPositions: columnPositions.value,
1149
+ search: props.search,
1150
+ searchMatchColumns: getRowSearchMatchColumns(row),
1151
+ activeSearchMatchColumn: getRowActiveSearchMatchColumn(row),
1152
+ textEllipsis: props.textEllipsis,
1153
+ } as unknown as UTableRowProps,
1154
+ renderTableRowSlots(row, rowIndex),
1155
+ );
1156
+ }
1157
+
1158
+ function renderRowTemplate(row: FlatRow, rowIndex: number): VNode[] {
1159
+ return [renderDateDividerRow(row, rowIndex), renderTableRow(row, rowIndex)].filter(
1160
+ Boolean,
1161
+ ) as VNode[];
1162
+ }
856
1163
  </script>
857
1164
 
858
1165
  <template>
@@ -893,10 +1200,10 @@ const tableRowAttrs = {
893
1200
  >
894
1201
  <template v-if="hasSlotContent($slots[`header-${column.key}`], { column, index })">
895
1202
  <!--
896
- @slot Use it to customize needed header cell.
897
- @binding {object} column
898
- @binding {number} index
899
- -->
1203
+ @slot Use it to customize needed header cell.
1204
+ @binding {object} column
1205
+ @binding {number} index
1206
+ -->
900
1207
  <slot :name="`header-${column.key}`" :column="column" :index="index" />
901
1208
  </template>
902
1209
 
@@ -1042,7 +1349,12 @@ const tableRowAttrs = {
1042
1349
  <ULoaderProgress :loading="loading" v-bind="headerLoaderAttrs" />
1043
1350
  </thead>
1044
1351
 
1045
- <tbody v-if="sortedRows.length" v-bind="bodyAttrs">
1352
+ <tbody
1353
+ v-if="sortedRows.length"
1354
+ v-bind="bodyAttrs"
1355
+ @click="onBodyClick"
1356
+ @dblclick="onBodyDoubleClick"
1357
+ >
1046
1358
  <tr
1047
1359
  v-if="hasBeforeFirstRowSlot"
1048
1360
  v-bind="isRowSelected(sortedRows[0]) ? beforeBodyRowCheckedAttrs : beforeBodyRowAttrs"
@@ -1060,105 +1372,9 @@ const tableRowAttrs = {
1060
1372
  />
1061
1373
  </tr>
1062
1374
 
1063
- <template v-for="(row, rowIndex) in renderedRows" :key="row.id">
1064
- <tr
1065
- v-if="isShownDateDivider(rowIndex) && !isRowSelectedWithin(rowIndex) && row.rowDate"
1066
- v-bind="bodyRowDateDividerAttrs"
1067
- >
1068
- <td v-bind="bodyCellDateDividerAttrs" :colspan="colsCount">
1069
- <UDivider
1070
- :label="getDateDividerData(row.rowDate).label"
1071
- v-bind="bodyDateDividerAttrs"
1072
- :config="getDateDividerConfig(row, false)"
1073
- />
1074
- </td>
1075
- </tr>
1076
-
1077
- <tr
1078
- v-if="isShownDateDivider(rowIndex) && isRowSelectedWithin(rowIndex) && row.rowDate"
1079
- v-bind="bodyRowCheckedDateDividerAttrs"
1080
- >
1081
- <td v-bind="bodyCellDateDividerAttrs" :colspan="colsCount">
1082
- <UDivider
1083
- :label="getDateDividerData(row.rowDate).label"
1084
- v-bind="bodySelectedDateDividerAttrs"
1085
- :config="getDateDividerConfig(row, true)"
1086
- />
1087
- </td>
1088
- </tr>
1089
-
1090
- <UTableRow
1091
- :selectable="selectable"
1092
- :row="row"
1093
- :columns="normalizedColumns"
1094
- :config="config"
1095
- :attrs="tableRowAttrs as unknown as UTableRowAttrs"
1096
- :cols-count="colsCount"
1097
- :nested-level="Number(row.nestedLevel || 0)"
1098
- :empty-cell-label="emptyCellLabel"
1099
- :data-test="getDataTest('row')"
1100
- :data-row-id="row.id"
1101
- :is-expanded="localExpandedRows.includes(row.id)"
1102
- :is-checked="isRowSelected(row)"
1103
- :column-positions="columnPositions"
1104
- :search="search"
1105
- :search-match-columns="getRowSearchMatchColumns(row)"
1106
- :active-search-match-column="getRowActiveSearchMatchColumn(row)"
1107
- :text-ellipsis="textEllipsis"
1108
- @click="onClickRow"
1109
- @dblclick="onDoubleClickRow"
1110
- @click-cell="onClickCell"
1111
- @toggle-expand="onToggleExpand"
1112
- @toggle-checkbox="onToggleRowCheckbox"
1113
- >
1114
- <template
1115
- v-for="(value, key, cellIndex) in mapRowColumns(row, normalizedColumns)"
1116
- :key="`${rowIndex}-${cellIndex}`"
1117
- #[`cell-${key}`]="{ value: cellValue, row: cellRow }"
1118
- >
1119
- <!--
1120
- @slot Use it to customize needed table cell.
1121
- @binding {string} value
1122
- @binding {object} row
1123
- @binding {number} index
1124
- @binding {number} cellIndex
1125
- -->
1126
- <slot
1127
- :name="`cell-${key}`"
1128
- :value="cellValue"
1129
- :row="cellRow"
1130
- :index="rowIndex"
1131
- :cell-index="cellIndex"
1132
- />
1133
- </template>
1134
-
1135
- <template #expand="{ row: expandedRow, expanded }">
1136
- <!--
1137
- @slot Use it to customize row expand icon.
1138
- @binding {object} row
1139
- @binding {boolean} expanded
1140
- @binding {number} index
1141
- -->
1142
- <slot name="expand" :index="rowIndex" :row="expandedRow" :expanded="expanded" />
1143
- </template>
1144
-
1145
- <template #nested-row>
1146
- <!--
1147
- @slot Use it to add inside nested row.
1148
- @binding {object} row
1149
- @binding {number} index
1150
- @binding {number} nestedLevel
1151
- -->
1152
- <slot
1153
- v-if="row"
1154
- name="nested-row"
1155
- :index="rowIndex"
1156
- :row="row"
1157
- :nested-level="Number(row.nestedLevel || 0)"
1158
- />
1159
- </template>
1160
- </UTableRow>
1161
- </template>
1375
+ <component
1376
+ :is="() => renderedRows.map((row, rowIndex) => renderRowTemplate(row, rowIndex)).flat()"
1377
+ />
1162
1378
 
1163
1379
  <tr v-if="props.virtualScroll && virtualScroll.bottomSpacerHeight.value > 0">
1164
1380
  <td
@@ -1,13 +1,13 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, useTemplateRef } from "vue";
3
- import { cx } from "../utils/ui";
2
+ import { computed, onMounted, useTemplateRef, useSlots, useAttrs, h } from "vue";
4
3
  import { hasSlotContent, isEmptyValue } from "../utils/helper";
4
+ import { cx } from "../utils/ui";
5
5
 
6
6
  import { PX_IN_REM } from "../constants";
7
7
  import { mapRowColumns } from "./utilTable";
8
8
 
9
- import { useMutationObserver } from "../composables/useMutationObserver";
10
9
  import { useUI } from "../composables/useUI";
10
+ import { useMutationObserver } from "../composables/useMutationObserver";
11
11
 
12
12
  import UIcon from "../ui.image-icon/UIcon.vue";
13
13
  import UCheckbox from "../ui.form-checkbox/UCheckbox.vue";
@@ -15,6 +15,7 @@ import UCheckbox from "../ui.form-checkbox/UCheckbox.vue";
15
15
  import defaultConfig from "./config";
16
16
 
17
17
  import { StickySide } from "./types";
18
+ import type { VNode } from "vue";
18
19
  import type { Cell, CellObject, Row, UTableRowProps, Config, ColumnObject } from "./types";
19
20
 
20
21
  const NESTED_ROW_SHIFT_REM = 1.5;
@@ -24,7 +25,8 @@ defineOptions({ internal: true });
24
25
 
25
26
  const props = defineProps<UTableRowProps>();
26
27
 
27
- const emit = defineEmits(["click", "dblclick", "clickCell", "toggleExpand", "toggleCheckbox"]);
28
+ const slots = useSlots();
29
+ const attrs = useAttrs();
28
30
 
29
31
  const cellRef = useTemplateRef<HTMLDivElement[]>("cell");
30
32
  const toggleWrapperRef = useTemplateRef<HTMLDivElement[]>("toggle-wrapper");
@@ -109,20 +111,6 @@ function getNestedCheckboxShift() {
109
111
  return { transform: `translateX(${props.nestedLevel * LAST_NESTED_ROW_SHIFT_REM}rem)` };
110
112
  }
111
113
 
112
- function onClick(row: Row) {
113
- emit("click", row);
114
- }
115
-
116
- function onDoubleClick(row: Row) {
117
- const selection = window.getSelection();
118
-
119
- if (selection) {
120
- selection.removeAllRanges();
121
- }
122
-
123
- emit("dblclick", row);
124
- }
125
-
126
114
  function setCellTitle(mutations: MutationRecord[]) {
127
115
  mutations.forEach((mutation) => {
128
116
  const { target } = mutation;
@@ -147,10 +135,6 @@ function setElementTitle(element: HTMLElement) {
147
135
  }
148
136
  }
149
137
 
150
- function onClickCell(cell: unknown | string | number, row: Row, key: string | number) {
151
- emit("clickCell", cell, row, key);
152
- }
153
-
154
138
  function getRowClasses(row: Row) {
155
139
  const rowClasses = row?.class || "";
156
140
 
@@ -161,14 +145,6 @@ function getRowAttrs() {
161
145
  return props.isChecked ? props.attrs.bodyRowCheckedAttrs.value : props.attrs.bodyRowAttrs.value;
162
146
  }
163
147
 
164
- function onToggleExpand(row: Row) {
165
- emit("toggleExpand", row);
166
- }
167
-
168
- function onInputCheckbox(row: Row) {
169
- emit("toggleCheckbox", row);
170
- }
171
-
172
148
  function getStickyColumnStyle(column: ColumnObject) {
173
149
  const position = props.columnPositions.get(column.key);
174
150
 
@@ -254,6 +230,10 @@ function escapeHtml(text: string): string {
254
230
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
255
231
  }
256
232
 
233
+ function isNestedFirstCell(index: number): boolean {
234
+ return (Boolean(props.row.row) || Boolean(props.nestedLevel)) && index === 0;
235
+ }
236
+
257
237
  function shouldRenderCellWrapper(row: Row, key: string): boolean {
258
238
  return Boolean(
259
239
  props.textEllipsis ||
@@ -262,126 +242,222 @@ function shouldRenderCellWrapper(row: Row, key: string): boolean {
262
242
  );
263
243
  }
264
244
 
245
+ function renderCellContent(value: Cell, key: string, index: number): VNode | VNode[] | string {
246
+ const keyStr = String(key);
247
+ const hasCellSlot = hasSlotContent(slots[`cell-${key}`], { value, row: props.row, index });
248
+
249
+ // Check if slot exists
250
+ if (hasCellSlot) {
251
+ return slots[`cell-${key}`]?.({ value, row: props.row, index }) || "";
252
+ }
253
+
254
+ // Render cell wrapper with highlighted HTML
255
+ if (shouldRenderCellWrapper(props.row, keyStr)) {
256
+ return h("div", {
257
+ ref: cellRef,
258
+ ...props.attrs.bodyCellContentAttrs.value,
259
+ class: cx([
260
+ props.attrs.bodyCellContentAttrs.value.class,
261
+ getCellContentClass(props.row, keyStr),
262
+ ]),
263
+ innerHTML: getHighlightedHtml(value, keyStr),
264
+ });
265
+ }
266
+
267
+ // Render plain text
268
+ return formatCellValue(value);
269
+ }
270
+
271
+ function renderNestedFirstCell(value: Cell, key: string, index: number): VNode {
272
+ const keyStr = String(key);
273
+ const hasExpandSlot = hasSlotContent(slots?.expand, {
274
+ row: props.row,
275
+ expanded: props.isExpanded,
276
+ });
277
+
278
+ const toggleIconNode = h(UIcon, {
279
+ size: "xs",
280
+ interactive: true,
281
+ name: getToggleIconName(),
282
+ color: "primary",
283
+ ...toggleIconConfig.value,
284
+ });
285
+
286
+ const toggleWrapperNode = h(
287
+ "div",
288
+ {
289
+ ref: toggleWrapperRef,
290
+ ...props.attrs.bodyCellNestedIconWrapperAttrs.value,
291
+ style: { width: getIconWidth() },
292
+ },
293
+ [toggleIconNode],
294
+ );
295
+
296
+ const toggleIconWrapperNode = h(
297
+ "div",
298
+ {
299
+ "data-row-toggle-icon": props.row.id,
300
+ "data-expand-icon": props.row.id,
301
+ onDblclick: (e: Event) => e.stopPropagation(),
302
+ },
303
+ hasExpandSlot
304
+ ? slots?.expand?.({ row: props.row, expanded: props.isExpanded })
305
+ : [toggleWrapperNode],
306
+ );
307
+
308
+ const hasCellSlot = hasSlotContent(slots[`cell-${key}`], { value, row: props.row, index });
309
+
310
+ return h(
311
+ "div",
312
+ {
313
+ style: getNestedShift(),
314
+ ...props.attrs.bodyCellNestedAttrs.value,
315
+ },
316
+ [
317
+ props.row.row ? toggleIconWrapperNode : null,
318
+ // Cell content
319
+ ...(() => {
320
+ if (hasCellSlot) {
321
+ const slotContent = slots[`cell-${key}`]?.({ value, row: props.row, index });
322
+
323
+ return Array.isArray(slotContent) ? slotContent : [slotContent];
324
+ }
325
+
326
+ if (shouldRenderCellWrapper(props.row, keyStr)) {
327
+ return [
328
+ h("div", {
329
+ ref: cellRef,
330
+ ...props.attrs.bodyCellContentAttrs.value,
331
+ class: cx([
332
+ props.attrs.bodyCellContentAttrs.value.class,
333
+ getCellContentClass(props.row, keyStr),
334
+ ]),
335
+ "data-test": getDataTest(`${key}-cell`),
336
+ innerHTML: getHighlightedHtml(value, keyStr),
337
+ }),
338
+ ];
339
+ }
340
+
341
+ return [formatCellValue(value)];
342
+ })(),
343
+ ].filter(Boolean),
344
+ );
345
+ }
346
+
347
+ function renderTableCell(value: Cell, key: string, index: number): VNode {
348
+ const keyStr = String(key);
349
+
350
+ const nestedCellNode = isNestedFirstCell(index)
351
+ ? renderNestedFirstCell(value, key, index)
352
+ : renderCellContent(value, key, index);
353
+
354
+ return h(
355
+ "td",
356
+ {
357
+ key: index,
358
+ ...props.attrs.bodyCellBaseAttrs.value,
359
+ class: cx([
360
+ props.attrs.bodyCellBaseAttrs.value.class,
361
+ props.columns[index].tdClass,
362
+ getCellClasses(props.row, keyStr),
363
+ getStickyColumnClass(props.columns[index]),
364
+ getSearchMatchCellClass(keyStr),
365
+ ]),
366
+ style: getStickyColumnStyle(props.columns[index]),
367
+ "data-cell-key": key,
368
+ "data-test": getDataTest(`${key}-cell`),
369
+ },
370
+ [nestedCellNode],
371
+ );
372
+ }
373
+
374
+ function renderCheckboxCell(): VNode | null {
375
+ if (!props.selectable) return null;
376
+
377
+ const checkboxNode = h(UCheckbox, {
378
+ modelValue: props.isChecked,
379
+ size: "md",
380
+ ...props.attrs.bodyCheckboxAttrs.value,
381
+ "data-id": props.row.id,
382
+ "data-checkbox-id": props.row.id,
383
+ "data-test": getDataTest("body-checkbox"),
384
+ });
385
+
386
+ return h(
387
+ "td",
388
+ {
389
+ ...props.attrs.bodyCellCheckboxAttrs.value,
390
+ "data-checkbox-id": props.row.id,
391
+ class: cx([
392
+ props.attrs.bodyCellCheckboxAttrs.value.class,
393
+ props.columns[0]?.sticky === StickySide.Left
394
+ ? props.attrs.bodyCellStickyLeftAttrs.value.class
395
+ : "",
396
+ ]),
397
+ style: {
398
+ ...getNestedCheckboxShift(),
399
+ ...(props.columns[0]?.sticky === StickySide.Left ? { left: "0" } : {}),
400
+ },
401
+ onDblclick: (e: Event) => e.stopPropagation(),
402
+ },
403
+ [checkboxNode],
404
+ );
405
+ }
406
+
407
+ function renderMainRow(): VNode | null {
408
+ const hasNestedRowSlot = hasSlotContent(slots["nested-row"], {
409
+ row: props.row,
410
+ nestedLevel: props.nestedLevel,
411
+ });
412
+
413
+ if (hasNestedRowSlot && props.row.parentRowId) {
414
+ return null;
415
+ }
416
+
417
+ const cells = Object.entries(mapRowColumns(props.row, props.columns)).map(
418
+ ([key, value], index) => {
419
+ return renderTableCell(value, key, index);
420
+ },
421
+ );
422
+
423
+ return h(
424
+ "tr",
425
+ {
426
+ ...attrs,
427
+ ...getRowAttrs(),
428
+ class: cx([getRowAttrs().class, getRowClasses(props.row)]),
429
+ },
430
+ [renderCheckboxCell(), ...cells].filter(Boolean),
431
+ );
432
+ }
433
+
434
+ function renderNestedRow(): VNode | null {
435
+ const hasNestedRowSlot = hasSlotContent(slots["nested-row"], {
436
+ row: props.row,
437
+ nestedLevel: props.nestedLevel,
438
+ });
439
+
440
+ if (!hasNestedRowSlot || !props.row.parentRowId) {
441
+ return null;
442
+ }
443
+
444
+ const nestedRowSlotContent = slots["nested-row"]?.({
445
+ row: props.row,
446
+ nestedLevel: props.nestedLevel,
447
+ });
448
+
449
+ const tdNode = h(
450
+ "td",
451
+ { colspan: props.columns.length + Number(props.selectable) },
452
+ nestedRowSlotContent,
453
+ );
454
+
455
+ return h("tr", { class: props.row.class }, [tdNode]);
456
+ }
457
+
265
458
  const { getDataTest } = useUI<Config>(defaultConfig);
266
459
  </script>
267
460
 
268
461
  <template>
269
- <tr
270
- v-if="!row.parentRowId || !hasSlotContent($slots['nested-row'])"
271
- v-bind="{ ...$attrs, ...getRowAttrs() }"
272
- :class="cx([getRowAttrs().class, getRowClasses(row)])"
273
- @click="onClick(props.row)"
274
- @dblclick="onDoubleClick(props.row)"
275
- >
276
- <td
277
- v-if="selectable"
278
- :style="{
279
- ...getNestedCheckboxShift(),
280
- ...(columns[0]?.sticky === StickySide.Left ? { left: '0' } : {}),
281
- }"
282
- v-bind="attrs.bodyCellCheckboxAttrs.value"
283
- :class="
284
- cx([
285
- attrs.bodyCellCheckboxAttrs.value.class,
286
- columns[0]?.sticky === StickySide.Left ? attrs.bodyCellStickyLeftAttrs.value.class : '',
287
- ])
288
- "
289
- @click.stop
290
- @dblclick.stop
291
- >
292
- <UCheckbox
293
- :model-value="isChecked"
294
- size="md"
295
- v-bind="attrs.bodyCheckboxAttrs.value"
296
- :data-id="row.id"
297
- :data-test="getDataTest('body-checkbox')"
298
- @input="onInputCheckbox(row)"
299
- />
300
- </td>
301
-
302
- <td
303
- v-for="(value, key, index) in mapRowColumns(row, columns)"
304
- :key="index"
305
- v-bind="attrs.bodyCellBaseAttrs.value"
306
- :class="
307
- cx([
308
- columns[index].tdClass,
309
- getCellClasses(row, String(key)),
310
- getStickyColumnClass(columns[index]),
311
- getSearchMatchCellClass(String(key)),
312
- ])
313
- "
314
- :style="getStickyColumnStyle(columns[index])"
315
- @click="onClickCell(value, row, key)"
316
- >
317
- <div
318
- v-if="(row.row || nestedLevel) && index === 0"
319
- :style="getNestedShift()"
320
- v-bind="attrs.bodyCellNestedAttrs.value"
321
- >
322
- <div
323
- v-if="row.row"
324
- :data-row-toggle-icon="row.id"
325
- @dblclick.stop
326
- @click.stop="onToggleExpand(row)"
327
- >
328
- <slot name="expand" :row="row" :expanded="isExpanded">
329
- <div
330
- ref="toggle-wrapper"
331
- v-bind="attrs.bodyCellNestedIconWrapperAttrs.value"
332
- :style="{ width: getIconWidth() }"
333
- >
334
- <UIcon
335
- size="xs"
336
- interactive
337
- :name="getToggleIconName()"
338
- color="primary"
339
- v-bind="toggleIconConfig"
340
- />
341
- </div>
342
- </slot>
343
- </div>
344
- <slot :name="`cell-${key}`" :value="value" :row="row" :index="index">
345
- <!-- eslint-disable vue/no-v-html -->
346
- <div
347
- v-if="shouldRenderCellWrapper(row, String(key))"
348
- ref="cell"
349
- v-bind="attrs.bodyCellContentAttrs.value"
350
- :class="
351
- cx([attrs.bodyCellContentAttrs.value.class, getCellContentClass(row, String(key))])
352
- "
353
- :data-test="getDataTest(`${key}-cell`)"
354
- v-html="getHighlightedHtml(value, String(key))"
355
- />
356
- <span v-else :data-test="getDataTest(`${key}-cell`)">{{ formatCellValue(value) }}</span>
357
- </slot>
358
- </div>
359
-
360
- <template v-else>
361
- <slot :name="`cell-${key}`" :value="value" :row="row" :index="index">
362
- <!-- eslint-disable vue/no-v-html -->
363
- <div
364
- v-if="shouldRenderCellWrapper(row, String(key))"
365
- v-bind="attrs.bodyCellContentAttrs.value"
366
- ref="cell"
367
- :class="
368
- cx([attrs.bodyCellContentAttrs.value.class, getCellContentClass(row, String(key))])
369
- "
370
- :data-test="getDataTest(`${key}-cell`)"
371
- v-html="getHighlightedHtml(value, String(key))"
372
- />
373
- <span v-else :data-test="getDataTest(`${key}-cell`)">{{ formatCellValue(value) }}</span>
374
- </slot>
375
- </template>
376
- </td>
377
- </tr>
378
-
379
- <tr
380
- v-if="row.parentRowId && hasSlotContent($slots['nested-row'], { row, nestedLevel })"
381
- :class="row.class"
382
- >
383
- <td :colspan="columns.length + Number(selectable)">
384
- <slot name="nested-row" :row="row" :nested-level="nestedLevel" />
385
- </td>
386
- </tr>
462
+ <component :is="() => [renderMainRow(), renderNestedRow()].filter(Boolean)" />
387
463
  </template>
@@ -775,9 +775,9 @@ describe("UTable.vue", () => {
775
775
  it("Toggle Row Checkbox – emits update:selectedRows when row checkbox is clicked", async () => {
776
776
  const component = mountUTable(getDefaultProps({ selectable: true }));
777
777
 
778
- const rowCheckbox = component.find("tbody tr").find("input[type='checkbox']");
778
+ const checkboxCell = component.find("tbody tr td[data-checkbox-id]");
779
779
 
780
- await rowCheckbox.trigger("change");
780
+ await checkboxCell.trigger("click");
781
781
 
782
782
  expect(component.emitted("update:selectedRows")).toBeTruthy();
783
783
  const emittedRows = component.emitted("update:selectedRows")![0][0] as Row[];
@@ -836,12 +836,24 @@ describe("UTable.vue", () => {
836
836
  it("Multiple Row Selection – emits update:selectedRows with all selected rows", async () => {
837
837
  const component = mountUTable(getDefaultProps({ selectable: true }));
838
838
 
839
- const tableRows = component.findAll("tbody tr");
840
-
841
839
  // Select first row
842
- await tableRows[0].find("input[type='checkbox']").trigger("change");
840
+ let tableRows = component.findAll("tbody tr");
841
+
842
+ await tableRows[0].find("td[data-checkbox-id]").trigger("click");
843
+ await nextTick();
844
+
845
+ // Update props with the first selected row
846
+ const firstEmit = component.emitted("update:selectedRows")![0][0] as Row[];
847
+
848
+ await component.setProps({ selectedRows: firstEmit });
849
+ await nextTick();
850
+
851
+ // Re-query the DOM after props update
852
+ tableRows = component.findAll("tbody tr");
853
+
843
854
  // Select second row
844
- await tableRows[1].find("input[type='checkbox']").trigger("change");
855
+ await tableRows[1].find("td[data-checkbox-id]").trigger("click");
856
+ await nextTick();
845
857
 
846
858
  const emittedEvents = component.emitted("update:selectedRows");
847
859
 
@@ -1,6 +1,6 @@
1
1
  import { flushPromises, mount } from "@vue/test-utils";
2
2
  import { describe, it, expect, vi } from "vitest";
3
- import { ref } from "vue";
3
+ import { ref, nextTick } from "vue";
4
4
 
5
5
  import UTableRow from "../UTableRow.vue";
6
6
  import UIcon from "../../ui.image-icon/UIcon.vue";
@@ -264,14 +264,17 @@ describe("UTableRow.vue", () => {
264
264
  }),
265
265
  });
266
266
 
267
- const icon = component.findComponent(UIcon);
267
+ let icon = component.findComponent(UIcon);
268
268
 
269
269
  expect(icon.props("name")).toBe(defaultConfig.defaults.expandIcon);
270
270
 
271
- component.setProps({ isExpanded: true });
272
-
271
+ await component.setProps({ isExpanded: true });
272
+ await nextTick();
273
273
  await flushPromises();
274
274
 
275
+ // Re-query the icon after props update
276
+ icon = component.findComponent(UIcon);
277
+
275
278
  expect(icon.props("name")).toBe(defaultConfig.defaults.collapseIcon);
276
279
  });
277
280
 
@@ -290,106 +293,8 @@ describe("UTableRow.vue", () => {
290
293
  });
291
294
  });
292
295
 
293
- describe("Events", () => {
294
- it("Click emits click event when row is clicked", async () => {
295
- const component = mount(UTableRow, {
296
- props: getDefaultProps(),
297
- });
298
-
299
- await component.get("tr").trigger("click");
300
-
301
- expect(component.emitted("click")).toBeTruthy();
302
- expect(component.emitted("click")![0][0]).toEqual(defaultRow);
303
- });
304
-
305
- it("Double Click – emits dblclick event when row is double-clicked", async () => {
306
- const component = mount(UTableRow, {
307
- props: getDefaultProps(),
308
- });
309
-
310
- await component.get("tr").trigger("dblclick");
311
-
312
- expect(component.emitted("dblclick")).toBeTruthy();
313
- expect(component.emitted("dblclick")![0][0]).toEqual(defaultRow);
314
- });
315
-
316
- it("Click Cell – emits clickCell event when cell is clicked", async () => {
317
- const component = mount(UTableRow, {
318
- props: getDefaultProps(),
319
- });
320
-
321
- const firstCell = component.find("td");
322
-
323
- await firstCell.trigger("click");
324
-
325
- expect(component.emitted("clickCell")).toBeTruthy();
326
- expect(component.emitted("clickCell")![0]).toEqual(["John Doe", defaultRow, "name"]);
327
- });
328
-
329
- it("Toggle Expand – emits toggleExpand event when expand icon is clicked", async () => {
330
- const expandableRow: FlatRow = {
331
- ...defaultRow,
332
- row: [{ id: "2", name: "Child", nestedLeveL: 1 }],
333
- };
334
-
335
- const component = mount(UTableRow, {
336
- props: getDefaultProps({ row: expandableRow }),
337
- });
338
-
339
- const expandIcon = component.find("[data-row-toggle-icon='1']");
340
-
341
- await expandIcon.trigger("click");
342
-
343
- expect(component.emitted("toggleExpand")).toBeTruthy();
344
- expect(component.emitted("toggleExpand")![0][0]).toEqual(expandableRow);
345
- });
346
-
347
- it("Toggle Checkbox – emits toggleCheckbox event when checkbox is changed", async () => {
348
- const component = mount(UTableRow, {
349
- props: getDefaultProps({ selectable: true }),
350
- });
351
-
352
- const checkbox = component.getComponent(UCheckbox);
353
-
354
- await checkbox.vm.$emit("input", defaultRow);
355
-
356
- expect(component.emitted("toggleCheckbox")).toBeTruthy();
357
- expect(component.emitted("toggleCheckbox")![0][0]).toEqual(defaultRow);
358
- });
359
-
360
- it("Checkbox Cell – prevents row click events when checkbox cell is clicked", async () => {
361
- const component = mount(UTableRow, {
362
- props: getDefaultProps({ selectable: true }),
363
- });
364
-
365
- const checkboxCell = component.find("td");
366
-
367
- await checkboxCell.trigger("click");
368
- await checkboxCell.trigger("dblclick");
369
-
370
- expect(component.emitted("click")).toBeFalsy();
371
- expect(component.emitted("dblclick")).toBeFalsy();
372
- });
373
-
374
- it("Expand Icon – prevents row click events when expand icon is clicked", async () => {
375
- const expandableRow: FlatRow = {
376
- ...defaultRow,
377
- row: [{ id: "2", name: "Child", nestedLeveL: 1 }],
378
- };
379
-
380
- const component = mount(UTableRow, {
381
- props: getDefaultProps({ row: expandableRow }),
382
- });
383
-
384
- const expandIcon = component.find("[data-row-toggle-icon='1']");
385
-
386
- await expandIcon.trigger("click");
387
- await expandIcon.trigger("dblclick");
388
-
389
- expect(component.emitted("click")).toBeFalsy();
390
- expect(component.emitted("dblclick")).toBeFalsy();
391
- });
392
- });
296
+ // Events are now handled by UTable via event delegation
297
+ // UTableRow no longer emits click, dblclick, clickCell, toggleExpand, or toggleCheckbox events
393
298
 
394
299
  describe("Slots", () => {
395
300
  it("Cell Slot – renders custom content from cell slot", () => {
@@ -199,4 +199,6 @@ export interface UTableRowProps {
199
199
  searchMatchColumns?: Set<string>;
200
200
  activeSearchMatchColumn?: string;
201
201
  textEllipsis?: boolean;
202
+ onToggleExpand?: (row: Row) => void;
203
+ onToggleCheckbox?: (row: Row) => void;
202
204
  }
@@ -48,6 +48,38 @@ const preparedNumber = computed(() => {
48
48
  );
49
49
  });
50
50
 
51
+ const formattedNumber = computed(() => {
52
+ let result = "";
53
+
54
+ if (props.currencyAlign === "left" && props.currency) {
55
+ result += props.currency;
56
+
57
+ if (props.currencySpace) {
58
+ result += " ";
59
+ }
60
+ }
61
+
62
+ if (props.value) {
63
+ result += mathSign.value;
64
+ }
65
+
66
+ result += preparedNumber.value.integer;
67
+
68
+ if (props.maxFractionDigits > 0) {
69
+ result += preparedNumber.value.decimalSeparator + preparedNumber.value.fraction;
70
+ }
71
+
72
+ if (props.currencyAlign === "right" && props.currency) {
73
+ if (props.currencySpace) {
74
+ result += " ";
75
+ }
76
+
77
+ result += props.currency;
78
+ }
79
+
80
+ return result;
81
+ });
82
+
51
83
  defineExpose({
52
84
  /**
53
85
  * A reference to the component's wrapper element for direct DOM manipulation.
@@ -76,10 +108,12 @@ const {
76
108
  <!-- @slot Use it to add something before the number. -->
77
109
  <slot name="left" />
78
110
 
79
- <div v-bind="numberAttrs" :data-test="getDataTest()">
111
+ <template v-if="raw">{{ formattedNumber }}</template>
112
+
113
+ <div v-else v-bind="numberAttrs" :data-test="getDataTest()">
80
114
  <span v-if="currencyAlign === 'left' && currency" v-bind="currencyAttrs" v-text="currency" />
81
115
 
82
- <span v-if="value" v-bind="mathSignAttrs" v-text="mathSign" />
116
+ <span v-if="value && mathSign" v-bind="mathSignAttrs" v-text="mathSign" />
83
117
 
84
118
  <span v-bind="integerAttrs" v-text="preparedNumber.integer" />
85
119
 
@@ -31,6 +31,7 @@ export default /*tw*/ {
31
31
  align: "left",
32
32
  currencyAlign: "right",
33
33
  currencySpace: false,
34
+ raw: false,
34
35
  minFractionDigits: 0,
35
36
  maxFractionDigits: 2,
36
37
  decimalSeparator: ",",
@@ -92,6 +92,17 @@ Sizes.args = { enum: "size" };
92
92
  export const CurrencyAlign = EnumTemplate.bind({});
93
93
  CurrencyAlign.args = { enum: "currencyAlign", currency: "USD", currencySpace: true };
94
94
 
95
+ export const Raw = DefaultTemplate.bind({});
96
+ Raw.args = { raw: true, currency: "USD", currencySpace: true };
97
+ Raw.parameters = {
98
+ docs: {
99
+ description: {
100
+ story:
101
+ "When `raw` is enabled, the number is displayed without formatting and html tags, showing only the raw value.",
102
+ },
103
+ },
104
+ };
105
+
95
106
  export const LimitFractionDigits: StoryFn<UNumberArgs> = (args: UNumberArgs) => ({
96
107
  components: { UNumber, UCol },
97
108
  setup: () => ({ args }),
@@ -192,6 +192,96 @@ describe("UNumber.vue", () => {
192
192
  });
193
193
  });
194
194
 
195
+ it("Raw – renders formatted number as plain text without HTML elements when raw is true", () => {
196
+ const component = mount(UNumber, {
197
+ props: {
198
+ value,
199
+ raw: true,
200
+ },
201
+ });
202
+
203
+ // Should render plain text without the number div structure
204
+ expect(component.find("[vl-key='number']").exists()).toBe(false);
205
+ expect(component.text()).toContain("1 234,56");
206
+ });
207
+
208
+ it("Raw – renders with currency when raw is true and currency is set", () => {
209
+ const currency = "$";
210
+
211
+ const component = mount(UNumber, {
212
+ props: {
213
+ value,
214
+ currency,
215
+ currencyAlign: "left",
216
+ raw: true,
217
+ },
218
+ });
219
+
220
+ expect(component.text()).toBe("$1 234,56");
221
+ });
222
+
223
+ it("Raw – renders with currency and space when raw is true, currency is set, and currencySpace is true", () => {
224
+ const currency = "$";
225
+
226
+ const component = mount(UNumber, {
227
+ props: {
228
+ value,
229
+ currency,
230
+ currencyAlign: "left",
231
+ currencySpace: true,
232
+ raw: true,
233
+ },
234
+ });
235
+
236
+ expect(component.text()).toBe("$ 1 234,56");
237
+ });
238
+
239
+ it("Raw – renders with currency on right when raw is true and currencyAlign is right", () => {
240
+ const currency = "$";
241
+
242
+ const component = mount(UNumber, {
243
+ props: {
244
+ value,
245
+ currency,
246
+ currencyAlign: "right",
247
+ raw: true,
248
+ },
249
+ });
250
+
251
+ expect(component.text()).toBe("1 234,56$");
252
+ });
253
+
254
+ // eslint-disable-next-line vue/max-len
255
+ it("Raw – renders with currency on right and space when raw is true, currencyAlign is right, and currencySpace is true", () => {
256
+ const currency = "$";
257
+
258
+ const component = mount(UNumber, {
259
+ props: {
260
+ value,
261
+ currency,
262
+ currencyAlign: "right",
263
+ currencySpace: true,
264
+ raw: true,
265
+ },
266
+ });
267
+
268
+ expect(component.text()).toBe("1 234,56 $");
269
+ });
270
+
271
+ it("Raw – renders with sign when raw is true and sign is set", () => {
272
+ const testNegativeValue = -123;
273
+
274
+ const component = mount(UNumber, {
275
+ props: {
276
+ value: testNegativeValue,
277
+ sign: MATH_SIGN_TYPE.auto as Props["sign"],
278
+ raw: true,
279
+ },
280
+ });
281
+
282
+ expect(component.text()).toContain(MATH_SIGN.MINUS);
283
+ });
284
+
195
285
  it("MinFractionDigits – adds zeros to meet the minimum fraction digits requirement", () => {
196
286
  const value = 123;
197
287
  const minFractionDigits = 2;
@@ -48,6 +48,11 @@ export interface Props {
48
48
  */
49
49
  currencySpace?: boolean;
50
50
 
51
+ /**
52
+ * Show formatted number as plain text without HTML elements.
53
+ */
54
+ raw?: boolean;
55
+
51
56
  /**
52
57
  * Minimal number of signs after the decimal separator (fractional part of a number).
53
58
  */