admin/components/heroui/Dropdown.tsx
alisaza 5404974c32 refactor(TextEditor, Dropdown, UserDropdown): improve dropdown trigger implementation and styling
- Updated `TextEditor` component to use consistent class names for dropdown triggers, enhancing maintainability.
- Refactored `Dropdown` component to resolve trigger properties more reliably, ensuring proper rendering of button-like triggers.
- Modified `UserDropdown` to utilize `buttonVariants` for styling the dropdown trigger, improving visual consistency across components.
2026-09-08 11:51:36 +03:30

223 lines
7.1 KiB
TypeScript

'use client'
import type { ComponentProps, CSSProperties, ReactElement, ReactNode } from 'react'
import { Children, Fragment, isValidElement } from 'react'
import { Description, Dropdown as HeroDropdown, Header, Label, Separator } from '@heroui/react'
type DropdownKey = string | number
export interface DropdownItemProps {
children?: ReactNode
className?: string
color?: 'default' | 'danger'
description?: ReactNode
key?: DropdownKey
startContent?: ReactNode
textValue?: string
onPress?: () => void
}
interface DropdownMenuProps {
children?: ReactNode
'aria-label'?: string
closeOnSelect?: boolean
disabledKeys?: Iterable<DropdownKey>
disallowEmptySelection?: boolean
selectedKeys?: 'all' | Iterable<DropdownKey>
selectionMode?: 'none' | 'single' | 'multiple'
variant?: string
onAction?: (key: DropdownKey) => void
onSelectionChange?: (keys: 'all' | Set<DropdownKey>) => void
}
interface DropdownSectionProps {
children?: ReactNode
showDivider?: boolean
title?: ReactNode
}
interface DropdownTriggerProps {
children?: ReactNode
'aria-label'?: string
className?: string
isDisabled?: boolean
style?: CSSProperties
}
interface DropdownProps {
children?: ReactNode
placement?: ComponentProps<typeof HeroDropdown.Popover>['placement'] | 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'
trigger?: 'press' | 'longPress'
}
export function DropdownTrigger({ children }: DropdownTriggerProps) {
return children
}
export function DropdownMenu({ children }: DropdownMenuProps) {
return children
}
export function DropdownItem({ children }: DropdownItemProps) {
return children
}
export function DropdownSection({ children }: DropdownSectionProps) {
return children
}
function itemTextValue(item: ReactElement<DropdownItemProps>, id: string): string {
if (item.props.textValue) return item.props.textValue
if (typeof item.props.children === 'string' || typeof item.props.children === 'number') return String(item.props.children)
return id
}
function renderItem(item: ReactElement<DropdownItemProps>, index: number, showIndicator: boolean) {
const id = normalizeElementKey(item.key, index)
const label = <Label>{item.props.children}</Label>
const description = item.props.description ? <Description>{item.props.description}</Description> : null
return (
<HeroDropdown.Item
key={id}
className={item.props.className}
id={id}
textValue={itemTextValue(item, id)}
variant={item.props.color === 'danger' ? 'danger' : undefined}
onAction={item.props.onPress}
>
{showIndicator ? <HeroDropdown.ItemIndicator /> : null}
{item.props.startContent}
{description ? (
<div className="flex min-w-0 flex-col">
{label}
{description}
</div>
) : (
label
)}
</HeroDropdown.Item>
)
}
function normalizeElementKey(key: ReactElement['key'], index: number): string {
if (key == null) return String(index)
const value = String(key)
return value.startsWith('.$') ? value.slice(2) : value
}
function renderMenuChildren(children: ReactNode, showIndicator: boolean): ReactNode {
return Children.toArray(children).map((child, index) => {
if (!isValidElement(child)) return null
if (child.type === DropdownSection) {
const section = child as ReactElement<DropdownSectionProps>
return (
<Fragment key={normalizeElementKey(section.key, index)}>
<HeroDropdown.Section>
{section.props.title ? <Header>{section.props.title}</Header> : null}
{renderMenuChildren(section.props.children, showIndicator)}
</HeroDropdown.Section>
{section.props.showDivider ? <Separator /> : null}
</Fragment>
)
}
return renderItem(child as ReactElement<DropdownItemProps>, index, showIndicator)
})
}
function isButtonLikeTrigger(child: ReactElement): boolean {
if (typeof child.type === 'string') return child.type === 'button'
const displayName =
typeof child.type === 'function' || typeof child.type === 'object'
? (child.type as { displayName?: string; name?: string }).displayName ||
(child.type as { name?: string }).name
: undefined
return displayName === 'ConsumerButton' || displayName === 'Button'
}
/** Button/`button` داخل MenuTrigger reliably باز نمی‌شود؛ محتوا را به Dropdown.Trigger واقعی منتقل می‌کنیم. */
function resolveTrigger(triggerElement: ReactElement<DropdownTriggerProps>): {
'aria-label'?: string
className?: string
content: ReactNode
isDisabled?: boolean
style?: CSSProperties
} {
const { children, className, isDisabled, style } = triggerElement.props
const ariaLabel = triggerElement.props['aria-label']
const onlyChild = Children.count(children) === 1 ? Children.toArray(children)[0] : null
if (isValidElement(onlyChild) && isButtonLikeTrigger(onlyChild)) {
const nested = onlyChild.props as {
'aria-label'?: string
children?: ReactNode
className?: string
disabled?: boolean
style?: CSSProperties
}
return {
'aria-label': nested['aria-label'] ?? ariaLabel,
className: nested.className ?? className,
content: nested.children,
isDisabled: nested.disabled ?? isDisabled,
style: nested.style ?? style,
}
}
return {
'aria-label': ariaLabel,
className,
content: children,
isDisabled,
style,
}
}
export function Dropdown({ children, placement, trigger }: DropdownProps) {
const parts = Children.toArray(children).filter(isValidElement)
const triggerElement = parts.find((part) => part.type === DropdownTrigger) as ReactElement<DropdownTriggerProps> | undefined
const menuElement = parts.find((part) => part.type === DropdownMenu) as ReactElement<DropdownMenuProps> | undefined
if (!triggerElement || !menuElement) return null
const resolvedTrigger = resolveTrigger(triggerElement)
const menuChildren = menuElement.props.children
const menuProps = {
'aria-label': menuElement.props['aria-label'],
disabledKeys: menuElement.props.disabledKeys,
disallowEmptySelection: menuElement.props.disallowEmptySelection,
selectedKeys: menuElement.props.selectedKeys,
selectionMode: menuElement.props.selectionMode,
onAction: menuElement.props.onAction,
onSelectionChange: menuElement.props.onSelectionChange,
}
const showIndicator = menuProps.selectionMode === 'single' || menuProps.selectionMode === 'multiple'
const normalizedPlacement = placement?.replace('-', ' ') as ComponentProps<typeof HeroDropdown.Popover>['placement']
return (
<HeroDropdown trigger={trigger}>
<HeroDropdown.Trigger
aria-label={resolvedTrigger['aria-label']}
className={resolvedTrigger.className}
isDisabled={resolvedTrigger.isDisabled}
style={resolvedTrigger.style}
>
{resolvedTrigger.content}
</HeroDropdown.Trigger>
<HeroDropdown.Popover placement={normalizedPlacement}>
<HeroDropdown.Menu {...menuProps}>{renderMenuChildren(menuChildren, showIndicator)}</HeroDropdown.Menu>
</HeroDropdown.Popover>
</HeroDropdown>
)
}