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: `
Leading
Trailing
Both
No icon
`, }), } /** * 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: `
`, }), } /* -------------------------------------------------------------------------- */ /* 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: `
Default
Disabled
Critical
Loading
Selected
`, }), } /* -------------------------------------------------------------------------- */ /* 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: ` `, }), }