@minht11/solid-virtual-container
Version:
Virtual list/grid for Solid-js.
240 lines (239 loc) • 9.97 kB
JSX
import { createMemo, Index, Show, createComputed, children, } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Dynamic } from 'solid-js/web';
import { clickFocusedElement, createArray, doesElementContainFocus, getFiniteNumberOrZero, } from './helpers/utils';
import { createMeasurementsObserver } from './helpers/create-measurements-observer';
import { createMainAxisPositions } from './helpers/create-main-axis-positions';
const uniqueHash = Math.random().toString(36).slice(2, Infinity);
// Avoid conflicting class names.
const CONTAINER_CLASSNAME = `virtual-container-${uniqueHash}`;
let globalContainerStylesheet;
// Dom bindings are expensive. Even setting the same style values
// causes performance issues, so instead apply static styles
// ahead of time using global style tag.
const insertGlobalStylesheet = () => {
if (!globalContainerStylesheet) {
globalContainerStylesheet = document.createElement('style');
globalContainerStylesheet.type = 'text/css';
globalContainerStylesheet.textContent = `
.${CONTAINER_CLASSNAME} {
position: relative !important;
flex-shrink: 0 !important;
}
.${CONTAINER_CLASSNAME} > * {
will-change: transform !important;
box-sizing: border-box !important;
contain: strict !important;
position: absolute !important;
top: 0 !important;
left: 0 !important;
}
`;
document.head.appendChild(globalContainerStylesheet);
}
};
export function VirtualContainer(props) {
insertGlobalStylesheet();
const [state, setState] = createStore({
focusPosition: 0,
mainAxis: {
totalItemCount: 0,
focusPosition: 0,
scrollValue: 0,
},
crossAxis: {
totalItemCount: 0,
},
});
const { containerEl, setContainerRefEl, isDirectionHorizontal, measurements, } = createMeasurementsObserver(props);
// This selector is used for position calculations,
// so value must always be current.
// itemsMemo below is used only for rendering items.
const itemsCount = () => (props.items && props.items.length) || 0;
createComputed(() => {
if (!measurements.isMeasured) {
return;
}
const cTotal = getFiniteNumberOrZero(props.crossAxisCount?.(measurements, itemsCount()) || 0);
// There are always must be at least one column.
setState('crossAxis', {
totalItemCount: Math.max(1, cTotal),
});
});
createComputed(() => {
if (!measurements.isMeasured) {
return;
}
const iCount = itemsCount();
const cTotal = state.crossAxis.totalItemCount;
const mTotal = Math.ceil(iCount / cTotal);
setState('mainAxis', {
totalItemCount: getFiniteNumberOrZero(mTotal),
});
setState('crossAxis', {
totalItemCount: cTotal,
positions: createArray(0, state.crossAxis.totalItemCount),
});
});
createComputed(() => {
const mFocusPos = Math.floor(state.focusPosition / state.crossAxis.totalItemCount);
setState('mainAxis', 'focusPosition', getFiniteNumberOrZero(mFocusPos));
});
const mainAxisPositions = createMainAxisPositions(measurements, state.mainAxis, () => props.overscan);
const containerStyleProps = () => {
const containerSize = state.mainAxis.totalItemCount * measurements.itemSize.main;
const property = isDirectionHorizontal() ? 'width' : 'height';
const property2 = isDirectionHorizontal() ? 'height' : 'width';
return {
[property]: `${containerSize}px`,
[property2]: '100%',
};
};
const getItemStyle = (mainPos, crossPos = 0) => {
const size = measurements.itemSize;
const mainSize = size.main * mainPos;
const crossSize = size.cross * crossPos;
let xTranslate = crossSize;
let yTranslate = mainSize;
let width = size.cross;
let height = size.main;
if (isDirectionHorizontal()) {
xTranslate = mainSize;
yTranslate = crossSize;
width = size.main;
height = size.cross;
}
return {
transform: `translate(${xTranslate}px, ${yTranslate}px)`,
width: width ? `${width}px` : '',
height: height ? `${height}px` : '',
};
};
const crossAxisPositions = createMemo(() => createArray(0, state.crossAxis.totalItemCount));
// When items change, old positions haven't yet changed,
// so if there are more positions than items things will break.
// This memo delays resolving items until new positions are calculated.
const items = createMemo(() => props.items || []);
const calculatePosition = (m, c) => m * state.crossAxis.totalItemCount + c;
const MainAxisItems = (itemProps) => (<Index each={mainAxisPositions()}>
{(mainPos) => {
const index = createMemo(() => {
const mPos = mainPos();
const cPos = itemProps.crossPos;
if (cPos === undefined) {
return mPos;
}
return calculatePosition(mPos, cPos);
});
return (<Show when={index() < items().length}>
<Dynamic component={props.children} items={items()} item={items()[index()]} index={index()} tabIndex={index() === state.focusPosition ? 0 : -1} style={getItemStyle(mainPos(), itemProps.crossPos)}/>
</Show>);
}}
</Index>);
const virtualElements = children(() => (
// If there less than 2 cross axis columns
// use fast path with only one loop, instead of 2.
<Show when={state.crossAxis.totalItemCount > 1} fallback={<MainAxisItems />}>
<Index each={crossAxisPositions()}>
{(crossPos) => <MainAxisItems crossPos={crossPos()}/>}
</Index>
</Show>));
const findFocusPosition = () => {
const cPositions = crossAxisPositions();
const mPositions = mainAxisPositions();
const elements = virtualElements();
const focusedElementIndex = elements.findIndex((element) =>
// inside grid last few elements can be undefined,
// so safeguard for undefined.
element?.matches(':focus-within, :focus'));
if (focusedElementIndex === -1) {
return -1;
}
if (state.crossAxis.totalItemCount > 1) {
const cIndex = Math.floor(focusedElementIndex / mPositions.length);
const mIndex = focusedElementIndex % mPositions.length;
const cPos = cPositions[cIndex];
const mPos = mPositions[mIndex];
const focusPosition = calculatePosition(mPos, cPos);
return focusPosition;
}
// If grid is one dimenisonal (i.e. just list) index
// maps directly to position.
return mPositions[focusedElementIndex];
};
const moveFocusHandle = (increment, isMainDirection) => {
const fPosition = state.focusPosition;
let cPos = fPosition % state.crossAxis.totalItemCount;
let mPos = Math.floor(fPosition / state.crossAxis.totalItemCount);
if (isMainDirection) {
mPos += increment;
}
else {
cPos += increment;
}
const newFocusPos = calculatePosition(mPos, cPos);
// Prevent focus position from going out of list bounds.
if (newFocusPos < 0 || newFocusPos >= itemsCount()) {
return;
}
const cIndex = crossAxisPositions().indexOf(cPos);
if (cIndex === -1) {
return;
}
setState('focusPosition', newFocusPos);
// After focusPosition is set elements and positions might have changed.
const elements = virtualElements();
const mPositions = mainAxisPositions();
const mIndex = mPositions.indexOf(mPos);
if (mIndex === -1) {
return;
}
const newIndex = cIndex * mPositions.length + mIndex;
const foundEl = elements[newIndex];
if (!foundEl) {
return;
}
queueMicrotask(() => {
foundEl.focus();
foundEl.scrollIntoView({ block: 'nearest' });
});
};
const onKeydownHandle = (e) => {
const { code } = e;
const isArrowUp = code === 'ArrowUp';
const isArrowDown = code === 'ArrowDown';
const isArrowLeft = code === 'ArrowLeft';
const isArrowRight = code === 'ArrowRight';
const isArrowUpOrDown = isArrowUp || isArrowDown;
const isArrowLeftOrRight = isArrowLeft || isArrowRight;
if (isArrowUpOrDown || isArrowLeftOrRight) {
const isArrowDownOrRight = isArrowDown || isArrowRight;
moveFocusHandle(isArrowDownOrRight ? 1 : -1, isDirectionHorizontal() ? isArrowLeftOrRight : isArrowUpOrDown);
}
else if (code === 'Enter') {
if (!clickFocusedElement(containerEl())) {
return;
}
}
else {
return;
}
e.preventDefault();
};
const onFocusInHandle = () => {
// Restore previous focus position. For example user switching tab
// back and forth.
const newFocusPosition = findFocusPosition();
setState('focusPosition', newFocusPosition === -1 ? 0 : newFocusPosition);
};
const onFocusOutHandle = async () => {
queueMicrotask(() => {
if (!doesElementContainFocus(containerEl())) {
setState('focusPosition', 0);
}
});
};
return (<div ref={setContainerRefEl} className={`${CONTAINER_CLASSNAME} ${props.className || ''}`} style={containerStyleProps()} onKeyDown={onKeydownHandle} onFocusIn={onFocusInHandle} onFocusOut={onFocusOutHandle} role={props.role || 'list'}>
{virtualElements()}
</div>);
}