diff --git a/.changeset/swift-tigers-cry.md b/.changeset/swift-tigers-cry.md new file mode 100644 index 00000000..347d1e46 --- /dev/null +++ b/.changeset/swift-tigers-cry.md @@ -0,0 +1,5 @@ +--- +"@indielayer/ui": minor +--- + +feat: add virtual components diff --git a/packages/ui/docs/components/menu/DocsMenu.vue b/packages/ui/docs/components/menu/DocsMenu.vue index f3a44486..84b7738c 100644 --- a/packages/ui/docs/components/menu/DocsMenu.vue +++ b/packages/ui/docs/components/menu/DocsMenu.vue @@ -69,8 +69,11 @@ const components = [ collapseIcon: 'chevron-down', expanded: true, items: [ + { to: '/component/infiniteLoader', label: 'Infinite Loader' }, { to: '/component/scroll', label: 'Scroll' }, { to: '/component/spacer', label: 'Spacer' }, + { to: '/component/virtualGrid', label: 'Virtual Grid' }, + { to: '/component/virtualList', label: 'Virtual List' }, ], }, ] diff --git a/packages/ui/docs/pages/component/infiniteLoader/composable.vue b/packages/ui/docs/pages/component/infiniteLoader/composable.vue new file mode 100644 index 00000000..6cdb0be9 --- /dev/null +++ b/packages/ui/docs/pages/component/infiniteLoader/composable.vue @@ -0,0 +1,168 @@ + + + diff --git a/packages/ui/docs/pages/component/infiniteLoader/index.vue b/packages/ui/docs/pages/component/infiniteLoader/index.vue new file mode 100644 index 00000000..7a6a2825 --- /dev/null +++ b/packages/ui/docs/pages/component/infiniteLoader/index.vue @@ -0,0 +1,36 @@ + + + diff --git a/packages/ui/docs/pages/component/infiniteLoader/usage.vue b/packages/ui/docs/pages/component/infiniteLoader/usage.vue new file mode 100644 index 00000000..b628674a --- /dev/null +++ b/packages/ui/docs/pages/component/infiniteLoader/usage.vue @@ -0,0 +1,161 @@ + + + diff --git a/packages/ui/docs/pages/component/virtualGrid/index.vue b/packages/ui/docs/pages/component/virtualGrid/index.vue new file mode 100644 index 00000000..7667964f --- /dev/null +++ b/packages/ui/docs/pages/component/virtualGrid/index.vue @@ -0,0 +1,29 @@ + + + diff --git a/packages/ui/docs/pages/component/virtualGrid/usage.vue b/packages/ui/docs/pages/component/virtualGrid/usage.vue new file mode 100644 index 00000000..d07deb54 --- /dev/null +++ b/packages/ui/docs/pages/component/virtualGrid/usage.vue @@ -0,0 +1,20 @@ + + + diff --git a/packages/ui/docs/pages/component/virtualList/dynamicHeight.vue b/packages/ui/docs/pages/component/virtualList/dynamicHeight.vue new file mode 100644 index 00000000..60e79491 --- /dev/null +++ b/packages/ui/docs/pages/component/virtualList/dynamicHeight.vue @@ -0,0 +1,75 @@ + + + diff --git a/packages/ui/docs/pages/component/virtualList/index.vue b/packages/ui/docs/pages/component/virtualList/index.vue new file mode 100644 index 00000000..9068516c --- /dev/null +++ b/packages/ui/docs/pages/component/virtualList/index.vue @@ -0,0 +1,36 @@ + + + diff --git a/packages/ui/docs/pages/component/virtualList/usage.vue b/packages/ui/docs/pages/component/virtualList/usage.vue new file mode 100644 index 00000000..0766affc --- /dev/null +++ b/packages/ui/docs/pages/component/virtualList/usage.vue @@ -0,0 +1,17 @@ + + + diff --git a/packages/ui/docs/search/components.json b/packages/ui/docs/search/components.json index 9896abe3..0a31da2f 100644 --- a/packages/ui/docs/search/components.json +++ b/packages/ui/docs/search/components.json @@ -1 +1 @@ -[{"name":"Accordion","description":"Accordion is a component that allows you to collapse and expand content.","url":"/component/accordion"},{"name":"Alert","description":"Alerts are used to communicate a state that affects a system, feature or page.","url":"/component/alert"},{"name":"Avatar","description":"Avatars are used to represent a user.","url":"/component/avatar"},{"name":"Badge","description":"Badges are used to display a small amount of information.","url":"/component/badge"},{"name":"Breadcrumbs","description":"Breadcrumbs are used to indicate the current page\\","url":"/component/breadcrumbs"},{"name":"Button","description":"Buttons allow users to perform actions and make choices with a single click. They are commonly used for submitting forms, opening dialogs or menus, and executing commands. Buttons can be customized with different styles, sizes, and icons to fit various use cases.","url":"/component/button"},{"name":"Card","description":"Cards are used to display content in an organized manner.","url":"/component/card"},{"name":"Carousel","description":"A carousel is a rotating set of images.","url":"/component/carousel"},{"name":"Checkbox","description":"Checkboxes enable users to select one or multiple options from a list. They are ideal for toggling settings or making selections where more than one choice is allowed. Each checkbox can be checked or unchecked independently.","url":"/component/checkbox"},{"name":"Container","description":"Containers provide a flexible layout element for grouping and organizing content. They help structure pages by controlling width, alignment, and spacing, ensuring consistent presentation across different sections.","url":"/component/container"},{"name":"Datepicker","description":"The datepicker component is used to select a date or time.","url":"/component/datepicker"},{"name":"Divider","description":"Dividers are used to separate content.","url":"/component/divider"},{"name":"Drawer","description":"Drawers slide in from the edge of the screen to present navigation, options, or additional content without disrupting the main view. They are ideal for menus, filters, or contextual panels.","url":"/component/drawer"},{"name":"Form","description":"Forms provide a structured way to collect and validate user input, such as text, selections, and files. They support features like validation, grouping, and submission handling, making it easy to build interactive and accessible data entry interfaces.","url":"/component/form"},{"name":"FormGroup","description":"Form groups organize related form elements together, providing structure and shared validation. They help group checkboxes, radio buttons, or inputs that belong to the same logical section.","url":"/component/formGroup"},{"name":"Icon","description":"Icons visually represent actions, objects, or concepts, enhancing usability and recognition in interfaces. They can be used alone or alongside text for buttons, menus, and indicators.","url":"/component/icon"},{"name":"Image","description":"Image is used to load an image file with a skeleton as placeholder and on load display the image.","url":"/component/image"},{"name":"Input","description":"This is a text input component that allows users to enter and edit text.","url":"/component/input"},{"name":"Link","description":"Links are used to navigate to a different page.","url":"/component/link"},{"name":"Loader","description":"Loader component is used to show a loading state.","url":"/component/loader"},{"name":"Menu","description":"Menus are used to display a list of options.","url":"/component/menu"},{"name":"Modal","description":"Modals are used to display content on top of the current page.","url":"/component/modal"},{"name":"Notifications","description":"Notifications provide timely feedback or alerts to users, such as success messages, warnings, or errors. They help keep users informed about important events or actions.","url":"/component/notifications"},{"name":"Pagination","description":"Pagination divides large sets of content into manageable pages, allowing users to navigate through items efficiently. It is commonly used in tables, lists, and search results to improve usability and performance.","url":"/component/pagination"},{"name":"Popover","description":"Popovers display additional information or interactive content in a floating overlay, anchored to a trigger element. They are useful for tooltips, menus, or contextual actions without navigating away from the current view.","url":"/component/popover"},{"name":"Progress","description":"Progress indicators visually communicate the completion status of a task or process, such as file uploads or data loading. They help users understand how much work remains.","url":"/component/progress"},{"name":"QR Code","description":"The QR Code component is used to generate a QR code.","url":"/component/qrCode"},{"name":"Radio","description":"Radios allow the user to select one option from a set. Use radio buttons for exclusive selection if you think that the user needs to see all available options side-by-side.","url":"/component/radio"},{"name":"Scroll","description":"The Scroll component enhances scrolling experiences by adding visual cues, such as inner shadows, to indicate additional content. It is useful for horizontally or vertically scrolling lists and containers.","url":"/component/scroll"},{"name":"Select","description":"Selects allow the user to select one or more options from a set.","url":"/component/select"},{"name":"Skeleton","description":"Skeletons provide placeholder elements that mimic the layout of content while data is loading. They improve perceived performance by giving users a visual cue that content is being fetched.","url":"/component/skeleton"},{"name":"Slider","description":"Sliders allow users to select a numeric value or range by dragging a handle along a track. They are ideal for adjusting settings such as volume, brightness, or price filters.","url":"/component/slider"},{"name":"Spacer","description":"Spacers create adjustable gaps between elements within flex layouts, helping to control spacing and alignment without using margin or padding directly.","url":"/component/spacer"},{"name":"Spinner","description":"Spinners visually indicate that a background process or operation is ongoing, such as loading data or submitting a form. They help manage user expectations by signaling that the system is working.","url":"/component/spinner"},{"name":"Stepper","description":"Stepper is a navigation component that guides users through the steps of a task.","url":"/component/stepper"},{"name":"Table","description":"Tables are used to display data in a tabular format.","url":"/component/table"},{"name":"Tabs","description":"Tabs are used to navigate through a set of views.","url":"/component/tabs"},{"name":"Tag","description":"Tags are compact elements used to display brief information, such as categories, statuses, or labels. They help organize content and provide quick context or filtering options within lists and interfaces.","url":"/component/tag"},{"name":"Textarea","description":"Textareas provide a multi-line input field for users to enter longer text, such as comments, descriptions, or messages. They support resizing and can be customized for different use cases.","url":"/component/textarea"},{"name":"Toggle","description":"Toggles provide a simple switch interface for enabling or disabling a setting, or switching between two mutually exclusive states. They are ideal for binary options such as on/off, active/inactive, or show/hide.","url":"/component/toggle"},{"name":"Tooltip","description":"Tooltips are used to display a message when hovering over an element.","url":"/component/tooltip"},{"name":"Upload","description":"Upload is a component that allows you to upload files.","url":"/component/upload"}] \ No newline at end of file +[{"name":"Accordion","description":"Accordion is a component that allows you to collapse and expand content.","url":"/component/accordion"},{"name":"Alert","description":"Alerts are used to communicate a state that affects a system, feature or page.","url":"/component/alert"},{"name":"Avatar","description":"Avatars are used to represent a user.","url":"/component/avatar"},{"name":"Badge","description":"Badges are used to display a small amount of information.","url":"/component/badge"},{"name":"Breadcrumbs","description":"Breadcrumbs are used to indicate the current page\\","url":"/component/breadcrumbs"},{"name":"Button","description":"Buttons allow users to perform actions and make choices with a single click. They are commonly used for submitting forms, opening dialogs or menus, and executing commands. Buttons can be customized with different styles, sizes, and icons to fit various use cases.","url":"/component/button"},{"name":"Card","description":"Cards are used to display content in an organized manner.","url":"/component/card"},{"name":"Carousel","description":"A carousel is a rotating set of images.","url":"/component/carousel"},{"name":"Checkbox","description":"Checkboxes enable users to select one or multiple options from a list. They are ideal for toggling settings or making selections where more than one choice is allowed. Each checkbox can be checked or unchecked independently.","url":"/component/checkbox"},{"name":"Container","description":"Containers provide a flexible layout element for grouping and organizing content. They help structure pages by controlling width, alignment, and spacing, ensuring consistent presentation across different sections.","url":"/component/container"},{"name":"Datepicker","description":"The datepicker component is used to select a date or time.","url":"/component/datepicker"},{"name":"Divider","description":"Dividers are used to separate content.","url":"/component/divider"},{"name":"Drawer","description":"Drawers slide in from the edge of the screen to present navigation, options, or additional content without disrupting the main view. They are ideal for menus, filters, or contextual panels.","url":"/component/drawer"},{"name":"Form","description":"Forms provide a structured way to collect and validate user input, such as text, selections, and files. They support features like validation, grouping, and submission handling, making it easy to build interactive and accessible data entry interfaces.","url":"/component/form"},{"name":"FormGroup","description":"Form groups organize related form elements together, providing structure and shared validation. They help group checkboxes, radio buttons, or inputs that belong to the same logical section.","url":"/component/formGroup"},{"name":"Icon","description":"Icons visually represent actions, objects, or concepts, enhancing usability and recognition in interfaces. They can be used alone or alongside text for buttons, menus, and indicators.","url":"/component/icon"},{"name":"Image","description":"Image is used to load an image file with a skeleton as placeholder and on load display the image.","url":"/component/image"},{"name":"InfiniteLoader","description":"InfiniteLoader is a component that allows you to load data on demand as users scroll through a list.","url":"/component/infiniteLoader"},{"name":"Input","description":"This is a text input component that allows users to enter and edit text.","url":"/component/input"},{"name":"Link","description":"Links are used to navigate to a different page.","url":"/component/link"},{"name":"Loader","description":"Loader component is used to show a loading state.","url":"/component/loader"},{"name":"Menu","description":"Menus are used to display a list of options.","url":"/component/menu"},{"name":"Modal","description":"Modals are used to display content on top of the current page.","url":"/component/modal"},{"name":"Notifications","description":"Notifications provide timely feedback or alerts to users, such as success messages, warnings, or errors. They help keep users informed about important events or actions.","url":"/component/notifications"},{"name":"Pagination","description":"Pagination divides large sets of content into manageable pages, allowing users to navigate through items efficiently. It is commonly used in tables, lists, and search results to improve usability and performance.","url":"/component/pagination"},{"name":"Popover","description":"Popovers display additional information or interactive content in a floating overlay, anchored to a trigger element. They are useful for tooltips, menus, or contextual actions without navigating away from the current view.","url":"/component/popover"},{"name":"Progress","description":"Progress indicators visually communicate the completion status of a task or process, such as file uploads or data loading. They help users understand how much work remains.","url":"/component/progress"},{"name":"QR Code","description":"The QR Code component is used to generate a QR code.","url":"/component/qrCode"},{"name":"Radio","description":"Radios allow the user to select one option from a set. Use radio buttons for exclusive selection if you think that the user needs to see all available options side-by-side.","url":"/component/radio"},{"name":"Scroll","description":"The Scroll component enhances scrolling experiences by adding visual cues, such as inner shadows, to indicate additional content. It is useful for horizontally or vertically scrolling lists and containers.","url":"/component/scroll"},{"name":"Select","description":"Selects allow the user to select one or more options from a set.","url":"/component/select"},{"name":"Skeleton","description":"Skeletons provide placeholder elements that mimic the layout of content while data is loading. They improve perceived performance by giving users a visual cue that content is being fetched.","url":"/component/skeleton"},{"name":"Slider","description":"Sliders allow users to select a numeric value or range by dragging a handle along a track. They are ideal for adjusting settings such as volume, brightness, or price filters.","url":"/component/slider"},{"name":"Spacer","description":"Spacers create adjustable gaps between elements within flex layouts, helping to control spacing and alignment without using margin or padding directly.","url":"/component/spacer"},{"name":"Spinner","description":"Spinners visually indicate that a background process or operation is ongoing, such as loading data or submitting a form. They help manage user expectations by signaling that the system is working.","url":"/component/spinner"},{"name":"Stepper","description":"Stepper is a navigation component that guides users through the steps of a task.","url":"/component/stepper"},{"name":"Table","description":"Tables are used to display data in a tabular format.","url":"/component/table"},{"name":"Tabs","description":"Tabs are used to navigate through a set of views.","url":"/component/tabs"},{"name":"Tag","description":"Tags are compact elements used to display brief information, such as categories, statuses, or labels. They help organize content and provide quick context or filtering options within lists and interfaces.","url":"/component/tag"},{"name":"Textarea","description":"Textareas provide a multi-line input field for users to enter longer text, such as comments, descriptions, or messages. They support resizing and can be customized for different use cases.","url":"/component/textarea"},{"name":"Toggle","description":"Toggles provide a simple switch interface for enabling or disabling a setting, or switching between two mutually exclusive states. They are ideal for binary options such as on/off, active/inactive, or show/hide.","url":"/component/toggle"},{"name":"Tooltip","description":"Tooltips are used to display a message when hovering over an element.","url":"/component/tooltip"},{"name":"Upload","description":"Upload is a component that allows you to upload files.","url":"/component/upload"},{"name":"VirtualGrid","description":"VirtualGrid is a component that allows you to render a grid of items in a virtualized way. It is a high-performance component that is used to render large grids of items.","url":"/component/virtualGrid"},{"name":"VirtualList","description":"VirtualList is a component that allows you to render a list of items in a virtualized way. It is a high-performance component that is used to render large lists of items.","url":"/component/virtualList"}] \ No newline at end of file diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 5231e3d7..a15df3d2 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -3,6 +3,7 @@ export { default as version } from './version' export * from './components' export * from './composables' export * from './themes' +export * from './virtual' export type { UITheme, ComponentThemes } from './theme' export { default as createUI, type UIOptions } from './create' diff --git a/packages/ui/src/install.ts b/packages/ui/src/install.ts index 630084f7..cbc24272 100644 --- a/packages/ui/src/install.ts +++ b/packages/ui/src/install.ts @@ -1,8 +1,14 @@ import * as components from './components' +import { XVirtualGrid, XVirtualList, XInfiniteLoader } from './virtual' import create from './create' export default create({ - components: Object.keys(components).map( - (key) => components[key as keyof object], - ), + components: [ + ...Object.keys(components).map( + (key) => components[key as keyof object], + ), + XVirtualList, + XVirtualGrid, + XInfiniteLoader, + ], }) diff --git a/packages/ui/src/virtual/README.md b/packages/ui/src/virtual/README.md new file mode 100644 index 00000000..df3c7585 --- /dev/null +++ b/packages/ui/src/virtual/README.md @@ -0,0 +1,285 @@ +# Indielayer - Virtual Vue + +Vue 3 port of [react-window](https://github.com/bvaughn/react-window) - Efficient virtualized list and grid components using the Composition API. + +## Overview + +High-performance virtualized list and grid components for Vue 3 applications. It renders only the visible items in large datasets, dramatically improving performance when dealing with thousands of rows or cells. + +## Quick Start + +### List Example + +```vue + + + + + +``` + +### Grid Example + +```vue + + + + + +``` + +## Features + +- ⚡️ **Virtual scrolling** - Only renders visible items +- 📏 **Variable sizes** - Fixed, variable, or dynamic item sizes +- 📐 **Dynamic measurement** - Automatic height measurement with `useDynamicRowHeight` +- 🌐 **RTL support** - Right-to-left language support +- 🖥️ **SSR compatible** - Works with server-side rendering +- 📝 **TypeScript** - Full type definitions included +- 🎯 **Imperative API** - Programmatic scrolling via template refs +- ♿️ **ARIA support** - Accessibility attributes built-in +- 🔄 **Overscan** - Render extra items to reduce flicker +- ♾️ **Infinite loading** - Built-in support for infinite scroll/pagination + +## Components & Composables + +### List + +Virtualized list component for rendering large datasets with many rows. + +```vue + + + +``` + +### Grid + +Virtualized grid component for rendering data with rows and columns. + +```vue + + + +``` + +### Infinite Loader + +Utility for loading data on-demand as users scroll through lists. + +**Using the composable (recommended):** + +```vue + + + +``` + +**Using the component:** + +```vue + + + +``` + +## API + +### XVirtualList Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `rowCount` | `number` | ✅ | Number of rows in the list | +| `rowHeight` | `number \| string \| function` | ✅ | Row height (px, %, or function) | +| `style` | `CSSProperties` | ❌ | Container styles (should include height) | +| `rowProps` | `object` | ❌ | Additional props passed to row slot | +| `overscanCount` | `number` | ❌ | Extra rows to render (default: 3) | +| `defaultHeight` | `number` | ❌ | Default height for SSR | +| `onRowsRendered` | `function` | ❌ | Callback when visible rows change | +| `onResize` | `function` | ❌ | Callback when container resizes | + +### XVirtualGrid Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `rowCount` | `number` | ✅ | Number of rows | +| `columnCount` | `number` | ✅ | Number of columns | +| `rowHeight` | `number \| string \| function` | ✅ | Row height | +| `columnWidth` | `number \| string \| function` | ✅ | Column width | +| `style` | `CSSProperties` | ❌ | Container styles | +| `cellProps` | `object` | ❌ | Additional props passed to cell slot | +| `overscanCount` | `number` | ❌ | Extra rows/columns (default: 3) | +| `dir` | `'ltr' \| 'rtl' \| 'auto'` | ❌ | Text direction | +| `onCellsRendered` | `function` | ❌ | Callback when visible cells change | +| `onResize` | `function` | ❌ | Callback when container resizes | + +### Infinite Loader Props + +| Prop | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `isRowLoaded` | `(index: number) => boolean` | ✅ | - | Function to check if a row is loaded | +| `loadMoreRows` | `(start: number, stop: number) => Promise` | ✅ | - | Async function to load more rows | +| `rowCount` | `number` | ✅ | - | Total row count (can be estimated) | +| `minimumBatchSize` | `number` | ❌ | 10 | Min rows to load per request | +| `threshold` | `number` | ❌ | 15 | Pre-fetch distance in rows | + +## Credits + +This library is a Vue 3 port of [react-window](https://github.com/bvaughn/react-window) by Brian Vaughn. All credit for the original design and virtualization algorithms goes to the original author and contributors. diff --git a/packages/ui/src/virtual/components/infiniteLoader/InfiniteLoader.test.ts b/packages/ui/src/virtual/components/infiniteLoader/InfiniteLoader.test.ts new file mode 100644 index 00000000..50f74592 --- /dev/null +++ b/packages/ui/src/virtual/components/infiniteLoader/InfiniteLoader.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { h } from 'vue' +import InfiniteLoader from './InfiniteLoader.vue' +import type { InfiniteLoaderProps } from '../../composables/infinite-loader/types' + +interface InfiniteLoaderSlotProps { + onRowsRendered: (props: { startIndex: number; stopIndex: number; }) => void; +} + +describe('InfiniteLoader', () => { + test('should render slot with onRowsRendered callback', () => { + const isRowLoaded = vi.fn(() => true) + const loadMoreRows = vi.fn(() => Promise.resolve()) + + const wrapper = mount(InfiniteLoader, { + props: { + isRowLoaded, + loadMoreRows, + rowCount: 10, + } as InfiniteLoaderProps, + slots: { + default: (slotProps: InfiniteLoaderSlotProps) => { + return h('div', { 'data-testid': 'slot-content' }, [ + `Callback type: ${typeof slotProps.onRowsRendered}`, + ]) + }, + }, + }) + + expect(wrapper.html()).toContain('Callback type: function') + }) + + test('should pass through onRowsRendered callback that triggers loadMoreRows', () => { + const loadMoreRows = vi.fn(() => Promise.resolve()) + + let capturedCallback: ((props: { startIndex: number; stopIndex: number; }) => void) | null = null + + mount(InfiniteLoader, { + props: { + isRowLoaded: (index) => index <= 2, + loadMoreRows, + rowCount: 10, + threshold: 0, // Disable threshold for this test + minimumBatchSize: 0, // Disable minimum batch size for this test + } as InfiniteLoaderProps, + slots: { + default: (slotProps: InfiniteLoaderSlotProps) => { + capturedCallback = slotProps.onRowsRendered + + return h('div') + }, + }, + }) + + expect(capturedCallback).toBeDefined() + expect(typeof capturedCallback).toBe('function') + + // Call the callback if it is defined + if (capturedCallback) { + (capturedCallback as (props: { startIndex: number; stopIndex: number; }) => void)({ startIndex: 0, stopIndex: 5 }) + } else { + throw new Error('capturedCallback is null') + } + + // Should have triggered loadMoreRows for unloaded rows 3-5 + expect(loadMoreRows).toHaveBeenCalled() + expect(loadMoreRows).toHaveBeenCalledWith(3, 5) + }) + + test('should work with VList integration pattern', () => { + const loadMoreRows = vi.fn(() => Promise.resolve()) + const loadedRows = new Set([0, 1, 2]) + + const wrapper = mount(InfiniteLoader, { + props: { + isRowLoaded: (index) => loadedRows.has(index), + loadMoreRows, + rowCount: 10, + minimumBatchSize: 5, + } as InfiniteLoaderProps, + slots: { + default: (slotProps: InfiniteLoaderSlotProps) => { + // Simulate VList calling onRowsRendered + slotProps.onRowsRendered({ startIndex: 0, stopIndex: 7 }) + + return h('div', 'List content') + }, + }, + }) + + // Should load rows 3-7 with minimum batch size of 5 + expect(loadMoreRows).toHaveBeenCalled() + expect(wrapper.html()).toContain('List content') + }) +}) diff --git a/packages/ui/src/virtual/components/infiniteLoader/InfiniteLoader.vue b/packages/ui/src/virtual/components/infiniteLoader/InfiniteLoader.vue new file mode 100644 index 00000000..9e53a4cd --- /dev/null +++ b/packages/ui/src/virtual/components/infiniteLoader/InfiniteLoader.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/packages/ui/src/virtual/components/virtualGrid/VirtualGrid.vue b/packages/ui/src/virtual/components/virtualGrid/VirtualGrid.vue new file mode 100644 index 00000000..5dcba817 --- /dev/null +++ b/packages/ui/src/virtual/components/virtualGrid/VirtualGrid.vue @@ -0,0 +1,322 @@ + + + + + diff --git a/packages/ui/src/virtual/components/virtualGrid/types.ts b/packages/ui/src/virtual/components/virtualGrid/types.ts new file mode 100644 index 00000000..8c175f45 --- /dev/null +++ b/packages/ui/src/virtual/components/virtualGrid/types.ts @@ -0,0 +1,160 @@ +import type { CSSProperties } from 'vue' +import type { TagNames } from '../../types' + +type ForbiddenKeys = 'ariaAttributes' | 'columnIndex' | 'rowIndex' | 'style'; +type ExcludeForbiddenKeys = { + [Key in keyof Type]: Key extends ForbiddenKeys ? never : Type[Key]; +}; + +export interface VirtualGridProps { + /** + * Additional props to be passed to the cell-rendering component via slots. + */ + cellProps?: ExcludeForbiddenKeys>; + + /** + * CSS class name. + */ + class?: string; + + /** + * Number of columns to be rendered in the grid. + */ + columnCount: number; + + /** + * Column width; the following formats are supported: + * - number of pixels (number) + * - percentage of the grid's current width (string) + * - function that returns the column width (in pixels) given an index and `cellProps` + */ + columnWidth: + | number + | string + | ((index: number, cellProps: Record) => number); + + /** + * Default height of grid for initial render. + * This value is important for server rendering. + */ + defaultHeight?: number; + + /** + * Default width of grid for initial render. + * This value is important for server rendering. + */ + defaultWidth?: number; + + /** + * Indicates the directionality of grid cells. + */ + dir?: 'ltr' | 'rtl' | 'auto'; + + /** + * Callback notified when the range of rendered cells changes. + */ + onCellsRendered?: ( + visibleCells: { + columnStartIndex: number; + columnStopIndex: number; + rowStartIndex: number; + rowStopIndex: number; + }, + allCells: { + columnStartIndex: number; + columnStopIndex: number; + rowStartIndex: number; + rowStopIndex: number; + } + ) => void; + + /** + * Callback notified when the Grid's outermost HTMLElement resizes. + * This may be used to (re)scroll a cell into view. + */ + onResize?: ( + size: { height: number; width: number; }, + prevSize: { height: number; width: number; } + ) => void; + + /** + * How many additional rows/columns to render outside of the visible area. + * This can reduce visual flickering near the edges of a grid when scrolling. + */ + overscanCount?: number; + + /** + * Number of rows to be rendered in the grid. + */ + rowCount: number; + + /** + * Row height; the following formats are supported: + * - number of pixels (number) + * - percentage of the grid's current height (string) + * - function that returns the row height (in pixels) given an index and `cellProps` + */ + rowHeight: number | string | ((index: number, cellProps: Record) => number); + + /** + * Optional CSS properties. + * The grid of cells will fill the height and width defined by this style. + */ + style?: CSSProperties; + + /** + * Can be used to override the root HTML element rendered by the Grid component. + * The default value is "div", meaning that Grid renders an HTMLDivElement as its root. + */ + tag?: TagNames; +} + +export interface CellSlotProps { + ariaAttributes: { + 'aria-colindex': number; + role: 'gridcell'; + }; + columnIndex: number; + rowIndex: number; + style: CSSProperties; + props?: Record; +} + +/** + * Imperative Grid API. + */ +export interface VirtualGridImperativeAPI { + /** + * Outermost HTML element for the grid if mounted and null (if not mounted. + */ + readonly element: HTMLDivElement | null; + + /** + * Scrolls the grid so that the specified cell is visible. + */ + scrollToCell(config: { + behavior?: ScrollBehavior; + columnAlign?: 'auto' | 'center' | 'end' | 'smart' | 'start'; + columnIndex: number; + rowAlign?: 'auto' | 'center' | 'end' | 'smart' | 'start'; + rowIndex: number; + }): void; + + /** + * Scrolls the grid so that the specified column is visible. + */ + scrollToColumn(config: { + align?: 'auto' | 'center' | 'end' | 'smart' | 'start'; + behavior?: ScrollBehavior; + index: number; + }): void; + + /** + * Scrolls the grid so that the specified row is visible. + */ + scrollToRow(config: { + align?: 'auto' | 'center' | 'end' | 'smart' | 'start'; + behavior?: ScrollBehavior; + index: number; + }): void; +} diff --git a/packages/ui/src/virtual/components/virtualList/VirtualList.test.ts b/packages/ui/src/virtual/components/virtualList/VirtualList.test.ts new file mode 100644 index 00000000..8b8931d1 --- /dev/null +++ b/packages/ui/src/virtual/components/virtualList/VirtualList.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { h, type CSSProperties } from 'vue' +import VirtualList from './VirtualList.vue' + +interface RowSlotProps { + index: number; + style: CSSProperties; +} + +describe('VirtualList', () => { + it('renders correctly', () => { + const wrapper = mount(VirtualList, { + props: { + rowCount: 100, + rowHeight: 50, + style: { height: '400px' }, + }, + slots: { + row: ({ index, style }: RowSlotProps) => + h('div', { style }, `Row ${index}`), + }, + }) + + expect(wrapper.exists()).toBe(true) + expect(wrapper.attributes('role')).toBe('list') + }) + + it('renders visible rows', () => { + const wrapper = mount(VirtualList, { + props: { + rowCount: 100, + rowHeight: 50, + style: { height: '400px' }, + }, + slots: { + row: ({ index, style }: RowSlotProps) => + h('div', { style, class: 'test-row' }, `Row ${index}`), + }, + }) + + // Should render some rows (visible + overscan) + const rows = wrapper.findAll('.test-row') + + expect(rows.length).toBeGreaterThan(0) + }) +}) diff --git a/packages/ui/src/virtual/components/virtualList/VirtualList.vue b/packages/ui/src/virtual/components/virtualList/VirtualList.vue new file mode 100644 index 00000000..47a6573e --- /dev/null +++ b/packages/ui/src/virtual/components/virtualList/VirtualList.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/packages/ui/src/virtual/components/virtualList/isDynamicRowHeight.ts b/packages/ui/src/virtual/components/virtualList/isDynamicRowHeight.ts new file mode 100644 index 00000000..34e55897 --- /dev/null +++ b/packages/ui/src/virtual/components/virtualList/isDynamicRowHeight.ts @@ -0,0 +1,13 @@ +import type { DynamicRowHeight } from './types' + +export function isDynamicRowHeight( + value: unknown, +): value is DynamicRowHeight { + return ( + typeof value === 'object' && + value !== null && + 'getAverageRowHeight' in value && + 'getRowHeight' in value && + 'setRowHeight' in value + ) +} diff --git a/packages/ui/src/virtual/components/virtualList/types.ts b/packages/ui/src/virtual/components/virtualList/types.ts new file mode 100644 index 00000000..c824f78b --- /dev/null +++ b/packages/ui/src/virtual/components/virtualList/types.ts @@ -0,0 +1,126 @@ +import type { CSSProperties } from 'vue' +import type { TagNames } from '../../types' + +export type DynamicRowHeight = { + getAverageRowHeight(): number; + getRowHeight(index: number): number | undefined; + setRowHeight(index: number, size: number): void; + observeRowElements: (elements: Element[] | NodeListOf) => () => void; +}; + +type ForbiddenKeys = 'ariaAttributes' | 'index' | 'style'; +type ExcludeForbiddenKeys = { + [Key in keyof Type]: Key extends ForbiddenKeys ? never : Type[Key]; +}; + +export interface VirtualListProps { + /** + * CSS class name. + */ + class?: string; + + /** + * Default height of list for initial render. + * This value is important for server rendering. + */ + defaultHeight?: number; + + /** + * Callback notified when the List's outermost HTMLElement resizes. + * This may be used to (re)scroll a row into view. + */ + onResize?: ( + size: { height: number; width: number; }, + prevSize: { height: number; width: number; } + ) => void; + + /** + * Callback notified when the range of visible rows changes. + */ + onRowsRendered?: ( + visibleRows: { startIndex: number; stopIndex: number; }, + allRows: { startIndex: number; stopIndex: number; } + ) => void; + + /** + * How many additional rows to render outside of the visible area. + * This can reduce visual flickering near the edges of a list when scrolling. + */ + overscanCount?: number; + + /** + * Number of items to be rendered in the list. + */ + rowCount: number; + + /** + * Row height; the following formats are supported: + * - number of pixels (number) + * - percentage of the grid's current height (string) + * - function that returns the row height (in pixels) given an index and `cellProps` + * - dynamic row height cache returned by the `useDynamicRowHeight` hook + * + * ⚠️ Dynamic row heights are not as efficient as predetermined sizes. + * It's recommended to provide your own height values if they can be determined ahead of time. + */ + rowHeight: + | number + | string + | ((index: number, cellProps: Record) => number) + | DynamicRowHeight; + + /** + * Additional props to be passed to the row-rendering component via slots. + */ + rowProps?: ExcludeForbiddenKeys>; + + /** + * Optional CSS properties. + * The list of rows will fill the height defined by this style. + */ + style?: CSSProperties; + + /** + * Can be used to override the root HTML element rendered by the List component. + * The default value is "div", meaning that List renders an HTMLDivElement as its root. + * + * ⚠️ In most use cases the default ARIA roles are sufficient and this prop is not needed. + */ + tag?: TagNames; +} + +export interface RowSlotProps { + ariaAttributes: { + 'aria-posinset': number; + 'aria-setsize': number; + role: 'listitem'; + }; + index: number; + style: CSSProperties; + props?: Record; +} + +/** + * Imperative List API. + */ +export interface VirtualListImperativeAPI { + /** + * Outermost HTML element for the list if mounted and null (if not mounted. + */ + readonly element: HTMLDivElement | null; + + /** + * Scrolls the list so that the specified row is visible. + * + * @param align Determines the vertical alignment of the element within the list + * @param behavior Determines whether scrolling is instant or animates smoothly + * @param index Index of the row to scroll to (0-based) + * + * @throws RangeError if an invalid row index is provided + */ + scrollToRow(config: { + align?: 'auto' | 'center' | 'end' | 'smart' | 'start'; + behavior?: 'auto' | 'instant' | 'smooth'; + index: number; + }): void; +} diff --git a/packages/ui/src/virtual/components/virtualList/useDynamicRowHeight.test.ts b/packages/ui/src/virtual/components/virtualList/useDynamicRowHeight.test.ts new file mode 100644 index 00000000..e22bf888 --- /dev/null +++ b/packages/ui/src/virtual/components/virtualList/useDynamicRowHeight.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test, beforeEach, afterEach } from 'vitest' +import { ref, nextTick } from 'vue' +import { useDynamicRowHeight, DATA_ATTRIBUTE_LIST_INDEX } from './useDynamicRowHeight' +import { mockResizeObserver, setElementSize } from '../../test-utils/mockResizeObserver' + +describe('useDynamicRowHeight', () => { + let unmock: (() => void) | undefined + + beforeEach(() => { + unmock = mockResizeObserver() + }) + + afterEach(() => { + if (unmock) { + unmock() + } + }) + + describe('getAverageRowHeight', () => { + test('returns an initial estimate based on the defaultRowHeight', () => { + const dynamicRowHeight = useDynamicRowHeight({ + defaultRowHeight: 100, + }) + + expect(dynamicRowHeight.getAverageRowHeight()).toBe(100) + }) + + test('returns an estimate based on measured rows', () => { + const dynamicRowHeight = useDynamicRowHeight({ + defaultRowHeight: 100, + }) + + dynamicRowHeight.setRowHeight(0, 10) + dynamicRowHeight.setRowHeight(1, 20) + expect(dynamicRowHeight.getAverageRowHeight()).toBe(15) + + dynamicRowHeight.setRowHeight(2, 30) + expect(dynamicRowHeight.getAverageRowHeight()).toBe(20) + + dynamicRowHeight.setRowHeight(2, 15) + expect(dynamicRowHeight.getAverageRowHeight()).toBe(15) + }) + + test('resets when key changes', async () => { + const key = ref('a') + const dynamicRowHeight = useDynamicRowHeight({ + defaultRowHeight: 100, + key, + }) + + dynamicRowHeight.setRowHeight(0, 10) + expect(dynamicRowHeight.getAverageRowHeight()).toBe(10) + + // Key hasn't changed + await nextTick() + expect(dynamicRowHeight.getAverageRowHeight()).toBe(10) + + // Change key + key.value = 'b' + await nextTick() + expect(dynamicRowHeight.getAverageRowHeight()).toBe(100) + }) + }) + + describe('getRowHeight', () => { + test('returns estimated height for a row that has not yet been measured', () => { + const dynamicRowHeight = useDynamicRowHeight({ + defaultRowHeight: 100, + }) + + expect(dynamicRowHeight.getRowHeight(0)).toBe(100) + }) + + test('returns the most recently measured size', () => { + const dynamicRowHeight = useDynamicRowHeight({ + defaultRowHeight: 100, + }) + + dynamicRowHeight.setRowHeight(0, 15) + dynamicRowHeight.setRowHeight(1, 20) + dynamicRowHeight.setRowHeight(3, 25) + expect(dynamicRowHeight.getRowHeight(0)).toBe(15) + expect(dynamicRowHeight.getRowHeight(1)).toBe(20) + expect(dynamicRowHeight.getRowHeight(2)).toBe(100) + expect(dynamicRowHeight.getRowHeight(3)).toBe(25) + + dynamicRowHeight.setRowHeight(1, 25) + expect(dynamicRowHeight.getRowHeight(1)).toBe(25) + }) + + test('resets when key changes', async () => { + const key = ref('a') + const dynamicRowHeight = useDynamicRowHeight({ + defaultRowHeight: 100, + key, + }) + + dynamicRowHeight.setRowHeight(0, 10) + expect(dynamicRowHeight.getRowHeight(0)).toBe(10) + + // Key hasn't changed + await nextTick() + expect(dynamicRowHeight.getRowHeight(0)).toBe(10) + + // Change key + key.value = 'b' + await nextTick() + expect(dynamicRowHeight.getRowHeight(0)).toBe(100) + }) + }) + + describe('observeRowElements', () => { + function createRowElement(index: number) { + const element = document.createElement('div') + + element.setAttribute(DATA_ATTRIBUTE_LIST_INDEX, '' + index) + + return element + } + + test('should update cache when an observed element is resized', async () => { + const dynamicRowHeight = useDynamicRowHeight({ + defaultRowHeight: 100, + }) + + const elementA = createRowElement(0) + const elementB = createRowElement(1) + + dynamicRowHeight.observeRowElements([elementA, elementB]) + await nextTick() + expect(dynamicRowHeight.getRowHeight(0)).toBe(100) + expect(dynamicRowHeight.getRowHeight(1)).toBe(100) + + setElementSize({ + element: elementB, + width: 100, + height: 20, + }) + await nextTick() + expect(dynamicRowHeight.getRowHeight(0)).toBe(100) + expect(dynamicRowHeight.getRowHeight(1)).toBe(20) + + setElementSize({ + element: elementA, + width: 100, + height: 15, + }) + await nextTick() + expect(dynamicRowHeight.getRowHeight(0)).toBe(15) + expect(dynamicRowHeight.getRowHeight(1)).toBe(20) + }) + + test('should unobserve an element when requested', async () => { + const dynamicRowHeight = useDynamicRowHeight({ + defaultRowHeight: 100, + }) + + const element = createRowElement(0) + + const unobserve = dynamicRowHeight.observeRowElements([element]) + + setElementSize({ + element, + width: 100, + height: 10, + }) + await nextTick() + expect(dynamicRowHeight.getRowHeight(0)).toBe(10) + + unobserve() + + setElementSize({ + element, + width: 100, + height: 20, + }) + await nextTick() + expect(dynamicRowHeight.getRowHeight(0)).toBe(10) + }) + }) + + // setRowHeight is tested indirectly by "getAverageRowHeight" and "getRowHeight" blocks above +}) diff --git a/packages/ui/src/virtual/components/virtualList/useDynamicRowHeight.ts b/packages/ui/src/virtual/components/virtualList/useDynamicRowHeight.ts new file mode 100644 index 00000000..8e5046a0 --- /dev/null +++ b/packages/ui/src/virtual/components/virtualList/useDynamicRowHeight.ts @@ -0,0 +1,149 @@ +import { getCurrentInstance, onBeforeUnmount, ref, watch, type Ref } from 'vue' +import { assert } from '../../utils/assert' +import type { DynamicRowHeight } from './types' + +export const DATA_ATTRIBUTE_LIST_INDEX = 'data-virtual-index' + +export function useDynamicRowHeight({ + defaultRowHeight, + key, +}: { + defaultRowHeight: number; + key?: Ref | string | number; +}): DynamicRowHeight { + const map = ref>(new Map()) + // Revision counter to trigger reactivity when map changes + const revision = ref(0) + + // Handle reactive key changes + if (key !== undefined && typeof key === 'object' && 'value' in key) { + watch(key as Ref, () => { + map.value = new Map() + revision.value++ + }) + } + + const getAverageRowHeight = () => { + // Access revision to track dependency + revision.value + + let totalHeight = 0 + + map.value.forEach((height) => { + totalHeight += height + }) + + if (totalHeight === 0) { + return defaultRowHeight + } + + return totalHeight / map.value.size + } + + const getRowHeight = (index: number) => { + // Access revision to track dependency + revision.value + + const measuredHeight = map.value.get(index) + + if (measuredHeight !== undefined) { + return measuredHeight + } + + // Don't cache default height here - let ResizeObserver do the measuring + // This prevents stale default heights from blocking updates + return defaultRowHeight + } + + const setRowHeight = (index: number, size: number) => { + if (map.value.get(index) === size) { + return + } + + // Create a new Map to trigger reactivity + const newMap = new Map(map.value) + + newMap.set(index, size) + map.value = newMap + revision.value++ + } + + const resizeObserverCallback = (entries: ResizeObserverEntry[]) => { + if (entries.length === 0) { + return + } + + // Batch updates to avoid triggering multiple recalculations + let hasChanges = false + const updates: Array<{ index: number; height: number; }> = [] + + entries.forEach((entry) => { + const { borderBoxSize, target } = entry + + const attribute = target.getAttribute(DATA_ATTRIBUTE_LIST_INDEX) + + assert( + attribute !== null, + `Invalid ${DATA_ATTRIBUTE_LIST_INDEX} attribute value`, + ) + + const index = parseInt(attribute) + + const { blockSize: height } = borderBoxSize[0] + + if (!height) { + // Ignore heights that have not yet been measured (e.g. elements that have not yet loaded) + return + } + + // Check if height actually changed before updating + const currentHeight = map.value.get(index) + + if (currentHeight !== height) { + updates.push({ index, height }) + hasChanges = true + } + }) + + // Apply all updates at once + if (hasChanges) { + const newMap = new Map(map.value) + + updates.forEach(({ index, height }) => { + newMap.set(index, height) + }) + map.value = newMap + revision.value++ + } + } + + const resizeObserver = new ResizeObserver(resizeObserverCallback) + + // Only use onBeforeUnmount if we're inside a component instance + const instance = getCurrentInstance() + + if (instance) { + onBeforeUnmount(() => { + resizeObserver.disconnect() + }) + } + + const observeRowElements = (elements: Element[] | NodeListOf) => { + const elementsArray = Array.isArray(elements) + ? elements + : Array.from(elements) + + elementsArray.forEach((element) => resizeObserver.observe(element)) + + return () => { + elementsArray.forEach((element) => resizeObserver.unobserve(element)) + } + } + + return { + getAverageRowHeight, + getRowHeight, + setRowHeight, + observeRowElements, + } +} diff --git a/packages/ui/src/virtual/composables/infinite-loader/scanForUnloadedIndices.test.ts b/packages/ui/src/virtual/composables/infinite-loader/scanForUnloadedIndices.test.ts new file mode 100644 index 00000000..61c75414 --- /dev/null +++ b/packages/ui/src/virtual/composables/infinite-loader/scanForUnloadedIndices.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test, vi } from 'vitest' +import { scanForUnloadedIndices } from './scanForUnloadedIndices' + +describe('scanForUnloadedIndices', () => { + test('should return an empty array for a range of rows that have all been loaded', () => { + const isRowLoaded = vi.fn((index: number) => { + expect(index).toBeGreaterThanOrEqual(0) + expect(index).toBeLessThanOrEqual(2) + + return true + }) + + expect( + scanForUnloadedIndices({ + minimumBatchSize: 0, + isRowLoaded, + rowCount: 3, + startIndex: 0, + stopIndex: 2, + }), + ).toEqual([]) + + expect(isRowLoaded).toHaveBeenCalledTimes(3) + }) + + test('return a range of only 1 unloaded row', () => { + const isRowLoaded = vi.fn((index: number) => { + expect(index).toBeGreaterThanOrEqual(0) + expect(index).toBeLessThanOrEqual(2) + + return index !== 1 + }) + + expect( + scanForUnloadedIndices({ + minimumBatchSize: 0, + isRowLoaded, + rowCount: 3, + startIndex: 0, + stopIndex: 2, + }), + ).toEqual([{ startIndex: 1, stopIndex: 1 }]) + + expect(isRowLoaded).toHaveBeenCalledTimes(3) + }) + + test('return a range of multiple unloaded rows', () => { + const isRowLoaded = vi.fn((index: number) => { + expect(index).toBeGreaterThanOrEqual(0) + expect(index).toBeLessThanOrEqual(2) + + return index === 2 + }) + + expect( + scanForUnloadedIndices({ + minimumBatchSize: 0, + isRowLoaded, + rowCount: 3, + startIndex: 0, + stopIndex: 2, + }), + ).toEqual([{ startIndex: 0, stopIndex: 1 }]) + + expect(isRowLoaded).toHaveBeenCalledTimes(3) + }) + + test('return multiple ranges of unloaded rows', () => { + const isRowLoaded = vi.fn((index: number) => { + expect(index).toBeGreaterThanOrEqual(0) + expect(index).toBeLessThanOrEqual(6) + switch (index) { + case 0: + case 3: + case 5: { + return true + } + } + + return false + }) + + expect( + scanForUnloadedIndices({ + minimumBatchSize: 0, + isRowLoaded, + rowCount: 7, + startIndex: 0, + stopIndex: 6, + }), + ).toEqual([ + { startIndex: 1, stopIndex: 2 }, + { startIndex: 4, stopIndex: 4 }, + { startIndex: 6, stopIndex: 6 }, + ]) + + expect(isRowLoaded).toHaveBeenCalledTimes(7) + }) + + test('return respect the minimum batch size param when fetching rows ahead', () => { + const isRowLoaded = vi.fn((index: number) => { + expect(index).toBeGreaterThanOrEqual(0) + expect(index).toBeLessThanOrEqual(9) + + return index < 4 + }) + + expect( + scanForUnloadedIndices({ + minimumBatchSize: 4, + isRowLoaded, + rowCount: 10, + startIndex: 0, + stopIndex: 4, + }), + ).toEqual([{ startIndex: 4, stopIndex: 7 }]) + + expect(isRowLoaded).toHaveBeenCalledTimes(8) + }) + + test('return respect the minimum batch size param when fetching rows behind', () => { + const isRowLoaded = vi.fn((index: number) => { + expect(index).toBeGreaterThanOrEqual(0) + expect(index).toBeLessThanOrEqual(9) + + return index > 6 + }) + + expect( + scanForUnloadedIndices({ + minimumBatchSize: 4, + isRowLoaded, + rowCount: 10, + startIndex: 6, + stopIndex: 9, + }), + ).toEqual([{ startIndex: 3, stopIndex: 6 }]) + + expect(isRowLoaded).toHaveBeenCalledTimes(7) + }) +}) diff --git a/packages/ui/src/virtual/composables/infinite-loader/scanForUnloadedIndices.ts b/packages/ui/src/virtual/composables/infinite-loader/scanForUnloadedIndices.ts new file mode 100644 index 00000000..ea37fe83 --- /dev/null +++ b/packages/ui/src/virtual/composables/infinite-loader/scanForUnloadedIndices.ts @@ -0,0 +1,82 @@ +import type { Indices } from './types' + +export function scanForUnloadedIndices({ + isRowLoaded, + minimumBatchSize, + rowCount, + startIndex, + stopIndex, +}: { + isRowLoaded: (index: number) => boolean; + minimumBatchSize: number; + rowCount: number; + startIndex: number; + stopIndex: number; +}): Indices[] { + const indices: Indices[] = [] + + let currentStartIndex = -1 + let currentStopIndex = -1 + + for (let index = startIndex; index <= stopIndex; index++) { + if (!isRowLoaded(index)) { + currentStopIndex = index + if (currentStartIndex < 0) { + currentStartIndex = index + } + } else if (currentStopIndex >= 0) { + indices.push({ + startIndex: currentStartIndex, + stopIndex: currentStopIndex, + }) + + currentStartIndex = currentStopIndex = -1 + } + } + + // Scan forward to satisfy the minimum batch size. + if (currentStopIndex >= 0) { + const potentialStopIndex = Math.min( + Math.max(currentStopIndex, currentStartIndex + minimumBatchSize - 1), + rowCount - 1, + ) + + for ( + let index = currentStopIndex + 1; + index <= potentialStopIndex; + index++ + ) { + if (!isRowLoaded(index)) { + currentStopIndex = index + } else { + break + } + } + + indices.push({ + startIndex: currentStartIndex, + stopIndex: currentStopIndex, + }) + } + + // Check to see if our first range ended prematurely. + // In this case we should scan backwards to satisfy the minimum batch size. + if (indices.length) { + const firstIndices = indices[0] + + while ( + firstIndices.stopIndex - firstIndices.startIndex + 1 < minimumBatchSize && + firstIndices.startIndex > 0 + ) { + const index = firstIndices.startIndex - 1 + + if (!isRowLoaded(index)) { + firstIndices.startIndex = index + } else { + break + } + } + } + + return indices +} diff --git a/packages/ui/src/virtual/composables/infinite-loader/types.ts b/packages/ui/src/virtual/composables/infinite-loader/types.ts new file mode 100644 index 00000000..03aa4f13 --- /dev/null +++ b/packages/ui/src/virtual/composables/infinite-loader/types.ts @@ -0,0 +1,36 @@ +export type Indices = { + startIndex: number; + stopIndex: number; +}; + +export type OnRowsRendered = (indices: Indices) => void; + +export type InfiniteLoaderProps = { + /** + * Function responsible for tracking the loaded state of each item. + */ + isRowLoaded: (index: number) => boolean; + + /** + * Callback to be invoked when more rows must be loaded. + * It should return a Promise that is resolved once all data has finished loading. + */ + loadMoreRows: (startIndex: number, stopIndex: number) => Promise; + + /** + * Minimum number of rows to be loaded at a time; defaults to 10. + * This property can be used to batch requests to reduce HTTP requests. + */ + minimumBatchSize?: number; + + /** + * Threshold at which to pre-fetch data; defaults to 15. + * A threshold of 15 means that data will start loading when a user scrolls within 15 rows. + */ + threshold?: number; + + /** + * Number of rows in list; can be arbitrary high number if actual number is unknown. + */ + rowCount: number; +}; diff --git a/packages/ui/src/virtual/composables/infinite-loader/useInfiniteLoader.test.ts b/packages/ui/src/virtual/composables/infinite-loader/useInfiniteLoader.test.ts new file mode 100644 index 00000000..3ff14a4d --- /dev/null +++ b/packages/ui/src/virtual/composables/infinite-loader/useInfiniteLoader.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test, vi } from 'vitest' +import { ref } from 'vue' +import { useInfiniteLoader } from './useInfiniteLoader' +import type { InfiniteLoaderProps } from './types' + +describe('useInfiniteLoader', () => { + test('should not load rows that have already been loaded', () => { + const isRowLoaded = vi.fn(() => true) + const loadMoreRows = vi.fn(() => Promise.resolve()) + + const props: InfiniteLoaderProps = { + isRowLoaded, + loadMoreRows, + rowCount: 10, + } + + const { onRowsRendered } = useInfiniteLoader(props) + + expect(isRowLoaded).not.toHaveBeenCalled() + + onRowsRendered({ + startIndex: 0, + stopIndex: 9, + }) + + expect(isRowLoaded).toHaveBeenCalled() + expect(loadMoreRows).not.toHaveBeenCalled() + }) + + test('should call loadMoreRows when needed', () => { + const loadMoreRows = vi.fn(() => Promise.resolve()) + + const props: InfiniteLoaderProps = { + isRowLoaded: (index) => index <= 2, + loadMoreRows, + rowCount: 5, + } + + const { onRowsRendered } = useInfiniteLoader(props) + + expect(loadMoreRows).not.toHaveBeenCalled() + + onRowsRendered({ + startIndex: 0, + stopIndex: 4, + }) + + expect(loadMoreRows).toHaveBeenCalled() + expect(loadMoreRows).toHaveBeenLastCalledWith(3, 4) + }) + + test('should work with reactive props', () => { + const loadMoreRows = vi.fn(() => Promise.resolve()) + const rowCount = ref(5) + + const props = ref({ + isRowLoaded: (index) => index <= 2, + loadMoreRows, + rowCount: rowCount.value, + }) + + const { onRowsRendered } = useInfiniteLoader(props) + + onRowsRendered({ + startIndex: 0, + stopIndex: 4, + }) + + expect(loadMoreRows).toHaveBeenCalledWith(3, 4) + + // Update rowCount + rowCount.value = 10 + props.value = { + ...props.value, + rowCount: rowCount.value, + } + + onRowsRendered({ + startIndex: 0, + stopIndex: 9, + }) + + // Should now load indices 3-9 (excluding already pending 3-4) + expect(loadMoreRows).toHaveBeenLastCalledWith(5, 9) + }) + + test('should respect minimumBatchSize', () => { + const loadMoreRows = vi.fn(() => Promise.resolve()) + + const props: InfiniteLoaderProps = { + isRowLoaded: (index) => index < 4, + loadMoreRows, + rowCount: 10, + minimumBatchSize: 4, + threshold: 0, // Disable threshold for this test + } + + const { onRowsRendered } = useInfiniteLoader(props) + + onRowsRendered({ + startIndex: 0, + stopIndex: 4, + }) + + // Should load at least minimumBatchSize (4) rows + expect(loadMoreRows).toHaveBeenCalledWith(4, 7) + }) + + test('should respect threshold for pre-fetching', () => { + const loadMoreRows = vi.fn(() => Promise.resolve()) + + const props: InfiniteLoaderProps = { + isRowLoaded: (index) => index < 5, + loadMoreRows, + rowCount: 100, + threshold: 10, + } + + const { onRowsRendered } = useInfiniteLoader(props) + + // User is viewing rows 10-20 + onRowsRendered({ + startIndex: 10, + stopIndex: 20, + }) + + // With threshold 10, should check indices 0-30 + // Rows 5-30 should be loaded + expect(loadMoreRows).toHaveBeenCalledWith(5, 30) + }) + + test('should track pending rows', () => { + const loadMoreRows = vi.fn(() => Promise.resolve()) + + const props: InfiniteLoaderProps = { + isRowLoaded: (index) => index <= 2, + loadMoreRows, + rowCount: 10, + } + + const { onRowsRendered, pendingRowsCache } = useInfiniteLoader(props) + + expect(pendingRowsCache.size).toBe(0) + + onRowsRendered({ + startIndex: 0, + stopIndex: 5, + }) + + // Rows 3, 4, 5 should be marked as pending + expect(pendingRowsCache.has(3)).toBe(true) + expect(pendingRowsCache.has(4)).toBe(true) + expect(pendingRowsCache.has(5)).toBe(true) + expect(pendingRowsCache.has(2)).toBe(false) + }) + + test('should not load rows that are already pending', () => { + const loadMoreRows = vi.fn(() => Promise.resolve()) + + const props: InfiniteLoaderProps = { + isRowLoaded: (index) => index <= 2, + loadMoreRows, + rowCount: 10, + threshold: 0, // Disable threshold for this test + minimumBatchSize: 0, // Disable minimum batch size for this test + } + + const { onRowsRendered } = useInfiniteLoader(props) + + // First call loads rows 3-5 + onRowsRendered({ + startIndex: 0, + stopIndex: 5, + }) + + expect(loadMoreRows).toHaveBeenCalledTimes(1) + expect(loadMoreRows).toHaveBeenLastCalledWith(3, 5) + + // Second call with same range shouldn't load again + onRowsRendered({ + startIndex: 0, + stopIndex: 5, + }) + + // Should still be called only once + expect(loadMoreRows).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/ui/src/virtual/composables/infinite-loader/useInfiniteLoader.ts b/packages/ui/src/virtual/composables/infinite-loader/useInfiniteLoader.ts new file mode 100644 index 00000000..d0c2da4a --- /dev/null +++ b/packages/ui/src/virtual/composables/infinite-loader/useInfiniteLoader.ts @@ -0,0 +1,69 @@ +import { computed, reactive, toValue, type MaybeRefOrGetter } from 'vue' +import { scanForUnloadedIndices } from './scanForUnloadedIndices' +import type { Indices, OnRowsRendered, InfiniteLoaderProps } from './types' + +export function useInfiniteLoader( + props: MaybeRefOrGetter, +) { + // Create a reactive Set to track pending rows + const pendingRowsCache = reactive(new Set()) + + // Computed values that react to props changes + const isRowLoaded = computed( + () => toValue(props).isRowLoaded, + ) + const loadMoreRows = computed( + () => toValue(props).loadMoreRows, + ) + const minimumBatchSize = computed( + () => toValue(props).minimumBatchSize ?? 10, + ) + const rowCount = computed( + () => toValue(props).rowCount, + ) + const threshold = computed( + () => toValue(props).threshold ?? 15, + ) + + // Check if a row is loaded or pending + const isRowLoadedOrPending = (index: number): boolean => { + if (isRowLoaded.value(index)) { + return true + } + + return pendingRowsCache.has(index) + } + + // Main callback to be passed to VList's onRowsRendered + const onRowsRendered: OnRowsRendered = ({ startIndex, stopIndex }: Indices) => { + const unloadedIndices = scanForUnloadedIndices({ + isRowLoaded: isRowLoadedOrPending, + minimumBatchSize: minimumBatchSize.value, + rowCount: rowCount.value, + startIndex: Math.max(0, startIndex - threshold.value), + stopIndex: Math.min(rowCount.value - 1, stopIndex + threshold.value), + }) + + for (let index = 0; index < unloadedIndices.length; index++) { + const { startIndex: unloadedStartIndex, stopIndex: unloadedStopIndex } = + unloadedIndices[index] + + // Mark all indices in this range as pending + for ( + let idx = unloadedStartIndex; + idx <= unloadedStopIndex; + idx++ + ) { + pendingRowsCache.add(idx) + } + + // Fire the load request (fire-and-forget pattern) + loadMoreRows.value(unloadedStartIndex, unloadedStopIndex) + } + } + + return { + onRowsRendered, + pendingRowsCache, + } +} diff --git a/packages/ui/src/virtual/core/createCachedBounds.ts b/packages/ui/src/virtual/core/createCachedBounds.ts new file mode 100644 index 00000000..9a860d07 --- /dev/null +++ b/packages/ui/src/virtual/core/createCachedBounds.ts @@ -0,0 +1,72 @@ +import { assert } from '../utils/assert' +import type { Bounds, CachedBounds, SizeFunction } from './types' + +export function createCachedBounds({ + itemCount, + itemProps, + itemSize, +}: { + itemCount: number; + itemProps: Props; + itemSize: number | SizeFunction; +}): CachedBounds { + const cache = new Map() + + return { + get(index: number) { + assert(index < itemCount, `Invalid index ${index}`) + + while (cache.size - 1 < index) { + const currentIndex = cache.size + + let size: number + + switch (typeof itemSize) { + case 'function': { + size = itemSize(currentIndex, itemProps) + break + } + case 'number': { + size = itemSize + break + } + } + + if (currentIndex === 0) { + cache.set(currentIndex, { + size, + scrollOffset: 0, + }) + } else { + const previousRowBounds = cache.get(currentIndex - 1) + + assert( + previousRowBounds !== undefined, + `Unexpected bounds cache miss for index ${index}`, + ) + + cache.set(currentIndex, { + scrollOffset: + previousRowBounds.scrollOffset + previousRowBounds.size, + size, + }) + } + } + + const bounds = cache.get(index) + + assert( + bounds !== undefined, + `Unexpected bounds cache miss for index ${index}`, + ) + + return bounds + }, + set(index: number, bounds: Bounds) { + cache.set(index, bounds) + }, + get size() { + return cache.size + }, + } +} diff --git a/packages/ui/src/virtual/core/getEstimatedSize.ts b/packages/ui/src/virtual/core/getEstimatedSize.ts new file mode 100644 index 00000000..a9190417 --- /dev/null +++ b/packages/ui/src/virtual/core/getEstimatedSize.ts @@ -0,0 +1,29 @@ +import type { CachedBounds, SizeFunction } from './types' +import { assert } from '../utils/assert' + +export function getEstimatedSize({ + cachedBounds, + itemCount, + itemSize, +}: { + cachedBounds: CachedBounds; + itemCount: number; + itemSize: number | SizeFunction; +}) { + if (itemCount === 0) { + return 0 + } else if (typeof itemSize === 'number') { + return itemCount * itemSize + } else { + const bounds = cachedBounds.get( + cachedBounds.size === 0 ? 0 : cachedBounds.size - 1, + ) + + assert(bounds !== undefined, 'Unexpected bounds cache miss') + + const averageItemSize = + (bounds.scrollOffset + bounds.size) / cachedBounds.size + + return itemCount * averageItemSize + } +} diff --git a/packages/ui/src/virtual/core/getOffsetForIndex.ts b/packages/ui/src/virtual/core/getOffsetForIndex.ts new file mode 100644 index 00000000..4ab49037 --- /dev/null +++ b/packages/ui/src/virtual/core/getOffsetForIndex.ts @@ -0,0 +1,90 @@ +import type { Align } from '../types' +import { getEstimatedSize } from './getEstimatedSize' +import type { CachedBounds, SizeFunction } from './types' + +export function getOffsetForIndex({ + align, + cachedBounds, + index, + itemCount, + itemSize, + containerScrollOffset, + containerSize, +}: { + align: Align; + cachedBounds: CachedBounds; + index: number; + itemCount: number; + itemSize: number | SizeFunction; + containerScrollOffset: number; + containerSize: number; +}) { + if (index < 0 || index >= itemCount) { + throw new RangeError( + `Invalid index specified: ${index}. Index ${index} is not within the range of 0 - ${itemCount - 1}`, + ) + } + + const estimatedTotalSize = getEstimatedSize({ + cachedBounds, + itemCount, + itemSize, + }) + + const bounds = cachedBounds.get(index) + const maxOffset = Math.max( + 0, + Math.min(estimatedTotalSize - containerSize, bounds.scrollOffset), + ) + const minOffset = Math.max( + 0, + bounds.scrollOffset - containerSize + bounds.size, + ) + + if (align === 'smart') { + if ( + containerScrollOffset >= minOffset && + containerScrollOffset <= maxOffset + ) { + align = 'auto' + } else { + align = 'center' + } + } + + switch (align) { + case 'start': { + return maxOffset + } + case 'end': { + return minOffset + } + case 'center': { + if (bounds.scrollOffset <= containerSize / 2) { + // Too near the beginning to center-align + return 0 + } else if ( + bounds.scrollOffset + bounds.size / 2 >= + estimatedTotalSize - containerSize / 2 + ) { + // Too near the end to center-align + return estimatedTotalSize - containerSize + } else { + return bounds.scrollOffset + bounds.size / 2 - containerSize / 2 + } + } + case 'auto': + default: { + if ( + containerScrollOffset >= minOffset && + containerScrollOffset <= maxOffset + ) { + return containerScrollOffset + } else if (containerScrollOffset < minOffset) { + return minOffset + } else { + return maxOffset + } + } + } +} diff --git a/packages/ui/src/virtual/core/getStartStopIndices.test.ts b/packages/ui/src/virtual/core/getStartStopIndices.test.ts new file mode 100644 index 00000000..35296dce --- /dev/null +++ b/packages/ui/src/virtual/core/getStartStopIndices.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' +import { getStartStopIndices } from './getStartStopIndices' +import { createCachedBounds } from './createCachedBounds' + +describe('getStartStopIndices', () => { + it('calculates visible indices correctly', () => { + const cachedBounds = createCachedBounds({ + itemCount: 100, + itemProps: {}, + itemSize: 50, + }) + + const result = getStartStopIndices({ + cachedBounds, + containerScrollOffset: 0, + containerSize: 400, + itemCount: 100, + overscanCount: 3, + }) + + expect(result.startIndexVisible).toBe(0) + expect(result.startIndexOverscan).toBe(0) + expect(result.stopIndexVisible).toBeGreaterThanOrEqual(0) + expect(result.stopIndexOverscan).toBeGreaterThanOrEqual(result.stopIndexVisible) + }) + + it('handles scrolled position', () => { + const cachedBounds = createCachedBounds({ + itemCount: 100, + itemProps: {}, + itemSize: 50, + }) + + const result = getStartStopIndices({ + cachedBounds, + containerScrollOffset: 500, + containerSize: 400, + itemCount: 100, + overscanCount: 3, + }) + + expect(result.startIndexVisible).toBeGreaterThan(0) + expect(result.startIndexOverscan).toBeLessThanOrEqual(result.startIndexVisible) + }) +}) diff --git a/packages/ui/src/virtual/core/getStartStopIndices.ts b/packages/ui/src/virtual/core/getStartStopIndices.ts new file mode 100644 index 00000000..7d4d18f5 --- /dev/null +++ b/packages/ui/src/virtual/core/getStartStopIndices.ts @@ -0,0 +1,71 @@ +import type { CachedBounds } from './types' + +export function getStartStopIndices({ + cachedBounds, + containerScrollOffset, + containerSize, + itemCount, + overscanCount, +}: { + cachedBounds: CachedBounds; + containerScrollOffset: number; + containerSize: number; + itemCount: number; + overscanCount: number; +}): { + startIndexVisible: number; + stopIndexVisible: number; + startIndexOverscan: number; + stopIndexOverscan: number; + } { + const maxIndex = itemCount - 1 + + let startIndexVisible = 0 + let stopIndexVisible = -1 + let startIndexOverscan = 0 + let stopIndexOverscan = -1 + let currentIndex = 0 + + while (currentIndex < maxIndex) { + const bounds = cachedBounds.get(currentIndex) + + if (bounds.scrollOffset + bounds.size > containerScrollOffset) { + break + } + + currentIndex++ + } + + startIndexVisible = currentIndex + startIndexOverscan = Math.max(0, startIndexVisible - overscanCount) + + while (currentIndex < maxIndex) { + const bounds = cachedBounds.get(currentIndex) + + if ( + bounds.scrollOffset + bounds.size >= + containerScrollOffset + containerSize + ) { + break + } + + currentIndex++ + } + + stopIndexVisible = Math.min(maxIndex, currentIndex) + stopIndexOverscan = Math.min(itemCount - 1, stopIndexVisible + overscanCount) + + if (startIndexVisible < 0) { + startIndexVisible = 0 + stopIndexVisible = -1 + startIndexOverscan = 0 + stopIndexOverscan = -1 + } + + return { + startIndexVisible, + stopIndexVisible, + startIndexOverscan, + stopIndexOverscan, + } +} diff --git a/packages/ui/src/virtual/core/types.ts b/packages/ui/src/virtual/core/types.ts new file mode 100644 index 00000000..0aab7fc7 --- /dev/null +++ b/packages/ui/src/virtual/core/types.ts @@ -0,0 +1,17 @@ +export type Bounds = { + size: number; + scrollOffset: number; +}; + +export type CachedBounds = { + get(index: number): Bounds; + set(index: number, bounds: Bounds): void; + size: number; +}; + +export type Direction = 'horizontal' | 'vertical'; + +export type SizeFunction = ( + index: number, + props: Props +) => number; diff --git a/packages/ui/src/virtual/core/useCachedBounds.ts b/packages/ui/src/virtual/core/useCachedBounds.ts new file mode 100644 index 00000000..078038bd --- /dev/null +++ b/packages/ui/src/virtual/core/useCachedBounds.ts @@ -0,0 +1,21 @@ +import { computed, type ComputedRef } from 'vue' +import { createCachedBounds } from './createCachedBounds' +import type { CachedBounds, SizeFunction } from './types' + +export function useCachedBounds({ + itemCount, + itemProps, + itemSize, +}: { + itemCount: number; + itemProps: Props; + itemSize: number | SizeFunction; +}): ComputedRef { + return computed(() => + createCachedBounds({ + itemCount, + itemProps, + itemSize, + }), + ) +} diff --git a/packages/ui/src/virtual/core/useIsRtl.ts b/packages/ui/src/virtual/core/useIsRtl.ts new file mode 100644 index 00000000..cc8016a4 --- /dev/null +++ b/packages/ui/src/virtual/core/useIsRtl.ts @@ -0,0 +1,25 @@ +import { ref, watch, type Ref } from 'vue' +import { isRtl } from '../utils/isRtl' + +export function useIsRtl( + element: Ref, + dir?: 'ltr' | 'rtl' | 'auto', +) { + const value = ref(dir === 'rtl') + + watch( + [element, () => dir], + ([el, direction]) => { + if (el) { + if (!direction || direction === 'auto') { + value.value = isRtl(el) + } else { + value.value = direction === 'rtl' + } + } + }, + { immediate: true }, + ) + + return value +} diff --git a/packages/ui/src/virtual/core/useItemSize.ts b/packages/ui/src/virtual/core/useItemSize.ts new file mode 100644 index 00000000..f7b00eb1 --- /dev/null +++ b/packages/ui/src/virtual/core/useItemSize.ts @@ -0,0 +1,34 @@ +import { assert } from '../utils/assert' +import type { SizeFunction } from './types' + +export function useItemSize({ + containerSize, + itemSize: itemSizeProp, +}: { + containerSize: number; + itemSize: number | string | SizeFunction; +}) { + let itemSize: number | SizeFunction + + switch (typeof itemSizeProp) { + case 'string': { + assert( + itemSizeProp.endsWith('%'), + `Invalid item size: "${itemSizeProp}"; string values must be percentages (e.g. "100%")`, + ) + assert( + containerSize !== undefined, + 'Container size must be defined if a percentage item size is specified', + ) + + itemSize = (containerSize * parseInt(itemSizeProp)) / 100 + break + } + default: { + itemSize = itemSizeProp + break + } + } + + return itemSize +} diff --git a/packages/ui/src/virtual/core/useVirtualizer.ts b/packages/ui/src/virtual/core/useVirtualizer.ts new file mode 100644 index 00000000..9c430906 --- /dev/null +++ b/packages/ui/src/virtual/core/useVirtualizer.ts @@ -0,0 +1,294 @@ +import { + computed, + isRef, + ref, + watch, + type ComputedRef, + type CSSProperties, + type Ref, +} from 'vue' +import { useEventListener, useResizeObserver } from '@vueuse/core' +import type { Align } from '../types' +import { parseNumericStyleValue } from '../utils/parseNumericStyleValue' +import { adjustScrollOffsetForRtl } from '../utils/adjustScrollOffsetForRtl' +import { shallowCompare } from '../utils/shallowCompare' +import { getEstimatedSize as getEstimatedSizeUtil } from './getEstimatedSize' +import { getOffsetForIndex } from './getOffsetForIndex' +import { getStartStopIndices as getStartStopIndicesUtil } from './getStartStopIndices' +import type { Direction, SizeFunction } from './types' +import { useCachedBounds } from './useCachedBounds' +import { useItemSize } from './useItemSize' + +export function useVirtualizer({ + containerElement, + containerStyle, + defaultContainerSize = 0, + direction, + isRtl = false, + itemCount: itemCountInput, + itemProps: itemPropsInput, + itemSize: itemSizeProp, + onResize, + overscanCount, +}: { + containerElement: Ref; + containerStyle?: CSSProperties; + defaultContainerSize?: number; + direction: Direction; + isRtl?: boolean; + itemCount: number | Ref | ComputedRef; + itemProps: Props | ComputedRef; + itemSize: number | string | SizeFunction | Ref> | ComputedRef>; + onResize: + | (( + size: { height: number; width: number; }, + prevSize: { height: number; width: number; } + ) => void) + | undefined; + overscanCount: number; +}) { + const itemCount = computed(() => { + return isRef(itemCountInput) ? itemCountInput.value : itemCountInput + }) + + const itemProps = computed(() => { + return isRef(itemPropsInput) ? itemPropsInput.value : itemPropsInput + }) + + const itemSizeNormalized = computed(() => { + return isRef(itemSizeProp) ? itemSizeProp.value : itemSizeProp + }) + + const styleHeight = computed(() => parseNumericStyleValue(containerStyle?.height)) + const styleWidth = computed(() => parseNumericStyleValue(containerStyle?.width)) + + const sizeState = ref<{ + height: number | undefined; + width: number | undefined; + }>({ + height: direction === 'vertical' ? defaultContainerSize : undefined, + width: direction === 'horizontal' ? defaultContainerSize : undefined, + }) + + const observerDisabled = computed( + () => + (direction === 'vertical' && styleHeight.value !== undefined) || + (direction === 'horizontal' && styleWidth.value !== undefined), + ) + + useResizeObserver( + containerElement, + (entries) => { + if (observerDisabled.value) return + + for (const entry of entries) { + const { contentRect } = entry + const prevState = sizeState.value + + if ( + prevState.height === contentRect.height && + prevState.width === contentRect.width + ) { + return + } + + sizeState.value = { + height: contentRect.height, + width: contentRect.width, + } + } + }, + ) + + const size = computed(() => ({ + height: styleHeight.value ?? sizeState.value.height, + width: styleWidth.value ?? sizeState.value.width, + })) + + const prevSize = ref<{ height: number; width: number; }>({ + height: 0, + width: 0, + }) + + watch( + size, + (newSize) => { + if (typeof onResize === 'function') { + const prev = prevSize.value + const height = newSize.height ?? 0 + const width = newSize.width ?? 0 + + if (prev.height !== height || prev.width !== width) { + onResize({ height, width }, { ...prev }) + + prevSize.value = { height, width } + } + } + }, + { immediate: true }, + ) + + const containerSize = computed(() => + direction === 'vertical' + ? size.value.height ?? defaultContainerSize + : size.value.width ?? defaultContainerSize, + ) + + const itemSize = computed(() => + useItemSize({ containerSize: containerSize.value, itemSize: itemSizeNormalized.value }), + ) + + const cachedBounds = computed(() => + useCachedBounds({ + itemCount: itemCount.value, + itemProps: itemProps.value, + itemSize: itemSize.value, + }).value, + ) + + const getCellBounds = (index: number) => cachedBounds.value.get(index) + + const indices = ref<{ + startIndexVisible: number; + stopIndexVisible: number; + startIndexOverscan: number; + stopIndexOverscan: number; + }>( + getStartStopIndicesUtil({ + cachedBounds: cachedBounds.value, + containerScrollOffset: 0, + containerSize: containerSize.value, + itemCount: itemCount.value, + overscanCount, + }), + ) + + const safeIndices = computed(() => { + const ind = indices.value + + return { + startIndexVisible: Math.min(itemCount.value - 1, ind.startIndexVisible), + startIndexOverscan: Math.min(itemCount.value - 1, ind.startIndexOverscan), + stopIndexVisible: Math.min(itemCount.value - 1, ind.stopIndexVisible), + stopIndexOverscan: Math.min(itemCount.value - 1, ind.stopIndexOverscan), + } + }) + + const getEstimatedSize = computed(() => + getEstimatedSizeUtil({ + cachedBounds: cachedBounds.value, + itemCount: itemCount.value, + itemSize: itemSize.value, + }), + ) + + const getStartStopIndices = (scrollOffset: number) => { + const containerScrollOffset = adjustScrollOffsetForRtl({ + containerElement: containerElement.value, + direction, + isRtl, + scrollOffset, + }) + + return getStartStopIndicesUtil({ + cachedBounds: cachedBounds.value, + containerScrollOffset, + containerSize: containerSize.value, + itemCount: itemCount.value, + overscanCount, + }) + } + + watch( + [containerElement, () => direction, () => cachedBounds.value], + ([el]) => { + const scrollOffset = + (direction === 'vertical' ? el?.scrollTop : el?.scrollLeft) ?? 0 + + indices.value = getStartStopIndices(scrollOffset) + }, + { immediate: true }, + ) + + const onScroll = () => { + const el = containerElement.value + + if (!el) return + + const { scrollLeft, scrollTop } = el + + const scrollOffset = adjustScrollOffsetForRtl({ + containerElement: el, + direction, + isRtl, + scrollOffset: direction === 'vertical' ? scrollTop : scrollLeft, + }) + + const next = getStartStopIndicesUtil({ + cachedBounds: cachedBounds.value, + containerScrollOffset: scrollOffset, + containerSize: containerSize.value, + itemCount: itemCount.value, + overscanCount, + }) + + if (!shallowCompare(next, indices.value)) { + indices.value = next + } + } + + useEventListener(containerElement, 'scroll', onScroll) + + const scrollToIndex = ({ + align = 'auto', + containerScrollOffset, + index, + }: { + align?: Align; + containerScrollOffset: number; + index: number; + }) => { + let scrollOffset = getOffsetForIndex({ + align, + cachedBounds: cachedBounds.value, + containerScrollOffset, + containerSize: containerSize.value, + index, + itemCount: itemCount.value, + itemSize: itemSize.value, + }) + + const el = containerElement.value + + if (el) { + scrollOffset = adjustScrollOffsetForRtl({ + containerElement: el, + direction, + isRtl, + scrollOffset, + }) + + if (typeof el.scrollTo !== 'function') { + const next = getStartStopIndices(scrollOffset) + + if (!shallowCompare(indices.value, next)) { + indices.value = next + } + } + + return scrollOffset + } + + return undefined + } + + return { + getCellBounds, + getEstimatedSize, + scrollToIndex, + startIndexOverscan: computed(() => safeIndices.value.startIndexOverscan), + startIndexVisible: computed(() => safeIndices.value.startIndexVisible), + stopIndexOverscan: computed(() => safeIndices.value.stopIndexOverscan), + stopIndexVisible: computed(() => safeIndices.value.stopIndexVisible), + } +} diff --git a/packages/ui/src/virtual/index.ts b/packages/ui/src/virtual/index.ts new file mode 100644 index 00000000..c2c21494 --- /dev/null +++ b/packages/ui/src/virtual/index.ts @@ -0,0 +1,25 @@ +export { default as XVirtualGrid } from './components/virtualGrid/VirtualGrid.vue' +export type { + CellSlotProps, + VirtualGridImperativeAPI, + VirtualGridProps, +} from './components/virtualGrid/types' + +export { default as XVirtualList } from './components/virtualList/VirtualList.vue' +export { useDynamicRowHeight } from './components/virtualList/useDynamicRowHeight' +export type { + DynamicRowHeight, + VirtualListImperativeAPI, + VirtualListProps, + RowSlotProps, +} from './components/virtualList/types' + +export { default as XInfiniteLoader } from './components/infiniteLoader/InfiniteLoader.vue' +export { useInfiniteLoader } from './composables/infinite-loader/useInfiniteLoader' +export type { + Indices, + OnRowsRendered, + InfiniteLoaderProps, +} from './composables/infinite-loader/types' + +export { getScrollbarSize } from './utils/getScrollbarSize' diff --git a/packages/ui/src/virtual/test-utils/mockResizeObserver.ts b/packages/ui/src/virtual/test-utils/mockResizeObserver.ts new file mode 100644 index 00000000..7b45aa22 --- /dev/null +++ b/packages/ui/src/virtual/test-utils/mockResizeObserver.ts @@ -0,0 +1,162 @@ +import EventEmitter from 'node:events' + +type GetDOMRect = (element: HTMLElement) => DOMRectReadOnly | undefined | void; + +const emitter = new EventEmitter() + +emitter.setMaxListeners(100) + +const elementToDOMRect = new Map() + +let defaultDomRect: DOMRectReadOnly = new DOMRect(0, 0, 0, 0) +let disabled: boolean = false +let getDOMRect: GetDOMRect | undefined = undefined + +export function disableResizeObserverForCurrentTest() { + disabled = true +} + +export function setDefaultElementSize({ + height, + width, +}: { + height: number; + width: number; +}) { + defaultDomRect = new DOMRect(0, 0, width, height) + + emitter.emit('change') +} + +export function setElementSizeFunction(value: GetDOMRect) { + getDOMRect = value + + emitter.emit('change') +} + +export function setElementSize({ + element, + height, + width, +}: { + element: HTMLElement; + height: number; + width: number; +}) { + elementToDOMRect.set(element, new DOMRect(0, 0, width, height)) + + emitter.emit('change', element) +} + +export function simulateUnsupportedEnvironmentForTest() { + // @ts-expect-error Simulate API being unsupported + window.ResizeObserver = null +} + +export function mockResizeObserver() { + disabled = false + + const originalResizeObserver = window.ResizeObserver + + window.ResizeObserver = MockResizeObserver + + return function unmockResizeObserver() { + window.ResizeObserver = originalResizeObserver + + defaultDomRect = new DOMRect(0, 0, 0, 0) + disabled = false + getDOMRect = undefined + + elementToDOMRect.clear() + } +} + +class MockResizeObserver implements ResizeObserver { + readonly #callback: ResizeObserverCallback + #disconnected: boolean = false + #elements: Set = new Set() + + constructor(callback: ResizeObserverCallback) { + this.#callback = callback + + emitter.addListener('change', this.#onChange) + } + + observe(element: HTMLElement) { + if (this.#disconnected) { + return + } + + this.#elements.add(element) + this.#notify([element]) + } + + unobserve(element: HTMLElement) { + this.#elements.delete(element) + } + + disconnect() { + this.#disconnected = true + this.#elements.clear() + + emitter.removeListener('change', this.#onChange) + } + + #notify(elements: HTMLElement[]) { + if (disabled) { + return + } + + const entries = elements.map((element) => { + const computedStyle = window.getComputedStyle(element) + const writingMode = computedStyle.writingMode + + let contentRect: DOMRectReadOnly = + elementToDOMRect.get(element) ?? defaultDomRect + + if (getDOMRect) { + const contentRectOverride = getDOMRect(element) + + if (contentRectOverride) { + contentRect = contentRectOverride + } + } + + let blockSize = 0 + let inlineSize = 0 + + if (writingMode.includes('vertical')) { + blockSize = contentRect.width + inlineSize = contentRect.height + } else { + blockSize = contentRect.height + inlineSize = contentRect.width + } + + return { + borderBoxSize: [ + { + blockSize, + inlineSize, + }, + ], + contentBoxSize: [], + contentRect, + devicePixelContentBoxSize: [], + target: element, + } + }) + + this.#callback(entries, this) + } + + #onChange = (target?: HTMLElement) => { + if (target) { + if (this.#elements.has(target)) { + this.#notify([target]) + } + } else { + this.#notify(Array.from(this.#elements)) + } + } +} diff --git a/packages/ui/src/virtual/types.ts b/packages/ui/src/virtual/types.ts new file mode 100644 index 00000000..cf5cdbe0 --- /dev/null +++ b/packages/ui/src/virtual/types.ts @@ -0,0 +1,3 @@ +export type Align = 'auto' | 'center' | 'end' | 'smart' | 'start'; + +export type TagNames = keyof HTMLElementTagNameMap; diff --git a/packages/ui/src/virtual/utils/adjustScrollOffsetForRtl.ts b/packages/ui/src/virtual/utils/adjustScrollOffsetForRtl.ts new file mode 100644 index 00000000..4bdc5e4b --- /dev/null +++ b/packages/ui/src/virtual/utils/adjustScrollOffsetForRtl.ts @@ -0,0 +1,37 @@ +import type { Direction } from '../core/types' +import { getRTLOffsetType } from './getRTLOffsetType' + +export function adjustScrollOffsetForRtl({ + containerElement, + direction, + isRtl, + scrollOffset, +}: { + containerElement: HTMLElement | null; + direction: Direction; + isRtl: boolean; + scrollOffset: number; +}) { + // TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements. + // This is not the case for all browsers though (e.g. Chrome reports values as positive, measured relative to the left). + // So we need to determine which browser behavior we're dealing with, and mimic it. + if (direction === 'horizontal') { + if (isRtl) { + switch (getRTLOffsetType()) { + case 'negative': { + return -scrollOffset + } + case 'positive-descending': { + if (containerElement) { + const { clientWidth, scrollLeft, scrollWidth } = containerElement + + return scrollWidth - clientWidth - scrollLeft + } + break + } + } + } + } + + return scrollOffset +} diff --git a/packages/ui/src/virtual/utils/areArraysEqual.ts b/packages/ui/src/virtual/utils/areArraysEqual.ts new file mode 100644 index 00000000..962cb7e6 --- /dev/null +++ b/packages/ui/src/virtual/utils/areArraysEqual.ts @@ -0,0 +1,13 @@ +export function areArraysEqual(a: unknown[], b: unknown[]) { + if (a.length !== b.length) { + return false + } + + for (let index = 0; index < a.length; index++) { + if (!Object.is(a[index], b[index])) { + return false + } + } + + return true +} diff --git a/packages/ui/src/virtual/utils/assert.ts b/packages/ui/src/virtual/utils/assert.ts new file mode 100644 index 00000000..6926a0c9 --- /dev/null +++ b/packages/ui/src/virtual/utils/assert.ts @@ -0,0 +1,10 @@ +export function assert( + expectedCondition: unknown, + message: string = 'Assertion error', +): asserts expectedCondition { + if (!expectedCondition) { + console.error(message) + + throw Error(message) + } +} diff --git a/packages/ui/src/virtual/utils/getRTLOffsetType.ts b/packages/ui/src/virtual/utils/getRTLOffsetType.ts new file mode 100644 index 00000000..abee51fa --- /dev/null +++ b/packages/ui/src/virtual/utils/getRTLOffsetType.ts @@ -0,0 +1,51 @@ +export type RTLOffsetType = + | 'negative' + | 'positive-descending' + | 'positive-ascending'; + +let cachedRTLResult: RTLOffsetType | null = null + +// TRICKY According to the spec, scrollLeft should be negative for RTL aligned elements. +// Chrome does not seem to adhere; its scrollLeft values are positive (measured relative to the left). +// Safari's elastic bounce makes detecting this even more complicated wrt potential false positives. +// The safest way to check this is to intentionally set a negative offset, +// and then verify that the subsequent "scroll" event matches the negative offset. +// If it does not match, then we can assume a non-standard RTL scroll implementation. +export function getRTLOffsetType(recalculate: boolean = false): RTLOffsetType { + if (cachedRTLResult === null || recalculate) { + const outerDiv = document.createElement('div') + const outerStyle = outerDiv.style + + outerStyle.width = '50px' + outerStyle.height = '50px' + outerStyle.overflow = 'scroll' + outerStyle.direction = 'rtl' + + const innerDiv = document.createElement('div') + const innerStyle = innerDiv.style + + innerStyle.width = '100px' + innerStyle.height = '100px' + + outerDiv.appendChild(innerDiv) + + document.body.appendChild(outerDiv) + + if (outerDiv.scrollLeft > 0) { + cachedRTLResult = 'positive-descending' + } else { + outerDiv.scrollLeft = 1 + if (outerDiv.scrollLeft === 0) { + cachedRTLResult = 'negative' + } else { + cachedRTLResult = 'positive-ascending' + } + } + + document.body.removeChild(outerDiv) + + return cachedRTLResult + } + + return cachedRTLResult +} diff --git a/packages/ui/src/virtual/utils/getScrollbarSize.ts b/packages/ui/src/virtual/utils/getScrollbarSize.ts new file mode 100644 index 00000000..20189796 --- /dev/null +++ b/packages/ui/src/virtual/utils/getScrollbarSize.ts @@ -0,0 +1,24 @@ +let size: number = -1 + +export function getScrollbarSize(recalculate: boolean = false): number { + if (size === -1 || recalculate) { + const div = document.createElement('div') + const style = div.style + + style.width = '50px' + style.height = '50px' + style.overflow = 'scroll' + + document.body.appendChild(div) + + size = div.offsetWidth - div.clientWidth + + document.body.removeChild(div) + } + + return size +} + +export function setScrollbarSizeForTests(value: number) { + size = value +} diff --git a/packages/ui/src/virtual/utils/isRtl.ts b/packages/ui/src/virtual/utils/isRtl.ts new file mode 100644 index 00000000..fa45f765 --- /dev/null +++ b/packages/ui/src/virtual/utils/isRtl.ts @@ -0,0 +1,13 @@ +export function isRtl(element: HTMLElement) { + let currentElement: HTMLElement | null = element + + while (currentElement) { + if (currentElement.dir) { + return currentElement.dir === 'rtl' + } + + currentElement = currentElement.parentElement + } + + return false +} diff --git a/packages/ui/src/virtual/utils/parseNumericStyleValue.ts b/packages/ui/src/virtual/utils/parseNumericStyleValue.ts new file mode 100644 index 00000000..4212ec89 --- /dev/null +++ b/packages/ui/src/virtual/utils/parseNumericStyleValue.ts @@ -0,0 +1,19 @@ +import type { CSSProperties } from 'vue' + +export function parseNumericStyleValue( + value: CSSProperties['height'], +): number | undefined { + if (value !== undefined) { + switch (typeof value) { + case 'number': { + return value + } + case 'string': { + if (value.endsWith('px')) { + return parseFloat(value) + } + break + } + } + } +} diff --git a/packages/ui/src/virtual/utils/shallowCompare.ts b/packages/ui/src/virtual/utils/shallowCompare.ts new file mode 100644 index 00000000..c85708a8 --- /dev/null +++ b/packages/ui/src/virtual/utils/shallowCompare.ts @@ -0,0 +1,29 @@ +import { assert } from './assert' + +export function shallowCompare( + a: Type | undefined, + b: Type | undefined, +) { + if (a === b) { + return true + } + + if (!!a !== !!b) { + return false + } + + assert(a !== undefined) + assert(b !== undefined) + + if (Object.keys(a).length !== Object.keys(b).length) { + return false + } + + for (const key in a) { + if (!Object.is(b[key], a[key])) { + return false + } + } + + return true +} diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 012062be..7b378c5e 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -75,6 +75,7 @@ export default defineConfig(({ command, mode }) => { }, }, test: { + setupFiles: ['./vitest.setup.ts'], transformMode: { web: [/\.[jt]sx$/], }, diff --git a/packages/ui/vitest.setup.ts b/packages/ui/vitest.setup.ts new file mode 100644 index 00000000..f91c864b --- /dev/null +++ b/packages/ui/vitest.setup.ts @@ -0,0 +1,40 @@ +// Polyfill DOMRect for Node.js test environment +if (typeof global.DOMRect === 'undefined') { + class DOMRectPolyfill implements DOMRect { + readonly x: number + readonly y: number + readonly width: number + readonly height: number + readonly top: number + readonly right: number + readonly bottom: number + readonly left: number + + constructor(x = 0, y = 0, width = 0, height = 0) { + this.x = x + this.y = y + this.width = width + this.height = height + this.top = y + this.left = x + this.right = x + width + this.bottom = y + height + } + + toJSON() { + return { + x: this.x, + y: this.y, + width: this.width, + height: this.height, + top: this.top, + right: this.right, + bottom: this.bottom, + left: this.left, + } + } + } + + // @ts-expect-error - Polyfilling DOMRect for test environment + global.DOMRect = DOMRectPolyfill +} diff --git a/packages/ui/volar.d.ts b/packages/ui/volar.d.ts index b8950510..4486b644 100644 --- a/packages/ui/volar.d.ts +++ b/packages/ui/volar.d.ts @@ -20,6 +20,7 @@ declare module 'vue' { XFormGroup: typeof import('@indielayer/ui')['XFormGroup'] XIcon: typeof import('@indielayer/ui')['XIcon'] XImage: typeof import('@indielayer/ui')['XImage'] + XInfiniteLoader: typeof import('@indielayer/ui')['XInfiniteLoader'] XInput: typeof import('@indielayer/ui')['XInput'] XInputFooter: typeof import('@indielayer/ui')['XInputFooter'] XLabel: typeof import('@indielayer/ui')['XLabel'] @@ -58,6 +59,8 @@ declare module 'vue' { XToggleTip: typeof import('@indielayer/ui')['XToggleTip'] XTooltip: typeof import('@indielayer/ui')['XTooltip'] XUpload: typeof import('@indielayer/ui')['XUpload'] + XVirtualGrid: typeof import('@indielayer/ui')['XVirtualGrid'] + XVirtualList: typeof import('@indielayer/ui')['XVirtualList'] } }