diff --git a/examples/horizontal.tsx b/examples/horizontal.tsx new file mode 100644 index 000000000..f1e72be30 --- /dev/null +++ b/examples/horizontal.tsx @@ -0,0 +1,16 @@ +import * as React from 'react' +import { Virtuoso } from '../src' + +export function Example() { + return ( +
+ `item-${key.toString()}`} + totalCount={100} + itemContent={(index) =>
Item {index}
} + style={{ height: '100%' }} + horizontalDirection + /> +
+ ) +} diff --git a/src/Virtuoso.tsx b/src/Virtuoso.tsx index 3a74bac4f..ebc9776ea 100644 --- a/src/Virtuoso.tsx +++ b/src/Virtuoso.tsx @@ -66,6 +66,7 @@ const DefaultScrollSeekPlaceholder = ({ height }: { height: number }) =>
)(item.index, item.groupIndex!, item.data, context) @@ -194,6 +208,12 @@ export const scrollerStyle: React.CSSProperties = { WebkitOverflowScrolling: 'touch', } +const horizontalScrollerStyle: React.CSSProperties = { + outline: 'none', + overflowX: 'auto', + position: 'relative', +} + export const viewportStyle: (alignToBottom: boolean) => React.CSSProperties = (alignToBottom) => ({ width: '100%', height: '100%', @@ -255,21 +275,25 @@ export function buildScroller({ usePublisher, useEmitter, useEmitterValue }: Hoo const smoothScrollTargetReached = usePublisher('smoothScrollTargetReached') const scrollerRefCallback = useEmitterValue('scrollerRef') const context = useEmitterValue('context') + const horizontalDirection = useEmitterValue('horizontalDirection') || false const { scrollerRef, scrollByCallback, scrollToCallback } = useScrollTop( scrollContainerStateCallback, smoothScrollTargetReached, ScrollerComponent, - scrollerRefCallback + scrollerRefCallback, + undefined, + horizontalDirection ) useEmitter('scrollTo', scrollToCallback) useEmitter('scrollBy', scrollByCallback) + const defaultStyle = horizontalDirection ? horizontalScrollerStyle : scrollerStyle return React.createElement( ScrollerComponent, { ref: scrollerRef as React.MutableRefObject, - style: { ...scrollerStyle, ...style }, + style: { ...defaultStyle, ...style }, 'data-testid': 'virtuoso-scroller', 'data-virtuoso-scroller': true, tabIndex: 0, @@ -327,7 +351,13 @@ const Viewport: React.FC> = ({ children }) => { const viewportHeight = usePublisher('viewportHeight') const fixedItemHeight = usePublisher('fixedItemHeight') const alignToBottom = useEmitterValue('alignToBottom') - const viewportRef = useSize(React.useMemo(() => u.compose(viewportHeight, (el) => correctItemSize(el, 'height')), [viewportHeight])) + + const horizontalDirection = useEmitterValue('horizontalDirection') + const viewportSizeCallbackMemo = React.useMemo( + () => u.compose(viewportHeight, (el: HTMLElement) => correctItemSize(el, horizontalDirection ? 'width' : 'height')), + [viewportHeight, horizontalDirection] + ) + const viewportRef = useSize(viewportSizeCallbackMemo) React.useEffect(() => { if (ctx) { @@ -434,6 +464,7 @@ export const { customScrollParent: 'customScrollParent', scrollerRef: 'scrollerRef', logLevel: 'logLevel', + horizontalDirection: 'horizontalDirection', }, methods: { scrollToIndex: 'scrollToIndex', diff --git a/src/component-interfaces/Virtuoso.ts b/src/component-interfaces/Virtuoso.ts index 9ca2dddf8..54a02c12f 100644 --- a/src/component-interfaces/Virtuoso.ts +++ b/src/component-interfaces/Virtuoso.ts @@ -252,6 +252,11 @@ export interface VirtuosoProps extends ListRootProps { * This is useful when you want to keep the list state when the component is unmounted and remounted, for example when navigating to a different page. */ restoreStateFrom?: StateSnapshot + + /** + * When set, turns the scroller into a horizontal list. The items are positioned with `inline-block`. + */ + horizontalDirection?: boolean } export interface GroupedVirtuosoProps extends Omit, 'totalCount' | 'itemContent'> { diff --git a/src/domIOSystem.ts b/src/domIOSystem.ts index 5b8455d1f..dd5f49d7d 100644 --- a/src/domIOSystem.ts +++ b/src/domIOSystem.ts @@ -17,6 +17,7 @@ export const domIOSystem = u.system( const scrollTo = u.stream() const scrollBy = u.stream() const scrollingInProgress = u.statefulStream(false) + const horizontalDirection = u.statefulStream(false) u.connect( u.pipe( @@ -47,6 +48,7 @@ export const domIOSystem = u.system( footerHeight, scrollHeight, smoothScrollTargetReached, + horizontalDirection, // signals scrollTo, diff --git a/src/gridSystem.ts b/src/gridSystem.ts index c0171a9fe..b74731d1a 100644 --- a/src/gridSystem.ts +++ b/src/gridSystem.ts @@ -97,6 +97,7 @@ export const gridSystem = /*#__PURE__*/ u.system( const initialTopMostItemIndex = u.statefulStream(0) const scrolledToInitialItem = u.statefulStream(true) const scrollScheduled = u.statefulStream(false) + const horizontalDirection = u.statefulStream(false) u.subscribe( u.pipe( @@ -429,6 +430,7 @@ export const gridSystem = /*#__PURE__*/ u.system( restoreStateFrom, ...scrollSeek, initialTopMostItemIndex, + horizontalDirection, // output gridState, diff --git a/src/hooks/useChangedChildSizes.ts b/src/hooks/useChangedChildSizes.ts index 98405338a..c932f165e 100644 --- a/src/hooks/useChangedChildSizes.ts +++ b/src/hooks/useChangedChildSizes.ts @@ -9,11 +9,12 @@ export default function useChangedListContentsSizes( scrollContainerStateCallback: (state: ScrollContainerState) => void, log: Log, gap?: (gap: number) => void, - customScrollParent?: HTMLElement + customScrollParent?: HTMLElement, + horizontalDirection?: boolean ) { const memoedCallback = React.useCallback( (el: HTMLElement) => { - const ranges = getChangedChildSizes(el.children, itemSize, 'offsetHeight', log) + const ranges = getChangedChildSizes(el.children, itemSize, horizontalDirection ? 'offsetWidth' : 'offsetHeight', log) let scrollableElement = el.parentElement! while (!scrollableElement.dataset['virtuosoScroller']) { @@ -24,21 +25,39 @@ export default function useChangedListContentsSizes( const windowScrolling = (scrollableElement.lastElementChild! as HTMLDivElement).dataset['viewportType']! === 'window' const scrollTop = customScrollParent - ? customScrollParent.scrollTop + ? horizontalDirection + ? customScrollParent.scrollLeft + : customScrollParent.scrollTop : windowScrolling - ? window.pageYOffset || document.documentElement.scrollTop + ? horizontalDirection + ? window.pageXOffset || document.documentElement.scrollLeft + : window.pageYOffset || document.documentElement.scrollTop + : horizontalDirection + ? scrollableElement.scrollLeft : scrollableElement.scrollTop const scrollHeight = customScrollParent - ? customScrollParent.scrollHeight + ? horizontalDirection + ? customScrollParent.scrollWidth + : customScrollParent.scrollHeight : windowScrolling - ? document.documentElement.scrollHeight + ? horizontalDirection + ? document.documentElement.scrollWidth + : document.documentElement.scrollHeight + : horizontalDirection + ? scrollableElement.scrollWidth : scrollableElement.scrollHeight const viewportHeight = customScrollParent - ? customScrollParent.offsetHeight + ? horizontalDirection + ? customScrollParent.offsetWidth + : customScrollParent.offsetHeight : windowScrolling - ? window.innerHeight + ? horizontalDirection + ? window.innerWidth + : window.innerHeight + : horizontalDirection + ? scrollableElement.offsetWidth : scrollableElement.offsetHeight scrollContainerStateCallback({ @@ -47,7 +66,11 @@ export default function useChangedListContentsSizes( viewportHeight, }) - gap?.(resolveGapValue('row-gap', getComputedStyle(el).rowGap, log)) + gap?.( + horizontalDirection + ? resolveGapValue('column-gap', getComputedStyle(el).columnGap, log) + : resolveGapValue('row-gap', getComputedStyle(el).rowGap, log) + ) if (ranges !== null) { callback(ranges) diff --git a/src/hooks/useScrollTop.ts b/src/hooks/useScrollTop.ts index a415cfce7..0bee1c34e 100644 --- a/src/hooks/useScrollTop.ts +++ b/src/hooks/useScrollTop.ts @@ -12,7 +12,8 @@ export default function useScrollTop( smoothScrollTargetReached: (yes: true) => void, scrollerElement: any, scrollerRefCallback: (ref: ScrollerRef) => void = u.noop, - customScrollParent?: HTMLElement + customScrollParent?: HTMLElement, + horizontalDirection?: boolean ) { const scrollerRef = React.useRef(null) const scrollTopTarget = React.useRef(null) @@ -22,9 +23,29 @@ export default function useScrollTop( (ev: Event) => { const el = ev.target as HTMLElement const windowScroll = (el as any) === window || (el as any) === document - const scrollTop = windowScroll ? window.pageYOffset || document.documentElement.scrollTop : el.scrollTop - const scrollHeight = windowScroll ? document.documentElement.scrollHeight : el.scrollHeight - const viewportHeight = windowScroll ? window.innerHeight : el.offsetHeight + const scrollTop = horizontalDirection + ? windowScroll + ? window.pageXOffset || document.documentElement.scrollLeft + : el.scrollLeft + : windowScroll + ? window.pageYOffset || document.documentElement.scrollTop + : el.scrollTop + + const scrollHeight = horizontalDirection + ? windowScroll + ? document.documentElement.scrollWidth + : el.scrollWidth + : windowScroll + ? document.documentElement.scrollHeight + : el.scrollHeight + + const viewportHeight = horizontalDirection + ? windowScroll + ? window.innerWidth + : el.offsetWidth + : windowScroll + ? window.innerHeight + : el.offsetHeight const call = () => { scrollContainerStateCallback({ @@ -69,7 +90,12 @@ export default function useScrollTop( function scrollToCallback(location: ScrollToOptions) { const scrollerElement = scrollerRef.current - if (!scrollerElement || ('offsetHeight' in scrollerElement && scrollerElement.offsetHeight === 0)) { + if ( + !scrollerElement || + (horizontalDirection + ? 'offsetWidth' in scrollerElement && scrollerElement.offsetWidth === 0 + : 'offsetHeight' in scrollerElement && scrollerElement.offsetHeight === 0) + ) { return } @@ -81,13 +107,16 @@ export default function useScrollTop( if (scrollerElement === window) { // this is not a mistake - scrollHeight = Math.max(correctItemSize(document.documentElement, 'height'), document.documentElement.scrollHeight) - offsetHeight = window.innerHeight - scrollTop = document.documentElement.scrollTop + scrollHeight = Math.max( + correctItemSize(document.documentElement, horizontalDirection ? 'width' : 'height'), + horizontalDirection ? document.documentElement.scrollWidth : document.documentElement.scrollHeight + ) + offsetHeight = horizontalDirection ? window.innerWidth : window.innerHeight + scrollTop = horizontalDirection ? document.documentElement.scrollLeft : document.documentElement.scrollTop } else { - scrollHeight = (scrollerElement as HTMLElement).scrollHeight - offsetHeight = correctItemSize(scrollerElement as HTMLElement, 'height') - scrollTop = (scrollerElement as HTMLElement).scrollTop + scrollHeight = (scrollerElement as HTMLElement)[horizontalDirection ? 'scrollWidth' : 'scrollHeight'] + offsetHeight = correctItemSize(scrollerElement as HTMLElement, horizontalDirection ? 'width' : 'height') + scrollTop = (scrollerElement as HTMLElement)[horizontalDirection ? 'scrollLeft' : 'scrollTop'] } const maxScrollTop = scrollHeight - offsetHeight @@ -119,10 +148,17 @@ export default function useScrollTop( scrollTopTarget.current = null } + if (horizontalDirection) { + location = { left: location.top, behavior: location.behavior } + } + scrollerElement.scrollTo(location) } function scrollByCallback(location: ScrollToOptions) { + if (horizontalDirection) { + location = { left: location.top, behavior: location.behavior } + } scrollerRef.current!.scrollBy(location) } diff --git a/test/__snapshots__/VirtuosoMockContext.test.tsx.snap b/test/__snapshots__/VirtuosoMockContext.test.tsx.snap index 5d76388a6..bdbb69bc2 100644 --- a/test/__snapshots__/VirtuosoMockContext.test.tsx.snap +++ b/test/__snapshots__/VirtuosoMockContext.test.tsx.snap @@ -128,7 +128,7 @@ exports[`VirtuosoMockContext > List > correctly renders items 1`] = ` >
List > correctly renders items with useWindowScro >