diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-05-06 20:27:05 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-05-06 10:27:05 -0700 |
commit | 973538d246a3f76550611e438152f1a6cad75f49 (patch) | |
tree | 83c7547eb9ba1123bac8ab8ef30f37d5164b3ce2 /src/components/Select | |
parent | 25f8506c4152840e83ba9210452b60ea5cc0987f (diff) | |
download | voidsky-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')
-rw-r--r-- | src/components/Select/index.tsx | 289 | ||||
-rw-r--r-- | src/components/Select/index.web.tsx | 280 | ||||
-rw-r--r-- | src/components/Select/types.ts | 185 |
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> +} |