about summary refs log tree commit diff
path: root/src/components/Menu
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/Menu')
-rw-r--r--src/components/Menu/context.tsx8
-rw-r--r--src/components/Menu/index.tsx221
-rw-r--r--src/components/Menu/index.web.tsx293
-rw-r--r--src/components/Menu/types.ts99
4 files changed, 621 insertions, 0 deletions
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..051e95b95
--- /dev/null
+++ b/src/components/Menu/index.tsx
@@ -0,0 +1,221 @@
+import React from 'react'
+import {View, Pressable, ViewStyle, StyleProp} 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'
+import {Button, ButtonText} from '#/components/Button'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {isNative} from 'platform/detection'
+
+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,
+  showCancel,
+}: React.PropsWithChildren<{
+  showCancel?: boolean
+  style?: StyleProp<ViewStyle>
+}>) {
+  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}
+            {isNative && showCancel && <Cancel />}
+          </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>
+  )
+}
+
+function Cancel() {
+  const {_} = useLingui()
+  const {control} = React.useContext(Context)
+
+  return (
+    <Button
+      label={_(msg`Close this dialog`)}
+      size="small"
+      variant="ghost"
+      color="secondary"
+      onPress={() => control.close()}>
+      <ButtonText>
+        <Trans>Cancel</Trans>
+      </ButtonText>
+    </Button>
+  )
+}
+
+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..60b234203
--- /dev/null
+++ b/src/components/Menu/index.web.tsx
@@ -0,0 +1,293 @@
+/* eslint-disable react/prop-types */
+
+import React from 'react'
+import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+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,
+  RadixPassThroughTriggerProps,
+} from '#/components/Menu/types'
+import {Context} from '#/components/Menu/context'
+import {Portal} from '#/components/Portal'
+
+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 {_} = useLingui()
+  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}>
+      {context.control.isOpen && (
+        <Portal>
+          <Pressable
+            style={[a.fixed, a.inset_0, a.z_50]}
+            onPress={() => context.control.close()}
+            accessibilityHint=""
+            accessibilityLabel={_(
+              msg`Context menu backdrop, click to close the menu.`,
+            )}
+          />
+        </Portal>
+      )}
+      <DropdownMenu.Root
+        open={context.control.isOpen}
+        onOpenChange={onOpenChange}>
+        {children}
+      </DropdownMenu.Root>
+    </Context.Provider>
+  )
+}
+
+const RadixTriggerPassThrough = React.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 {control} = React.useContext(Context)
+  const {
+    state: hovered,
+    onIn: onMouseEnter,
+    onOut: onMouseLeave,
+  } = useInteractionState()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+
+  return (
+    <DropdownMenu.Trigger asChild>
+      <RadixTriggerPassThrough>
+        {props =>
+          children({
+            isNative: false,
+            control,
+            state: {
+              hovered,
+              focused,
+              pressed: false,
+            },
+            props: {
+              ...props,
+              // disable on web, use `onPress`
+              onPointerDown: () => false,
+              onPress: () =>
+                control.isOpen ? control.close() : control.open(),
+              onFocus: onFocus,
+              onBlur: onBlur,
+              onMouseEnter,
+              onMouseLeave,
+              accessibilityLabel: label,
+            },
+          })
+        }
+      </RadixTriggerPassThrough>
+    </DropdownMenu.Trigger>
+  )
+}
+
+export function Outer({
+  children,
+  style,
+}: React.PropsWithChildren<{
+  showCancel?: boolean
+  style?: StyleProp<ViewStyle>
+}>) {
+  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,
+            style,
+          ]}>
+          {children}
+        </View>
+
+        {/* Disabled until we can fix positioning
+        <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_lg,
+          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..e710971ee
--- /dev/null
+++ b/src/components/Menu/types.ts
@@ -0,0 +1,99 @@
+import React from 'react'
+import {
+  GestureResponderEvent,
+  PressableProps,
+  AccessibilityProps,
+} 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 RadixPassThroughTriggerProps = {
+  id: string
+  type: 'button'
+  disabled: boolean
+  ['data-disabled']: boolean
+  ['data-state']: string
+  ['aria-controls']?: string
+  ['aria-haspopup']?: boolean
+  ['aria-expanded']?: AccessibilityProps['aria-expanded']
+  onKeyDown: (e: React.KeyboardEvent) => void
+  /**
+   * Radix provides this, but we override on web to use `onPress` instead,
+   * which is less sensitive while scrolling.
+   */
+  onPointerDown: PressableProps['onPointerDown']
+}
+export type TriggerProps = {
+  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: RadixPassThroughTriggerProps & {
+        onPress: () => void
+        onFocus: () => void
+        onBlur: () => void
+        onMouseEnter: () => void
+        onMouseLeave: () => void
+        accessibilityLabel: string
+      }
+    }
+
+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 & {}>