This commit is contained in:
Eric Bailey
2024-01-13 12:11:25 -06:00
parent 45c807b83b
commit ceef55041a
32 changed files with 729 additions and 525 deletions
+434
View File
@@ -0,0 +1,434 @@
import React from 'react'
import {
Pressable,
Text,
PressableProps,
TextProps,
ViewStyle,
AccessibilityProps,
} from 'react-native'
import {useTheme, atoms, tokens, web, native} from '#/alf'
export type ButtonVariant = 'solid' | 'outline' | 'ghost'
export type ButtonColor = 'primary' | 'secondary' | 'negative'
export type ButtonSize = 'small' | 'large'
export type VariantProps = {
/**
* The style variation of the button
*/
variant?: ButtonVariant
/**
* The color of the button
*/
color?: ButtonColor
/**
* The size of the button
*/
size?: ButtonSize
}
export type ButtonProps = Omit<
PressableProps,
'children' | 'style' | 'accessibilityLabel' | 'accessibilityHint'
> &
VariantProps & {
children:
| ((props: {
state: {
pressed: boolean
hovered: boolean
focused: boolean
}
props: VariantProps & {
disabled?: boolean
}
}) => React.ReactNode)
| React.ReactNode
| string
accessibilityLabel: Required<AccessibilityProps>['accessibilityLabel']
accessibilityHint: Required<AccessibilityProps>['accessibilityHint']
}
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
export function Button({
children,
variant,
color,
size,
accessibilityLabel,
accessibilityHint,
disabled = false,
...rest
}: ButtonProps) {
const t = useTheme()
const [state, setState] = React.useState({
pressed: false,
hovered: false,
focused: false,
})
const onPressIn = React.useCallback(() => {
setState(s => ({
...s,
pressed: true,
}))
}, [setState])
const onPressOut = React.useCallback(() => {
setState(s => ({
...s,
pressed: false,
}))
}, [setState])
const onHoverIn = React.useCallback(() => {
setState(s => ({
...s,
hovered: true,
}))
}, [setState])
const onHoverOut = React.useCallback(() => {
setState(s => ({
...s,
hovered: false,
}))
}, [setState])
const onFocus = React.useCallback(() => {
setState(s => ({
...s,
focused: true,
}))
}, [setState])
const onBlur = React.useCallback(() => {
setState(s => ({
...s,
focused: false,
}))
}, [setState])
const {baseStyles, hoverStyles} = React.useMemo(() => {
const baseStyles: ViewStyle[] = []
const hoverStyles: ViewStyle[] = []
const light = t.name === 'light'
if (color === 'primary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.primary_500,
})
hoverStyles.push({
backgroundColor: t.palette.primary_600,
})
} else {
baseStyles.push({
backgroundColor: t.palette.primary_700,
})
}
} else if (variant === 'outline') {
baseStyles.push(atoms.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(atoms.border, {
borderColor: tokens.color.blue_500,
})
hoverStyles.push(atoms.border, {
backgroundColor: light
? t.palette.primary_100
: t.palette.primary_900,
})
} else {
baseStyles.push(atoms.border, {
borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? t.palette.primary_100
: t.palette.primary_900,
})
}
}
} else if (color === 'secondary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: light
? tokens.color.gray_200
: tokens.color.gray_800,
})
hoverStyles.push({
backgroundColor: light
? tokens.color.gray_300
: tokens.color.gray_900,
})
} else {
baseStyles.push({
backgroundColor: light
? tokens.color.gray_300
: tokens.color.gray_900,
})
}
} else if (variant === 'outline') {
baseStyles.push(atoms.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(atoms.border, {
borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500,
})
hoverStyles.push(atoms.border, t.atoms.bg_contrast_50)
} else {
baseStyles.push(atoms.border, {
borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light
? tokens.color.gray_100
: tokens.color.gray_800,
})
}
}
} else if (color === 'negative') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
backgroundColor: t.palette.negative_500,
})
hoverStyles.push({
backgroundColor: t.palette.negative_600,
})
} else {
baseStyles.push({
backgroundColor: t.palette.negative_700,
})
}
} else if (variant === 'outline') {
baseStyles.push(atoms.border, t.atoms.bg, {
borderWidth: 1,
})
if (!disabled) {
baseStyles.push(atoms.border, {
borderColor: t.palette.negative_600,
})
hoverStyles.push(atoms.border, {
backgroundColor: light ? t.palette.negative_50 : '#2D0614', // darker red
})
} else {
baseStyles.push(atoms.border, {
borderColor: light
? t.palette.negative_200
: t.palette.negative_900,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push(t.atoms.bg)
hoverStyles.push({
backgroundColor: light ? t.palette.negative_50 : '#2D0614', // darker red
})
}
}
}
if (size === 'large') {
baseStyles.push(
{paddingVertical: 15},
atoms.px_2xl,
atoms.rounded_sm,
atoms.gap_sm,
)
} else if (size === 'small') {
baseStyles.push(
{paddingVertical: 9},
atoms.px_md,
atoms.rounded_sm,
atoms.gap_xs,
)
}
return {
baseStyles,
hoverStyles,
}
}, [t, variant, color, size, disabled])
const childProps = React.useMemo(
() => ({
state,
props: {
variant,
color,
size,
disabled: disabled || false,
},
}),
[state, variant, color, size, disabled],
)
return (
<Pressable
role="button"
{...rest}
aria-label={accessibilityLabel}
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
disabled={disabled || false}
accessibilityState={{
disabled: disabled || false,
}}
style={[
atoms.flex_row,
atoms.align_center,
...baseStyles,
...(state.hovered ? hoverStyles : []),
]}
onPressIn={onPressIn}
onPressOut={onPressOut}
onHoverIn={onHoverIn}
onHoverOut={onHoverOut}
onFocus={onFocus}
onBlur={onBlur}>
{typeof children === 'string' ? (
<ButtonText
variant={variant}
color={color}
size={size}
disabled={disabled || false}>
{children}
</ButtonText>
) : typeof children === 'function' ? (
children(childProps)
) : (
children
)}
</Pressable>
)
}
export function ButtonText({
children,
style,
variant,
color,
size,
disabled,
...rest
}: ButtonTextProps) {
const t = useTheme()
const textStyles = React.useMemo(() => {
const baseStyles = []
const light = t.name === 'light'
if (color === 'primary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({color: t.palette.white})
} else {
baseStyles.push({color: t.palette.white, opacity: 0.5})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({
color: light ? t.palette.primary_600 : t.palette.primary_500,
})
} else {
baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({color: t.palette.primary_600})
} else {
baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
}
}
} else if (color === 'secondary') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({
color: light ? tokens.color.gray_700 : tokens.color.gray_100,
})
} else {
baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_700,
})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({
color: light ? tokens.color.gray_600 : tokens.color.gray_300,
})
} else {
baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_700,
})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({
color: light ? tokens.color.gray_600 : tokens.color.gray_300,
})
} else {
baseStyles.push({
color: light ? tokens.color.gray_400 : tokens.color.gray_600,
})
}
}
} else if (color === 'negative') {
if (variant === 'solid') {
if (!disabled) {
baseStyles.push({color: t.palette.white})
} else {
baseStyles.push({color: t.palette.white, opacity: 0.5})
}
} else if (variant === 'outline') {
if (!disabled) {
baseStyles.push({color: t.palette.negative_500})
} else {
baseStyles.push({color: t.palette.negative_500, opacity: 0.5})
}
} else if (variant === 'ghost') {
if (!disabled) {
baseStyles.push({color: t.palette.negative_500})
} else {
baseStyles.push({color: t.palette.negative_500, opacity: 0.5})
}
}
}
if (size === 'large') {
baseStyles.push(
atoms.text_md,
web({paddingBottom: 1}),
native({marginTop: 2}),
)
} else {
baseStyles.push(
atoms.text_md,
web({paddingBottom: 1}),
native({marginTop: 2}),
)
}
return baseStyles
}, [t, variant, color, size, disabled])
return (
<Text
{...rest}
style={[atoms.font_bold, atoms.text_center, ...textStyles, style]}>
{children}
</Text>
)
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react'
import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types'
export const Context = React.createContext<DialogContextProps>({
close: () => {},
})
export function useDialogContext() {
return React.useContext(Context)
}
export function useDialogControl() {
const control = React.useRef<DialogControlProps>({
open: () => {},
close: () => {},
})
return {
ref: control,
open: () => control.current.open(),
close: () => control.current.close(),
}
}
+127
View File
@@ -0,0 +1,127 @@
import React, {useImperativeHandle} from 'react'
import {View, Dimensions} from 'react-native'
import BottomSheet, {BottomSheetBackdrop} from '@gorhom/bottom-sheet'
import {useTheme, atoms as a} from '#/alf'
import {Portal} from '#/components/Portal'
import {
DialogOuterProps,
DialogControlProps,
DialogInnerProps,
} from '#/components/Dialog/types'
import {Context} from '#/components/Dialog/context'
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
export function Outer({
children,
control,
onClose,
nativeOptions,
}: React.PropsWithChildren<DialogOuterProps>) {
const t = useTheme()
const sheet = React.useRef<BottomSheet>(null)
const open = React.useCallback<DialogControlProps['open']>((i = 0) => {
sheet.current?.snapToIndex(i)
}, [])
const close = React.useCallback(() => {
sheet.current?.close()
onClose?.()
}, [onClose])
useImperativeHandle(
control.ref,
() => ({
open,
close,
}),
[open, close],
)
const context = React.useMemo(() => ({close}), [close])
return (
<Portal>
<BottomSheet
snapPoints={['90%']}
enablePanDownToClose
keyboardBehavior="extend"
android_keyboardInputMode="adjustResize"
{...(nativeOptions?.sheet || {})}
ref={sheet}
index={-1}
backgroundStyle={{backgroundColor: 'transparent'}}
backdropComponent={props => (
<BottomSheetBackdrop
appearsOnIndex={0}
disappearsOnIndex={-1}
{...props}
/>
)}
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
handleStyle={{display: 'none'}}
onClose={onClose}>
<Context.Provider value={context}>
<View
style={[
a.absolute,
a.inset_0,
t.atoms.bg,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
height: Dimensions.get('window').height * 2,
},
]}
/>
{children}
</Context.Provider>
</BottomSheet>
</Portal>
)
}
// TODO a11y props here, or is that handled by the sheet?
export function Inner(props: DialogInnerProps) {
return (
<View
style={[
a.p_lg,
a.pt_3xl,
{
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
},
]}>
{props.children}
</View>
)
}
export function Handle() {
const t = useTheme()
return (
<View
style={[
a.absolute,
a.rounded_sm,
a.z_10,
t.atoms.bg_contrast_200,
{
top: 12,
width: 50,
height: 6,
alignSelf: 'center',
},
]}
/>
)
}
export function Close() {
return null
}
+196
View File
@@ -0,0 +1,196 @@
import React, {useImperativeHandle} from 'react'
import {View, TouchableWithoutFeedback} from 'react-native'
import {FocusScope} from '@tamagui/focus-scope'
import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated'
import {useTheme, atoms as a, useBreakpoints, web} from '#/alf'
import {Text} from '#/components/Typography'
import {Portal} from '#/components/Portal'
import {Button} from '#/components/Button'
import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
import {Context, useDialogContext} from '#/components/Dialog/context'
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export * from '#/components/Dialog/types'
const stopPropagation = (e: any) => e.stopPropagation()
export function Outer({
control,
onClose,
children,
}: React.PropsWithChildren<DialogOuterProps>) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
const [isOpen, setIsOpen] = React.useState(false)
const [isVisible, setIsVisible] = React.useState(true)
const open = React.useCallback(() => {
setIsOpen(true)
}, [setIsOpen])
const close = React.useCallback(async () => {
setIsVisible(false)
await new Promise(resolve => setTimeout(resolve, 150))
setIsOpen(false)
setIsVisible(true)
onClose?.()
}, [onClose, setIsOpen])
useImperativeHandle(
control.ref,
() => ({
open,
close,
}),
[open, close],
)
React.useEffect(() => {
if (!isOpen) return
function handler(e: KeyboardEvent) {
if (e.key === 'Escape') close()
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [isOpen, close])
const context = React.useMemo(
() => ({
close,
}),
[close],
)
return (
<>
{isOpen && (
<Portal>
<Context.Provider value={context}>
<TouchableWithoutFeedback
accessibilityRole="button"
onPress={close}>
<View
style={[
web(a.fixed),
a.inset_0,
a.z_10,
a.align_center,
gtMobile ? a.p_lg : a.p_md,
{overflowY: 'auto'},
]}>
{isVisible && (
<Animated.View
entering={FadeIn.duration(150)}
// exiting={FadeOut.duration(150)}
style={[
web(a.fixed),
a.inset_0,
t.atoms.bg_contrast_300,
{opacity: 0.8},
]}
/>
)}
<View
style={[
a.w_full,
a.z_20,
a.justify_center,
a.align_center,
{
minHeight: web('calc(90vh - 36px)') || undefined,
},
]}>
{isVisible ? children : null}
</View>
</View>
</TouchableWithoutFeedback>
</Context.Provider>
</Portal>
)}
</>
)
}
export function Inner({
children,
style,
accessibilityLabelledBy,
accessibilityDescribedBy,
}: DialogInnerProps) {
const t = useTheme()
const {gtMobile} = useBreakpoints()
return (
<FocusScope loop enabled trapped>
<Animated.View
aria-labelledby={accessibilityLabelledBy}
aria-describedby={accessibilityDescribedBy}
aria-role="dialog"
// @ts-ignore web only -prf
onClick={stopPropagation}
onStartShouldSetResponder={_ => true}
onTouchEnd={stopPropagation}
entering={FadeInDown.duration(100)}
// exiting={FadeOut.duration(100)}
style={[
a.relative,
a.rounded_md,
a.w_full,
a.border,
gtMobile ? a.p_xl : a.p_lg,
t.atoms.bg,
{maxWidth: 600, borderColor: t.palette.contrast_300},
...(Array.isArray(style) ? style : [style || {}]),
]}>
{children}
</Animated.View>
</FocusScope>
)
}
export function Handle() {
return null
}
export function Close() {
const t = useTheme()
const {close} = useDialogContext()
return (
<View
style={[
a.absolute,
a.z_10,
{
top: a.pt_lg.paddingTop,
right: a.pr_lg.paddingRight,
},
]}>
<Button
onPress={close}
accessibilityLabel="Close dialog"
accessibilityHint="Clicking this button will close the current dialog.">
{() => (
<View
style={[
a.justify_center,
a.align_center,
a.rounded_full,
t.atoms.bg_contrast_200,
{
pointerEvents: 'none',
height: 32,
width: 32,
},
]}>
<Text>X</Text>
</View>
)}
</Button>
</View>
)
}
+33
View File
@@ -0,0 +1,33 @@
import React from 'react'
import type {ViewStyle, AccessibilityProps} from 'react-native'
import {BottomSheetProps} from '@gorhom/bottom-sheet'
type A11yProps = Required<AccessibilityProps>
export type DialogContextProps = {
close: () => void
}
export type DialogControlProps = {
open: (index?: number) => void
close: () => void
}
export type DialogOuterProps = {
control: {
ref: React.RefObject<DialogControlProps>
open: (index?: number) => void
close: () => void
}
onClose?: () => void
nativeOptions?: {
sheet?: Omit<BottomSheetProps, 'children'>
}
webOptions?: {}
}
export type DialogInnerProps = React.PropsWithChildren<{
style?: ViewStyle
accessibilityLabelledBy: A11yProps['aria-labelledby']
accessibilityDescribedBy: string
}>
+172
View File
@@ -0,0 +1,172 @@
import React from 'react'
import {
Text,
TextStyle,
StyleProp,
GestureResponderEvent,
Linking,
} from 'react-native'
import {
useLinkProps,
useNavigation,
StackActions,
} from '@react-navigation/native'
import {sanitizeUrl} from '@braintree/sanitize-url'
import {isWeb} from '#/platform/detection'
import {useTheme, web} from '#/alf'
import {Button, ButtonProps} from '#/components/Button'
import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types'
import {
convertBskyAppUrlIfNeeded,
isExternalUrl,
linkRequiresWarning,
} from '#/lib/strings/url-helpers'
import {useModalControls} from '#/state/modals'
import {router} from '#/routes'
export type LinkProps = Omit<ButtonProps, 'style' | 'onPress' | 'disabled'> & {
/**
* `TextStyle` to apply to the anchor element itself. Does not apply to any children.
*/
style?: StyleProp<TextStyle>
/**
* The React Navigation `StackAction` to perform when the link is pressed.
*/
action?: 'push' | 'replace' | 'navigate'
/**
* If true, will warn the user if the link text does not match the href. Only
* works for Links with children that are strings i.e. text links.
*/
warnOnMismatchingTextChild?: boolean
} & Pick<Parameters<typeof useLinkProps<AllNavigatorParams>>[0], 'to'>
/**
* A interactive element that renders as a `<a>` tag on the web. On mobile it
* will translate the `href` to navigator screens and params and dispatch a
* navigation action.
*
* Intended to behave as a web anchor tag. For more complex routing, use a
* `Button`.
*/
export function Link({
children,
to,
style,
action = 'push',
warnOnMismatchingTextChild,
...rest
}: LinkProps) {
const t = useTheme()
const navigation = useNavigation<NavigationProp>()
const {href} = useLinkProps<AllNavigatorParams>({
to:
typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to,
})
const isExternal = isExternalUrl(href)
const {openModal, closeModal} = useModalControls()
const onPress = React.useCallback(
(e: GestureResponderEvent) => {
const label = typeof children === 'string' ? children : ''
const requiresWarning = Boolean(
warnOnMismatchingTextChild &&
label &&
isExternal &&
linkRequiresWarning(href, label),
)
if (requiresWarning) {
e.preventDefault()
openModal({
name: 'link-warning',
text: label,
href: href,
})
} else {
e.preventDefault()
if (isExternal) {
Linking.openURL(href)
} else {
/**
* A `GestureResponderEvent`, but cast to `any` to avoid using a bunch
* of @ts-ignore below.
*/
const event = e as any
const isMiddleClick = isWeb && event.button === 1
const isMetaKey =
isWeb &&
(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
const shouldOpenInNewTab = isMetaKey || isMiddleClick
if (
shouldOpenInNewTab ||
href.startsWith('http') ||
href.startsWith('mailto')
) {
Linking.openURL(href)
} else {
closeModal() // close any active modals
if (action === 'push') {
navigation.dispatch(StackActions.push(...router.matchPath(href)))
} else if (action === 'replace') {
navigation.dispatch(
StackActions.replace(...router.matchPath(href)),
)
} else if (action === 'navigate') {
// @ts-ignore
navigation.navigate(...router.matchPath(href))
} else {
throw Error('Unsupported navigator action.')
}
}
}
}
},
[
href,
isExternal,
warnOnMismatchingTextChild,
navigation,
action,
children,
closeModal,
openModal,
],
)
return (
<Button
{...rest}
role="link"
accessibilityRole="link"
href={href}
onPress={onPress}
{...web({
target: isExternal ? '_blank' : undefined,
rel: isExternal ? 'noopener noreferrer' : undefined,
dataSet: {
// default to no underline, apply this ourselves
noUnderline: '1',
},
})}>
{typeof children === 'string'
? ({state}) => (
<Text
style={[
style,
{color: t.palette.primary_500},
state.hovered && {
textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500,
},
]}>
{children as string}
</Text>
)
: children}
</Button>
)
}
+56
View File
@@ -0,0 +1,56 @@
import React from 'react'
type Component = React.ReactElement
type ContextType = {
outlet: Component | null
append(id: string, component: Component): void
remove(id: string): void
}
type ComponentMap = {
[id: string]: Component
}
export const Context = React.createContext<ContextType>({
outlet: null,
append: () => {},
remove: () => {},
})
export function Provider(props: React.PropsWithChildren<{}>) {
const map = React.useRef<ComponentMap>({})
const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
const append = React.useCallback<ContextType['append']>((id, component) => {
if (map.current[id]) return
map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
setOutlet(<>{Object.values(map.current)}</>)
}, [])
const remove = React.useCallback<ContextType['remove']>(id => {
delete map.current[id]
setOutlet(<>{Object.values(map.current)}</>)
}, [])
return (
<Context.Provider value={{outlet, append, remove}}>
{props.children}
</Context.Provider>
)
}
export function Outlet() {
const ctx = React.useContext(Context)
return ctx.outlet
}
export function Portal({children}: React.PropsWithChildren<{}>) {
const {append, remove} = React.useContext(Context)
const id = React.useId()
React.useEffect(() => {
append(id, children as Component)
return () => remove(id)
}, [id, children, append, remove])
return null
}
+136
View File
@@ -0,0 +1,136 @@
import React from 'react'
import {View, PressableProps, LayoutChangeEvent} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {useTheme, atoms as a} from '#/alf'
import {H2, P} from '#/components/Typography'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
export {useDialogControl as usePromptControl} from '#/components/Dialog'
const Context = React.createContext<{
titleId: string
descriptionId: string
}>({
titleId: '',
descriptionId: '',
})
export function Outer({
children,
control,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
}>) {
const insets = useSafeAreaInsets()
const titleId = React.useId()
const descriptionId = React.useId()
const [defaultSnapPoints, setDefaultSnapPoints] = React.useState<
string | number
>('25%')
const context = React.useMemo(
() => ({titleId, descriptionId}),
[titleId, descriptionId],
)
const measureDefaultSnapPoint = React.useCallback(
(e: LayoutChangeEvent) => {
setDefaultSnapPoints(e.nativeEvent.layout.height + insets.bottom + 50)
},
[insets, setDefaultSnapPoints],
)
return (
<Dialog.Outer
control={control}
nativeOptions={{
sheet: {
snapPoints: [defaultSnapPoints],
},
}}>
<Context.Provider value={context}>
<View onLayout={measureDefaultSnapPoint}>
<Dialog.Inner
accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId}
style={{width: 'auto', maxWidth: 400}}>
<Dialog.Handle />
{children}
</Dialog.Inner>
</View>
</Context.Provider>
</Dialog.Outer>
)
}
export function Title({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const {titleId} = React.useContext(Context)
return (
<H2
nativeID={titleId}
style={[a.font_bold, t.atoms.text_contrast_600, a.pb_sm]}>
{children}
</H2>
)
}
export function Description({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const {descriptionId} = React.useContext(Context)
return (
<P nativeID={descriptionId} style={[t.atoms.text, a.pb_lg]}>
{children}
</P>
)
}
export function Actions({children}: React.PropsWithChildren<{}>) {
return (
<View style={[a.w_full, a.flex_row, a.gap_sm, a.justify_end]}>
{children}
</View>
)
}
export function Cancel({
children,
}: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) {
const {close} = Dialog.useDialogContext()
return (
<Button
variant="solid"
color="secondary"
size="small"
accessibilityLabel="Cancel"
accessibilityHint="Cancel this action"
onPress={close}>
{children}
</Button>
)
}
export function Action({
children,
onPress,
}: React.PropsWithChildren<{onPress?: () => void}>) {
const {close} = Dialog.useDialogContext()
const handleOnPress = React.useCallback(() => {
close()
onPress?.()
}, [close, onPress])
return (
<Button
variant="solid"
color="primary"
size="small"
accessibilityLabel="Confirm"
accessibilityHint="Confirm this action"
onPress={handleOnPress}>
{children}
</Button>
)
}
+124
View File
@@ -0,0 +1,124 @@
import React from 'react'
import {Text as RNText, TextProps} from 'react-native'
import {useTheme, atoms, web, flatten} from '#/alf'
export function Text({style, ...rest}: TextProps) {
const t = useTheme()
return <RNText style={[atoms.text_sm, t.atoms.text, style]} {...rest} />
}
export function H1({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 1,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_5xl, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
export function H2({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 2,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_4xl, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
export function H3({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 3,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_3xl, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
export function H4({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 4,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_2xl, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
export function H5({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 5,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_xl, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
export function H6({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'heading',
'aria-level': 6,
}) || {}
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_lg, atoms.font_bold, t.atoms.text, flatten(style)]}
/>
)
}
export function P({style, ...rest}: TextProps) {
const t = useTheme()
const attr =
web({
role: 'paragraph',
}) || {}
const _style = flatten(style)
const lineHeight =
(_style?.lineHeight || atoms.text_md.lineHeight) *
atoms.leading_normal.lineHeight
return (
<RNText
{...attr}
{...rest}
style={[atoms.text_md, t.atoms.text, _style, {lineHeight}]}
/>
)
}
@@ -0,0 +1,161 @@
import React from 'react'
import {View, TextStyle, Pressable} from 'react-native'
import DateTimePicker, {
BaseProps as DateTimePickerProps,
} from '@react-native-community/datetimepicker'
import {Logo} from '#/view/icons/Logo'
import {useTheme, atoms, tokens} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {InputDateProps} from '#/components/forms/InputDate/types'
import {
localizeDate,
toSimpleDateString,
} from '#/components/forms/InputDate/utils'
export * as utils from '#/components/forms/InputDate/utils'
export function InputDate({
value: initialValue,
onChange,
testID,
label,
hasError,
accessibilityLabel,
accessibilityHint,
...props
}: InputDateProps) {
const labelId = React.useId()
const t = useTheme()
const [open, setOpen] = React.useState(false)
const [value, setValue] = React.useState(initialValue)
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {inputStyles, iconStyles} = React.useMemo(() => {
const input: TextStyle[] = [
{
paddingLeft: 40,
},
]
const icon: TextStyle[] = []
if (hasError) {
input.push({
borderColor: tokens.color.red_200,
})
icon.push({
color: tokens.color.red_400,
})
}
if (focused) {
input.push({
borderColor: t.atoms.border_contrast.borderColor,
})
if (hasError) {
input.push({
borderColor: tokens.color.red_500,
})
}
}
return {inputStyles: input, iconStyles: icon}
}, [t, focused, hasError])
const onChangeInternal = React.useCallback<
Required<DateTimePickerProps>['onChange']
>(
(_event, date) => {
setOpen(false)
if (date) {
const formatted = toSimpleDateString(date)
onChange(formatted)
setValue(formatted)
}
},
[onChange, setOpen, setValue],
)
return (
<View style={[atoms.relative, atoms.w_full]}>
{label && (
<Text
nativeID={labelId}
style={[
atoms.text_sm,
atoms.font_bold,
t.atoms.text_contrast_600,
atoms.mb_sm,
]}>
{label}
</Text>
)}
<Pressable
{...props}
aria-labelledby={labelId}
aria-label={label}
accessibilityLabelledBy={labelId}
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
onPress={() => setOpen(true)}
onFocus={onFocus}
onBlur={onBlur}
style={[
{
paddingTop: atoms.pt_md.paddingTop + 2,
},
atoms.w_full,
atoms.px_lg,
atoms.pb_md,
atoms.rounded_sm,
t.atoms.bg_contrast_100,
...inputStyles,
]}>
<Text style={[atoms.text_md, t.atoms.text]}>{localizeDate(value)}</Text>
</Pressable>
<View
style={[
atoms.absolute,
atoms.inset_0,
atoms.align_center,
atoms.justify_center,
atoms.pl_md,
{right: 'auto'},
]}>
<Logo
style={[
{color: t.atoms.border_contrast.borderColor},
{
width: 20,
pointerEvents: 'none',
},
...iconStyles,
]}
/>
</View>
{open && (
<DateTimePicker
testID={`${testID}-datepicker`}
mode="date"
timeZoneName={'Etc/UTC'}
display="spinner"
// @ts-ignore applies in iOS only -prf
themeVariant={t.name === 'dark' ? 'dark' : 'light'}
value={new Date(value)}
onChange={onChangeInternal}
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
aria-labelledby={labelId}
aria-label={label}
/>
)}
</View>
)
}
+75
View File
@@ -0,0 +1,75 @@
import React from 'react'
import {View} from 'react-native'
import DateTimePicker, {
DateTimePickerEvent,
} from '@react-native-community/datetimepicker'
import {useTheme, atoms} from '#/alf'
import {Text} from '#/components/Typography'
import {toSimpleDateString} from '#/components/forms/InputDate/utils'
import {InputDateProps} from '#/components/forms/InputDate/types'
export * as utils from '#/components/forms/InputDate/utils'
/**
* Date-only input. Accepts a date in the format YYYY-MM-DD, and reports date
* changes in the same format.
*
* For dates of unknown format, convert with the
* `utils.toSimpleDateString(Date)` export of this file.
*/
export function InputDate({
value: initialValue,
onChange,
testID,
label,
accessibilityLabel,
accessibilityHint,
}: InputDateProps) {
const labelId = React.useId()
const t = useTheme()
const [value, setValue] = React.useState(initialValue)
const onChangeInternal = React.useCallback(
(event: DateTimePickerEvent, date: Date | undefined) => {
if (date) {
const formatted = toSimpleDateString(date)
onChange(formatted)
setValue(formatted)
}
},
[onChange],
)
return (
<View style={[atoms.relative, atoms.w_full]}>
{label && (
<Text
nativeID={labelId}
style={[
atoms.text_sm,
atoms.font_bold,
t.atoms.text_contrast_600,
atoms.mb_sm,
]}>
{label}
</Text>
)}
<DateTimePicker
testID={`${testID}-datepicker`}
mode="date"
timeZoneName={'Etc/UTC'}
display="spinner"
// @ts-ignore applies in iOS only -prf
themeVariant={t.name === 'dark' ? 'dark' : 'light'}
value={new Date(value)}
onChange={onChangeInternal}
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
aria-labelledby={labelId}
aria-label={label}
/>
</View>
)
}
@@ -0,0 +1,152 @@
import React from 'react'
import {View, TextStyle} from 'react-native'
// @ts-ignore
import {unstable_createElement} from 'react-native-web'
import {Logo} from '#/view/icons/Logo'
import {useTheme, atoms, tokens} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {InputDateProps} from '#/components/forms/InputDate/types'
import {toSimpleDateString} from '#/components/forms/InputDate/utils'
export * as utils from '#/components/forms/InputDate/utils'
export function InputDate({
label,
hasError,
testID,
value: initialValue,
onChange,
accessibilityLabel,
accessibilityHint,
...props
}: InputDateProps) {
const labelId = React.useId()
const t = useTheme()
const [value, setValue] = React.useState<string>(initialValue)
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const {inputStyles, iconStyles} = React.useMemo(() => {
const input: TextStyle[] = [
{
paddingLeft: 40,
},
]
const icon: TextStyle[] = []
if (hasError) {
input.push({
borderColor: tokens.color.red_200,
})
icon.push({
color: tokens.color.red_400,
})
}
if (hovered || focused) {
input.push({
borderColor: t.atoms.border_contrast.borderColor,
})
if (hasError) {
input.push({
borderColor: tokens.color.red_500,
})
}
}
return {inputStyles: input, iconStyles: icon}
}, [t, hovered, focused, hasError])
const handleOnChange = React.useCallback(
(e: any) => {
const date = e.currentTarget.valueAsDate
if (date) {
const formatted = toSimpleDateString(date)
onChange(formatted)
setValue(formatted)
}
},
[onChange, setValue],
)
return (
<View style={[atoms.relative, atoms.w_full]}>
{label && (
<Text
nativeID={labelId}
style={[
atoms.text_sm,
atoms.font_bold,
t.atoms.text_contrast_600,
atoms.mb_sm,
]}>
{label}
</Text>
)}
{unstable_createElement('input', {
...props,
testID: `${testID}-datepicker`,
'aria-labelledby': labelId,
'aria-label': label,
accessibilityLabel: accessibilityLabel,
accessibilityHint: accessibilityHint,
type: 'date',
value: value,
onFocus: onFocus,
onBlur: onBlur,
onChange: handleOnChange,
onMouseEnter: onHoverIn,
onMouseLeave: onHoverOut,
style: [
{
outline: 0,
border: 0,
appearance: 'none',
boxSizing: 'border-box',
lineHeight: atoms.text_md.lineHeight * 1.1875,
paddingTop: atoms.pt_md.paddingTop - 1,
},
atoms.w_full,
atoms.px_lg,
atoms.pb_md,
atoms.rounded_sm,
atoms.text_md,
t.atoms.bg_contrast_100,
t.atoms.text,
...inputStyles,
],
})}
<View
style={[
atoms.absolute,
atoms.inset_0,
atoms.align_center,
atoms.justify_center,
atoms.pl_md,
{right: 'auto'},
]}>
<Logo
style={[
{color: t.atoms.border_contrast.borderColor},
{
width: 20,
pointerEvents: 'none',
},
...iconStyles,
]}
/>
</View>
</View>
)
}
+10
View File
@@ -0,0 +1,10 @@
import {TextInputProps} from 'react-native'
import {BaseProps} from '#/components/forms/types'
export type InputDateProps = BaseProps & {
/**
* **NOTE:** Available only on web
*/
autoFocus?: TextInputProps['autoFocus']
}
+16
View File
@@ -0,0 +1,16 @@
import {getLocales} from 'expo-localization'
const LOCALE = getLocales()[0]
// we need the date in the form yyyy-MM-dd to pass to the input
export function toSimpleDateString(date: Date | string): string {
const _date = typeof date === 'string' ? new Date(date) : date
return _date.toISOString().split('T')[0]
}
export function localizeDate(date: Date | string): string {
const _date = typeof date === 'string' ? new Date(date) : date
return new Intl.DateTimeFormat(LOCALE.languageTag, {
timeZone: 'UTC',
}).format(_date)
}
+37
View File
@@ -0,0 +1,37 @@
import React from 'react'
import {View} from 'react-native'
import {atoms} from '#/alf'
/**
* NOT FINISHED, just here as a reference
*/
export function InputGroup(props: React.PropsWithChildren<{}>) {
const children = React.Children.toArray(props.children)
const total = children.length
return (
<View style={[atoms.w_full]}>
{children.map((child, i) => {
return React.isValidElement(child) ? (
<React.Fragment key={i}>
{React.cloneElement(child, {
// @ts-ignore
style: [
...(Array.isArray(child.props?.style)
? child.props.style
: [child.props.style || {}]),
{
borderTopLeftRadius: i > 0 ? 0 : undefined,
borderTopRightRadius: i > 0 ? 0 : undefined,
borderBottomLeftRadius: i < total - 1 ? 0 : undefined,
borderBottomRightRadius: i < total - 1 ? 0 : undefined,
borderBottomWidth: i < total - 1 ? 0 : undefined,
},
],
})}
</React.Fragment>
) : null
})}
</View>
)
}
+185
View File
@@ -0,0 +1,185 @@
import React from 'react'
import {
View,
TextInput,
TextInputProps,
TextStyle,
LayoutChangeEvent,
} from 'react-native'
import {useTheme, atoms, web, tokens} from '#/alf'
import {Text} from '#/components/Typography'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {BaseProps} from '#/components/forms/types'
type Props = BaseProps &
Omit<TextInputProps, 'placeholder'> & {
placeholder: Required<TextInputProps>['placeholder']
icon?: React.FunctionComponent<any>
suffix?: React.FunctionComponent<any>
}
export function InputText({
value: initialValue,
onChange,
testID,
accessibilityLabel,
accessibilityHint,
label,
hasError,
icon: Icon,
suffix: Suffix,
...props
}: Props) {
const labelId = React.useId()
const t = useTheme()
const [value, setValue] = React.useState<string>(initialValue)
const [suffixPadding, setSuffixPadding] = React.useState(0)
const {
state: hovered,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
const handleSuffixLayout = React.useCallback(
(e: LayoutChangeEvent) => {
setSuffixPadding(e.nativeEvent.layout.width + 16)
},
[setSuffixPadding],
)
const {inputStyles, iconStyles} = React.useMemo(() => {
const input: TextStyle[] = []
const icon: TextStyle[] = []
if (Icon) {
input.push({
paddingLeft: 40,
})
}
if (hasError) {
input.push({
borderColor: tokens.color.red_200,
})
icon.push({
color: tokens.color.red_400,
})
}
if (hovered || focused) {
input.push({
borderColor: t.atoms.border_contrast.borderColor,
})
if (hasError) {
input.push({
borderColor: tokens.color.red_500,
})
}
}
return {inputStyles: input, iconStyles: icon}
}, [t, hovered, focused, hasError, Icon])
const handleOnChange = React.useCallback(
(e: any) => {
const value = e.currentTarget.value
onChange(value)
setValue(value)
},
[onChange, setValue],
)
return (
<View style={[atoms.relative, atoms.w_full]}>
{label && (
<Text
nativeID={labelId}
style={[
atoms.text_sm,
atoms.font_bold,
t.atoms.text_contrast_600,
atoms.mb_sm,
]}>
{label}
</Text>
)}
<TextInput
{...props}
value={value}
testID={testID}
aria-labelledby={labelId}
aria-label={label}
accessibilityLabel={accessibilityLabel}
accessibilityHint={accessibilityHint}
placeholderTextColor={t.atoms.text_contrast_500.color}
onFocus={onFocus}
onBlur={onBlur}
onChange={handleOnChange}
{...web({
onMouseEnter: onHoverIn,
onMouseLeave: onHoverOut,
})}
style={[
t.name === 'dark' ? t.atoms.bg_contrast_100 : t.atoms.bg,
atoms.w_full,
atoms.px_lg,
atoms.py_md,
atoms.rounded_sm,
atoms.text_md,
t.atoms.border,
t.atoms.text,
web({
paddingTop: atoms.pt_md.paddingTop - 1,
}),
{paddingRight: suffixPadding},
{borderWidth: 2, lineHeight: atoms.text_md.lineHeight * 1.1875},
...inputStyles,
...(Array.isArray(props.style) ? props.style : [props.style]),
]}
/>
{Icon && (
<View
style={[
atoms.absolute,
atoms.inset_0,
atoms.align_center,
atoms.justify_center,
atoms.pl_md,
{right: 'auto'},
]}>
<Icon
style={[
{color: t.atoms.border_contrast.borderColor},
{
width: 20,
pointerEvents: 'none',
},
...iconStyles,
]}
/>
</View>
)}
{Suffix && (
<View
onLayout={handleSuffixLayout}
style={[
atoms.absolute,
atoms.inset_0,
atoms.align_center,
atoms.justify_center,
atoms.pr_lg,
{left: 'auto'},
]}>
<Suffix />
</View>
)}
</View>
)
}
+18
View File
@@ -0,0 +1,18 @@
import {AccessibilityProps} from 'react-native'
export type RequiredAccessibilityProps = Required<AccessibilityProps>
export type BaseProps<T = string> = Omit<
AccessibilityProps,
'accessibilityLabel' | 'accessibilityHint'
> &
Pick<
RequiredAccessibilityProps,
'accessibilityLabel' | 'accessibilityHint'
> & {
value: T
onChange: (value: T) => void
testID: string
label?: string
hasError?: boolean
}
@@ -0,0 +1,21 @@
import React from 'react'
export function useInteractionState() {
const [state, setState] = React.useState(false)
const onIn = React.useCallback(() => {
setState(true)
}, [setState])
const onOut = React.useCallback(() => {
setState(false)
}, [setState])
return React.useMemo(
() => ({
state,
onIn,
onOut,
}),
[state, onIn, onOut],
)
}