From a65e81c5c143f4c8dc442e286781af081f017f17 Mon Sep 17 00:00:00 2001 From: Shevchik Igor Date: Wed, 1 Jul 2026 09:37:51 +0000 Subject: [PATCH] feat(Table): add getScrollElement virtualize option Port of upstream nuxt/ui 7e6d0f7 (#6657). Brings the external-scroll capability (from ScrollArea's a84de85) to Table, and refactors ScrollArea's handling to a theme variant: - Table: new `getScrollElement` in the `virtualize` option; `isExternalScroll` + an `externalScroll` theme variant; a `getScrollElement` resolver + `scrollMargin` computed wired into the virtualizer; `virtualPaddingTop`/`Bottom` account for `scrollMargin` so rows sit inline below preceding content. - ScrollArea: move `isExternalScroll` above the theme call and feed a new `externalScroll` variant; simplify the item offset to always subtract `scrollMargin` (defaults 0); drop the inline `overflow: visible` root style in favour of the theme variant. Adds a Table renderEach case (4 snapshots), a Table docs section + example, and a horizontal-orientation option on the ScrollArea example. Docs adapted to b24ui conventions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JS8ypVfQSFzYVZzkTHhURb --- ...e6d0f767666be718ae32709a029cfe456fb660b.md | 70 ++++++++++ .sync/nuxt-ui.json | 12 +- .../ScrollAreaExternalScrollExample.vue | 103 ++++++++------ .../table/TableExternalScrollExample.vue | 126 ++++++++++++++++++ docs/content/docs/2.components/scroll-area.md | 9 +- docs/content/docs/2.components/table.md | 18 +++ src/runtime/components/ScrollArea.vue | 15 ++- src/runtime/components/Table.vue | 28 +++- src/theme/scroll-area.ts | 5 + src/theme/table.ts | 5 + test/components/Table.spec.ts | 1 + .../__snapshots__/ScrollArea-vue.spec.ts.snap | 2 +- .../__snapshots__/ScrollArea.spec.ts.snap | 2 +- .../__snapshots__/Table-vue.spec.ts.snap | 22 +++ .../__snapshots__/Table.spec.ts.snap | 22 +++ 15 files changed, 380 insertions(+), 60 deletions(-) create mode 100644 .sync/log/7e6d0f767666be718ae32709a029cfe456fb660b.md create mode 100644 docs/app/components/content/examples/table/TableExternalScrollExample.vue diff --git a/.sync/log/7e6d0f767666be718ae32709a029cfe456fb660b.md b/.sync/log/7e6d0f767666be718ae32709a029cfe456fb660b.md new file mode 100644 index 000000000..d41fb8d48 --- /dev/null +++ b/.sync/log/7e6d0f767666be718ae32709a029cfe456fb660b.md @@ -0,0 +1,70 @@ +# Port: feat(Table): add `getScrollElement` virtualize option (#6657) + +**Upstream:** `7e6d0f767666be718ae32709a029cfe456fb660b` (nuxt/ui) +**Decision:** port + +## Upstream change +Adds the external-scroll capability (from `a84de85`/ScrollArea) to **Table**, and +refactors ScrollArea's external-scroll handling to a theme variant: + +**ScrollArea (refactor):** move `isExternalScroll` above the `tv` call and feed a +new `externalScroll` variant; simplify the item `offset` to always subtract +`virtualizerProps.scrollMargin` (defaults to 0); drop the inline +`overflow: visible` root style (now the `externalScroll` theme variant sets +`root: 'overflow-visible'`). + +**Table (feature):** new `getScrollElement?: () => Element | null` in the +`virtualize` option (removed from the `Omit`); `isExternalScroll` computed + an +`externalScroll` tv variant; a `getScrollElement` resolver + `scrollMargin` +computed wired into `useVirtualizer` (getter for `scrollMargin`, `getScrollElement`); +`virtualPaddingTop`/`Bottom` exclude/add `scrollMargin` so rows sit inline below +preceding content. `theme/table.ts` gains the `externalScroll` variant. + +Plus a Table `renderEach` spec case, a Table docs section + example, and the +ScrollArea example/`.md` gaining a horizontal-orientation option. + +## b24ui port — component 1:1, docs adapted +b24ui's `ScrollArea.vue`/`Table.vue` matched upstream's pre-change structure, so +**all component + theme changes applied 1:1**, with only these b24ui surface +diffs (pre-existing, preserved): +- `ScrollArea.vue`: `virtualizerProps` already defaults `scrollMargin: 0`, so the + simplified `offset = start - scrollMargin` is safe. +- `Table.vue`: b24ui's `tv` call carries an extra `virtualize: !!props.virtualize` + variant (a b24ui divergence); `externalScroll: isExternalScroll.value` was added + **alongside** it. `virtualizerProps` has no `scrollMargin` default (same as + upstream) — handled by `scrollMargin = computed(() => … ?? 0)`. +- `theme/table.ts`: `externalScroll` variant inserted after `sticky` (b24ui has a + `loading` variant between `sticky` and `loadingAnimation`; placement mirrors + upstream's "after sticky"). + +### Tests +Added the `with virtualize external scroll element` `renderEach` case to +`Table.spec.ts` verbatim. **4 snapshots touched:** Table +2 (new case × +nuxt/vue, renders `root … overflow-visible`), ScrollArea 2 updated (external case +now carries the `overflow-visible` **class** instead of the old inline +`style="overflow: visible;"` — the refactor demonstrating itself). Suite 5147 +passed (+2 vs 5145). + +### Docs — adapted to b24ui +- **Table:** ported the `### With external scroll element` md section (badge `New` + vs upstream `Soon`) + `note`, and created `TableExternalScrollExample.vue` — a + faithful b24ui port of upstream's payments-table example (external `overflow-auto` + container + sticky title feeding `scrollMargin` via `useElementSize` + + `B24Table :virtualize="{ scrollMargin, getScrollElement }"`, `:b24ui`), using + b24ui conventions: `TableColumn` from `@bitrix24/b24ui-nuxt`, + `resolveComponent('B24Badge')`, air status colors + (`air-primary-success`/`air-primary-alert`/`air-primary`, matching the existing + `TableVirtualizeExample`), header badge `air-tertiary-no-accent`. +- **ScrollArea:** extended the (previously trimmed, #a84de85) example with the + horizontal-orientation option upstream added — `orientation` prop, `isHorizontal`, + axis-aware `itemSize`/`scrollMargin` (width vs height), left-vs-top header layout, + and a `Start`/`Top` button (`ArrowToTheLeftIcon`/`ArrowToTheTopIcon`). Added the + matching `orientation` options block to `scroll-area.md`. The find-toolbar + dropped in #a84de85 stays dropped (incidental demo sugar). + +`useElementSize` is imported explicitly from `@vueuse/core` in both examples (not +auto-imported in b24ui docs). + +## Verify (CI=true) +`dev:prepare` · `lint` · `typecheck` (both docs examples compile) · `test` (225 +files, 5147 passed / 6 skipped) · `build` — all green. diff --git a/.sync/nuxt-ui.json b/.sync/nuxt-ui.json index 2d2ec9425..104248cf9 100644 --- a/.sync/nuxt-ui.json +++ b/.sync/nuxt-ui.json @@ -2,7 +2,7 @@ "upstream": "nuxt/ui", "branch": "v4", "sync_enabled": false, - "cursor": "8d46034c19474e62e5dbc25b5efb3f887d615c58", + "cursor": "7e6d0f767666be718ae32709a029cfe456fb660b", "_cursor_note": "cursor = last upstream commit ported into b24ui (oldest-first, manual cadence). sync_enabled stays false until Phase 2 (porter workflow #67 + CLAUDE_CODE_OAUTH_TOKEN) is wired and trusted. `processed` is maintained per port from now on (backfilled #68-#72 on 2026-06-09).", "stats": { "queue_depth": 0, @@ -827,10 +827,16 @@ "summary": "fix(module): avoid unhead v2-only hookOnce in colors plugin (#6658) — src/runtime/plugins/colors.ts SPA-hydration branch: replace injectHead().hooks.hookOnce('dom:rendered', removeTemporaryColorsStyle) with self-unhooking head.hooks?.hook('dom:rendered', ()=>{removeTemporaryColorsStyle(); unhook?.()}) so once-semantics work on both unhead v2 (Hookable.hookOnce) and v3 (HookableCore.hook only). Direct 1:1 (b24ui had identical line; data-bitrix24-ui-colors attr untouched). Arrived upstream DURING the #227-233 batch (first commit past prior v4 HEAD a84de85, now new v4 HEAD). Client-only plugin path, no snapshot churn" }, "8d46034c19474e62e5dbc25b5efb3f887d615c58": { - "pr": null, - "b24ui_sha": "pending-merge", + "pr": 235, + "b24ui_sha": "d57802d0", "decision": "no-op", "summary": "feat(module): pre-bundle used icons into @nuxt/icon client bundle — NO-OP: whole commit is @nuxt/icon+iconify machinery (new src/utils/icons.ts getClientBundleIcons parsing i-{collection}-{name} -> {collection}:{name}; module.ts icon:clientBundleIcons hook; @nuxt/icon 2.2.4->2.2.5 + pnpm-workspace minimumReleaseAgeExclude; docs about clientBundle/@iconify-json). b24ui has NO @nuxt/icon dep (grep => none; only commented-out in module.ts), renders icons via @bitrix24/b24icons-vue components, no theme/icons.ts, no utils/icons.ts, no icon:clientBundleIcons hook. Docs prose describes @nuxt/icon clientBundle behavior b24ui doesn't implement -> not carried over. Matches prior @nuxt/icon no-ops #69/#219. Bookkeeping only" + }, + "7e6d0f767666be718ae32709a029cfe456fb660b": { + "pr": null, + "b24ui_sha": "pending-merge", + "decision": "port", + "summary": "feat(Table): add getScrollElement virtualize option (#6657) — Table.vue: new getScrollElement?:()=>Element|null in virtualize option (removed from Omit); isExternalScroll computed + externalScroll tv variant (alongside b24ui's extra virtualize variant); getScrollElement resolver + scrollMargin computed wired into useVirtualizer (scrollMargin getter + getScrollElement); virtualPaddingTop -=scrollMargin, virtualPaddingBottom +=scrollMargin. theme/table.ts +externalScroll:{true:{root:'overflow-visible'}} (after sticky). ScrollArea refactor: move isExternalScroll above tv + externalScroll variant; offset simplified to start-virtualizerProps.scrollMargin (defaults 0); drop inline overflow:visible root style (now theme variant). theme/scroll-area.ts +externalScroll variant. Component 1:1 (b24ui matched pre-change). +Table renderEach case; 4 snapshots (Table +2 new case overflow-visible; ScrollArea 2 updated inline-style->overflow-visible class). Docs adapted: table.md section (badge New) + NEW TableExternalScrollExample (b24ui payments table: overflow-auto container + sticky title scrollMargin via useElementSize + B24Table virtualize getScrollElement/scrollMargin, :b24ui; TableColumn from @bitrix24/b24ui-nuxt, resolveComponent B24Badge, air status colors like TableVirtualizeExample); ScrollArea example extended with horizontal orientation (orientation prop, axis-aware itemSize/scrollMargin, Start/Top button ArrowToTheLeft/Top) + scroll-area.md orientation options block (find-toolbar stays dropped from #a84de85). useElementSize imported from @vueuse/core. Tests 5147 passed (+2)" } } } diff --git a/docs/app/components/content/examples/scroll-area/ScrollAreaExternalScrollExample.vue b/docs/app/components/content/examples/scroll-area/ScrollAreaExternalScrollExample.vue index a3fb18662..cd5318115 100644 --- a/docs/app/components/content/examples/scroll-area/ScrollAreaExternalScrollExample.vue +++ b/docs/app/components/content/examples/scroll-area/ScrollAreaExternalScrollExample.vue @@ -1,6 +1,13 @@ diff --git a/docs/app/components/content/examples/table/TableExternalScrollExample.vue b/docs/app/components/content/examples/table/TableExternalScrollExample.vue new file mode 100644 index 000000000..50b7ade14 --- /dev/null +++ b/docs/app/components/content/examples/table/TableExternalScrollExample.vue @@ -0,0 +1,126 @@ + + + diff --git a/docs/content/docs/2.components/scroll-area.md b/docs/content/docs/2.components/scroll-area.md index b40991641..a7b1b6219 100644 --- a/docs/content/docs/2.components/scroll-area.md +++ b/docs/content/docs/2.components/scroll-area.md @@ -170,11 +170,18 @@ collapse: true overflowHidden: true name: 'scroll-area-external-scroll-example' class: '!p-0' +options: + - name: orientation + label: orientation + default: vertical + items: + - vertical + - horizontal --- :: ::note -Because the container owns the scroll, the toolbar's "Top" button scrolls it directly with `container.scrollTo`. +Because the container owns the scroll, the header's "Top"/"Start" button scrolls it directly with `container.scrollTo`. :: ::caution diff --git a/docs/content/docs/2.components/table.md b/docs/content/docs/2.components/table.md index bf73ff9ed..008eb365d 100644 --- a/docs/content/docs/2.components/table.md +++ b/docs/content/docs/2.components/table.md @@ -703,6 +703,24 @@ class: '!p-0' A height constraint is required on the table for virtualization to work properly (e.g., `class="h-[400px]"`). :: +### With external scroll element :badge{label="New" class="align-text-top"} + +Pass a `getScrollElement` function in the `virtualize` prop to virtualize against an ancestor scroll container instead of the table's own root. Set `scrollMargin` to the table's offset from the scroll element's start (e.g. the height of the content above it), so a header and the table body share a single scrollbar. + +::component-example +--- +prettier: true +collapse: true +overflowHidden: true +name: 'table-external-scroll-example' +class: '!p-0' +--- +:: + +::note +In this mode the table root's `overflow` is `visible` and the external container owns scrolling on both axes, so give it `overflow-auto` (not just `overflow-y-auto`) to keep wide tables horizontally scrollable. A `sticky` header then anchors to that container. +:: + ### With tree data You can use the `get-sub-rows` prop to display hierarchical (tree) data in the table. diff --git a/src/runtime/components/ScrollArea.vue b/src/runtime/components/ScrollArea.vue index 5a68c6217..450242272 100644 --- a/src/runtime/components/ScrollArea.vue +++ b/src/runtime/components/ScrollArea.vue @@ -116,9 +116,13 @@ const props = useComponentProps>('scrollArea', _props) const { dir } = useLocale() const appConfig = useAppConfig() as ScrollArea['AppConfig'] +// When an external scroll element is provided, it owns the scroll (the root grows inline). +const isExternalScroll = computed(() => typeof props.virtualize === 'object' && !!props.virtualize.getScrollElement) + // eslint-disable-next-line vue/no-dupe-keys const b24ui = computed(() => tv({ extend: theme, ...(appConfig.b24ui?.scrollArea || {}) })({ - orientation: props.orientation + orientation: props.orientation, + externalScroll: isExternalScroll.value })) const rootRef = useTemplateRef('rootRef') @@ -137,9 +141,6 @@ const isRtl = computed(() => dir.value === 'rtl') const isHorizontal = computed(() => props.orientation === 'horizontal') const isVertical = computed(() => !isHorizontal.value) -// When an external scroll element is provided, it owns the scroll -const isExternalScroll = computed(() => typeof props.virtualize === 'object' && !!props.virtualize.getScrollElement) - // The scroll viewport: the external element when provided, otherwise the component's root. const getScrollElement = () => (isExternalScroll.value ? virtualizerProps.value.getScrollElement?.() : rootRef.value?.$el) ?? null @@ -214,8 +215,8 @@ function getVirtualItemStyle(virtualItem: VirtualItem): CSSProperties { const hasLanes = lanes.value !== undefined && lanes.value > 1 const lane = virtualItem.lane const gap = virtualizerProps.value.gap ?? 0 - // In external-scroll mode `start` includes `scrollMargin`; subtract it so items sit inline. - const offset = virtualItem.start - (isExternalScroll.value ? (virtualizerProps.value.scrollMargin ?? 0) : 0) + // `start` includes `scrollMargin`; subtract it so items sit inline (0 unless set). + const offset = virtualItem.start - virtualizerProps.value.scrollMargin // For cross-axis gaps: calculate size and position accounting for gaps between lanes // laneSize = (100% - (lanes - 1) * gap) / lanes @@ -308,7 +309,7 @@ defineExpose({ data-slot="root" :data-orientation="props.orientation" :class="b24ui.root({ class: [props.b24ui?.root, props.class] })" - :style="[scrollShadowStyle, isExternalScroll ? { overflow: 'visible' } : undefined]" + :style="scrollShadowStyle" >