about summary refs log tree commit diff
path: root/src/components/Select
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/Select')
-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
3 files changed, 754 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>
+}
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>
+}