Replies, buttons, add shadow (#353)

* improve primary button

* create shadow component

* add all avatar variants

* update reply

* update button prop

* add usePressableColors hook

* update icon button component

* update messages actions

* update composer

* add inverted variant to Shadow component

* update Message component

* fix prop name

* add user-select none to web app

* fix avatar shape

* fix button icon color

* fix icon button token name

* fix icon button selected prop name

* change accordion item prop name

* set default indicator variant

* fix button prop

* export helper types

* refactor accordion props

* buttons only extend PressableProps

* improve typing of variants in component props

* add tmp workaround for pressable props

* remove new line

* make GetVariants helper nonnullable

* fix image aspectRatio

* fix avatar indicator

* fix icon button props

* add todo
This commit is contained in:
Pavel 2023-03-22 10:39:42 +01:00 committed by GitHub
parent 461a9bfe60
commit 6474b39bac
No known key found for this signature in database
GPG Key ID: 0EB8D75C775AB6F1
27 changed files with 923 additions and 637 deletions

View File

@ -124,9 +124,9 @@ export default function App() {
> >
<Heading color="$textPrimary">Rarible</Heading> <Heading color="$textPrimary">Rarible</Heading>
<Avatar <Avatar
withOutline
src="https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fi.seadn.io%2Fgae%2FFG0QJ00fN3c_FWuPeUr9-T__iQl63j9hn5d6svW8UqOmia5zp3lKHPkJuHcvhZ0f_Pd6P2COo9tt9zVUvdPxG_9BBw%3Fw%3D500%26auto%3Dformat&f=1&nofb=1&ipt=c177cd71d8d0114080cfc6efd3f9e098ddaeb1b347919bd3089bf0aacb003b3e&ipo=images" src="https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fi.seadn.io%2Fgae%2FFG0QJ00fN3c_FWuPeUr9-T__iQl63j9hn5d6svW8UqOmia5zp3lKHPkJuHcvhZ0f_Pd6P2COo9tt9zVUvdPxG_9BBw%3Fw%3D500%26auto%3Dformat&f=1&nofb=1&ipt=c177cd71d8d0114080cfc6efd3f9e098ddaeb1b347919bd3089bf0aacb003b3e&ipo=images"
size={48} size={48}
outline
/> />
</View> </View>
)} )}

View File

@ -3,6 +3,7 @@ body,
#root { #root {
height: 100%; height: 100%;
overscroll-behavior: none; overscroll-behavior: none;
user-select: none;
} }
*::selection { *::selection {

View File

@ -8,7 +8,7 @@ const meta: Meta<typeof Accordion> = {
component: Accordion, component: Accordion,
argTypes: {}, argTypes: {},
args: { args: {
numberOfNewMessages: 3, unreadCount: 3,
title: 'Welcome', title: 'Welcome',
}, },
parameters: { parameters: {
@ -27,7 +27,7 @@ export const Default: Story = {
<> <>
<AccordionItem <AccordionItem
key="welcome" key="welcome"
isSelected selected
channel={CHANNEL_GROUPS[0].channels[0]} channel={CHANNEL_GROUPS[0].channels[0]}
/> />
<AccordionItem key="general" channel={CHANNEL_GROUPS[0].channels[0]} /> <AccordionItem key="general" channel={CHANNEL_GROUPS[0].channels[0]} />

View File

@ -6,24 +6,18 @@ import { AnimatePresence } from 'tamagui'
import { Label, Paragraph } from '../typography' import { Label, Paragraph } from '../typography'
import type { GetProps } from '@tamagui/core'
type BaseProps = GetProps<typeof Stack>
type Props = { type Props = {
children: React.ReactElement[] | React.ReactElement children: React.ReactElement[] | React.ReactElement
initialExpanded: boolean initialExpanded: boolean
title: string title: string
numberOfNewMessages?: number unreadCount?: number
} & BaseProps }
const Accordion = (props: Props) => {
const { children, initialExpanded, title, unreadCount } = props
const Accordion = ({
children,
initialExpanded,
title,
numberOfNewMessages,
}: Props) => {
const [isExpanded, setIsExpanded] = useState(initialExpanded) const [isExpanded, setIsExpanded] = useState(initialExpanded)
return ( return (
<Stack <Stack
accessibilityRole="button" accessibilityRole="button"
@ -32,6 +26,7 @@ const Accordion = ({
borderTopWidth={1} borderTopWidth={1}
borderTopColor="$neutral-10" borderTopColor="$neutral-10"
paddingHorizontal={8} paddingHorizontal={8}
paddingBottom={8}
> >
<Stack justifyContent="flex-start"> <Stack justifyContent="flex-start">
<Stack width="100%"> <Stack width="100%">
@ -68,7 +63,7 @@ const Accordion = ({
</Paragraph> </Paragraph>
</Stack> </Stack>
<AnimatePresence> <AnimatePresence>
{!isExpanded && numberOfNewMessages && ( {!isExpanded && unreadCount !== 0 && (
<Stack <Stack
key={`notifications-${title}}`} key={`notifications-${title}}`}
width={20} width={20}
@ -96,7 +91,7 @@ const Accordion = ({
alignItems="center" alignItems="center"
> >
<Label color="$white-100" weight="medium"> <Label color="$white-100" weight="medium">
{numberOfNewMessages} {unreadCount}
</Label> </Label>
</Stack> </Stack>
</Stack> </Stack>

View File

@ -6,10 +6,9 @@ import { Label, Paragraph } from '../typography'
import type { Channel } from '../sidebar/mock-data' import type { Channel } from '../sidebar/mock-data'
type Props = { type Props = {
isSelected?: boolean selected?: boolean
onPress?: () => void onPress?: () => void
channel: Channel channel: Channel
mb?: number
} }
const textColor = { const textColor = {
@ -20,8 +19,9 @@ const textColor = {
} }
const AccordionItem = (props: Props) => { const AccordionItem = (props: Props) => {
const { channel, isSelected, onPress, mb } = props const { channel, selected, onPress } = props
const { emoji, title, channelStatus = 'normal', numberOfMessages } = channel
const { emoji, title, channelStatus = 'normal', unreadCount } = channel
return ( return (
<Stack <Stack
@ -34,7 +34,7 @@ const AccordionItem = (props: Props) => {
}, },
}, },
]} ]}
backgroundColor={isSelected ? '$primary-50-opa-10' : 'transparent'} backgroundColor={selected ? '$primary-50-opa-10' : 'transparent'}
hoverStyle={{ hoverStyle={{
backgroundColor: '$primary-50-opa-5', backgroundColor: '$primary-50-opa-5',
}} }}
@ -51,7 +51,6 @@ const AccordionItem = (props: Props) => {
flexDirection="row" flexDirection="row"
cursor="pointer" cursor="pointer"
onPress={onPress} onPress={onPress}
mb={mb}
> >
<Stack <Stack
justifyContent="flex-start" justifyContent="flex-start"
@ -91,7 +90,7 @@ const AccordionItem = (props: Props) => {
alignItems="center" alignItems="center"
> >
<Label color="$white-100" weight="medium"> <Label color="$white-100" weight="medium">
{numberOfMessages} {unreadCount}
</Label> </Label>
</Stack> </Stack>
</Stack> </Stack>

View File

@ -18,12 +18,28 @@ export const Default: Story = {
src: 'https://images.unsplash.com/photo-1518020382113-a7e8fc38eac9?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=500&h=500&q=80', src: 'https://images.unsplash.com/photo-1518020382113-a7e8fc38eac9?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=500&h=500&q=80',
}, },
render: args => ( render: args => (
<Stack space flexDirection="row">
<Stack space> <Stack space>
<Avatar {...args} size={80} />
<Avatar {...args} size={56} /> <Avatar {...args} size={56} />
<Avatar {...args} size={52} />
<Avatar {...args} size={48} /> <Avatar {...args} size={48} />
<Avatar {...args} size={32} /> <Avatar {...args} size={32} />
<Avatar {...args} size={28} />
<Avatar {...args} size={24} />
<Avatar {...args} size={20} /> <Avatar {...args} size={20} />
<Avatar {...args} size={16} />
</Stack>
<Stack space>
<Avatar {...args} size={80} indicator="online" />
<Avatar {...args} size={56} indicator="online" />
<Avatar {...args} size={48} indicator="online" />
<Avatar {...args} size={32} indicator="online" />
<Avatar {...args} size={28} indicator="online" />
<Avatar {...args} size={24} indicator="online" />
<Avatar {...args} size={20} indicator="online" />
<Avatar {...args} size={16} indicator="online" />
</Stack>
</Stack> </Stack>
), ),
} }
@ -31,14 +47,18 @@ export const Default: Story = {
export const Rounded: Story = { export const Rounded: Story = {
args: { args: {
src: 'https://images.unsplash.com/photo-1518020382113-a7e8fc38eac9?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=500&h=500&q=80', src: 'https://images.unsplash.com/photo-1518020382113-a7e8fc38eac9?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=500&h=500&q=80',
shape: 'rounded',
}, },
render: args => ( render: args => (
<Stack space> <Stack space>
<Avatar {...args} size={56} shape="rounded" /> <Avatar {...args} size={80} />
<Avatar {...args} size={52} shape="rounded" /> <Avatar {...args} size={56} />
<Avatar {...args} size={48} shape="rounded" /> <Avatar {...args} size={48} />
<Avatar {...args} size={32} shape="rounded" /> <Avatar {...args} size={32} />
<Avatar {...args} size={20} shape="rounded" /> <Avatar {...args} size={28} />
<Avatar {...args} size={24} />
<Avatar {...args} size={20} />
<Avatar {...args} size={16} />
</Stack> </Stack>
), ),
} }

View File

@ -4,160 +4,28 @@ import { Stack, styled, Text, Unspaced } from '@tamagui/core'
import { Image } from '../image' import { Image } from '../image'
import type { GetProps } from '@tamagui/core' import type { GetStyledVariants } from '@tamagui/core'
// import { Button as RNButton } from 'react-native' type Variants = GetStyledVariants<typeof Base>
// setupReactNative({ Button: RNButton }) type Props = {
// import type { GetProps} from '@tamagui/core';
const Base = styled(Stack, {
name: 'Avatar',
display: 'flex',
position: 'relative',
backgroundColor: '$white-100',
justifyContent: 'center',
alignItems: 'center',
variants: {
size: {
80: {
width: 80,
height: 80,
borderRadius: 80 / 2,
},
56: {
width: 56,
height: 56,
borderRadius: 56 / 2,
},
52: {
width: 52,
height: 52,
borderRadius: 52 / 2,
},
48: {
width: 48,
height: 48,
borderRadius: 48 / 2,
},
32: {
width: 32,
height: 32,
borderRadius: 32 / 2,
},
20: {
width: 20,
height: 20,
borderRadius: 20 / 2,
},
},
shape: {
circle: {},
rounded: {
borderRadius: 16,
},
},
withOutline: {
true: {
borderWidth: 2,
borderColor: '$white-100',
},
},
} as const,
})
const Indicator = styled(Stack, {
name: 'Indicator',
position: 'absolute',
bottom: 2,
right: 2,
zIndex: 2,
borderWidth: 2,
borderColor: '$white-100',
variants: {
size: {
80: {
width: 10,
height: 10,
borderRadius: 10 / 2,
},
56: {
width: 10,
height: 10,
borderRadius: 10 / 2,
},
// FIXME: use catch all variant
52: {
width: 12,
height: 12,
borderRadius: 12 / 2,
},
48: {
width: 10,
height: 10,
borderRadius: 10 / 2,
right: 0,
bottom: 0,
},
32: {
width: 10,
height: 10,
borderRadius: 10 / 2,
right: 0,
bottom: 0,
},
20: {
width: 10,
height: 10,
borderRadius: 10 / 2,
right: 0,
bottom: 0,
},
},
state: {
online: {
backgroundColor: '$success-50',
},
offline: {
backgroundColor: '$neutral-40',
},
},
shape: {
circle: {},
rounded: {
borderRadius: 16,
},
},
} as const,
})
const Fallback = styled(Text, {
name: 'AvatarFallback',
})
type BaseProps = GetProps<typeof Base>
interface Props {
src: string src: string
size: NonNullable<BaseProps['size']> size: 80 | 56 | 48 | 32 | 28 | 24 | 20 | 16
indicator?: 'online' | 'offline' shape?: Variants['shape']
shape?: 'circle' | 'rounded' outline?: Variants['outline']
withOutline?: boolean indicator?: GetStyledVariants<typeof Indicator>['state']
} }
type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error' type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error'
const Avatar = (props: Props) => { const Avatar = (props: Props) => {
const { src, size, shape = 'circle', withOutline, indicator } = props const {
src,
size,
shape = 'circle',
outline = false,
indicator = 'none',
} = props
const [status, setStatus] = useState<ImageLoadingStatus>('idle') const [status, setStatus] = useState<ImageLoadingStatus>('idle')
@ -166,17 +34,16 @@ const Avatar = (props: Props) => {
}, [src]) }, [src])
return ( return (
<Base size={size} shape={shape} withOutline={withOutline}> <Base size={size} shape={shape} outline={outline}>
{indicator && ( {indicator !== 'none' && (
<Unspaced> <Unspaced>
<Indicator size={size} state={indicator} /> <Indicator size={size} state={indicator} />
</Unspaced> </Unspaced>
)} )}
<Shape shape={shape}>
<Image <Image
src={src} src={src}
width="full" width="full"
radius="full"
aspectRatio={1} aspectRatio={1}
onLoad={() => setStatus('loaded')} onLoad={() => setStatus('loaded')}
onError={() => setStatus('error')} onError={() => setStatus('error')}
@ -193,9 +60,137 @@ const Avatar = (props: Props) => {
PP PP
</Fallback> </Fallback>
)} )}
</Shape>
</Base> </Base>
) )
} }
export { Avatar } export { Avatar }
export type { Props as AvatarProps } export type { Props as AvatarProps }
const Base = styled(Stack, {
name: 'Avatar',
position: 'relative',
justifyContent: 'center',
alignItems: 'center',
variants: {
// defined in Avatar props
size: {
'...': (size: number) => {
return {
width: size,
height: size,
}
},
},
shape: {
circle: {
borderRadius: 80, // big enough to cover all sizes
},
rounded: {
borderRadius: 16,
},
},
outline: {
true: {
borderWidth: 2,
borderColor: '$white-100',
},
},
} as const,
})
const Shape = styled(Stack, {
name: 'AvatarShape',
width: '100%',
height: '100%',
backgroundColor: '$white-100',
overflow: 'hidden',
variants: {
shape: {
circle: {
borderRadius: 80, // big enough to cover all sizes
},
rounded: {
borderRadius: 16,
},
},
},
})
const Indicator = styled(Stack, {
name: 'AvatarIndicator',
position: 'absolute',
zIndex: 2,
borderWidth: 2,
borderColor: '$white-100',
borderRadius: 10,
variants: {
size: {
80: {
width: 16,
height: 16,
bottom: 4,
right: 4,
},
56: {
width: 12,
height: 12,
bottom: 2,
right: 2,
},
48: {
width: 12,
height: 12,
right: 0,
bottom: 0,
},
32: {
width: 12,
height: 12,
right: -2,
bottom: -2,
},
28: {
width: 12,
height: 12,
right: -2,
bottom: -2,
},
24: {
width: 12,
height: 12,
right: -2,
bottom: -2,
},
20: {
display: 'none',
},
16: {
display: 'none',
},
},
state: {
none: {},
online: {
backgroundColor: '$success-50',
},
offline: {
backgroundColor: '$neutral-40',
},
},
} as const,
})
const Fallback = styled(Text, {
name: 'AvatarFallback',
})

View File

@ -58,13 +58,6 @@ export const PrimaryDisabled: Story = {
}, },
} }
export const PrimaryFullWidth: Story = {
args: {
children: 'Click me',
width: 'full',
},
}
export const Primary32: Story = { export const Primary32: Story = {
name: 'Primary / 32', name: 'Primary / 32',
args: { args: {
@ -103,30 +96,38 @@ export const PrimaryIconOnly: Story = {
}, },
} }
export const PrimaryIconOnlyCirlce: Story = {
name: 'Primary/Icon only/Circle',
args: {
icon,
shape: 'circle',
},
}
export const Success: Story = { export const Success: Story = {
args: { args: {
type: 'positive', variant: 'positive',
children: 'Click me', children: 'Click me',
}, },
} }
export const Outline: Story = { export const Outline: Story = {
args: { args: {
type: 'outline', variant: 'outline',
children: 'Click me', children: 'Click me',
}, },
} }
export const Ghost: Story = { export const Ghost: Story = {
args: { args: {
type: 'ghost', variant: 'ghost',
children: 'Click me', children: 'Click me',
}, },
} }
export const Danger: Story = { export const Danger: Story = {
args: { args: {
type: 'danger', variant: 'danger',
children: 'Click me', children: 'Click me',
}, },
} }

View File

@ -1,12 +1,73 @@
import { forwardRef } from 'react' import { cloneElement, forwardRef } from 'react'
import { Stack, styled } from '@tamagui/core' import { Stack, styled } from '@tamagui/core'
import { Paragraph } from '../typography' import { Paragraph } from '../typography'
import type { GetProps } from '@tamagui/core' import type { GetVariants, MapVariant, PressableProps } from '../types'
import type { StackProps } from '@tamagui/core'
import type { Ref } from 'react' import type { Ref } from 'react'
type Variants = GetVariants<typeof Base>
type Props = PressableProps & {
variant?: Variants['variant']
size?: Variants['size']
shape?: 'default' | 'circle'
children?: string
icon?: React.ReactElement
iconAfter?: React.ReactElement
disabled?: boolean
}
const textColors: MapVariant<typeof Base, 'variant'> = {
primary: '$white-100',
positive: '$white-100',
grey: '$neutral-100',
darkGrey: '$neutral-100',
outline: '$neutral-100',
ghost: '$neutral-100',
danger: '$white-100',
}
const Button = (props: Props, ref: Ref<HTMLButtonElement>) => {
const {
variant = 'primary',
shape = 'default',
size = 40,
icon = null,
iconAfter = null,
children,
...buttonProps
} = props
// TODO: provider aria-label if button has only icon
const iconOnly = !children && Boolean(icon)
const textColor = textColors[variant]
return (
<Base
{...(buttonProps as unknown as StackProps)} // TODO: Tamagui has incorrect types for PressableProps
ref={ref}
variant={variant}
radius={shape === 'circle' ? 'full' : size}
size={size}
iconOnly={iconOnly}
>
{icon ? cloneElement(icon, { color: textColor }) : null}
<ButtonText color={textColor} size={size}>
{children}
</ButtonText>
{iconAfter ? cloneElement(iconAfter, { color: textColor }) : null}
</Base>
)
}
const _Button = forwardRef(Button)
export { _Button as Button }
export type { Props as ButtonProps }
const Base = styled(Stack, { const Base = styled(Stack, {
tag: 'button', tag: 'button',
name: 'Button', name: 'Button',
@ -16,26 +77,24 @@ const Base = styled(Stack, {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingHorizontal: 16,
paddingTop: 7,
paddingBottom: 9,
cursor: 'pointer', cursor: 'pointer',
userSelect: 'none', userSelect: 'none',
animation: 'fast',
borderWidth: 1, borderWidth: 1,
borderColor: 'transparent', borderColor: 'transparent',
animation: 'fast',
variants: { variants: {
type: { variant: {
primary: { primary: {
backgroundColor: '$primary', backgroundColor: '$primary-50',
hoverStyle: { backgroundColor: '$primaryHover' }, hoverStyle: { backgroundColor: '$primary-60' },
pressStyle: { backgroundColor: '$primaryHover' }, // TODO: update background color
pressStyle: { backgroundColor: '$primary-50' },
}, },
positive: { positive: {
backgroundColor: '$success-50', backgroundColor: '$success-50',
hoverStyle: { backgroundColor: '$success-60' }, hoverStyle: { backgroundColor: '$success-60' },
// TODO: update background color
pressStyle: { backgroundColor: '$success-50' }, pressStyle: { backgroundColor: '$success-50' },
}, },
grey: { grey: {
@ -51,7 +110,7 @@ const Base = styled(Stack, {
outline: { outline: {
borderWidth: 1, borderWidth: 1,
borderColor: '$neutral-30', borderColor: '$neutral-30',
hoverStyle: { borderColor: '$neutral-30' }, hoverStyle: { borderColor: '$neutral-40' },
pressStyle: { borderColor: '$neutral-50' }, pressStyle: { borderColor: '$neutral-50' },
}, },
ghost: { ghost: {
@ -62,6 +121,7 @@ const Base = styled(Stack, {
danger: { danger: {
backgroundColor: '$danger', backgroundColor: '$danger',
hoverStyle: { backgroundColor: '$danger-60' }, hoverStyle: { backgroundColor: '$danger-60' },
// TODO: update background color
pressStyle: { backgroundColor: '$danger' }, pressStyle: { backgroundColor: '$danger' },
}, },
}, },
@ -75,32 +135,41 @@ const Base = styled(Stack, {
size: { size: {
40: { 40: {
minHeight: 40, height: 40,
borderRadius: 12,
paddingHorizontal: 16, paddingHorizontal: 16,
paddingTop: 7, gap: 4,
paddingBottom: 9,
}, },
32: { 32: {
minHeight: 32, height: 32,
borderRadius: 10, paddingHorizontal: 12,
paddingHorizontal: 16, gap: 4,
paddingTop: 4,
paddingBottom: 6,
}, },
24: { 24: {
minHeight: 24, height: 24,
borderRadius: 8,
paddingHorizontal: 8, paddingHorizontal: 8,
paddingTop: 2, },
paddingBottom: 4, },
radius: {
full: {
borderRadius: 40,
},
40: {
borderRadius: 12,
},
32: {
borderRadius: 10,
},
24: {
borderRadius: 8,
}, },
}, },
iconOnly: { iconOnly: {
true: { true: {
space: 0, gap: 0,
paddingHorizontal: 8, padding: 0,
aspectRatio: 1,
}, },
}, },
} as const, } as const,
@ -113,30 +182,6 @@ const ButtonText = styled(Paragraph, {
weight: 'medium', weight: 'medium',
variants: { variants: {
type: {
primary: {
color: '$white-100',
},
positive: {
color: '$white-100',
},
grey: {
color: '$neutral-100',
},
darkGrey: {
color: '$neutral-100',
},
outline: {
color: '$neutral-100',
},
ghost: {
color: '$neutral-100',
},
danger: {
color: '$white-100',
},
},
size: { size: {
40: { 40: {
variant: 'normal', variant: 'normal',
@ -150,42 +195,3 @@ const ButtonText = styled(Paragraph, {
}, },
} as const, } as const,
}) })
type BaseProps = GetProps<typeof Base>
type Props = BaseProps & {
children?: string
type?: BaseProps['type']
size?: BaseProps['size']
disabled?: boolean
icon?: React.ReactNode
iconAfter?: React.ReactNode
}
const Button = (props: Props, ref: Ref<HTMLButtonElement>) => {
const {
type = 'primary',
size = 40,
children,
icon,
iconAfter,
...rest
} = props
const iconOnly = !children && Boolean(icon)
return (
<Base {...rest} ref={ref} type={type} size={size} iconOnly={iconOnly}>
<ButtonText type={type} size={size}>
{icon}
{children}
{iconAfter}
</ButtonText>
</Base>
)
}
const _Button = forwardRef(Button)
export { _Button as Button }
export type { Props as ButtonProps }

View File

@ -10,7 +10,7 @@ import {
ReactionIcon, ReactionIcon,
} from '@status-im/icons/20' } from '@status-im/icons/20'
import { BlurView } from 'expo-blur' import { BlurView } from 'expo-blur'
import { AnimatePresence, Stack, XStack, YStack } from 'tamagui' import { AnimatePresence, Stack, XStack } from 'tamagui'
import { Button } from '../button' import { Button } from '../button'
import { IconButton } from '../icon-button' import { IconButton } from '../icon-button'
@ -18,6 +18,7 @@ import { Image } from '../image'
import { Input } from '../input' import { Input } from '../input'
import { useChatDispatch, useChatState } from '../provider' import { useChatDispatch, useChatState } from '../provider'
import { Reply } from '../reply' import { Reply } from '../reply'
import { Shadow } from '../shadow'
interface Props { interface Props {
blur?: boolean blur?: boolean
@ -43,6 +44,8 @@ const Composer = (props: Props) => {
const chatState = useChatState() const chatState = useChatState()
const chatDispatch = useChatDispatch() const chatDispatch = useChatDispatch()
const showSendButton = text !== '' || imagesData.length > 0
return ( return (
<BlurView <BlurView
intensity={40} intensity={40}
@ -51,20 +54,16 @@ const Composer = (props: Props) => {
width: '100%', width: '100%',
}} }}
> >
<YStack <Shadow
variant={iconButtonBlurred ? 'none' : '$2'}
inverted
animation="fast" animation="fast"
backgroundColor={iconButtonBlurred ? '$blurBackground' : '$background'} backgroundColor={iconButtonBlurred ? '$blurBackground' : '$background'}
shadowColor={iconButtonBlurred ? 'none' : 'rgba(9, 16, 28, 0.08)'}
shadowOffset={{ width: 4, height: iconButtonBlurred ? 0 : 4 }}
shadowRadius={20}
borderTopLeftRadius={20} borderTopLeftRadius={20}
borderTopRightRadius={20} borderTopRightRadius={20}
px={16} px={16}
width="100%" width="100%"
py={12} py={12}
style={{
elevation: 10,
}}
> >
{chatState?.type === 'reply' && ( {chatState?.type === 'reply' && (
<Stack paddingLeft={4} paddingBottom={4}> <Stack paddingLeft={4} paddingBottom={4}>
@ -169,42 +168,38 @@ const Composer = (props: Props) => {
variant="outline" variant="outline"
icon={<ImageIcon />} icon={<ImageIcon />}
disabled={isImageUploadDisabled} disabled={isImageUploadDisabled}
blurred={iconButtonBlurred} blur={iconButtonBlurred}
/> />
</label> </label>
<IconButton <IconButton
variant="outline" variant="outline"
icon={<ReactionIcon />} icon={<ReactionIcon />}
blurred={iconButtonBlurred} blur={iconButtonBlurred}
/> />
<IconButton <IconButton
variant="outline" variant="outline"
icon={<FormatIcon />} icon={<FormatIcon />}
disabled disabled
blurred={iconButtonBlurred} blur={iconButtonBlurred}
/> />
</Stack> </Stack>
{text || imagesData.length > 0 ? ( {showSendButton ? (
// TODO fix styles for circular button. Also the color is different from the design and we have layout shift because of the size.
<Button <Button
variant="primary"
shape="circle"
icon={<ArrowUpIcon />} icon={<ArrowUpIcon />}
height={32}
size={32} size={32}
width={32}
borderRadius={32}
justifyContent="center"
alignItems="center"
type="positive"
/> />
) : ( ) : (
<IconButton <Button
variant="outline" variant="outline"
icon={<AudioIcon />} icon={<AudioIcon />}
blurred={iconButtonBlurred} size={32}
// blurred={iconButtonBlurred}
/> />
)} )}
</XStack> </XStack>
</YStack> </Shadow>
</BlurView> </BlurView>
) )
} }

View File

@ -0,0 +1,69 @@
import { useState } from 'react'
import type { PressableProps } from '../types'
import type { ColorTokens } from 'tamagui'
type Config = {
default: ColorTokens
hover: ColorTokens
press: ColorTokens
active: ColorTokens
}
type Return = {
color: ColorTokens
pressableProps: Pick<
PressableProps,
'onHoverIn' | 'onHoverOut' | 'onPressIn' | 'onPressOut'
>
}
export const usePressableColors = (
styles: Config,
props: Partial<PressableProps> & {
'aria-expanded'?: boolean
'aria-selected'?: boolean
selected?: boolean
}
): Return => {
const [hovered, setHovered] = useState(false)
const [pressed, setPressed] = useState(false)
/**
* Order of precedence:
* 1. active
* 2. press
* 3. hover
* 4. default
*/
const key =
props['aria-expanded'] || props['aria-selected']
? 'active'
: pressed
? 'press'
: hovered
? 'hover'
: 'default'
return {
color: styles[key],
pressableProps: {
onHoverIn: event => {
props.onHoverIn?.(event)
setHovered(true)
},
onHoverOut: event => {
props.onHoverOut?.(event)
setHovered(false)
},
onPressIn: event => {
props.onPressIn?.(event)
setPressed(true)
},
onPressOut: event => {
props.onPressOut?.(event)
setPressed(false)
},
} as const,
}
}

View File

@ -1,4 +1,5 @@
import { OptionsIcon } from '@status-im/icons/20' import { OptionsIcon } from '@status-im/icons/20'
import { Stack } from 'tamagui'
import { IconButton } from './icon-button' import { IconButton } from './icon-button'
@ -8,6 +9,12 @@ import type { Meta, StoryObj } from '@storybook/react'
const meta: Meta<typeof IconButton> = { const meta: Meta<typeof IconButton> = {
component: IconButton, component: IconButton,
argTypes: {}, argTypes: {},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/IBmFKgGL1B4GzqD8LQTw6n/Design-System-for-Desktop%2FWeb?node-id=10466-128996&t=GxddSvW99WvZQY0A-11',
},
},
} }
type Story = StoryObj<typeof IconButton> type Story = StoryObj<typeof IconButton>
@ -17,6 +24,29 @@ export const Default: Story = {
args: { args: {
icon: <OptionsIcon />, icon: <OptionsIcon />,
}, },
render: args => {
return (
<Stack space backgroundColor="" padding={40}>
<Stack flexDirection="row" gap={8}>
<IconButton {...args} variant="default" />
<IconButton {...args} variant="outline" selected />
<IconButton {...args} variant="ghost" />
<IconButton {...args} variant="default" aria-selected />
<IconButton {...args} variant="outline" aria-selected />
<IconButton {...args} variant="ghost" aria-selected />
</Stack>
<Stack flexDirection="row" gap={8}>
<IconButton {...args} variant="default" blur />
<IconButton {...args} variant="outline" blur />
<IconButton {...args} variant="ghost" blur />
<IconButton {...args} variant="default" blur aria-selected />
<IconButton {...args} variant="outline" blur aria-selected />
<IconButton {...args} variant="ghost" blur aria-selected />
</Stack>
</Stack>
)
},
} }
export default meta export default meta

View File

@ -1,110 +1,20 @@
import { cloneElement, forwardRef } from 'react' import { cloneElement, forwardRef } from 'react'
import { Stack, styled } from '@tamagui/core' import { Stack, styled } from 'tamagui'
import { usePressableColors } from '../hooks/use-pressable-colors'
import type { GetVariants, PressableProps } from '../types'
import type { StackProps } from '@tamagui/core'
import type { Ref } from 'react' import type { Ref } from 'react'
const Base = styled(Stack, { type Variants = GetVariants<typeof Base>
name: 'IconButton',
accessibilityRole: 'button',
cursor: 'pointer', type Props = PressableProps & {
userSelect: 'none',
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
animation: 'fast',
width: 30,
height: 30,
borderWidth: 1,
padding: 4,
variants: {
variant: {
default: {
backgroundColor: '$iconButtonBackground',
borderColor: 'transparent',
hoverStyle: {
backgroundColor: '$iconButtonBackgroundHover',
},
pressStyle: {
backgroundColor: '$iconButtonBackgroundHover',
},
},
outline: {
backgroundColor: 'transparent',
borderColor: '$iconButtonOutlineBorder',
hoverStyle: {
borderColor: '$iconButtonOutlineBorderHover',
},
pressStyle: {
borderColor: '$iconButtonOutlineBorderHover',
},
},
},
blurred: {
default: {
backgroundColor: '$iconButtonBackgroundBlurred',
hoverStyle: {
backgroundColor: '$iconButtonBackgroundBlurredHover',
},
pressStyle: {
backgroundColor: 'iconButtonBackgroundBlurredHover',
},
},
outline: {
borderColor: '$iconButtonOutlineBorderBlurred',
hoverStyle: {
borderColor: '$iconButtonOutlineBorderBlurredHover',
},
pressStyle: {
borderColor: '$iconButtonOutlineBorderBlurredHover',
},
},
},
selected: {
default: {
backgroundColor: '$iconButtonBackgroundSelected',
borderColor: '$iconButtonBorderSelected',
},
defaultWithBlur: {
backgroundColor: '$iconButtonBackgroundBlurredSelected',
borderColor: '$iconButtonBorderBlurredSelected',
},
outline: {
backgroundColor: '$iconButtonOutlineBackgroundSelected',
borderColor: '$iconButtonOutlineBorderSelected',
},
outlineWithBlur: {
backgroundColor: '$iconButtonOutBackgroundBlurredSelected',
borderColor: '$iconButtonOutlineBorderBlurredSelected',
},
},
disabled: {
true: {
opacity: 0.3,
cursor: 'default',
},
},
} as const,
})
interface Props {
icon: React.ReactElement icon: React.ReactElement
onPress?: () => void variant?: Variants['variant']
selected?: boolean selected?: boolean
blurred?: boolean blur?: boolean
variant?: 'default' | 'outline'
disabled?: boolean disabled?: boolean
// FIXME: enforce aria-label for accessibility // FIXME: enforce aria-label for accessibility
// 'aria-label'?: string // 'aria-label'?: string
@ -113,80 +23,34 @@ interface Props {
'aria-selected'?: boolean 'aria-selected'?: boolean
} }
const iconColor = {
default: {
default: '$iconButtonColor',
defaultBlurred: '$iconButtonColorBlurred',
selected: '$iconButtonColorSelected',
selectedBlurred: '$iconButtonColorBlurred',
},
outline: {
default: '$iconButtonColorOutline',
defaultBlurred: '$iconButtonColorOutlineBlurred',
selected: '$iconButtonColorOutlineSelected',
selectedBlurred: '$iconButtonColorOutlineBlurred',
},
}
const getStateForIconColor = ({
blurred,
selected,
}: {
blurred?: boolean
selected?: boolean
}) => {
if (!selected && blurred) {
return 'defaultBlurred'
}
if (selected && blurred) {
return 'selectedBlurred'
}
if (selected && !blurred) {
return 'selected'
}
return 'default'
}
const getSelectedVariant = ({
selected,
blurred,
variant,
}: {
selected?: boolean
blurred?: boolean
variant?: 'default' | 'outline'
}) => {
if (!selected) {
return undefined
}
if (blurred && variant === 'default') {
return 'defaultWithBlur'
}
if (blurred && variant === 'outline') {
return 'outlineWithBlur'
}
return variant
}
const IconButton = (props: Props, ref: Ref<HTMLButtonElement>) => { const IconButton = (props: Props, ref: Ref<HTMLButtonElement>) => {
const { icon, blurred, variant = 'default', ...rest } = props const { icon, blur, variant = 'default', ...buttonProps } = props
const { pressableProps, color } = usePressableColors(
{
default: blur ? '$neutral-80-opa-70' : '$neutral-50',
hover: blur ? '$neutral-80-opa-70' : '$neutral-50',
press: '$neutral-100',
active: '$neutral-100',
},
props
)
const selected = const selected =
props.selected || props['aria-expanded'] || props['aria-selected'] props.selected || props['aria-expanded'] || props['aria-selected']
const state = getStateForIconColor({ blurred, selected })
const selectedVariant = getSelectedVariant({ selected, variant, blurred })
return ( return (
<Base <Base
{...rest} {...(buttonProps as unknown as StackProps)} // TODO: Tamagui has incorrect types for PressableProps
{...(pressableProps as unknown as StackProps)} // TODO: Tamagui has incorrect types for PressableProps
ref={ref} ref={ref}
variant={variant} variant={blur ? undefined : variant}
selected={selectedVariant} active={blur ? undefined : selected ? variant : undefined}
blurred={blurred ? variant : undefined} variantBlur={blur ? variant : undefined}
activeBlur={blur ? (selected ? variant : undefined) : undefined}
> >
{cloneElement(icon, { {cloneElement(icon, {
color: iconColor[variant][state], color,
size: 20, size: 20,
})} })}
</Base> </Base>
@ -197,3 +61,127 @@ const _IconButton = forwardRef(IconButton)
export { _IconButton as IconButton } export { _IconButton as IconButton }
export type { Props as IconButtonProps } export type { Props as IconButtonProps }
const Base = styled(Stack, {
name: 'IconButton',
tag: 'button',
accessibilityRole: 'button',
cursor: 'pointer',
userSelect: 'none',
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 4,
width: 32,
height: 32,
borderWidth: 1,
borderColor: 'transparent',
animation: 'fast',
variants: {
variant: {
default: {
backgroundColor: '$neutral-10',
borderColor: 'transparent',
hoverStyle: { backgroundColor: '$neutral-20' },
pressStyle: {
backgroundColor: '$neutral-20',
borderColor: '$neutral-30',
},
},
outline: {
backgroundColor: 'transparent',
borderColor: '$neutral-20',
hoverStyle: { borderColor: '$neutral-30' },
pressStyle: {
borderColor: '$neutral-20',
backgroundColor: '$neutral-10',
},
},
ghost: {
backgroundColor: 'transparent',
hoverStyle: { backgroundColor: '$neutral-10' },
pressStyle: {
backgroundColor: '$neutral-10',
borderColor: '$neutral-20',
},
},
},
active: {
default: {
backgroundColor: '$neutral-20',
borderColor: '$neutral-30',
},
outline: {
borderColor: '$neutral-20',
backgroundColor: '$neutral-10',
},
ghost: {
backgroundColor: '$neutral-10',
borderColor: '$neutral-20',
},
},
variantBlur: {
default: {
backgroundColor: '$neutral-80-opa-5',
borderColor: 'transparent',
hoverStyle: { backgroundColor: '$neutral-80-opa-10' },
pressStyle: {
backgroundColor: '$neutral-80-opa-10',
borderColor: '$neutral-80-opa-5',
},
},
outline: {
backgroundColor: 'transparent',
borderColor: '$neutral-80-opa-10',
hoverStyle: { borderColor: '$neutral-80-opa-20' },
pressStyle: {
borderColor: '$neutral-80-opa-10',
backgroundColor: '$neutral-80-opa-5',
},
},
ghost: {
backgroundColor: 'transparent',
hoverStyle: { backgroundColor: '$neutral-80-opa-5' },
pressStyle: {
backgroundColor: '$neutral-80-opa-5',
borderColor: '$neutral-80-opa-10',
},
},
},
activeBlur: {
default: {
backgroundColor: '$neutral-80-opa-10',
borderColor: '$neutral-80-opa-5',
},
outline: {
borderColor: '$neutral-80-opa-10',
backgroundColor: '$neutral-80-opa-5',
},
ghost: {
backgroundColor: '$neutral-80-opa-5',
borderColor: '$neutral-80-opa-10',
},
},
disabled: {
true: {
opacity: 0.3,
cursor: 'default',
},
},
} as const,
})

View File

@ -3,48 +3,24 @@ import { forwardRef } from 'react'
import { setupReactNative, styled } from '@tamagui/core' import { setupReactNative, styled } from '@tamagui/core'
import { Image as RNImage } from 'react-native' import { Image as RNImage } from 'react-native'
import type { GetProps } from '@tamagui/core' import type { GetProps, GetVariants } from '../types'
import type { Ref } from 'react' import type { Ref } from 'react'
import type { ImagePropsBase as RNImageProps } from 'react-native'
setupReactNative({ setupReactNative({
Image: RNImage, Image: RNImage,
}) })
const Base = styled(RNImage, { type Variants = GetVariants<typeof Base>
name: 'Image',
position: 'relative',
zIndex: 1,
source: {
uri: '',
},
variants: { type Props = GetProps<typeof Base> & {
radius: {
12: {
borderRadius: 12,
},
full: {
borderRadius: 9999,
},
},
},
})
type ImageProps = GetProps<typeof Base>
interface Props {
src: string src: string
width: number | 'full' width: number | 'full'
height?: number height?: number
aspectRatio?: ImageProps['aspectRatio'] radius?: Variants['radius']
radius?: ImageProps['radius']
onLoad?: RNImageProps['onLoad']
onError?: RNImageProps['onError']
} }
const Image = (props: Props, ref: Ref<HTMLImageElement>) => { const Image = (props: Props, ref: Ref<HTMLImageElement>) => {
const { src, aspectRatio, radius, ...rest } = props const { src, radius = 'none', aspectRatio, ...imageProps } = props
const width = props.width === 'full' ? '100%' : props.width const width = props.width === 'full' ? '100%' : props.width
const height = aspectRatio ? undefined : props.height const height = aspectRatio ? undefined : props.height
@ -56,19 +32,39 @@ const Image = (props: Props, ref: Ref<HTMLImageElement>) => {
return ( return (
<Base <Base
{...rest} {...imageProps}
ref={ref} ref={ref}
source={source} source={source}
width={width} width={width}
height={height} height={height}
radius={radius}
aspectRatio={aspectRatio} aspectRatio={aspectRatio}
radius={radius}
/> />
) )
} }
// TODO?: this was used in @tamagui/image package. Why? const _Image = forwardRef(Image)
// focusableInputHOC(Image)
const _Image = Base.extractable(forwardRef(Image))
export { _Image as Image } export { _Image as Image }
export type { Props as ImageProps }
const Base = styled(RNImage, {
name: 'Image',
position: 'relative',
zIndex: 1,
source: {
uri: '',
},
variants: {
radius: {
none: {},
12: {
borderRadius: 12,
},
full: {
borderRadius: 9999,
},
},
},
})

View File

@ -11,10 +11,10 @@ import {
PinIcon, PinIcon,
ReplyIcon, ReplyIcon,
} from '@status-im/icons/20' } from '@status-im/icons/20'
import { Stack } from 'tamagui'
import { DropdownMenu } from '../../dropdown-menu' import { DropdownMenu } from '../../dropdown-menu'
import { IconButton } from '../../icon-button' import { IconButton } from '../../icon-button'
import { Shadow } from '../../shadow'
import { ReactionPopover } from './reaction-popover' import { ReactionPopover } from './reaction-popover'
import type { ReactionsType } from '../types' import type { ReactionsType } from '../types'
@ -35,21 +35,19 @@ export const Actions = (props: Props) => {
}, [onOpenChange]) }, [onOpenChange])
return ( return (
<Stack <Shadow
backgroundColor="$white-100" variant="$1"
borderWidth={1}
borderColor="$neutral-10"
borderRadius={12}
padding={2}
space={2}
overflow="hidden" overflow="hidden"
position="absolute" position="absolute"
top={-16} top={-8}
right={0} right={8}
borderRadius={12}
borderWidth={1}
borderColor="$neutral-10"
backgroundColor="$white-100"
padding={2}
space={2}
flexDirection="row" flexDirection="row"
shadowRadius={20}
shadowOffset={{ width: 0, height: 4 }}
shadowColor="rgba(9, 16, 28, 0.08)"
zIndex={10} zIndex={10}
> >
{/* REACTION */} {/* REACTION */}
@ -59,29 +57,25 @@ export const Actions = (props: Props) => {
sideOffset={6} sideOffset={6}
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
> >
<IconButton variant="outline" icon={<AddReactionIcon />} /> <IconButton variant="ghost" icon={<AddReactionIcon />} />
</ReactionPopover> </ReactionPopover>
{/* REPLY */} {/* REPLY */}
<IconButton <IconButton variant="ghost" icon={<ReplyIcon />} onPress={onReplyPress} />
variant="outline"
icon={<ReplyIcon />}
onPress={onReplyPress}
/>
{/* EDIT */} {/* EDIT */}
<IconButton variant="outline" icon={<EditIcon />} onPress={onEditPress} /> <IconButton variant="ghost" icon={<EditIcon />} onPress={onEditPress} />
{/* DELETE */} {/* DELETE */}
{/* <IconButton {/* <IconButton
variant="outline" variant="ghost"
icon={<DeleteIcon />} icon={<DeleteIcon />}
onPress={onDeletePress} onPress={onDeletePress}
/> */} /> */}
{/* OPTIONS MENU */} {/* OPTIONS MENU */}
<DropdownMenu modal={false} onOpenChange={onOpenChange}> <DropdownMenu modal={false} onOpenChange={onOpenChange}>
<IconButton variant="outline" icon={<OptionsIcon />} /> <IconButton variant="ghost" icon={<OptionsIcon />} />
<DropdownMenu.Content align="end" sideOffset={10}> <DropdownMenu.Content align="end" sideOffset={10}>
<DropdownMenu.Item <DropdownMenu.Item
icon={<EditIcon />} icon={<EditIcon />}
@ -124,6 +118,6 @@ export const Actions = (props: Props) => {
/> />
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu> </DropdownMenu>
</Stack> </Shadow>
) )
} }

View File

@ -1,10 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { PinIcon } from '@status-im/icons/16' import { PinIcon } from '@status-im/icons/16'
import { View } from 'react-native'
import { Stack, styled, Unspaced, XStack, YStack } from 'tamagui' import { Stack, styled, Unspaced, XStack, YStack } from 'tamagui'
import { Author } from '../author/author' import { Author } from '../author'
import { Avatar } from '../avatar' import { Avatar } from '../avatar'
import { Image } from '../image' import { Image } from '../image'
import { useChatDispatch } from '../provider' import { useChatDispatch } from '../provider'
@ -23,7 +22,7 @@ interface Props {
pinned?: boolean pinned?: boolean
} }
const Wrapper = styled(View, { const Base = styled(Stack, {
position: 'relative', position: 'relative',
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 8, paddingVertical: 8,
@ -36,6 +35,7 @@ const Wrapper = styled(View, {
backgroundColor: '$neutral-5', backgroundColor: '$neutral-5',
}, },
}, },
pinned: { pinned: {
true: { true: {
backgroundColor: '$blue-50-opa-5', backgroundColor: '$blue-50-opa-5',
@ -48,15 +48,16 @@ const Message = (props: Props) => {
const { text, images, reactions, reply, pinned } = props const { text, images, reactions, reply, pinned } = props
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
const [actionsOpen, setActionsOpen] = useState(false) const [showActions, setShowActions] = useState(false)
const active = actionsOpen || hovered const active = showActions || hovered
const hasReactions = Object.keys(reactions).length > 0
// <Sheet press="long"> // <Sheet press="long">
const dispatch = useChatDispatch() const dispatch = useChatDispatch()
return ( return (
<Wrapper <Base
active={active} active={active}
onHoverIn={() => setHovered(true)} onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)} onHoverOut={() => setHovered(false)}
@ -65,7 +66,7 @@ const Message = (props: Props) => {
<Unspaced> <Unspaced>
<Actions <Actions
reactions={reactions} reactions={reactions}
onOpenChange={setActionsOpen} onOpenChange={setShowActions}
onReplyPress={() => dispatch({ type: 'reply', messageId: '1' })} onReplyPress={() => dispatch({ type: 'reply', messageId: '1' })}
onEditPress={() => dispatch({ type: 'edit', messageId: '1' })} onEditPress={() => dispatch({ type: 'edit', messageId: '1' })}
/> />
@ -113,7 +114,12 @@ const Message = (props: Props) => {
/> />
{text && ( {text && (
<Paragraph flexGrow={0} weight="regular" color="$neutral-100"> <Paragraph
flexGrow={0}
weight="regular"
color="$neutral-100"
userSelect="text"
>
{text} {text}
</Paragraph> </Paragraph>
)} )}
@ -130,14 +136,14 @@ const Message = (props: Props) => {
</Stack> </Stack>
))} ))}
{reactions && ( {hasReactions && (
<Stack paddingTop={8}> <Stack paddingTop={8}>
<Reactions reactions={reactions} /> <Reactions reactions={reactions} />
</Stack> </Stack>
)} )}
</YStack> </YStack>
</XStack> </XStack>
</Wrapper> </Base>
) )
} }

View File

@ -23,7 +23,7 @@ export const Default: Story = {
args: {}, args: {},
render: args => ( render: args => (
<Popover {...args}> <Popover {...args}>
<Button type="primary">Trigger</Button> <Button variant="primary">Trigger</Button>
<Popover.Content>some content</Popover.Content> <Popover.Content>some content</Popover.Content>
</Popover> </Popover>
), ),

View File

@ -13,10 +13,72 @@ import { Stack, styled } from '@tamagui/core'
import { Paragraph } from '../typography' import { Paragraph } from '../typography'
import type { GetProps } from '@tamagui/core' import type { GetVariants } from '../types'
import type { StackProps } from '@tamagui/core'
import type { Ref } from 'react' import type { Ref } from 'react'
import type { PressableProps } from 'react-native' import type { PressableProps } from 'react-native'
type Variants = GetVariants<typeof Button>
export const REACTIONS = {
love: LoveIcon,
laugh: LaughIcon,
'thumbs-up': ThumbsUpIcon,
'thumbs-down': ThumbsDownIcon,
sad: SadIcon,
angry: AngryIcon,
add: AddReactionIcon,
} as const
type Props = PressableProps & {
icon: keyof typeof REACTIONS
variant?: Variants['variant']
size?: Variants['size']
// FIXME: use aria-selected
selected?: boolean
count?: number
// FIXME: update to latest RN
'aria-expanded'?: boolean
'aria-selected'?: boolean
}
const ReactButton = (props: Props, ref: Ref<HTMLButtonElement>) => {
const {
icon,
variant = 'outline',
size = 40,
count,
...pressableProps
} = props
const Icon = REACTIONS[icon]
const selected =
props.selected || props['aria-expanded'] || props['aria-selected']
return (
<Button
{...(pressableProps as unknown as StackProps)} // TODO: Tamagui has incorrect types for PressableProps
ref={ref}
variant={variant}
size={size}
selected={selected}
>
<Icon color="$neutral-100" />
{count && (
<Paragraph weight="medium" variant="smaller" whiteSpace="nowrap">
{count}
</Paragraph>
)}
</Button>
)
}
const _ReactButton = forwardRef(ReactButton)
export { _ReactButton as ReactButton }
export type { Props as ReactButtonProps }
const Button = styled(Stack, { const Button = styled(Stack, {
name: 'ReactButton', name: 'ReactButton',
accessibilityRole: 'button', accessibilityRole: 'button',
@ -78,64 +140,3 @@ const Button = styled(Stack, {
}, },
} as const, } as const,
}) })
type ButtonProps = GetProps<typeof Button>
export const REACTIONS = {
love: LoveIcon,
laugh: LaughIcon,
'thumbs-up': ThumbsUpIcon,
'thumbs-down': ThumbsDownIcon,
sad: SadIcon,
angry: AngryIcon,
add: AddReactionIcon,
} as const
interface Props extends PressableProps {
icon: keyof typeof REACTIONS
variant?: ButtonProps['variant']
size?: ButtonProps['size']
// FIXME: use aria-selected
selected?: boolean
count?: number
// FIXME: update to latest RN
'aria-expanded'?: boolean
'aria-selected'?: boolean
}
const ReactButton = (props: Props, ref: Ref<HTMLButtonElement>) => {
const {
icon,
variant = 'outline',
size = 40,
count,
...pressableProps
} = props
const Icon = REACTIONS[icon]
const selected =
props.selected || props['aria-expanded'] || props['aria-selected']
return (
<Button
{...(pressableProps as any)}
ref={ref}
variant={variant}
size={size}
selected={selected}
>
<Icon color="$neutral-100" />
{count && (
<Paragraph weight="medium" variant="smaller" whiteSpace="nowrap">
{count}
</Paragraph>
)}
</Button>
)
}
const _ReactButton = forwardRef(ReactButton)
export { _ReactButton as ReactButton }
export type { Props as ReactButtonProps }

View File

@ -27,7 +27,7 @@ const Reply = (props: Props) => {
</Stack> </Stack>
</Unspaced> </Unspaced>
<Avatar size={20} src={src} /> <Avatar size={16} src={src} />
<Paragraph variant="smaller" weight="semibold" color="$neutral-100"> <Paragraph variant="smaller" weight="semibold" color="$neutral-100">
{name} {name}
@ -64,12 +64,11 @@ const Reply = (props: Props) => {
> >
{content} {content}
{/* FIXME: This should be regular button with size 24 */}
{onClose && ( {onClose && (
<Button <Button
type="outline"
size={24}
icon={<CloseIcon />} icon={<CloseIcon />}
variant="outline"
size={24}
onPress={onClose} onPress={onClose}
/> />
)} )}

View File

@ -0,0 +1 @@
export { Shadow, type ShadowProps } from './shadow'

View File

@ -0,0 +1,61 @@
import { Stack } from 'tamagui'
import { Shadow } from './shadow'
import type { Meta, StoryObj } from '@storybook/react'
const meta: Meta<typeof Shadow> = {
component: Shadow,
argTypes: {},
args: {},
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/v98g9ZiaSHYUdKWrbFg9eM/Foundations?node-id=624-752&t=ppNe6QC4ntgNciqw-11',
},
},
}
export default meta
type Story = StoryObj<typeof Shadow>
export const Default: Story = {
args: {},
render: () => (
<Stack gap={40} flexDirection="row">
<Shadow
variant="$1"
width={50}
height={50}
borderWidth={1}
borderColor="$neutral-20"
borderRadius={12}
/>
<Shadow
variant="$2"
width={50}
height={50}
borderWidth={1}
borderColor="$neutral-20"
borderRadius={12}
/>
<Shadow
variant="$3"
width={50}
height={50}
borderWidth={1}
borderColor="$neutral-20"
borderRadius={12}
/>
<Shadow
variant="$4"
width={50}
height={50}
borderWidth={1}
borderColor="$neutral-20"
borderRadius={12}
/>
</Stack>
),
}

View File

@ -0,0 +1,97 @@
import { forwardRef } from 'react'
import { Stack, styled } from 'tamagui'
import type { GetVariants } from '../types'
import type { StackProps } from '@tamagui/core'
import type { Ref } from 'react'
import type { View } from 'react-native'
type Variants = GetVariants<typeof Base>
type Props = StackProps & {
variant?: Variants['variant']
inverted?: boolean
}
const Shadow = (props: Props, ref: Ref<View>) => {
const { variant = '$1', inverted = false, ...stackProps } = props
return (
<Base
{...stackProps}
ref={ref}
variant={inverted ? undefined : variant}
inverted={inverted ? variant : undefined}
/>
)
}
const _Shadow = forwardRef(Shadow)
export { _Shadow as Shadow }
export type { Props as ShadowProps }
const Base = styled(Stack, {
variants: {
variant: {
$1: {
// box-shadow: 0px 2px 20px 0px hsla(218, 51%, 7%, 0.04);
shadowOffset: { width: 0, height: 2 },
shadowRadius: 20,
shadowColor: 'hsla(218, 51%, 7%, 0.04)',
},
$2: {
// box-shadow: 0px 4px 20px 0px hsla(218, 51%, 7%, 0.08);
shadowOffset: { width: 0, height: 4 },
shadowRadius: 20,
shadowColor: 'hsla(218, 51%, 7%, 0.08)',
},
$3: {
// box-shadow: 0px 8px 30px 0px hsla(218, 51%, 7%, 0.12);
shadowOffset: { width: 0, height: 8 },
shadowRadius: 30,
shadowColor: 'hsla(218, 51%, 7%, 0.12)',
},
$4: {
// box-shadow: 0px 12px 56px 0px hsla(218, 51%, 7%, 0.16);
shadowOffset: { width: 0, height: 12 },
shadowRadius: 56,
shadowColor: 'hsla(218, 51%, 7%, 0.16)',
},
none: {},
},
inverted: {
$1: {
// box-shadow: 0px -2px 20px 0px hsla(218, 51%, 7%, 0.04);
shadowOffset: { width: 0, height: -2 },
shadowRadius: 20,
shadowColor: 'hsla(218, 51%, 7%, 0.04)',
},
$2: {
// box-shadow: 0px -4px 20px 0px hsla(218, 51%, 7%, 0.08);
shadowOffset: { width: 0, height: -4 },
shadowRadius: 20,
shadowColor: 'hsla(218, 51%, 7%, 0.08)',
},
$3: {
// box-shadow: 0px -8px 30px 0px hsla(218, 51%, 7%, 0.12);
shadowOffset: { width: 0, height: -8 },
shadowRadius: 30,
shadowColor: 'hsla(218, 51%, 7%, 0.12)',
},
$4: {
// box-shadow: 0px 12px 56px 0px hsla(218, 51%, 7%, 0.16);
shadowOffset: { width: 0, height: 12 },
shadowRadius: 56,
shadowColor: 'hsla(218, 51%, 7%, 0.16)',
},
none: {},
},
} as const,
defaultVariants: {
variant: '$1',
},
})

View File

@ -4,7 +4,7 @@ export interface Channel {
description: string description: string
emoji: string emoji: string
channelStatus?: 'muted' | 'normal' | 'withMessages' | 'withMentions' channelStatus?: 'muted' | 'normal' | 'withMessages' | 'withMentions'
numberOfMessages?: number unreadCount?: number
} }
export interface ChannelGroup { export interface ChannelGroup {
@ -49,7 +49,7 @@ export const CHANNEL_GROUPS: ChannelGroup[] = [
emoji: randomEmoji(), emoji: randomEmoji(),
description: 'Share random funny stuff with the community. Play nice.', description: 'Share random funny stuff with the community. Play nice.',
channelStatus: 'withMentions', channelStatus: 'withMentions',
numberOfMessages: 3, unreadCount: 3,
}, },
], ],
}, },
@ -68,7 +68,7 @@ export const CHANNEL_GROUPS: ChannelGroup[] = [
id: 'jobs', id: 'jobs',
title: '# jobs', title: '# jobs',
channelStatus: 'withMentions', channelStatus: 'withMentions',
numberOfMessages: 3, unreadCount: 3,
emoji: randomEmoji(), emoji: randomEmoji(),
description: 'Share random funny stuff with the community. Play nice.', description: 'Share random funny stuff with the community. Play nice.',
}, },
@ -76,7 +76,7 @@ export const CHANNEL_GROUPS: ChannelGroup[] = [
id: 'events', id: 'events',
title: '# events', title: '# events',
channelStatus: 'withMentions', channelStatus: 'withMentions',
numberOfMessages: 2, unreadCount: 2,
emoji: randomEmoji(), emoji: randomEmoji(),
description: 'Share random funny stuff with the community. Play nice.', description: 'Share random funny stuff with the community. Play nice.',
}, },

View File

@ -53,7 +53,7 @@ const Sidebar = (props: Props) => {
<Stack paddingHorizontal={16} paddingBottom={16}> <Stack paddingHorizontal={16} paddingBottom={16}>
<Stack marginTop={-40} marginBottom={12}> <Stack marginTop={-40} marginBottom={12}>
<Avatar <Avatar
withOutline outline
src="https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fi.seadn.io%2Fgae%2FFG0QJ00fN3c_FWuPeUr9-T__iQl63j9hn5d6svW8UqOmia5zp3lKHPkJuHcvhZ0f_Pd6P2COo9tt9zVUvdPxG_9BBw%3Fw%3D500%26auto%3Dformat&f=1&nofb=1&ipt=c177cd71d8d0114080cfc6efd3f9e098ddaeb1b347919bd3089bf0aacb003b3e&ipo=images" src="https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fi.seadn.io%2Fgae%2FFG0QJ00fN3c_FWuPeUr9-T__iQl63j9hn5d6svW8UqOmia5zp3lKHPkJuHcvhZ0f_Pd6P2COo9tt9zVUvdPxG_9BBw%3Fw%3D500%26auto%3Dformat&f=1&nofb=1&ipt=c177cd71d8d0114080cfc6efd3f9e098ddaeb1b347919bd3089bf0aacb003b3e&ipo=images"
size={80} size={80}
/> />
@ -72,18 +72,15 @@ const Sidebar = (props: Props) => {
key={group.id} key={group.id}
initialExpanded={group.id === 'welcome'} initialExpanded={group.id === 'welcome'}
title={group.title} title={group.title}
numberOfNewMessages={group.unreadCount} unreadCount={group.unreadCount}
> >
{group.channels.map((channel, index) => { {group.channels.map(channel => {
const isLastChannelOfTheList = index === group.channels.length - 1
return ( return (
<AccordionItem <AccordionItem
key={channel.id} key={channel.id}
channel={channel} channel={channel}
isSelected={selectedChannelId === channel.id} selected={selectedChannelId === channel.id}
onPress={() => onChannelPress(channel.id)} onPress={() => onChannelPress(channel.id)}
mb={isLastChannelOfTheList ? 8 : 0}
/> />
) )
})} })}

View File

@ -22,7 +22,7 @@ export const Default: Story = {
}, },
render: args => ( render: args => (
<Tooltip {...args}> <Tooltip {...args}>
<Button type="outline">Trigger</Button> <Button variant="outline">Trigger</Button>
</Tooltip> </Tooltip>
), ),
} }

View File

@ -51,7 +51,7 @@ const Topbar = (props: Props) => {
<IconButton <IconButton
icon={<ArrowLeftIcon />} icon={<ArrowLeftIcon />}
onPress={() => goBack?.()} onPress={() => goBack?.()}
blurred={blur} blur={blur}
/> />
</Stack> </Stack>
@ -100,7 +100,7 @@ const Topbar = (props: Props) => {
icon={<MembersIcon />} icon={<MembersIcon />}
selected={membersVisisble} selected={membersVisisble}
onPress={onMembersPress} onPress={onMembersPress}
blurred={blur} blur={blur}
/> />
</Stack> </Stack>

View File

@ -0,0 +1,35 @@
import type {
ColorTokens,
GetBaseProps,
GetProps,
GetStyledVariants,
TamaguiComponent,
} from '@tamagui/core'
import type { PressableProps as NativePressableProps } from 'react-native'
type PressableProps = {
onHoverIn?: Exclude<NativePressableProps['onHoverIn'], null>
onHoverOut?: NativePressableProps['onHoverOut']
onPress?: NativePressableProps['onPress']
onPressIn?: NativePressableProps['onPressIn']
onPressOut?: NativePressableProps['onPressOut']
onLongPress?: NativePressableProps['onLongPress']
delayHoverIn?: NativePressableProps['delayHoverIn']
delayHoverOut?: NativePressableProps['delayHoverOut']
delayLongPress?: NativePressableProps['delayLongPress']
disabled?: NativePressableProps['disabled']
}
export type MapVariant<
C extends TamaguiComponent,
K extends keyof GetStyledVariants<C>,
V extends GetStyledVariants<C> = GetStyledVariants<C>
> = {
[key in V[K] & string]: ColorTokens
}
export type GetVariants<A extends TamaguiComponent> = Required<
GetStyledVariants<A>
>
export type { GetBaseProps, GetProps, PressableProps }