├── .gitignore ├── src ├── index.tsx ├── scroll-target-context.tsx ├── types.ts ├── helpers │ ├── utils.ts │ ├── diff-positions.ts │ ├── create-main-axis-positions.ts │ └── create-measurements-observer.ts └── virtual-container.tsx ├── .prettierrc ├── rollup.config.js ├── web-test-runner.config.mjs ├── tsconfig.json ├── CHANGELOG.md ├── .github └── workflows │ └── publish-package.yml ├── LICENSE ├── package.json ├── test ├── get-offset-between-elements.test.tsx ├── diff-positions.test.tsx └── create-measurements-observer.test.tsx └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export { VirtualContainer } from './virtual-container' 2 | export * from './types' 3 | export * from './scroll-target-context' 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "typescript", 3 | "singleQuote": true, 4 | "jsxSingleQuote": true, 5 | "semi": false, 6 | "trailingComma": "all" 7 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import withSolid from 'rollup-preset-solid' 2 | 3 | export default withSolid({ 4 | input: 'src/index.tsx', 5 | external: ['solid-js/store'], 6 | targets: ['esm', 'cjs'], 7 | }) 8 | -------------------------------------------------------------------------------- /src/scroll-target-context.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'solid-js' 2 | 3 | export interface ScrollTargetContextProps { 4 | scrollTarget?: HTMLElement 5 | } 6 | 7 | export const ScrollTargetContext = createContext() 8 | -------------------------------------------------------------------------------- /web-test-runner.config.mjs: -------------------------------------------------------------------------------- 1 | import { esbuildPlugin } from '@web/dev-server-esbuild' 2 | 3 | export default { 4 | nodeResolve: true, 5 | plugins: [ 6 | esbuildPlugin({ 7 | ts: true, 8 | tsx: true, 9 | jsx: true, 10 | jsxFactory: 'h', 11 | jsxFragment: 'Fragment', 12 | }), 13 | ], 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "jsx": "preserve", 5 | "jsxImportSource": "solid-js", 6 | "target": "ESNext", 7 | "module": "ESNext", 8 | "moduleResolution": "node", 9 | "isolatedModules": true, 10 | "strict": true 11 | }, 12 | "include": [ 13 | "./src" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.2.1 - 2021-08-06 4 | Fix rendering error when container is created, but not attached to the dom yet. 5 | 6 | ## 0.2.0 - 2021-07-17 7 | - New requirement: CSP must allow inline style tags. 8 | - Static styles are now applied using inline global style tag instead of setting them directly. This has minor performance improvement. 9 | 10 | ## 0.1.2 - 2021-07-04 11 | - Fix arrow keys navigation with horizontal direction. 12 | 13 | ## 0.1.1 - 2021-07-04 14 | - Initial release 15 | -------------------------------------------------------------------------------- /.github/workflows/publish-package.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | release: 4 | types: 5 | - created 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v2 12 | with: 13 | node-version: 16 14 | registry-url: https://registry.npmjs.org/ 15 | - run: npm install 16 | - run: npm run build 17 | - run: npm run test 18 | - run: npm publish --access public 19 | env: 20 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Justinas Delinda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { JSX } from 'solid-js' 2 | import { Axis, Measurements } from './helpers/create-measurements-observer' 3 | 4 | export type { Measurements } 5 | 6 | export type ScrollDirection = 'vertical' | 'horizontal' 7 | 8 | export interface VirtualItemSizeStatic { 9 | width?: number 10 | height?: number 11 | } 12 | 13 | export type VirtualItemSizeDynamic = ( 14 | crossAxisContentSize: number, 15 | isHorizontal: boolean, 16 | ) => VirtualItemSizeStatic 17 | 18 | export type VirtualItemSize = VirtualItemSizeStatic | VirtualItemSizeDynamic 19 | 20 | export interface VirtualItemProps { 21 | items: readonly T[] 22 | item: T 23 | index: number 24 | tabIndex: number 25 | style: Record 26 | } 27 | 28 | export interface CrossAxisCountOptions { 29 | target: Axis 30 | container: Axis 31 | itemSize: Axis 32 | } 33 | 34 | export interface VirtualContainerProps { 35 | items: readonly T[] 36 | itemSize: VirtualItemSize 37 | scrollTarget?: HTMLElement 38 | direction?: ScrollDirection 39 | overscan?: number 40 | className?: string 41 | role?: JSX.HTMLAttributes['role'] 42 | crossAxisCount?: ( 43 | measurements: CrossAxisCountOptions, 44 | itemsCount: number, 45 | ) => number 46 | children: (props: VirtualItemProps) => JSX.Element 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@minht11/solid-virtual-container", 3 | "version": "0.2.1", 4 | "description": "Virtual list/grid for Solid-js.", 5 | "author": "Justinas Delinda", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git://github.com/minht11/solid-virtual-container.git" 10 | }, 11 | "main": "./dist/cjs/index.js", 12 | "module": "./dist/esm/index.js", 13 | "exports": { 14 | ".": { 15 | "solid": "./dist/source/index.jsx", 16 | "import": "./dist/esm/index.js", 17 | "browser": "./dist/esm/index.js" 18 | } 19 | }, 20 | "types": "./dist/types/index.d.ts", 21 | "files": [ 22 | "dist" 23 | ], 24 | "sideEffects": false, 25 | "keywords": [ 26 | "solid-js", 27 | "virtual-list", 28 | "virtual-grid", 29 | "virtual-scroller", 30 | "virtual", 31 | "virtualized", 32 | "scrolling", 33 | "scroller", 34 | "infinite" 35 | ], 36 | "scripts": { 37 | "build": "rollup -c", 38 | "watch": "rollup -cw", 39 | "test": "wtr test/**/*.test.tsx --node-resolve", 40 | "test:watch": "wtr test/**/*.test.tsx --node-resolve --watch", 41 | "coverage": "wtr test/**/*.test.tsx --node-resolve --coverage", 42 | "prettier": "npx prettier src --check", 43 | "prettier:fix": "npm run prettier -- --write" 44 | }, 45 | "devDependencies": { 46 | "@esm-bundle/chai": "^4.3.4", 47 | "@web/dev-server-esbuild": "^0.2.12", 48 | "@web/test-runner": "^0.13.13", 49 | "prettier": "^2.3.2", 50 | "rollup-preset-solid": "^1.0.0", 51 | "typescript": "^4.3.4" 52 | }, 53 | "peerDependencies": { 54 | "solid-js": ">= 1.0.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | export const createArray = (startPosition: number, count: number) => { 2 | const array = [] 3 | for (let i = 0; i < count; i += 1) { 4 | array.push(startPosition + i) 5 | } 6 | return array 7 | } 8 | 9 | export const getFiniteNumberOrZero = (value: number) => 10 | Number.isFinite(value) ? value : 0 11 | 12 | export const doesElementContainFocus = (element: Element): boolean => 13 | element.matches(':focus-within') 14 | 15 | export const findFocusedElement = ( 16 | container: Element | Document, 17 | ): HTMLElement | null => { 18 | // If element contains focus it must be instanceof HTMLElement, 19 | // otherwise it's always null 20 | const element = container.querySelector(':focus') 21 | 22 | return element 23 | } 24 | 25 | export const clickFocusedElement = (container: Element | Document): boolean => { 26 | const element = findFocusedElement(container) 27 | 28 | if (element) { 29 | element.click() 30 | return true 31 | } 32 | return false 33 | } 34 | 35 | // Get element offset relative to the target element excluding css transforms. 36 | export const getOffsetBetweenElements = ( 37 | targetEl: HTMLElement, 38 | startEl: HTMLElement, 39 | ) => { 40 | let element = startEl 41 | let top = 0 42 | let left = 0 43 | 44 | while (element && targetEl !== element) { 45 | const { offsetTop, offsetLeft, offsetParent } = element 46 | 47 | // Check if nearest containing root is or is inside target element. 48 | if (targetEl.contains(offsetParent)) { 49 | top += offsetTop 50 | left += offsetLeft 51 | } else { 52 | top += offsetTop - targetEl.offsetTop 53 | left += offsetLeft - targetEl.offsetLeft 54 | break 55 | } 56 | 57 | element = offsetParent as HTMLElement 58 | } 59 | 60 | return { 61 | offsetTop: top, 62 | offsetLeft: left, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /test/get-offset-between-elements.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jsxImportSource solid-js 3 | */ 4 | import h from 'solid-js/h' 5 | import { expect } from '@esm-bundle/chai' 6 | import { getOffsetBetweenElements } from '../src/helpers/utils' 7 | 8 | interface OffsetContainerProps { 9 | target?: boolean 10 | middle?: boolean 11 | } 12 | 13 | const TARGET_OFFSET = 40 14 | const MIDDLE_OFFSET = 50 15 | const EXPECTED_DISTANCE = TARGET_OFFSET + MIDDLE_OFFSET 16 | 17 | const setup = (roots: OffsetContainerProps) => { 18 | const style = (isContainmentRoot: boolean, padding = 0) => ({ 19 | contain: isContainmentRoot && 'content', 20 | padding: `${padding}px`, 21 | }) 22 | 23 | const elements = ( 24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | ) as HTMLDivElement 32 | 33 | document.body.appendChild(elements) 34 | 35 | return { 36 | target: elements.querySelector('#target') as HTMLDivElement, 37 | start: elements.querySelector('#start') as HTMLDivElement, 38 | } 39 | } 40 | 41 | beforeEach(() => { 42 | document.body.innerHTML = '' 43 | }) 44 | 45 | describe('Test the offset distance measuring between target and child', () => { 46 | it('should get right distance when containing root is body', () => { 47 | const { start, target } = setup({ target: false, middle: false }) 48 | 49 | expect(getOffsetBetweenElements(target, start).offsetLeft).to.equal( 50 | EXPECTED_DISTANCE, 51 | ) 52 | }) 53 | it('should get right distance when containing root is body and there is one containing root in between', () => { 54 | const { start, target } = setup({ target: false, middle: true }) 55 | 56 | expect(getOffsetBetweenElements(target, start).offsetLeft).to.equal( 57 | EXPECTED_DISTANCE, 58 | ) 59 | }) 60 | it('should get right distance when containing root is target and there is no containing root in between', () => { 61 | const { start, target } = setup({ target: true, middle: false }) 62 | 63 | expect(getOffsetBetweenElements(target, start).offsetLeft).to.equal( 64 | EXPECTED_DISTANCE, 65 | ) 66 | }) 67 | it('should get right distance when containing root is target and there is one containing root in between', () => { 68 | const { start, target } = setup({ target: true, middle: true }) 69 | 70 | expect(getOffsetBetweenElements(target, start).offsetLeft).to.equal( 71 | EXPECTED_DISTANCE, 72 | ) 73 | }) 74 | }) 75 | -------------------------------------------------------------------------------- /src/helpers/diff-positions.ts: -------------------------------------------------------------------------------- 1 | import { createArray } from './utils' 2 | 3 | export const ELEMENT_USED_FOR_FOCUS = 1 4 | 5 | interface DiffPositionsOptions { 6 | total: number 7 | focusPosition: number 8 | positionCount: number 9 | startPosition: number 10 | prevStartPosition: number 11 | prevPositions: number[] 12 | } 13 | 14 | export const diffPositions = (options: DiffPositionsOptions) => { 15 | const { 16 | total, 17 | focusPosition, 18 | positionCount, 19 | startPosition, 20 | prevPositions, 21 | prevStartPosition, 22 | } = options 23 | 24 | const prevPositionsCount = prevPositions.length 25 | 26 | const arePositionsStatic = total <= positionCount 27 | 28 | if (arePositionsStatic) { 29 | if ( 30 | prevPositionsCount === positionCount && 31 | prevStartPosition === startPosition 32 | ) { 33 | return prevPositions 34 | } 35 | 36 | return createArray(0, positionCount) 37 | } 38 | 39 | const endPosition = startPosition + positionCount 40 | const isPositionOutOfBounds = (pos: number) => 41 | pos < startPosition || pos >= endPosition 42 | 43 | // Focused position must remain static, 44 | // so add one more position. 45 | const findExpandedPositionForFocus = () => { 46 | if (isPositionOutOfBounds(focusPosition)) { 47 | return focusPosition 48 | } 49 | 50 | let newPosition 51 | if (endPosition < total) { 52 | newPosition = endPosition 53 | // At the very end of the list there is nowhere to add new postion 54 | // so instead insert it before startPosition. 55 | } else { 56 | newPosition = startPosition - 1 57 | } 58 | 59 | return newPosition 60 | } 61 | 62 | const newPositionsTotalCount = positionCount + ELEMENT_USED_FOR_FOCUS 63 | 64 | // If arrays are different size diffing won't work. 65 | // This ussually happens when layout size changes. 66 | if (prevPositionsCount !== newPositionsTotalCount) { 67 | const newPositions = createArray(startPosition, positionCount) 68 | newPositions.push(findExpandedPositionForFocus()) 69 | 70 | return newPositions 71 | } 72 | 73 | const unusedPositions: number[] = [] 74 | for (let i = 0; i < positionCount; i += 1) { 75 | const position = startPosition + i 76 | 77 | if (!prevPositions.includes(position)) { 78 | unusedPositions.push(position) 79 | } 80 | } 81 | 82 | const newAddedPosition = findExpandedPositionForFocus() 83 | if (!prevPositions.includes(newAddedPosition)) { 84 | unusedPositions.push(newAddedPosition) 85 | } 86 | 87 | return prevPositions.map((prevPosition) => { 88 | if ( 89 | isPositionOutOfBounds(prevPosition) && 90 | prevPosition !== newAddedPosition 91 | ) { 92 | return unusedPositions.pop() as number 93 | } 94 | 95 | return prevPosition 96 | }) 97 | } 98 | -------------------------------------------------------------------------------- /src/helpers/create-main-axis-positions.ts: -------------------------------------------------------------------------------- 1 | import { createComputed, createMemo, untrack } from 'solid-js' 2 | import { createStore, unwrap } from 'solid-js/store' 3 | import { Measurements } from './create-measurements-observer' 4 | import { diffPositions } from './diff-positions' 5 | import { getFiniteNumberOrZero } from './utils' 6 | 7 | export const getIntegerOrZero = (value: number) => 8 | Number.isInteger(value) ? value : 0 9 | 10 | interface AxisValues { 11 | totalItemCount: number 12 | focusPosition: number 13 | } 14 | 15 | interface State { 16 | overscan: number 17 | positionCount: number 18 | currentPosition: number 19 | maxScrollPosition: number 20 | } 21 | 22 | export const createMainAxisPositions = ( 23 | measurements: Measurements, 24 | axis: AxisValues, 25 | getOverscan: () => number | undefined, 26 | ) => { 27 | const [state, setState] = createStore({ 28 | overscan: 0, 29 | positionCount: 0, 30 | maxScrollPosition: 0, 31 | currentPosition: 0, 32 | }) 33 | 34 | createComputed(() => { 35 | if (!measurements.isMeasured) { 36 | return 37 | } 38 | 39 | const totalElementCount = axis.totalItemCount 40 | const mItemSize = measurements.itemSize.main 41 | const mTargetSize = measurements.target.main 42 | 43 | untrack(() => { 44 | const MINIMUM_OVERSCAN_DISTANCE = 180 45 | const overscanNotSafe = 46 | getOverscan() ?? 47 | Math.max(Math.ceil(MINIMUM_OVERSCAN_DISTANCE / mItemSize), 2) 48 | 49 | const overscan = getFiniteNumberOrZero(overscanNotSafe) 50 | setState('overscan', overscan) 51 | 52 | // Calculate how many elements are visible on screen. 53 | const mainAxisVisibleCount = Math.ceil(mTargetSize / mItemSize) 54 | 55 | const positionCount = getIntegerOrZero( 56 | Math.min(mainAxisVisibleCount + overscan * 2, totalElementCount), 57 | ) 58 | 59 | setState('positionCount', positionCount) 60 | setState('maxScrollPosition', totalElementCount - positionCount) 61 | }) 62 | }) 63 | 64 | createComputed(() => { 65 | if (!measurements.isMeasured) { 66 | return 67 | } 68 | 69 | // Calculate scrollValue only from place where itemsContainer starts. 70 | const scrollValueAdjusted = 71 | measurements.mainAxisScrollValue - measurements.container.offsetMain 72 | 73 | // Scroll position is an index representing each item's place on screen. 74 | const basePosition = Math.floor( 75 | scrollValueAdjusted / measurements.itemSize.main, 76 | ) 77 | 78 | const positionAdjusted = basePosition - state.overscan 79 | 80 | // Clamp scroll position so it doesn't exceed bounds. 81 | const currentPosition = Math.min( 82 | Math.max(0, positionAdjusted), 83 | state.maxScrollPosition, 84 | ) 85 | setState('currentPosition', currentPosition) 86 | }) 87 | 88 | let prevPosition = 0 89 | const positions = createMemo((prev = []) => { 90 | if (!measurements.isMeasured) { 91 | return prev 92 | } 93 | 94 | const startPosition = state.currentPosition 95 | const newPositions = diffPositions({ 96 | total: axis.totalItemCount, 97 | focusPosition: axis.focusPosition, 98 | positionCount: state.positionCount, 99 | startPosition, 100 | prevStartPosition: prevPosition, 101 | prevPositions: prev, 102 | }) 103 | 104 | prevPosition = startPosition 105 | 106 | return newPositions 107 | }) 108 | 109 | return positions 110 | } 111 | -------------------------------------------------------------------------------- /test/diff-positions.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jsxImportSource solid-js 3 | */ 4 | import h from 'solid-js/h' 5 | import { expect } from '@esm-bundle/chai' 6 | import { diffPositions } from '../src/helpers/diff-positions' 7 | 8 | const uniqueLength = (items: unknown[]) => [...new Set(items)].length 9 | 10 | const TOTAL = 40 11 | 12 | const positionsShouldBeUnique = (positions: number[]) => { 13 | expect(uniqueLength(positions)).to.be.eq(positions.length) 14 | } 15 | const shouldHaveFocusPosition = ( 16 | positions: number[], 17 | focusPosition: number, 18 | ) => { 19 | expect(positions.includes(focusPosition)).to.be.true 20 | } 21 | 22 | describe('Test the target and child offset distance', () => { 23 | it('should change position incrementally', () => { 24 | const focusPosition = 0 25 | const newPositions = diffPositions({ 26 | total: TOTAL, 27 | focusPosition, 28 | positionCount: 4, 29 | startPosition: 2, 30 | prevStartPosition: 0, 31 | prevPositions: [0, 1, 2, 3, 4], 32 | }) 33 | 34 | expect(newPositions).to.have.all.members([0, 5, 2, 3, 4]) 35 | 36 | shouldHaveFocusPosition(newPositions, focusPosition) 37 | positionsShouldBeUnique(newPositions) 38 | }) 39 | it('should correctly diff positions when new and old positions count do not match', () => { 40 | const focusPosition = 0 41 | const newPositions = diffPositions({ 42 | total: TOTAL, 43 | focusPosition, 44 | positionCount: 4, 45 | startPosition: 2, 46 | prevStartPosition: 0, 47 | prevPositions: [5], 48 | }) 49 | 50 | expect(newPositions).to.have.all.members([0, 5, 2, 3, 4]) 51 | 52 | shouldHaveFocusPosition(newPositions, focusPosition) 53 | positionsShouldBeUnique(newPositions) 54 | }) 55 | 56 | it('should correctly create positions if focus is inside bounds', () => { 57 | const focusPosition = 35 58 | const newPositions = diffPositions({ 59 | total: TOTAL, 60 | focusPosition, 61 | positionCount: 4, 62 | startPosition: TOTAL - 5, 63 | prevStartPosition: 0, 64 | prevPositions: [], 65 | }) 66 | 67 | expect(newPositions).to.have.all.members([focusPosition, 36, 37, 38, 39]) 68 | 69 | shouldHaveFocusPosition(newPositions, focusPosition) 70 | positionsShouldBeUnique(newPositions) 71 | }) 72 | 73 | it('should correctly create positions if focus is inside bounds at the very end', () => { 74 | const focusPosition = 38 75 | const newPositions = diffPositions({ 76 | total: TOTAL, 77 | focusPosition, 78 | positionCount: 4, 79 | startPosition: TOTAL - 4, 80 | prevStartPosition: 0, 81 | prevPositions: [], 82 | }) 83 | 84 | expect(newPositions).to.have.all.members([35, 36, 37, focusPosition, 39]) 85 | 86 | shouldHaveFocusPosition(newPositions, focusPosition) 87 | positionsShouldBeUnique(newPositions) 88 | }) 89 | 90 | it('should correctly create positions if focus is outside the bounds with previous and new positions', () => { 91 | const focusPosition = 34 92 | const newPositions = diffPositions({ 93 | total: TOTAL, 94 | focusPosition, 95 | positionCount: 4, 96 | startPosition: TOTAL - 4, 97 | prevStartPosition: 0, 98 | prevPositions: [4, 0, 1, 2, 3], 99 | }) 100 | 101 | expect(newPositions).to.have.all.members([focusPosition, 36, 37, 38, 39]) 102 | 103 | shouldHaveFocusPosition(newPositions, focusPosition) 104 | positionsShouldBeUnique(newPositions) 105 | }) 106 | 107 | it('should correctly create static positions', () => { 108 | const STATIC_TOTAL = 4 109 | const focusPosition = 2 110 | const newPositions = diffPositions({ 111 | total: STATIC_TOTAL, 112 | focusPosition, 113 | positionCount: 4, 114 | startPosition: 0, 115 | prevStartPosition: 0, 116 | prevPositions: [], 117 | }) 118 | 119 | expect(newPositions).to.have.all.members([0, 1, focusPosition, 3]) 120 | 121 | shouldHaveFocusPosition(newPositions, focusPosition) 122 | positionsShouldBeUnique(newPositions) 123 | }) 124 | 125 | it('should correctly return cached static positions', () => { 126 | const STATIC_TOTAL = 4 127 | const focusPosition = 2 128 | const newPositions = diffPositions({ 129 | total: STATIC_TOTAL, 130 | focusPosition, 131 | positionCount: 4, 132 | startPosition: 0, 133 | prevStartPosition: 0, 134 | prevPositions: [0, 1, focusPosition, 3], 135 | }) 136 | 137 | expect(newPositions).to.have.all.members([0, 1, focusPosition, 3]) 138 | 139 | shouldHaveFocusPosition(newPositions, focusPosition) 140 | positionsShouldBeUnique(newPositions) 141 | }) 142 | }) 143 | -------------------------------------------------------------------------------- /src/helpers/create-measurements-observer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createSignal, 3 | createMemo, 4 | useContext, 5 | createEffect, 6 | batch, 7 | onCleanup, 8 | createComputed, 9 | } from 'solid-js' 10 | import { createStore } from 'solid-js/store' 11 | import { 12 | ScrollDirection, 13 | VirtualItemSize, 14 | VirtualItemSizeStatic, 15 | } from '../types' 16 | import { ScrollTargetContext } from '../scroll-target-context' 17 | import { getOffsetBetweenElements } from './utils' 18 | 19 | const getSizeFromResizeEntry = (entry: ResizeObserverEntry) => { 20 | let width = 0 21 | let height = 0 22 | if (entry.borderBoxSize) { 23 | const { borderBoxSize } = entry 24 | const size = Array.isArray(borderBoxSize) ? borderBoxSize[0] : borderBoxSize 25 | width = size.inlineSize 26 | height = size.blockSize 27 | } else { 28 | // TODO. As of yet Safari 14 still doesn't support borderBoxSize. 29 | // getBoundingClientRect changes in respect of css transform 30 | // so this is partial solution, I have no way to test if this 31 | // is working correctly. 32 | const rect = entry.target.getBoundingClientRect() 33 | width = rect.width 34 | height = rect.height 35 | } 36 | 37 | return { width, height } 38 | } 39 | 40 | const createAxis = (axisA: number, axisB: number, flip: boolean) => { 41 | const [main, cross] = flip ? [axisA, axisB] : [axisB, axisA] 42 | return { 43 | main, 44 | cross, 45 | } 46 | } 47 | 48 | export interface Axis { 49 | main: number 50 | cross: number 51 | } 52 | 53 | export interface Measurements { 54 | isMeasured: boolean 55 | mainAxisScrollValue: number 56 | itemSize: Axis 57 | target: Axis 58 | container: { 59 | offsetMain: number 60 | offsetCross: number 61 | main: number 62 | cross: number 63 | } 64 | } 65 | 66 | export interface MeasurementsObserverProps { 67 | scrollTarget?: HTMLElement 68 | direction?: ScrollDirection 69 | itemSize: VirtualItemSize 70 | } 71 | 72 | const DEFAULT_SIZE = { 73 | main: 0, 74 | cross: 0, 75 | } 76 | 77 | const doCrossAxisSizeMatch = (a: Axis, b: Axis) => a.cross === b.cross 78 | 79 | export const createMeasurementsObserver = ( 80 | props: MeasurementsObserverProps, 81 | ) => { 82 | const scrollTargetContext = useContext(ScrollTargetContext) 83 | 84 | const [containerEl, setContainerRefEl] = createSignal( 85 | undefined as unknown as HTMLDivElement, 86 | ) 87 | const targetEl = () => props.scrollTarget || scrollTargetContext?.scrollTarget 88 | 89 | const isDirectionHorizontal = createMemo( 90 | () => (props.direction || 'vertical') === 'horizontal', 91 | ) 92 | 93 | const [measurements, setMeasurements] = createStore({ 94 | isMeasured: false, 95 | mainAxisScrollValue: 0, 96 | target: { ...DEFAULT_SIZE }, 97 | container: { 98 | ...DEFAULT_SIZE, 99 | offsetMain: 0, 100 | offsetCross: 0, 101 | }, 102 | itemSize: { ...DEFAULT_SIZE }, 103 | }) 104 | 105 | const onEntry = (entry: ResizeObserverEntry) => { 106 | const entryTarget = entry.target as HTMLElement 107 | 108 | const target = targetEl() as HTMLElement 109 | const container = containerEl() 110 | 111 | const isHorizontal = isDirectionHorizontal() 112 | 113 | const size = getSizeFromResizeEntry(entry) 114 | const axisSize = createAxis(size.width, size.height, isHorizontal) 115 | 116 | if (entryTarget === target) { 117 | setMeasurements('target', axisSize) 118 | } else if (entryTarget === container) { 119 | if ( 120 | !doCrossAxisSizeMatch(measurements.container, axisSize) || 121 | !measurements.isMeasured 122 | ) { 123 | const offset = getOffsetBetweenElements(target, container) 124 | 125 | const offsetAxis = createAxis( 126 | offset.offsetLeft, 127 | offset.offsetTop, 128 | isHorizontal, 129 | ) 130 | 131 | setMeasurements('container', { 132 | ...axisSize, 133 | offsetMain: offsetAxis.main, 134 | offsetCross: offsetAxis.cross, 135 | }) 136 | } 137 | } 138 | } 139 | 140 | const getLiveScrollValue = () => { 141 | const target = targetEl() 142 | if (target) { 143 | const value = isDirectionHorizontal() 144 | ? target.scrollLeft 145 | : target.scrollTop 146 | 147 | // We are not interested in subpixel values. 148 | return Math.floor(value) 149 | } 150 | return 0 151 | } 152 | 153 | const ro = new ResizeObserver((entries) => { 154 | batch(() => { 155 | entries.forEach((entry) => onEntry(entry)) 156 | setMeasurements({ 157 | isMeasured: true, 158 | mainAxisScrollValue: getLiveScrollValue(), 159 | }) 160 | }) 161 | }) 162 | 163 | createComputed(() => { 164 | if (!measurements.isMeasured) { 165 | return 166 | } 167 | 168 | const isHorizontal = isDirectionHorizontal() 169 | const size = props.itemSize 170 | 171 | let itemSizeResolved: VirtualItemSizeStatic 172 | if (typeof size === 'function') { 173 | itemSizeResolved = size(measurements.container.cross, isHorizontal) 174 | } else { 175 | itemSizeResolved = size 176 | } 177 | const itemAxis = createAxis( 178 | itemSizeResolved.width || 0, 179 | itemSizeResolved.height || 0, 180 | isHorizontal, 181 | ) 182 | 183 | setMeasurements('itemSize', itemAxis) 184 | }) 185 | 186 | const onScrollHandle = () => { 187 | setMeasurements('mainAxisScrollValue', getLiveScrollValue()) 188 | } 189 | 190 | createEffect(() => { 191 | const target = targetEl() 192 | const container = containerEl() 193 | if (!target || !container) { 194 | return 195 | } 196 | 197 | target.addEventListener('scroll', onScrollHandle) 198 | 199 | ro.observe(target) 200 | ro.observe(container) 201 | 202 | onCleanup(() => { 203 | setMeasurements('isMeasured', false) 204 | target.removeEventListener('scroll', onScrollHandle) 205 | ro.unobserve(target) 206 | ro.unobserve(container) 207 | }) 208 | }) 209 | 210 | return { 211 | containerEl, 212 | setContainerRefEl, 213 | isDirectionHorizontal, 214 | measurements, 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Virtual container for Solid-js 2 | Efficient, single direction virtual list/grid for Solid-js 3 | 4 | ## Features 5 | * Support for grid/list modes. 6 | * Only render visible items, no matter how big is your list. 7 | * Keyboard navigation and focus management out of the box. 8 | * Option to change items size based on available space. 9 | 10 | [Demo](https://codesandbox.io/s/minht11solid-virtual-container-demo-pk74r) 11 | 12 | ## Usage 13 | 14 | ``` 15 | npm install @minht11/solid-virtual-container 16 | ``` 17 | 18 | Create list item component. 19 | ```tsx 20 | const ListItem = (props) => ( 21 |
30 |
{props.item}
31 |
32 | ) 33 | ``` 34 | Create vertically scrolling virtual list 35 | ```tsx 36 | import { VirtualContainer } from "@minht11/solid-virtual-container" 37 | 38 | const App = () => { 39 | const items = [0, 1, 2, 3] 40 | let scrollTargetElement!: HTMLDivElement 41 | return ( 42 |
43 | 49 | {ListItem} 50 | 51 |
52 | ) 53 | } 54 | ``` 55 | or a virtual grid 56 | ```tsx 57 | const App = () => { 58 | const items = [0, 1, 2, 3] 59 | let scrollTargetElement!: HTMLDivElement 60 | return ( 61 |
62 | ( 68 | Math.floor( 69 | measurements.container.cross / measurements.itemSize.cross 70 | ) 71 | )} 72 | > 73 | {ListItem} 74 | 75 |
76 | ) 77 | } 78 | ``` 79 | > You can control list items styling using regular CSS, including `width` and `height`, however properties defined using `itemSize` will take a priority. 80 | 81 | > One dimensional lists require only main direction size be set using `itemSize`. For vertical scrolling that's `height` and for horizontal direction that's `width`. Multidimensional lists require both. 82 | ## Api 83 | ### ScrollTargetContext 84 | If you you do not have an immediate access to the VirtualContainer, or do not want to pass props several components deep you can use context api. 85 | 86 | ```tsx 87 | const App = () => { 88 | const items = [0, 1, 2, 3] 89 | let scrollTargetElement!: HTMLDivElement 90 | return ( 91 |
92 | 93 | ... 94 | 95 | ... 96 | 97 |
98 | ) 99 | } 100 | ``` 101 | ### Virtual container options 102 | ```tsx 103 | interface VirtualContainer { 104 | // your list data array. 105 | items: readonly T[] 106 | // Define elements size. 107 | // All elements will use same size. 108 | itemSize: VirtualItemSize 109 | // Scrolling element, if context api is used this is not needed, 110 | // however you must use one or the other. 111 | scrollTarget?: HTMLElement 112 | // Scroll direction. Default is vertical. 113 | direction?: 'vertical' | 'horizontal' 114 | // Number of elements to render below and above currently visible items, 115 | // if not provided an optimal amount will be automatically picked. 116 | overscan?: number 117 | // Container className, if at all possible ignore this option, 118 | // because direct styling can break virtualizing, instead wrap 119 | // element in another div and style that. 120 | className?: string 121 | role?: JSX.HTMLAttributes['role'] 122 | // Function which determines how many columns in vertical mode 123 | // or rows in horizontal to show. Default is 1. 124 | crossAxisCount?: ( 125 | measurements: CrossAxisCountOptions, 126 | // The same as items.length 127 | itemsCount: number, 128 | ) => number 129 | // List item render function. 130 | children: (props: VirtualItemProps) => JSX.Element 131 | } 132 | ``` 133 | If `direction` is `vertical` main axis is vertical. 134 | If `direction` is `horizontal` main axis is horizontal. 135 | ```tsx 136 | interface Axis { 137 | // Main scrolling direction axis. 138 | main: number 139 | // Opposite axis to main. 140 | cross: number 141 | } 142 | ``` 143 | 144 | Parameter object used in `VirtualContainer.crossAxisCount` function. 145 | ```tsx 146 | interface CrossAxisCountOptions { 147 | // Scrolling element dimensions. 148 | target: Axis 149 | // Container element dimensions. 150 | container: Axis 151 | // List element dimensions. 152 | itemSize: Axis 153 | } 154 | ``` 155 | ### Item size 156 | ```tsx 157 | // You can use static object to define item size 158 | interface VirtualItemSizeStatic { 159 | width?: number 160 | height?: number 161 | } 162 | 163 | // or use a function to calculate it when layout changes. 164 | type VirtualItemSizeDynamic = ( 165 | crossAxisContentSize: number, 166 | // Scroll direction. 167 | isHorizontal: boolean, 168 | ) => VirtualItemSizeStatic 169 | ``` 170 | Dynamic size is useful when you want your 171 | grid items to fill all available space 172 | ```tsx 173 | // One possible example 174 | const calculateItemSize = (crossAxisSize: number) => { 175 | // Choose minimum size depending on the available space. 176 | const minWidth = crossAxisSize > 560 ? 180 : 140 177 | 178 | const count = Math.floor(crossAxisSize / minWidth) 179 | const width = Math.floor(crossAxisSize / count) 180 | 181 | return { 182 | width, 183 | height: width + 48 184 | } 185 | } 186 | 187 | 188 | ``` 189 | 190 | ## Limitations 191 | Different individual item sizes and scrolling with both directions at the same time are not and likely will never be supported by this package. 192 | 193 | Page CSP must allow inline style sheets. 194 | -------------------------------------------------------------------------------- /test/create-measurements-observer.test.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @jsxImportSource solid-js 3 | */ 4 | import h from 'solid-js/h' 5 | import { expect } from '@esm-bundle/chai' 6 | import { createMeasurementsObserver } from '../src/helpers/create-measurements-observer' 7 | import { createEffect, createRoot, on, onMount } from 'solid-js' 8 | 9 | declare module 'mocha' { 10 | export interface Context { 11 | target: HTMLDivElement 12 | container: HTMLDivElement 13 | } 14 | } 15 | 16 | describe('Test measurements observer', () => { 17 | describe('Test direction', () => { 18 | it('should resolve vertical direction default', () => { 19 | createRoot((dispose) => { 20 | const { isDirectionHorizontal } = createMeasurementsObserver({ 21 | itemSize: {}, 22 | }) 23 | 24 | expect(isDirectionHorizontal()).to.be.false 25 | dispose() 26 | }) 27 | }) 28 | 29 | it('should resolve vertical direction', () => { 30 | createRoot((dispose) => { 31 | const { isDirectionHorizontal } = createMeasurementsObserver({ 32 | direction: 'vertical', 33 | itemSize: {}, 34 | }) 35 | 36 | expect(isDirectionHorizontal()).to.be.false 37 | dispose() 38 | }) 39 | }) 40 | 41 | it('should resolve horizontal direction', () => { 42 | createRoot((dispose) => { 43 | const { isDirectionHorizontal } = createMeasurementsObserver({ 44 | direction: 'horizontal', 45 | itemSize: {}, 46 | }) 47 | 48 | expect(isDirectionHorizontal()).to.be.true 49 | dispose() 50 | }) 51 | }) 52 | }) 53 | 54 | describe('Test layout', function () { 55 | it('should ignore empty target', function (done) { 56 | createRoot(() => { 57 | const { measurements } = createMeasurementsObserver({ 58 | itemSize: {}, 59 | scrollTarget: null, 60 | }) 61 | 62 | setTimeout(() => { 63 | expect(measurements.isMeasured).to.be.false 64 | done() 65 | }, 50) 66 | }) 67 | }) 68 | 69 | before(function () { 70 | let container 71 | const target = ( 72 |
80 |
{ 83 | container = el 84 | }} 85 | /> 86 |
87 | ) as HTMLDivElement 88 | 89 | document.body.appendChild(target) 90 | 91 | this.target = target 92 | this.container = container 93 | }) 94 | 95 | beforeEach(function () { 96 | this.target.scrollTop = 0 97 | this.target.scrollLeft = 0 98 | }) 99 | 100 | it('should get correct initial vertical scroll value', function (done) { 101 | createRoot((dispose) => { 102 | this.target.scrollTop = 200 103 | const { measurements, setContainerRefEl } = createMeasurementsObserver({ 104 | itemSize: {}, 105 | scrollTarget: this.target, 106 | }) 107 | 108 | setContainerRefEl(this.container) 109 | 110 | createEffect(() => { 111 | if (measurements.isMeasured) { 112 | expect(measurements.mainAxisScrollValue).to.be.equal( 113 | this.target.scrollTop, 114 | ) 115 | done() 116 | dispose() 117 | } 118 | }) 119 | }) 120 | }) 121 | 122 | it('should get correct initial horizontal scroll value', function (done) { 123 | createRoot((dispose) => { 124 | this.target.scrollLeft = 200 125 | const { measurements, setContainerRefEl } = createMeasurementsObserver({ 126 | itemSize: {}, 127 | scrollTarget: this.target, 128 | direction: 'horizontal', 129 | }) 130 | 131 | setContainerRefEl(this.container) 132 | 133 | createEffect(() => { 134 | if (measurements.isMeasured) { 135 | expect(measurements.mainAxisScrollValue).to.be.equal( 136 | this.target.scrollLeft, 137 | ) 138 | done() 139 | dispose() 140 | } 141 | }) 142 | }) 143 | }) 144 | 145 | it('should observe scroll value change', function (done) { 146 | createRoot((dispose) => { 147 | const { measurements, setContainerRefEl } = createMeasurementsObserver({ 148 | itemSize: {}, 149 | scrollTarget: this.target, 150 | }) 151 | 152 | setContainerRefEl(this.container) 153 | 154 | createEffect(() => { 155 | if (!measurements.isMeasured) { 156 | return 157 | } 158 | 159 | if (measurements.mainAxisScrollValue === 0) { 160 | this.target.scrollTop = 400 161 | return 162 | } 163 | 164 | expect(measurements.mainAxisScrollValue).to.be.equal( 165 | this.target.scrollTop, 166 | ) 167 | done() 168 | dispose() 169 | }) 170 | }) 171 | }) 172 | 173 | it('should measure correct target and container layout sizes', function (done) { 174 | createRoot(() => { 175 | const { measurements, setContainerRefEl } = createMeasurementsObserver({ 176 | itemSize: {}, 177 | scrollTarget: this.target, 178 | }) 179 | 180 | setContainerRefEl(this.container) 181 | 182 | createEffect(() => { 183 | if (!measurements.isMeasured) { 184 | return 185 | } 186 | 187 | expect(measurements.container).to.deep.equal({ 188 | main: 1000, 189 | cross: 1100, 190 | offsetMain: 50, 191 | offsetCross: 60, 192 | }) 193 | done() 194 | }) 195 | }) 196 | }) 197 | 198 | it('uses static item sizes', function (done) { 199 | createRoot(() => { 200 | const { measurements, setContainerRefEl } = createMeasurementsObserver({ 201 | itemSize: { height: 50, width: 60 }, 202 | scrollTarget: this.target, 203 | }) 204 | 205 | setContainerRefEl(this.container) 206 | 207 | createEffect(() => { 208 | if (!measurements.isMeasured) { 209 | return 210 | } 211 | 212 | expect(measurements.itemSize).to.deep.equal({ 213 | main: 50, 214 | cross: 60, 215 | }) 216 | done() 217 | }) 218 | }) 219 | }) 220 | 221 | it('uses correct dynamic item sizes with vertical direction', function (done) { 222 | createRoot(() => { 223 | const { measurements, setContainerRefEl } = createMeasurementsObserver({ 224 | itemSize: (containerCrossSize, isHorizontal) => { 225 | expect(containerCrossSize).to.equal(measurements.container.cross) 226 | expect(isHorizontal).to.be.false 227 | return { height: 50, width: 60 } 228 | }, 229 | scrollTarget: this.target, 230 | }) 231 | 232 | setContainerRefEl(this.container) 233 | 234 | createEffect(() => { 235 | if (!measurements.isMeasured) { 236 | return 237 | } 238 | 239 | expect(measurements.itemSize).to.deep.equal({ 240 | main: 50, 241 | cross: 60, 242 | }) 243 | done() 244 | }) 245 | }) 246 | }) 247 | 248 | it('uses correct dynamic item sizes with horizontal direction', function (done) { 249 | createRoot(() => { 250 | const { measurements, setContainerRefEl } = createMeasurementsObserver({ 251 | itemSize: (containerCrossSize, isHorizontal) => { 252 | expect(containerCrossSize).to.equal(measurements.container.cross) 253 | expect(isHorizontal).to.be.true 254 | return { height: 50, width: 60 } 255 | }, 256 | scrollTarget: this.target, 257 | direction: 'horizontal', 258 | }) 259 | 260 | setContainerRefEl(this.container) 261 | 262 | createEffect(() => { 263 | if (!measurements.isMeasured) { 264 | return 265 | } 266 | 267 | expect(measurements.itemSize).to.deep.equal({ 268 | main: 60, 269 | cross: 50, 270 | }) 271 | done() 272 | }) 273 | }) 274 | }) 275 | 276 | it('should measure correct target and container layout sizes without ResizeObserverEntry.prototype.borderBoxSize', function (done) { 277 | // @ts-ignore 278 | delete ResizeObserverEntry.prototype.borderBoxSize 279 | createRoot(() => { 280 | const { measurements, setContainerRefEl } = createMeasurementsObserver({ 281 | itemSize: {}, 282 | scrollTarget: this.target, 283 | }) 284 | 285 | setContainerRefEl(this.container) 286 | 287 | createEffect(() => { 288 | if (!measurements.isMeasured) { 289 | return 290 | } 291 | 292 | expect(measurements.container).to.deep.equal({ 293 | main: 1000, 294 | cross: 1100, 295 | offsetMain: 50, 296 | offsetCross: 60, 297 | }) 298 | done() 299 | }) 300 | }) 301 | }) 302 | }) 303 | }) 304 | -------------------------------------------------------------------------------- /src/virtual-container.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createMemo, 3 | Index, 4 | Show, 5 | createComputed, 6 | Component, 7 | children, 8 | JSX, 9 | } from 'solid-js' 10 | import { createStore } from 'solid-js/store' 11 | import { Dynamic } from 'solid-js/web' 12 | import { 13 | clickFocusedElement, 14 | createArray, 15 | doesElementContainFocus, 16 | getFiniteNumberOrZero, 17 | } from './helpers/utils' 18 | import { VirtualContainerProps } from './types' 19 | import { createMeasurementsObserver } from './helpers/create-measurements-observer' 20 | import { createMainAxisPositions } from './helpers/create-main-axis-positions' 21 | 22 | export interface VirtualState { 23 | focusPosition: number 24 | mainAxis: { 25 | totalItemCount: number 26 | // Focused position for this axis. 27 | focusPosition: number 28 | scrollValue: number 29 | } 30 | crossAxis: { 31 | totalItemCount: number 32 | } 33 | } 34 | 35 | const uniqueHash = Math.random().toString(36).slice(2, Infinity) 36 | // Avoid conflicting class names. 37 | const CONTAINER_CLASSNAME = `virtual-container-${uniqueHash}` 38 | 39 | let globalContainerStylesheet: HTMLStyleElement 40 | 41 | // Dom bindings are expensive. Even setting the same style values 42 | // causes performance issues, so instead apply static styles 43 | // ahead of time using global style tag. 44 | const insertGlobalStylesheet = () => { 45 | if (!globalContainerStylesheet) { 46 | globalContainerStylesheet = document.createElement('style') 47 | globalContainerStylesheet.type = 'text/css' 48 | 49 | globalContainerStylesheet.textContent = ` 50 | .${CONTAINER_CLASSNAME} { 51 | position: relative !important; 52 | flex-shrink: 0 !important; 53 | } 54 | .${CONTAINER_CLASSNAME} > * { 55 | will-change: transform !important; 56 | box-sizing: border-box !important; 57 | contain: strict !important; 58 | position: absolute !important; 59 | top: 0 !important; 60 | left: 0 !important; 61 | } 62 | ` 63 | document.head.appendChild(globalContainerStylesheet) 64 | } 65 | } 66 | 67 | export function VirtualContainer(props: VirtualContainerProps) { 68 | insertGlobalStylesheet() 69 | 70 | const [state, setState] = createStore({ 71 | focusPosition: 0, 72 | mainAxis: { 73 | totalItemCount: 0, 74 | focusPosition: 0, 75 | scrollValue: 0, 76 | }, 77 | crossAxis: { 78 | totalItemCount: 0, 79 | }, 80 | }) 81 | 82 | const { 83 | containerEl, 84 | setContainerRefEl, 85 | isDirectionHorizontal, 86 | measurements, 87 | } = createMeasurementsObserver(props) 88 | 89 | // This selector is used for position calculations, 90 | // so value must always be current. 91 | // itemsMemo below is used only for rendering items. 92 | const itemsCount = () => (props.items && props.items.length) || 0 93 | 94 | createComputed(() => { 95 | if (!measurements.isMeasured) { 96 | return 97 | } 98 | 99 | const cTotal = getFiniteNumberOrZero( 100 | props.crossAxisCount?.(measurements, itemsCount()) || 0, 101 | ) 102 | 103 | // There are always must be at least one column. 104 | setState('crossAxis', { 105 | totalItemCount: Math.max(1, cTotal), 106 | }) 107 | }) 108 | 109 | createComputed(() => { 110 | if (!measurements.isMeasured) { 111 | return 112 | } 113 | 114 | const iCount = itemsCount() 115 | const cTotal = state.crossAxis.totalItemCount 116 | 117 | const mTotal = Math.ceil(iCount / cTotal) 118 | 119 | setState('mainAxis', { 120 | totalItemCount: getFiniteNumberOrZero(mTotal), 121 | }) 122 | setState('crossAxis', { 123 | totalItemCount: cTotal, 124 | positions: createArray(0, state.crossAxis.totalItemCount), 125 | }) 126 | }) 127 | 128 | createComputed(() => { 129 | const mFocusPos = Math.floor( 130 | state.focusPosition / state.crossAxis.totalItemCount, 131 | ) 132 | setState('mainAxis', 'focusPosition', getFiniteNumberOrZero(mFocusPos)) 133 | }) 134 | 135 | const mainAxisPositions = createMainAxisPositions( 136 | measurements, 137 | state.mainAxis, 138 | () => props.overscan, 139 | ) 140 | 141 | const containerStyleProps = (): JSX.CSSProperties => { 142 | const containerSize = 143 | state.mainAxis.totalItemCount * measurements.itemSize.main 144 | const property = isDirectionHorizontal() ? 'width' : 'height' 145 | const property2 = isDirectionHorizontal() ? 'height' : 'width' 146 | 147 | return { 148 | [property]: `${containerSize}px`, 149 | [property2]: '100%', 150 | } 151 | } 152 | 153 | const getItemStyle = (mainPos: number, crossPos = 0) => { 154 | const size = measurements.itemSize 155 | 156 | const mainSize = size.main * mainPos 157 | const crossSize = size.cross * crossPos 158 | 159 | let xTranslate = crossSize 160 | let yTranslate = mainSize 161 | let width = size.cross 162 | let height = size.main 163 | 164 | if (isDirectionHorizontal()) { 165 | xTranslate = mainSize 166 | yTranslate = crossSize 167 | width = size.main 168 | height = size.cross 169 | } 170 | 171 | return { 172 | transform: `translate(${xTranslate}px, ${yTranslate}px)`, 173 | width: width ? `${width}px` : '', 174 | height: height ? `${height}px` : '', 175 | } 176 | } 177 | 178 | const crossAxisPositions = createMemo(() => 179 | createArray(0, state.crossAxis.totalItemCount), 180 | ) 181 | 182 | // When items change, old positions haven't yet changed, 183 | // so if there are more positions than items things will break. 184 | // This memo delays resolving items until new positions are calculated. 185 | const items = createMemo(() => props.items || []) 186 | 187 | const calculatePosition = (m: number, c: number) => 188 | m * state.crossAxis.totalItemCount + c 189 | 190 | const MainAxisItems: Component<{ crossPos?: number }> = (itemProps) => ( 191 | 192 | {(mainPos) => { 193 | const index = createMemo(() => { 194 | const mPos = mainPos() 195 | const cPos = itemProps.crossPos 196 | if (cPos === undefined) { 197 | return mPos 198 | } 199 | 200 | return calculatePosition(mPos, cPos) 201 | }) 202 | 203 | return ( 204 | 205 | 213 | 214 | ) 215 | }} 216 | 217 | ) 218 | 219 | const virtualElements = children(() => ( 220 | // If there less than 2 cross axis columns 221 | // use fast path with only one loop, instead of 2. 222 | 1} 224 | fallback={} 225 | > 226 | 227 | {(crossPos) => } 228 | 229 | 230 | )) as () => (HTMLElement | undefined)[] 231 | 232 | const findFocusPosition = () => { 233 | const cPositions = crossAxisPositions() 234 | const mPositions = mainAxisPositions() 235 | const elements = virtualElements() 236 | 237 | const focusedElementIndex = elements.findIndex((element) => 238 | // inside grid last few elements can be undefined, 239 | // so safeguard for undefined. 240 | element?.matches(':focus-within, :focus'), 241 | ) 242 | 243 | if (focusedElementIndex === -1) { 244 | return -1 245 | } 246 | 247 | if (state.crossAxis.totalItemCount > 1) { 248 | const cIndex = Math.floor(focusedElementIndex / mPositions.length) 249 | const mIndex = focusedElementIndex % mPositions.length 250 | 251 | const cPos = cPositions[cIndex] 252 | const mPos = mPositions[mIndex] 253 | 254 | const focusPosition = calculatePosition(mPos, cPos) 255 | 256 | return focusPosition 257 | } 258 | 259 | // If grid is one dimenisonal (i.e. just list) index 260 | // maps directly to position. 261 | return mPositions[focusedElementIndex] 262 | } 263 | 264 | const moveFocusHandle = (increment: number, isMainDirection: boolean) => { 265 | const fPosition = state.focusPosition 266 | 267 | let cPos = fPosition % state.crossAxis.totalItemCount 268 | let mPos = Math.floor(fPosition / state.crossAxis.totalItemCount) 269 | 270 | if (isMainDirection) { 271 | mPos += increment 272 | } else { 273 | cPos += increment 274 | } 275 | 276 | const newFocusPos = calculatePosition(mPos, cPos) 277 | 278 | // Prevent focus position from going out of list bounds. 279 | if (newFocusPos < 0 || newFocusPos >= itemsCount()) { 280 | return 281 | } 282 | 283 | const cIndex = crossAxisPositions().indexOf(cPos) 284 | 285 | if (cIndex === -1) { 286 | return 287 | } 288 | 289 | setState('focusPosition', newFocusPos) 290 | 291 | // After focusPosition is set elements and positions might have changed. 292 | const elements = virtualElements() 293 | 294 | const mPositions = mainAxisPositions() 295 | const mIndex = mPositions.indexOf(mPos) 296 | 297 | if (mIndex === -1) { 298 | return 299 | } 300 | 301 | const newIndex = cIndex * mPositions.length + mIndex 302 | const foundEl = elements[newIndex] 303 | 304 | if (!foundEl) { 305 | return 306 | } 307 | 308 | queueMicrotask(() => { 309 | foundEl.focus() 310 | foundEl.scrollIntoView({ block: 'nearest' }) 311 | }) 312 | } 313 | 314 | const onKeydownHandle = (e: KeyboardEvent) => { 315 | const { code } = e 316 | 317 | const isArrowUp = code === 'ArrowUp' 318 | const isArrowDown = code === 'ArrowDown' 319 | const isArrowLeft = code === 'ArrowLeft' 320 | const isArrowRight = code === 'ArrowRight' 321 | 322 | const isArrowUpOrDown = isArrowUp || isArrowDown 323 | const isArrowLeftOrRight = isArrowLeft || isArrowRight 324 | 325 | if (isArrowUpOrDown || isArrowLeftOrRight) { 326 | const isArrowDownOrRight = isArrowDown || isArrowRight 327 | 328 | moveFocusHandle( 329 | isArrowDownOrRight ? 1 : -1, 330 | isDirectionHorizontal() ? isArrowLeftOrRight : isArrowUpOrDown, 331 | ) 332 | } else if (code === 'Enter') { 333 | if (!clickFocusedElement(containerEl())) { 334 | return 335 | } 336 | } else { 337 | return 338 | } 339 | 340 | e.preventDefault() 341 | } 342 | 343 | const onFocusInHandle = () => { 344 | // Restore previous focus position. For example user switching tab 345 | // back and forth. 346 | const newFocusPosition = findFocusPosition() 347 | setState('focusPosition', newFocusPosition === -1 ? 0 : newFocusPosition) 348 | } 349 | 350 | const onFocusOutHandle = async () => { 351 | queueMicrotask(() => { 352 | if (!doesElementContainFocus(containerEl())) { 353 | setState('focusPosition', 0) 354 | } 355 | }) 356 | } 357 | 358 | return ( 359 |
368 | {virtualElements()} 369 |
370 | ) 371 | } 372 | --------------------------------------------------------------------------------