Skip to content

Commit e3d4245

Browse files
Radix dropdown -> Headless dropdown (#2452)
* use headless dropdown menu for user menu in topbar * extract DropdownMenu and convert TopBarPicker * convert the last two dropdowns and uninstall @radix-ui/react-dropdown-menu * fix menu item width and hover bg color * Bot commit: format with prettier * fix menu position on more actions column * remove done todo * use z-popover (10) on menu contents and add z-topBarPopover (25) * remove unused portal prop and remove an unnecessary anchor prop --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 728f1eb commit e3d4245

File tree

9 files changed

+160
-346
lines changed

9 files changed

+160
-346
lines changed

app/components/MoreActionsMenu.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import { More12Icon } from '@oxide/design-system/icons/react'
99

1010
import type { MenuAction } from '~/table/columns/action-col'
11-
import { DropdownMenu } from '~/ui/lib/DropdownMenu'
11+
import * as DropdownMenu from '~/ui/lib/DropdownMenu'
1212
import { Tooltip } from '~/ui/lib/Tooltip'
1313
import { Wrap } from '~/ui/util/wrap'
1414

@@ -26,7 +26,7 @@ export const MoreActionsMenu = ({ actions, label }: MoreActionsMenuProps) => {
2626
>
2727
<More12Icon className="text-tertiary" />
2828
</DropdownMenu.Trigger>
29-
<DropdownMenu.Content align="end" className="mt-2">
29+
<DropdownMenu.Content className="mt-2">
3030
{actions.map((a) => (
3131
<Wrap key={a.label} when={!!a.disabled} with={<Tooltip content={a.disabled} />}>
3232
<DropdownMenu.Item

app/components/TopBar.tsx

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import cn from 'classnames'
89
import React from 'react'
910

1011
import { navToLogin, useApiMutation } from '@oxide/api'
1112
import { DirectionDownIcon, Profile16Icon } from '@oxide/design-system/icons/react'
1213

1314
import { useCurrentUser } from '~/layouts/AuthenticatedLayout'
14-
import { Button } from '~/ui/lib/Button'
15-
import { DropdownMenu } from '~/ui/lib/DropdownMenu'
15+
import { buttonStyle } from '~/ui/lib/Button'
16+
import * as DropdownMenu from '~/ui/lib/DropdownMenu'
1617
import { pb } from '~/util/path-builder'
1718

1819
export function TopBar({ children }: { children: React.ReactNode }) {
@@ -40,25 +41,21 @@ export function TopBar({ children }: { children: React.ReactNode }) {
4041
<div className="mx-3 flex h-[60px] shrink-0 items-center justify-between">
4142
<div className="flex items-center">{otherPickers}</div>
4243
<div className="flex items-center gap-2">
43-
{/* <Button variant="secondary" size="icon" className="ml-2" title="Notifications">
44-
<Notifications16Icon className="text-quaternary" />
45-
</Button> */}
4644
<DropdownMenu.Root>
47-
<DropdownMenu.Trigger asChild>
48-
<Button
49-
size="sm"
50-
variant="secondary"
51-
aria-label="User menu"
52-
innerClassName="space-x-2"
53-
>
54-
<Profile16Icon className="text-quaternary" />
55-
<span className="normal-case text-sans-md text-secondary">
56-
{me.displayName || 'User'}
57-
</span>
58-
<DirectionDownIcon className="!w-2.5" />
59-
</Button>
45+
<DropdownMenu.Trigger
46+
className={cn(
47+
buttonStyle({ size: 'sm', variant: 'secondary' }),
48+
'flex items-center gap-2'
49+
)}
50+
aria-label="User menu"
51+
>
52+
<Profile16Icon className="text-quaternary" />
53+
<span className="normal-case text-sans-md text-secondary">
54+
{me.displayName || 'User'}
55+
</span>
56+
<DirectionDownIcon className="!w-2.5" />
6057
</DropdownMenu.Trigger>
61-
<DropdownMenu.Content align="end" sideOffset={8}>
58+
<DropdownMenu.Content gap={8} className="!z-topBarPopover">
6259
<DropdownMenu.LinkItem to={pb.profile()}>Settings</DropdownMenu.LinkItem>
6360
<DropdownMenu.Item onSelect={() => logout.mutate({})}>
6461
Sign out

app/components/TopBarPicker.tsx

Lines changed: 38 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ import {
2424
} from '~/hooks/use-params'
2525
import { useCurrentUser } from '~/layouts/AuthenticatedLayout'
2626
import { PAGE_SIZE } from '~/table/QueryTable'
27-
import { Button } from '~/ui/lib/Button'
28-
import { DropdownMenu } from '~/ui/lib/DropdownMenu'
27+
import { buttonStyle } from '~/ui/lib/Button'
28+
import * as DropdownMenu from '~/ui/lib/DropdownMenu'
2929
import { Identicon } from '~/ui/lib/Identicon'
3030
import { Wrap } from '~/ui/util/wrap'
3131
import { pb } from '~/util/path-builder'
@@ -95,53 +95,51 @@ const TopBarPicker = (props: TopBarPickerProps) => {
9595
{props.items && (
9696
<div className="ml-2 shrink-0">
9797
<DropdownMenu.Trigger
98-
className="group"
98+
className={cn(
99+
'group h-[2rem] w-[1.125rem]',
100+
buttonStyle({ size: 'icon', variant: 'ghost' })
101+
)}
99102
aria-label={props['aria-label']}
100-
asChild
101103
>
102-
<Button size="icon" variant="ghost" className="h-[2rem] w-[1.125rem]">
103-
{/* aria-hidden is a tip from the Reach docs */}
104-
<SelectArrows6Icon className="text-secondary" aria-hidden />
105-
</Button>
104+
{/* aria-hidden is a tip from the Reach docs */}
105+
<SelectArrows6Icon className="text-secondary" aria-hidden />
106106
</DropdownMenu.Trigger>
107107
</div>
108108
)}
109109
</div>
110110
{/* TODO: item size and focus highlight */}
111111
{/* TODO: popover position should be further right */}
112112
{props.items && (
113-
// portal is necessary to avoid the menu popover getting its own after:
114-
// separator thing
115-
<DropdownMenu.Portal>
116-
<DropdownMenu.Content
117-
className="mt-2 max-h-80 min-w-[12.8125rem] overflow-y-auto"
118-
align="start"
119-
>
120-
{props.items.length > 0 ? (
121-
props.items.map(({ label, to }) => {
122-
const isSelected = props.current === label
123-
return (
124-
<DropdownMenu.Item asChild key={label}>
125-
<Link to={to} className={cn({ 'is-selected': isSelected })}>
126-
<span className="flex w-full items-center gap-2">
127-
{label}
128-
{isSelected && <Success12Icon className="-mr-3 block" />}
129-
</span>
130-
</Link>
131-
</DropdownMenu.Item>
132-
)
133-
})
134-
) : (
135-
<DropdownMenu.Item
136-
className="!pr-3 !text-center !text-secondary hover:cursor-default"
137-
onSelect={() => {}}
138-
disabled
139-
>
140-
{props.noItemsText || 'No items found'}
141-
</DropdownMenu.Item>
142-
)}
143-
</DropdownMenu.Content>
144-
</DropdownMenu.Portal>
113+
<DropdownMenu.Content
114+
className="!z-topBarPopover mt-2 max-h-80 min-w-[12.8125rem] overflow-y-auto"
115+
anchor="bottom start"
116+
>
117+
{props.items.length > 0 ? (
118+
props.items.map(({ label, to }) => {
119+
const isSelected = props.current === label
120+
return (
121+
<DropdownMenu.LinkItem
122+
key={label}
123+
to={to}
124+
className={cn({ 'is-selected': isSelected })}
125+
>
126+
<span className="flex w-full items-center gap-2">
127+
{label}
128+
{isSelected && <Success12Icon className="-mr-3 block" />}
129+
</span>
130+
</DropdownMenu.LinkItem>
131+
)
132+
})
133+
) : (
134+
<DropdownMenu.Item
135+
className="!pr-3 !text-center !text-secondary hover:cursor-default"
136+
onSelect={() => {}}
137+
disabled
138+
>
139+
{props.noItemsText || 'No items found'}
140+
</DropdownMenu.Item>
141+
)}
142+
</DropdownMenu.Content>
145143
)}
146144
</DropdownMenu.Root>
147145
)

app/table/columns/action-col.tsx

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { useMemo } from 'react'
1111

1212
import { More12Icon } from '@oxide/design-system/icons/react'
1313

14-
import { DropdownMenu } from '~/ui/lib/DropdownMenu'
14+
import * as DropdownMenu from '~/ui/lib/DropdownMenu'
1515
import { Tooltip } from '~/ui/lib/Tooltip'
1616
import { Wrap } from '~/ui/util/wrap'
1717
import { kebabCase } from '~/util/str'
@@ -75,41 +75,38 @@ export const RowActions = ({ id, copyIdLabel = 'Copy ID', actions }: RowActionsP
7575
>
7676
<More12Icon className="text-tertiary" />
7777
</DropdownMenu.Trigger>
78-
{/* portal fixes mysterious z-index issue where menu is behind button */}
79-
<DropdownMenu.Portal>
80-
<DropdownMenu.Content align="end" className="-mt-3 mr-2">
81-
{id && (
82-
<DropdownMenu.Item
83-
onSelect={() => {
84-
window.navigator.clipboard.writeText(id)
85-
}}
78+
{/* offset moves menu in from the right so it doesn't align with the table border */}
79+
<DropdownMenu.Content anchor={{ to: 'bottom end', offset: -6 }} className="-mt-2">
80+
{id && (
81+
<DropdownMenu.Item
82+
onSelect={() => {
83+
window.navigator.clipboard.writeText(id)
84+
}}
85+
>
86+
{copyIdLabel}
87+
</DropdownMenu.Item>
88+
)}
89+
{actions?.map((action) => {
90+
// TODO: Tooltip on disabled button broke, probably due to portal
91+
return (
92+
<Wrap
93+
when={!!action.disabled}
94+
with={<Tooltip content={action.disabled} />}
95+
key={kebabCase(`action-${action.label}`)}
8696
>
87-
{copyIdLabel}
88-
</DropdownMenu.Item>
89-
)}
90-
{actions?.map((action) => {
91-
// TODO: Tooltip on disabled button broke, probably due to portal
92-
return (
93-
<Wrap
94-
when={!!action.disabled}
95-
with={<Tooltip content={action.disabled} />}
96-
key={kebabCase(`action-${action.label}`)}
97+
<DropdownMenu.Item
98+
className={cn(action.className, {
99+
destructive: action.label.toLowerCase() === 'delete' && !action.disabled,
100+
})}
101+
onSelect={action.onActivate}
102+
disabled={!!action.disabled}
97103
>
98-
<DropdownMenu.Item
99-
className={cn(action.className, {
100-
destructive:
101-
action.label.toLowerCase() === 'delete' && !action.disabled,
102-
})}
103-
onSelect={action.onActivate}
104-
disabled={!!action.disabled}
105-
>
106-
{action.label}
107-
</DropdownMenu.Item>
108-
</Wrap>
109-
)
110-
})}
111-
</DropdownMenu.Content>
112-
</DropdownMenu.Portal>
104+
{action.label}
105+
</DropdownMenu.Item>
106+
</Wrap>
107+
)
108+
})}
109+
</DropdownMenu.Content>
113110
</DropdownMenu.Root>
114111
)
115112
}

app/ui/lib/DropdownMenu.tsx

Lines changed: 69 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,47 +5,77 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
89
import {
9-
Content,
10-
Item,
11-
Portal,
12-
Root,
13-
Trigger,
14-
type DropdownMenuContentProps,
15-
type DropdownMenuItemProps,
16-
} from '@radix-ui/react-dropdown-menu'
10+
Menu,
11+
MenuButton,
12+
MenuItem,
13+
MenuItems,
14+
type MenuItemsProps,
15+
} from '@headlessui/react'
1716
import cn from 'classnames'
18-
import { forwardRef, type ForwardedRef } from 'react'
17+
import { forwardRef, type ForwardedRef, type ReactNode } from 'react'
1918
import { Link } from 'react-router-dom'
2019

21-
type DivRef = ForwardedRef<HTMLDivElement>
22-
23-
// remove possibility of disabling links for now. if we put it back, make sure
24-
// to forwardRef on LinkItem so the disabled tooltip can work
25-
type LinkitemProps = Omit<DropdownMenuItemProps, 'disabled'> & { to: string }
26-
27-
export const DropdownMenu = {
28-
Root,
29-
Trigger,
30-
Portal,
31-
// don't need to forward ref here for a particular reason but Radix gives a
32-
// big angry warning if we don't
33-
Content: forwardRef(({ className, ...props }: DropdownMenuContentProps, ref: DivRef) => (
34-
<Content
35-
{...props}
36-
// prevents focus ring showing up on trigger when you close the menu
37-
onCloseAutoFocus={(e) => e.preventDefault()}
38-
className={cn('DropdownMenuContent', className)}
39-
ref={ref}
40-
/>
41-
)),
42-
// need to forward ref because of tooltips on disabled menu buttons
43-
Item: forwardRef(({ className, ...props }: DropdownMenuItemProps, ref: DivRef) => (
44-
<Item {...props} className={cn('DropdownMenuItem ox-menu-item', className)} ref={ref} />
45-
)),
46-
LinkItem: ({ className, children, to, ...props }: LinkitemProps) => (
47-
<Item {...props} className={cn('DropdownMenuItem ox-menu-item', className)} asChild>
48-
<Link to={to}>{children}</Link>
49-
</Item>
50-
),
20+
export const Root = Menu
21+
22+
export const Trigger = MenuButton
23+
24+
type ContentProps = {
25+
className?: string
26+
children: ReactNode
27+
anchor?: MenuItemsProps['anchor']
28+
/** Spacing in px, passed as --anchor-gap */
29+
gap?: 8
5130
}
31+
32+
export function Content({ className, children, anchor = 'bottom end', gap }: ContentProps) {
33+
return (
34+
<MenuItems
35+
anchor={anchor}
36+
// goofy gap because tailwind hates string interpolation
37+
className={cn('DropdownMenuContent', gap === 8 && `[--anchor-gap:8px]`, className)}
38+
// necessary to turn off scroll locking so the scrollbar doesn't pop in
39+
// and out as menu closes and opens
40+
modal={false}
41+
>
42+
{children}
43+
</MenuItems>
44+
)
45+
}
46+
47+
type LinkItemProps = { className?: string; to: string; children: ReactNode }
48+
49+
export function LinkItem({ className, to, children }: LinkItemProps) {
50+
return (
51+
<MenuItem>
52+
<Link className={cn('DropdownMenuItem ox-menu-item', className)} to={to}>
53+
{children}
54+
</Link>
55+
</MenuItem>
56+
)
57+
}
58+
59+
type ButtonRef = ForwardedRef<HTMLButtonElement>
60+
type ItemProps = {
61+
className?: string
62+
onSelect?: () => void
63+
children: ReactNode
64+
disabled?: boolean
65+
}
66+
67+
// need to forward ref because of tooltips on disabled menu buttons
68+
export const Item = forwardRef(
69+
({ className, onSelect, children, disabled }: ItemProps, ref: ButtonRef) => (
70+
<MenuItem disabled={disabled}>
71+
<button
72+
type="button"
73+
className={cn('DropdownMenuItem ox-menu-item', className)}
74+
ref={ref}
75+
onClick={onSelect}
76+
>
77+
{children}
78+
</button>
79+
</MenuItem>
80+
)
81+
)

app/ui/styles/components/menu-button.css

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
*/
88

99
.DropdownMenuContent {
10-
@apply z-30 min-w-36 rounded border p-0 bg-raise border-secondary;
10+
@apply z-popover min-w-36 rounded border p-0 bg-raise border-secondary;
1111

1212
& .DropdownMenuItem {
13-
@apply block cursor-pointer select-none border-b py-2 pl-3 pr-6 text-left text-sans-md text-secondary border-secondary last:border-b-0;
13+
@apply block w-full cursor-pointer select-none border-b py-2 pl-3 pr-6 text-left text-sans-md text-secondary border-secondary last:border-b-0;
1414

1515
&.destructive {
1616
@apply text-destructive;
@@ -24,8 +24,7 @@
2424
@apply text-destructive-disabled;
2525
}
2626

27-
&[data-highlighted] {
28-
/* background: hsl(211, 81%, 36%); */
27+
&[data-focus] {
2928
outline: none;
3029
@apply bg-tertiary;
3130
}

0 commit comments

Comments
 (0)