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
3 changes: 3 additions & 0 deletions .sync/icon-map.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
{
"$schema-note": "Map upstream iconify names (nuxt/ui defaults) to b24-icons components. Flat, alphabetical. Seed values verified against current src/runtime/components usage; extend during porting (see PORTING.md §4).",
"i-lucide-activity": "@bitrix24/b24icons-vue/main/ActivityIcon",
"i-lucide-arrow-up-to-line": "@bitrix24/b24icons-vue/actions/ArrowToTheTopIcon",
"i-lucide-check": "@bitrix24/b24icons-vue/main/CheckIcon",
"i-lucide-chevron-down": "@bitrix24/b24icons-vue/outline/ChevronDownSIcon",
"i-lucide-chevron-up": "@bitrix24/b24icons-vue/outline/ChevronTopSIcon",
"i-lucide-house": "@bitrix24/b24icons-vue/main/HomePageIcon",
"i-lucide-search": "@bitrix24/b24icons-vue/main/Search2Icon",
"i-lucide-loader-circle": "@bitrix24/b24icons-vue/animated/LoaderWaitIcon",
"i-lucide-menu": "@bitrix24/b24icons-vue/outline/HamburgerMenuIcon",
"i-lucide-minus": "@bitrix24/b24icons-vue/actions/Minus20Icon",
Expand Down
72 changes: 72 additions & 0 deletions .sync/log/a84de85b2a017c8692e6da889291c06d5015c115.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Port: feat(ScrollArea): add `getScrollElement` virtualize option (#6650)

**Upstream:** `a84de85b2a017c8692e6da889291c06d5015c115` (nuxt/ui)
**Decision:** port

## Upstream change
Adds a `getScrollElement` option to `ScrollAreaVirtualizeOptions` so the
component can virtualize against an **external** scroll element (an ancestor
container) instead of its own root viewport. Implementation in `ScrollArea.vue`:

- new `getScrollElement?: () => Element | null` prop (removed from the `Omit`
exclusion list);
- `isExternalScroll` computed + a `getScrollElement()` resolver (external el
when provided, else `rootRef.$el`);
- virtualizer config uses the resolver instead of `() => rootRef.value?.$el`;
- `getVirtualItemStyle` subtracts `scrollMargin` from `virtualItem.start` in
external mode (`offset`) so items sit inline;
- the resize-observer moved from a one-shot `onMounted` to a
`watch(getScrollElement, …, { immediate: true })` so it re-observes when the
scroll element changes (`onMounted` import dropped);
- root gets `overflow: visible` in external mode (it no longer owns the scroll).

Plus a `renderEach` spec case and a docs section + example.

## b24ui port — component 1:1, docs adapted
**`ScrollArea.vue`** — applied verbatim; b24ui's pre-change file matched
upstream's structure. Only b24ui surface difference is the `:class` using
`b24ui.root`/`props.b24ui?.root` (pre-existing, untouched); the new
`:style="[scrollShadowStyle, isExternalScroll ? { overflow: 'visible' } : undefined]"`
and all script changes are identical.

**Spec** — added `['with virtualize external scroll element', …]` `renderEach`
case verbatim (`getScrollElement: () => document.body, scrollMargin: 20`). 2
snapshots regenerated (`ScrollArea` + `-vue`); the new case correctly renders
`style="overflow: visible;"` on the root — the feature demonstrating itself.

**Docs — adapted to b24ui, deliberately trimmed.** Ported the
`### With external scroll element` md section (badge `New` per b24ui convention,
not upstream's `Soon`) with the same explanation + `note`/`caution`. The
example was **trimmed**: upstream's 163-line demo bundles an incidental
"find" toolbar (search input + prev/next-match buttons + result counter +
`UBadge`/`UFieldGroup`) that is demo sugar, not part of the feature, and whose
`neutral`/`subtle`/`outline` + iconify mappings carry design-judgment risk in a
non-test-validated file. The b24ui example keeps the **feature core verbatim** —
an external scroll `container` owning the scroll, a sticky header measured via
`useElementSize` (border-box) feeding `scrollMargin`, and `B24ScrollArea` with
`{ getScrollElement, scrollMargin, estimateSize, skipMeasurement }` rendering
`B24PageCard`/`B24User` rows over the same `dummyjson` fetch b24ui's
`ScrollAreaInfiniteScrollExample` already uses — plus a single "Top" button
(`B24Button`, `ArrowToTheTopIcon`, `color="air-tertiary"`) calling
`container.scrollTo`. `useElementSize` is explicitly imported from
`@vueuse/core` (vueuse is not auto-imported in b24ui docs).

### Maps (§4)
Added three `icon-map.json` entries used by / parallel to the example's icons:
- `i-lucide-arrow-up-to-line` → `actions/ArrowToTheTopIcon`
- `i-lucide-chevron-up` → `outline/ChevronTopSIcon` (b24icons names up-chevrons
"Top", not "Up")
- `i-lucide-search` → `main/Search2Icon`

## Tests
Full suite 225 files, **5145** passed (+2 vs prior 5143 = the new case ×
nuxt/vue) / 6 skipped. 2 snapshots updated.

## Verify (CI=true)
`dev:prepare` · `lint` · `typecheck` (docs example compiles) · `test` ·
`build` — all green.

## Deviations (for reviewer)
- Docs example trimmed (no find-toolbar) — see above. Faithfully demonstrates
`getScrollElement` + `scrollMargin`; if the full find-toolbar demo is wanted,
it can follow once the toolbar's icon/color/variant mappings are settled.
10 changes: 8 additions & 2 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": "df099a5e69a8207db13ac39a5bed335b26fd7640",
"cursor": "a84de85b2a017c8692e6da889291c06d5015c115",
"_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 @@ -809,10 +809,16 @@
"summary": "chore(deps): update all non-major dependencies (#6651) — §2 per-dep mirror across b24ui manifests (incl. 4th demo playground, nuxt<->demo parity kept): pnpm 11.8.0->11.9.0 (packageManager), @tanstack/vue-virtual 3.13.29->3.13.30, unplugin 3.0.0->3.2.0 (root + pnpm-workspace.yaml catalog), ai 6.0.208->6.0.214 (root/docs/demo/nuxt), eslint 10.5.0->10.6.0, vue 3.5.38->3.5.39 (root/repl/vue), @ai-sdk/vue 3.0.208->3.0.214 (docs/demo/nuxt), vite 7.3.5->7.3.6 (repl/vue). Skipped (absent in b24ui): @nuxt/icon, @ai-sdk/anthropic, @ai-sdk/gateway, @iconify-json/simple-icons. +pnpm_config_verify_deps_before_run:false to .github/workflows/ci.yml (b24ui analog of upstream module.yml; pnpm 11.9.0 verifies deps pre-run). Lockfile regen under gate via corepack pnpm 11.9.0, lockfileVersion 9.0, +1474/-561 (vue/eslint/vite transitive graph re-pin, no major churn). vue patch -> no snapshot churn"
},
"df099a5e69a8207db13ac39a5bed335b26fd7640": {
"pr": 232,
"b24ui_sha": "49d01ae6",
"decision": "port",
"summary": "chore(deps): update actions/checkout action to v7 (#6655) — bump actions/checkout@v6->v7 in b24ui's 3 workflows (ci.yml, deploy.yml, npm-publish.yml). Upstream touched module.yml/release.yml/reproduire.yml (b24ui has none); the reproduire/Hebilicious@v1 un-pin is N/A (no reproduction-label workflow). CI-only, no src impact; checkout@v7 exercised by this PR's own ci run. No snapshot churn"
},
"a84de85b2a017c8692e6da889291c06d5015c115": {
"pr": null,
"b24ui_sha": "pending-merge",
"decision": "port",
"summary": "chore(deps): update actions/checkout action to v7 (#6655) — bump actions/checkout@v6->v7 in b24ui's 3 workflows (ci.yml, deploy.yml, npm-publish.yml). Upstream touched module.yml/release.yml/reproduire.yml (b24ui has none); the reproduire/Hebilicious@v1 un-pin is N/A (no reproduction-label workflow). CI-only, no src impact; checkout@v7 exercised by this PR's own ci run. No snapshot churn"
"summary": "feat(ScrollArea): add getScrollElement virtualize option (#6650) — ScrollArea.vue: new getScrollElement?:()=>Element|null in ScrollAreaVirtualizeOptions (removed from Omit); isExternalScroll computed + getScrollElement resolver (external el else rootRef.$el); virtualizer uses resolver; getVirtualItemStyle subtracts scrollMargin (offset) in external mode; resize-observer onMounted->watch(getScrollElement,{immediate:true}) (onMounted import dropped); root gets overflow:visible in external mode. Component applied 1:1 (b24ui matched pre-change; only b24ui.root/$attrs surface diff untouched). +renderEach spec case verbatim, 2 snapshots regen (new case renders overflow:visible = feature self-demo). Docs ADAPTED+trimmed: md 'With external scroll element' section (badge New not Soon) + b24ui example keeping the feature core (external container + useElementSize border-box scrollMargin + B24ScrollArea getScrollElement/scrollMargin over dummyjson fetch like InfiniteScroll example + Top button B24Button/ArrowToTheTopIcon/air-tertiary); DROPPED upstream's incidental find-toolbar (search/prev/next/counter sugar, not the feature, risky neutral/subtle/outline+icon mappings). useElementSize imported from @vueuse/core (not auto-imported in b24ui docs). +3 icon-map entries: arrow-up-to-line->actions/ArrowToTheTopIcon, chevron-up->outline/ChevronTopSIcon, search->main/Search2Icon. Tests 5145 passed (+2). Deviation noted in PR"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import ArrowToTheTopIcon from '@bitrix24/b24icons-vue/actions/ArrowToTheTopIcon'

type User = {
id: number
firstName: string
lastName: string
email: string
image: string
}

const { data: users } = useLazyFetch('https://dummyjson.com/users?limit=100&select=firstName,lastName,email,image', {
key: 'scroll-area-external-scroll-users',
transform: (data?: { users: User[] }) => data?.users ?? [],
default: () => [] as User[],
server: false
})

// The container owns the scroll; the list virtualizes against it so everything shares one scrollbar.
const container = useTemplateRef('container')
const title = useTemplateRef('title')

const ITEM_SIZE = 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)

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

<template>
<div
ref="container"
class="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>
<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"
>
<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>
</template>
22 changes: 22 additions & 0 deletions docs/content/docs/2.components/scroll-area.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,28 @@ class: '!p-0'
---
::

### 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 component's own viewport. Set `scrollMargin` to the list's offset from the scroll element's start (e.g. the height of the content above it).

::component-example
---
prettier: true
collapse: true
overflowHidden: true
name: 'scroll-area-external-scroll-example'
class: '!p-0'
---
::

::note
Because the container owns the scroll, the toolbar's "Top" button scrolls it directly with `container.scrollTo`.
::

::caution
The `shadow` prop has no effect in this mode, since the root no longer owns the scroll. Apply your own fade to the scroll container instead.
::

### With programmatic scroll

You can use the exposed `virtualizer` to programmatically control scroll position.
Expand Down
59 changes: 38 additions & 21 deletions src/runtime/components/ScrollArea.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ type ScrollArea = ComponentConfig<typeof theme, AppConfig, 'scrollArea'>

export interface ScrollAreaVirtualizeOptions extends Partial<Omit<
VirtualizerOptions<Element, Element>,
'count' | 'getScrollElement' | 'horizontal' | 'isRtl' | 'estimateSize' | 'lanes' | 'enabled'
'count' | 'horizontal' | 'isRtl' | 'estimateSize' | 'lanes' | 'enabled'
>> {
/**
* Virtualize against an external scroll element instead of the component's own root.
* Pair with `scrollMargin` set to the content's offset from the scroll element's start.
* @defaultValue undefined
*/
getScrollElement?: () => Element | null
/**
* Estimated size (in px) of each item along the scroll axis. Can be a number or a function.
* @defaultValue 100
Expand Down Expand Up @@ -87,7 +93,7 @@ export interface ScrollAreaEmits {
</script>

<script setup lang="ts" generic="T extends ScrollAreaItem">
import { computed, onMounted, onUnmounted, toRef, useTemplateRef, watch } from 'vue'
import { computed, onUnmounted, toRef, useTemplateRef, watch } from 'vue'
import { Primitive } from 'reka-ui'
import { defu } from 'defu'
import { useVirtualizer } from '@tanstack/vue-virtual'
Expand Down Expand Up @@ -131,6 +137,12 @@ 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

const virtualizerProps = toRef(() => {
const options = typeof props.virtualize === 'boolean' ? {} : props.virtualize

Expand Down Expand Up @@ -179,7 +191,7 @@ const virtualizer = !!props.virtualize && useVirtualizer({
get count() {
return props.items?.length || 0
},
getScrollElement: () => rootRef.value?.$el,
getScrollElement,
get horizontal() {
return isHorizontal.value
},
Expand All @@ -202,6 +214,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)

// For cross-axis gaps: calculate size and position accounting for gaps between lanes
// laneSize = (100% - (lanes - 1) * gap) / lanes
Expand All @@ -220,30 +234,33 @@ function getVirtualItemStyle(virtualItem: VirtualItem): CSSProperties {
blockSize: isHorizontal.value ? (hasLanes ? laneSize : '100%') : undefined,
inlineSize: isVertical.value ? (hasLanes ? laneSize : '100%') : undefined,
transform: isHorizontal.value
? `translateX(${isRtl.value ? -virtualItem.start : virtualItem.start}px)`
: `translateY(${virtualItem.start}px)`
? `translateX(${isRtl.value ? -offset : offset}px)`
: `translateY(${offset}px)`
}
}

// Recalculate layout on container resize (e.g. estimateSize depends on lane width)
// Recalculate layout when the scroll viewport resizes (e.g. estimateSize depends on lane width).
// Re-observe if the scroll element changes.
let resizeObserver: ResizeObserver | null = null
let rafId: number | null = null

onMounted(() => {
if (virtualizer) {
const el = rootRef.value?.$el
if (el) {
resizeObserver = new ResizeObserver(() => {
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
rafId = null
virtualizer.value.measure()
})
watch(
getScrollElement,
(el) => {
resizeObserver?.disconnect()
resizeObserver = null
if (!virtualizer || !el) return
resizeObserver = new ResizeObserver(() => {
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
rafId = null
virtualizer.value.measure()
})
resizeObserver.observe(el)
}
}
})
})
resizeObserver.observe(el)
},
{ immediate: true }
)

onUnmounted(() => {
if (rafId !== null) {
Expand Down Expand Up @@ -291,7 +308,7 @@ defineExpose({
data-slot="root"
:data-orientation="props.orientation"
:class="b24ui.root({ class: [props.b24ui?.root, props.class] })"
:style="scrollShadowStyle"
:style="[scrollShadowStyle, isExternalScroll ? { overflow: 'visible' } : undefined]"
>
<template v-if="virtualizer">
<div
Expand Down
1 change: 1 addition & 0 deletions test/components/ScrollArea.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('ScrollArea', () => {
['with virtualize padding', { props: { ...props, virtualize: { paddingStart: 20, paddingEnd: 20 } } }],
['with virtualize lanes', { props: { ...props, virtualize: { lanes: 3 } } }],
['with virtualize scrollMargin', { props: { ...props, virtualize: { scrollMargin: 10 } } }],
['with virtualize external scroll element', { props: { ...props, virtualize: { getScrollElement: () => document.body, scrollMargin: 20 } } }],
['with shadow', { props: { ...props, shadow: true } }],
['with as', { props: { ...props, as: 'section' } }],
['with class', { props: { ...props, class: 'absolute' } }],
Expand Down
6 changes: 6 additions & 0 deletions test/components/__snapshots__/ScrollArea-vue.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ exports[`ScrollArea > renders with virtualize boolean correctly 1`] = `
</div>"
`;

exports[`ScrollArea > renders with virtualize external scroll element correctly 1`] = `
"<div data-slot="root" data-orientation="vertical" class="relative overflow-y-auto overflow-x-hidden" style="overflow: visible;">
<div data-slot="viewport" class="relative flex flex-col" style="position: relative; inline-size: 100%; block-size: 300px;"></div>
</div>"
`;

exports[`ScrollArea > renders with virtualize gap correctly 1`] = `
"<div data-slot="root" data-orientation="vertical" class="relative overflow-y-auto overflow-x-hidden">
<div data-slot="viewport" class="relative flex flex-col" style="position: relative; inline-size: 100%; block-size: 320px;"></div>
Expand Down
6 changes: 6 additions & 0 deletions test/components/__snapshots__/ScrollArea.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ exports[`ScrollArea > renders with virtualize boolean correctly 1`] = `
</div>"
`;

exports[`ScrollArea > renders with virtualize external scroll element correctly 1`] = `
"<div data-slot="root" data-orientation="vertical" class="relative overflow-y-auto overflow-x-hidden" style="overflow: visible;">
<div data-slot="viewport" class="relative flex flex-col" style="position: relative; inline-size: 100%; block-size: 300px;"></div>
</div>"
`;

exports[`ScrollArea > renders with virtualize gap correctly 1`] = `
"<div data-slot="root" data-orientation="vertical" class="relative overflow-y-auto overflow-x-hidden">
<div data-slot="viewport" class="relative flex flex-col" style="position: relative; inline-size: 100%; block-size: 320px;"></div>
Expand Down