Skip to content
Closed
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
28 changes: 27 additions & 1 deletion docs/content/docs/2.components/scroll-area.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ title: ScrollArea
description: A flexible scroll container with virtualization support.
category: data
links:
- label: Reka UI
avatar:
src: https://github.com/unovue.png
loading: lazy
to: https://reka-ui.com/docs/components/scroll-area
- label: TanStack Virtual
avatar:
src: https://github.com/tanstack.png
Expand Down Expand Up @@ -63,6 +68,20 @@ options:
---
::

### Type

Use the `type` prop to control the scrollbar visibility behavior. Defaults to `hover`.

- `auto` - Scrollbars are visible when content overflows.
- `always` - Scrollbars are always visible regardless of overflow.
- `scroll` - Scrollbars are visible when the user is scrolling.
- `hover` - Scrollbars are visible when scrolling or hovering over the scroll area.
- `glimpse` - Briefly shows scrollbars when entering the scroll area, then hides them.

### Scroll Hide Delay

Use the `scroll-hide-delay` prop to control the delay (in milliseconds) before scrollbars hide after the user stops interacting. Only applies when `type` is `scroll` or `hover`. Defaults to `600`.

### Virtualize

Use the `virtualize` prop to render only the items currently in view, significantly boosting performance when working with large datasets.
Expand Down Expand Up @@ -217,8 +236,15 @@ This will give you access to the following:

| Name | Type | Description |
| ---- | ---- | ----------- |
| `$el`{lang="ts-type"} | `HTMLElement`{lang="ts-type"} | The root element of the component. |
| `$el`{lang="ts-type"} | `HTMLElement \| undefined`{lang="ts-type"} | The scrollable viewport element. Alias for `viewport`. |
| `viewport`{lang="ts-type"} | `HTMLElement \| undefined`{lang="ts-type"} | The scrollable viewport element. Use this for composables like `useInfiniteScroll` or `useElementSize`. |
| `virtualizer`{lang="ts-type"} | `Ref<Virtualizer> \| undefined`{lang="ts-type"} | The [TanStack Virtual](https://tanstack.com/virtual/latest/docs/api/virtualizer) virtualizer instance (`undefined` if virtualization is disabled). |
| `scrollTop()`{lang="ts-type"} | `() => void`{lang="ts-type"} | Scroll the viewport to the top. |
| `scrollTopLeft()`{lang="ts-type"} | `() => void`{lang="ts-type"} | Scroll the viewport to the top-left corner. |

::note
The scrollbar appearance can be customized via the `ui` prop using the `scrollbar`, `thumb`, and `corner` slots.
::

## Theme

Expand Down
132 changes: 85 additions & 47 deletions src/runtime/components/ScrollArea.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import type { ComponentPublicInstance, CSSProperties, VNode } from 'vue'
import type { AppConfig } from '@nuxt/schema'
import type { ScrollAreaRootProps } from 'reka-ui'
import type { VirtualItem, VirtualizerOptions } from '@tanstack/vue-virtual'
import theme from '#build/ui/scroll-area'
import type { ComponentConfig } from '../types/tv'
Expand Down Expand Up @@ -38,7 +39,8 @@ export interface ScrollAreaVirtualizeOptions extends Partial<Omit<

export type ScrollAreaItem = any

export interface ScrollAreaProps<T extends ScrollAreaItem = ScrollAreaItem> {
export interface ScrollAreaProps<T extends ScrollAreaItem = ScrollAreaItem>
extends Pick<ScrollAreaRootProps, 'type' | 'scrollHideDelay'> {
/**
* The element or component this component should render as.
* @defaultValue 'div'
Expand Down Expand Up @@ -82,7 +84,12 @@ export interface ScrollAreaEmits {

<script setup lang="ts" generic="T extends ScrollAreaItem">
import { computed, onMounted, onUnmounted, toRef, useTemplateRef, watch } from 'vue'
import { Primitive } from 'reka-ui'
import {
ScrollAreaRoot, ScrollAreaViewport,
ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaCorner,
useForwardProps
} from 'reka-ui'
import { reactivePick } from '@vueuse/core'
import { defu } from 'defu'
import { useVirtualizer } from '@tanstack/vue-virtual'
import { useAppConfig } from '#imports'
Expand All @@ -105,7 +112,9 @@ const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.scrollArea |
orientation: props.orientation
}))

const rootRef = useTemplateRef<ComponentPublicInstance>('rootRef')
const rootProps = useForwardProps(reactivePick(props, 'as', 'type', 'scrollHideDelay'))
const rootRef = useTemplateRef<InstanceType<typeof ScrollAreaRoot>>('rootRef')
const viewportRef = useTemplateRef<InstanceType<typeof ScrollAreaViewport>>('viewportRef')

const isRtl = computed(() => dir.value === 'rtl')
const isHorizontal = computed(() => props.orientation === 'horizontal')
Expand Down Expand Up @@ -159,7 +168,7 @@ const virtualizer = !!props.virtualize && useVirtualizer({
get count() {
return props.items?.length || 0
},
getScrollElement: () => rootRef.value?.$el,
getScrollElement: () => viewportRef.value?.viewportElement as Element ?? null,
get horizontal() {
return isHorizontal.value
},
Expand All @@ -173,9 +182,10 @@ const virtualItems = computed<VirtualItem[]>(() => virtualizer ? virtualizer.val
const totalSize = computed(() => virtualizer ? virtualizer.value.getTotalSize() : 0)

const virtualViewportStyle = computed<CSSProperties>(() => ({
position: 'relative',
position: isHorizontal.value ? 'absolute' : 'relative',
insetBlock: isHorizontal.value ? 0 : undefined,
inlineSize: isHorizontal.value ? `${totalSize.value}px` : '100%',
blockSize: isVertical.value ? `${totalSize.value}px` : '100%'
blockSize: isVertical.value ? `${totalSize.value}px` : undefined
}))

function getVirtualItemStyle(virtualItem: VirtualItem): CSSProperties {
Expand Down Expand Up @@ -211,7 +221,7 @@ let rafId: number | null = null

onMounted(() => {
if (virtualizer) {
const el = rootRef.value?.$el
const el = viewportRef.value?.viewportElement
if (el) {
resizeObserver = new ResizeObserver(() => {
if (rafId !== null) return
Expand Down Expand Up @@ -258,61 +268,89 @@ function getItemKey(item: T, index: number) {

defineExpose({
get $el() {
return rootRef.value?.$el as HTMLElement
return rootRef.value?.viewport as HTMLElement | undefined
},
virtualizer: virtualizer || undefined
get viewport() {
return rootRef.value?.viewport as HTMLElement | undefined
},
virtualizer: virtualizer || undefined,
scrollTop: () => rootRef.value?.scrollTop(),
scrollTopLeft: () => rootRef.value?.scrollTopLeft()
})
</script>

<template>
<Primitive
<ScrollAreaRoot
ref="rootRef"
:as="as"
v-bind="rootProps"
:dir="dir"
data-slot="root"
:data-orientation="orientation"
:class="ui.root({ class: [uiProp?.root, props.class] })"
>
<template v-if="virtualizer">
<div
data-slot="viewport"
:class="ui.viewport({ class: uiProp?.viewport })"
:style="virtualViewportStyle"
>
<ScrollAreaViewport ref="viewportRef" class="size-full">
<template v-if="virtualizer">
<div
v-for="virtualItem in virtualItems"
:key="String(virtualItem.key)"
:ref="measureElement"
:data-index="virtualItem.index"
data-slot="item"
:class="ui.item({ class: uiProp?.item })"
:style="getVirtualItemStyle(virtualItem)"
data-slot="viewport"
:class="ui.viewport({ class: uiProp?.viewport })"
:style="virtualViewportStyle"
>
<slot
:item="(items?.[virtualItem.index] as T)"
:index="virtualItem.index"
:virtual-item="virtualItem"
/>
</div>
</div>
</template>

<template v-else>
<div data-slot="viewport" :class="ui.viewport({ class: uiProp?.viewport })">
<template v-if="items">
<div
v-for="(item, index) in items"
:key="getItemKey(item, index)"
v-for="virtualItem in virtualItems"
:key="String(virtualItem.key)"
:ref="measureElement"
:data-index="virtualItem.index"
data-slot="item"
:class="ui.item({ class: uiProp?.item })"
:style="getVirtualItemStyle(virtualItem)"
>
<slot :item="item" :index="index" />
<slot
:item="(items?.[virtualItem.index] as T)"
:index="virtualItem.index"
:virtual-item="virtualItem"
/>
</div>
</template>

<template v-else>
<slot :item="({} as T)" :index="0" />
</template>
</div>
</template>
</Primitive>
</div>
</template>

<template v-else>
<div data-slot="viewport" :class="ui.viewport({ class: uiProp?.viewport })">
<template v-if="items">
<div
v-for="(item, index) in items"
:key="getItemKey(item, index)"
data-slot="item"
:class="ui.item({ class: uiProp?.item })"
>
<slot :item="item" :index="index" />
</div>
</template>

<template v-else>
<slot :item="({} as T)" :index="0" />
</template>
</div>
</template>
</ScrollAreaViewport>

<ScrollAreaScrollbar
force-mount
orientation="vertical"
data-slot="scrollbar"
:class="ui.scrollbar({ class: uiProp?.scrollbar })"
>
<ScrollAreaThumb data-slot="thumb" :class="ui.thumb({ class: uiProp?.thumb })" />
</ScrollAreaScrollbar>

<ScrollAreaScrollbar
force-mount
orientation="horizontal"
data-slot="scrollbar"
:class="ui.scrollbar({ class: uiProp?.scrollbar })"
>
<ScrollAreaThumb data-slot="thumb" :class="ui.thumb({ class: uiProp?.thumb })" />
</ScrollAreaScrollbar>

<ScrollAreaCorner data-slot="corner" :class="ui.corner({ class: uiProp?.corner })" />
</ScrollAreaRoot>
</template>
11 changes: 7 additions & 4 deletions src/theme/scroll-area.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
export default {
slots: {
root: 'relative',
root: 'overflow-hidden',
viewport: 'relative flex',
item: ''
item: '',
scrollbar: 'flex touch-none select-none p-0.5 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:h-2.5 data-[orientation=vertical]:flex-col transition-opacity duration-160 ease-out data-[state=visible]:opacity-100 data-[state=hidden]:opacity-0',
thumb: 'relative rounded-full bg-[var(--ui-bg-accented)]',
corner: ''
},
variants: {
orientation: {
vertical: {
root: 'overflow-y-auto overflow-x-hidden',
root: '',
viewport: 'flex-col',
item: ''
},
horizontal: {
root: 'overflow-x-auto overflow-y-hidden',
root: '',
viewport: 'flex-row',
item: ''
}
Expand Down
9 changes: 9 additions & 0 deletions test/components/ScrollArea.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ describe('ScrollArea', () => {
['with as', { props: { ...props, as: 'section' } }],
['with class', { props: { ...props, class: 'absolute' } }],
['with ui', { props: { ...props, ui: { viewport: 'gap-4' } } }],
['with type auto', { props: { ...props, type: 'auto' } }],
['with type always', { props: { ...props, type: 'always' } }],
['with type scroll', { props: { ...props, type: 'scroll' } }],
['with type hover', { props: { ...props, type: 'hover' } }],
['with type glimpse', { props: { ...props, type: 'glimpse' } }],
['with scrollHideDelay', { props: { ...props, type: 'scroll', scrollHideDelay: 1000 } }],
['with ui scrollbar', { props: { ...props, ui: { scrollbar: 'w-4' } } }],
['with ui thumb', { props: { ...props, ui: { thumb: 'bg-red-500' } } }],
['with ui corner', { props: { ...props, ui: { corner: 'bg-gray-100' } } }],
// Slots
['with default slot', { slots: { default: () => 'Default slot' } }]
])
Expand Down
Loading
Loading