about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-05-06 20:27:05 +0300
committerGitHub <noreply@github.com>2025-05-06 10:27:05 -0700
commit973538d246a3f76550611e438152f1a6cad75f49 (patch)
tree83c7547eb9ba1123bac8ab8ef30f37d5164b3ce2 /src/components
parent25f8506c4152840e83ba9210452b60ea5cc0987f (diff)
downloadvoidsky-973538d246a3f76550611e438152f1a6cad75f49.tar.zst
New `Select` component (#8323)
* radix select component on web

* native implementation (wip)

* fix sheet height/padding

* tone down web styles

* react 19 cleanup

* replace primary language select

* change style on native

* get auto placeholder working

* more style tweaks

* replace app language dropdown

* replace rnpickerselect with native select

* rm react-native-picker-select dependency

* rm placeholder, since a value is always selected

* docblock for renderItem

* add more docblocks

* add style prop to item

* pass selectedValue through renderItem

* fix context

* Style overflow buttons

---------

Co-authored-by: Eric Bailey <git@esb.lol>
Diffstat (limited to 'src/components')
-rw-r--r--src/components/AppLanguageDropdown.tsx87
-rw-r--r--src/components/AppLanguageDropdown.web.tsx83
-rw-r--r--src/components/Dialog/shared.tsx11
-rw-r--r--src/components/Select/index.tsx289
-rw-r--r--src/components/Select/index.web.tsx280
-rw-r--r--src/components/Select/types.ts185
6 files changed, 811 insertions, 124 deletions
diff --git a/src/components/AppLanguageDropdown.tsx b/src/components/AppLanguageDropdown.tsx
index de2e50fc8..9837ce5ce 100644
--- a/src/components/AppLanguageDropdown.tsx
+++ b/src/components/AppLanguageDropdown.tsx
@@ -1,17 +1,19 @@
 import React from 'react'
-import {View} from 'react-native'
-import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
 
 import {sanitizeAppLanguageSetting} from '#/locale/helpers'
 import {APP_LANGUAGES} from '#/locale/languages'
 import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
 import {resetPostsFeedQueries} from '#/state/queries/post-feed'
-import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
-import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
+import {atoms as a, platform, useTheme} from '#/alf'
+import * as Select from '#/components/Select'
+import {Button} from './Button'
 
-export function AppLanguageDropdown(_props: ViewStyleProp) {
+export function AppLanguageDropdown() {
   const t = useTheme()
+  const {_} = useLingui()
 
   const queryClient = useQueryClient()
   const langPrefs = useLanguagePrefs()
@@ -19,7 +21,7 @@ export function AppLanguageDropdown(_props: ViewStyleProp) {
   const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
 
   const onChangeAppLanguage = React.useCallback(
-    (value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
+    (value: string) => {
       if (!value) return
       if (sanitizedLang !== value) {
         setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
@@ -32,43 +34,48 @@ export function AppLanguageDropdown(_props: ViewStyleProp) {
   )
 
   return (
-    <View style={a.relative}>
-      <RNPickerSelect
-        darkTheme={t.scheme === 'dark'}
-        placeholder={{}}
-        value={sanitizedLang}
-        onValueChange={onChangeAppLanguage}
-        items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
+    <Select.Root
+      value={sanitizeAppLanguageSetting(langPrefs.appLanguage)}
+      onValueChange={onChangeAppLanguage}>
+      <Select.Trigger label={_(msg`Change app language`)}>
+        {({props}) => (
+          <Button
+            {...props}
+            label={props.accessibilityLabel}
+            size={platform({
+              web: 'tiny',
+              native: 'small',
+            })}
+            variant="ghost"
+            color="secondary"
+            style={[
+              a.pr_xs,
+              a.pl_sm,
+              platform({
+                web: [{alignSelf: 'flex-start'}, a.gap_sm],
+                native: [a.gap_xs],
+              }),
+            ]}>
+            <Select.ValueText
+              placeholder={_(msg`Select an app language`)}
+              style={[t.atoms.text_contrast_medium]}
+            />
+            <Select.Icon style={[t.atoms.text_contrast_medium]} />
+          </Button>
+        )}
+      </Select.Trigger>
+      <Select.Content
+        renderItem={({label, value}) => (
+          <Select.Item value={value} label={label}>
+            <Select.ItemIndicator />
+            <Select.ItemText>{label}</Select.ItemText>
+          </Select.Item>
+        )}
+        items={APP_LANGUAGES.map(l => ({
           label: l.name,
           value: l.code2,
-          key: l.code2,
         }))}
-        useNativeAndroidPickerStyle={false}
-        style={{
-          inputAndroid: {
-            color: t.atoms.text_contrast_medium.color,
-            fontSize: 16,
-            paddingRight: 12 + 4,
-          },
-          inputIOS: {
-            color: t.atoms.text.color,
-            fontSize: 16,
-            paddingRight: 12 + 4,
-          },
-        }}
       />
-
-      <View
-        style={[
-          a.absolute,
-          a.inset_0,
-          {left: 'auto'},
-          {pointerEvents: 'none'},
-          a.align_center,
-          a.justify_center,
-        ]}>
-        <ChevronDown fill={t.atoms.text.color} size="xs" />
-      </View>
-    </View>
+    </Select.Root>
   )
 }
diff --git a/src/components/AppLanguageDropdown.web.tsx b/src/components/AppLanguageDropdown.web.tsx
deleted file mode 100644
index d51b53ac0..000000000
--- a/src/components/AppLanguageDropdown.web.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-import React from 'react'
-import {View} from 'react-native'
-import {useQueryClient} from '@tanstack/react-query'
-
-import {sanitizeAppLanguageSetting} from '#/locale/helpers'
-import {APP_LANGUAGES} from '#/locale/languages'
-import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
-import {resetPostsFeedQueries} from '#/state/queries/post-feed'
-import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
-import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
-import {Text} from '#/components/Typography'
-
-export function AppLanguageDropdown({style}: ViewStyleProp) {
-  const t = useTheme()
-
-  const queryClient = useQueryClient()
-  const langPrefs = useLanguagePrefs()
-  const setLangPrefs = useLanguagePrefsApi()
-
-  const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
-
-  const onChangeAppLanguage = React.useCallback(
-    (ev: React.ChangeEvent<HTMLSelectElement>) => {
-      const value = ev.target.value
-
-      if (!value) return
-      if (sanitizedLang !== value) {
-        setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
-      }
-
-      // reset feeds to refetch content
-      resetPostsFeedQueries(queryClient)
-    },
-    [sanitizedLang, setLangPrefs, queryClient],
-  )
-
-  return (
-    <View
-      style={[
-        // We don't have hitSlop here to increase the tap region,
-        // alternative is negative margins.
-        {height: 32, marginVertical: -((32 - 14) / 2)},
-        style,
-      ]}>
-      <View
-        style={[
-          a.flex_row,
-          a.gap_sm,
-          a.align_center,
-          a.flex_shrink,
-          a.h_full,
-          t.atoms.bg,
-        ]}>
-        <Text aria-hidden={true} style={t.atoms.text_contrast_medium}>
-          {APP_LANGUAGES.find(l => l.code2 === sanitizedLang)?.name}
-        </Text>
-        <ChevronDown fill={t.atoms.text.color} size="xs" style={a.flex_0} />
-      </View>
-
-      <select
-        value={sanitizedLang}
-        onChange={onChangeAppLanguage}
-        style={{
-          fontSize: a.text_sm.fontSize,
-          letterSpacing: a.text_sm.letterSpacing,
-          cursor: 'pointer',
-          position: 'absolute',
-          inset: 0,
-          opacity: 0,
-          color: t.atoms.text.color,
-          background: t.atoms.bg.backgroundColor,
-          padding: 4,
-          maxWidth: '100%',
-        }}>
-        {APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => (
-          <option key={l.code2} value={l.code2}>
-            {l.name}
-          </option>
-        ))}
-      </select>
-    </View>
-  )
-}
diff --git a/src/components/Dialog/shared.tsx b/src/components/Dialog/shared.tsx
index 44a4f6b0b..eec47b2ba 100644
--- a/src/components/Dialog/shared.tsx
+++ b/src/components/Dialog/shared.tsx
@@ -1,5 +1,11 @@
 import React from 'react'
-import {StyleProp, TextStyle, View, ViewStyle} from 'react-native'
+import {
+  LayoutChangeEvent,
+  StyleProp,
+  TextStyle,
+  View,
+  ViewStyle,
+} from 'react-native'
 
 import {atoms as a, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
@@ -9,15 +15,18 @@ export function Header({
   renderRight,
   children,
   style,
+  onLayout,
 }: {
   renderLeft?: () => React.ReactNode
   renderRight?: () => React.ReactNode
   children?: React.ReactNode
   style?: StyleProp<ViewStyle>
+  onLayout?: (event: LayoutChangeEvent) => void
 }) {
   const t = useTheme()
   return (
     <View
+      onLayout={onLayout}
       style={[
         a.relative,
         a.w_full,
diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx
new file mode 100644
index 000000000..4e8e53216
--- /dev/null
+++ b/src/components/Select/index.tsx
@@ -0,0 +1,289 @@
+import {
+  createContext,
+  useCallback,
+  useContext,
+  useLayoutEffect,
+  useMemo,
+  useState,
+} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useTheme} from '#/alf'
+import {atoms as a} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
+import {ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon} from '#/components/icons/Chevron'
+import {Text} from '#/components/Typography'
+import {
+  type ContentProps,
+  type IconProps,
+  type ItemIndicatorProps,
+  type ItemProps,
+  type ItemTextProps,
+  type RootProps,
+  type TriggerProps,
+  type ValueProps,
+} from './types'
+
+type ContextType = {
+  control: Dialog.DialogControlProps
+} & Pick<RootProps, 'value' | 'onValueChange' | 'disabled'>
+
+const Context = createContext<ContextType | null>(null)
+
+const ValueTextContext = createContext<
+  [any, React.Dispatch<React.SetStateAction<any>>]
+>([undefined, () => {}])
+
+function useSelectContext() {
+  const ctx = useContext(Context)
+  if (!ctx) {
+    throw new Error('Select components must must be used within a Select.Root')
+  }
+  return ctx
+}
+
+export function Root({children, value, onValueChange, disabled}: RootProps) {
+  const control = Dialog.useDialogControl()
+  const valueTextCtx = useState<any>()
+
+  const ctx = useMemo(
+    () => ({
+      control,
+      value,
+      onValueChange,
+      disabled,
+    }),
+    [control, value, onValueChange, disabled],
+  )
+  return (
+    <Context.Provider value={ctx}>
+      <ValueTextContext.Provider value={valueTextCtx}>
+        {children}
+      </ValueTextContext.Provider>
+    </Context.Provider>
+  )
+}
+
+export function Trigger({children, label}: TriggerProps) {
+  const {control} = useSelectContext()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+
+  if (typeof children === 'function') {
+    return children({
+      isNative: true,
+      control,
+      state: {
+        hovered: false,
+        focused,
+        pressed,
+      },
+      props: {
+        onPress: control.open,
+        onFocus,
+        onBlur,
+        onPressIn,
+        onPressOut,
+        accessibilityLabel: label,
+      },
+    })
+  } else {
+    return (
+      <Button
+        label={label}
+        onPress={control.open}
+        style={[a.flex_1, a.justify_between]}
+        color="secondary"
+        size="small"
+        variant="solid">
+        <>{children}</>
+      </Button>
+    )
+  }
+}
+
+export function ValueText({
+  placeholder,
+  children = value => value.label,
+  style,
+}: ValueProps) {
+  const [value] = useContext(ValueTextContext)
+  const t = useTheme()
+
+  let text = value && children(value)
+  if (typeof text !== 'string') text = placeholder
+
+  return (
+    <ButtonText style={[t.atoms.text, a.font_normal, style]}>{text}</ButtonText>
+  )
+}
+
+export function Icon({}: IconProps) {
+  return <ButtonIcon icon={ChevronUpDownIcon} />
+}
+
+export function Content<T>({
+  items,
+  valueExtractor = defaultItemValueExtractor,
+  ...props
+}: ContentProps<T>) {
+  const {control, ...context} = useSelectContext()
+  const [, setValue] = useContext(ValueTextContext)
+
+  useLayoutEffect(() => {
+    const item = items.find(item => valueExtractor(item) === context.value)
+    if (item) {
+      setValue(item)
+    }
+  }, [items, context.value, valueExtractor, setValue])
+
+  return (
+    <Dialog.Outer control={control}>
+      <ContentInner
+        control={control}
+        items={items}
+        valueExtractor={valueExtractor}
+        {...props}
+        {...context}
+      />
+    </Dialog.Outer>
+  )
+}
+
+function ContentInner<T>({
+  items,
+  renderItem,
+  valueExtractor,
+  ...context
+}: ContentProps<T> & ContextType) {
+  const control = Dialog.useDialogContext()
+
+  const {_} = useLingui()
+  const [headerHeight, setHeaderHeight] = useState(50)
+
+  const render = useCallback(
+    ({item, index}: {item: T; index: number}) => {
+      return renderItem(item, index, context.value)
+    },
+    [renderItem, context.value],
+  )
+
+  const doneButton = useCallback(
+    () => (
+      <Button
+        label={_(msg`Done`)}
+        onPress={() => control.close()}
+        size="small"
+        color="primary"
+        variant="ghost"
+        style={[a.rounded_full]}>
+        <ButtonText style={[a.text_md]}>
+          <Trans>Done</Trans>
+        </ButtonText>
+      </Button>
+    ),
+    [control, _],
+  )
+
+  return (
+    <Context.Provider value={context}>
+      <Dialog.Header
+        renderRight={doneButton}
+        onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}
+        style={[a.absolute, a.top_0, a.left_0, a.right_0, a.z_10]}>
+        <Dialog.HeaderText>
+          <Trans>Select an option</Trans>
+        </Dialog.HeaderText>
+      </Dialog.Header>
+      <Dialog.InnerFlatList
+        headerOffset={headerHeight}
+        data={items}
+        renderItem={render}
+        keyExtractor={valueExtractor}
+      />
+    </Context.Provider>
+  )
+}
+
+function defaultItemValueExtractor(item: any) {
+  return item.value
+}
+
+const ItemContext = createContext<{
+  selected: boolean
+  hovered: boolean
+  focused: boolean
+  pressed: boolean
+}>({
+  selected: false,
+  hovered: false,
+  focused: false,
+  pressed: false,
+})
+
+export function useItemContext() {
+  return useContext(ItemContext)
+}
+
+export function Item({children, value, label, style}: ItemProps) {
+  const t = useTheme()
+  const control = Dialog.useDialogContext()
+  const {value: selected, onValueChange} = useSelectContext()
+
+  return (
+    <Button
+      role="listitem"
+      label={label}
+      style={[a.flex_1]}
+      onPress={() => {
+        control.close(() => {
+          onValueChange?.(value)
+        })
+      }}>
+      {({hovered, focused, pressed}) => (
+        <ItemContext.Provider
+          value={{selected: value === selected, hovered, focused, pressed}}>
+          <View
+            style={[
+              a.flex_1,
+              a.pl_md,
+              (focused || pressed) && t.atoms.bg_contrast_25,
+              a.flex_row,
+              a.align_center,
+              a.gap_sm,
+              style,
+            ]}>
+            {children}
+          </View>
+        </ItemContext.Provider>
+      )}
+    </Button>
+  )
+}
+
+export function ItemText({children}: ItemTextProps) {
+  const {selected} = useItemContext()
+  const t = useTheme()
+
+  // eslint-disable-next-line bsky-internal/avoid-unwrapped-text
+  return (
+    <View style={[a.flex_1, a.py_md, a.border_b, t.atoms.border_contrast_low]}>
+      <Text style={[a.text_md, selected && a.font_bold]}>{children}</Text>
+    </View>
+  )
+}
+
+export function ItemIndicator({icon: Icon = CheckIcon}: ItemIndicatorProps) {
+  const {selected} = useItemContext()
+
+  return <View style={{width: 24}}>{selected && <Icon size="md" />}</View>
+}
diff --git a/src/components/Select/index.web.tsx b/src/components/Select/index.web.tsx
new file mode 100644
index 000000000..e9d26631c
--- /dev/null
+++ b/src/components/Select/index.web.tsx
@@ -0,0 +1,280 @@
+import {createContext, forwardRef, useContext, useMemo} from 'react'
+import {View} from 'react-native'
+import {Select as RadixSelect} from 'radix-ui'
+
+import {flatten, useTheme} from '#/alf'
+import {atoms as a} from '#/alf'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
+import {
+  ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
+  ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
+} from '#/components/icons/Chevron'
+import {Text} from '#/components/Typography'
+import {
+  type ContentProps,
+  type IconProps,
+  type ItemIndicatorProps,
+  type ItemProps,
+  type RadixPassThroughTriggerProps,
+  type RootProps,
+  type TriggerProps,
+  type ValueProps,
+} from './types'
+
+const SelectedValueContext = createContext<string | undefined | null>(null)
+
+export function Root(props: RootProps) {
+  return (
+    <SelectedValueContext.Provider value={props.value}>
+      <RadixSelect.Root {...props} />
+    </SelectedValueContext.Provider>
+  )
+}
+
+const RadixTriggerPassThrough = forwardRef(
+  (
+    props: {
+      children: (
+        props: RadixPassThroughTriggerProps & {
+          ref: React.Ref<any>
+        },
+      ) => React.ReactNode
+    },
+    ref,
+  ) => {
+    // @ts-expect-error Radix provides no types of this stuff
+
+    return props.children?.({...props, ref})
+  },
+)
+RadixTriggerPassThrough.displayName = 'RadixTriggerPassThrough'
+
+export function Trigger({children, label}: TriggerProps) {
+  const t = useTheme()
+  const {
+    state: hovered,
+    onIn: onMouseEnter,
+    onOut: onMouseLeave,
+  } = useInteractionState()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+
+  if (typeof children === 'function') {
+    return (
+      <RadixSelect.Trigger asChild>
+        <RadixTriggerPassThrough>
+          {props =>
+            children({
+              isNative: false,
+              state: {
+                hovered,
+                focused,
+                pressed: false,
+              },
+              props: {
+                ...props,
+                onFocus: onFocus,
+                onBlur: onBlur,
+                onMouseEnter,
+                onMouseLeave,
+                accessibilityLabel: label,
+              },
+            })
+          }
+        </RadixTriggerPassThrough>
+      </RadixSelect.Trigger>
+    )
+  } else {
+    return (
+      <RadixSelect.Trigger
+        onFocus={onFocus}
+        onBlur={onBlur}
+        onMouseEnter={onMouseEnter}
+        onMouseLeave={onMouseLeave}
+        style={flatten([
+          a.flex,
+          a.relative,
+          t.atoms.bg_contrast_25,
+          a.rounded_sm,
+          a.w_full,
+          {maxWidth: 400},
+          a.align_center,
+          a.gap_sm,
+          a.justify_between,
+          a.py_sm,
+          a.px_md,
+          {
+            outline: 0,
+            borderWidth: 2,
+            borderStyle: 'solid',
+            borderColor: focused
+              ? t.palette.primary_500
+              : hovered
+              ? t.palette.contrast_100
+              : t.palette.contrast_25,
+          },
+        ])}>
+        {children}
+      </RadixSelect.Trigger>
+    )
+  }
+}
+
+export function ValueText({children: _, style, ...props}: ValueProps) {
+  return (
+    <Text style={style}>
+      <RadixSelect.Value {...props} />
+    </Text>
+  )
+}
+
+export function Icon({style}: IconProps) {
+  const t = useTheme()
+  return (
+    <RadixSelect.Icon>
+      <ChevronDownIcon style={[t.atoms.text, style]} size="xs" />
+    </RadixSelect.Icon>
+  )
+}
+
+export function Content<T>({items, renderItem}: ContentProps<T>) {
+  const t = useTheme()
+  const selectedValue = useContext(SelectedValueContext)
+
+  const scrollBtnStyles: React.CSSProperties[] = [
+    a.absolute,
+    a.flex,
+    a.align_center,
+    a.justify_center,
+    a.rounded_sm,
+    a.z_10,
+  ]
+  const up: React.CSSProperties[] = [
+    ...scrollBtnStyles,
+    a.pt_sm,
+    a.pb_lg,
+    {
+      top: 0,
+      left: 0,
+      right: 0,
+      borderBottomLeftRadius: 0,
+      borderBottomRightRadius: 0,
+      background: `linear-gradient(to bottom, ${t.atoms.bg.backgroundColor} 0%, transparent 100%)`,
+    },
+  ]
+  const down: React.CSSProperties[] = [
+    ...scrollBtnStyles,
+    a.pt_lg,
+    a.pb_sm,
+    {
+      bottom: 0,
+      left: 0,
+      right: 0,
+      borderBottomLeftRadius: 0,
+      borderBottomRightRadius: 0,
+      background: `linear-gradient(to top, ${t.atoms.bg.backgroundColor} 0%, transparent 100%)`,
+    },
+  ]
+
+  return (
+    <RadixSelect.Portal>
+      <RadixSelect.Content
+        style={flatten([t.atoms.bg, a.rounded_sm, a.overflow_hidden])}
+        position="popper"
+        sideOffset={5}
+        className="radix-select-content">
+        <View
+          style={[
+            a.flex_1,
+            a.border,
+            t.atoms.border_contrast_low,
+            a.rounded_sm,
+          ]}>
+          <RadixSelect.ScrollUpButton style={flatten(up)}>
+            <ChevronUpIcon style={[t.atoms.text]} size="xs" />
+          </RadixSelect.ScrollUpButton>
+          <RadixSelect.Viewport style={flatten([a.p_xs])}>
+            {items.map((item, index) => renderItem(item, index, selectedValue))}
+          </RadixSelect.Viewport>
+          <RadixSelect.ScrollDownButton style={flatten(down)}>
+            <ChevronDownIcon style={[t.atoms.text]} size="xs" />
+          </RadixSelect.ScrollDownButton>
+        </View>
+      </RadixSelect.Content>
+    </RadixSelect.Portal>
+  )
+}
+
+const ItemContext = createContext<{
+  hovered: boolean
+  focused: boolean
+  pressed: boolean
+  selected: boolean
+}>({
+  hovered: false,
+  focused: false,
+  pressed: false,
+  selected: false,
+})
+
+export function useItemContext() {
+  return useContext(ItemContext)
+}
+
+export function Item({ref, value, style, children}: ItemProps) {
+  const t = useTheme()
+  const {
+    state: hovered,
+    onIn: onMouseEnter,
+    onOut: onMouseLeave,
+  } = useInteractionState()
+  const selected = useContext(SelectedValueContext) === value
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+  const ctx = useMemo(
+    () => ({hovered, focused, pressed: false, selected}),
+    [hovered, focused, selected],
+  )
+  return (
+    <RadixSelect.Item
+      ref={ref}
+      value={value}
+      onMouseEnter={onMouseEnter}
+      onMouseLeave={onMouseLeave}
+      onFocus={onFocus}
+      onBlur={onBlur}
+      style={flatten([
+        a.relative,
+        a.flex,
+        {minHeight: 25, paddingLeft: 30, paddingRight: 35},
+        a.user_select_none,
+        a.align_center,
+        a.rounded_xs,
+        a.py_2xs,
+        a.text_sm,
+        {outline: 0},
+        (hovered || focused) && {backgroundColor: t.palette.primary_50},
+        selected && [a.font_bold],
+        a.transition_color,
+        style,
+      ])}>
+      <ItemContext.Provider value={ctx}>{children}</ItemContext.Provider>
+    </RadixSelect.Item>
+  )
+}
+
+export const ItemText = RadixSelect.ItemText
+
+export function ItemIndicator({icon: Icon = CheckIcon}: ItemIndicatorProps) {
+  return (
+    <RadixSelect.ItemIndicator
+      style={flatten([
+        a.absolute,
+        {left: 0, width: 30},
+        a.flex,
+        a.align_center,
+        a.justify_center,
+      ])}>
+      <Icon size="sm" />
+    </RadixSelect.ItemIndicator>
+  )
+}
diff --git a/src/components/Select/types.ts b/src/components/Select/types.ts
new file mode 100644
index 000000000..5c1b80a3b
--- /dev/null
+++ b/src/components/Select/types.ts
@@ -0,0 +1,185 @@
+import {
+  type AccessibilityProps,
+  type StyleProp,
+  type TextStyle,
+  type ViewStyle,
+} from 'react-native'
+
+import {type TextStyleProp} from '#/alf'
+import {type DialogControlProps} from '#/components/Dialog'
+import {type Props as SVGIconProps} from '#/components/icons/common'
+
+export type RootProps = {
+  children?: React.ReactNode
+  value?: string
+  onValueChange?: (value: string) => void
+  disabled?: boolean
+  /**
+   * @platform web
+   */
+  defaultValue?: string
+  /**
+   * @platform web
+   */
+  open?: boolean
+  /**
+   * @platform web
+   */
+  defaultOpen?: boolean
+  /**
+   * @platform web
+   */
+  onOpenChange?(open: boolean): void
+  /**
+   * @platform web
+   */
+  name?: string
+  /**
+   * @platform web
+   */
+  autoComplete?: string
+  /**
+   * @platform web
+   */
+  required?: boolean
+}
+
+export type RadixPassThroughTriggerProps = {
+  id: string
+  type: 'button'
+  disabled: boolean
+  ['data-disabled']: boolean
+  ['data-state']: string
+  ['aria-controls']?: string
+  ['aria-haspopup']?: boolean
+  ['aria-expanded']?: AccessibilityProps['aria-expanded']
+  onPress: () => void
+}
+
+export type TriggerProps = {
+  children: React.ReactNode | ((props: TriggerChildProps) => React.ReactNode)
+  label: string
+}
+
+export type TriggerChildProps =
+  | {
+      isNative: true
+      control: DialogControlProps
+      state: {
+        /**
+         * Web only, `false` on native
+         */
+        hovered: false
+        focused: boolean
+        pressed: boolean
+      }
+      /**
+       * We don't necessarily know what these will be spread on to, so we
+       * should add props one-by-one.
+       *
+       * On web, these properties are applied to a parent `Pressable`, so this
+       * object is empty.
+       */
+      props: {
+        onPress: () => void
+        onFocus: () => void
+        onBlur: () => void
+        onPressIn: () => void
+        onPressOut: () => void
+        accessibilityLabel: string
+      }
+    }
+  | {
+      isNative: false
+      state: {
+        hovered: boolean
+        focused: boolean
+        /**
+         * Native only, `false` on web
+         */
+        pressed: false
+      }
+      props: RadixPassThroughTriggerProps & {
+        onPress: () => void
+        onFocus: () => void
+        onBlur: () => void
+        onMouseEnter: () => void
+        onMouseLeave: () => void
+        accessibilityLabel: string
+      }
+    }
+
+/*
+ * For use within the `Select.Trigger` component.
+ * Shows the currently selected value. You can also
+ * provide a placeholder to show when no value is selected.
+ *
+ * If you're passing items of a different shape than {value: string, label: string},
+ * you'll need to pass a function to `children` that extracts the label from an item.
+ */
+export type ValueProps = {
+  /**
+   * Only needed for native. Extracts the label from an item. Defaults to `item => item.label`
+   */
+  children?: (value: any) => string
+  placeholder?: string
+  style?: StyleProp<TextStyle>
+}
+
+/*
+ * Icon for use within the `Select.Trigger` component.
+ * Changes based on platform - chevron down on web, up/down chevrons on native
+ *
+ * `style` prop is web only
+ */
+export type IconProps = TextStyleProp
+
+export type ContentProps<T> = {
+  /**
+   * Items to render. Recommended to be in the form {value: string, label: string} - if not,
+   * you need to provide a `valueExtractor` function to extract the value from an item and
+   * customise the `Select.ValueText` component.
+   */
+  items: T[]
+  /**
+   * Renders an item. You should probably use the `Select.Item` component.
+   *
+   * @example
+   * ```tsx
+   * renderItem={({label, value}) => (
+   *   <Select.Item value={value} label={label}>
+   *     <Select.ItemIndicator />
+   *     <Select.ItemText>{label}</Select.ItemText>
+   *   </Select.Item>
+   * )}
+   * ```
+   */
+  renderItem: (
+    item: T,
+    index: number,
+    selectedValue?: string | null,
+  ) => React.ReactElement
+  /*
+   * Extracts the value from an item. Defaults to `item => item.value`
+   */
+  valueExtractor?: (item: T) => string
+}
+
+/*
+ * An item within the select dropdown
+ */
+export type ItemProps = {
+  ref?: React.Ref<HTMLDivElement>
+  value: string
+  label: string
+  children: React.ReactNode
+  style?: StyleProp<ViewStyle>
+}
+
+export type ItemTextProps = {
+  children: React.ReactNode
+}
+
+export type ItemIndicatorProps = {
+  icon?: React.ComponentType<SVGIconProps>
+}