Top-level navigation bar with delayed hover/focus dropdowns and trigger/content builders.

Preview
Style
Basic
src/demo/views/navigation-menu.ts255 lines
import { 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 * as Update from 'foldkit/update'

import * as NavMenu from '../../generated/registry/ui/navigation-menu'
import { icon } from '../../generated/registry/lib/icons'
import { CircleAlert, CircleCheck, CircleDashed } from 'lucide'

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

const NavigationMenu = NavMenu.NavigationMenu

const Message = defineMessageUnion({
  GotNavigationMenuMessage: { message: NavMenu.Message },
})

const COMPONENTS: ReadonlyArray<{ title: string; href: string; description: string }> = [
  {
    title: 'Alert Dialog',
    href: '/docs/primitives/alert-dialog',
    description: 'A modal dialog that interrupts the user with important content.',
  },
  {
    title: 'Hover Card',
    href: '/docs/primitives/hover-card',
    description: 'For sighted users to preview content available behind a link.',
  },
  {
    title: 'Progress',
    href: '/docs/primitives/progress',
    description: 'Displays an indicator showing the completion progress of a task.',
  },
  {
    title: 'Scroll-area',
    href: '/docs/primitives/scroll-area',
    description: 'Visually or semantically separates content.',
  },
  {
    title: 'Tabs',
    href: '/docs/primitives/tabs',
    description: 'A set of layered sections of content displayed one at a time.',
  },
  {
    title: 'Tooltip',
    href: '/docs/primitives/tooltip',
    description: 'A popup that displays information related to an element.',
  },
]

const toParentMessage = (message: NavMenu.Message): AppMessage =>
  Message.GotNavigationMenuMessage({ message })

const GETTING_STARTED_ID = 'getting-started'
const COMPONENTS_ID = 'components'
const WITH_ICON_ID = 'with-icon'
const NAV_ITEM_IDS = [GETTING_STARTED_ID, COMPONENTS_ID, WITH_ICON_ID] as const

const navDropdown = (
  id: string,
  trigger: string,
  content: ReadonlyArray<Html>,
  model: Model,
  h: HtmlBuilder<AppMessage>,
): Html =>
  NavigationMenu.item(
    {},
    [
      h.submodel({
        slotId: id,
        model: NavMenu.getItem(model.navigationMenu, id),
        view: NavMenu.view,
        viewInputs: NavMenu.dropdownViewInputs({ id, trigger }, content, h),
        toParentMessage: (message) =>
          toParentMessage(NavMenu.Message.GotItemMessage({ id, message })),
      }),
    ],
    h,
  )

export const navigationMenuView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      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']),
          NavigationMenu(
            {},
            [
              NavigationMenu.list(
                {},
                [
                  navDropdown(
                    GETTING_STARTED_ID,
                    'Getting started',
                    [
                      h.ul(
                        [h.Class('grid w-96 gap-1')],
                        [
                          navListItem(
                            h,
                            'Introduction',
                            '/docs',
                            'Re-usable components built with Tailwind CSS.',
                          ),
                          navListItem(
                            h,
                            'Installation',
                            '/docs/installation',
                            'How to install dependencies and structure your app.',
                          ),
                          navListItem(
                            h,
                            'Typography',
                            '/docs/primitives/typography',
                            'Styles for headings, paragraphs, lists...etc',
                          ),
                        ],
                      ),
                    ],
                    model,
                    h,
                  ),
                  navDropdown(
                    COMPONENTS_ID,
                    'Components',
                    [
                      h.ul(
                        [h.Class('grid w-[400px] gap-2 md:w-[500px] md:grid-cols-2 lg:w-[600px]')],
                        COMPONENTS.map((c) => navListItem(h, c.title, c.href, c.description)),
                      ),
                    ],
                    model,
                    h,
                  ),
                  navDropdown(
                    WITH_ICON_ID,
                    'With Icon',
                    [
                      h.ul(
                        [h.Class('grid w-[200px] gap-1')],
                        [
                          h.li(
                            [],
                            [
                              NavigationMenu.link(
                                { href: '#', className: 'flex-row items-center gap-2' },
                                [icon(h, CircleAlert, 'size-4'), ' Backlog'],
                                h,
                              ),
                            ],
                          ),
                          h.li(
                            [],
                            [
                              NavigationMenu.link(
                                { href: '#', className: 'flex-row items-center gap-2' },
                                [icon(h, CircleDashed, 'size-4'), ' To Do'],
                                h,
                              ),
                            ],
                          ),
                          h.li(
                            [],
                            [
                              NavigationMenu.link(
                                { href: '#', className: 'flex-row items-center gap-2' },
                                [icon(h, CircleCheck, 'size-4'), ' Done'],
                                h,
                              ),
                            ],
                          ),
                        ],
                      ),
                    ],
                    model,
                    h,
                  ),
                  NavigationMenu.item(
                    {},
                    [
                      NavigationMenu.link(
                        { href: '/docs', className: NavMenu.navigationMenuTriggerStyle() },
                        ['Docs'],
                        h,
                      ),
                    ],
                    h,
                  ),
                ],
                h,
              ),
            ],
            h,
          ),
        ],
      ),
    ],
  )

const navListItem = (
  h: HtmlBuilder<AppMessage>,
  title: string,
  href: string,
  description: string,
): Html =>
  h.li(
    [],
    [
      NavigationMenu.link(
        { href },
        [
          h.div(
            [h.Class('flex flex-col gap-1 text-sm')],
            [
              h.div([h.Class('leading-none font-medium')], [title]),
              h.div([h.Class('line-clamp-2 text-muted-foreground')], [description]),
            ],
          ),
        ],
        h,
      ),
    ],
  )

const fields = { navigationMenu: NavMenu.Model }

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

const foldNavigationMenu = Update.foldChild({
  update: NavMenu.update,
  read: (model: State) => Option.some(model.navigationMenu),
  write: (model, next) => evo(model, { navigationMenu: () => next }),
  toParentMessage,
  foldOutMessage: (): Update.Step<State, unknown> => (model) => ({ model }),
})

export const slice = defineSlice({
  fields,
  init: { navigationMenu: NavMenu.init(NAV_ITEM_IDS) },
  messages: [Message.GotNavigationMenuMessage],
  handlers: (model: State) => ({
    GotNavigationMenuMessage: (
      payload: typeof Message.GotNavigationMenuMessage.Type,
    ): UpdateReturn => foldNavigationMenu(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/navigation-menu

Source

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

registry/default/ui/navigation-menu.ts462 lines
import { HoverIntent, Popover as FoldkitPopover } from '@foldkit/ui'
import type { AnchorConfig } from '@foldkit/ui/popover'
import { Option, Schema as S } from 'effect'
import * as Command from 'foldkit/command'
import { childAttributes, type ChildAttribute, type Html, type HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { evo } from 'foldkit/struct'
import { defineView } from 'foldkit/submodel'
import * as Update from 'foldkit/update'
import { ChevronDown } from 'lucide'
import { icon } from '@/lib/icons'

type Child = Html | string

import { cn } from '@/lib/utils'
import { placementToSide } from './popover'

// NavigationMenu is a top-level nav bar. `NavigationMenu` is the container
// (`nav`); sub-builders are attached as properties: `.list`, `.item`,
// `.link` (fully presentational, for static links) and `dropdownViewInputs`
// (stateful — see below).
//
// `dropdownViewInputs` builds the `ViewInputs` for one hover-aware Popover
// submodel per item, keyed by id; the consumer wires it up with `h.submodel`
// themselves. HoverIntent owns delayed pointer/focus engagement; Popover owns
// anchoring, dismissal, and focus management. `update` adds "opening one
// item's dropdown closes any other open one," since a nav bar shows at most
// one dropdown at a time.
//
// foldcn gaps vs upstream: no shared/animated Viewport (Base UI's single
// morphing panel with a slide-direction indicator) — each dropdown is its own
// independently-anchored Popover panel instead. cn-navigation-menu-item is an
// intentional no-op hook upstream.

export const ItemModel = S.Struct({
  hoverIntent: HoverIntent.Model,
  popover: FoldkitPopover.Model,
})
export type ItemModel = typeof ItemModel.Type

export const Model = S.Struct({
  items: S.Record(S.String, ItemModel),
})
export type Model = typeof Model.Type

/** Creates an initial nav-menu model with one closed hover-aware item per id. */
export const init = (itemIds: ReadonlyArray<string>): Model => ({
  items: Object.fromEntries(
    itemIds.map((id) => [
      id,
      {
        hoverIntent: HoverIntent.init({ closeDelay: 200 }),
        popover: FoldkitPopover.init({ id, isAnimated: true, contentFocus: true }),
      },
    ]),
  ),
})

export const ItemMessage = defineMessageUnion({
  GotHoverIntentMessage: { message: HoverIntent.Message },
  GotPopoverMessage: { message: FoldkitPopover.Message },
})
export type ItemMessage = typeof ItemMessage.Type

export const Message = defineMessageUnion({
  GotItemMessage: { id: S.String, message: ItemMessage },
})
export type Message = typeof Message.Type

export const OutMessage = defineMessageUnion({
  Opened: { id: S.String },
  Closed: { id: S.String },
})
export type OutMessage = typeof OutMessage.Type

const ItemOutMessage = defineMessageUnion({
  Opened: {},
  Closed: {},
})
type ItemOutMessage = typeof ItemOutMessage.Type

type UpdateReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage>
type ItemUpdateReturn = Update.ReturnWithOutMessage<ItemModel, ItemMessage, ItemOutMessage>

/** Looks up one item's hover and popover state. */
export const getItem = (model: Model, id: string): ItemModel => {
  const item = model.items[id]
  if (item === undefined) {
    const knownIds = Object.keys(model.items)
    throw new Error(
      `NavigationMenu: unknown item id "${id}" — add it to the array passed to NavigationMenu.init. Known ids: ${knownIds.length > 0 ? knownIds.join(',') : '(none — init was called with an empty array)'}.`,
    )
  }
  return item
}

const toItemMessage =
  (id: string) =>
  (message: ItemMessage): Message =>
    Message.GotItemMessage({ id, message })

const withoutPopoverInteractions = (
  attributes: ReadonlyArray<ChildAttribute>,
): ReadonlyArray<ChildAttribute> =>
  attributes.filter((wrapped) => {
    // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: foldkit attribute._tag
    const tag = (wrapped.attribute as { readonly _tag?: string } | undefined)?._tag
    return ![
      'OnPointerDown',
      'OnClick',
      'OnKeyDownPreventDefault',
      'OnKeyUpPreventDefault',
    ].includes(tag ?? '')
  })

const mapPopoverCommands = (
  commands: NonNullable<ReturnType<typeof FoldkitPopover.open>['commands']> = [],
) =>
  Command.mapMessages(
    commands.filter((command) => command.name !== 'FocusButton'),
    (message) => ItemMessage.GotPopoverMessage({ message }),
  )

const syncPopover = (
  model: ItemModel,
  operation: typeof FoldkitPopover.open | typeof FoldkitPopover.close,
  outMessage: ItemOutMessage,
): ItemUpdateReturn => {
  const result = operation(model.popover)
  return {
    model: { ...model, popover: result.model },
    commands: mapPopoverCommands(result.commands),
    outMessage,
  }
}

const releaseTriggerFocus = (hoverIntent: HoverIntent.Model): HoverIntent.Model =>
  evo(hoverIntent, {
    maybeFocusLocation: () =>
      Option.filter(hoverIntent.maybeFocusLocation, (location) => location !== 'Trigger'),
  })

const updateItem = (model: ItemModel, message: ItemMessage): ItemUpdateReturn => {
  if (message._tag === 'GotHoverIntentMessage') {
    if (message.message._tag === 'FocusedPanel' || message.message._tag === 'BlurredPanel') {
      return { model }
    }

    const result = HoverIntent.update(
      message.message._tag === 'LeftTrigger'
        ? releaseTriggerFocus(model.hoverIntent)
        : model.hoverIntent,
      message.message,
    )
    const nextModel = { ...model, hoverIntent: result.model }

    if (result.outMessage?._tag === 'Opened') {
      return syncPopover(nextModel, FoldkitPopover.open, ItemOutMessage.Opened())
    }
    if (result.outMessage?._tag === 'Closed') {
      return syncPopover(nextModel, FoldkitPopover.close, ItemOutMessage.Closed())
    }
    return {
      model: nextModel,
      commands: Command.mapMessages(result.commands ?? [], (message) =>
        ItemMessage.GotHoverIntentMessage({ message }),
      ),
    }
  }

  const result = FoldkitPopover.update(model.popover, message.message)
  const hoverIntent =
    message.message._tag === 'RequestedClose'
      ? HoverIntent.close(model.hoverIntent)
      : { model: model.hoverIntent }
  const base = {
    model: { hoverIntent: hoverIntent.model, popover: result.model },
    commands: mapPopoverCommands(result.commands),
  }
  if (hoverIntent.outMessage === undefined) return base
  return { ...base, outMessage: hoverIntent.outMessage }
}

const closeItem = (item: ItemModel): ItemUpdateReturn =>
  updateItem(
    item,
    ItemMessage.GotPopoverMessage({ message: FoldkitPopover.Message.RequestedClose() }),
  )

/** Processes a nav-menu message. Opening one item's popover force-closes any
 *  other currently-open item, so at most one dropdown shows at a time.
 *  That auto-close is silent: only the newly opened id's `OutMessage.Opened`
 *  is emitted, the siblings are closed purely via commands with no matching
 *  `Closed` out message, so don't expect one for the item that got bumped. */
export const update = (model: Model, message: Message): UpdateReturn => {
  const { id, message: itemMessage } = message
  const current = model.items[id]
  if (current === undefined) return { model }

  const {
    model: nextItem,
    commands: itemCommands = [],
    outMessage,
  } = updateItem(current, itemMessage)
  const justOpened = outMessage?._tag === 'Opened'

  if (!justOpened) {
    const base = {
      model: { items: { ...model.items, [id]: nextItem } },
      commands: Command.mapMessages(itemCommands, toItemMessage(id)),
    }
    if (outMessage === undefined) return base
    return { ...base, outMessage: OutMessage.Closed({ id }) }
  }

  const closedOthers = Object.entries(model.items).flatMap(([key, item]) =>
    key === id || !item.popover.isOpen ? [] : [[key, closeItem(item)] as const],
  )

  return {
    model: {
      items: {
        ...model.items,
        [id]: nextItem,
        ...Object.fromEntries(closedOthers.map(([key, result]) => [key, result.model])),
      },
    },
    commands: [
      ...Command.mapMessages(itemCommands, toItemMessage(id)),
      ...closedOthers.flatMap(([key, { commands = [] }]) =>
        Command.mapMessages(commands, toItemMessage(key)),
      ),
    ],
    outMessage: OutMessage.Opened({ id }),
  }
}

export const navigationMenuClass =
  'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center'

export const navigationMenuListClass =
  'gap-0 group flex flex-1 list-none items-center justify-center'

export const navigationMenuItemClass = 'relative'

/** Upstream link token string; data-active is foldkit's attr name. */
export const navigationMenuLinkClass = 'data-active:focus:bg-muted data-active:hover:bg-muted data-active:bg-muted/50 focus-visible:ring-ring/50 hover:bg-muted focus:bg-muted flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none focus-visible:ring-3 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md [&_svg:not([class*=\'size-\'])]:size-4'

/** Upstream trigger component + token strings. */
export const navigationMenuTriggerClass =
  'hover:bg-muted focus:bg-muted data-open:hover:bg-muted data-open:focus:bg-muted data-open:bg-muted/50 focus-visible:ring-ring/50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all focus-visible:ring-3 focus-visible:outline-1 disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 group/navigation-menu-trigger group inline-flex h-9 w-max items-center justify-center outline-none disabled:pointer-events-none'

export const navigationMenuTriggerIconClass = 'relative top-px ml-1 size-3 transition duration-300 group-data-open/navigation-menu-trigger:rotate-180 group-data-popup-open/navigation-menu-trigger:rotate-180'

export const navigationMenuContentClass =
  'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:ring-foreground/10 p-1 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:duration-300 max-h-none! overflow-visible! data-enter:animate-in data-enter:fade-in-0 data-enter:zoom-in-95 data-leave:animate-out data-leave:fade-out-0 data-leave:zoom-out-95 data-ending-style:data-activation-direction=left:translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] transition-[opacity,transform,translate] duration-[0.35s] data-ending-style:opacity-0 data-starting-style:opacity-0 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none z-50 bg-popover text-popover-foreground shadow ring-1 ring-foreground/10 rounded-lg'

export const navigationMenuViewportClass = 'bg-popover text-popover-foreground data-enter:animate-in data-leave:animate-out data-leave:zoom-out-90 data-enter:zoom-in-90 ring-foreground/10 rounded-lg shadow ring-1 duration-100'

export const navigationMenuPositionerClass = 'transition-[top,left,right,bottom] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:right-0 data-[side=bottom]:before:left-0'

export const navigationMenuPopupClass = 'bg-popover text-popover-foreground ring-foreground/10 rounded-lg shadow ring-1 transition-all ease-[cubic-bezier(0.22,1,0.36,1)] outline-none data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:scale-90 data-starting-style:opacity-0'

export const navigationMenuIndicatorClass = 'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in'

export const navigationMenuIndicatorArrowClass = 'bg-border rounded-tl-sm shadow-md'

export const navigationMenuTriggerStyle = () => navigationMenuTriggerClass

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

type StyleConfig = Readonly<{ className?: string }>

const navigationMenuContainer = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.nav(
    [
      h.Class(cn(navigationMenuClass, config.className)),
      h.DataAttribute('slot', 'navigation-menu'),
      h.DataAttribute('viewport', 'false'),
    ],
    children,
  )

const navigationMenuList = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.ul(
    [h.Class(cn(navigationMenuListClass)), h.DataAttribute('slot', 'navigation-menu-list')],
    children,
  )

const navigationMenuItem = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.li(
    [h.Class(cn(navigationMenuItemClass)), h.DataAttribute('slot', 'navigation-menu-item')],
    children,
  )

type LinkConfig = Readonly<{ className?: string; href?: string }>

const navigationMenuLink = <M>(
  config: LinkConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.a(
    [
      h.Class(cn(navigationMenuLinkClass, config.className)),
      h.DataAttribute('slot', 'navigation-menu-link'),
      ...(config.href !== undefined ? [h.Attribute('href', config.href)] : []),
    ],
    children,
  )

export type DropdownConfig = Readonly<{
  id: string
  trigger: Child
  anchor?: AnchorConfig
  triggerClass?: string
  contentClass?: string
  isDisabled?: boolean
}>

/** Builds hover-aware `ViewInputs` for one nav item. The consumer owns
 * `h.submodel` (model: `NavigationMenu.getItem(model, config.id)`, view:
 * `NavigationMenu.view`) and the surrounding `<li>`. */
const navigationMenuViewport = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.Class(cn(navigationMenuViewportClass, config.className)),
      h.DataAttribute('slot', 'navigation-menu-viewport'),
    ],
    children,
  )

const navigationMenuIndicator = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.Class(cn(navigationMenuIndicatorClass, config.className)),
      h.DataAttribute('slot', 'navigation-menu-indicator'),
    ],
    children.length > 0 ? children : [h.div([h.Class(navigationMenuIndicatorArrowClass)])],
  )

export type ViewInputs = Readonly<{
  anchor: AnchorConfig
  toView: (render: RenderInfo) => Html
  isDisabled?: boolean
}>

export type RenderInfo = Readonly<{
  trigger: ReadonlyArray<ChildAttribute>
  panel: ReadonlyArray<ChildAttribute>
  isVisible: boolean
}>

/** HoverIntent owns interaction; Popover remains only for positioning. */
export const view = defineView<ItemModel, ItemMessage, ViewInputs>((model, viewInputs, h) =>
  h.submodel({
    slotId: `${model.popover.id}-hover-intent`,
    model: model.hoverIntent,
    view: HoverIntent.view,
    viewInputs: {
      focusTriggerSelector: `#${FoldkitPopover.buttonId(model.popover.id)}`,
      toView: ({ trigger, panel }) =>
        h.submodel({
          slotId: `${model.popover.id}-popover`,
          model: model.popover,
          view: FoldkitPopover.view,
          viewInputs: {
            anchor: viewInputs.anchor,
            isDisabled: viewInputs.isDisabled,
            toView: ({ button, panel: popoverPanel, isVisible }) =>
              viewInputs.toView({
                trigger: [
                  ...withoutPopoverInteractions(button),
                  ...(viewInputs.isDisabled ? [] : trigger),
                  ...childAttributes([h.DataAttribute('slot', 'navigation-menu-trigger')]),
                ],
                panel: [
                  ...withoutPopoverInteractions(popoverPanel),
                  ...(viewInputs.isDisabled ? [] : panel),
                  ...childAttributes([h.DataAttribute('slot', 'navigation-menu-content')]),
                ],
                isVisible,
              }),
          },
          toParentMessage: (message) => ItemMessage.GotPopoverMessage({ message }),
        }),
    },
    toParentMessage: (message) => ItemMessage.GotHoverIntentMessage({ message }),
  }),
)

export const dropdownViewInputs = <M>(
  config: DropdownConfig,
  content: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): ViewInputs => {
  const anchor = config.anchor ?? NAVIGATION_MENU_ANCHOR
  return {
    anchor,
    ...(config.isDisabled !== undefined && { isDisabled: config.isDisabled }),
    toView: ({ trigger, panel, isVisible }) =>
      h.div(
        [h.Class('contents')],
        [
          h.button(
            [h.Class(cn(navigationMenuTriggerClass, config.triggerClass)), ...trigger],
            [config.trigger, icon(h, ChevronDown, navigationMenuTriggerIconClass)],
          ),
          ...(isVisible
            ? [
                h.div(
                  [
                    h.Class(cn(navigationMenuContentClass, config.contentClass)),
                    h.DataAttribute('side', placementToSide(anchor.placement ?? 'bottom')),
                    ...panel,
                  ],
                  content,
                ),
              ]
            : []),
        ],
      ),
  }
}

/** Composable navigation menu — `NavigationMenu` is the container, with
 *  sub-builders as properties: `NavigationMenu.list`, `NavigationMenu.item`,
 *  `NavigationMenu.link` (presentational), `NavigationMenu.viewport`,
 *  `NavigationMenu.indicator`. Build a stateful dropdown item
 *  with `dropdownViewInputs` + `h.submodel` + `NavigationMenu.item`. */
export const NavigationMenu = Object.assign(navigationMenuContainer, {
  list: navigationMenuList,
  item: navigationMenuItem,
  link: navigationMenuLink,
  viewport: navigationMenuViewport,
  indicator: navigationMenuIndicator,
})