[Layout] Base (#6907)

* Add common gutter styles as hook

* Add computed scrollbar gutter CSS vars

* Add new layout components

* Replace layout components in settings screens

* Remove old back button

* Invert web border logic for easier migration

* Clean up Slot API

* Port over FF handling of scrollbar offset

* Trade boilerplate for ease of use

* Limit to one line

* Allow two lines, fix wrapping

* Fix alignment

* sticky headers

* set max with on header and center

* [Layout] Notifications Header (#6910)

* Replace notifications screen header

* fix cropped indicator

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* Replace Hashtag header (#6928)

* [Layout] ChatList header (#6929)

* Replace ChatList header

* update chat settings as well

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* Add web borders to Chat settings

* Remove unused var

* Move ChatList header outside center

* Replace empty chat layout

* fix breakpoints

* [Layout] Scrollbar gutters (#6908)

* Fix sidebar alignment

* Make sure scrollbars don't hide

* Gift left nav more space

* Use stable one-edge, update logic in RightNav

* Ope

* Increase width

* Reset

* Add transform to sidebars

* Remove bg in sidebars

* Handle shifts in layout components

* Replace scroll-removal handling

* Make react-remove-scroll an explicit dep

* Remove unused script

* use correct scroll insets (#6950)

* [Layout] Feeds headers (#6913)

* Replace ViewHeader internals, duplicate old ViewHeader

* Replace Feeds header

* Replace SavedFeeds header

* Visual alignment

* Uglier but clear

* Use old ViewHeader for SavedFeeds

* use Layout.Center instead of Layout.Content

* use left-aligned header for feed edit

* delete unused old view header

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

* [Layout] Every other screen (#6953)

* attempt to fix double borders on every other screen

* delete ListHeaderDesktop

* delete `SimpleViewHeader` and fix screens (#6956)

* Make Layout.Center not full height

* Refactor List to use Layout.Center, remove built-in borders

* Fix Home screen

* Refactor PagerWithHeader to use Layout components

* Replace components in ProfileFeed and ProfileList

* Borders on Profile

* Search screen replacements

* use new header for profile subpage header (#6958)

* Search AutocompleteResults

* use new header for starter pack wizard (#6957)

* Fix post thread

* Enable borders by default

* Moderation muted and blocked accounts

* Fix scrollbar offset on Labeler

* Remove ScrollView from Moderation

* Remove ScrollView from Deactivated

* Remove ScrollView from onboarding

* Remove ScrollView from SignupQueued

* Mark deprecations

* fix lint

* Fix double borders on profile load

* Remove unneeded CenteredView from noty Feed

* Remove double Center layout on Notifications screen

* Remove double Center layout on ChatList screen

* Handle scrollbar offset in chat

* Use new atom for other scrollbar offsets

* Remove borders from old views

* Better doc

* Remove temp migration prop

* Fix new atom usage on native

* Clean up Hashtag screen

* Layout docs

* Clarify usage in Pager

* Handle nested offset contexts

* Clean up Layout

* fix feeds page

* asymmetric header on native (#6969)

* Reusable header const

* Fix up home header

* Add back button to convo

* Add hitslop to header buttons

* Comment

* Better handling on native for new atom

* Format

* Fix nested flatlist on mod screens

* Use react-remove-scroll-bar directly

* Fix notification count overflow on web

* Clarify doc

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
Eric Bailey
2024-12-05 18:59:26 -06:00
committed by GitHub
parent 8467dfd452
commit 143e2c802d
89 changed files with 1725 additions and 2052 deletions
+199
View File
@@ -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>
)
}
+172
View File
@@ -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.
+16
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
import React from 'react'
export const ScrollbarOffsetContext = React.createContext({
isWithinOffsetView: false,
})
+188
View File
@@ -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
})