about summary refs log tree commit diff
path: root/src/components/Select/index.tsx
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/Select/index.tsx
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/Select/index.tsx')
-rw-r--r--src/components/Select/index.tsx289
1 files changed, 289 insertions, 0 deletions
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>
+}