New component library based on ALF (#2459)
* Install on native as well * Add button and link components * Comments * Use new prop * Add some form elements * Add labels to input * Fix line height, add suffix * Date inputs * Autofill styles * Clean up InputDate types * Improve types for InputText, value handling * Enforce a11y props on buttons * Add Dialog, Portal * Dialog contents * Native dialog * Clean up * Fix animations * Improvements to web modal, exiting still broken * Clean up dialog types * Add Prompt, Dialog refinement, mobile refinement * Integrate new design tokens, reorg storybook * Button colors * Dim mode * Reorg * Some styles * Toggles * Improve a11y * Autosize dialog, handle max height, Dialog.ScrolLView not working * Try to use BottomSheet's own APIs * Scrollable dialogs * Add web shadow * Handle overscroll * Styles * Dialog text input * Shadows * Button focus states * Button pressed states * Gradient poc * Gradient colors and hovers * Add hrefAttrs to Link * Some more a11y * Toggle invalid states * Update dialog descriptions for demo * Icons * WIP Toggle cleanup * Refactor toggle to not rely on immediate children * Make Toggle controlled * Clean up Toggles storybook * ToggleButton styles * Improve a11y labels * ToggleButton hover darkmode * Some i18n * Refactor input * Allow extension of input * Remove old input * Improve icons, add CalendarDays * Refactor DateField, web done * Add label example * Clean up old InputDate, DateField android, text area example * Consistent imports * Button context, icons * Add todo * Add closeAllDialogs control * Alignment * Expand color palette * Hitslops, add shortcut to Storybook in dev * Fix multiline on ios * Mark dialog close button as unused
This commit is contained in:
parent
9cbd3c0937
commit
66b8774ecb
60 changed files with 4683 additions and 968 deletions
507
src/components/Button.tsx
Normal file
507
src/components/Button.tsx
Normal file
|
@ -0,0 +1,507 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
PressableProps,
|
||||
TextProps,
|
||||
ViewStyle,
|
||||
AccessibilityProps,
|
||||
View,
|
||||
TextStyle,
|
||||
StyleSheet,
|
||||
} from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
|
||||
import {useTheme, atoms as a, tokens, web, native} from '#/alf'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
|
||||
export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
|
||||
export type ButtonColor =
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'negative'
|
||||
| 'gradient_sky'
|
||||
| 'gradient_midnight'
|
||||
| 'gradient_sunrise'
|
||||
| 'gradient_sunset'
|
||||
| 'gradient_nordic'
|
||||
| 'gradient_bonfire'
|
||||
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 = React.PropsWithChildren<
|
||||
Pick<PressableProps, 'disabled' | 'onPress'> &
|
||||
AccessibilityProps &
|
||||
VariantProps & {
|
||||
label: string
|
||||
}
|
||||
>
|
||||
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
|
||||
|
||||
const Context = React.createContext<
|
||||
VariantProps & {
|
||||
hovered: boolean
|
||||
focused: boolean
|
||||
pressed: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
>({
|
||||
hovered: false,
|
||||
focused: false,
|
||||
pressed: false,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
export function useButtonContext() {
|
||||
return React.useContext(Context)
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant,
|
||||
color,
|
||||
size,
|
||||
label,
|
||||
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, focusStyles} = 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(a.border, t.atoms.bg, {
|
||||
borderWidth: 1,
|
||||
})
|
||||
|
||||
if (!disabled) {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: tokens.color.blue_500,
|
||||
})
|
||||
hoverStyles.push(a.border, {
|
||||
backgroundColor: light
|
||||
? t.palette.primary_50
|
||||
: t.palette.primary_950,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push(a.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_100
|
||||
: tokens.color.gray_900,
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: light
|
||||
? tokens.color.gray_200
|
||||
: tokens.color.gray_950,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
backgroundColor: light
|
||||
? tokens.color.gray_300
|
||||
: tokens.color.gray_950,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
baseStyles.push(a.border, t.atoms.bg, {
|
||||
borderWidth: 1,
|
||||
})
|
||||
|
||||
if (!disabled) {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: light ? tokens.color.gray_500 : tokens.color.gray_500,
|
||||
})
|
||||
hoverStyles.push(a.border, t.atoms.bg_contrast_50)
|
||||
} else {
|
||||
baseStyles.push(a.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_900,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (color === 'negative') {
|
||||
if (variant === 'solid') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.negative_400,
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.negative_500,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.negative_600,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
baseStyles.push(a.border, t.atoms.bg, {
|
||||
borderWidth: 1,
|
||||
})
|
||||
|
||||
if (!disabled) {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.negative_400,
|
||||
})
|
||||
hoverStyles.push(a.border, {
|
||||
backgroundColor: light
|
||||
? t.palette.negative_50
|
||||
: t.palette.negative_975,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push(a.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_100
|
||||
: t.palette.negative_950,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (size === 'large') {
|
||||
baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_sm)
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({paddingVertical: 9}, a.px_md, a.rounded_sm, a.gap_sm)
|
||||
}
|
||||
|
||||
return {
|
||||
baseStyles,
|
||||
hoverStyles,
|
||||
focusStyles: [
|
||||
...hoverStyles,
|
||||
{
|
||||
outline: 0,
|
||||
} as ViewStyle,
|
||||
],
|
||||
}
|
||||
}, [t, variant, color, size, disabled])
|
||||
|
||||
const {gradientColors, gradientHoverColors, gradientLocations} =
|
||||
React.useMemo(() => {
|
||||
const colors: string[] = []
|
||||
const hoverColors: string[] = []
|
||||
const locations: number[] = []
|
||||
const gradient = {
|
||||
primary: tokens.gradients.sky,
|
||||
secondary: tokens.gradients.sky,
|
||||
negative: tokens.gradients.sky,
|
||||
gradient_sky: tokens.gradients.sky,
|
||||
gradient_midnight: tokens.gradients.midnight,
|
||||
gradient_sunrise: tokens.gradients.sunrise,
|
||||
gradient_sunset: tokens.gradients.sunset,
|
||||
gradient_nordic: tokens.gradients.nordic,
|
||||
gradient_bonfire: tokens.gradients.bonfire,
|
||||
}[color || 'primary']
|
||||
|
||||
if (variant === 'gradient') {
|
||||
colors.push(...gradient.values.map(([_, color]) => color))
|
||||
hoverColors.push(...gradient.values.map(_ => gradient.hover_value))
|
||||
locations.push(...gradient.values.map(([location, _]) => location))
|
||||
}
|
||||
|
||||
return {
|
||||
gradientColors: colors,
|
||||
gradientHoverColors: hoverColors,
|
||||
gradientLocations: locations,
|
||||
}
|
||||
}, [variant, color])
|
||||
|
||||
const context = React.useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
variant,
|
||||
color,
|
||||
size,
|
||||
disabled: disabled || false,
|
||||
}),
|
||||
[state, variant, color, size, disabled],
|
||||
)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
role="button"
|
||||
accessibilityHint={undefined} // optional
|
||||
{...rest}
|
||||
aria-label={label}
|
||||
aria-pressed={state.pressed}
|
||||
accessibilityLabel={label}
|
||||
disabled={disabled || false}
|
||||
accessibilityState={{
|
||||
disabled: disabled || false,
|
||||
}}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.overflow_hidden,
|
||||
...baseStyles,
|
||||
...(state.hovered || state.pressed ? hoverStyles : []),
|
||||
...(state.focused ? focusStyles : []),
|
||||
]}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onHoverIn={onHoverIn}
|
||||
onHoverOut={onHoverOut}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}>
|
||||
{variant === 'gradient' && (
|
||||
<LinearGradient
|
||||
colors={
|
||||
state.hovered || state.pressed || state.focused
|
||||
? gradientHoverColors
|
||||
: gradientColors
|
||||
}
|
||||
locations={gradientLocations}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
)}
|
||||
<Context.Provider value={context}>
|
||||
{typeof children === 'string' ? (
|
||||
<ButtonText>{children}</ButtonText>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Context.Provider>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSharedButtonTextStyles() {
|
||||
const t = useTheme()
|
||||
const {color, variant, disabled, size} = useButtonContext()
|
||||
return React.useMemo(() => {
|
||||
const baseStyles: TextStyle[] = []
|
||||
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' || variant === 'gradient') {
|
||||
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' || variant === 'gradient') {
|
||||
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_400})
|
||||
} else {
|
||||
baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
|
||||
}
|
||||
} else if (variant === 'ghost') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({color: t.palette.negative_400})
|
||||
} else {
|
||||
baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!disabled) {
|
||||
baseStyles.push({color: t.palette.white})
|
||||
} else {
|
||||
baseStyles.push({color: t.palette.white, opacity: 0.5})
|
||||
}
|
||||
}
|
||||
|
||||
if (size === 'large') {
|
||||
baseStyles.push(
|
||||
a.text_md,
|
||||
web({paddingBottom: 1}),
|
||||
native({marginTop: 2}),
|
||||
)
|
||||
} else {
|
||||
baseStyles.push(
|
||||
a.text_md,
|
||||
web({paddingBottom: 1}),
|
||||
native({marginTop: 2}),
|
||||
)
|
||||
}
|
||||
|
||||
return StyleSheet.flatten(baseStyles)
|
||||
}, [t, variant, color, size, disabled])
|
||||
}
|
||||
|
||||
export function ButtonText({children, style, ...rest}: ButtonTextProps) {
|
||||
const textStyles = useSharedButtonTextStyles()
|
||||
|
||||
return (
|
||||
<Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function ButtonIcon({
|
||||
icon: Comp,
|
||||
}: {
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
}) {
|
||||
const {size} = useButtonContext()
|
||||
const textStyles = useSharedButtonTextStyles()
|
||||
|
||||
return (
|
||||
<View style={[a.z_20]}>
|
||||
<Comp
|
||||
size={size === 'large' ? 'md' : 'sm'}
|
||||
style={[{color: textStyles.color, pointerEvents: 'none'}]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
35
src/components/Dialog/context.ts
Normal file
35
src/components/Dialog/context.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import React from 'react'
|
||||
|
||||
import {useDialogStateContext} from '#/state/dialogs'
|
||||
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 id = React.useId()
|
||||
const control = React.useRef<DialogControlProps>({
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
})
|
||||
const {activeDialogs} = useDialogStateContext()
|
||||
|
||||
React.useEffect(() => {
|
||||
activeDialogs.current.set(id, control)
|
||||
return () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
activeDialogs.current.delete(id)
|
||||
}
|
||||
}, [id, activeDialogs])
|
||||
|
||||
return {
|
||||
ref: control,
|
||||
open: () => control.current.open(),
|
||||
close: () => control.current.close(),
|
||||
}
|
||||
}
|
162
src/components/Dialog/index.tsx
Normal file
162
src/components/Dialog/index.tsx
Normal file
|
@ -0,0 +1,162 @@
|
|||
import React, {useImperativeHandle} from 'react'
|
||||
import {View, Dimensions} from 'react-native'
|
||||
import BottomSheet, {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetView,
|
||||
} from '@gorhom/bottom-sheet'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
|
||||
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'
|
||||
// @ts-ignore
|
||||
export const Input = createInput(BottomSheetTextInput)
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
control,
|
||||
onClose,
|
||||
nativeOptions,
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
const t = useTheme()
|
||||
const sheet = React.useRef<BottomSheet>(null)
|
||||
const sheetOptions = nativeOptions?.sheet || {}
|
||||
const hasSnapPoints = !!sheetOptions.snapPoints
|
||||
|
||||
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
|
||||
enableDynamicSizing={!hasSnapPoints}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="interactive"
|
||||
android_keyboardInputMode="adjustResize"
|
||||
keyboardBlurBehavior="restore"
|
||||
{...sheetOptions}
|
||||
ref={sheet}
|
||||
index={-1}
|
||||
backgroundStyle={{backgroundColor: 'transparent'}}
|
||||
backdropComponent={props => (
|
||||
<BottomSheetBackdrop
|
||||
opacity={0.4}
|
||||
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) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<BottomSheetView
|
||||
style={[
|
||||
a.p_lg,
|
||||
a.pt_3xl,
|
||||
{
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
|
||||
},
|
||||
]}>
|
||||
{props.children}
|
||||
</BottomSheetView>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScrollableInner(props: DialogInnerProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<BottomSheetScrollView
|
||||
style={[
|
||||
a.flex_1, // main diff is this
|
||||
a.p_lg,
|
||||
a.pt_3xl,
|
||||
{
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
},
|
||||
]}>
|
||||
{props.children}
|
||||
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
|
||||
</BottomSheetScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
export function Handle() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_sm,
|
||||
a.z_10,
|
||||
{
|
||||
top: a.pt_lg.paddingTop,
|
||||
width: 35,
|
||||
height: 4,
|
||||
alignSelf: 'center',
|
||||
backgroundColor: t.palette.contrast_900,
|
||||
opacity: 0.5,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Close() {
|
||||
return null
|
||||
}
|
194
src/components/Dialog/index.web.tsx
Normal file
194
src/components/Dialog/index.web.tsx
Normal file
|
@ -0,0 +1,194 @@
|
|||
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 {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useTheme, atoms as a, useBreakpoints, web} from '#/alf'
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
|
||||
import {Context} from '#/components/Dialog/context'
|
||||
|
||||
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
|
||||
export * from '#/components/Dialog/types'
|
||||
export {Input} from '#/components/forms/TextField'
|
||||
|
||||
const stopPropagation = (e: any) => e.stopPropagation()
|
||||
|
||||
export function Outer({
|
||||
control,
|
||||
onClose,
|
||||
children,
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
const {_} = useLingui()
|
||||
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
|
||||
accessibilityHint={undefined}
|
||||
accessibilityLabel={_(msg`Close active dialog`)}
|
||||
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,
|
||||
{opacity: 0.5, backgroundColor: t.palette.black},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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,
|
||||
label,
|
||||
accessibilityLabelledBy,
|
||||
accessibilityDescribedBy,
|
||||
}: DialogInnerProps) {
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
return (
|
||||
<FocusScope loop enabled trapped>
|
||||
<Animated.View
|
||||
role="dialog"
|
||||
aria-role="dialog"
|
||||
aria-label={label}
|
||||
aria-labelledby={accessibilityLabelledBy}
|
||||
aria-describedby={accessibilityDescribedBy}
|
||||
// @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_200,
|
||||
shadowColor: t.palette.black,
|
||||
shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
|
||||
shadowRadius: 30,
|
||||
},
|
||||
...(Array.isArray(style) ? style : [style || {}]),
|
||||
]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</FocusScope>
|
||||
)
|
||||
}
|
||||
|
||||
export const ScrollableInner = Inner
|
||||
|
||||
export function Handle() {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(eric) unused rn
|
||||
*/
|
||||
// export function Close() {
|
||||
// const {_} = useLingui()
|
||||
// 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} label={_(msg`Close active dialog`)}>
|
||||
// </Button>
|
||||
// </View>
|
||||
// )
|
||||
// }
|
43
src/components/Dialog/types.ts
Normal file
43
src/components/Dialog/types.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
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?: {}
|
||||
}
|
||||
|
||||
type DialogInnerPropsBase<T> = React.PropsWithChildren<{
|
||||
style?: ViewStyle
|
||||
}> &
|
||||
T
|
||||
export type DialogInnerProps =
|
||||
| DialogInnerPropsBase<{
|
||||
label?: undefined
|
||||
accessibilityLabelledBy: A11yProps['aria-labelledby']
|
||||
accessibilityDescribedBy: string
|
||||
}>
|
||||
| DialogInnerPropsBase<{
|
||||
label: string
|
||||
accessibilityLabelledBy?: undefined
|
||||
accessibilityDescribedBy?: undefined
|
||||
}>
|
191
src/components/Link.tsx
Normal file
191
src/components/Link.tsx
Normal file
|
@ -0,0 +1,191 @@
|
|||
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, flatten} from '#/alf'
|
||||
import {Button, ButtonProps, useButtonContext} 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' | 'label'
|
||||
> & {
|
||||
/**
|
||||
* `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
|
||||
label?: ButtonProps['label']
|
||||
} & 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,
|
||||
action = 'push',
|
||||
warnOnMismatchingTextChild,
|
||||
style,
|
||||
...rest
|
||||
}: LinkProps) {
|
||||
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 stringChildren = typeof children === 'string' ? children : ''
|
||||
const requiresWarning = Boolean(
|
||||
warnOnMismatchingTextChild &&
|
||||
stringChildren &&
|
||||
isExternal &&
|
||||
linkRequiresWarning(href, stringChildren),
|
||||
)
|
||||
|
||||
if (requiresWarning) {
|
||||
e.preventDefault()
|
||||
|
||||
openModal({
|
||||
name: 'link-warning',
|
||||
text: stringChildren,
|
||||
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
|
||||
label={href}
|
||||
{...rest}
|
||||
role="link"
|
||||
accessibilityRole="link"
|
||||
href={href}
|
||||
onPress={onPress}
|
||||
{...web({
|
||||
hrefAttrs: {
|
||||
target: isExternal ? 'blank' : undefined,
|
||||
rel: isExternal ? 'noopener noreferrer' : undefined,
|
||||
},
|
||||
dataSet: {
|
||||
// default to no underline, apply this ourselves
|
||||
noUnderline: '1',
|
||||
},
|
||||
})}>
|
||||
{typeof children === 'string' ? (
|
||||
<LinkText style={style}>{children}</LinkText>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function LinkText({
|
||||
children,
|
||||
style,
|
||||
}: React.PropsWithChildren<{
|
||||
style?: StyleProp<TextStyle>
|
||||
}>) {
|
||||
const t = useTheme()
|
||||
const {hovered} = useButtonContext()
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{color: t.palette.primary_500},
|
||||
hovered && {
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: t.palette.primary_500,
|
||||
},
|
||||
flatten(style),
|
||||
]}>
|
||||
{children as string}
|
||||
</Text>
|
||||
)
|
||||
}
|
56
src/components/Portal.tsx
Normal file
56
src/components/Portal.tsx
Normal 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
|
||||
}
|
119
src/components/Prompt.tsx
Normal file
119
src/components/Prompt.tsx
Normal file
|
@ -0,0 +1,119 @@
|
|||
import React from 'react'
|
||||
import {View, PressableProps} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {H4, 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 titleId = React.useId()
|
||||
const descriptionId = React.useId()
|
||||
|
||||
const context = React.useMemo(
|
||||
() => ({titleId, descriptionId}),
|
||||
[titleId, descriptionId],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Context.Provider value={context}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.Inner
|
||||
accessibilityLabelledBy={titleId}
|
||||
accessibilityDescribedBy={descriptionId}
|
||||
style={{width: 'auto', maxWidth: 400}}>
|
||||
{children}
|
||||
</Dialog.Inner>
|
||||
</Context.Provider>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function Title({children}: React.PropsWithChildren<{}>) {
|
||||
const t = useTheme()
|
||||
const {titleId} = React.useContext(Context)
|
||||
return (
|
||||
<H4
|
||||
nativeID={titleId}
|
||||
style={[a.font_bold, t.atoms.text_contrast_700, a.pb_sm]}>
|
||||
{children}
|
||||
</H4>
|
||||
)
|
||||
}
|
||||
|
||||
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 {_} = useLingui()
|
||||
const {close} = Dialog.useDialogContext()
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
label={_(msg`Cancel`)}
|
||||
onPress={close}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Action({
|
||||
children,
|
||||
onPress,
|
||||
}: React.PropsWithChildren<{onPress?: () => void}>) {
|
||||
const {_} = useLingui()
|
||||
const {close} = Dialog.useDialogContext()
|
||||
const handleOnPress = React.useCallback(() => {
|
||||
close()
|
||||
onPress?.()
|
||||
}, [close, onPress])
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="small"
|
||||
label={_(msg`Confirm`)}
|
||||
onPress={handleOnPress}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
124
src/components/Typography.tsx
Normal file
124
src/components/Typography.tsx
Normal 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}]}
|
||||
/>
|
||||
)
|
||||
}
|
108
src/components/forms/DateField/index.android.tsx
Normal file
108
src/components/forms/DateField/index.android.tsx
Normal file
|
@ -0,0 +1,108 @@
|
|||
import React from 'react'
|
||||
import {View, Pressable} from 'react-native'
|
||||
import DateTimePicker, {
|
||||
BaseProps as DateTimePickerProps,
|
||||
} from '@react-native-community/datetimepicker'
|
||||
|
||||
import {useTheme, atoms} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
|
||||
|
||||
import {DateFieldProps} from '#/components/forms/DateField/types'
|
||||
import {
|
||||
localizeDate,
|
||||
toSimpleDateString,
|
||||
} from '#/components/forms/DateField/utils'
|
||||
|
||||
export * as utils from '#/components/forms/DateField/utils'
|
||||
export const Label = TextField.Label
|
||||
|
||||
export function DateField({
|
||||
value,
|
||||
onChangeDate,
|
||||
label,
|
||||
isInvalid,
|
||||
testID,
|
||||
}: DateFieldProps) {
|
||||
const t = useTheme()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const {
|
||||
state: pressed,
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
|
||||
const {chromeFocus, chromeError, chromeErrorHover} =
|
||||
TextField.useSharedInputStyles()
|
||||
|
||||
const onChangeInternal = React.useCallback<
|
||||
Required<DateTimePickerProps>['onChange']
|
||||
>(
|
||||
(_event, date) => {
|
||||
setOpen(false)
|
||||
|
||||
if (date) {
|
||||
const formatted = toSimpleDateString(date)
|
||||
onChangeDate(formatted)
|
||||
}
|
||||
},
|
||||
[onChangeDate, setOpen],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[atoms.relative, atoms.w_full]}>
|
||||
<Pressable
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={undefined}
|
||||
onPress={() => setOpen(true)}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[
|
||||
{
|
||||
paddingTop: 16,
|
||||
paddingBottom: 16,
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
},
|
||||
atoms.flex_row,
|
||||
atoms.flex_1,
|
||||
atoms.w_full,
|
||||
atoms.px_lg,
|
||||
atoms.rounded_sm,
|
||||
t.atoms.bg_contrast_50,
|
||||
focused || pressed ? chromeFocus : {},
|
||||
isInvalid ? chromeError : {},
|
||||
isInvalid && (focused || pressed) ? chromeErrorHover : {},
|
||||
]}>
|
||||
<TextField.Icon icon={CalendarDays} />
|
||||
|
||||
<Text
|
||||
style={[atoms.text_md, atoms.pl_xs, t.atoms.text, {paddingTop: 3}]}>
|
||||
{localizeDate(value)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{open && (
|
||||
<DateTimePicker
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={undefined}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
56
src/components/forms/DateField/index.tsx
Normal file
56
src/components/forms/DateField/index.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import DateTimePicker, {
|
||||
DateTimePickerEvent,
|
||||
} from '@react-native-community/datetimepicker'
|
||||
|
||||
import {useTheme, atoms} from '#/alf'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import {DateFieldProps} from '#/components/forms/DateField/types'
|
||||
|
||||
export * as utils from '#/components/forms/DateField/utils'
|
||||
export const Label = TextField.Label
|
||||
|
||||
/**
|
||||
* 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 DateField({
|
||||
value,
|
||||
onChangeDate,
|
||||
testID,
|
||||
label,
|
||||
}: DateFieldProps) {
|
||||
const t = useTheme()
|
||||
|
||||
const onChangeInternal = React.useCallback(
|
||||
(event: DateTimePickerEvent, date: Date | undefined) => {
|
||||
if (date) {
|
||||
const formatted = toSimpleDateString(date)
|
||||
onChangeDate(formatted)
|
||||
}
|
||||
},
|
||||
[onChangeDate],
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[atoms.relative, atoms.w_full]}>
|
||||
<DateTimePicker
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={undefined}
|
||||
testID={`${testID}-datepicker`}
|
||||
mode="date"
|
||||
timeZoneName={'Etc/UTC'}
|
||||
display="spinner"
|
||||
themeVariant={t.name === 'dark' ? 'dark' : 'light'}
|
||||
value={new Date(value)}
|
||||
onChange={onChangeInternal}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
64
src/components/forms/DateField/index.web.tsx
Normal file
64
src/components/forms/DateField/index.web.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import React from 'react'
|
||||
import {TextInput, TextInputProps, StyleSheet} from 'react-native'
|
||||
// @ts-ignore
|
||||
import {unstable_createElement} from 'react-native-web'
|
||||
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import {DateFieldProps} from '#/components/forms/DateField/types'
|
||||
|
||||
export * as utils from '#/components/forms/DateField/utils'
|
||||
export const Label = TextField.Label
|
||||
|
||||
const InputBase = React.forwardRef<HTMLInputElement, TextInputProps>(
|
||||
({style, ...props}, ref) => {
|
||||
return unstable_createElement('input', {
|
||||
...props,
|
||||
ref,
|
||||
type: 'date',
|
||||
style: [
|
||||
StyleSheet.flatten(style),
|
||||
{
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
InputBase.displayName = 'InputBase'
|
||||
|
||||
const Input = TextField.createInput(InputBase as unknown as typeof TextInput)
|
||||
|
||||
export function DateField({
|
||||
value,
|
||||
onChangeDate,
|
||||
label,
|
||||
isInvalid,
|
||||
testID,
|
||||
}: DateFieldProps) {
|
||||
const handleOnChange = React.useCallback(
|
||||
(e: any) => {
|
||||
const date = e.target.valueAsDate || e.target.value
|
||||
|
||||
if (date) {
|
||||
const formatted = toSimpleDateString(date)
|
||||
onChangeDate(formatted)
|
||||
}
|
||||
},
|
||||
[onChangeDate],
|
||||
)
|
||||
|
||||
return (
|
||||
<TextField.Root isInvalid={isInvalid}>
|
||||
<Input
|
||||
value={value}
|
||||
label={label}
|
||||
onChange={handleOnChange}
|
||||
onChangeText={() => {}}
|
||||
testID={testID}
|
||||
/>
|
||||
</TextField.Root>
|
||||
)
|
||||
}
|
7
src/components/forms/DateField/types.ts
Normal file
7
src/components/forms/DateField/types.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export type DateFieldProps = {
|
||||
value: string
|
||||
onChangeDate: (date: string) => void
|
||||
label: string
|
||||
isInvalid?: boolean
|
||||
testID?: string
|
||||
}
|
16
src/components/forms/DateField/utils.ts
Normal file
16
src/components/forms/DateField/utils.ts
Normal 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)
|
||||
}
|
43
src/components/forms/InputGroup.tsx
Normal file
43
src/components/forms/InputGroup.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms, useTheme} from '#/alf'
|
||||
|
||||
/**
|
||||
* NOT FINISHED, just here as a reference
|
||||
*/
|
||||
export function InputGroup(props: React.PropsWithChildren<{}>) {
|
||||
const t = useTheme()
|
||||
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}>
|
||||
{i > 0 ? (
|
||||
<View
|
||||
style={[atoms.border_b, {borderColor: t.palette.contrast_500}]}
|
||||
/>
|
||||
) : null}
|
||||
{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>
|
||||
)
|
||||
}
|
334
src/components/forms/TextField.tsx
Normal file
334
src/components/forms/TextField.tsx
Normal file
|
@ -0,0 +1,334 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
TextInputProps,
|
||||
TextStyle,
|
||||
ViewStyle,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
AccessibilityProps,
|
||||
} from 'react-native'
|
||||
|
||||
import {HITSLOP_20} from 'lib/constants'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useTheme, atoms as a, web, tokens, android} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
|
||||
const Context = React.createContext<{
|
||||
inputRef: React.RefObject<TextInput> | null
|
||||
isInvalid: boolean
|
||||
hovered: boolean
|
||||
onHoverIn: () => void
|
||||
onHoverOut: () => void
|
||||
focused: boolean
|
||||
onFocus: () => void
|
||||
onBlur: () => void
|
||||
}>({
|
||||
inputRef: null,
|
||||
isInvalid: false,
|
||||
hovered: false,
|
||||
onHoverIn: () => {},
|
||||
onHoverOut: () => {},
|
||||
focused: false,
|
||||
onFocus: () => {},
|
||||
onBlur: () => {},
|
||||
})
|
||||
|
||||
export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
|
||||
|
||||
export function Root({children, isInvalid = false}: RootProps) {
|
||||
const inputRef = React.useRef<TextInput>(null)
|
||||
const rootRef = React.useRef<View>(null)
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
|
||||
const context = React.useMemo(
|
||||
() => ({
|
||||
inputRef,
|
||||
hovered,
|
||||
onHoverIn,
|
||||
onHoverOut,
|
||||
focused,
|
||||
onFocus,
|
||||
onBlur,
|
||||
isInvalid,
|
||||
}),
|
||||
[
|
||||
inputRef,
|
||||
hovered,
|
||||
onHoverIn,
|
||||
onHoverOut,
|
||||
focused,
|
||||
onFocus,
|
||||
onBlur,
|
||||
isInvalid,
|
||||
],
|
||||
)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const root = rootRef.current
|
||||
if (!root || !isWeb) return
|
||||
// @ts-ignore web only
|
||||
root.tabIndex = -1
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Context.Provider value={context}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
ref={rootRef}
|
||||
role="none"
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.relative,
|
||||
a.w_full,
|
||||
a.px_md,
|
||||
{
|
||||
paddingVertical: 14,
|
||||
},
|
||||
]}
|
||||
// onPressIn/out don't work on android web
|
||||
onPress={() => inputRef.current?.focus()}
|
||||
onHoverIn={onHoverIn}
|
||||
onHoverOut={onHoverOut}>
|
||||
{children}
|
||||
</Pressable>
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSharedInputStyles() {
|
||||
const t = useTheme()
|
||||
return React.useMemo(() => {
|
||||
const hover: ViewStyle[] = [
|
||||
{
|
||||
borderColor: t.palette.contrast_100,
|
||||
},
|
||||
]
|
||||
const focus: ViewStyle[] = [
|
||||
{
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
borderColor: t.palette.primary_500,
|
||||
},
|
||||
]
|
||||
const error: ViewStyle[] = [
|
||||
{
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
|
||||
borderColor:
|
||||
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
|
||||
},
|
||||
]
|
||||
const errorHover: ViewStyle[] = [
|
||||
{
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
|
||||
borderColor: tokens.color.red_500,
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
chromeHover: StyleSheet.flatten(hover),
|
||||
chromeFocus: StyleSheet.flatten(focus),
|
||||
chromeError: StyleSheet.flatten(error),
|
||||
chromeErrorHover: StyleSheet.flatten(errorHover),
|
||||
}
|
||||
}, [t])
|
||||
}
|
||||
|
||||
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
|
||||
label: string
|
||||
value: string
|
||||
onChangeText: (value: string) => void
|
||||
isInvalid?: boolean
|
||||
}
|
||||
|
||||
export function createInput(Component: typeof TextInput) {
|
||||
return function Input({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onChangeText,
|
||||
isInvalid,
|
||||
...rest
|
||||
}: InputProps) {
|
||||
const t = useTheme()
|
||||
const ctx = React.useContext(Context)
|
||||
const withinRoot = Boolean(ctx.inputRef)
|
||||
|
||||
const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
|
||||
useSharedInputStyles()
|
||||
|
||||
if (!withinRoot) {
|
||||
return (
|
||||
<Root isInvalid={isInvalid}>
|
||||
<Input
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
isInvalid={isInvalid}
|
||||
{...rest}
|
||||
/>
|
||||
</Root>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Component
|
||||
accessibilityHint={undefined}
|
||||
{...rest}
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
ref={ctx.inputRef}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={ctx.onFocus}
|
||||
onBlur={ctx.onBlur}
|
||||
placeholder={placeholder || label}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
hitSlop={HITSLOP_20}
|
||||
style={[
|
||||
a.relative,
|
||||
a.z_20,
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
t.atoms.text,
|
||||
a.px_xs,
|
||||
android({
|
||||
paddingBottom: 2,
|
||||
}),
|
||||
{
|
||||
lineHeight: a.text_md.lineHeight * 1.1875,
|
||||
textAlignVertical: rest.multiline ? 'top' : undefined,
|
||||
minHeight: rest.multiline ? 60 : undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.z_10,
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_25,
|
||||
{borderColor: 'transparent', borderWidth: 2},
|
||||
ctx.hovered ? chromeHover : {},
|
||||
ctx.focused ? chromeFocus : {},
|
||||
ctx.isInvalid || isInvalid ? chromeError : {},
|
||||
(ctx.isInvalid || isInvalid) && (ctx.hovered || ctx.focused)
|
||||
? chromeErrorHover
|
||||
: {},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const Input = createInput(TextInput)
|
||||
|
||||
export function Label({children}: React.PropsWithChildren<{}>) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_600, a.mb_sm]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
|
||||
const t = useTheme()
|
||||
const ctx = React.useContext(Context)
|
||||
const {hover, focus, errorHover, errorFocus} = React.useMemo(() => {
|
||||
const hover: TextStyle[] = [
|
||||
{
|
||||
color: t.palette.contrast_800,
|
||||
},
|
||||
]
|
||||
const focus: TextStyle[] = [
|
||||
{
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
]
|
||||
const errorHover: TextStyle[] = [
|
||||
{
|
||||
color: t.palette.negative_500,
|
||||
},
|
||||
]
|
||||
const errorFocus: TextStyle[] = [
|
||||
{
|
||||
color: t.palette.negative_500,
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
hover,
|
||||
focus,
|
||||
errorHover,
|
||||
errorFocus,
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<View style={[a.z_20, a.pr_xs]}>
|
||||
<Comp
|
||||
size="md"
|
||||
style={[
|
||||
{color: t.palette.contrast_500, pointerEvents: 'none'},
|
||||
ctx.hovered ? hover : {},
|
||||
ctx.focused ? focus : {},
|
||||
ctx.isInvalid && ctx.hovered ? errorHover : {},
|
||||
ctx.isInvalid && ctx.focused ? errorFocus : {},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Suffix({
|
||||
children,
|
||||
label,
|
||||
accessibilityHint,
|
||||
}: React.PropsWithChildren<{
|
||||
label: string
|
||||
accessibilityHint?: AccessibilityProps['accessibilityHint']
|
||||
}>) {
|
||||
const t = useTheme()
|
||||
const ctx = React.useContext(Context)
|
||||
return (
|
||||
<Text
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={accessibilityHint}
|
||||
style={[
|
||||
a.z_20,
|
||||
a.pr_sm,
|
||||
a.text_md,
|
||||
t.atoms.text_contrast_400,
|
||||
{
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
web({
|
||||
marginTop: -2,
|
||||
}),
|
||||
ctx.hovered || ctx.focused
|
||||
? {
|
||||
color: t.palette.contrast_800,
|
||||
}
|
||||
: {},
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
473
src/components/forms/Toggle.tsx
Normal file
473
src/components/forms/Toggle.tsx
Normal file
|
@ -0,0 +1,473 @@
|
|||
import React from 'react'
|
||||
import {Pressable, View, ViewStyle} from 'react-native'
|
||||
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {useTheme, atoms as a, web, native} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
|
||||
export type ItemState = {
|
||||
name: string
|
||||
selected: boolean
|
||||
disabled: boolean
|
||||
isInvalid: boolean
|
||||
hovered: boolean
|
||||
pressed: boolean
|
||||
focused: boolean
|
||||
}
|
||||
|
||||
const ItemContext = React.createContext<ItemState>({
|
||||
name: '',
|
||||
selected: false,
|
||||
disabled: false,
|
||||
isInvalid: false,
|
||||
hovered: false,
|
||||
pressed: false,
|
||||
focused: false,
|
||||
})
|
||||
|
||||
const GroupContext = React.createContext<{
|
||||
values: string[]
|
||||
disabled: boolean
|
||||
type: 'radio' | 'checkbox'
|
||||
maxSelectionsReached: boolean
|
||||
setFieldValue: (props: {name: string; value: boolean}) => void
|
||||
}>({
|
||||
type: 'checkbox',
|
||||
values: [],
|
||||
disabled: false,
|
||||
maxSelectionsReached: false,
|
||||
setFieldValue: () => {},
|
||||
})
|
||||
|
||||
export type GroupProps = React.PropsWithChildren<{
|
||||
type?: 'radio' | 'checkbox'
|
||||
values: string[]
|
||||
maxSelections?: number
|
||||
disabled?: boolean
|
||||
onChange: (value: string[]) => void
|
||||
label: string
|
||||
}>
|
||||
|
||||
export type ItemProps = {
|
||||
type?: 'radio' | 'checkbox'
|
||||
name: string
|
||||
label: string
|
||||
value?: boolean
|
||||
disabled?: boolean
|
||||
onChange?: (selected: boolean) => void
|
||||
isInvalid?: boolean
|
||||
style?: (state: ItemState) => ViewStyle
|
||||
children: ((props: ItemState) => React.ReactNode) | React.ReactNode
|
||||
}
|
||||
|
||||
export function useItemContext() {
|
||||
return React.useContext(ItemContext)
|
||||
}
|
||||
|
||||
export function Group({
|
||||
children,
|
||||
values: providedValues,
|
||||
onChange,
|
||||
disabled = false,
|
||||
type = 'checkbox',
|
||||
maxSelections,
|
||||
label,
|
||||
}: GroupProps) {
|
||||
const groupRole = type === 'radio' ? 'radiogroup' : undefined
|
||||
const values = type === 'radio' ? providedValues.slice(0, 1) : providedValues
|
||||
const [maxReached, setMaxReached] = React.useState(false)
|
||||
|
||||
const setFieldValue = React.useCallback<
|
||||
(props: {name: string; value: boolean}) => void
|
||||
>(
|
||||
({name, value}) => {
|
||||
if (type === 'checkbox') {
|
||||
const pruned = values.filter(v => v !== name)
|
||||
const next = value ? pruned.concat(name) : pruned
|
||||
onChange(next)
|
||||
} else {
|
||||
onChange([name])
|
||||
}
|
||||
},
|
||||
[type, onChange, values],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (type === 'checkbox') {
|
||||
if (
|
||||
maxSelections &&
|
||||
values.length >= maxSelections &&
|
||||
maxReached === false
|
||||
) {
|
||||
setMaxReached(true)
|
||||
} else if (
|
||||
maxSelections &&
|
||||
values.length < maxSelections &&
|
||||
maxReached === true
|
||||
) {
|
||||
setMaxReached(false)
|
||||
}
|
||||
}
|
||||
}, [type, values.length, maxSelections, maxReached, setMaxReached])
|
||||
|
||||
const context = React.useMemo(
|
||||
() => ({
|
||||
values,
|
||||
type,
|
||||
disabled,
|
||||
maxSelectionsReached: maxReached,
|
||||
setFieldValue,
|
||||
}),
|
||||
[values, disabled, type, maxReached, setFieldValue],
|
||||
)
|
||||
|
||||
return (
|
||||
<GroupContext.Provider value={context}>
|
||||
<View
|
||||
role={groupRole}
|
||||
{...(groupRole === 'radiogroup'
|
||||
? {
|
||||
'aria-label': label,
|
||||
accessibilityLabel: label,
|
||||
accessibilityRole: groupRole,
|
||||
}
|
||||
: {})}>
|
||||
{children}
|
||||
</View>
|
||||
</GroupContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Item({
|
||||
children,
|
||||
name,
|
||||
value = false,
|
||||
disabled: itemDisabled = false,
|
||||
onChange,
|
||||
isInvalid,
|
||||
style,
|
||||
type = 'checkbox',
|
||||
label,
|
||||
...rest
|
||||
}: ItemProps) {
|
||||
const {
|
||||
values: selectedValues,
|
||||
type: groupType,
|
||||
disabled: groupDisabled,
|
||||
setFieldValue,
|
||||
maxSelectionsReached,
|
||||
} = React.useContext(GroupContext)
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
const {
|
||||
state: pressed,
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
|
||||
const role = groupType === 'radio' ? 'radio' : type
|
||||
const selected = selectedValues.includes(name) || !!value
|
||||
const disabled =
|
||||
groupDisabled || itemDisabled || (!selected && maxSelectionsReached)
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
const next = !selected
|
||||
setFieldValue({name, value: next})
|
||||
onChange?.(next)
|
||||
}, [name, selected, onChange, setFieldValue])
|
||||
|
||||
const state = React.useMemo(
|
||||
() => ({
|
||||
name,
|
||||
selected,
|
||||
disabled: disabled ?? false,
|
||||
isInvalid: isInvalid ?? false,
|
||||
hovered,
|
||||
pressed,
|
||||
focused,
|
||||
}),
|
||||
[name, selected, disabled, hovered, pressed, focused, isInvalid],
|
||||
)
|
||||
|
||||
return (
|
||||
<ItemContext.Provider value={state}>
|
||||
<Pressable
|
||||
accessibilityHint={undefined} // optional
|
||||
hitSlop={HITSLOP_10}
|
||||
{...rest}
|
||||
disabled={disabled}
|
||||
aria-disabled={disabled ?? false}
|
||||
aria-checked={selected}
|
||||
aria-invalid={isInvalid}
|
||||
aria-label={label}
|
||||
role={role}
|
||||
accessibilityRole={role}
|
||||
accessibilityState={{
|
||||
disabled: disabled ?? false,
|
||||
selected: selected,
|
||||
}}
|
||||
accessibilityLabel={label}
|
||||
onPress={onPress}
|
||||
onHoverIn={onHoverIn}
|
||||
onHoverOut={onHoverOut}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
focused ? web({outline: 'none'}) : {},
|
||||
style?.(state),
|
||||
]}>
|
||||
{typeof children === 'function' ? children(state) : children}
|
||||
</Pressable>
|
||||
</ItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Label({children}: React.PropsWithChildren<{}>) {
|
||||
const t = useTheme()
|
||||
const {disabled} = useItemContext()
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
{
|
||||
userSelect: 'none',
|
||||
color: disabled ? t.palette.contrast_400 : t.palette.contrast_600,
|
||||
},
|
||||
native({
|
||||
paddingTop: 3,
|
||||
}),
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
// TODO(eric) refactor to memoize styles without knowledge of state
|
||||
export function createSharedToggleStyles({
|
||||
theme: t,
|
||||
hovered,
|
||||
focused,
|
||||
selected,
|
||||
disabled,
|
||||
isInvalid,
|
||||
}: {
|
||||
theme: ReturnType<typeof useTheme>
|
||||
selected: boolean
|
||||
hovered: boolean
|
||||
focused: boolean
|
||||
disabled: boolean
|
||||
isInvalid: boolean
|
||||
}) {
|
||||
const base: ViewStyle[] = []
|
||||
const baseHover: ViewStyle[] = []
|
||||
const indicator: ViewStyle[] = []
|
||||
|
||||
if (selected) {
|
||||
base.push({
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.primary_25 : t.palette.primary_900,
|
||||
borderColor: t.palette.primary_500,
|
||||
})
|
||||
|
||||
if (hovered || focused) {
|
||||
baseHover.push({
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.primary_100 : t.palette.primary_800,
|
||||
borderColor:
|
||||
t.name === 'light' ? t.palette.primary_600 : t.palette.primary_400,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (hovered || focused) {
|
||||
baseHover.push({
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.contrast_50 : t.palette.contrast_100,
|
||||
borderColor: t.palette.contrast_500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isInvalid) {
|
||||
base.push({
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
|
||||
borderColor:
|
||||
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
|
||||
})
|
||||
|
||||
if (hovered || focused) {
|
||||
baseHover.push({
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
|
||||
borderColor: t.palette.negative_500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
base.push({
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
borderColor: t.palette.contrast_400,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
baseStyles: base,
|
||||
baseHoverStyles: disabled ? [] : baseHover,
|
||||
indicatorStyles: indicator,
|
||||
}
|
||||
}
|
||||
|
||||
export function Checkbox() {
|
||||
const t = useTheme()
|
||||
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
|
||||
const {baseStyles, baseHoverStyles, indicatorStyles} =
|
||||
createSharedToggleStyles({
|
||||
theme: t,
|
||||
hovered,
|
||||
focused,
|
||||
selected,
|
||||
disabled,
|
||||
isInvalid,
|
||||
})
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.border,
|
||||
a.rounded_xs,
|
||||
t.atoms.border_contrast,
|
||||
{
|
||||
height: 20,
|
||||
width: 20,
|
||||
},
|
||||
baseStyles,
|
||||
hovered || focused ? baseHoverStyles : {},
|
||||
]}>
|
||||
{selected ? (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_2xs,
|
||||
{height: 12, width: 12},
|
||||
selected
|
||||
? {
|
||||
backgroundColor: t.palette.primary_500,
|
||||
}
|
||||
: {},
|
||||
indicatorStyles,
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Switch() {
|
||||
const t = useTheme()
|
||||
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
|
||||
const {baseStyles, baseHoverStyles, indicatorStyles} =
|
||||
createSharedToggleStyles({
|
||||
theme: t,
|
||||
hovered,
|
||||
focused,
|
||||
selected,
|
||||
disabled,
|
||||
isInvalid,
|
||||
})
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.border,
|
||||
a.rounded_full,
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast,
|
||||
{
|
||||
height: 20,
|
||||
width: 30,
|
||||
},
|
||||
baseStyles,
|
||||
hovered || focused ? baseHoverStyles : {},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
{
|
||||
height: 12,
|
||||
width: 12,
|
||||
top: 3,
|
||||
left: 3,
|
||||
backgroundColor: t.palette.contrast_400,
|
||||
},
|
||||
selected
|
||||
? {
|
||||
backgroundColor: t.palette.primary_500,
|
||||
left: 13,
|
||||
}
|
||||
: {},
|
||||
indicatorStyles,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Radio() {
|
||||
const t = useTheme()
|
||||
const {selected, hovered, focused, disabled, isInvalid} =
|
||||
React.useContext(ItemContext)
|
||||
const {baseStyles, baseHoverStyles, indicatorStyles} =
|
||||
createSharedToggleStyles({
|
||||
theme: t,
|
||||
hovered,
|
||||
focused,
|
||||
selected,
|
||||
disabled,
|
||||
isInvalid,
|
||||
})
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.border,
|
||||
a.rounded_full,
|
||||
t.atoms.border_contrast,
|
||||
{
|
||||
height: 20,
|
||||
width: 20,
|
||||
},
|
||||
baseStyles,
|
||||
hovered || focused ? baseHoverStyles : {},
|
||||
]}>
|
||||
{selected ? (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
{height: 12, width: 12},
|
||||
selected
|
||||
? {
|
||||
backgroundColor: t.palette.primary_500,
|
||||
}
|
||||
: {},
|
||||
indicatorStyles,
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
124
src/components/forms/ToggleButton.tsx
Normal file
124
src/components/forms/ToggleButton.tsx
Normal file
|
@ -0,0 +1,124 @@
|
|||
import React from 'react'
|
||||
import {View, AccessibilityProps, TextStyle, ViewStyle} from 'react-native'
|
||||
|
||||
import {atoms as a, useTheme, native} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
|
||||
export type ItemProps = Omit<Toggle.ItemProps, 'style' | 'role' | 'children'> &
|
||||
AccessibilityProps &
|
||||
React.PropsWithChildren<{}>
|
||||
|
||||
export type GroupProps = Omit<Toggle.GroupProps, 'style' | 'type'> & {
|
||||
multiple?: boolean
|
||||
}
|
||||
|
||||
export function Group({children, multiple, ...props}: GroupProps) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Toggle.Group type={multiple ? 'checkbox' : 'radio'} {...props}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.border,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
t.atoms.border,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
)
|
||||
}
|
||||
|
||||
export function Button({children, ...props}: ItemProps) {
|
||||
return (
|
||||
<Toggle.Item {...props}>
|
||||
<ButtonInner>{children}</ButtonInner>
|
||||
</Toggle.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonInner({children}: React.PropsWithChildren<{}>) {
|
||||
const t = useTheme()
|
||||
const state = Toggle.useItemContext()
|
||||
|
||||
const {baseStyles, hoverStyles, activeStyles, textStyles} =
|
||||
React.useMemo(() => {
|
||||
const base: ViewStyle[] = []
|
||||
const hover: ViewStyle[] = []
|
||||
const active: ViewStyle[] = []
|
||||
const text: TextStyle[] = []
|
||||
|
||||
hover.push(
|
||||
t.name === 'light' ? t.atoms.bg_contrast_100 : t.atoms.bg_contrast_25,
|
||||
)
|
||||
|
||||
if (state.selected) {
|
||||
active.push({
|
||||
backgroundColor: t.palette.contrast_800,
|
||||
})
|
||||
text.push(t.atoms.text_inverted)
|
||||
hover.push({
|
||||
backgroundColor: t.palette.contrast_800,
|
||||
})
|
||||
|
||||
if (state.disabled) {
|
||||
active.push({
|
||||
backgroundColor: t.palette.contrast_500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (state.disabled) {
|
||||
base.push({
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
})
|
||||
text.push({
|
||||
opacity: 0.5,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
baseStyles: base,
|
||||
hoverStyles: hover,
|
||||
activeStyles: active,
|
||||
textStyles: text,
|
||||
}
|
||||
}, [t, state])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
borderLeftWidth: 1,
|
||||
marginLeft: -1,
|
||||
},
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
native({
|
||||
paddingTop: 14,
|
||||
}),
|
||||
t.atoms.bg,
|
||||
t.atoms.border,
|
||||
baseStyles,
|
||||
activeStyles,
|
||||
(state.hovered || state.focused || state.pressed) && hoverStyles,
|
||||
]}>
|
||||
{typeof children === 'string' ? (
|
||||
<Text
|
||||
style={[
|
||||
a.text_center,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_500,
|
||||
textStyles,
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
21
src/components/hooks/useInteractionState.ts
Normal file
21
src/components/hooks/useInteractionState.ts
Normal file
|
@ -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],
|
||||
)
|
||||
}
|
5
src/components/icons/ArrowTopRight.tsx
Normal file
5
src/components/icons/ArrowTopRight.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const ArrowTopRight_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M8 6a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v9a1 1 0 1 1-2 0V8.414l-9.793 9.793a1 1 0 0 1-1.414-1.414L15.586 7H9a1 1 0 0 1-1-1Z',
|
||||
})
|
5
src/components/icons/CalendarDays.tsx
Normal file
5
src/components/icons/CalendarDays.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const CalendarDays_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4 3a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h16a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H4Zm1 16V9h14v10H5ZM5 7h14V5H5v2Zm3 10.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM17.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 13.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5ZM9.25 12a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0ZM12 17.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z',
|
||||
})
|
5
src/components/icons/ColorPalette.tsx
Normal file
5
src/components/icons/ColorPalette.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const ColorPalette_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4 12c0-4.09 3.527-7.5 8-7.5s8 3.41 8 7.5c0 1.579-.419 2.056-.708 2.236-.388.241-1.031.286-2.058.153-.33-.043-.652-.096-.991-.152a65.905 65.905 0 0 0-.531-.087c-.52-.081-1.077-.156-1.61-.164-1.065-.016-2.336.245-2.996 1.567-.418.834-.295 1.67-.078 2.314.18.534.47 1.055.683 1.437v.001l.097.175.01.018C7.432 19.407 4 16.033 4 12Zm8-9.5C6.532 2.5 2 6.7 2 12s4.532 9.5 10 9.5c.401 0 .812-.04 1.166-.193.41-.176.761-.517.866-1.028.085-.416-.03-.796-.118-1.029a5.981 5.981 0 0 0-.351-.73l-.12-.215c-.215-.392-.403-.73-.52-1.078-.13-.387-.111-.614-.029-.78.146-.291.404-.473 1.178-.461.385.005.825.06 1.329.14.15.023.308.05.47.077.36.059.742.122 1.105.17 1.021.132 2.325.213 3.373-.439C21.496 15.22 22 13.874 22 12c0-5.3-4.532-9.5-10-9.5Zm3.5 8.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM9 12.25a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm1.5-2.75a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z',
|
||||
})
|
5
src/components/icons/Globe.tsx
Normal file
5
src/components/icons/Globe.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Globe_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z',
|
||||
})
|
48
src/components/icons/TEMPLATE.tsx
Normal file
48
src/components/icons/TEMPLATE.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import React from 'react'
|
||||
import Svg, {Path} from 'react-native-svg'
|
||||
|
||||
import {useCommonSVGProps, Props} from '#/components/icons/common'
|
||||
|
||||
export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef(
|
||||
function LogoImpl(props: Props, ref) {
|
||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
{...rest}
|
||||
// @ts-ignore it's fiiiiine
|
||||
ref={ref}
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
style={[style]}>
|
||||
<Path
|
||||
fill={fill}
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.062 11h2.961c.103-2.204.545-4.218 1.235-5.77.06-.136.123-.269.188-.399A8.007 8.007 0 0 0 4.062 11ZM12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2Zm0 2c-.227 0-.518.1-.868.432-.354.337-.719.872-1.047 1.61-.561 1.263-.958 2.991-1.06 4.958h5.95c-.102-1.967-.499-3.695-1.06-4.958-.328-.738-.693-1.273-1.047-1.61C12.518 4.099 12.227 4 12 4Zm4.977 7c-.103-2.204-.545-4.218-1.235-5.77a9.78 9.78 0 0 0-.188-.399A8.006 8.006 0 0 1 19.938 11h-2.961Zm-2.003 2H9.026c.101 1.966.498 3.695 1.06 4.958.327.738.692 1.273 1.046 1.61.35.333.641.432.868.432.227 0 .518-.1.868-.432.354-.337.719-.872 1.047-1.61.561-1.263.958-2.991 1.06-4.958Zm.58 6.169c.065-.13.128-.263.188-.399.69-1.552 1.132-3.566 1.235-5.77h2.961a8.006 8.006 0 0 1-4.384 6.169Zm-7.108 0a9.877 9.877 0 0 1-.188-.399c-.69-1.552-1.132-3.566-1.235-5.77H4.062a8.006 8.006 0 0 0 4.384 6.169Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export function createSinglePathSVG({path}: {path: string}) {
|
||||
return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) {
|
||||
const {fill, size, style, ...rest} = useCommonSVGProps(props)
|
||||
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
{...rest}
|
||||
ref={ref}
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
height={size}
|
||||
style={[style]}>
|
||||
<Path fill={fill} fillRule="evenodd" clipRule="evenodd" d={path} />
|
||||
</Svg>
|
||||
)
|
||||
})
|
||||
}
|
32
src/components/icons/common.ts
Normal file
32
src/components/icons/common.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import {StyleSheet, TextProps} from 'react-native'
|
||||
import type {SvgProps, PathProps} from 'react-native-svg'
|
||||
|
||||
import {tokens} from '#/alf'
|
||||
|
||||
export type Props = {
|
||||
fill?: PathProps['fill']
|
||||
style?: TextProps['style']
|
||||
size?: keyof typeof sizes
|
||||
} & Omit<SvgProps, 'style' | 'size'>
|
||||
|
||||
export const sizes = {
|
||||
xs: 12,
|
||||
sm: 16,
|
||||
md: 20,
|
||||
lg: 24,
|
||||
xl: 28,
|
||||
}
|
||||
|
||||
export function useCommonSVGProps(props: Props) {
|
||||
const {fill, size, ...rest} = props
|
||||
const style = StyleSheet.flatten(rest.style)
|
||||
const _fill = fill || style?.color || tokens.color.blue_500
|
||||
const _size = Number(size ? sizes[size] : rest.width || sizes.md)
|
||||
|
||||
return {
|
||||
fill: _fill,
|
||||
size: _size,
|
||||
style,
|
||||
...rest,
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue