Composable floating panel — Popover.header, Popover.title, Popover.description. Built on the @foldkit/ui Popover submodel.

Preview
Style
Basic
Sides
src/demo/views/popover.ts306 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 * as popover from '../../generated/registry/ui/popover'

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

const Message = defineMessageUnion({
  GotPopoverMessage: { message: popover.Message },
  GotTopPopoverMessage: { message: popover.Message },
  GotRightPopoverMessage: { message: popover.Message },
  GotBottomPopoverMessage: { message: popover.Message },
  GotLeftPopoverMessage: { message: popover.Message },
})

export const popoverView = (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']),
          h.submodel({
            slotId: model.popover.id,
            model: model.popover,
            view: popover.view,
            viewInputs: popover.styledViewInputs(
              {
                anchor: { placement: 'bottom', gap: 4, padding: 8 },
                trigger: 'Open popover',
                content: [
                  h.div(
                    [h.Class('grid gap-4')],
                    [
                      h.div(
                        [h.Class('space-y-2')],
                        [
                          h.h4([h.Class('leading-none font-medium')], ['Dimensions']),
                          h.p(
                            [h.Class('text-sm text-muted-foreground')],
                            ['Set the dimensions for the layer.'],
                          ),
                        ],
                      ),
                      h.div(
                        [h.Class('grid gap-2')],
                        [
                          h.div(
                            [h.Class('grid grid-cols-3 items-center gap-4')],
                            [
                              h.label(
                                [h.Class('text-sm font-medium'), h.For('popover-width')],
                                ['Width'],
                              ),
                              h.input([
                                h.Class(
                                  'col-span-2 flex h-8 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm',
                                ),
                                h.Id('popover-width'),
                                h.Attribute('defaultValue', '100%'),
                              ]),
                            ],
                          ),
                          h.div(
                            [h.Class('grid grid-cols-3 items-center gap-4')],
                            [
                              h.label(
                                [h.Class('text-sm font-medium'), h.For('popover-maxWidth')],
                                ['Max. width'],
                              ),
                              h.input([
                                h.Class(
                                  'col-span-2 flex h-8 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm',
                                ),
                                h.Id('popover-maxWidth'),
                                h.Attribute('defaultValue', '300px'),
                              ]),
                            ],
                          ),
                          h.div(
                            [h.Class('grid grid-cols-3 items-center gap-4')],
                            [
                              h.label(
                                [h.Class('text-sm font-medium'), h.For('popover-height')],
                                ['Height'],
                              ),
                              h.input([
                                h.Class(
                                  'col-span-2 flex h-8 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm',
                                ),
                                h.Id('popover-height'),
                                h.Attribute('defaultValue', '25px'),
                              ]),
                            ],
                          ),
                          h.div(
                            [h.Class('grid grid-cols-3 items-center gap-4')],
                            [
                              h.label(
                                [h.Class('text-sm font-medium'), h.For('popover-maxHeight')],
                                ['Max. height'],
                              ),
                              h.input([
                                h.Class(
                                  'col-span-2 flex h-8 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm',
                                ),
                                h.Id('popover-maxHeight'),
                                h.Attribute('defaultValue', 'none'),
                              ]),
                            ],
                          ),
                        ],
                      ),
                    ],
                  ),
                ],
                contentClass: 'w-80',
              },
              h,
            ),
            toParentMessage: (message) => Message.GotPopoverMessage({ 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')], ['Sides']),
          h.div(
            [h.Class('flex flex-wrap gap-2 justify-center')],
            [
              h.submodel({
                slotId: model.topPopover.id,
                model: model.topPopover,
                view: popover.view,
                viewInputs: popover.styledViewInputs(
                  {
                    anchor: { placement: 'top', gap: 4 },
                    trigger: 'Top',
                    content: ['Top popover'],
                    triggerClass: 'rounded-lg border px-3 py-1 text-sm',
                    contentClass: 'w-40',
                  },
                  h,
                ),
                toParentMessage: (message) => Message.GotTopPopoverMessage({ message }),
              }),
              h.submodel({
                slotId: model.rightPopover.id,
                model: model.rightPopover,
                view: popover.view,
                viewInputs: popover.styledViewInputs(
                  {
                    anchor: { placement: 'right', gap: 4 },
                    trigger: 'Right',
                    content: ['Right popover'],
                    triggerClass: 'rounded-lg border px-3 py-1 text-sm',
                    contentClass: 'w-40',
                  },
                  h,
                ),
                toParentMessage: (message) => Message.GotRightPopoverMessage({ message }),
              }),
              h.submodel({
                slotId: model.bottomPopover.id,
                model: model.bottomPopover,
                view: popover.view,
                viewInputs: popover.styledViewInputs(
                  {
                    anchor: { placement: 'bottom', gap: 4 },
                    trigger: 'Bottom',
                    content: ['Bottom popover'],
                    triggerClass: 'rounded-lg border px-3 py-1 text-sm',
                    contentClass: 'w-40',
                  },
                  h,
                ),
                toParentMessage: (message) => Message.GotBottomPopoverMessage({ message }),
              }),
              h.submodel({
                slotId: model.leftPopover.id,
                model: model.leftPopover,
                view: popover.view,
                viewInputs: popover.styledViewInputs(
                  {
                    anchor: { placement: 'left', gap: 4 },
                    trigger: 'Left',
                    content: ['Left popover'],
                    triggerClass: 'rounded-lg border px-3 py-1 text-sm',
                    contentClass: 'w-40',
                  },
                  h,
                ),
                toParentMessage: (message) => Message.GotLeftPopoverMessage({ message }),
              }),
            ],
          ),
        ],
      ),
    ],
  )

const foldNoOp =
  <Out>(): ((out: Out) => Update.Step<State, unknown>) =>
  () =>
  (model) => ({ model })

const foldPopoverOutMessage = M.type<popover.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    Opened: foldNoOp(),
    Closed: foldNoOp(),
  }),
)

const foldPopover = Update.foldChild({
  update: popover.update,
  read: (model: State) => Option.some(model.popover),
  write: (model, next) => evo(model, { popover: () => next }),
  toParentMessage: (message) => Message.GotPopoverMessage({ message }),
  foldOutMessage: foldPopoverOutMessage,
})

const foldTopPopover = Update.foldChild({
  update: popover.update,
  read: (model: State) => Option.some(model.topPopover),
  write: (model, next) => evo(model, { topPopover: () => next }),
  toParentMessage: (message) => Message.GotTopPopoverMessage({ message }),
  foldOutMessage: foldPopoverOutMessage,
})

const foldRightPopover = Update.foldChild({
  update: popover.update,
  read: (model: State) => Option.some(model.rightPopover),
  write: (model, next) => evo(model, { rightPopover: () => next }),
  toParentMessage: (message) => Message.GotRightPopoverMessage({ message }),
  foldOutMessage: foldPopoverOutMessage,
})

const foldBottomPopover = Update.foldChild({
  update: popover.update,
  read: (model: State) => Option.some(model.bottomPopover),
  write: (model, next) => evo(model, { bottomPopover: () => next }),
  toParentMessage: (message) => Message.GotBottomPopoverMessage({ message }),
  foldOutMessage: foldPopoverOutMessage,
})

const foldLeftPopover = Update.foldChild({
  update: popover.update,
  read: (model: State) => Option.some(model.leftPopover),
  write: (model, next) => evo(model, { leftPopover: () => next }),
  toParentMessage: (message) => Message.GotLeftPopoverMessage({ message }),
  foldOutMessage: foldPopoverOutMessage,
})

const fields = {
  popover: popover.Model,
  topPopover: popover.Model,
  rightPopover: popover.Model,
  bottomPopover: popover.Model,
  leftPopover: popover.Model,
}

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

export const slice = defineSlice({
  fields,
  init: {
    popover: popover.init({ id: 'popover-demo' }),
    topPopover: popover.init({ id: 'popover-top-demo' }),
    rightPopover: popover.init({ id: 'popover-right-demo' }),
    bottomPopover: popover.init({ id: 'popover-bottom-demo' }),
    leftPopover: popover.init({ id: 'popover-left-demo' }),
  },
  messages: [
    Message.GotPopoverMessage,
    Message.GotTopPopoverMessage,
    Message.GotRightPopoverMessage,
    Message.GotBottomPopoverMessage,
    Message.GotLeftPopoverMessage,
  ],
  handlers: (model: State) => ({
    GotPopoverMessage: (payload: typeof Message.GotPopoverMessage.Type): UpdateReturn =>
      foldPopover(model, payload.message),
    GotTopPopoverMessage: (payload: typeof Message.GotTopPopoverMessage.Type): UpdateReturn =>
      foldTopPopover(model, payload.message),
    GotRightPopoverMessage: (payload: typeof Message.GotRightPopoverMessage.Type): UpdateReturn =>
      foldRightPopover(model, payload.message),
    GotBottomPopoverMessage: (payload: typeof Message.GotBottomPopoverMessage.Type): UpdateReturn =>
      foldBottomPopover(model, payload.message),
    GotLeftPopoverMessage: (payload: typeof Message.GotLeftPopoverMessage.Type): UpdateReturn =>
      foldLeftPopover(model, payload.message),
  }),
  samples: [],
  // Popover open/close flows entirely through the submodel; the public
  // @foldkit/ui namespace exports no child-message constructors, so there
  // are no top-level samples to feed update().
})

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/popover

Source

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

registry/default/ui/popover.ts198 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as Popover from '@/components/ui/popover'`
 */
import { Popover as FoldkitPopover } from '@foldkit/ui'
import type { AnchorConfig } from '@foldkit/ui/popover'
import type { Html, HtmlBuilder } from 'foldkit/html'

type Child = Html | string

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

// Re-export the @foldkit/ui Popover submodel surface.

export const Model = FoldkitPopover.Model
export type Model = typeof Model.Type

export const Message = FoldkitPopover.Message
export type Message = typeof Message.Type

export const OutMessage = FoldkitPopover.OutMessage
export type OutMessage = typeof OutMessage.Type

export const init = (config: InitConfig): Model =>
  FoldkitPopover.init({ isAnimated: true, ...config })
export const update = FoldkitPopover.update
export const open = FoldkitPopover.open
export const close = FoldkitPopover.close
export const buttonId = FoldkitPopover.buttonId
export const view = FoldkitPopover.view

export type InitConfig = FoldkitPopover.InitConfig
export type RenderInfo = FoldkitPopover.RenderInfo

/** Default anchor matching the shadcn reference `PopoverContent` defaults:
 *  `side="bottom"`, `sideOffset=4`, `align="center"`, `alignOffset=0`.
 *  `placement` maps side+align (a bare side centers the popover), `gap` maps
 *  sideOffset, `offset` maps alignOffset and defaults to 0. */
export const POPOVER_ANCHOR: AnchorConfig = {
  placement: 'bottom',
  gap: 4,
  padding: 8,
}

export const popoverTriggerClass =
  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 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'

/** Upstream PopoverContent string; enter/leave animations come baked into
 *  the cn-popover-content token (sync-script transform). */
export const popoverContentClass =
  'bg-popover text-popover-foreground 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 flex flex-col gap-2.5 rounded-lg p-2.5 text-sm 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 max-h-none! overflow-visible! data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-72 origin-(--transform-origin) outline-hidden'

/** Kept for backward compatibility — animations now live in the token, so
 *  this matches `popoverContentClass`. */
export const popoverContentAnimatedClass = popoverContentClass

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

export const popoverWrapperClass = 'relative inline-block'

export const popoverHeaderClass = 'flex flex-col gap-0.5 text-sm'

export const popoverTitleClass = 'font-medium'

export const popoverDescriptionClass = 'text-muted-foreground'

// Use inside `styledViewInputs` content arrays:
//
//   content: [
//     Popover.header({}, [
//       Popover.title({}, ['Title'], h),
//       Popover.description({}, ['Subtitle'], h),
//     ], h),
//     h.p([], ['Custom content']),
//   ]

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

/** Popover header wrapper. */
export const header = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.DataAttribute('slot', 'popover-header'), h.Class(cn(popoverHeaderClass, config.className))],
    children,
  )

/** Popover title. */
export const title = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.DataAttribute('slot', 'popover-title'), h.Class(cn(popoverTitleClass, config.className))],
    children,
  )

/** Popover description. */
export const description = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.p(
    [
      h.DataAttribute('slot', 'popover-description'),
      h.Class(cn(popoverDescriptionClass, config.className)),
    ],
    children,
  )

export type PopoverContent = Readonly<{
  button: ReadonlyArray<Child>
  isVisible: boolean
}>

export type StyledViewInputs = Readonly<{
  anchor?: AnchorConfig
  /** Trigger button label. */
  trigger: Child
  /** Panel content. */
  content: ReadonlyArray<Child>
  isDisabled?: boolean
  focusSelector?: string
  ariaLabel?: string
  ariaLabelledBy?: string
  className?: string
  triggerClass?: string
  contentClass?: string
  backdropClass?: string
  wrapperClass?: string
  /** When true, apply enter/leave transition classes on the panel. */
  isAnimated?: boolean
}>

/** Derives upstream's data-side from a foldkit anchor placement
 *  ("bottom-start" → "bottom"). Logical sides have no foldkit equivalent.
 *  Exported so other Popover-backed components (e.g. navigation-menu) share
 *  this mapping instead of duplicating it. */
export const placementToSide = (placement: string): string => placement.split('-')[0] || 'bottom'

/** Build styled `Popover.ViewInputs`. Pass your view's `h` so the trigger
 *  and content can dispatch your app's own messages. */
export const styledViewInputs = <M>(
  viewInputs: StyledViewInputs,
  h: HtmlBuilder<M>,
): FoldkitPopover.ViewInputs => {
  const anchor = { ...POPOVER_ANCHOR, ...viewInputs.anchor }
  return {
    anchor,
    isDisabled: viewInputs.isDisabled,
    focusSelector: viewInputs.focusSelector,
    ariaLabel: viewInputs.ariaLabel,
    ariaLabelledBy: viewInputs.ariaLabelledBy,
    toView: ({ button, panel, backdrop, isVisible }) =>
      h.div(
        [
          h.Class(cn(popoverWrapperClass, viewInputs.wrapperClass)),
          h.DataAttribute('slot', 'popover'),
        ],
        [
          h.button(
            [
              ...button,
              h.Class(cn(popoverTriggerClass, viewInputs.triggerClass)),
              h.DataAttribute('slot', 'popover-trigger'),
            ],
            [viewInputs.trigger],
          ),
          ...(isVisible
            ? [
                h.div([...backdrop, h.Class(cn(popoverBackdropClass, viewInputs.backdropClass))]),
                h.div(
                  [
                    ...panel,
                    h.DataAttribute('slot', 'popover-content'),
                    h.DataAttribute('side', placementToSide(anchor.placement ?? 'bottom')),
                    h.Class(
                      cn(
                        viewInputs.isAnimated !== false
                          ? popoverContentAnimatedClass
                          : popoverContentClass,
                        viewInputs.contentClass,
                      ),
                    ),
                  ],
                  viewInputs.content,
                ),
              ]
            : []),
        ],
      ),
  }
}