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.
This commit is contained in:
alisaza 2026-09-08 11:51:36 +03:30
parent 8967a48f94
commit 5404974c32
3 changed files with 116 additions and 84 deletions

View File

@ -20,6 +20,12 @@ import FileUpload from '@/components/media/FileUpload'
import ConsumerModal from '@/components/consumer/ConsumerModal' import ConsumerModal from '@/components/consumer/ConsumerModal'
import { fileAddress, coerceToString } from '@/helpers' import { fileAddress, coerceToString } from '@/helpers'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { buttonVariants } from '@heroui/react'
const CONSUMER_TOOLBAR_TRIGGER_CLASS =
'min-h-10 rounded-[10px] border border-consumer-border bg-consumer-surface px-3 text-sm font-medium text-consumer-text outline-none'
const ADMIN_TOOLBAR_TRIGGER_CLASS = cn(buttonVariants({ size: 'sm', variant: 'secondary' }), 'outline-none')
interface TextEditorProps { interface TextEditorProps {
label?: string label?: string
@ -186,14 +192,11 @@ const TextEditor = ({ label, required, value, onChange, className, variant = 'de
{isConsumer ? ( {isConsumer ? (
<> <>
<Dropdown> <Dropdown>
<DropdownTrigger> <DropdownTrigger
<button
aria-label="عنوان" aria-label="عنوان"
className="min-h-10 rounded-[10px] border border-consumer-border bg-consumer-surface px-3 text-sm font-medium text-consumer-text" className={CONSUMER_TOOLBAR_TRIGGER_CLASS}
type="button"
> >
عنوان عنوان
</button>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu aria-label="نوع عنوان"> <DropdownMenu aria-label="نوع عنوان">
<DropdownItem <DropdownItem
@ -222,14 +225,11 @@ const TextEditor = ({ label, required, value, onChange, className, variant = 'de
</Dropdown> </Dropdown>
<Dropdown> <Dropdown>
<DropdownTrigger> <DropdownTrigger
<button
aria-label="رنگ متن" aria-label="رنگ متن"
className="min-h-10 rounded-[10px] border border-consumer-border bg-consumer-surface px-3 text-sm font-medium text-consumer-text" className={CONSUMER_TOOLBAR_TRIGGER_CLASS}
type="button"
> >
رنگ رنگ
</button>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu aria-label="رنگ متن"> <DropdownMenu aria-label="رنگ متن">
{CONSUMER_TEXT_COLORS.map((color) => ( {CONSUMER_TEXT_COLORS.map((color) => (
@ -250,14 +250,11 @@ const TextEditor = ({ label, required, value, onChange, className, variant = 'de
</Dropdown> </Dropdown>
<Dropdown> <Dropdown>
<DropdownTrigger> <DropdownTrigger
<button
aria-label="فهرست" aria-label="فهرست"
className="min-h-10 rounded-[10px] border border-consumer-border bg-consumer-surface px-3 text-sm font-medium text-consumer-text" className={CONSUMER_TOOLBAR_TRIGGER_CLASS}
type="button"
> >
فهرست فهرست
</button>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu aria-label="نوع فهرست"> <DropdownMenu aria-label="نوع فهرست">
<DropdownItem <DropdownItem
@ -278,14 +275,7 @@ const TextEditor = ({ label, required, value, onChange, className, variant = 'de
) : ( ) : (
<> <>
<Dropdown> <Dropdown>
<DropdownTrigger> <DropdownTrigger className={ADMIN_TOOLBAR_TRIGGER_CLASS}>font</DropdownTrigger>
<Button
size="sm"
variant="bordered"
>
font
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions"> <DropdownMenu aria-label="Static Actions">
<DropdownItem <DropdownItem
key="bold" key="bold"
@ -314,14 +304,7 @@ const TextEditor = ({ label, required, value, onChange, className, variant = 'de
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
<Dropdown> <Dropdown>
<DropdownTrigger> <DropdownTrigger className={ADMIN_TOOLBAR_TRIGGER_CLASS}>heading</DropdownTrigger>
<Button
size="sm"
variant="bordered"
>
heading
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions"> <DropdownMenu aria-label="Static Actions">
<DropdownItem <DropdownItem
key="H1" key="H1"
@ -374,14 +357,7 @@ const TextEditor = ({ label, required, value, onChange, className, variant = 'de
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
<Dropdown> <Dropdown>
<DropdownTrigger> <DropdownTrigger className={ADMIN_TOOLBAR_TRIGGER_CLASS}>align</DropdownTrigger>
<Button
size="sm"
variant="bordered"
>
align
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions"> <DropdownMenu aria-label="Static Actions">
<DropdownItem <DropdownItem
key="left" key="left"
@ -410,14 +386,7 @@ const TextEditor = ({ label, required, value, onChange, className, variant = 'de
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
<Dropdown> <Dropdown>
<DropdownTrigger> <DropdownTrigger className={ADMIN_TOOLBAR_TRIGGER_CLASS}>link</DropdownTrigger>
<Button
size="sm"
variant="bordered"
>
link
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions"> <DropdownMenu aria-label="Static Actions">
<DropdownItem <DropdownItem
key="link" key="link"
@ -434,14 +403,7 @@ const TextEditor = ({ label, required, value, onChange, className, variant = 'de
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
<Dropdown> <Dropdown>
<DropdownTrigger> <DropdownTrigger className={ADMIN_TOOLBAR_TRIGGER_CLASS}>list</DropdownTrigger>
<Button
size="sm"
variant="bordered"
>
list
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions"> <DropdownMenu aria-label="Static Actions">
<DropdownItem <DropdownItem
key="ordered" key="ordered"

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import type { ComponentProps, ReactElement, ReactNode } from 'react' import type { ComponentProps, CSSProperties, ReactElement, ReactNode } from 'react'
import { Children, Fragment, isValidElement } from 'react' import { Children, Fragment, isValidElement } from 'react'
import { Description, Dropdown as HeroDropdown, Header, Label, Separator } from '@heroui/react' import { Description, Dropdown as HeroDropdown, Header, Label, Separator } from '@heroui/react'
@ -37,13 +37,21 @@ interface DropdownSectionProps {
title?: ReactNode title?: ReactNode
} }
interface DropdownTriggerProps {
children?: ReactNode
'aria-label'?: string
className?: string
isDisabled?: boolean
style?: CSSProperties
}
interface DropdownProps { interface DropdownProps {
children?: ReactNode children?: ReactNode
placement?: ComponentProps<typeof HeroDropdown.Popover>['placement'] | 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' placement?: ComponentProps<typeof HeroDropdown.Popover>['placement'] | 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end'
trigger?: 'press' | 'longPress' trigger?: 'press' | 'longPress'
} }
export function DropdownTrigger({ children }: { children?: ReactNode }) { export function DropdownTrigger({ children }: DropdownTriggerProps) {
return children return children
} }
@ -124,13 +132,65 @@ function renderMenuChildren(children: ReactNode, showIndicator: boolean): ReactN
}) })
} }
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) { export function Dropdown({ children, placement, trigger }: DropdownProps) {
const parts = Children.toArray(children).filter(isValidElement) const parts = Children.toArray(children).filter(isValidElement)
const triggerElement = parts.find((part) => part.type === DropdownTrigger) as ReactElement<{ children?: ReactNode }> | undefined const triggerElement = parts.find((part) => part.type === DropdownTrigger) as ReactElement<DropdownTriggerProps> | undefined
const menuElement = parts.find((part) => part.type === DropdownMenu) as ReactElement<DropdownMenuProps> | undefined const menuElement = parts.find((part) => part.type === DropdownMenu) as ReactElement<DropdownMenuProps> | undefined
if (!triggerElement || !menuElement) return null if (!triggerElement || !menuElement) return null
const resolvedTrigger = resolveTrigger(triggerElement)
const menuChildren = menuElement.props.children const menuChildren = menuElement.props.children
const menuProps = { const menuProps = {
'aria-label': menuElement.props['aria-label'], 'aria-label': menuElement.props['aria-label'],
@ -146,7 +206,14 @@ export function Dropdown({ children, placement, trigger }: DropdownProps) {
return ( return (
<HeroDropdown trigger={trigger}> <HeroDropdown trigger={trigger}>
{triggerElement.props.children} <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.Popover placement={normalizedPlacement}>
<HeroDropdown.Menu {...menuProps}>{renderMenuChildren(menuChildren, showIndicator)}</HeroDropdown.Menu> <HeroDropdown.Menu {...menuProps}>{renderMenuChildren(menuChildren, showIndicator)}</HeroDropdown.Menu>
</HeroDropdown.Popover> </HeroDropdown.Popover>

View File

@ -1,16 +1,17 @@
'use client' 'use client'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useMemo } from 'react' import { useMemo } from 'react'
import { buttonVariants } from '@heroui/react'
import { Dropdown, DropdownItem, DropdownMenu, DropdownSection, DropdownTrigger } from '@/components/heroui/Dropdown' import { Dropdown, DropdownItem, DropdownMenu, DropdownSection, DropdownTrigger } from '@/components/heroui/Dropdown'
import { Avatar } from '@/components/heroui/Avatar' import { Avatar } from '@/components/heroui/Avatar'
import Button from '@/components/formElements/Button'
import LogoutIcon from '@/components/icons/LogoutIcon' import LogoutIcon from '@/components/icons/LogoutIcon'
import { fileAddress } from '@/helpers' import { fileAddress } from '@/helpers'
import { resolveUserAvatarSrc } from '@/lib/resolveUserAvatarSrc' import { resolveUserAvatarSrc } from '@/lib/resolveUserAvatarSrc'
import useAuth from '@/hooks/useAuth' import useAuth from '@/hooks/useAuth'
import useAlertModal from '@/hooks/useAlertModal' import useAlertModal from '@/hooks/useAlertModal'
import EditIcon from '@/components/icons/EditIcon' import EditIcon from '@/components/icons/EditIcon'
import { cn } from '@/lib/cn'
const UserDropdown = () => { const UserDropdown = () => {
const { user, logout } = useAuth() const { user, logout } = useAuth()
@ -25,13 +26,16 @@ const UserDropdown = () => {
return ( return (
<> <>
<Dropdown placement="bottom-start"> <Dropdown placement="bottom-start">
<DropdownTrigger> <DropdownTrigger
<Button
iconOnly
aria-label="منوی حساب کاربری" aria-label="منوی حساب کاربری"
className="rounded-full p-0" className={cn(
radius="full" buttonVariants({
variant="light" isIconOnly: true,
size: 'lg',
variant: 'tertiary',
}),
'rounded-full p-0 outline-none'
)}
> >
<Avatar <Avatar
isBordered isBordered
@ -39,7 +43,6 @@ const UserDropdown = () => {
size="lg" size="lg"
src={avatarSrc} src={avatarSrc}
/> />
</Button>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu <DropdownMenu