Web dropdowns (#2358)
* Split out web dropdown * Remove unused * Remove unused style * Close on escape * Reduce chance of opening while scrolling * Tune web dropdown styles * Fix type --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com>zio/stable
parent
f402f33a02
commit
b326e1d3bd
|
@ -0,0 +1,241 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
|
import {Pressable, StyleSheet, View, Text} from 'react-native'
|
||||||
|
import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||||
|
import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {useTheme} from 'lib/ThemeContext'
|
||||||
|
import {HITSLOP_10} from 'lib/constants'
|
||||||
|
|
||||||
|
// Custom Dropdown Menu Components
|
||||||
|
// ==
|
||||||
|
export const DropdownMenuRoot = DropdownMenu.Root
|
||||||
|
export const DropdownMenuContent = DropdownMenu.Content
|
||||||
|
|
||||||
|
type ItemProps = React.ComponentProps<(typeof DropdownMenu)['Item']>
|
||||||
|
export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => {
|
||||||
|
const theme = useTheme()
|
||||||
|
const [focused, setFocused] = React.useState(false)
|
||||||
|
const backgroundColor = theme.colorScheme === 'dark' ? '#fff1' : '#0001'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
{...props}
|
||||||
|
style={StyleSheet.flatten([
|
||||||
|
styles.item,
|
||||||
|
focused && {backgroundColor: backgroundColor},
|
||||||
|
])}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true)
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types for Dropdown Menu and Items
|
||||||
|
export type DropdownItem = {
|
||||||
|
label: string | 'separator'
|
||||||
|
onPress?: () => void
|
||||||
|
testID?: string
|
||||||
|
icon?: {
|
||||||
|
ios: MenuItemCommonProps['ios']
|
||||||
|
android: string
|
||||||
|
web: IconProp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type Props = {
|
||||||
|
items: DropdownItem[]
|
||||||
|
testID?: string
|
||||||
|
accessibilityLabel?: string
|
||||||
|
accessibilityHint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NativeDropdown({
|
||||||
|
items,
|
||||||
|
children,
|
||||||
|
testID,
|
||||||
|
accessibilityLabel,
|
||||||
|
accessibilityHint,
|
||||||
|
}: React.PropsWithChildren<Props>) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const theme = useTheme()
|
||||||
|
const dropDownBackgroundColor =
|
||||||
|
theme.colorScheme === 'dark' ? pal.btn : pal.view
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
||||||
|
const menuRef = React.useRef<HTMLDivElement>(null)
|
||||||
|
const {borderColor: separatorColor} =
|
||||||
|
theme.colorScheme === 'dark' ? pal.borderDark : pal.border
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
function clickHandler(e: MouseEvent) {
|
||||||
|
const t = e.target
|
||||||
|
|
||||||
|
if (!open) return
|
||||||
|
if (!t) return
|
||||||
|
if (!buttonRef.current || !menuRef.current) return
|
||||||
|
|
||||||
|
if (
|
||||||
|
t !== buttonRef.current &&
|
||||||
|
!buttonRef.current.contains(t as Node) &&
|
||||||
|
t !== menuRef.current &&
|
||||||
|
!menuRef.current.contains(t as Node)
|
||||||
|
) {
|
||||||
|
// prevent clicking through to links beneath dropdown
|
||||||
|
// only applies to mobile web
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
// close menu
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keydownHandler(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape' && open) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', clickHandler, true)
|
||||||
|
window.addEventListener('keydown', keydownHandler, true)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', clickHandler, true)
|
||||||
|
window.removeEventListener('keydown', keydownHandler, true)
|
||||||
|
}
|
||||||
|
}, [open, setOpen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuRoot open={open} onOpenChange={o => setOpen(o)}>
|
||||||
|
<DropdownMenu.Trigger asChild onPointerDown={e => e.preventDefault()}>
|
||||||
|
<Pressable
|
||||||
|
ref={buttonRef as unknown as React.Ref<View>}
|
||||||
|
testID={testID}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
accessibilityHint={accessibilityHint}
|
||||||
|
onPress={() => setOpen(o => !o)}
|
||||||
|
hitSlop={HITSLOP_10}>
|
||||||
|
{children}
|
||||||
|
</Pressable>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
ref={menuRef}
|
||||||
|
style={
|
||||||
|
StyleSheet.flatten([
|
||||||
|
styles.content,
|
||||||
|
dropDownBackgroundColor,
|
||||||
|
]) as React.CSSProperties
|
||||||
|
}
|
||||||
|
loop>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
if (item.label === 'separator') {
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Separator
|
||||||
|
key={getKey(item.label, index, item.testID)}
|
||||||
|
style={
|
||||||
|
StyleSheet.flatten([
|
||||||
|
styles.separator,
|
||||||
|
{backgroundColor: separatorColor},
|
||||||
|
]) as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (index > 1 && items[index - 1].label === 'separator') {
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Group
|
||||||
|
key={getKey(item.label, index, item.testID)}>
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={getKey(item.label, index, item.testID)}
|
||||||
|
onSelect={item.onPress}>
|
||||||
|
<Text
|
||||||
|
selectable={false}
|
||||||
|
style={[pal.text, styles.itemTitle]}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
{item.icon && (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={item.icon.web}
|
||||||
|
size={20}
|
||||||
|
color={pal.colors.textLight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={getKey(item.label, index, item.testID)}
|
||||||
|
onSelect={item.onPress}>
|
||||||
|
<Text selectable={false} style={[pal.text, styles.itemTitle]}>
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
{item.icon && (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={item.icon.web}
|
||||||
|
size={20}
|
||||||
|
color={pal.colors.textLight}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenuRoot>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getKey = (label: string, index: number, id?: string) => {
|
||||||
|
if (id) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return `${label}_${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
separator: {
|
||||||
|
height: 1,
|
||||||
|
marginTop: 4,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingTop: 4,
|
||||||
|
paddingBottom: 4,
|
||||||
|
paddingLeft: 4,
|
||||||
|
paddingRight: 4,
|
||||||
|
marginTop: 6,
|
||||||
|
|
||||||
|
// @ts-ignore web only -prf
|
||||||
|
boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px',
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
columnGap: 20,
|
||||||
|
// @ts-ignore -web
|
||||||
|
cursor: 'pointer',
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 8,
|
||||||
|
paddingLeft: 12,
|
||||||
|
paddingRight: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
itemTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
paddingRight: 10,
|
||||||
|
},
|
||||||
|
})
|
Loading…
Reference in New Issue