✨(frontend) new Menu component
ditch the "PopoverList" component that was basically a poor Menu. Now dropdown buttons can be made with react aria Menu+MenuItems components via the Menu+MenuList components. The difference now is dropdown menus behave better with keyboard (they use up/down arrows instead of tabs), like selects. Popovers are there if we need to show any content in a big tooltip, not for actionable list items.
This commit is contained in:
committed by
aleb_the_flash
parent
b472220151
commit
a8b2c56f4b
@@ -2,11 +2,13 @@ import { Link } from 'wouter'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Stack } from '@/styled-system/jsx'
|
import { Stack } from '@/styled-system/jsx'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { A, Button, Popover, PopoverList, Text } from '@/primitives'
|
import { A, Text, Button } from '@/primitives'
|
||||||
import { SettingsButton } from '@/features/settings'
|
import { SettingsButton } from '@/features/settings'
|
||||||
import { authUrl, logoutUrl, useUser } from '@/features/auth'
|
import { authUrl, logoutUrl, useUser } from '@/features/auth'
|
||||||
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
|
import { useMatchesRoute } from '@/navigation/useMatchesRoute'
|
||||||
import { Feedback } from '@/components/Feedback'
|
import { Feedback } from '@/components/Feedback'
|
||||||
|
import { Menu } from '@/primitives/Menu'
|
||||||
|
import { MenuList } from '@/primitives/MenuList'
|
||||||
|
|
||||||
export const Header = () => {
|
export const Header = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -64,7 +66,7 @@ export const Header = () => {
|
|||||||
<A href={authUrl()}>{t('login')}</A>
|
<A href={authUrl()}>{t('login')}</A>
|
||||||
)}
|
)}
|
||||||
{!!user && (
|
{!!user && (
|
||||||
<Popover aria-label={t('logout')}>
|
<Menu>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
invisible
|
invisible
|
||||||
@@ -73,17 +75,15 @@ export const Header = () => {
|
|||||||
>
|
>
|
||||||
{user.email}
|
{user.email}
|
||||||
</Button>
|
</Button>
|
||||||
<PopoverList
|
<MenuList
|
||||||
items={[
|
items={[{ value: 'logout', label: t('logout') }]}
|
||||||
{ key: 'logout', value: 'logout', label: t('logout') },
|
|
||||||
]}
|
|
||||||
onAction={(value) => {
|
onAction={(value) => {
|
||||||
if (value === 'logout') {
|
if (value === 'logout') {
|
||||||
window.location.href = logoutUrl()
|
window.location.href = logoutUrl()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
</Menu>
|
||||||
)}
|
)}
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
25
src/frontend/src/primitives/Menu.tsx
Normal file
25
src/frontend/src/primitives/Menu.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { MenuTrigger } from 'react-aria-components'
|
||||||
|
import { StyledPopover } from './Popover'
|
||||||
|
import { Box } from './Box'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* a Menu is a tuple of a trigger component (most usually a Button) that toggles menu items in a tooltip around the trigger
|
||||||
|
*/
|
||||||
|
export const Menu = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: [trigger: ReactNode, menu: ReactNode]
|
||||||
|
}) => {
|
||||||
|
const [trigger, menu] = children
|
||||||
|
return (
|
||||||
|
<MenuTrigger>
|
||||||
|
{trigger}
|
||||||
|
<StyledPopover>
|
||||||
|
<Box size="sm" type="popover">
|
||||||
|
{menu}
|
||||||
|
</Box>
|
||||||
|
</StyledPopover>
|
||||||
|
</MenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
src/frontend/src/primitives/MenuList.tsx
Normal file
44
src/frontend/src/primitives/MenuList.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { Menu, MenuProps, MenuItem as RACMenuItem } from 'react-aria-components'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { menuItemStyles } from './menuItemStyles'
|
||||||
|
|
||||||
|
const MenuItem = styled(RACMenuItem, menuItemStyles)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* render a Button primitive that shows a popover showing a list of pressable items
|
||||||
|
*/
|
||||||
|
export const MenuList = <T extends string | number = string>({
|
||||||
|
onAction,
|
||||||
|
selectedItem,
|
||||||
|
items = [],
|
||||||
|
...menuProps
|
||||||
|
}: {
|
||||||
|
onAction: (key: T) => void
|
||||||
|
selectedItem?: T
|
||||||
|
items: Array<string | { value: T; label: ReactNode }>
|
||||||
|
} & MenuProps<unknown>) => {
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
selectionMode={selectedItem !== undefined ? 'single' : undefined}
|
||||||
|
selectedKeys={selectedItem !== undefined ? [selectedItem] : undefined}
|
||||||
|
{...menuProps}
|
||||||
|
>
|
||||||
|
{items.map((item) => {
|
||||||
|
const value = typeof item === 'string' ? item : item.value
|
||||||
|
const label = typeof item === 'string' ? item : item.label
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={value}
|
||||||
|
id={value as string}
|
||||||
|
onAction={() => {
|
||||||
|
onAction(value as T)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -49,7 +49,10 @@ const StyledOverlayArrow = styled(OverlayArrow, {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a Popover is a tuple of a trigger component (most usually a Button) that toggles some interactive content in a tooltip around the trigger
|
* a Popover is a tuple of a trigger component (most usually a Button) that toggles some content in a tooltip around the trigger
|
||||||
|
*
|
||||||
|
* Note: to show a list of actionable items, like a dropdown menu, prefer using a <Menu> or <Select>.
|
||||||
|
* This is here when needing to show unrestricted content in a box.
|
||||||
*/
|
*/
|
||||||
export const Popover = ({
|
export const Popover = ({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import { ReactNode, useContext } from 'react'
|
|
||||||
import {
|
|
||||||
ButtonProps,
|
|
||||||
Button,
|
|
||||||
OverlayTriggerStateContext,
|
|
||||||
} from 'react-aria-components'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
|
||||||
|
|
||||||
const ListItem = styled(Button, {
|
|
||||||
base: {
|
|
||||||
paddingY: 0.125,
|
|
||||||
paddingX: 0.5,
|
|
||||||
textAlign: 'left',
|
|
||||||
width: 'full',
|
|
||||||
borderRadius: 4,
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: 'box.text',
|
|
||||||
border: '1px solid transparent',
|
|
||||||
'&[data-selected]': {
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
'&[data-focused]': {
|
|
||||||
color: 'primary.text',
|
|
||||||
backgroundColor: 'primary',
|
|
||||||
outline: 'none!',
|
|
||||||
},
|
|
||||||
'&[data-hovered]': {
|
|
||||||
color: 'primary.text',
|
|
||||||
backgroundColor: 'primary',
|
|
||||||
outline: 'none!',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* render a Button primitive that shows a popover showing a list of pressable items
|
|
||||||
*/
|
|
||||||
export const PopoverList = <T extends string | number = string>({
|
|
||||||
onAction,
|
|
||||||
closeOnAction = true,
|
|
||||||
items = [],
|
|
||||||
}: {
|
|
||||||
closeOnAction?: boolean
|
|
||||||
onAction: (key: T) => void
|
|
||||||
items: Array<string | { key: string; value: T; label: ReactNode }>
|
|
||||||
} & ButtonProps) => {
|
|
||||||
const popoverState = useContext(OverlayTriggerStateContext)!
|
|
||||||
return (
|
|
||||||
<ul>
|
|
||||||
{items.map((item) => {
|
|
||||||
const value = typeof item === 'string' ? item : item.value
|
|
||||||
const label = typeof item === 'string' ? item : item.label
|
|
||||||
const key = typeof item === 'string' ? item : item.key
|
|
||||||
return (
|
|
||||||
<li key={key}>
|
|
||||||
<ListItem
|
|
||||||
key={value}
|
|
||||||
onPress={() => {
|
|
||||||
onAction(value as T)
|
|
||||||
if (closeOnAction) {
|
|
||||||
popoverState.close()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</ListItem>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from 'react-aria-components'
|
} from 'react-aria-components'
|
||||||
import { Box } from './Box'
|
import { Box } from './Box'
|
||||||
import { StyledPopover } from './Popover'
|
import { StyledPopover } from './Popover'
|
||||||
|
import { menuItemStyles } from './menuItemStyles'
|
||||||
|
|
||||||
const StyledButton = styled(Button, {
|
const StyledButton = styled(Button, {
|
||||||
base: {
|
base: {
|
||||||
@@ -43,26 +44,7 @@ const StyledSelectValue = styled(SelectValue, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const StyledListBoxItem = styled(ListBoxItem, {
|
const StyledListBoxItem = styled(ListBoxItem, menuItemStyles)
|
||||||
base: {
|
|
||||||
paddingY: 0.125,
|
|
||||||
paddingX: 0.5,
|
|
||||||
textAlign: 'left',
|
|
||||||
width: 'full',
|
|
||||||
borderRadius: 4,
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: 'box.text',
|
|
||||||
border: '1px solid transparent',
|
|
||||||
'&[data-selected]': {
|
|
||||||
fontWeight: 'bold',
|
|
||||||
},
|
|
||||||
'&[data-focused]': {
|
|
||||||
color: 'primary.text',
|
|
||||||
backgroundColor: 'primary',
|
|
||||||
outline: 'none!',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const Select = <T extends string | number>({
|
export const Select = <T extends string | number>({
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* exposes all primitives we want to use in other parts of the app.
|
||||||
|
*
|
||||||
|
* It's intended not everything is exported: some primitives are meant as building-blocks
|
||||||
|
* for other primitives and don't have any value being exposed.
|
||||||
|
*/
|
||||||
export { A } from './A'
|
export { A } from './A'
|
||||||
export { Badge } from './Badge'
|
export { Badge } from './Badge'
|
||||||
export { Bold } from './Bold'
|
export { Bold } from './Bold'
|
||||||
@@ -13,9 +19,10 @@ export { Hr } from './Hr'
|
|||||||
export { Italic } from './Italic'
|
export { Italic } from './Italic'
|
||||||
export { Input } from './Input'
|
export { Input } from './Input'
|
||||||
export { Link } from './Link'
|
export { Link } from './Link'
|
||||||
|
export { Menu } from './Menu'
|
||||||
|
export { MenuList } from './MenuList'
|
||||||
export { P } from './P'
|
export { P } from './P'
|
||||||
export { Popover } from './Popover'
|
export { Popover } from './Popover'
|
||||||
export { PopoverList } from './PopoverList'
|
|
||||||
export { Text } from './Text'
|
export { Text } from './Text'
|
||||||
export { Ul } from './Ul'
|
export { Ul } from './Ul'
|
||||||
export { VerticallyOffCenter } from './VerticallyOffCenter'
|
export { VerticallyOffCenter } from './VerticallyOffCenter'
|
||||||
|
|||||||
31
src/frontend/src/primitives/menuItemStyles.ts
Normal file
31
src/frontend/src/primitives/menuItemStyles.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* reusable styles for a menu item, select item, etc… to be used with panda `css()` or `styled()`
|
||||||
|
*
|
||||||
|
* these are in their own files because react hot refresh doesn't like exporting stuff
|
||||||
|
* that aren't components in component files
|
||||||
|
*/
|
||||||
|
export const menuItemStyles = {
|
||||||
|
base: {
|
||||||
|
paddingY: 0.125,
|
||||||
|
paddingX: 0.5,
|
||||||
|
textAlign: 'left',
|
||||||
|
width: 'full',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'box.text',
|
||||||
|
border: '1px solid transparent',
|
||||||
|
'&[data-selected]': {
|
||||||
|
fontWeight: 'bold!',
|
||||||
|
},
|
||||||
|
'&[data-focused]': {
|
||||||
|
color: 'primary.text',
|
||||||
|
backgroundColor: 'primary',
|
||||||
|
outline: 'none!',
|
||||||
|
},
|
||||||
|
'&[data-hovered]': {
|
||||||
|
color: 'primary.text',
|
||||||
|
backgroundColor: 'primary',
|
||||||
|
outline: 'none!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user