about summary refs log tree commit diff
path: root/src/components/forms
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/forms')
-rw-r--r--src/components/forms/DateField/index.android.tsx108
-rw-r--r--src/components/forms/DateField/index.tsx56
-rw-r--r--src/components/forms/DateField/index.web.tsx64
-rw-r--r--src/components/forms/DateField/types.ts7
-rw-r--r--src/components/forms/DateField/utils.ts16
-rw-r--r--src/components/forms/InputGroup.tsx43
-rw-r--r--src/components/forms/TextField.tsx334
-rw-r--r--src/components/forms/Toggle.tsx473
-rw-r--r--src/components/forms/ToggleButton.tsx124
9 files changed, 1225 insertions, 0 deletions
diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx
new file mode 100644
index 000000000..83fa285f5
--- /dev/null
+++ b/src/components/forms/DateField/index.android.tsx
@@ -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>
+  )
+}
diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx
new file mode 100644
index 000000000..c359a9d46
--- /dev/null
+++ b/src/components/forms/DateField/index.tsx
@@ -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>
+  )
+}
diff --git a/src/components/forms/DateField/index.web.tsx b/src/components/forms/DateField/index.web.tsx
new file mode 100644
index 000000000..32f38a5d1
--- /dev/null
+++ b/src/components/forms/DateField/index.web.tsx
@@ -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>
+  )
+}
diff --git a/src/components/forms/DateField/types.ts b/src/components/forms/DateField/types.ts
new file mode 100644
index 000000000..129f5672d
--- /dev/null
+++ b/src/components/forms/DateField/types.ts
@@ -0,0 +1,7 @@
+export type DateFieldProps = {
+  value: string
+  onChangeDate: (date: string) => void
+  label: string
+  isInvalid?: boolean
+  testID?: string
+}
diff --git a/src/components/forms/DateField/utils.ts b/src/components/forms/DateField/utils.ts
new file mode 100644
index 000000000..c787272fe
--- /dev/null
+++ b/src/components/forms/DateField/utils.ts
@@ -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)
+}
diff --git a/src/components/forms/InputGroup.tsx b/src/components/forms/InputGroup.tsx
new file mode 100644
index 000000000..6908d4df8
--- /dev/null
+++ b/src/components/forms/InputGroup.tsx
@@ -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>
+  )
+}
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
new file mode 100644
index 000000000..1ee58303a
--- /dev/null
+++ b/src/components/forms/TextField.tsx
@@ -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>
+  )
+}
diff --git a/src/components/forms/Toggle.tsx b/src/components/forms/Toggle.tsx
new file mode 100644
index 000000000..ad82bdff5
--- /dev/null
+++ b/src/components/forms/Toggle.tsx
@@ -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>
+  )
+}
diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx
new file mode 100644
index 000000000..615fedae8
--- /dev/null
+++ b/src/components/forms/ToggleButton.tsx
@@ -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>
+  )
+}