about summary refs log tree commit diff
path: root/src/components/Layout
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/Layout')
-rw-r--r--src/components/Layout/Header/index.tsx199
-rw-r--r--src/components/Layout/README.md172
-rw-r--r--src/components/Layout/const.ts16
-rw-r--r--src/components/Layout/context.ts5
-rw-r--r--src/components/Layout/index.tsx188
5 files changed, 580 insertions, 0 deletions
diff --git a/src/components/Layout/Header/index.tsx b/src/components/Layout/Header/index.tsx
new file mode 100644
index 000000000..a35a09537
--- /dev/null
+++ b/src/components/Layout/Header/index.tsx
@@ -0,0 +1,199 @@
+import {createContext, useCallback, useContext} from 'react'
+import {GestureResponderEvent, View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {HITSLOP_30} from '#/lib/constants'
+import {NavigationProp} from '#/lib/routes/types'
+import {isIOS} from '#/platform/detection'
+import {useSetDrawerOpen} from '#/state/shell'
+import {
+  atoms as a,
+  platform,
+  TextStyleProp,
+  useBreakpoints,
+  useGutterStyles,
+  useTheme,
+} from '#/alf'
+import {Button, ButtonIcon, ButtonProps} from '#/components/Button'
+import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
+import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
+import {
+  BUTTON_VISUAL_ALIGNMENT_OFFSET,
+  HEADER_SLOT_SIZE,
+} from '#/components/Layout/const'
+import {ScrollbarOffsetContext} from '#/components/Layout/context'
+import {Text} from '#/components/Typography'
+
+export function Outer({
+  children,
+  noBottomBorder,
+}: {
+  children: React.ReactNode
+  noBottomBorder?: boolean
+}) {
+  const t = useTheme()
+  const gutter = useGutterStyles()
+  const {gtMobile} = useBreakpoints()
+  const {isWithinOffsetView} = useContext(ScrollbarOffsetContext)
+
+  return (
+    <View
+      style={[
+        a.w_full,
+        !noBottomBorder && a.border_b,
+        a.flex_row,
+        a.align_center,
+        a.gap_sm,
+        gutter,
+        platform({
+          native: [a.pb_sm, a.pt_xs],
+          web: [a.py_sm],
+        }),
+        t.atoms.border_contrast_low,
+        gtMobile && [a.mx_auto, {maxWidth: 600}],
+        !isWithinOffsetView && a.scrollbar_offset,
+      ]}>
+      {children}
+    </View>
+  )
+}
+
+const AlignmentContext = createContext<'platform' | 'left'>('platform')
+
+export function Content({
+  children,
+  align = 'platform',
+}: {
+  children?: React.ReactNode
+  align?: 'platform' | 'left'
+}) {
+  return (
+    <View
+      style={[
+        a.flex_1,
+        a.justify_center,
+        isIOS && align === 'platform' && a.align_center,
+        {minHeight: HEADER_SLOT_SIZE},
+      ]}>
+      <AlignmentContext.Provider value={align}>
+        {children}
+      </AlignmentContext.Provider>
+    </View>
+  )
+}
+
+export function Slot({children}: {children?: React.ReactNode}) {
+  return (
+    <View
+      style={[
+        a.z_50,
+        {
+          width: HEADER_SLOT_SIZE,
+        },
+      ]}>
+      {children}
+    </View>
+  )
+}
+
+export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
+  const {_} = useLingui()
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPressBack = useCallback(
+    (evt: GestureResponderEvent) => {
+      onPress?.(evt)
+      if (evt.defaultPrevented) return
+      if (navigation.canGoBack()) {
+        navigation.goBack()
+      } else {
+        navigation.navigate('Home')
+      }
+    },
+    [onPress, navigation],
+  )
+
+  return (
+    <Slot>
+      <Button
+        label={_(msg`Go back`)}
+        size="small"
+        variant="ghost"
+        color="secondary"
+        shape="square"
+        onPress={onPressBack}
+        hitSlop={HITSLOP_30}
+        style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}, style]}
+        {...props}>
+        <ButtonIcon icon={ArrowLeft} size="lg" />
+      </Button>
+    </Slot>
+  )
+}
+
+export function MenuButton() {
+  const {_} = useLingui()
+  const setDrawerOpen = useSetDrawerOpen()
+  const {gtMobile} = useBreakpoints()
+
+  const onPress = useCallback(() => {
+    setDrawerOpen(true)
+  }, [setDrawerOpen])
+
+  return gtMobile ? null : (
+    <Slot>
+      <Button
+        label={_(msg`Open drawer menu`)}
+        size="small"
+        variant="ghost"
+        color="secondary"
+        shape="square"
+        onPress={onPress}
+        hitSlop={HITSLOP_30}
+        style={[{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET}]}>
+        <ButtonIcon icon={Menu} size="lg" />
+      </Button>
+    </Slot>
+  )
+}
+
+export function TitleText({
+  children,
+  style,
+}: {children: React.ReactNode} & TextStyleProp) {
+  const {gtMobile} = useBreakpoints()
+  const align = useContext(AlignmentContext)
+  return (
+    <Text
+      style={[
+        a.text_lg,
+        a.font_heavy,
+        a.leading_tight,
+        isIOS && align === 'platform' && a.text_center,
+        gtMobile && a.text_xl,
+        style,
+      ]}
+      numberOfLines={2}>
+      {children}
+    </Text>
+  )
+}
+
+export function SubtitleText({children}: {children: React.ReactNode}) {
+  const t = useTheme()
+  const align = useContext(AlignmentContext)
+  return (
+    <Text
+      style={[
+        a.text_sm,
+        a.leading_snug,
+        isIOS && align === 'platform' && a.text_center,
+        t.atoms.text_contrast_medium,
+      ]}
+      numberOfLines={2}>
+      {children}
+    </Text>
+  )
+}
diff --git a/src/components/Layout/README.md b/src/components/Layout/README.md
new file mode 100644
index 000000000..1bcc3489e
--- /dev/null
+++ b/src/components/Layout/README.md
@@ -0,0 +1,172 @@
+# Layout
+
+This directory contains our core layout components. Use these when creating new
+screens, or when supplementing other components with functionality like
+centering.
+
+## Usage
+
+If we aren't talking about the `shell` components, layouts on individual screens
+look like more or less like this:
+
+```tsx
+<Outer>
+  <Header>...</Header>
+  <Content>...</Content>
+</Outer>
+```
+
+I'll map these words to real components.
+
+### `Layout.Screen`
+
+Provides the "Outer" functionality for a screen, like taking up the full height
+of the screen. **All screens should be wrapped with this component,** probably
+as the outermost component.
+
+> [!NOTE]
+> On web, `Layout.Screen` also provides the side borders on our central content
+> column. These borders are fixed position, 1px outside our center column width
+> of 600px.
+>
+> What this effectively means is that _nothing inside the center content column
+> needs (or should) define left/right borders._ That is now handled in one
+> place: within `Layout.Screen`.
+
+### `Layout.Header.*`
+
+The `Layout.Header` component actually contains multiple sub-components. Use
+this to compose different versions of the header. The most basic version looks
+like this:
+
+```tsx
+<Layout.Header.Outer>
+  <Layout.Header.BackButton /> {/* or <Layout.Header.MenuButton /> */}
+
+  <Layout.Header.Content>
+    <Layout.Header.TitleText>Account</Layout.Header.TitleText>
+
+    {/* Optional subtitle */}
+    <Layout.Header.SubtitleText>Settings for @esb.lol</Layout.Header.SubtitleText>
+  </Layout.Header.Content>
+
+  <Layout.Header.Slot />
+</Layout.Header.Outer>
+```
+
+Note the additional `Slot` component. This is here to keep the header balanced
+and provide correct spacing on all platforms. The `Slot` is 34px wide, which
+matches the `BackButton` and `MenuButton`.
+
+> If anyone has better ideas, I'm all ears, but this was simple and the small
+> amount of boilerplate is only incurred when creating a new screen, which is
+> infrequent.
+
+It can also function as a "slot" for a button positioned on the right side. See
+the `Hashtag` screen for an example, abbreviated below:
+
+```tsx
+<Layout.Header.Slot>
+  <Button size='small' shape='round'>...</Button>
+</Layout.Header.Slot>
+```
+
+If you need additional customization, simply use the components that are helpful
+and create new ones as needed. A good example is the `SavedFeeds` screen, which
+looks roughly like this:
+
+```tsx
+<Layout.Header.Outer>
+  <Layout.Header.BackButton />
+
+  {/* Override to align content to the left, making room for the button */}
+  <Layout.Header.Content align='left'>
+    <Layout.Header.TitleText>Edit My Feeds</Layout.Header.TitleText>
+  </Layout.Header.Content>
+
+  {/* Custom button, wider than 34px */}
+  <Button size='small'>...</Button>
+</Layout.Header.Outer>
+```
+
+> [!TIP]
+> The `Header` should be _outside_ the `Content` component in order to be
+> fixed on scroll on native. Placing it inside will make it scroll with the rest
+> of the page.
+
+### `Layout.Content`
+
+This provides the "Content" functionality for a screen. This component is
+actually an `Animated.ScrollView`, and accepts props for that component. It
+provides a little default styling as well. On web, it also _centers the content
+inside our center content column of 600px_.
+
+> [!NOTE]
+> What about flatlists or pagers? Those components are not colocated here (yet).
+> But those components serve the same purpose of "Content".
+
+## Examples
+
+The most basic layout available to us looks like this:
+
+```tsx
+<Layout.Screen>
+  <Layout.Header.Outer>
+    <Layout.Header.BackButton /> {/* or <Layout.Header.MenuButton /> */}
+
+    <Layout.Header.Content>
+      <Layout.Header.TitleText>Account</Layout.Header.TitleText>
+
+      {/* Optional subtitle */}
+      <Layout.Header.SubtitleText>Settings for @esb.lol</Layout.Header.SubtitleText>
+    </Layout.Header.Content>
+
+    <Layout.Header.Slot />
+  </Layout.Header.Outer>
+
+  <Layout.Content>
+    ...
+  </Layout.Content>
+</Layout.Screen>
+```
+
+**For `List` views,** you'd sub in `List` for `Layout.Content` and it will
+function the same. See `Feeds` screen for an example.
+
+**For `Pager` views,** including `PagerWithHeader`, do the same. See `Hashtag`
+screen for an example.
+
+## Utilities
+
+### `Layout.Center`
+
+This component behaves like our old `CenteredView` component.
+
+### `Layout.SCROLLBAR_OFFSET` and `Layout.SCROLLBAR_OFFSET_POSITIVE`
+
+Provide a pre-configured CSS vars for use when aligning fixed position elements.
+More on this below.
+
+## Scrollbar gutter handling
+
+Operating systems allow users to configure if their browser _always_ shows
+scrollbars not. Some OSs also don't allow configuration.
+
+The presence of scrollbars affects layout, particularly fixed position elements.
+Browsers support `scrollbar-gutter`, but each behaves differently. Our approach
+is to use the default `scrollbar-gutter: auto`. Basically, we start from a clean
+slate.
+
+This handling becomes particularly thorny when we need to lock scroll, like when
+opening a dialog or dropdown. Radix uses the library `react-remove-scroll`
+internally, which in turn depends on
+[`react-remove-scroll-bar`](https://github.com/theKashey/react-remove-scroll-bar).
+We've opted to rely on this transient dependency. This library adds some utility
+classes and CSS vars to the page when scroll is locked.
+
+**It is this CSS variable that we use in `SCROLLBAR_OFFSET` values.** This
+ensures that elements do not shift relative to the screen when opening a
+dropdown or dialog.
+
+These styles are applied where needed and we should have very little need of
+adjusting them often.
diff --git a/src/components/Layout/const.ts b/src/components/Layout/const.ts
new file mode 100644
index 000000000..11825d323
--- /dev/null
+++ b/src/components/Layout/const.ts
@@ -0,0 +1,16 @@
+export const SCROLLBAR_OFFSET =
+  'calc(-1 * var(--removed-body-scroll-bar-size, 0px) / 2)' as any
+export const SCROLLBAR_OFFSET_POSITIVE =
+  'calc(var(--removed-body-scroll-bar-size, 0px) / 2)' as any
+
+/**
+ * Useful for visually aligning icons within header buttons with the elements
+ * below them on the screen. Apply positively or negatively depending on side
+ * of the screen you're on.
+ */
+export const BUTTON_VISUAL_ALIGNMENT_OFFSET = 3
+
+/**
+ * Corresponds to the width of a small square or round button
+ */
+export const HEADER_SLOT_SIZE = 34
diff --git a/src/components/Layout/context.ts b/src/components/Layout/context.ts
new file mode 100644
index 000000000..8e0c5445e
--- /dev/null
+++ b/src/components/Layout/context.ts
@@ -0,0 +1,5 @@
+import React from 'react'
+
+export const ScrollbarOffsetContext = React.createContext({
+  isWithinOffsetView: false,
+})
diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx
new file mode 100644
index 000000000..d08505fbf
--- /dev/null
+++ b/src/components/Layout/index.tsx
@@ -0,0 +1,188 @@
+import React, {useContext, useMemo} from 'react'
+import {StyleSheet, View, ViewProps, ViewStyle} from 'react-native'
+import {StyleProp} from 'react-native'
+import {
+  KeyboardAwareScrollView,
+  KeyboardAwareScrollViewProps,
+} from 'react-native-keyboard-controller'
+import Animated, {
+  AnimatedScrollViewProps,
+  useAnimatedProps,
+} from 'react-native-reanimated'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+
+import {isWeb} from '#/platform/detection'
+import {useShellLayout} from '#/state/shell/shell-layout'
+import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import {ScrollbarOffsetContext} from '#/components/Layout/context'
+
+export * from '#/components/Layout/const'
+export * as Header from '#/components/Layout/Header'
+
+export type ScreenProps = React.ComponentProps<typeof View> & {
+  style?: StyleProp<ViewStyle>
+}
+
+/**
+ * Outermost component of every screen
+ */
+export const Screen = React.memo(function Screen({
+  style,
+  ...props
+}: ScreenProps) {
+  const {top} = useSafeAreaInsets()
+  return (
+    <>
+      {isWeb && <WebCenterBorders />}
+      <View
+        style={[a.util_screen_outer, {paddingTop: top}, style]}
+        {...props}
+      />
+    </>
+  )
+})
+
+export type ContentProps = AnimatedScrollViewProps & {
+  style?: StyleProp<ViewStyle>
+  contentContainerStyle?: StyleProp<ViewStyle>
+}
+
+/**
+ * Default scroll view for simple pages
+ */
+export const Content = React.memo(function Content({
+  children,
+  style,
+  contentContainerStyle,
+  ...props
+}: ContentProps) {
+  const {footerHeight} = useShellLayout()
+  const animatedProps = useAnimatedProps(() => {
+    return {
+      scrollIndicatorInsets: {
+        bottom: footerHeight.get(),
+        top: 0,
+        right: 1,
+      },
+    } satisfies AnimatedScrollViewProps
+  })
+
+  return (
+    <Animated.ScrollView
+      id="content"
+      automaticallyAdjustsScrollIndicatorInsets={false}
+      // sets the scroll inset to the height of the footer
+      animatedProps={animatedProps}
+      style={[scrollViewStyles.common, style]}
+      contentContainerStyle={[
+        scrollViewStyles.contentContainer,
+        contentContainerStyle,
+      ]}
+      {...props}>
+      {isWeb ? (
+        // @ts-ignore web only -esb
+        <Center>{children}</Center>
+      ) : (
+        children
+      )}
+    </Animated.ScrollView>
+  )
+})
+
+const scrollViewStyles = StyleSheet.create({
+  common: {
+    width: '100%',
+  },
+  contentContainer: {
+    paddingBottom: 100,
+  },
+})
+
+export type KeyboardAwareContentProps = KeyboardAwareScrollViewProps & {
+  children: React.ReactNode
+  contentContainerStyle?: StyleProp<ViewStyle>
+}
+
+/**
+ * Default scroll view for simple pages.
+ *
+ * BE SURE TO TEST THIS WHEN USING, it's untested as of writing this comment.
+ */
+export const KeyboardAwareContent = React.memo(function LayoutScrollView({
+  children,
+  style,
+  contentContainerStyle,
+  ...props
+}: KeyboardAwareContentProps) {
+  return (
+    <KeyboardAwareScrollView
+      style={[scrollViewStyles.common, style]}
+      contentContainerStyle={[
+        scrollViewStyles.contentContainer,
+        contentContainerStyle,
+      ]}
+      keyboardShouldPersistTaps="handled"
+      {...props}>
+      {isWeb ? <Center>{children}</Center> : children}
+    </KeyboardAwareScrollView>
+  )
+})
+
+/**
+ * Utility component to center content within the screen
+ */
+export const Center = React.memo(function LayoutContent({
+  children,
+  style,
+  ...props
+}: ViewProps) {
+  const {isWithinOffsetView} = useContext(ScrollbarOffsetContext)
+  const {gtMobile} = useBreakpoints()
+  const ctx = useMemo(() => ({isWithinOffsetView: true}), [])
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.mx_auto,
+        gtMobile && {
+          maxWidth: 600,
+        },
+        style,
+        !isWithinOffsetView && a.scrollbar_offset,
+      ]}
+      {...props}>
+      <ScrollbarOffsetContext.Provider value={ctx}>
+        {children}
+      </ScrollbarOffsetContext.Provider>
+    </View>
+  )
+})
+
+/**
+ * Only used within `Layout.Screen`, not for reuse
+ */
+const WebCenterBorders = React.memo(function LayoutContent() {
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+  return gtMobile ? (
+    <View
+      style={[
+        a.fixed,
+        a.inset_0,
+        a.border_l,
+        a.border_r,
+        t.atoms.border_contrast_low,
+        web({
+          width: 602,
+          left: '50%',
+          transform: [
+            {
+              translateX: '-50%',
+            },
+            ...a.scrollbar_offset.transform,
+          ],
+        }),
+      ]}
+    />
+  ) : null
+})