This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { fn } from 'storybook/test'
import { useState } from 'react'
import Dropdown, { type Item } from '.'
const PRIMARY_ITEMS: Item[] = [
{ value: 'rename', text: 'Rename' },
{ value: 'duplicate', text: 'Duplicate' },
]
const SECONDARY_ITEMS: Item[] = [
{ value: 'archive', text: <span className="text-text-destructive">Archive</span> },
{ value: 'delete', text: <span className="text-text-destructive">Delete</span> },
]
const meta = {
title: 'Base/Navigation/Dropdown',
component: Dropdown,
parameters: {
docs: {
description: {
component: 'Small contextual menu with optional destructive section. Uses portal positioning utilities for precise placement.',
},
},
},
tags: ['autodocs'],
args: {
items: PRIMARY_ITEMS,
secondItems: SECONDARY_ITEMS,
},
} satisfies Meta<typeof Dropdown>
export default meta
type Story = StoryObj<typeof meta>
const DropdownDemo = (props: React.ComponentProps<typeof Dropdown>) => {
const [lastAction, setLastAction] = useState<string>('None')
return (
<div className="flex h-[200px] flex-col items-center justify-center gap-4">
<Dropdown
{...props}
onSelect={(item) => {
setLastAction(String(item.value))
props.onSelect?.(item)
}}
/>
<div className="rounded-lg border border-divider-subtle bg-components-panel-bg px-3 py-2 text-xs text-text-secondary">
Last action: <span className="font-mono text-text-primary">{lastAction}</span>
</div>
</div>
)
}
export const Playground: Story = {
render: args => <DropdownDemo {...args} />,
args: {
items: PRIMARY_ITEMS,
secondItems: SECONDARY_ITEMS,
onSelect: fn(),
},
}
export const CustomTrigger: Story = {
render: args => (
<DropdownDemo
{...args}
renderTrigger={open => (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md border border-divider-subtle px-3 py-1.5 text-sm text-text-secondary hover:bg-state-base-hover-alt"
>
Actions
<span className={`transition-transform ${open ? 'rotate-180' : ''}`}>
</span>
</button>
)}
/>
),
args: {
items: PRIMARY_ITEMS,
onSelect: fn(),
},
}

View File

@@ -0,0 +1,122 @@
import type { FC } from 'react'
import { useState } from 'react'
import cn from '@/utils/classnames'
import {
RiMoreFill,
} from '@remixicon/react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import ActionButton from '@/app/components/base/action-button'
import type { ActionButtonProps } from '@/app/components/base/action-button'
export type Item = {
value: string | number
text: string | React.JSX.Element
}
type DropdownProps = {
items: Item[]
secondItems?: Item[]
onSelect: (item: Item) => void
renderTrigger?: (open: boolean) => React.ReactNode
triggerProps?: ActionButtonProps
popupClassName?: string
itemClassName?: string
secondItemClassName?: string
}
const Dropdown: FC<DropdownProps> = ({
items,
onSelect,
secondItems,
renderTrigger,
triggerProps,
popupClassName,
itemClassName,
secondItemClassName,
}) => {
const [open, setOpen] = useState(false)
const handleSelect = (item: Item) => {
setOpen(false)
onSelect(item)
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
{
renderTrigger
? renderTrigger(open)
: (
<ActionButton
{...triggerProps}
className={cn(
open && 'bg-divider-regular',
triggerProps?.className,
)}
>
<RiMoreFill className='h-4 w-4 text-text-tertiary' />
</ActionButton>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={popupClassName}>
<div className='rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg text-sm text-text-secondary shadow-lg'>
{
!!items.length && (
<div className='p-1'>
{
items.map(item => (
<div
key={item.value}
className={cn(
'flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover',
itemClassName,
)}
onClick={() => handleSelect(item)}
>
{item.text}
</div>
))
}
</div>
)
}
{
(!!items.length && !!secondItems?.length) && (
<div className='h-px bg-divider-regular' />
)
}
{
!!secondItems?.length && (
<div className='p-1'>
{
secondItems.map(item => (
<div
key={item.value}
className={cn(
'flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover',
secondItemClassName,
)}
onClick={() => handleSelect(item)}
>
{item.text}
</div>
))
}
</div>
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default Dropdown