|
| 1 | +import * as s from 'solid-js'; |
| 2 | +import * as lng from '@lightningtv/solid'; |
| 3 | +import * as lngp from '@lightningtv/solid/primitives'; |
| 4 | +import { List } from '@solid-primitives/list'; |
| 5 | +import * as utils from '../utils.js'; |
| 6 | + |
| 7 | +const columnScroll = lngp.withScrolling(false); |
| 8 | + |
| 9 | +const rowStyles: lng.NodeStyles = { |
| 10 | + display: 'flex', |
| 11 | + flexWrap: 'wrap', |
| 12 | + transition: { |
| 13 | + y: true, |
| 14 | + }, |
| 15 | +}; |
| 16 | + |
| 17 | +function scrollToIndex(this: lng.ElementNode, index: number) { |
| 18 | + this.selected = index; |
| 19 | + columnScroll(index, this); |
| 20 | + this.setFocus(); |
| 21 | +} |
| 22 | + |
| 23 | +export type VirtualGridProps<T> = lng.NewOmit<lngp.RowProps, 'children'> & { |
| 24 | + each: readonly T[] | undefined | null | false; |
| 25 | + columns: number; // items per row |
| 26 | + rows?: number; // number of visible rows (default: 1) |
| 27 | + buffer?: number; |
| 28 | + onEndReached?: () => void; |
| 29 | + children: (item: s.Accessor<T>, index: s.Accessor<number>) => s.JSX.Element; |
| 30 | +}; |
| 31 | + |
| 32 | +export function VirtualGrid<T>(props: VirtualGridProps<T>): s.JSX.Element { |
| 33 | + const bufferSize = () => props.buffer ?? 2; |
| 34 | + const [ cursor, setCursor ] = s.createSignal(props.selected ?? 0); |
| 35 | + const items = s.createMemo(() => props.each || []); |
| 36 | + const itemsPerRow = () => props.columns; |
| 37 | + const numberOfRows = () => props.rows ?? 1; |
| 38 | + const totalVisibleItems = () => itemsPerRow() * numberOfRows(); |
| 39 | + |
| 40 | + const start = s.createMemo(() => { |
| 41 | + const perRow = itemsPerRow(); |
| 42 | + const newRowIndex = Math.floor(cursor() / perRow); |
| 43 | + |
| 44 | + return utils.clamp( |
| 45 | + newRowIndex * perRow - bufferSize() * perRow, |
| 46 | + 0, |
| 47 | + Math.max(0, items().length - totalVisibleItems()) |
| 48 | + ); |
| 49 | + }) |
| 50 | + |
| 51 | + const end = s.createMemo(() => { |
| 52 | + const perRow = itemsPerRow(); |
| 53 | + const newRowIndex = Math.floor(cursor() / perRow); |
| 54 | + |
| 55 | + return Math.min( |
| 56 | + items().length, |
| 57 | + (newRowIndex + bufferSize()) * perRow + totalVisibleItems() |
| 58 | + ); |
| 59 | + }) |
| 60 | + |
| 61 | + const [slice, setSlice] = s.createSignal(items().slice(start(), end())); |
| 62 | + |
| 63 | + let viewRef!: lngp.NavigableElement; |
| 64 | + |
| 65 | + const onLeft = lngp.handleNavigation('left'); |
| 66 | + const onRight = lngp.handleNavigation('right'); |
| 67 | + |
| 68 | + const onUpDown: lngp.KeyHandler = function () { |
| 69 | + const perRow = itemsPerRow(); |
| 70 | + const selected = this.selected || 0; |
| 71 | + if (selected < perRow) return false; |
| 72 | + |
| 73 | + const newIndex = utils.clamp(selected - perRow, 0, items().length - 1); |
| 74 | + const lastIdx = selected; |
| 75 | + this.selected = newIndex; |
| 76 | + const active = this.children[this.selected]; |
| 77 | + if (active instanceof lng.ElementNode) { |
| 78 | + active.setFocus(); |
| 79 | + chainedOnSelectedChanged.call(this as lngp.NavigableElement, this.selected, this as lngp.NavigableElement, active, lastIdx); |
| 80 | + } |
| 81 | + }; |
| 82 | + |
| 83 | + const onSelectedChanged: lngp.OnSelectedChanged = function (_idx, elm, active, _lastIdx,) { |
| 84 | + let idx = _idx; |
| 85 | + let lastIdx = _lastIdx; |
| 86 | + const perRow = itemsPerRow(); |
| 87 | + const newRowIndex = Math.floor(idx / perRow); |
| 88 | + const prevRowIndex = Math.floor((lastIdx || 0) / perRow); |
| 89 | + const prevStart = start(); |
| 90 | + |
| 91 | + if (newRowIndex === prevRowIndex) return; |
| 92 | + |
| 93 | + setCursor(prevStart + idx); |
| 94 | + setSlice(items().slice(start(), end())); |
| 95 | + |
| 96 | + // this.selected is relative to the slice |
| 97 | + // and it doesn't get corrected automatically after children change |
| 98 | + const idxCorrection = prevStart - start(); |
| 99 | + if (lastIdx) lastIdx += idxCorrection; |
| 100 | + idx += idxCorrection; |
| 101 | + this.selected += idxCorrection; |
| 102 | + |
| 103 | + if (cursor() >= items().length - perRow * bufferSize()) { |
| 104 | + props.onEndReached?.(); |
| 105 | + } |
| 106 | + |
| 107 | + queueMicrotask(() => { |
| 108 | + const prevRowY = this.y + active.y; |
| 109 | + this.updateLayout(); |
| 110 | + // if (prevRowY > active.y) { |
| 111 | + // } |
| 112 | + this.lng.y = prevRowY - active.y; |
| 113 | + // this.y = prevRowY - active.y; |
| 114 | + columnScroll(idx, elm, active, lastIdx); |
| 115 | + }); |
| 116 | + }; |
| 117 | + |
| 118 | + const chainedOnSelectedChanged = lngp.chainFunctions(props.onSelectedChanged, onSelectedChanged)!; |
| 119 | + |
| 120 | + s.createEffect( |
| 121 | + s.on([() => props.selected, items], ([selected]) => { |
| 122 | + if (!viewRef || selected == null) return; |
| 123 | + |
| 124 | + const item = items()[selected]; |
| 125 | + let active = viewRef.children.find(x => x.item === item); |
| 126 | + const lastSelected = viewRef.selected; |
| 127 | + |
| 128 | + if (active instanceof lng.ElementNode) { |
| 129 | + viewRef.selected = viewRef.children.indexOf(active); |
| 130 | + chainedOnSelectedChanged.call(viewRef, viewRef.selected, viewRef, active, lastSelected); |
| 131 | + } else { |
| 132 | + setSlice(items().slice(start(), end())); |
| 133 | + |
| 134 | + queueMicrotask(() => { |
| 135 | + viewRef.updateLayout(); |
| 136 | + active = viewRef.children.find(x => x.item === item); |
| 137 | + if (active instanceof lng.ElementNode) { |
| 138 | + viewRef.selected = viewRef.children.indexOf(active); |
| 139 | + chainedOnSelectedChanged.call(viewRef, viewRef.selected, viewRef, active, lastSelected); |
| 140 | + } |
| 141 | + }); |
| 142 | + } |
| 143 | + }) |
| 144 | + ); |
| 145 | + |
| 146 | + s.createEffect( |
| 147 | + s.on(items, () => { |
| 148 | + if (!viewRef) return; |
| 149 | + setSlice(items().slice(start(), end())); |
| 150 | + }, { defer: true }) |
| 151 | + ); |
| 152 | + |
| 153 | + return ( |
| 154 | + <view |
| 155 | + {...props} |
| 156 | + ref={lngp.chainRefs(el => { viewRef = el as lngp.NavigableElement; }, props.ref)} |
| 157 | + selected={props.selected || 0} |
| 158 | + cursor={cursor()} |
| 159 | + onLeft={/* @once */ lngp.chainFunctions(props.onLeft, onLeft)} |
| 160 | + onRight={/* @once */ lngp.chainFunctions(props.onRight, onRight)} |
| 161 | + onUp={/* @once */ lngp.chainFunctions(props.onUp, onUpDown)} |
| 162 | + onDown={/* @once */ lngp.chainFunctions(props.onDown, onUpDown)} |
| 163 | + forwardFocus={/* @once */ lngp.onGridFocus(chainedOnSelectedChanged)} |
| 164 | + onCreate={/* @once */ props.selected ? lngp.chainFunctions(props.onCreate, columnScroll) : props.onCreate} |
| 165 | + scrollToIndex={/* @once */ scrollToIndex} |
| 166 | + onSelectedChanged={/* @once */ chainedOnSelectedChanged} |
| 167 | + style={/* @once */ lng.combineStyles(props.style, rowStyles)} |
| 168 | + > |
| 169 | + <List each={slice()}>{props.children}</List> |
| 170 | + </view> |
| 171 | + ); |
| 172 | +} |
0 commit comments