diff --git a/framework/ui/components/organisms/vc-data-table/VcDataTable.vue b/framework/ui/components/organisms/vc-data-table/VcDataTable.vue index a4cd424f6..35f2676f9 100644 --- a/framework/ui/components/organisms/vc-data-table/VcDataTable.vue +++ b/framework/ui/components/organisms/vc-data-table/VcDataTable.vue @@ -441,7 +441,7 @@ const props = withDefaults(defineProps & { fitMode?: pullToRefreshText: undefined, totalCount: undefined, totalLabel: undefined, - selectAllActive: false, + selectAllActive: undefined, addRow: undefined, validationRules: undefined, pagination: undefined, diff --git a/framework/ui/components/organisms/vc-data-table/composables/useDataTableOrchestrator.test.ts b/framework/ui/components/organisms/vc-data-table/composables/useDataTableOrchestrator.test.ts index 10b042206..e1f5df5e1 100644 --- a/framework/ui/components/organisms/vc-data-table/composables/useDataTableOrchestrator.test.ts +++ b/framework/ui/components/organisms/vc-data-table/composables/useDataTableOrchestrator.test.ts @@ -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"; @@ -88,7 +88,7 @@ function buildOptions( pullToRefreshText: undefined, totalCount: undefined, totalLabel: undefined, - selectAllActive: false, + selectAllActive: undefined, addRow: undefined, validationRules: undefined, pagination: undefined, @@ -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(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(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); + }); }); diff --git a/framework/ui/components/organisms/vc-data-table/composables/useDataTableOrchestrator.ts b/framework/ui/components/organisms/vc-data-table/composables/useDataTableOrchestrator.ts index 0e5ae05f0..716c345b3 100644 --- a/framework/ui/components/organisms/vc-data-table/composables/useDataTableOrchestrator.ts +++ b/framework/ui/components/organisms/vc-data-table/composables/useDataTableOrchestrator.ts @@ -233,6 +233,29 @@ export interface VcDataTableOrchestratorReturn /** 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>( options: VcDataTableOrchestratorOptions, ): VcDataTableOrchestratorReturn { @@ -275,6 +298,12 @@ export function useDataTableOrchestrator>( // 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({ items: toRef(props, "items") as Ref, selection: toRef(props, "selection") as Ref, @@ -284,6 +313,7 @@ export function useDataTableOrchestrator>( getItemKey, totalCount: toRef(props, "totalCount") as Ref, selectAllActive: toRef(props, "selectAllActive") as Ref, + selectAllEnabled, onSelectAllChange: (active: boolean) => { emit("update:selectAllActive", active); emit("select-all", { selected: active }); @@ -430,6 +460,24 @@ export function useDataTableOrchestrator>( } 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). @@ -444,7 +492,7 @@ export function useDataTableOrchestrator>( if (el) specialWidth += el.getBoundingClientRect().width; } } - return Math.max(0, cachedWrapper.clientWidth - specialWidth); + return Math.max(0, available - specialWidth); }; const cols = useTableColumns({ diff --git a/framework/ui/components/organisms/vc-data-table/composables/useTableSelectionV2.test.ts b/framework/ui/components/organisms/vc-data-table/composables/useTableSelectionV2.test.ts index a6c2eeab1..1503ad459 100644 --- a/framework/ui/components/organisms/vc-data-table/composables/useTableSelectionV2.test.ts +++ b/framework/ui/components/organisms/vc-data-table/composables/useTableSelectionV2.test.ts @@ -224,6 +224,51 @@ describe("useTableSelectionV2 — selectAll / clearSelection", () => { }); }); +describe("useTableSelectionV2 — showSelectAllChoice gating (opt-in)", () => { + function baseOptions() { + return { + items: ref([ + { 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(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(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(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(undefined); diff --git a/framework/ui/components/organisms/vc-data-table/composables/useTableSelectionV2.ts b/framework/ui/components/organisms/vc-data-table/composables/useTableSelectionV2.ts index b487fff14..5ebbeb8db 100644 --- a/framework/ui/components/organisms/vc-data-table/composables/useTableSelectionV2.ts +++ b/framework/ui/components/organisms/vc-data-table/composables/useTableSelectionV2.ts @@ -31,6 +31,12 @@ export interface UseTableSelectionV2Options { totalCount?: Ref; /** External "select all" state (v-model:selectAllActive) */ selectAllActive?: Ref; + /** + * 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; /** Callback when "select all" mode changes */ onSelectAllChange?: (active: boolean) => void; } @@ -103,6 +109,7 @@ export function useTableSelectionV2>( getItemKey, totalCount, selectAllActive, + selectAllEnabled, onSelectAllChange, } = options; @@ -181,6 +188,7 @@ export function useTableSelectionV2>( * Shows when all visible items are selected and more items exist (totalCount > items.length) */ const showSelectAllChoice = computed(() => { + 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; diff --git a/framework/ui/components/organisms/vc-data-table/vc-data-table.docs.md b/framework/ui/components/organisms/vc-data-table/vc-data-table.docs.md index 0fd87be28..cef2c97ec 100644 --- a/framework/ui/components/organisms/vc-data-table/vc-data-table.docs.md +++ b/framework/ui/components/organisms/vc-data-table/vc-data-table.docs.md @@ -376,6 +376,8 @@ For server-side "select all" that includes items not currently visible: ``` +> **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); @@ -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