Skip to content

Commit

Permalink
feat: horizontal list
Browse files Browse the repository at this point in the history
  • Loading branch information
petyosi committed Jul 31, 2024
1 parent 370023b commit b7f3527
Show file tree
Hide file tree
Showing 7 changed files with 143 additions and 28 deletions.
16 changes: 16 additions & 0 deletions examples/horizontal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as React from 'react'
import { Virtuoso } from '../src'

export function Example() {
return (
<div style={{ width: 500, height: 100, resize: 'both', overflow: 'hidden' }}>
<Virtuoso
computeItemKey={(key: number) => `item-${key.toString()}`}
totalCount={100}
itemContent={(index) => <div style={{ height: '100%', aspectRatio: '1 / 1', background: '#ccc' }}>Item {index}</div>}
style={{ height: '100%' }}
horizontalDirection
/>
</div>
)
}
47 changes: 39 additions & 8 deletions src/Virtuoso.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const DefaultScrollSeekPlaceholder = ({ height }: { height: number }) => <div st

const GROUP_STYLE = { position: positionStickyCssValue(), zIndex: 1, overflowAnchor: 'none' } as const
const ITEM_STYLE = { overflowAnchor: 'none' } as const
const HORIZONTAL_ITEM_STYLE = { ...ITEM_STYLE, display: 'inline-block', height: '100%' } as const

const Items = /*#__PURE__*/ React.memo(function VirtuosoItems({ showTopList = false }: { showTopList?: boolean }) {
const listState = useEmitterValue('listState')
Expand All @@ -84,6 +85,7 @@ const Items = /*#__PURE__*/ React.memo(function VirtuosoItems({ showTopList = fa
const itemSize = useEmitterValue('itemSize')
const log = useEmitterValue('log')
const listGap = usePublisher('gap')
const horizontalDirection = useEmitterValue('horizontalDirection')

const { callbackRef } = useChangedListContentsSizes(
sizeRanges,
Expand All @@ -92,7 +94,8 @@ const Items = /*#__PURE__*/ React.memo(function VirtuosoItems({ showTopList = fa
showTopList ? u.noop : scrollContainerStateCallback,
log,
listGap,
customScrollParent
customScrollParent,
horizontalDirection
)

const [deviation, setDeviation] = React.useState(0)
Expand All @@ -118,9 +121,20 @@ const Items = /*#__PURE__*/ React.memo(function VirtuosoItems({ showTopList = fa
? {}
: {
boxSizing: 'border-box',
paddingTop: listState.offsetTop,
paddingBottom: listState.offsetBottom,
marginTop: deviation !== 0 ? deviation : alignToBottom ? 'auto' : 0,
...(horizontalDirection
? {
whiteSpace: 'nowrap',
display: 'inline-block',
height: '100%',
paddingLeft: listState.offsetTop,
paddingRight: listState.offsetBottom,
marginLeft: deviation !== 0 ? deviation : alignToBottom ? 'auto' : 0,
}
: {
marginTop: deviation !== 0 ? deviation : alignToBottom ? 'auto' : 0,
paddingTop: listState.offsetTop,
paddingBottom: listState.offsetBottom,
}),
...(initialItemFinalLocationReached ? {} : { visibility: 'hidden' }),
}

Expand Down Expand Up @@ -175,7 +189,7 @@ const Items = /*#__PURE__*/ React.memo(function VirtuosoItems({ showTopList = fa
'data-known-size': item.size,
'data-item-index': item.index,
'data-item-group-index': item.groupIndex,
style: ITEM_STYLE,
style: horizontalDirection ? HORIZONTAL_ITEM_STYLE : ITEM_STYLE,
},
hasGroups
? (itemContent as GroupItemContent<any, any>)(item.index, item.groupIndex!, item.data, context)
Expand All @@ -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%',
Expand Down Expand Up @@ -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<HTMLDivElement | null>,
style: { ...scrollerStyle, ...style },
style: { ...defaultStyle, ...style },
'data-testid': 'virtuoso-scroller',
'data-virtuoso-scroller': true,
tabIndex: 0,
Expand Down Expand Up @@ -327,7 +351,13 @@ const Viewport: React.FC<React.PropsWithChildren<unknown>> = ({ 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) {
Expand Down Expand Up @@ -434,6 +464,7 @@ export const {
customScrollParent: 'customScrollParent',
scrollerRef: 'scrollerRef',
logLevel: 'logLevel',
horizontalDirection: 'horizontalDirection',
},
methods: {
scrollToIndex: 'scrollToIndex',
Expand Down
5 changes: 5 additions & 0 deletions src/component-interfaces/Virtuoso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@ export interface VirtuosoProps<D, C> 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<D, C> extends Omit<VirtuosoProps<D, C>, 'totalCount' | 'itemContent'> {
Expand Down
2 changes: 2 additions & 0 deletions src/domIOSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const domIOSystem = u.system(
const scrollTo = u.stream<ScrollToOptions>()
const scrollBy = u.stream<ScrollToOptions>()
const scrollingInProgress = u.statefulStream(false)
const horizontalDirection = u.statefulStream(false)

u.connect(
u.pipe(
Expand Down Expand Up @@ -47,6 +48,7 @@ export const domIOSystem = u.system(
footerHeight,
scrollHeight,
smoothScrollTargetReached,
horizontalDirection,

// signals
scrollTo,
Expand Down
2 changes: 2 additions & 0 deletions src/gridSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export const gridSystem = /*#__PURE__*/ u.system(
const initialTopMostItemIndex = u.statefulStream<GridIndexLocation>(0)
const scrolledToInitialItem = u.statefulStream(true)
const scrollScheduled = u.statefulStream(false)
const horizontalDirection = u.statefulStream(false)

u.subscribe(
u.pipe(
Expand Down Expand Up @@ -429,6 +430,7 @@ export const gridSystem = /*#__PURE__*/ u.system(
restoreStateFrom,
...scrollSeek,
initialTopMostItemIndex,
horizontalDirection,

// output
gridState,
Expand Down
41 changes: 32 additions & 9 deletions src/hooks/useChangedChildSizes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']) {
Expand All @@ -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({
Expand All @@ -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)
Expand Down
58 changes: 47 additions & 11 deletions src/hooks/useScrollTop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement | null | Window>(null)
const scrollTopTarget = React.useRef<any>(null)
Expand All @@ -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({
Expand Down Expand Up @@ -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
}

Expand All @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down

0 comments on commit b7f3527

Please sign in to comment.