import type { Args, Meta, StoryObj } from '@storybook/vue3-vite'
import { computed, ref } from 'vue'
import CpButton from '@/components/CpButton.vue'
import CpIcon from '@/components/CpIcon.vue'
import CpMenu from '@/components/CpMenu.vue'
import CpMenuItem from '@/components/CpMenuItem.vue'
import { docCellStyle, docLabelStyle, docRowWrapStyle } from '@/stories/documentationStyles'
const menuContainerStyle =
'display: flex; flex-direction: column; background: var(--cp-background-primary); border: 1px solid var(--cp-border-secondary, #e5e7eb); border-radius: 8px; padding: 4px 0; min-width: 240px;'
const meta = {
title: 'Atoms/CpMenuItem',
component: CpMenuItem,
parameters: {
docs: {
description: {
component:
'A single menu entry, typically used inside `CpMenu`. Renders a button with a leading and/or trailing icon, a label, and supports loading, critical, selected and disabled states. Sync and async commands are both supported through the `command` prop (set `isAsync` to keep the menu open and surface a loader while the promise resolves).',
},
},
},
argTypes: {
label: {
control: 'text',
description: 'The text displayed inside the item.',
},
leadingIcon: {
control: 'text',
description: 'Icon name displayed before the label. Overridden by the `leading-icon` slot.',
},
trailingIcon: {
control: 'text',
description: 'Icon name displayed after the label. Overridden by the `trailing-icon` slot.',
},
tooltip: {
control: 'text',
description: 'Optional tooltip text shown on hover/focus of the label.',
},
isCritical: {
control: 'boolean',
description: 'Render the item with a destructive (red) styling.',
},
isLoading: {
control: 'boolean',
description: 'Show a loader in place of the leading icon and disable the item.',
},
isSelected: {
control: 'boolean',
description: 'Initial selected state. Toggled internally when clicked.',
},
isAsync: {
control: 'boolean',
description:
'When the `command` returns a promise, set this to keep the parent menu open and emit `onAsyncCommandComplete` once it resolves.',
},
disabled: {
control: 'boolean',
description: 'Disable interactions and apply a muted style.',
},
hideLabel: {
control: 'boolean',
description: 'Hide the label and only show the leading and trailing icons.',
},
},
decorators: [
() => ({
template: '
',
}),
],
} satisfies Meta
export default meta
type Story = StoryObj
const defaultRender = (args: Args) => ({
components: { CpMenuItem },
setup() {
return { args, menuContainerStyle }
},
template: `
`,
})
/**
* Default item: a label with a leading icon. Click it to see the selected
* state toggle briefly before resetting.
*/
export const Default: Story = {
args: {
label: 'Menu item',
leadingIcon: 'edit',
isCritical: false,
isLoading: false,
isSelected: false,
disabled: false,
},
render: defaultRender,
}
/* -------------------------------------------------------------------------- */
/* Icons */
/* -------------------------------------------------------------------------- */
/**
* Different icon configurations: leading only, trailing only, both or none.
*/
export const Icons: Story = {
parameters: { controls: { disable: true } },
render: () => ({
components: { CpMenuItem },
setup() {
return { docCellStyle, docLabelStyle, docRowWrapStyle, menuContainerStyle }
},
template: `
`,
}),
}
/**
* The `leading-icon` and `trailing-icon` slots let you render arbitrary
* content (custom icons, badges, indicators...) instead of the default
* `CpIcon` rendered from the `leadingIcon` / `trailingIcon` props.
*/
export const CustomIconSlots: Story = {
parameters: { controls: { disable: true } },
render: () => ({
components: { CpMenuItem, CpIcon },
setup() {
return { menuContainerStyle }
},
template: `
A
⌘K
`,
}),
}
/* -------------------------------------------------------------------------- */
/* States */
/* -------------------------------------------------------------------------- */
/**
* The interactive states side by side: default, plus the explicit `disabled`,
* `isLoading`, `isSelected` and `isCritical` states. Hover/focus is shown by
* interacting with each item.
*/
export const States: Story = {
parameters: { controls: { disable: true } },
render: () => ({
components: { CpMenuItem },
setup() {
return { docCellStyle, docLabelStyle, docRowWrapStyle, menuContainerStyle }
},
template: `
`,
}),
}
/* -------------------------------------------------------------------------- */
/* Tooltip */
/* -------------------------------------------------------------------------- */
/**
* Hover the label to see the tooltip — useful when the label is truncated or
* when extra context is needed without taking visual space.
*/
export const WithTooltip: Story = {
args: {
label: 'Hover me for more details',
leadingIcon: 'info',
tooltip: 'Here is some additional context shown on hover.',
},
render: defaultRender,
}
/* -------------------------------------------------------------------------- */
/* Commands */
/* -------------------------------------------------------------------------- */
/**
* Sync command: the `command` callback is invoked on click, then the `click`
* event is emitted so the parent (`CpMenu`/`CpMenuList`) can react (e.g.
* close the menu).
*/
export const SyncCommand: Story = {
parameters: { controls: { disable: true } },
render: () => ({
components: { CpMenuItem },
setup() {
const lastAction = ref('—')
const onCommand = () => {
lastAction.value = `Clicked at ${new Date().toLocaleTimeString()}`
}
return { lastAction, onCommand, menuContainerStyle }
},
template: `
Last action: {{ lastAction }}
`,
}),
}
/**
* Async command: combine `isAsync` with a returning promise to display a
* loader while the command resolves. The component automatically toggles
* `isLoading` from outside in this example. `onAsyncCommandComplete` is
* emitted once the promise settles.
*/
export const AsyncCommand: Story = {
parameters: { controls: { disable: true } },
render: () => ({
components: { CpMenuItem },
setup() {
const isLoading = ref(false)
const completedCount = ref(0)
const onCommand = async () => {
isLoading.value = true
await new Promise((resolve) => setTimeout(resolve, 1500))
isLoading.value = false
}
const onAsyncCommandComplete = () => {
completedCount.value += 1
}
return { isLoading, completedCount, onCommand, onAsyncCommandComplete, menuContainerStyle }
},
template: `
Completed {{ completedCount }} time(s)
`,
}),
}
/* -------------------------------------------------------------------------- */
/* Composition */
/* -------------------------------------------------------------------------- */
/**
* Several `CpMenuItem`s stacked together to mimic a typical menu layout.
* In real apps this is usually wrapped by `CpMenu` / `CpMenuList`, but the
* primitive composes on its own too.
*/
export const InCpMenu: Story = {
render: () => ({
components: { CpButton, CpMenu },
setup() {
const isLoading = ref(false)
const onClick = () => alert('Clicked')
const items = computed(() => [
{
label: 'Sync',
leadingIcon: 'edit',
command: onClick,
},
{
label: 'Async',
leadingIcon: 'download',
isLoading: isLoading.value,
isAsync: true,
command: async () => {
isLoading.value = true
await new Promise((resolve) => setTimeout(resolve, 2000))
isLoading.value = false
},
},
{ separator: true },
{
label: 'Critical',
leadingIcon: 'trash-2',
isCritical: true,
command: onClick,
},
{
label: 'Disabled',
leadingIcon: 'edit',
disabled: true,
command: onClick,
},
{
label: 'Selected',
leadingIcon: 'check',
isSelected: true,
command: onClick,
},
{
label: 'Loading',
isLoading: true,
command: onClick,
},
])
return { items }
},
template: `
`,
}),
}