about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/util/List.tsx1
-rw-r--r--src/view/com/util/List.web.tsx132
-rw-r--r--src/view/screens/Storybook/ListContained.tsx98
-rw-r--r--src/view/screens/Storybook/index.tsx164
4 files changed, 310 insertions, 85 deletions
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index ff60e94cd..61dc5f81d 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -25,6 +25,7 @@ export type ListProps<ItemT> = Omit<
   headerOffset?: number
   refreshing?: boolean
   onRefresh?: () => void
+  containWeb?: boolean
 }
 export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
 
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
index 02564e1e1..e5c427f13 100644
--- a/src/view/com/util/List.web.tsx
+++ b/src/view/com/util/List.web.tsx
@@ -1,5 +1,6 @@
 import React, {isValidElement, memo, startTransition, useRef} from 'react'
 import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native'
+import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
 
 import {batchedUpdates} from '#/lib/batchedUpdates'
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
@@ -20,6 +21,7 @@ export type ListProps<ItemT> = Omit<
   refreshing?: boolean
   onRefresh?: () => void
   desktopFixedHeight: any // TODO: Better types.
+  containWeb?: boolean
 }
 export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
 
@@ -27,6 +29,7 @@ function ListImpl<ItemT>(
   {
     ListHeaderComponent,
     ListFooterComponent,
+    containWeb,
     contentContainerStyle,
     data,
     desktopFixedHeight,
@@ -83,13 +86,62 @@ function ListImpl<ItemT>(
     })
   }
 
+  const getScrollableNode = React.useCallback(() => {
+    if (containWeb) {
+      const element = nativeRef.current as HTMLDivElement | null
+      if (!element) return
+
+      return {
+        scrollWidth: element.scrollWidth,
+        scrollHeight: element.scrollHeight,
+        clientWidth: element.clientWidth,
+        clientHeight: element.clientHeight,
+        scrollY: element.scrollTop,
+        scrollX: element.scrollLeft,
+        scrollTo(options?: ScrollToOptions) {
+          element.scrollTo(options)
+        },
+        scrollBy(options: ScrollToOptions) {
+          element.scrollBy(options)
+        },
+        addEventListener(event: string, handler: any) {
+          element.addEventListener(event, handler)
+        },
+        removeEventListener(event: string, handler: any) {
+          element.removeEventListener(event, handler)
+        },
+      }
+    } else {
+      return {
+        scrollWidth: document.documentElement.scrollWidth,
+        scrollHeight: document.documentElement.scrollHeight,
+        clientWidth: window.innerWidth,
+        clientHeight: window.innerHeight,
+        scrollY: window.scrollY,
+        scrollX: window.scrollX,
+        scrollTo(options: ScrollToOptions) {
+          window.scrollTo(options)
+        },
+        scrollBy(options: ScrollToOptions) {
+          window.scrollBy(options)
+        },
+        addEventListener(event: string, handler: any) {
+          window.addEventListener(event, handler)
+        },
+        removeEventListener(event: string, handler: any) {
+          window.removeEventListener(event, handler)
+        },
+      }
+    }
+  }, [containWeb])
+
   const nativeRef = React.useRef(null)
   React.useImperativeHandle(
     ref,
     () =>
       ({
         scrollToTop() {
-          window.scrollTo({top: 0})
+          getScrollableNode()?.scrollTo({top: 0})
         },
         scrollToOffset({
           animated,
@@ -98,46 +150,74 @@ function ListImpl<ItemT>(
           animated: boolean
           offset: number
         }) {
-          window.scrollTo({
+          getScrollableNode()?.scrollTo({
             left: 0,
             top: offset,
             behavior: animated ? 'smooth' : 'instant',
           })
         },
+        scrollToEnd({animated = true}: {animated?: boolean}) {
+          const element = getScrollableNode()
+          element?.scrollTo({
+            left: 0,
+            top: element.scrollHeight,
+            behavior: animated ? 'smooth' : 'instant',
+          })
+        },
       } as any), // TODO: Better types.
-    [],
+    [getScrollableNode],
   )
 
-  // --- onContentSizeChange ---
+  // --- onContentSizeChange, maintainVisibleContentPosition ---
   const containerRef = useRef(null)
   useResizeObserver(containerRef, onContentSizeChange)
 
   // --- onScroll ---
   const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false)
-  const handleWindowScroll = useNonReactiveCallback(() => {
-    if (isInsideVisibleTree) {
-      contextScrollHandlers.onScroll?.(
-        {
-          contentOffset: {
-            x: Math.max(0, window.scrollX),
-            y: Math.max(0, window.scrollY),
-          },
-        } as any, // TODO: Better types.
-        null as any,
-      )
-    }
+  const handleScroll = useNonReactiveCallback(() => {
+    if (!isInsideVisibleTree) return
+
+    const element = getScrollableNode()
+    contextScrollHandlers.onScroll?.(
+      {
+        contentOffset: {
+          x: Math.max(0, element?.scrollX ?? 0),
+          y: Math.max(0, element?.scrollY ?? 0),
+        },
+        layoutMeasurement: {
+          width: element?.clientWidth,
+          height: element?.clientHeight,
+        },
+        contentSize: {
+          width: element?.scrollWidth,
+          height: element?.scrollHeight,
+        },
+      } as Exclude<
+        ReanimatedScrollEvent,
+        | 'velocity'
+        | 'eventName'
+        | 'zoomScale'
+        | 'targetContentOffset'
+        | 'contentInset'
+      >,
+      null as any,
+    )
   })
+
   React.useEffect(() => {
     if (!isInsideVisibleTree) {
       // Prevents hidden tabs from firing scroll events.
       // Only one list is expected to be firing these at a time.
       return
     }
-    window.addEventListener('scroll', handleWindowScroll)
+
+    const element = getScrollableNode()
+
+    element?.addEventListener('scroll', handleScroll)
     return () => {
-      window.removeEventListener('scroll', handleWindowScroll)
+      element?.removeEventListener('scroll', handleScroll)
     }
-  }, [isInsideVisibleTree, handleWindowScroll])
+  }, [isInsideVisibleTree, handleScroll, containWeb, getScrollableNode])
 
   // --- onScrolledDownChange ---
   const isScrolledDown = useRef(false)
@@ -174,7 +254,11 @@ function ListImpl<ItemT>(
   )
 
   return (
-    <View {...props} style={style} ref={nativeRef}>
+    <View
+      {...props}
+      // @ts-ignore web only
+      style={[style, containWeb && {flex: 1, 'overflow-y': 'scroll'}]}
+      ref={nativeRef}>
       <Visibility
         onVisibleChange={setIsInsideVisibleTree}
         style={
@@ -192,11 +276,13 @@ function ListImpl<ItemT>(
           pal.border,
         ]}>
         <Visibility
+          root={containWeb ? nativeRef.current : null}
           onVisibleChange={handleAboveTheFoldVisibleChange}
           style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
         />
         {onStartReached && (
           <Visibility
+            root={containWeb ? nativeRef.current : null}
             onVisibleChange={onHeadVisibilityChange}
             topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
           />
@@ -213,6 +299,7 @@ function ListImpl<ItemT>(
         ))}
         {onEndReached && (
           <Visibility
+            root={containWeb ? nativeRef.current : null}
             onVisibleChange={onTailVisibilityChange}
             bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
           />
@@ -275,11 +362,13 @@ let Row = function RowImpl<ItemT>({
 Row = React.memo(Row)
 
 let Visibility = ({
+  root = null,
   topMargin = '0px',
   bottomMargin = '0px',
   onVisibleChange,
   style,
 }: {
+  root?: Element | null
   topMargin?: string
   bottomMargin?: string
   onVisibleChange: (isVisible: boolean) => void
@@ -303,6 +392,7 @@ let Visibility = ({
 
   React.useEffect(() => {
     const observer = new IntersectionObserver(handleIntersection, {
+      root,
       rootMargin: `${topMargin} 0px ${bottomMargin} 0px`,
     })
     const tail: Element | null = tailRef.current!
@@ -310,7 +400,7 @@ let Visibility = ({
     return () => {
       observer.unobserve(tail)
     }
-  }, [bottomMargin, handleIntersection, topMargin])
+  }, [bottomMargin, handleIntersection, topMargin, root])
 
   return (
     <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} />
diff --git a/src/view/screens/Storybook/ListContained.tsx b/src/view/screens/Storybook/ListContained.tsx
new file mode 100644
index 000000000..c4e06efb2
--- /dev/null
+++ b/src/view/screens/Storybook/ListContained.tsx
@@ -0,0 +1,98 @@
+import React from 'react'
+import {FlatList, View} from 'react-native'
+
+import {ScrollProvider} from 'lib/ScrollContext'
+import {List} from 'view/com/util/List'
+import {Button, ButtonText} from '#/components/Button'
+import * as Toggle from '#/components/forms/Toggle'
+import {Text} from '#/components/Typography'
+
+export function ListContained() {
+  const [animated, setAnimated] = React.useState(false)
+  const ref = React.useRef<FlatList>(null)
+
+  const data = React.useMemo(() => {
+    return Array.from({length: 100}, (_, i) => ({
+      id: i,
+      text: `Message ${i}`,
+    }))
+  }, [])
+
+  return (
+    <>
+      <View style={{width: '100%', height: 300}}>
+        <ScrollProvider
+          onScroll={() => {
+            'worklet'
+            console.log('onScroll')
+          }}>
+          <List
+            data={data}
+            renderItem={item => {
+              return (
+                <View
+                  style={{
+                    padding: 10,
+                    borderBottomWidth: 1,
+                    borderBottomColor: 'rgba(0,0,0,0.1)',
+                  }}>
+                  <Text>{item.item.text}</Text>
+                </View>
+              )
+            }}
+            keyExtractor={item => item.id.toString()}
+            containWeb={true}
+            style={{flex: 1}}
+            onStartReached={() => {
+              console.log('Start Reached')
+            }}
+            onEndReached={() => {
+              console.log('End Reached (threshold of 2)')
+            }}
+            onEndReachedThreshold={2}
+            ref={ref}
+            disableVirtualization={true}
+          />
+        </ScrollProvider>
+      </View>
+
+      <View style={{flexDirection: 'row', gap: 10, alignItems: 'center'}}>
+        <Toggle.Item
+          name="a"
+          label="Click me"
+          value={animated}
+          onChange={() => setAnimated(prev => !prev)}>
+          <Toggle.Checkbox />
+          <Toggle.LabelText>Animated Scrolling</Toggle.LabelText>
+        </Toggle.Item>
+      </View>
+
+      <Button
+        variant="solid"
+        color="primary"
+        size="large"
+        label="Scroll to End"
+        onPress={() => ref.current?.scrollToOffset({animated, offset: 0})}>
+        <ButtonText>Scroll to Top</ButtonText>
+      </Button>
+
+      <Button
+        variant="solid"
+        color="primary"
+        size="large"
+        label="Scroll to End"
+        onPress={() => ref.current?.scrollToEnd({animated})}>
+        <ButtonText>Scroll to End</ButtonText>
+      </Button>
+
+      <Button
+        variant="solid"
+        color="primary"
+        size="large"
+        label="Scroll to Offset 100"
+        onPress={() => ref.current?.scrollToOffset({animated, offset: 500})}>
+        <ButtonText>Scroll to Offset 500</ButtonText>
+      </Button>
+    </>
+  )
+}
diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx
index 35a666601..282b3ff5c 100644
--- a/src/view/screens/Storybook/index.tsx
+++ b/src/view/screens/Storybook/index.tsx
@@ -1,8 +1,10 @@
 import React from 'react'
-import {View} from 'react-native'
+import {ScrollView, View} from 'react-native'
 
 import {useSetThemePrefs} from '#/state/shell'
-import {CenteredView, ScrollView} from '#/view/com/util/Views'
+import {isWeb} from 'platform/detection'
+import {CenteredView} from '#/view/com/util/Views'
+import {ListContained} from 'view/screens/Storybook/ListContained'
 import {atoms as a, ThemeProvider, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import {Breakpoints} from './Breakpoints'
@@ -18,77 +20,111 @@ import {Theming} from './Theming'
 import {Typography} from './Typography'
 
 export function Storybook() {
+  if (isWeb) return <StorybookInner />
+
+  return (
+    <ScrollView>
+      <StorybookInner />
+    </ScrollView>
+  )
+}
+
+function StorybookInner() {
   const t = useTheme()
   const {setColorMode, setDarkTheme} = useSetThemePrefs()
+  const [showContainedList, setShowContainedList] = React.useState(false)
 
   return (
-    <ScrollView>
-      <CenteredView style={[t.atoms.bg]}>
-        <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}>
-          <View style={[a.flex_row, a.align_start, a.gap_md]}>
-            <Button
-              variant="outline"
-              color="primary"
-              size="small"
-              label='Set theme to "system"'
-              onPress={() => setColorMode('system')}>
-              <ButtonText>System</ButtonText>
-            </Button>
-            <Button
-              variant="solid"
-              color="secondary"
-              size="small"
-              label='Set theme to "light"'
-              onPress={() => setColorMode('light')}>
-              <ButtonText>Light</ButtonText>
-            </Button>
+    <CenteredView style={[t.atoms.bg]}>
+      <View style={[a.p_xl, a.gap_5xl, {paddingBottom: 200}]}>
+        {!showContainedList ? (
+          <>
+            <View style={[a.flex_row, a.align_start, a.gap_md]}>
+              <Button
+                variant="outline"
+                color="primary"
+                size="small"
+                label='Set theme to "system"'
+                onPress={() => setColorMode('system')}>
+                <ButtonText>System</ButtonText>
+              </Button>
+              <Button
+                variant="solid"
+                color="secondary"
+                size="small"
+                label='Set theme to "light"'
+                onPress={() => setColorMode('light')}>
+                <ButtonText>Light</ButtonText>
+              </Button>
+              <Button
+                variant="solid"
+                color="secondary"
+                size="small"
+                label='Set theme to "dim"'
+                onPress={() => {
+                  setColorMode('dark')
+                  setDarkTheme('dim')
+                }}>
+                <ButtonText>Dim</ButtonText>
+              </Button>
+              <Button
+                variant="solid"
+                color="secondary"
+                size="small"
+                label='Set theme to "dark"'
+                onPress={() => {
+                  setColorMode('dark')
+                  setDarkTheme('dark')
+                }}>
+                <ButtonText>Dark</ButtonText>
+              </Button>
+            </View>
+
+            <Dialogs />
+            <ThemeProvider theme="light">
+              <Theming />
+            </ThemeProvider>
+            <ThemeProvider theme="dim">
+              <Theming />
+            </ThemeProvider>
+            <ThemeProvider theme="dark">
+              <Theming />
+            </ThemeProvider>
+
+            <Typography />
+            <Spacing />
+            <Shadows />
+            <Buttons />
+            <Icons />
+            <Links />
+            <Forms />
+            <Dialogs />
+            <Menus />
+            <Breakpoints />
+
             <Button
               variant="solid"
-              color="secondary"
-              size="small"
-              label='Set theme to "dim"'
-              onPress={() => {
-                setColorMode('dark')
-                setDarkTheme('dim')
-              }}>
-              <ButtonText>Dim</ButtonText>
+              color="primary"
+              size="large"
+              label="Switch to Contained List"
+              onPress={() => setShowContainedList(true)}>
+              <ButtonText>Switch to Contained List</ButtonText>
             </Button>
+          </>
+        ) : (
+          <>
             <Button
               variant="solid"
-              color="secondary"
-              size="small"
-              label='Set theme to "dark"'
-              onPress={() => {
-                setColorMode('dark')
-                setDarkTheme('dark')
-              }}>
-              <ButtonText>Dark</ButtonText>
+              color="primary"
+              size="large"
+              label="Switch to Storybook"
+              onPress={() => setShowContainedList(false)}>
+              <ButtonText>Switch to Storybook</ButtonText>
             </Button>
-          </View>
-
-          <Dialogs />
-          <ThemeProvider theme="light">
-            <Theming />
-          </ThemeProvider>
-          <ThemeProvider theme="dim">
-            <Theming />
-          </ThemeProvider>
-          <ThemeProvider theme="dark">
-            <Theming />
-          </ThemeProvider>
-
-          <Typography />
-          <Spacing />
-          <Shadows />
-          <Buttons />
-          <Icons />
-          <Links />
-          <Forms />
-          <Dialogs />
-          <Menus />
-          <Breakpoints />
-        </View>
-      </CenteredView>
-    </ScrollView>
+            <ListContained />
+          </>
+        )}
+      </View>
+    </CenteredView>
   )
 }