diff --git a/docs/app/components/content/ComponentExample.vue b/docs/app/components/content/ComponentExample.vue index 392edcf298..f6983b01c1 100644 --- a/docs/app/components/content/ComponentExample.vue +++ b/docs/app/components/content/ComponentExample.vue @@ -44,6 +44,7 @@ const props = withDefaults(defineProps<{ * A list of variable props to link to the component. */ options?: Array<{ + type?: string alias?: string name: string label: string @@ -195,6 +196,7 @@ const urlSearchParams = computed(() => { + + + +

Custom content without using the items prop.

+
+ + +

Any content can be placed here and it will be scrollable.

+
+ + +

You can mix different components and layouts as needed.

+
+
+ diff --git a/docs/app/components/content/examples/scroll-area/ScrollAreaExample.vue b/docs/app/components/content/examples/scroll-area/ScrollAreaExample.vue new file mode 100644 index 0000000000..fa8a47ad79 --- /dev/null +++ b/docs/app/components/content/examples/scroll-area/ScrollAreaExample.vue @@ -0,0 +1,41 @@ + + + diff --git a/docs/app/components/content/examples/scroll-area/ScrollAreaInfiniteScrollExample.vue b/docs/app/components/content/examples/scroll-area/ScrollAreaInfiniteScrollExample.vue new file mode 100644 index 0000000000..e0c615e055 --- /dev/null +++ b/docs/app/components/content/examples/scroll-area/ScrollAreaInfiniteScrollExample.vue @@ -0,0 +1,81 @@ + + + diff --git a/docs/app/components/content/examples/scroll-area/ScrollAreaOrientationExample.vue b/docs/app/components/content/examples/scroll-area/ScrollAreaOrientationExample.vue new file mode 100644 index 0000000000..643d794318 --- /dev/null +++ b/docs/app/components/content/examples/scroll-area/ScrollAreaOrientationExample.vue @@ -0,0 +1,32 @@ + + + diff --git a/docs/app/components/content/examples/scroll-area/ScrollAreaScrollToExample.vue b/docs/app/components/content/examples/scroll-area/ScrollAreaScrollToExample.vue new file mode 100644 index 0000000000..e3e9acfa95 --- /dev/null +++ b/docs/app/components/content/examples/scroll-area/ScrollAreaScrollToExample.vue @@ -0,0 +1,57 @@ + + + diff --git a/docs/app/components/content/examples/scroll-area/ScrollAreaVariableHeightExample.vue b/docs/app/components/content/examples/scroll-area/ScrollAreaVariableHeightExample.vue new file mode 100644 index 0000000000..f9411c631b --- /dev/null +++ b/docs/app/components/content/examples/scroll-area/ScrollAreaVariableHeightExample.vue @@ -0,0 +1,29 @@ + + + diff --git a/docs/app/components/content/examples/scroll-area/ScrollAreaVirtualizeExample.vue b/docs/app/components/content/examples/scroll-area/ScrollAreaVirtualizeExample.vue new file mode 100644 index 0000000000..309acb7033 --- /dev/null +++ b/docs/app/components/content/examples/scroll-area/ScrollAreaVirtualizeExample.vue @@ -0,0 +1,31 @@ + + + diff --git a/docs/content/docs/2.components/scroll-area.md b/docs/content/docs/2.components/scroll-area.md new file mode 100644 index 0000000000..93844b9646 --- /dev/null +++ b/docs/content/docs/2.components/scroll-area.md @@ -0,0 +1,268 @@ +--- +description: A flexible scroll container with virtualization support. +category: layout +links: + - label: TanStack Virtual + avatar: + src: https://github.com/tanstack.png + to: https://tanstack.com/virtual/latest + - label: GitHub + icon: i-simple-icons-github + to: https://github.com/nuxt/ui/blob/v4/src/runtime/components/ScrollArea.vue +navigation.badge: Soon +--- + +## Usage + +The ScrollArea component creates scrollable containers with optional virtualization for large lists. + +::note +When virtualization is **disabled**, spacing uses theme configuration. When **enabled**, spacing has sensible defaults (`gap: 16`, `paddingStart: 16`, `paddingEnd: 16`) matching the theme. Override via the `virtualize` prop as needed. +:: + +::component-example +--- +collapse: true +name: 'scroll-area-example' +options: + - name: orientation + label: orientation + default: vertical + items: + - vertical + - horizontal + - name: virtualize + label: virtualize + default: true + items: + - true + - false + - name: lanes + type: number + label: lanes + default: 3 + visibleWhen: + option: virtualize + is: true + - name: gap + type: number + label: gap + default: 16 + visibleWhen: + option: virtualize + is: true + - name: padding + type: number + label: padding + default: 16 + visibleWhen: + option: virtualize + is: true +--- +:: + +### Orientation + +Use the `orientation` prop to change the scroll direction. Defaults to `vertical`. + +::component-example +--- +collapse: true +name: 'scroll-area-orientation-example' +options: + - name: orientation + label: orientation + default: vertical + items: + - vertical + - horizontal +--- +:: + +### Virtualization + +Use the `virtualize` prop to render only the items currently in view, significantly boosting performance when working with large datasets. + +::note +Use virtualization for large lists (100+ items) or heavy components. Skip for small, simple lists (< 50 items). +:: + +::component-example +--- +collapse: true +name: 'scroll-area-virtualize-example' +options: + - name: itemCount + label: itemCount + default: 10000 +--- +:: + +## Examples + +### Masonry layouts + +Create masonry (waterfall) layouts with variable height items using `lanes`. Items are automatically measured and positioned as they render. + +::component-example +--- +collapse: true +name: 'scroll-area-variable-height-example' +--- +:: + +::note +Provide an accurate `estimateSize` close to the average item height for better initial rendering performance. Increase `overscan` for smoother scrolling at the cost of rendering more off-screen items. +:: + +### Responsive lanes + +Implement responsive column/row counts using breakpoints or container width tracking. + +```vue + + + +``` + +For container-based responsive behavior: + +```vue + + + +``` + +::tip +Use [`useWindowSize`](https://vueuse.org/core/useWindowSize/) for viewport-based or [`useElementSize`](https://vueuse.org/core/useElementSize/) for container-based responsive lanes. +:: + +### Programmatic scrolling + +Use the exposed methods to programmatically control scroll position (requires virtualization): + +::component-example +--- +collapse: true +name: 'scroll-area-scroll-to-example' +options: + - name: itemCount + label: itemCount + default: 10000 + - name: targetIndex + label: targetIndex + default: 500 +--- +:: + +### Infinite scroll + +Use `@load-more` to load more data as the user scrolls (requires virtualization): + +::component-example +--- +prettier: true +collapse: true +name: 'scroll-area-infinite-scroll-example' +class: '!p-0' +--- +:: + +::tip +The `@load-more` event fires when the user scrolls within `loadMoreThreshold` items from the end (default: 5). Use a loading flag to prevent multiple simultaneous requests and always use spread syntax (`[...items, ...newItems]`) for reactive updates. +:: + +### Custom content + +Use the default slot without `items` for custom scrollable content. + +::component-example +--- +name: 'scroll-area-custom-example' +class: 'p-8' +--- +:: + +## API + +### Props + +:component-props + +### Slots + +:component-slots + +### Emits + +:component-emits + +### Expose + +You can access the typed component instance using [`useTemplateRef`](https://vuejs.org/api/composition-api-helpers.html#usetemplateref). + +```vue + + + +``` + +This will give you access to the following: + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `virtualizer`{lang="ts-type"} | `ComputedRef`{lang="ts-type"} | The TanStack Virtual virtualizer instance (null if virtualization is disabled) | +| `scrollToOffset`{lang="ts-type"} | `(offset: number, options?: ScrollToOptions) => void`{lang="ts-type"} | Scroll to a specific pixel offset | +| `scrollToIndex`{lang="ts-type"} | `(index: number, options?: ScrollToOptions) => void`{lang="ts-type"} | Scroll to a specific item index | +| `getTotalSize`{lang="ts-type"} | `() => number`{lang="ts-type"} | Get the total size of all virtualized items in pixels | +| `measure`{lang="ts-type"} | `() => void`{lang="ts-type"} | Reset all previously measured item sizes | +| `getScrollOffset`{lang="ts-type"} | `() => number`{lang="ts-type"} | Get the current scroll offset in pixels | +| `isScrolling`{lang="ts-type"} | `() => boolean`{lang="ts-type"} | Check if the list is currently being scrolled | +| `getScrollDirection`{lang="ts-type"} | `() => 'forward' \| 'backward' \| null`{lang="ts-type"} | Get the current scroll direction | + +::warning +Scroll methods are only available when virtualization is enabled. Calling them with `virtualize` set to `false` will result in a warning message. +:: + +## Theme + +:component-theme + +## Changelog + +:component-changelog diff --git a/docs/public/components/dark/scroll-area.png b/docs/public/components/dark/scroll-area.png new file mode 100644 index 0000000000..f79d2ebf39 Binary files /dev/null and b/docs/public/components/dark/scroll-area.png differ diff --git a/docs/public/components/light/scroll-area.png b/docs/public/components/light/scroll-area.png new file mode 100644 index 0000000000..996ab306f6 Binary files /dev/null and b/docs/public/components/light/scroll-area.png differ diff --git a/playgrounds/nuxt/app/composables/useNavigation.ts b/playgrounds/nuxt/app/composables/useNavigation.ts index 5398023167..08aa9940c1 100644 --- a/playgrounds/nuxt/app/composables/useNavigation.ts +++ b/playgrounds/nuxt/app/composables/useNavigation.ts @@ -63,6 +63,7 @@ const components = [ 'pricing-table', 'progress', 'radio-group', + 'scroll-area', 'select-menu', 'select', 'separator', diff --git a/playgrounds/nuxt/app/pages/components/scroll-area.vue b/playgrounds/nuxt/app/pages/components/scroll-area.vue new file mode 100644 index 0000000000..d557268a47 --- /dev/null +++ b/playgrounds/nuxt/app/pages/components/scroll-area.vue @@ -0,0 +1,176 @@ + + + diff --git a/src/runtime/components/ScrollArea.vue b/src/runtime/components/ScrollArea.vue new file mode 100644 index 0000000000..e2a03058a1 --- /dev/null +++ b/src/runtime/components/ScrollArea.vue @@ -0,0 +1,359 @@ + + + + + diff --git a/src/runtime/components/Table.vue b/src/runtime/components/Table.vue index 4b8d5959e6..084f99cf08 100644 --- a/src/runtime/components/Table.vue +++ b/src/runtime/components/Table.vue @@ -96,6 +96,7 @@ export interface TableProps extends TableOption /** * Enable virtualization for large datasets. * Note: when enabled, the divider between rows and sticky properties are not supported. + * @see https://tanstack.com/virtual/latest/docs/api/virtualizer#options * @defaultValue false */ virtualize?: boolean | (Partial, 'getScrollElement' | 'count' | 'estimateSize' | 'overscan'>> & { diff --git a/src/runtime/types/index.ts b/src/runtime/types/index.ts index 9efa713385..b0e529b615 100644 --- a/src/runtime/types/index.ts +++ b/src/runtime/types/index.ts @@ -85,6 +85,7 @@ export * from '../components/PricingPlans.vue' export * from '../components/PricingTable.vue' export * from '../components/Progress.vue' export * from '../components/RadioGroup.vue' +export * from '../components/ScrollArea.vue' export * from '../components/Select.vue' export * from '../components/SelectMenu.vue' export * from '../components/Separator.vue' diff --git a/src/theme/index.ts b/src/theme/index.ts index aa4bdc8969..5fc269919f 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -83,6 +83,7 @@ export { default as pricingPlans } from './pricing-plans' export { default as pricingTable } from './pricing-table' export { default as progress } from './progress' export { default as radioGroup } from './radio-group' +export { default as scrollArea } from './scroll-area' export { default as select } from './select' export { default as selectMenu } from './select-menu' export { default as separator } from './separator' diff --git a/src/theme/scroll-area.ts b/src/theme/scroll-area.ts new file mode 100644 index 0000000000..d330ada10f --- /dev/null +++ b/src/theme/scroll-area.ts @@ -0,0 +1,21 @@ +export default { + slots: { + root: 'relative', + viewport: 'relative flex gap-4 p-4', + item: '' + }, + variants: { + orientation: { + vertical: { + root: 'overflow-y-auto overflow-x-hidden', + viewport: 'columns-xs flex-col', + item: '' + }, + horizontal: { + root: 'overflow-x-auto overflow-y-hidden', + viewport: 'flex-row', + item: 'w-max' + } + } + } +} diff --git a/test/components/ScrollArea.spec.ts b/test/components/ScrollArea.spec.ts new file mode 100644 index 0000000000..0ef9ed1fda --- /dev/null +++ b/test/components/ScrollArea.spec.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import ScrollArea from '../../src/runtime/components/ScrollArea.vue' +import type { ScrollAreaProps, ScrollAreaSlots } from '../../src/runtime/components/ScrollArea.vue' +import ComponentRender from '../component-render' + +describe('ScrollArea', () => { + const testItems = [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + { id: 3, name: 'Item 3' } + ] + + it.each([ + // Basic Props + ['with as', { props: { as: 'section' } }], + ['with class', { props: { class: 'custom-class' } }], + ['with ui', { props: { ui: { root: 'custom-root' } } }], + ['with items', { props: { items: testItems } }], + ['with orientation vertical', { props: { orientation: 'vertical' as const } }], + ['with orientation horizontal', { props: { orientation: 'horizontal' as const } }], + + // Virtualization + ['with virtualize boolean', { props: { items: testItems, virtualize: true } }], + ['with virtualize object', { props: { items: testItems, virtualize: { overscan: 5, estimateSize: 50 } } }], + ['with virtualize gap', { props: { items: testItems, virtualize: { gap: 10 } } }], + ['with virtualize padding', { props: { items: testItems, virtualize: { paddingStart: 20, paddingEnd: 20 } } }], + ['with virtualize lanes', { props: { items: testItems, virtualize: { lanes: 3 } } }], + ['with virtualize scrollMargin', { props: { items: testItems, virtualize: { scrollMargin: 10 } } }], + ['with virtualize loadMoreThreshold', { props: { items: testItems, virtualize: { loadMoreThreshold: 10 } } }], + ['with virtualize enabled false', { props: { items: testItems, virtualize: { enabled: false } } }], + + // Slots + ['with default slot', { slots: { default: () => 'Default slot' } }] + ])('renders %s correctly', async (nameOrHtml: string, options: { props?: ScrollAreaProps, slots?: Partial> }) => { + const html = await ComponentRender(nameOrHtml, options, ScrollArea) + expect(html).toMatchSnapshot() + }) +}) diff --git a/test/components/__snapshots__/ScrollArea-vue.spec.ts.snap b/test/components/__snapshots__/ScrollArea-vue.spec.ts.snap new file mode 100644 index 0000000000..ca7d9971f2 --- /dev/null +++ b/test/components/__snapshots__/ScrollArea-vue.spec.ts.snap @@ -0,0 +1,99 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ScrollArea > renders with as correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with class correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with default slot correctly 1`] = ` +"
+
Default slot
+
" +`; + +exports[`ScrollArea > renders with items correctly 1`] = ` +"
+
+
+
+
+
+
" +`; + +exports[`ScrollArea > renders with orientation horizontal correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with orientation vertical correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with ui correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with virtualize boolean correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with virtualize enabled false correctly 1`] = ` +"
+
+
+
+
+
+
" +`; + +exports[`ScrollArea > renders with virtualize gap correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with virtualize lanes correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with virtualize loadMoreThreshold correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with virtualize object correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with virtualize padding correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with virtualize scrollMargin correctly 1`] = ` +"
+
+
" +`; diff --git a/test/components/__snapshots__/ScrollArea.spec.ts.snap b/test/components/__snapshots__/ScrollArea.spec.ts.snap new file mode 100644 index 0000000000..ca7d9971f2 --- /dev/null +++ b/test/components/__snapshots__/ScrollArea.spec.ts.snap @@ -0,0 +1,99 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ScrollArea > renders with as correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with class correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with default slot correctly 1`] = ` +"
+
Default slot
+
" +`; + +exports[`ScrollArea > renders with items correctly 1`] = ` +"
+
+
+
+
+
+
" +`; + +exports[`ScrollArea > renders with orientation horizontal correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with orientation vertical correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with ui correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with virtualize boolean correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with virtualize enabled false correctly 1`] = ` +"
+
+
+
+
+
+
" +`; + +exports[`ScrollArea > renders with virtualize gap correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with virtualize lanes correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with virtualize loadMoreThreshold correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with virtualize object correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with virtualize padding correctly 1`] = ` +"
+
+
" +`; + +exports[`ScrollArea > renders with virtualize scrollMargin correctly 1`] = ` +"
+
+
" +`;