refactor(dropdown-menu): improve primitive defaults and deduplicate account-dropdown

- Add overflow handling (max-h-[var(--available-height)]) to Popup
- Add disabled styles (cursor-not-allowed, opacity-50) to Item and SubTrigger
- Change hover token to bg-state-base-hover for consistency
- Build arrow icon into SubTrigger so callers don't repeat it
- Style DropdownMenuGroupLabel with default typography
- Extract shared MenuItemContent and ExternalLinkIndicator into menu-item-content.tsx
- Remove duplicated className constants and component definitions across account-dropdown files
- Remove !important overrides from callers now that primitive defaults are correct
- Remove manual max-h-[70vh] from SubContent (handled by primitive)
This commit is contained in:
yyh 2026-03-03 14:45:02 +08:00
parent 2dfd7f4c65
commit a32ab27ce0
No known key found for this signature in database
5 changed files with 84 additions and 118 deletions

View File

@ -11,13 +11,27 @@ export const DropdownMenuPortal = Menu.Portal
export const DropdownMenuTrigger = Menu.Trigger
export const DropdownMenuSub = Menu.SubmenuRoot
export const DropdownMenuGroup = Menu.Group
export const DropdownMenuGroupLabel = Menu.GroupLabel
export const DropdownMenuRadioGroup = Menu.RadioGroup
export const DropdownMenuRadioItem = Menu.RadioItem
export const DropdownMenuRadioItemIndicator = Menu.RadioItemIndicator
export const DropdownMenuCheckboxItem = Menu.CheckboxItem
export const DropdownMenuCheckboxItemIndicator = Menu.CheckboxItemIndicator
export function DropdownMenuGroupLabel({
className,
...props
}: React.ComponentPropsWithoutRef<typeof Menu.GroupLabel>) {
return (
<Menu.GroupLabel
className={cn(
'px-3 py-1 text-text-tertiary system-2xs-medium-uppercase',
className,
)}
{...props}
/>
)
}
type DropdownMenuContentProps = {
children: React.ReactNode
placement?: Placement
@ -69,7 +83,7 @@ function renderDropdownMenuPopup({
>
<Menu.Popup
className={cn(
'rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-1 text-sm text-text-secondary shadow-lg',
'max-h-[var(--available-height)] overflow-y-auto overflow-x-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg py-1 text-sm text-text-secondary shadow-lg',
'origin-[var(--transform-origin)] transition-[transform,scale,opacity] data-[ending-style]:scale-95 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none',
popupClassName,
)}
@ -111,18 +125,23 @@ type DropdownMenuSubTriggerProps = React.ComponentPropsWithoutRef<typeof Menu.Su
export function DropdownMenuSubTrigger({
className,
destructive,
children,
...props
}: DropdownMenuSubTriggerProps) {
return (
<Menu.SubmenuTrigger
className={cn(
'mx-1 flex h-8 cursor-pointer select-none items-center rounded-lg px-3 outline-none',
'data-[highlighted]:bg-components-panel-on-panel-item-bg-hover',
'data-[highlighted]:bg-state-base-hover',
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
destructive && 'text-text-destructive',
className,
)}
{...props}
/>
>
{children}
<span aria-hidden className="i-ri-arrow-right-s-line ml-auto size-[14px] shrink-0 text-text-tertiary" />
</Menu.SubmenuTrigger>
)
}
@ -172,7 +191,8 @@ export function DropdownMenuItem({
<Menu.Item
className={cn(
'mx-1 flex h-8 cursor-pointer select-none items-center rounded-lg px-3 outline-none',
'data-[highlighted]:bg-components-panel-on-panel-item-bg-hover',
'data-[highlighted]:bg-state-base-hover',
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
destructive && 'text-text-destructive',
className,
)}

View File

@ -18,12 +18,7 @@ import SparklesSoft from '../../base/icons/src/public/common/SparklesSoft'
import PremiumBadge from '../../base/premium-badge'
import Spinner from '../../base/spinner'
import Toast from '../../base/toast'
const submenuTriggerClassName = '!mx-0 !h-8 !rounded-lg !px-3 data-[highlighted]:!bg-state-base-hover'
const submenuItemClassName = '!mx-0 !h-10 !rounded-lg !py-1 !pl-1 !pr-2 data-[highlighted]:!bg-state-base-hover'
const menuLabelClassName = 'grow px-1 text-text-secondary system-md-regular'
const menuLeadingIconClassName = 'size-4 shrink-0 text-text-tertiary'
const menuTrailingIconClassName = 'size-[14px] shrink-0 text-text-tertiary'
import { MenuItemContent } from './menu-item-content'
enum DocName {
SOC2_Type_I = 'SOC2_Type_I',
@ -32,26 +27,6 @@ enum DocName {
GDPR = 'GDPR',
}
type ComplianceMenuItemContentProps = {
iconClassName: string
label: ReactNode
trailing?: ReactNode
}
function ComplianceMenuItemContent({
iconClassName,
label,
trailing,
}: ComplianceMenuItemContentProps) {
return (
<>
<span aria-hidden className={cn(menuLeadingIconClassName, iconClassName)} />
<div className={menuLabelClassName}>{label}</div>
{trailing}
</>
)
}
type ComplianceDocActionVisualProps = {
isCurrentPlanCanDownload: boolean
isPending: boolean
@ -176,7 +151,7 @@ function ComplianceDocRowItem({
return (
<DropdownMenuItem
className={cn(submenuItemClassName, 'justify-between')}
className="h-10 justify-between py-1 pl-1 pr-2"
closeOnClick={!isCurrentPlanCanDownload}
onClick={handleSelect}
>
@ -199,15 +174,14 @@ export default function Compliance() {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className={cn(submenuTriggerClassName, 'justify-between')}>
<ComplianceMenuItemContent
<DropdownMenuSubTrigger>
<MenuItemContent
iconClassName="i-ri-verified-badge-line"
label={t('userProfile.compliance', { ns: 'common' })}
trailing={<span aria-hidden className={cn('i-ri-arrow-right-s-line', menuTrailingIconClassName)} />}
/>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
popupClassName="w-[337px] max-h-[70vh] overflow-y-auto divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
popupClassName="w-[337px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="p-1">
<ComplianceDocRowItem

View File

@ -24,33 +24,10 @@ import AccountAbout from '../account-about'
import GithubStar from '../github-star'
import Indicator from '../indicator'
import Compliance from './compliance'
import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
import Support from './support'
const menuItemClassName = '!mx-0 !h-8 !rounded-lg !px-3 data-[highlighted]:!bg-state-base-hover'
const menuStaticRowClassName = 'flex h-8 w-full items-center rounded-lg px-3 text-text-secondary system-md-regular'
const menuLabelClassName = 'grow px-1 text-text-secondary system-md-regular'
const menuLeadingIconClassName = 'size-4 shrink-0 text-text-tertiary'
const menuTrailingIconClassName = 'size-[14px] shrink-0 text-text-tertiary'
type AccountMenuItemContentProps = {
iconClassName: string
label: ReactNode
trailing?: ReactNode
}
function AccountMenuItemContent({
iconClassName,
label,
trailing,
}: AccountMenuItemContentProps) {
return (
<>
<span aria-hidden className={cn(menuLeadingIconClassName, iconClassName)} />
<div className={menuLabelClassName}>{label}</div>
{trailing}
</>
)
}
type AccountMenuRouteItemProps = {
href: string
@ -67,10 +44,10 @@ function AccountMenuRouteItem({
}: AccountMenuRouteItemProps) {
return (
<DropdownMenuItem
className={cn(menuItemClassName, 'justify-between')}
className="justify-between"
render={<Link href={href} />}
>
<AccountMenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
)
}
@ -90,10 +67,10 @@ function AccountMenuExternalItem({
}: AccountMenuExternalItemProps) {
return (
<DropdownMenuItem
className={cn(menuItemClassName, 'justify-between')}
className="justify-between"
render={<a href={href} rel="noopener noreferrer" target="_blank" />}
>
<AccountMenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
)
}
@ -113,18 +90,14 @@ function AccountMenuActionItem({
}: AccountMenuActionItemProps) {
return (
<DropdownMenuItem
className={cn(menuItemClassName, 'justify-between')}
className="justify-between"
onClick={onClick}
>
<AccountMenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
<MenuItemContent iconClassName={iconClassName} label={label} trailing={trailing} />
</DropdownMenuItem>
)
}
function ExternalLinkIndicator() {
return <span aria-hidden className={cn('i-ri-arrow-right-up-line', menuTrailingIconClassName)} />
}
type AccountMenuSectionProps = {
children: ReactNode
}
@ -257,7 +230,7 @@ export default function AppSelector() {
)}
<AccountMenuSection>
<div className={cn(menuStaticRowClassName, 'hover:bg-transparent')}>
<AccountMenuItemContent
<MenuItemContent
iconClassName="i-ri-t-shirt-2-line"
label={t('theme.theme', { ns: 'common' })}
trailing={<ThemeSwitcher />}

View File

@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import { cn } from '@/utils/classnames'
const menuLabelClassName = 'min-w-0 grow truncate px-1 text-text-secondary system-md-regular'
const menuLeadingIconClassName = 'size-4 shrink-0 text-text-tertiary'
export const menuTrailingIconClassName = 'size-[14px] shrink-0 text-text-tertiary'
type MenuItemContentProps = {
iconClassName: string
label: ReactNode
trailing?: ReactNode
}
export function MenuItemContent({
iconClassName,
label,
trailing,
}: MenuItemContentProps) {
return (
<>
<span aria-hidden className={cn(menuLeadingIconClassName, iconClassName)} />
<div className={menuLabelClassName}>{label}</div>
{trailing}
</>
)
}
export function ExternalLinkIndicator() {
return <span aria-hidden className={cn('i-ri-arrow-right-up-line', menuTrailingIconClassName)} />
}

View File

@ -1,4 +1,3 @@
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from '@/app/components/base/ui/dropdown-menu'
import { toggleZendeskWindow } from '@/app/components/base/zendesk/utils'
@ -6,43 +5,13 @@ import { Plan } from '@/app/components/billing/type'
import { ZENDESK_WIDGET_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
import { mailToSupport } from '../utils/util'
const submenuTriggerClassName = '!mx-0 !h-8 !rounded-lg !px-3 data-[highlighted]:!bg-state-base-hover'
const submenuItemClassName = '!mx-0 !h-8 !rounded-lg !px-3 data-[highlighted]:!bg-state-base-hover'
const menuLabelClassName = 'grow px-1 text-text-secondary system-md-regular'
const menuLeadingIconClassName = 'size-4 shrink-0 text-text-tertiary'
const menuTrailingIconClassName = 'size-[14px] shrink-0 text-text-tertiary'
import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
type SupportProps = {
closeAccountDropdown: () => void
}
type SupportMenuItemContentProps = {
iconClassName: string
label: ReactNode
trailing?: ReactNode
}
function SupportMenuItemContent({
iconClassName,
label,
trailing,
}: SupportMenuItemContentProps) {
return (
<>
<span aria-hidden className={cn(menuLeadingIconClassName, iconClassName)} />
<div className={menuLabelClassName}>{label}</div>
{trailing}
</>
)
}
function SupportExternalLinkIndicator() {
return <span aria-hidden className={cn('i-ri-arrow-right-up-line', menuTrailingIconClassName)} />
}
// Submenu-only: this component must be rendered within an existing DropdownMenu root.
export default function Support({ closeAccountDropdown }: SupportProps) {
const { t } = useTranslation()
@ -53,26 +22,25 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className={cn(submenuTriggerClassName, 'justify-between')}>
<SupportMenuItemContent
<DropdownMenuSubTrigger>
<MenuItemContent
iconClassName="i-ri-question-line"
label={t('userProfile.support', { ns: 'common' })}
trailing={<span aria-hidden className={cn('i-ri-arrow-right-s-line', menuTrailingIconClassName)} />}
/>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent
popupClassName="w-[216px] max-h-[70vh] overflow-y-auto divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
popupClassName="w-[216px] divide-y divide-divider-subtle !bg-components-panel-bg-blur !py-0 backdrop-blur-sm"
>
<DropdownMenuGroup className="p-1">
{hasDedicatedChannel && hasZendeskWidget && (
<DropdownMenuItem
className={cn(submenuItemClassName, 'justify-between')}
className="justify-between"
onClick={() => {
toggleZendeskWindow(true)
closeAccountDropdown()
}}
>
<SupportMenuItemContent
<MenuItemContent
iconClassName="i-ri-chat-smile-2-line"
label={t('userProfile.contactUs', { ns: 'common' })}
/>
@ -80,34 +48,34 @@ export default function Support({ closeAccountDropdown }: SupportProps) {
)}
{hasDedicatedChannel && !hasZendeskWidget && (
<DropdownMenuItem
className={cn(submenuItemClassName, 'justify-between')}
className="justify-between"
render={<a href={mailToSupport(userProfile.email, plan.type, langGeniusVersionInfo?.current_version)} rel="noopener noreferrer" target="_blank" />}
>
<SupportMenuItemContent
<MenuItemContent
iconClassName="i-ri-mail-send-line"
label={t('userProfile.emailSupport', { ns: 'common' })}
trailing={<SupportExternalLinkIndicator />}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuItem>
)}
<DropdownMenuItem
className={cn(submenuItemClassName, 'justify-between')}
className="justify-between"
render={<a href="https://forum.dify.ai/" rel="noopener noreferrer" target="_blank" />}
>
<SupportMenuItemContent
<MenuItemContent
iconClassName="i-ri-discuss-line"
label={t('userProfile.forum', { ns: 'common' })}
trailing={<SupportExternalLinkIndicator />}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuItem>
<DropdownMenuItem
className={cn(submenuItemClassName, 'justify-between')}
className="justify-between"
render={<a href="https://discord.gg/5AEfbxcd9k" rel="noopener noreferrer" target="_blank" />}
>
<SupportMenuItemContent
<MenuItemContent
iconClassName="i-ri-discord-line"
label={t('userProfile.community', { ns: 'common' })}
trailing={<SupportExternalLinkIndicator />}
trailing={<ExternalLinkIndicator />}
/>
</DropdownMenuItem>
</DropdownMenuGroup>