Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ const props = withDefaults(defineProps<VcDataTableExtendedProps<T> & { fitMode?:
pullToRefreshText: undefined,
totalCount: undefined,
totalLabel: undefined,
selectAllActive: false,
selectAllActive: undefined,
addRow: undefined,
validationRules: undefined,
pagination: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createApp, ref, computed } from "vue";
import type { Ref } from "vue";
import { useDataTableOrchestrator } from "./useDataTableOrchestrator";
import { useDataTableOrchestrator, findScrollViewportWidth } from "./useDataTableOrchestrator";
import type { VcDataTableOrchestratorOptions } from "./useDataTableOrchestrator";
import type { ColumnInstance } from "@ui/components/organisms/vc-data-table/utils/ColumnCollector";

Expand Down Expand Up @@ -88,7 +88,7 @@ function buildOptions(
pullToRefreshText: undefined,
totalCount: undefined,
totalLabel: undefined,
selectAllActive: false,
selectAllActive: undefined,
addRow: undefined,
validationRules: undefined,
pagination: undefined,
Expand Down Expand Up @@ -294,4 +294,102 @@ describe("useDataTableOrchestrator", () => {
app.unmount();
}
});

describe("cross-page select-all is opt-in (showSelectAllChoice gating)", () => {
function multiSelectOptions() {
const options = buildOptions();
options.props.items = [
{ id: "a", name: "A" },
{ id: "b", name: "B" },
];
options.props.selectionMode = "multiple";
options.props.totalCount = 5; // more items than visible → choice would otherwise appear
return options;
}

it("does NOT show the choice when selectAllActive is not bound (undefined)", () => {
const options = multiSelectOptions();
// selectAllActive defaults to undefined in buildOptions → feature off
const { result, app } = withSetup(() => useDataTableOrchestrator<TestItem>(options));

try {
result.selection.handleSelectAllChange(true); // select all visible rows
expect(result.selection.allSelected.value).toBe(true);
expect(result.selection.showSelectAllChoice.value).toBe(false);
} finally {
app.unmount();
}
});

it("shows the choice when selectAllActive is bound", () => {
const options = multiSelectOptions();
options.props.selectAllActive = false; // bound (defined) → feature opted in
const { result, app } = withSetup(() => useDataTableOrchestrator<TestItem>(options));

try {
result.selection.handleSelectAllChange(true);
expect(result.selection.showSelectAllChoice.value).toBe(true);
} finally {
app.unmount();
}
});
});
});

// ============================================================================
// findScrollViewportWidth — content-inflation guard for measureAvailableWidth
// ============================================================================

describe("findScrollViewportWidth", () => {
const withClientWidth = (el: HTMLElement, w: number): HTMLElement => {
Object.defineProperty(el, "clientWidth", { value: w, configurable: true });
return el;
};

let attached: HTMLElement[] = [];
const make = (clientWidth: number, overflowX?: string): HTMLElement => {
const el = withClientWidth(document.createElement("div"), clientWidth);
if (overflowX) el.style.overflowX = overflowX;
return el;
};
const chain = (...els: HTMLElement[]): void => {
for (let i = 0; i < els.length - 1; i++) els[i].appendChild(els[i + 1]);
document.body.appendChild(els[0]);
attached.push(els[0]);
};

beforeEach(() => {
attached.forEach((el) => el.remove());
attached = [];
});

it("returns clientWidth of the nearest overflow-x:auto ancestor, ignoring an inflated child", () => {
const viewport = make(952, "auto");
const inflated = make(4402); // overflow visible, stretched by content
const leaf = make(3920);
chain(viewport, inflated, leaf);
expect(findScrollViewportWidth(leaf)).toBe(952);
});

it("skips overflow-x:hidden ancestors (clip, not a scroll viewport)", () => {
const viewport = make(952, "auto");
const clipped = make(4402, "hidden"); // hidden but still inflated → must be skipped
const leaf = make(3920);
chain(viewport, clipped, leaf);
expect(findScrollViewportWidth(leaf)).toBe(952);
});

it("matches overflow-x:scroll viewports too", () => {
const viewport = make(800, "scroll");
const leaf = make(3000);
chain(viewport, leaf);
expect(findScrollViewportWidth(leaf)).toBe(800);
});

it("returns 0 when no scrollable ancestor exists (no clamp, legacy behaviour)", () => {
const root = make(1000);
const leaf = make(3000);
chain(root, leaf);
expect(findScrollViewportWidth(leaf)).toBe(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,29 @@ export interface VcDataTableOrchestratorReturn<T extends Record<string, unknown>
/** SortableJS `handle` selector matching the desktop row and mobile card drag handles. */
const ROW_REORDER_HANDLE_SELECTOR = ".vc-table-composition__row-drag-handle, .vc-data-table-mobile-card__drag-handle";

/**
* Width of the nearest horizontally-scrollable ancestor viewport, or `0` if none.
*
* A scroll viewport (`overflow-x: auto | scroll`) is sized by its own parent —
* its content scrolls inside it — so its `clientWidth` is independent of how wide
* the table's content is. `overflow-x: hidden` ancestors are deliberately skipped:
* they clip but can still be stretched wider than their box by the same content
* inflation we are guarding against, so they are not a trustworthy reference.
*
* Used by `measureAvailableWidth` to cap the raw wrapper measurement and break the
* content-inflation feedback loop (see there). Returns `0` when the table is not
* inside any scroll viewport, so callers fall back to the unclamped measurement.
*/
export function findScrollViewportWidth(el: HTMLElement): number {
let node = el.parentElement;
while (node) {
const overflowX = getComputedStyle(node).overflowX;
if (overflowX === "auto" || overflowX === "scroll") return node.clientWidth;
node = node.parentElement;
}
return 0;
}

export function useDataTableOrchestrator<T extends Record<string, unknown>>(
options: VcDataTableOrchestratorOptions<T>,
): VcDataTableOrchestratorReturn<T> {
Expand Down Expand Up @@ -275,6 +298,12 @@ export function useDataTableOrchestrator<T extends Record<string, unknown>>(
// Selection Composable
// ============================================================================

// The cross-page "select all" banner is opt-in: it appears only when the
// consumer wires the feature up by binding `selectAllActive` (one-way or via
// v-model). Its default is `undefined`, so a table that passes `totalCount`
// purely for pagination never surfaces a "Select all N items" prompt nobody handles.
const selectAllEnabled = computed(() => props.selectAllActive !== undefined);

const selection = useTableSelectionV2<T>({
items: toRef(props, "items") as Ref<T[]>,
selection: toRef(props, "selection") as Ref<T | T[] | undefined>,
Expand All @@ -284,6 +313,7 @@ export function useDataTableOrchestrator<T extends Record<string, unknown>>(
getItemKey,
totalCount: toRef(props, "totalCount") as Ref<number | undefined>,
selectAllActive: toRef(props, "selectAllActive") as Ref<boolean | undefined>,
selectAllEnabled,
onSelectAllChange: (active: boolean) => {
emit("update:selectAllActive", active);
emit("select-all", { selected: active });
Expand Down Expand Up @@ -430,6 +460,24 @@ export function useDataTableOrchestrator<T extends Record<string, unknown>>(
}
if (!cachedWrapper || cachedWrapper.clientWidth <= 0) return 0;

let available = cachedWrapper.clientWidth;

// Guard against a content-inflation feedback loop. During a cold mount or a
// layout reflow (view switch, column reset) the wrapper — and the whole
// `.vc-data-table` subtree — can be stretched wider than its allotted box by
// its own content: every flex ancestor up to the scroll viewport defaults to
// `min-width: auto`, so a momentarily-unconstrained table (long cell content,
// no column widths yet) inflates them all. If the engine then bakes column
// widths from that inflated measurement, the table stays oversized forever —
// the ResizeObserver sees no further size change, so it never recovers.
//
// Clamp to the nearest scroll viewport, whose width is set by ITS parent and
// is therefore immune to our content. In steady state the wrapper is narrower
// than the viewport, so this is a no-op; it only bites during inflation, after
// which the table shrinks back and the next tick measures the wrapper exactly.
const viewportWidth = findScrollViewportWidth(cachedWrapper);
if (viewportWidth > 0) available = Math.min(available, viewportWidth);

// Subtract special cells inside the wrapper.
let specialWidth = 0;
// 1. Implicit selection cells (not a VcColumn — no data-column-id).
Expand All @@ -444,7 +492,7 @@ export function useDataTableOrchestrator<T extends Record<string, unknown>>(
if (el) specialWidth += el.getBoundingClientRect().width;
}
}
return Math.max(0, cachedWrapper.clientWidth - specialWidth);
return Math.max(0, available - specialWidth);
};

const cols = useTableColumns({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,51 @@ describe("useTableSelectionV2 — selectAll / clearSelection", () => {
});
});

describe("useTableSelectionV2 — showSelectAllChoice gating (opt-in)", () => {
function baseOptions() {
return {
items: ref<Item[]>([
{ id: "a", price: 10, selectable: true },
{ id: "b", price: 20, selectable: true },
{ id: "c", price: 30, selectable: true },
]),
selectionMode: ref<"single" | "multiple">("multiple"),
dataKey: "id",
getItemKey: (item: Item) => item.id,
totalCount: ref<number | undefined>(10),
};
}

it("does NOT show the cross-page choice when the feature is not enabled by the consumer", () => {
const opts = baseOptions();
const { handleSelectAllChange, showSelectAllChoice } = useTableSelectionV2(opts);

// All visible rows selected and more items exist (totalCount 10 > 3 visible)
handleSelectAllChange(true);

// Feature not wired up by the consumer → banner must stay hidden
expect(showSelectAllChoice.value).toBe(false);
});

it("shows the cross-page choice when the consumer enabled the feature", () => {
const opts = { ...baseOptions(), selectAllEnabled: ref<boolean | undefined>(true) };
const { handleSelectAllChange, showSelectAllChoice } = useTableSelectionV2(opts);

handleSelectAllChange(true);

expect(showSelectAllChoice.value).toBe(true);
});

it("keeps the cross-page choice hidden when selectAllEnabled is false", () => {
const opts = { ...baseOptions(), selectAllEnabled: ref<boolean | undefined>(false) };
const { handleSelectAllChange, showSelectAllChoice } = useTableSelectionV2(opts);

handleSelectAllChange(true);

expect(showSelectAllChoice.value).toBe(false);
});
});

describe("useTableSelectionV2 — external modelValue sync", () => {
it("updating the selection ref externally reflects in internalSelection", async () => {
const selectionRef = ref<Item | Item[] | undefined>(undefined);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export interface UseTableSelectionV2Options<T> {
totalCount?: Ref<number | undefined>;
/** External "select all" state (v-model:selectAllActive) */
selectAllActive?: Ref<boolean | undefined>;
/**
* Whether the consumer opted into the cross-page "select all" feature.
* When falsy, the "Select all N items" choice banner stays hidden even if
* all visible rows are selected and more items exist.
*/
selectAllEnabled?: Ref<boolean | undefined>;
/** Callback when "select all" mode changes */
onSelectAllChange?: (active: boolean) => void;
}
Expand Down Expand Up @@ -103,6 +109,7 @@ export function useTableSelectionV2<T extends Record<string, any>>(
getItemKey,
totalCount,
selectAllActive,
selectAllEnabled,
onSelectAllChange,
} = options;

Expand Down Expand Up @@ -181,6 +188,7 @@ export function useTableSelectionV2<T extends Record<string, any>>(
* Shows when all visible items are selected and more items exist (totalCount > items.length)
*/
const showSelectAllChoice = computed<boolean>(() => {
if (!selectAllEnabled?.value) return false; // Consumer did not opt into cross-page select all
if (!totalCount?.value) return false;
if (isSelectAllActive.value) return false; // Already in select all mode
return allSelected.value && totalCount.value > items.value.length;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,8 @@ For server-side "select all" that includes items not currently visible:
</VcDataTable>
```

> **Opt-in.** The "Select all N items" banner is shown only when you bind `selectAllActive` (via `v-model:selectAllActive` or a one-way `:selectAllActive`). Its default is `undefined`, so a table that passes `totalCount` only for pagination never surfaces the banner.

```ts
const allSelected = ref(false);

Expand Down Expand Up @@ -1360,15 +1362,15 @@ function onRowRemove(event: { data: Product; index: number; cancel: () => void }

### Selection

| Prop | Type | Default | Description |
| -------------------- | ------------------------ | ------- | ------------------------------------------------------------------------------------------------- |
| `selection` | `T \| T[]` | -- | Selected item(s). Use with `v-model:selection`. |
| `selectionMode` | `"single" \| "multiple"` | -- | Row selection mode. |
| `isRowSelectable` | `(data: T) => boolean` | -- | Per-row function to disable selection. |
| `compareSelectionBy` | `"equals" \| "field"` | -- | Compare items by deep equality or by `dataKey` field. |
| `selectAll` | `boolean` | `false` | Enable "select all" header checkbox. |
| `selectAllActive` | `boolean` | `false` | Whether "select all" (including non-visible items) is active. Use with `v-model:selectAllActive`. |
| `activeItemId` | `string` | -- | ID of the highlighted row. Use with `v-model:activeItemId`. |
| Prop | Type | Default | Description |
| -------------------- | ------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `selection` | `T \| T[]` | -- | Selected item(s). Use with `v-model:selection`. |
| `selectionMode` | `"single" \| "multiple"` | -- | Row selection mode. |
| `isRowSelectable` | `(data: T) => boolean` | -- | Per-row function to disable selection. |
| `compareSelectionBy` | `"equals" \| "field"` | -- | Compare items by deep equality or by `dataKey` field. |
| `selectAll` | `boolean` | `false` | Enable "select all" header checkbox. |
| `selectAllActive` | `boolean` | `undefined` | "Select all" (including non-visible items) active state. Binding it (e.g. `v-model:selectAllActive`) opts the table into the cross-page "Select all N items" banner; left unbound, the banner never appears. |
| `activeItemId` | `string` | -- | ID of the highlighted row. Use with `v-model:activeItemId`. |

### Sorting

Expand Down
Loading