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
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>
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue