diff options
author | Eric Bailey <git@esb.lol> | 2024-03-05 21:15:42 -0600 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-03-05 21:15:42 -0600 |
commit | 317e0cda7a30d21f35229c096b6ef3284819d19a (patch) | |
tree | 1999f3a766966bda7bcc8934ac0a8b45cc7633cd /src/components | |
parent | e721f84a2cd64bd98f54049bd17925ddf1b194c8 (diff) | |
download | voidsky-317e0cda7a30d21f35229c096b6ef3284819d19a.tar.zst |
Add `Menu` component (#3097)
* Add POC menu abstraction * Better platform handling * Remove ignore * Add some menu items * Add controlled dropdown * Pass through a11y props * Ignore uninitialized context * Tweaks * Usability improvements * Rename handlers to props * Add radix comment * Ignore known type * Remove todo * Move storybook item * Improve Group matching * Adjust theming
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/Dialog/context.ts | 27 | ||||
-rw-r--r-- | src/components/Dialog/types.ts | 1 | ||||
-rw-r--r-- | src/components/Menu/context.tsx | 8 | ||||
-rw-r--r-- | src/components/Menu/index.tsx | 190 | ||||
-rw-r--r-- | src/components/Menu/index.web.tsx | 247 | ||||
-rw-r--r-- | src/components/Menu/types.ts | 72 |
6 files changed, 534 insertions, 11 deletions
diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts index eb717d8e2..9b571e8e9 100644 --- a/src/components/Dialog/context.ts +++ b/src/components/Dialog/context.ts @@ -21,7 +21,8 @@ export function useDialogControl(): DialogOuterProps['control'] { open: () => {}, close: () => {}, }) - const {activeDialogs} = useDialogStateContext() + const {activeDialogs, openDialogs} = useDialogStateContext() + const isOpen = openDialogs.includes(id) React.useEffect(() => { activeDialogs.current.set(id, control) @@ -31,14 +32,18 @@ export function useDialogControl(): DialogOuterProps['control'] { } }, [id, activeDialogs]) - return { - id, - ref: control, - open: () => { - control.current.open() - }, - close: cb => { - control.current.close(cb) - }, - } + return React.useMemo<DialogOuterProps['control']>( + () => ({ + id, + ref: control, + isOpen, + open: () => { + control.current.open() + }, + close: cb => { + control.current.close(cb) + }, + }), + [id, control, isOpen], + ) } diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts index 78dfedf5a..fa9398fe0 100644 --- a/src/components/Dialog/types.ts +++ b/src/components/Dialog/types.ts @@ -22,6 +22,7 @@ export type DialogControlRefProps = { export type DialogControlProps = DialogControlRefProps & { id: string ref: React.RefObject<DialogControlRefProps> + isOpen: boolean } export type DialogContextProps = { diff --git a/src/components/Menu/context.tsx b/src/components/Menu/context.tsx new file mode 100644 index 000000000..9fc91f681 --- /dev/null +++ b/src/components/Menu/context.tsx @@ -0,0 +1,8 @@ +import React from 'react' + +import type {ContextType} from '#/components/Menu/types' + +export const Context = React.createContext<ContextType>({ + // @ts-ignore + control: null, +}) diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx new file mode 100644 index 000000000..ee96a5667 --- /dev/null +++ b/src/components/Menu/index.tsx @@ -0,0 +1,190 @@ +import React from 'react' +import {View, Pressable} from 'react-native' +import flattenReactChildren from 'react-keyed-flatten-children' + +import {atoms as a, useTheme} from '#/alf' +import * as Dialog from '#/components/Dialog' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {Text} from '#/components/Typography' + +import {Context} from '#/components/Menu/context' +import { + ContextType, + TriggerProps, + ItemProps, + GroupProps, + ItemTextProps, + ItemIconProps, +} from '#/components/Menu/types' + +export {useDialogControl as useMenuControl} from '#/components/Dialog' + +export function useMemoControlContext() { + return React.useContext(Context) +} + +export function Root({ + children, + control, +}: React.PropsWithChildren<{ + control?: Dialog.DialogOuterProps['control'] +}>) { + const defaultControl = Dialog.useDialogControl() + const context = React.useMemo<ContextType>( + () => ({ + control: control || defaultControl, + }), + [control, defaultControl], + ) + + return <Context.Provider value={context}>{children}</Context.Provider> +} + +export function Trigger({children, label}: TriggerProps) { + const {control} = React.useContext(Context) + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + + return children({ + isNative: true, + control, + state: { + hovered: false, + focused, + pressed, + }, + props: { + onPress: control.open, + onFocus, + onBlur, + onPressIn, + onPressOut, + accessibilityLabel: label, + }, + }) +} + +export function Outer({children}: React.PropsWithChildren<{}>) { + const context = React.useContext(Context) + + return ( + <Dialog.Outer control={context.control}> + <Dialog.Handle /> + + {/* Re-wrap with context since Dialogs are portal-ed to root */} + <Context.Provider value={context}> + <Dialog.ScrollableInner label="Menu TODO"> + <View style={[a.gap_lg]}>{children}</View> + <View style={{height: a.gap_lg.gap}} /> + </Dialog.ScrollableInner> + </Context.Provider> + </Dialog.Outer> + ) +} + +export function Item({children, label, style, onPress, ...rest}: ItemProps) { + const t = useTheme() + const {control} = React.useContext(Context) + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const { + state: pressed, + onIn: onPressIn, + onOut: onPressOut, + } = useInteractionState() + + return ( + <Pressable + {...rest} + accessibilityHint="" + accessibilityLabel={label} + onPress={e => { + onPress(e) + + if (!e.defaultPrevented) { + control?.close() + } + }} + onFocus={onFocus} + onBlur={onBlur} + onPressIn={onPressIn} + onPressOut={onPressOut} + style={[ + a.flex_row, + a.align_center, + a.gap_sm, + a.px_md, + a.rounded_md, + a.border, + t.atoms.bg_contrast_25, + t.atoms.border_contrast_low, + {minHeight: 44, paddingVertical: 10}, + style, + (focused || pressed) && [t.atoms.bg_contrast_50], + ]}> + {children} + </Pressable> + ) +} + +export function ItemText({children, style}: ItemTextProps) { + const t = useTheme() + return ( + <Text + numberOfLines={1} + ellipsizeMode="middle" + style={[ + a.flex_1, + a.text_md, + a.font_bold, + t.atoms.text_contrast_medium, + {paddingTop: 3}, + style, + ]}> + {children} + </Text> + ) +} + +export function ItemIcon({icon: Comp}: ItemIconProps) { + const t = useTheme() + return <Comp size="lg" fill={t.atoms.text_contrast_medium.color} /> +} + +export function Group({children, style}: GroupProps) { + const t = useTheme() + return ( + <View + style={[ + a.rounded_md, + a.overflow_hidden, + a.border, + t.atoms.border_contrast_low, + style, + ]}> + {flattenReactChildren(children).map((child, i) => { + return React.isValidElement(child) && child.type === Item ? ( + <React.Fragment key={i}> + {i > 0 ? ( + <View style={[a.border_b, t.atoms.border_contrast_low]} /> + ) : null} + {React.cloneElement(child, { + // @ts-ignore + style: { + borderRadius: 0, + borderWidth: 0, + }, + })} + </React.Fragment> + ) : null + })} + </View> + ) +} + +export function Divider() { + return null +} diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx new file mode 100644 index 000000000..ca2e40566 --- /dev/null +++ b/src/components/Menu/index.web.tsx @@ -0,0 +1,247 @@ +import React from 'react' +import {View, Pressable} from 'react-native' +import * as DropdownMenu from '@radix-ui/react-dropdown-menu' + +import * as Dialog from '#/components/Dialog' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {atoms as a, useTheme, flatten, web} from '#/alf' +import {Text} from '#/components/Typography' + +import { + ContextType, + TriggerProps, + ItemProps, + GroupProps, + ItemTextProps, + ItemIconProps, +} from '#/components/Menu/types' +import {Context} from '#/components/Menu/context' + +export function useMenuControl(): Dialog.DialogControlProps { + const id = React.useId() + const [isOpen, setIsOpen] = React.useState(false) + + return React.useMemo( + () => ({ + id, + ref: {current: null}, + isOpen, + open() { + setIsOpen(true) + }, + close() { + setIsOpen(false) + }, + }), + [id, isOpen, setIsOpen], + ) +} + +export function useMemoControlContext() { + return React.useContext(Context) +} + +export function Root({ + children, + control, +}: React.PropsWithChildren<{ + control?: Dialog.DialogOuterProps['control'] +}>) { + const defaultControl = useMenuControl() + const context = React.useMemo<ContextType>( + () => ({ + control: control || defaultControl, + }), + [control, defaultControl], + ) + const onOpenChange = React.useCallback( + (open: boolean) => { + if (context.control.isOpen && !open) { + context.control.close() + } else if (!context.control.isOpen && open) { + context.control.open() + } + }, + [context.control], + ) + + return ( + <Context.Provider value={context}> + <DropdownMenu.Root + open={context.control.isOpen} + onOpenChange={onOpenChange}> + {children} + </DropdownMenu.Root> + </Context.Provider> + ) +} + +export function Trigger({children, label, style}: TriggerProps) { + const {control} = React.useContext(Context) + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + return ( + <DropdownMenu.Trigger asChild> + <Pressable + accessibilityHint="" + accessibilityLabel={label} + onFocus={onFocus} + onBlur={onBlur} + style={flatten([style, web({outline: 0})])} + onPointerDown={() => { + control.open() + }} + {...web({ + onMouseEnter, + onMouseLeave, + })}> + {children({ + isNative: false, + control, + state: { + hovered, + focused, + pressed: false, + }, + props: {}, + })} + </Pressable> + </DropdownMenu.Trigger> + ) +} + +export function Outer({children}: React.PropsWithChildren<{}>) { + const t = useTheme() + + return ( + <DropdownMenu.Portal> + <DropdownMenu.Content sideOffset={5} loop aria-label="Test"> + <View + style={[ + a.rounded_sm, + a.p_xs, + t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25, + t.atoms.shadow_md, + ]}> + {children} + </View> + + <DropdownMenu.Arrow + className="DropdownMenuArrow" + fill={ + (t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25) + .backgroundColor + } + /> + </DropdownMenu.Content> + </DropdownMenu.Portal> + ) +} + +export function Item({children, label, onPress, ...rest}: ItemProps) { + const t = useTheme() + const {control} = React.useContext(Context) + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + + return ( + <DropdownMenu.Item asChild> + <Pressable + {...rest} + className="radix-dropdown-item" + accessibilityHint="" + accessibilityLabel={label} + onPress={e => { + onPress(e) + + /** + * Ported forward from Radix + * @see https://www.radix-ui.com/primitives/docs/components/dropdown-menu#item + */ + if (!e.defaultPrevented) { + control.close() + } + }} + onFocus={onFocus} + onBlur={onBlur} + // need `flatten` here for Radix compat + style={flatten([ + a.flex_row, + a.align_center, + a.gap_sm, + a.py_sm, + a.rounded_xs, + {minHeight: 32, paddingHorizontal: 10}, + web({outline: 0}), + (hovered || focused) && [ + web({outline: '0 !important'}), + t.name === 'light' + ? t.atoms.bg_contrast_25 + : t.atoms.bg_contrast_50, + ], + ])} + {...web({ + onMouseEnter, + onMouseLeave, + })}> + {children} + </Pressable> + </DropdownMenu.Item> + ) +} + +export function ItemText({children, style}: ItemTextProps) { + const t = useTheme() + return ( + <Text style={[a.flex_1, a.font_bold, t.atoms.text_contrast_high, style]}> + {children} + </Text> + ) +} + +export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) { + const t = useTheme() + return ( + <Comp + size="md" + fill={t.atoms.text_contrast_medium.color} + style={[ + position === 'left' && { + marginLeft: -2, + }, + position === 'right' && { + marginRight: -2, + marginLeft: 12, + }, + ]} + /> + ) +} + +export function Group({children}: GroupProps) { + return children +} + +export function Divider() { + const t = useTheme() + return ( + <DropdownMenu.Separator + style={flatten([ + a.my_xs, + t.atoms.bg_contrast_100, + { + height: 1, + }, + ])} + /> + ) +} diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts new file mode 100644 index 000000000..2f52e6390 --- /dev/null +++ b/src/components/Menu/types.ts @@ -0,0 +1,72 @@ +import React from 'react' +import {GestureResponderEvent, PressableProps} from 'react-native' + +import {Props as SVGIconProps} from '#/components/icons/common' +import * as Dialog from '#/components/Dialog' +import {TextStyleProp, ViewStyleProp} from '#/alf' + +export type ContextType = { + control: Dialog.DialogOuterProps['control'] +} + +export type TriggerProps = ViewStyleProp & { + children(props: TriggerChildProps): React.ReactNode + label: string +} +export type TriggerChildProps = + | { + isNative: true + control: Dialog.DialogOuterProps['control'] + 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 + control: Dialog.DialogOuterProps['control'] + state: { + hovered: boolean + focused: boolean + /** + * Native only, `false` on web + */ + pressed: false + } + props: {} + } + +export type ItemProps = React.PropsWithChildren< + Omit<PressableProps, 'style'> & + ViewStyleProp & { + label: string + onPress: (e: GestureResponderEvent) => void + } +> + +export type ItemTextProps = React.PropsWithChildren<TextStyleProp & {}> +export type ItemIconProps = React.PropsWithChildren<{ + icon: React.ComponentType<SVGIconProps> + position?: 'left' | 'right' +}> + +export type GroupProps = React.PropsWithChildren<ViewStyleProp & {}> |