Skip to content
Merged
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
70 changes: 70 additions & 0 deletions .sync/log/7e6d0f767666be718ae32709a029cfe456fb660b.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 9 additions & 3 deletions .sync/nuxt-ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)"
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import ArrowToTheTopIcon from '@bitrix24/b24icons-vue/actions/ArrowToTheTopIcon'
import ArrowToTheLeftIcon from '@bitrix24/b24icons-vue/actions/ArrowToTheLeftIcon'

const props = withDefaults(defineProps<{
orientation?: 'vertical' | 'horizontal'
}>(), {
orientation: 'vertical'
})

type User = {
id: number
Expand All @@ -17,63 +24,75 @@ const { data: users } = useLazyFetch('https://dummyjson.com/users?limit=100&sele
server: false
})

// The container owns the scroll; the list virtualizes against it so everything shares one scrollbar.
const isHorizontal = computed(() => props.orientation === 'horizontal')

// The container owns the scroll; the list virtualizes against it so the header and cards share one scrollbar.
const container = useTemplateRef('container')
const title = useTemplateRef('title')

const ITEM_SIZE = 88
// Item size along the scroll axis: card width when horizontal, row height when vertical.
const itemSize = computed(() => isHorizontal.value ? 256 : 88)
const getScrollElement = () => container.value

// `scrollMargin` is the list's offset within the scroll element (border-box height of the header above it).
const { height: titleHeight } = useElementSize(title, undefined, { box: 'border-box' })
const scrollMargin = computed(() => titleHeight.value)
// `scrollMargin` is the title's offset along the scroll axis: its width when it sits left of the cards, its height when above.
const { width: titleWidth, height: titleHeight } = useElementSize(title, undefined, { box: 'border-box' })
const scrollMargin = computed(() => isHorizontal.value ? titleWidth.value : titleHeight.value)

function scrollToTop() {
container.value?.scrollTo({ top: 0, behavior: 'smooth' })
function scrollToStart() {
container.value?.scrollTo(isHorizontal.value ? { left: 0, behavior: 'smooth' } : { top: 0, behavior: 'smooth' })
}
</script>

<template>
<div
ref="container"
class="w-full h-128 overflow-y-auto scrollbar-thin"
:class="isHorizontal ? 'w-full overflow-x-auto scrollbar-thin' : 'w-full h-128 overflow-y-auto scrollbar-thin'"
>
<div
ref="title"
class="sticky top-0 z-10 flex items-end justify-between gap-4 px-6 py-4 border-b border-muted bg-elevated/50 backdrop-blur"
>
<div>
<h2 class="text-2xl font-bold">
Members
</h2>
<p class="text-muted">
This header and the virtualized list share one scrollbar.
</p>
<div :class="isHorizontal && 'flex'">
<div
ref="title"
class="z-10 flex gap-4 bg-elevated/50 backdrop-blur"
:class="isHorizontal
? 'sticky left-0 w-72 shrink-0 flex-col justify-center p-6 border-r border-muted'
: 'sticky top-0 items-end justify-between px-6 py-4 border-b border-muted'"
>
<div>
<h2 class="text-2xl font-bold">
Members
</h2>
<p class="text-muted">
This header scrolls away with the cards, sharing one scrollbar.
</p>
</div>
<B24Button
:icon="isHorizontal ? ArrowToTheLeftIcon : ArrowToTheTopIcon"
color="air-tertiary"
:class="isHorizontal && 'self-start'"
:label="isHorizontal ? 'Start' : 'Top'"
@click="scrollToStart"
/>
</div>
<B24Button
:icon="ArrowToTheTopIcon"
color="air-tertiary"
label="Top"
@click="scrollToTop"
/>
</div>

<B24ScrollArea
v-slot="{ item }"
:items="users"
:virtualize="{ scrollMargin, getScrollElement, estimateSize: ITEM_SIZE, skipMeasurement: true }"
>
<B24PageCard
orientation="horizontal"
class="rounded-none"
<B24ScrollArea
v-slot="{ item }"
:orientation="orientation"
:items="users"
:class="isHorizontal && 'h-48 shrink-0'"
:virtualize="{ scrollMargin, getScrollElement, estimateSize: itemSize, skipMeasurement: isHorizontal }"
>
<B24User
:name="`${item.firstName} ${item.lastName}`"
:description="item.email"
:avatar="{ src: item.image, alt: item.firstName, loading: 'lazy' as const }"
size="lg"
/>
</B24PageCard>
</B24ScrollArea>
<B24PageCard
orientation="horizontal"
class="rounded-none h-full"
:class="isHorizontal && 'w-64'"
>
<B24User
:name="`${item.firstName} ${item.lastName}`"
:description="item.email"
:avatar="{ src: item.image, alt: item.firstName, loading: 'lazy' as const }"
size="lg"
/>
</B24PageCard>
</B24ScrollArea>
</div>
</div>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { h, resolveComponent } from 'vue'
import { useElementSize } from '@vueuse/core'
import type { TableColumn } from '@bitrix24/b24ui-nuxt'

const B24Badge = resolveComponent('B24Badge')

type Payment = {
id: string
date: string
customer: string
email: string
method: string
status: 'paid' | 'failed' | 'refunded'
amount: number
}

const names = ['James Anderson', 'Mary Johnson', 'Robert Williams', 'Patricia Brown', 'Michael Davis']
const methods = ['Visa', 'Mastercard', 'PayPal', 'Amex']
const statuses = ['paid', 'failed', 'refunded'] as const

const data = ref<Payment[]>(Array.from({ length: 1000 }, (_, i) => {
const customer = names[i % names.length]!
return {
id: `4600-${i}`,
date: '2024-03-11T15:30:00',
customer,
email: `${customer.toLowerCase().replace(' ', '.')}@example.com`,
method: methods[i % methods.length]!,
status: statuses[i % statuses.length]!,
amount: 100 + ((i * 37) % 900)
}
}))

// The container owns the scroll; the table virtualizes against it so the header and rows share one scrollbar.
const container = useTemplateRef('container')
const title = useTemplateRef('title')
const getScrollElement = () => container.value

// `scrollMargin` is the table's offset within the scroll element (border-box height of the title above it).
const { height: titleHeight } = useElementSize(title, undefined, { box: 'border-box' })

const columns: TableColumn<Payment>[] = [{
accessorKey: 'id',
header: '#',
cell: ({ row }) => `#${row.getValue('id')}`
}, {
accessorKey: 'date',
header: 'Date',
cell: ({ row }) => new Date(row.getValue('date')).toLocaleString('en-US', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'UTC'
})
}, {
accessorKey: 'customer',
header: 'Customer'
}, {
accessorKey: 'email',
header: 'Email'
}, {
accessorKey: 'method',
header: 'Method'
}, {
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const color = ({
paid: 'air-primary-success' as const,
failed: 'air-primary-alert' as const,
refunded: 'air-primary' as const
})[row.getValue('status') as string]

return h(B24Badge, { class: 'capitalize', color }, () => row.getValue('status'))
}
}, {
accessorKey: 'amount',
header: 'Amount',
meta: {
class: {
th: 'text-right',
td: 'text-right font-medium'
}
},
cell: ({ row }) => new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR'
}).format(Number.parseFloat(row.getValue('amount')))
}]
</script>

<template>
<div
ref="container"
class="w-full h-96 overflow-auto"
>
<div
ref="title"
class="sticky left-0 z-10 flex items-end justify-between gap-4 p-6 bg-elevated/50"
>
<div>
<h2 class="text-2xl font-bold">
Payments
</h2>
<p class="text-muted">
The title stays put while the wide table scrolls both axes under one scrollbar.
</p>
</div>
<B24Badge
color="air-tertiary-no-accent"
:label="`${data.length} rows`"
/>
</div>

<B24Table
sticky
:data="data"
:columns="columns"
:virtualize="{ scrollMargin: titleHeight, getScrollElement }"
:b24ui="{ base: 'min-w-[1200px]' }"
/>
</div>
</template>
9 changes: 8 additions & 1 deletion docs/content/docs/2.components/scroll-area.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading