Skip to content

Commit

Permalink
Add Menu component (#3097)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
estrattonbailey authored Mar 6, 2024
1 parent e721f84 commit 317e0cd
Show file tree
Hide file tree
Showing 12 changed files with 712 additions and 11 deletions.
1 change: 1 addition & 0 deletions bskyweb/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@
}

/* NativeDropdown component */
.radix-dropdown-item:focus,
.nativeDropdown-item:focus {
outline: none;
}
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"@lingui/react": "^4.5.0",
"@mattermost/react-native-paste-input": "^0.6.4",
"@miblanchard/react-native-slider": "^2.3.1",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@react-native-async-storage/async-storage": "1.21.0",
"@react-native-camera-roll/camera-roll": "^5.2.2",
"@react-native-clipboard/clipboard": "^1.10.0",
Expand Down Expand Up @@ -148,6 +149,7 @@
"react-avatar-editor": "^13.0.0",
"react-circular-progressbar": "^2.1.0",
"react-dom": "^18.2.0",
"react-keyed-flatten-children": "^3.0.0",
"react-native": "0.73.2",
"react-native-appstate-hook": "^1.0.6",
"react-native-drawer-layout": "^4.0.0-alpha.3",
Expand Down
27 changes: 16 additions & 11 deletions src/components/Dialog/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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],
)
}
1 change: 1 addition & 0 deletions src/components/Dialog/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type DialogControlRefProps = {
export type DialogControlProps = DialogControlRefProps & {
id: string
ref: React.RefObject<DialogControlRefProps>
isOpen: boolean
}

export type DialogContextProps = {
Expand Down
8 changes: 8 additions & 0 deletions src/components/Menu/context.tsx
Original file line number Diff line number Diff line change
@@ -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,
})
190 changes: 190 additions & 0 deletions src/components/Menu/index.tsx
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 317e0cd

Please sign in to comment.