From edef460a53339ed1f6b6dd164c13cccde5afd941 Mon Sep 17 00:00:00 2001 From: Mike Newbon Date: Thu, 16 Oct 2025 02:39:13 +0200 Subject: [PATCH 01/48] Scroll-area initial --- docs/content/docs/2.components/scroll-area.md | 288 ++++++++++++++++++ .../nuxt/app/composables/useNavigation.ts | 1 + .../nuxt/app/pages/components/scroll-area.vue | 179 +++++++++++ src/runtime/components/ScrollArea.vue | 218 +++++++++++++ src/runtime/types/index.ts | 1 + src/theme/index.ts | 1 + src/theme/scroll-area.ts | 18 ++ test/components/ScrollArea.spec.ts | 30 ++ 8 files changed, 736 insertions(+) create mode 100644 docs/content/docs/2.components/scroll-area.md create mode 100644 playgrounds/nuxt/app/pages/components/scroll-area.vue create mode 100644 src/runtime/components/ScrollArea.vue create mode 100644 src/theme/scroll-area.ts create mode 100644 test/components/ScrollArea.spec.ts 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..706766b6e2 --- /dev/null +++ b/docs/content/docs/2.components/scroll-area.md @@ -0,0 +1,288 @@ +--- +title: ScrollArea +description: A flexible scroll container with virtualization support for efficiently rendering large lists of any content type. +links: + - label: TanStack Virtual + icon: i-simple-icons-tanstack + 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 +--- + +## Usage + +Use the `ScrollArea` component to create scrollable containers for any type of content. It supports both vertical and horizontal scrolling, and can optionally virtualize large lists for better performance. + +### Basic Vertical Scroll + +::component-example +--- +name: 'scroll-area-basic-example' +--- + +#component + :scroll-area-basic-example + +#code +```vue + + + +``` +:: + +### Horizontal Scroll + +Set `orientation="horizontal"` to enable horizontal scrolling. + +::component-example +--- +name: 'scroll-area-horizontal-example' +--- + +#component + :scroll-area-horizontal-example + +#code +```vue + + + +``` +:: + +## Examples + +### Virtualized Large List + +Enable virtualization with the `virtualize` prop to efficiently handle large datasets. This renders only visible items, dramatically improving performance. + +::component-example +--- +name: 'scroll-area-virtualized-example' +--- + +#component + :scroll-area-virtualized-example + +#code +```vue + + + +``` +:: + +### Variable Height Items + +When using virtualization with items of varying heights, provide an `estimateSize` that represents the average item height for better initial rendering. + +::component-example +--- +name: 'scroll-area-variable-height-example' +--- + +#component + :scroll-area-variable-height-example + +#code +```vue + + + +``` +:: + +### Custom Content + +You can also use `ScrollArea` without the `items` prop for custom scrollable content. + +::component-example +--- +name: 'scroll-area-custom-example' +--- + +#component + :scroll-area-custom-example + +#code +```vue + +``` +:: + +### Custom Virtualization Options + +Fine-tune virtualization behavior with custom options. + +```vue + +``` + +## API + +### Props + +:component-props + +### Slots + +:component-slots + +## Theme + +:component-theme + +## Performance Notes + +### When to Use Virtualization + +- **Large Lists**: Lists with 100+ items +- **Complex Items**: Each item contains heavy components or images +- **Infinite Scroll**: Continuously loading data +- **Performance Critical**: Mobile devices or lower-end hardware + +### When Not to Use Virtualization + +- **Small Lists**: Less than 50 simple items +- **Known Heights**: All items have the same fixed height (consider CSS-only solutions) +- **Rare Scrolling**: Content is rarely scrolled through + +### Virtualization Tips + +1. **Estimate Size**: Provide an accurate `estimateSize` for better initial rendering and scroll behavior +2. **Overscan**: Increase `overscan` for smoother scrolling at the cost of rendering more items +3. **Variable Heights**: TanStack Virtual automatically measures and adjusts for variable heights, but a good estimate helps initial rendering +4. **Horizontal**: Works seamlessly with `orientation="horizontal"` for horizontal lists + +## Changelog + +:component-changelog 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..94bfd266de --- /dev/null +++ b/playgrounds/nuxt/app/pages/components/scroll-area.vue @@ -0,0 +1,179 @@ + + + diff --git a/src/runtime/components/ScrollArea.vue b/src/runtime/components/ScrollArea.vue new file mode 100644 index 0000000000..1d7c015319 --- /dev/null +++ b/src/runtime/components/ScrollArea.vue @@ -0,0 +1,218 @@ + + + + + diff --git a/src/runtime/types/index.ts b/src/runtime/types/index.ts index 8dc0924ce2..a5498d822d 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..be678901b4 --- /dev/null +++ b/src/theme/scroll-area.ts @@ -0,0 +1,18 @@ +import type { ModuleOptions } from '../module' + +export default (_options: Required) => ({ + slots: { + root: 'relative overflow-auto', + viewport: 'relative' + }, + variants: { + orientation: { + vertical: { + root: 'overflow-y-auto overflow-x-hidden' + }, + horizontal: { + root: 'overflow-x-auto overflow-y-hidden' + } + } + } +}) diff --git a/test/components/ScrollArea.spec.ts b/test/components/ScrollArea.spec.ts new file mode 100644 index 0000000000..297b3a2189 --- /dev/null +++ b/test/components/ScrollArea.spec.ts @@ -0,0 +1,30 @@ +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([ + // 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 } }], + ['with virtualize boolean', { props: { items: testItems, virtualize: true } }], + ['with virtualize object', { props: { items: testItems, virtualize: { overscan: 5, estimateSize: 50 } } }], + + // 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() + }) +}) From 613fffe166f6fb831c33560f81680ff443d97b6e Mon Sep 17 00:00:00 2001 From: Mike Newbon Date: Thu, 16 Oct 2025 03:37:31 +0200 Subject: [PATCH 02/48] Add images and fix horizontal gap/padding --- .../nuxt/app/pages/components/scroll-area.vue | 34 ++--- scroll-area.md | 120 ++++++++++++++++++ src/runtime/components/ScrollArea.vue | 6 +- 3 files changed, 141 insertions(+), 19 deletions(-) create mode 100644 scroll-area.md diff --git a/playgrounds/nuxt/app/pages/components/scroll-area.vue b/playgrounds/nuxt/app/pages/components/scroll-area.vue index 94bfd266de..1c4f9d07c0 100644 --- a/playgrounds/nuxt/app/pages/components/scroll-area.vue +++ b/playgrounds/nuxt/app/pages/components/scroll-area.vue @@ -16,30 +16,23 @@ type Item = { color?: string } +const aspectRatios = ['1/1', '4/3', '16/9'] + // Generate items with variable sizes for dynamic sizing demo const items = computed(() => { - if (orientation.value === 'horizontal') { - return Array.from({ length: itemCount.value }, (_, i) => ({ - id: i + 1, - url: `https://picsum.photos/300/200?random=${i}`, - title: `Image ${i + 1}` - })) - } - return Array.from({ length: itemCount.value }, (_, i) => { - // For masonry layouts, vary the content length more dramatically + const aspectRatios = ['1/1', '4/3', '16/9'] const descriptions = [ `Item ${i + 1}`, `Item ${i + 1} with some additional text.`, `This is item number ${i + 1} with quite a bit more description text that demonstrates dynamic sizing with variable height content in masonry layouts.`, `Item ${i + 1} - short one.` ] - return { id: i + 1, - title: `Item ${i + 1}`, - description: descriptions[i % descriptions.length], - color: (['blue', 'green', 'purple', 'red', 'orange'] as const)[i % 5] as string + url: `https://picsum.photos/300/${aspectRatios[i % aspectRatios.length] === '1/1' ? 300 : aspectRatios[i % aspectRatios.length] === '4/3' ? 400 : 200}?random=${i}`, + title: `Image ${i + 1}`, + description: descriptions[i % descriptions.length] } }) }) @@ -136,11 +129,11 @@ const items = computed(() => { :items="items" :orientation="orientation" :virtualize="virtualize ? { estimateSize, gap, paddingStart, paddingEnd, lanes } : false" - :class="orientation === 'horizontal' ? 'h-64' : 'h-128'" + class="h-128" > -