Styled dropdown menu with keyboard navigation and typeahead, built on the @foldkit/ui Menu submodel.

Preview
Style

Last action: none yet · Appearance: Status Bar on · Activity Bar off · Panel off · Panel: bottom

Basic
With Shortcuts
With Icons & Destructive
With Checkboxes

Toggling a row closes the panel — upstream keeps it open (primitive gap).

With Radio Group
Complex
src/demo/views/menu.ts437 lines
import { Update } from 'foldkit'
import { Match as M, Option } from 'effect'
import { Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'
import { Check, CreditCard, LogOut, Settings, User } from 'lucide'

import { Menu as FoldkitMenu } from '@foldkit/ui'

import * as menu from '../../generated/registry/ui/menu'
import { icon } from '../../generated/registry/lib/icons'

import { DemoMenu } from '../bundles'
import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

export const Message = defineMessageUnion({
  GotBasicMenuMessage: { message: menu.Message },
  GotShortcutsMenuMessage: { message: menu.Message },
  GotIconsMenuMessage: { message: menu.Message },
  GotChecksMenuMessage: { message: menu.Message },
  GotRadioMenuMessage: { message: menu.Message },
  GotComplexMenuMessage: { message: menu.Message },
})

// Sections mirror apps/v4/examples/base/dropdown-menu-*.tsx. The foldkit
// primitive has flat items only (no submenu/checkbox/radio kinds), so the
// submenu rows from upstream render as labelled groups, and the
// checkbox/radio rows render check indicators from demo-owned state (see
// gapsByItem.menu). Every row is selectable; the status line below reports
// the last selection. Checkbox/radio selections toggle demo state and close
// the panel — upstream keeps the panel open, which needs primitive support.

const BASIC_ITEMS = ['Profile', 'Billing', 'Settings', 'GitHub', 'Support', 'API'] as const

const basicGroupKey = (item: string): string =>
  item === 'Profile' || item === 'Billing' || item === 'Settings' ? 'account' : 'links'

const SHORTCUT_ITEMS = ['Profile', 'Billing', 'Settings', 'Log out'] as const
const SHORTCUTS = new Map<string, string>([
  ['Profile', '⇧⌘P'],
  ['Billing', '⌘B'],
  ['Settings', '⌘S'],
  ['Log out', '⇧⌘Q'],
])

const ICON_ITEMS = ['Profile', 'Billing', 'Settings', 'Log out'] as const

const ICONS = new Map<string, typeof User>([
  ['Profile', User],
  ['Billing', CreditCard],
  ['Settings', Settings],
  ['Log out', LogOut],
])

const CHECK_ITEMS = ['Status Bar', 'Activity Bar', 'Panel'] as const

const RADIO_ITEMS = ['Top', 'Bottom', 'Right'] as const

const COMPLEX_ITEMS = [
  'Profile',
  'Billing',
  'Settings',
  'Team',
  'Email',
  'Message',
  'More...',
  'New Team',
  'GitHub',
  'Support',
  'API',
  'Log out',
] as const

const COMPLEX_SHORTCUTS = new Map<string, string>([
  ['Profile', '⇧⌘P'],
  ['Billing', '⌘B'],
  ['Settings', '⌘S'],
  ['New Team', '⌘+T'],
  ['Log out', '⇧⌘Q'],
])

const complexGroupKey = (item: string): string => {
  if (item === 'Profile' || item === 'Billing' || item === 'Settings') return 'account'
  if (item === 'Team' || item === 'Email' || item === 'Message' || item === 'More...') return 'team'
  if (item === 'New Team') return 'new-team'
  if (item === 'GitHub' || item === 'Support' || item === 'API') return 'links'
  return 'logout'
}

const withShortcut = (
  h: HtmlBuilder<AppMessage>,
  label: string,
  shortcut: string | undefined,
): Html =>
  shortcut === undefined
    ? h.span([], [label])
    : h.span(
        [h.Class('flex w-full items-center gap-2')],
        [h.span([], [label]), h.span([h.Class(menu.menuShortcutClass)], [shortcut])],
      )

const fields = {
  basicMenu: menu.Model,
  shortcutsMenu: menu.Model,
  iconsMenu: menu.Model,
  checksMenu: menu.Model,
  radioMenu: menu.Model,
  complexMenu: menu.Model,
  showStatusBar: S.Boolean,
  showPanel: S.Boolean,
  panelPosition: S.String,
  lastMenuAction: S.String,
}

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

const checkOn = (model: State, item: string): boolean => {
  if (item === 'Status Bar') return model.showStatusBar
  if (item === 'Panel') return model.showPanel
  return false
}

const statusLine = (model: State): string => {
  const checks = CHECK_ITEMS.map((item) => `${item} ${checkOn(model, item) ? 'on' : 'off'}`).join(
    ' · ',
  )
  return `Last action: ${model.lastMenuAction} · Appearance: ${checks} · Panel: ${model.panelPosition}`
}

export const menuView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.p([h.Class('px-1 text-xs text-muted-foreground')], [statusLine(model)]),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Basic']),
          h.submodel({
            slotId: model.basicMenu.id,
            model: model.basicMenu,
            view: DemoMenu.view,
            viewInputs: menu.viewInputs<string>({
              items: BASIC_ITEMS,
              buttonContent: h.span([], ['Open']),
              itemGroupKey: (item) => basicGroupKey(item),
              groupToHeading: (groupKey) =>
                groupKey === 'account' ? { content: h.span([], ['My Account']) } : undefined,
              isItemDisabled: (item) => item === 'API',
              itemToConfig: (item, { isActive }) => ({
                className: isActive ? 'font-medium' : '',
                content: h.span([], [item]),
              }),
            }),
            toParentMessage: (message) => Message.GotBasicMenuMessage({ message }),
          }),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Shortcuts']),
          h.submodel({
            slotId: model.shortcutsMenu.id,
            model: model.shortcutsMenu,
            view: DemoMenu.view,
            viewInputs: menu.viewInputs<string>({
              items: SHORTCUT_ITEMS,
              buttonContent: h.span([], ['Open']),
              itemsClass: 'w-44',
              itemToConfig: (item) => ({
                content: withShortcut(h, item, SHORTCUTS.get(item)),
              }),
            }),
            toParentMessage: (message) => Message.GotShortcutsMenuMessage({ message }),
          }),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div(
            [h.Class('px-1 text-xs font-medium text-muted-foreground')],
            ['With Icons & Destructive'],
          ),
          h.submodel({
            slotId: model.iconsMenu.id,
            model: model.iconsMenu,
            view: DemoMenu.view,
            viewInputs: menu.viewInputs<string>({
              items: ICON_ITEMS,
              buttonContent: h.span([], ['Open']),
              isItemDisabled: (item) => item === 'Billing',
              itemToConfig: (item) => ({
                className:
                  item === 'Log out'
                    ? 'text-destructive data-active:bg-destructive/10 data-active:text-destructive dark:data-active:bg-destructive/20 [&_svg]:text-destructive'
                    : '',
                content: h.span(
                  [h.Class('flex w-full items-center gap-2')],
                  [icon(h, ICONS.get(item) ?? User), h.span([], [item])],
                ),
              }),
            }),
            toParentMessage: (message) => Message.GotIconsMenuMessage({ message }),
          }),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Checkboxes']),
          h.submodel({
            slotId: model.checksMenu.id,
            model: model.checksMenu,
            view: DemoMenu.view,
            viewInputs: menu.viewInputs<string>({
              items: CHECK_ITEMS,
              buttonContent: h.span([], ['Open']),
              itemsClass: 'w-40',
              itemGroupKey: () => 'appearance',
              groupToHeading: () => ({ content: h.span([], ['Appearance']) }),
              isItemDisabled: (item) => item === 'Activity Bar',
              itemToConfig: (item) => ({
                content: h.span(
                  [h.Class('flex w-full items-center gap-2')],
                  [
                    h.span(
                      [h.Class('flex w-4 shrink-0 items-center justify-center')],
                      [...(checkOn(model, item) ? [icon(h, Check, 'size-4')] : [])],
                    ),
                    h.span([], [item]),
                  ],
                ),
              }),
            }),
            toParentMessage: (message) => Message.GotChecksMenuMessage({ message }),
          }),
          h.p(
            [h.Class('px-1 text-xs text-muted-foreground')],
            ['Toggling a row closes the panel — upstream keeps it open (primitive gap).'],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Radio Group']),
          h.submodel({
            slotId: model.radioMenu.id,
            model: model.radioMenu,
            view: DemoMenu.view,
            viewInputs: menu.viewInputs<string>({
              items: RADIO_ITEMS,
              buttonContent: h.span([], ['Open']),
              itemsClass: 'w-32',
              itemGroupKey: () => 'position',
              groupToHeading: () => ({ content: h.span([], ['Panel Position']) }),
              itemToConfig: (item) => ({
                content: h.span(
                  [h.Class('flex w-full items-center gap-2')],
                  [
                    h.span(
                      [h.Class('flex w-4 shrink-0 items-center justify-center')],
                      [...(model.panelPosition === item ? [icon(h, Check, 'size-4')] : [])],
                    ),
                    h.span([], [item]),
                  ],
                ),
              }),
            }),
            toParentMessage: (message) => Message.GotRadioMenuMessage({ message }),
          }),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Complex']),
          h.submodel({
            slotId: model.complexMenu.id,
            model: model.complexMenu,
            view: DemoMenu.view,
            viewInputs: menu.viewInputs<string>({
              items: COMPLEX_ITEMS,
              buttonContent: h.span([], ['Open']),
              itemsClass: 'w-44',
              itemGroupKey: (item) => complexGroupKey(item),
              groupToHeading: (groupKey) => {
                if (groupKey === 'account') return { content: h.span([], ['My Account']) }
                // Upstream nests these under an "Invite users" submenu;
                // without a submenu primitive they render as a labelled group.
                if (groupKey === 'team') return { content: h.span([], ['Invite users']) }
                return undefined
              },
              isItemDisabled: (item) => item === 'API',
              itemToConfig: (item) => ({
                content: withShortcut(h, item, COMPLEX_SHORTCUTS.get(item)),
              }),
            }),
            toParentMessage: (message) => Message.GotComplexMenuMessage({ message }),
          }),
        ],
      ),
    ],
  )

const recordSelection = (menuName: string, value: string) => (model: State) => ({
  model: evo(model, { lastMenuAction: () => `${menuName}: ${value}` }),
})

const foldOutMessage = (menuName: string) =>
  M.type<FoldkitMenu.OutMessage>().pipe(
    M.withReturnType<Update.Step<State, unknown>>(),
    M.tagsExhaustive({ Selected: ({ value }) => recordSelection(menuName, value) }),
  )

const foldChecksOutMessage = M.type<FoldkitMenu.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    Selected:
      ({ value }) =>
      (model) => ({
        model: evo(model, {
          showStatusBar: () =>
            value === 'Status Bar' ? !model.showStatusBar : model.showStatusBar,
          showPanel: () => (value === 'Panel' ? !model.showPanel : model.showPanel),
          lastMenuAction: () => `Appearance: ${value}`,
        }),
      }),
  }),
)

const foldRadioOutMessage = M.type<FoldkitMenu.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    Selected:
      ({ value }) =>
      (model) => ({
        model: evo(model, {
          panelPosition: () => value,
          lastMenuAction: () => `Panel position: ${value}`,
        }),
      }),
  }),
)

const foldBasic = Update.foldChild({
  update: DemoMenu.update,
  read: (model: State) => Option.some(model.basicMenu),
  write: (model, next) => evo(model, { basicMenu: () => next }),
  toParentMessage: (message) => Message.GotBasicMenuMessage({ message }),
  foldOutMessage: foldOutMessage('Basic'),
})

const foldShortcuts = Update.foldChild({
  update: DemoMenu.update,
  read: (model: State) => Option.some(model.shortcutsMenu),
  write: (model, next) => evo(model, { shortcutsMenu: () => next }),
  toParentMessage: (message) => Message.GotShortcutsMenuMessage({ message }),
  foldOutMessage: foldOutMessage('Shortcuts'),
})

const foldIcons = Update.foldChild({
  update: DemoMenu.update,
  read: (model: State) => Option.some(model.iconsMenu),
  write: (model, next) => evo(model, { iconsMenu: () => next }),
  toParentMessage: (message) => Message.GotIconsMenuMessage({ message }),
  foldOutMessage: foldOutMessage('Icons'),
})

const foldChecks = Update.foldChild({
  update: DemoMenu.update,
  read: (model: State) => Option.some(model.checksMenu),
  write: (model, next) => evo(model, { checksMenu: () => next }),
  toParentMessage: (message) => Message.GotChecksMenuMessage({ message }),
  foldOutMessage: foldChecksOutMessage,
})

const foldRadio = Update.foldChild({
  update: DemoMenu.update,
  read: (model: State) => Option.some(model.radioMenu),
  write: (model, next) => evo(model, { radioMenu: () => next }),
  toParentMessage: (message) => Message.GotRadioMenuMessage({ message }),
  foldOutMessage: foldRadioOutMessage,
})

const foldComplex = Update.foldChild({
  update: DemoMenu.update,
  read: (model: State) => Option.some(model.complexMenu),
  write: (model, next) => evo(model, { complexMenu: () => next }),
  toParentMessage: (message) => Message.GotComplexMenuMessage({ message }),
  foldOutMessage: foldOutMessage('Complex'),
})

export const slice = defineSlice({
  fields,
  init: {
    basicMenu: menu.init({ id: 'menu-basic' }),
    shortcutsMenu: menu.init({ id: 'menu-shortcuts' }),
    iconsMenu: menu.init({ id: 'menu-icons' }),
    checksMenu: menu.init({ id: 'menu-checks' }),
    radioMenu: menu.init({ id: 'menu-radio' }),
    complexMenu: menu.init({ id: 'menu-complex' }),
    showStatusBar: true,
    showPanel: false,
    panelPosition: 'bottom',
    lastMenuAction: 'none yet',
  },
  messages: [
    Message.GotBasicMenuMessage,
    Message.GotShortcutsMenuMessage,
    Message.GotIconsMenuMessage,
    Message.GotChecksMenuMessage,
    Message.GotRadioMenuMessage,
    Message.GotComplexMenuMessage,
  ],
  handlers: (model: State) => ({
    GotBasicMenuMessage: (payload: typeof Message.GotBasicMenuMessage.Type): UpdateReturn =>
      foldBasic(model, payload.message),
    GotShortcutsMenuMessage: (payload: typeof Message.GotShortcutsMenuMessage.Type): UpdateReturn =>
      foldShortcuts(model, payload.message),
    GotIconsMenuMessage: (payload: typeof Message.GotIconsMenuMessage.Type): UpdateReturn =>
      foldIcons(model, payload.message),
    GotChecksMenuMessage: (payload: typeof Message.GotChecksMenuMessage.Type): UpdateReturn =>
      foldChecks(model, payload.message),
    GotRadioMenuMessage: (payload: typeof Message.GotRadioMenuMessage.Type): UpdateReturn =>
      foldRadio(model, payload.message),
    GotComplexMenuMessage: (payload: typeof Message.GotComplexMenuMessage.Type): UpdateReturn =>
      foldComplex(model, payload.message),
  }),
  samples: [],
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/menu

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/menu.ts155 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as Menu from '@/components/ui/menu'`
 */
import { Menu as FoldkitMenu } from '@foldkit/ui'
import type { AnchorConfig } from '@foldkit/ui/menu'
import { childAttributes, inertHtml, type Html } from 'foldkit/html'

import { cn } from '@/lib/utils'

// Re-export the @foldkit/ui Menu surface. Create a bundle once per item type:
//
//   export const ActionMenu = Menu.create<"Edit" | "Delete">()
//
// foldkit deltas: items highlight via data-active (upstream uses focus:) —
// prefix adjusted per docs/deriving-from-base.md; the panel's data-side is
// emitted here from the anchor placement (foldkit anchors expose placement,
// not side). Gaps vs upstream: no checkbox/radio/submenu/
// destructive/inset item kinds (primitive-level). Flat item path uses correct
// tokens/slots; per-item data-slot (dropdown-menu-item etc) cannot be stamped —
// primitive has no per-item attribute hook (documented gap). Sub-trigger uses
// data-popup-open per upstream but foldkit emits data-open — compat via style
// token's data-open handling (see cn-compat.css).

export const create = FoldkitMenu.create
export const init = (config: InitConfig): Model => FoldkitMenu.init({ isAnimated: true, ...config })
export const buttonId = FoldkitMenu.buttonId
export const Model = FoldkitMenu.Model
export type Model = typeof Model.Type
export const Message = FoldkitMenu.Message
export type Message = typeof Message.Type
export const OutMessage = FoldkitMenu.OutMessage
export type OutMessage = typeof OutMessage.Type

export type Bundle<Item extends string = string> = FoldkitMenu.Bundle<Item>
export type InitConfig = FoldkitMenu.InitConfig
export type ViewInputs<Item extends string = string> = FoldkitMenu.ViewInputs<Item>
export type ItemConfig = FoldkitMenu.ItemConfig
export type GroupHeading = FoldkitMenu.GroupHeading

/** Upstream renders DropdownMenuTrigger unstyled (consumers pass a Button);
 *  foldcn's trigger builder keeps a ghost-button composition. */
export const menuTriggerClass = 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=\'size-\'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2'

export const menuItemsClass =
  'data-enter:animate-in data-leave:animate-out data-leave:fade-out-0 data-enter:fade-in-0 data-leave:zoom-out-95 data-enter:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 not-data-[side=bottom]:data-[placement=bottom]:slide-in-from-top-2 not-data-[side=left]:data-[placement=left]:slide-in-from-right-2 not-data-[side=right]:data-[placement=right]:slide-in-from-left-2 not-data-[side=top]:data-[placement=top]:slide-in-from-bottom-2 [--anchor-width:var(--button-width)] data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 animate-none! relative before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground! bg-popover z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden'

export const menuItemsAnimatedClass = menuItemsClass

export const menuItemClass =
  'focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=\'size-\'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-active:bg-accent data-active:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'

export const menuSeparatorClass = 'bg-border -mx-1 my-1 h-px'

export const menuHeadingClass = 'text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7'

export const menuShortcutClass = 'text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest group-data-active/dropdown-menu-item:text-accent-foreground'

export const menuLabelClass = menuHeadingClass

export const menuSubTriggerClass =
  'focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*=\'size-\'])]:size-4 data-open:bg-accent data-open:text-accent-foreground data-[popup-open]:bg-accent data-[popup-open]:text-accent-foreground flex cursor-default items-center outline-hidden select-none data-popup-open:bg-accent data-popup-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0'

export const menuCheckboxItemClass =
  'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=\'size-\'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'

export const menuRadioItemClass =
  'focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*=\'size-\'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'

export const menuBackdropClass = 'fixed inset-0 z-0'

export const menuWrapperClass = 'relative inline-block'

export const MENU_ANCHOR: AnchorConfig = {
  placement: 'bottom-start',
  gap: 4,
  padding: 8,
}

export type MenuViewInputsConfig<Item extends string> = Readonly<{
  items: ReadonlyArray<Item>
  itemToConfig: ViewInputs<Item>['itemToConfig']
  buttonContent: Html
  anchor?: AnchorConfig
  isItemDisabled?: (item: Item, index: number) => boolean
  itemToSearchText?: (item: Item, index: number) => string
  isButtonDisabled?: boolean
  isAnimated?: boolean
  itemGroupKey?: (item: Item, index: number) => string
  groupToHeading?: (groupKey: string) => GroupHeading | undefined
  triggerClass?: string
  itemsClass?: string
  itemClass?: string
  backdropClass?: string
  wrapperClass?: string
  separatorClass?: string
  groupClass?: string
  ariaLabel?: string
  ariaLabelledBy?: string
}>

/** Build styled `Menu.ViewInputs` with foldcn's classes baked in. */
export const viewInputs = <Item extends string>(
  config: MenuViewInputsConfig<Item>,
): ViewInputs<Item> => {
  // Upstream slide-in variants key on data-side; foldkit anchors expose
  // placement ("bottom-start"), so derive the physical side here.
  const anchor = config.anchor ?? MENU_ANCHOR
  const side = (anchor.placement ?? 'bottom').split('-')[0] || 'bottom'
  return {
    items: config.items,
    anchor,
    isItemDisabled: config.isItemDisabled,
    itemToSearchText: config.itemToSearchText,
    isButtonDisabled: config.isButtonDisabled,
    buttonContent: config.buttonContent,
    itemGroupKey: config.itemGroupKey,
    groupToHeading: config.groupToHeading
      ? (groupKey) => {
          const heading = config.groupToHeading!(groupKey)
          if (!heading) return undefined
          return {
            content: heading.content,
            className: cn(menuHeadingClass, heading.className),
          }
        }
      : undefined,
    ariaLabel: config.ariaLabel,
    ariaLabelledBy: config.ariaLabelledBy,
    buttonClassName: cn(menuTriggerClass, config.triggerClass),
    buttonAttributes: childAttributes([inertHtml.DataAttribute('slot', 'dropdown-menu-trigger')]),
    itemsClassName: cn(
      config.isAnimated !== false ? menuItemsAnimatedClass : menuItemsClass,
      config.itemsClass,
    ),
    itemsAttributes: childAttributes([
      inertHtml.DataAttribute('slot', 'dropdown-menu-content'),
      inertHtml.DataAttribute('side', side),
    ]),
    itemToConfig: (item, context) => {
      const { className, content } = config.itemToConfig(item, context)
      return { className: cn(menuItemClass, config.itemClass, className), content }
    },
    separatorClassName: cn(menuSeparatorClass, config.separatorClass),
    separatorAttributes: childAttributes([
      inertHtml.DataAttribute('slot', 'dropdown-menu-separator'),
    ]),
    groupClassName: config.groupClass,
    groupAttributes: childAttributes([inertHtml.DataAttribute('slot', 'dropdown-menu-group')]),
    backdropClassName: cn(menuBackdropClass, config.backdropClass),
    className: cn(menuWrapperClass, config.wrapperClass),
    attributes: childAttributes([inertHtml.DataAttribute('slot', 'dropdown-menu')]),
  }
}