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.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
>