Skip to content

Instantly share code, notes, and snippets.

@casprine
Created May 10, 2023 10:43
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save casprine/54e09df1af7c92b68ac7fce99f1bd5a5 to your computer and use it in GitHub Desktop.
Save casprine/54e09df1af7c92b68ac7fce99f1bd5a5 to your computer and use it in GitHub Desktop.
import React, { ComponentProps, FunctionComponent, memo, useMemo } from 'react'
import { View } from 'react-native'
import styled, { css } from 'styled-components/native'
import { ThemeColorName } from '../../../types'
import { invariant, normalize, useOnPress } from '../../../util'
import {
Avatar,
Button,
Icon,
IconButton,
MultiSelect,
Radio,
Stack,
Switch,
Text
} from '../../elements/'
import { ListLogo } from './ListLogo'
type Function = () => void
interface ListBaseProps {
title: string
subTitle?: string
hasDivider?: boolean
size?: 'default' | 'short'
hasHaptic?: boolean
onPress?: () => void
analyticEventName?: string
analyticEventOptions?: {}
titleColor?: ThemeColorName
}
interface TappableElement {
onPress: () => void
analyticEventName: string
analyticEventOptions?: {}
}
interface AvatarLeadingElementType {
leadingElementType: 'avatar'
leadingElementProps: Omit<ComponentProps<typeof Avatar>, 'size'>
}
interface IconLeadingElementType {
leadingElementType: 'icon'
leadingElementProps: Omit<
ComponentProps<typeof Icon>,
'size' | 'customSize'
> & {
withContainer?: boolean
}
}
interface LogoLeadingElementType {
leadingElementType: 'logo'
leadingElementProps: ComponentProps<typeof ListLogo>
}
interface NoLeadingElementType {
leadingElementType?: 'none'
}
interface ButtonTrailingElement extends TappableElement {
trailingElementType: 'button'
trailingElementProps: Omit<
ComponentProps<typeof Button>,
| 'size'
| 'onPress'
| 'analyticEventName'
| 'analyticEventOptions'
| 'hasHaptic'
>
}
interface IconTrailingElement extends TappableElement {
trailingElementType: 'icon'
trailingElementProps: Omit<
ComponentProps<typeof IconButton>,
| 'size'
| 'onPress'
| 'analyticEventName'
| 'analyticEventOptions'
| 'hasHaptic'
>
}
interface TextTrailingElement {
trailingElementType: 'value'
trailingElementProps: ComponentProps<typeof Text>
}
interface SingleValueIconTrailingElement extends TappableElement {
trailingElementType: 'singleValueIcon'
trailingElementProps: {
value: ComponentProps<typeof Text>
icon: Omit<ComponentProps<typeof Icon>, 'size'>
}
}
interface DoubleIconTrailingElement {
trailingElementType: 'doubleIcon'
trailingElementProps: {
firstIcon: Omit<ComponentProps<typeof Icon>, 'size' | 'customSize'>
secondIcon: Omit<ComponentProps<typeof Icon>, 'size' | 'customSize'>
}
}
interface DoubleValueIconTrailingElement extends TappableElement {
trailingElementType: 'doubleValueIcon'
trailingElementProps: {
firstValue: ComponentProps<typeof Text>
secondValue?: ComponentProps<typeof Text>
icon: Omit<ComponentProps<typeof Icon>, 'size' | 'customSize'>
}
}
interface SwitchTrailingElement extends TappableElement {
trailingElementType: 'switch'
trailingElementProps: Omit<
ComponentProps<typeof Switch>,
'onPress' | 'analyticEventName' | 'analyticEventOptions' | 'hasHaptic'
>
}
interface RadioTrailingElement extends TappableElement {
trailingElementType: 'radio'
trailingElementProps: Omit<
ComponentProps<typeof Radio>,
'onPress' | 'analyticEventName' | 'analyticEventOptions' | 'hasHaptic'
>
}
interface MultiSelectTrailingElement extends TappableElement {
trailingElementType: 'multiSelect'
trailingElementProps: Omit<
ComponentProps<typeof MultiSelect>,
'onPress' | 'analyticEventName' | 'analyticEventOptions' | 'hasHaptic'
>
}
interface NoTrailingElement {
trailingElementType?: 'none'
trailingElementProps?: null | {}
}
export type LeadingElement =
| AvatarLeadingElementType
| IconLeadingElementType
| LogoLeadingElementType
| NoLeadingElementType
type TrailingElement =
| NoTrailingElement
| MultiSelectTrailingElement
| RadioTrailingElement
| ButtonTrailingElement
| IconTrailingElement
| TextTrailingElement
| SingleValueIconTrailingElement
| DoubleIconTrailingElement
| DoubleValueIconTrailingElement
| SwitchTrailingElement
export type ListProps = ListBaseProps & LeadingElement & TrailingElement
const ListItemWrapper: FunctionComponent<ListProps> = props => {
const {
title,
subTitle,
hasDivider = true,
size = 'default',
onPress,
analyticEventName,
analyticEventOptions,
hasHaptic = false,
titleColor = 'textPrimary'
} = props
const handleContainerPress = useOnPress({
onPress,
analyticEventName,
analyticEventOptions,
hasHaptic
})
function generateLeadingElement() {
switch (props?.leadingElementType) {
case 'avatar':
return <Avatar {...props.leadingElementProps} />
case 'icon':
return (
<>
{props.leadingElementProps.withContainer ? (
<LeadingIconContainer justifyContent="center" alignItems="center">
<Icon {...props.leadingElementProps} />
</LeadingIconContainer>
) : (
<Icon {...props.leadingElementProps} customSize={32} />
)}
</>
)
case 'logo':
return <ListLogo {...props.leadingElementProps} />
default:
return null
}
}
function generateTrailingElement() {
switch (props.trailingElementType) {
case 'button':
return (
<Button
size="tiny"
{...props.trailingElementProps}
onPress={props.onPress}
analyticEventName={props.analyticEventName}
analyticEventOptions={props.analyticEventOptions}
hasHaptic={hasHaptic}
/>
)
case 'icon':
return (
<IconButton
size="medium"
{...props.trailingElementProps}
onPress={onPress as Function}
analyticEventName={props.analyticEventName}
analyticEventOptions={props.analyticEventOptions}
hasHaptic={hasHaptic}
/>
)
case 'value':
return <Text {...props.trailingElementProps} spacing="s0" />
case 'singleValueIcon':
return (
<Stack
flexOne={false}
spacing="s0"
alignItems="center"
flexDirection="row">
<Text {...props.trailingElementProps.value} spacing="s0" />
<Icon
{...props.trailingElementProps.icon}
size="medium"
style={{ marginLeft: normalize(8) }}
/>
</Stack>
)
case 'doubleValueIcon':
return (
<Stack
flexOne={false}
spacing="s0"
alignItems="center"
flexDirection="row">
<Stack spacing="s2" alignItems="flex-end">
<Text {...props.trailingElementProps.firstValue} spacing="s0" />
{props.trailingElementProps.secondValue && (
<Text
{...props.trailingElementProps.secondValue}
spacing="s0"
/>
)}
</Stack>
<Icon
{...props.trailingElementProps.icon}
size="medium"
style={{ marginLeft: normalize(5) }}
/>
</Stack>
)
case 'doubleIcon':
return (
<Stack spacing="s0" flexDirection="row" alignItems="center">
<Icon {...props.trailingElementProps.firstIcon} size="medium" />
<Icon
{...props.trailingElementProps.secondIcon}
size="medium"
style={{ marginLeft: normalize(8) }}
/>
</Stack>
)
case 'switch':
return (
<Switch
{...props.trailingElementProps}
onPress={props.onPress}
analyticEventName={props.analyticEventName}
analyticEventOptions={props.analyticEventOptions}
hasHaptic={hasHaptic}
/>
)
case 'radio':
return (
<Radio
{...props.trailingElementProps}
onPress={props.onPress}
analyticEventName={props.analyticEventName}
analyticEventOptions={props.analyticEventOptions}
hasHaptic={hasHaptic}
/>
)
case 'multiSelect':
return (
<MultiSelect
{...props.trailingElementProps}
onPress={props.onPress}
analyticEventName={props.analyticEventName}
analyticEventOptions={props.analyticEventOptions}
hasHaptic={hasHaptic}
/>
)
case 'none':
return null
default:
return <Icon size="medium" name="chevron-right" />
}
}
const spacing = useMemo(() => {
if (size === 'short' && !subTitle) {
return {
minHeight: normalize(40),
padding: normalize(2)
}
}
if (!subTitle) {
return {
minHeight: normalize(60),
padding: normalize(10)
}
}
if (size === 'default' && subTitle) {
return {
minHeight: normalize(46),
padding: normalize(16)
}
}
}, [size, subTitle])
const tappableTargets = ['button', 'switch', 'radio', 'multiSelect', 'value']
const disableContainerOnPress = useMemo(() => {
return tappableTargets.includes(props?.trailingElementType as string)
}, [props.trailingElementType, tappableTargets])
const Component = () => (
<Stack
fullWidth
style={{
flexDirection: 'row'
}}
spacing="s0"
alignItems="center">
{props.leadingElementType && (
<Stack
spacing="s0"
alignItems="center"
justifyContent="center"
style={{
marginRight: normalize(12),
width: normalize(40),
height: normalize(40)
}}>
{generateLeadingElement()}
</Stack>
)}
<Stack
flexDirection="row"
alignItems="center"
justifyContent="space-between"
spacing="s0">
<Stack spacing="s0" style={{ flex: 1 }}>
<Text
color={titleColor}
type={subTitle ? 'listMedium' : 'listDefault'}>
{title}
</Text>
{subTitle && (
<Text color="textSecondary" type="listSubTitle">
{subTitle}
</Text>
)}
</Stack>
<View style={{ marginLeft: normalize(12) }}>
{generateTrailingElement()}
</View>
</Stack>
</Stack>
)
if (disableContainerOnPress) {
return (
<>
<ListContainer spacing={spacing}>
<Component />
</ListContainer>
{hasDivider && (
<StyledDivider
spacing={spacing}
hasLeadingElement={Boolean(props.leadingElementType)}
/>
)}
</>
)
}
invariant(analyticEventName, 'analyticEventName prop is required')
invariant(onPress, 'onPress is required')
return (
<>
<TouchableListContainer
spacing={spacing}
style={({ pressed }: { pressed: boolean }) => [
{
opacity: pressed ? 0.7 : 1
}
]}
onPress={handleContainerPress}>
<Component />
</TouchableListContainer>
{hasDivider && (
<StyledDivider
spacing={spacing}
hasLeadingElement={Boolean(props.leadingElementType)}
/>
)}
</>
)
}
export const ListItem = memo(ListItemWrapper)
const ListContainer = styled.View<{
spacing?: { minHeight?: number; padding?: number }
}>`
justify-content: center;
background-color: ${({ theme }) => theme.colors.bgBase};
padding-horizontal: ${({ theme }) => theme.spacing.s24};
${({ spacing }) =>
spacing?.padding &&
css`
padding-vertical: ${spacing?.padding}px;
`}
${({ spacing }) =>
spacing?.minHeight &&
css`
min-height: ${spacing?.minHeight}px;
`}
`
const TouchableListContainer = styled.Pressable<{
spacing?: { minHeight?: number; padding?: number }
}>`
justify-content: center;
background-color: ${({ theme }) => theme.colors.bgBase};
padding-horizontal: ${({ theme }) => theme.spacing.s24};
${({ spacing }) =>
spacing?.padding &&
css`
padding-vertical: ${spacing?.padding}px;
`}
${({ spacing }) =>
spacing?.minHeight &&
css`
min-height: ${spacing?.minHeight}px;
`}
`
const StyledDivider = styled.View<{
hasLeadingElement?: boolean
spacing?: { padding?: number }
}>`
height: 1px;
width: ${({ hasLeadingElement }) => (hasLeadingElement ? '80%' : '87.5%')};
background-color: ${({ theme }) => theme.colors.border};
margin-left: auto;
margin-right: ${({ hasLeadingElement }) => (hasLeadingElement ? 0 : 'auto')};
`
const LeadingIconContainer = styled(Stack)`
width: ${() => normalize(36)}px;
height: ${() => normalize(36)}px;
border-radius: ${({ theme }) => theme.roundedCorners.rc8 / 2}px;
background-color: ${({ theme }) => theme.colors.bgLayerOne};
`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment