M
Marai.UI
v1.0.0-alpha.7

Popover

A composable floating panel anchored to a trigger element. Closes on outside click or Escape. All visual styling is supplied through Tailwind utility classes, giving you full control over the panel's appearance.

Overview

MPopover is a behavioral primitive composed of three parts: MPopover (state owner), MPopoverTrigger (toggle button), and MPopoverContent (the panel). No default styles are applied — the consumer provides all visual treatment through Class and OverlayClass.

Key Behaviors:

  • Internal open/close/toggle state cascaded via PopoverContext
  • Trigger exposes aria-haspopup="dialog" and aria-expanded
  • Panel renders with role="dialog", aria-modal="false", and tabindex="-1"
  • Placement stored as data-placement attribute for CSS targeting
  • Outside-click dismissal via a consumer-styled overlay
  • Escape key dismissal
  • No built-in positioning, sizing, color, or JS interop

Usage

Add the namespace import:

razor
@using Marai.UI.Components.MPopover

Basic Usage

Wrap MPopoverTrigger and MPopoverContent inside MPopover. Supply relative positioning on MPopover and absolute placement classes on MPopoverContent. Provide OverlayClass to enable outside-click dismissal.

Preview
razor
<MPopover Class="relative inline-block">
    <MPopoverTrigger Class="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90">
        Open Popover
    </MPopoverTrigger>
    <MPopoverContent Class="absolute top-full left-1/2 -translate-x-1/2 mt-2 z-50 w-64 rounded-md border bg-background p-4 text-foreground shadow-md"
                     OverlayClass="fixed inset-0 z-40">
        <p class="text-sm">This is popover content. You can place anything here including forms, lists, or rich text.</p>
    </MPopoverContent>
</MPopover>

Examples

Placement

Pass the Placement enum to MPopoverContent to record behavioral intent as a data-placement attribute. Apply the matching Tailwind positioning classes on Class to achieve the visual placement.

Preview
razor
<div class="flex flex-wrap gap-8 items-center justify-center py-8">
    <MPopover Class="relative inline-block">
        <MPopoverTrigger Class="inline-flex items-center rounded-md border px-3 py-1.5 text-sm hover:bg-accent">Top</MPopoverTrigger>
        <MPopoverContent Placement="PopoverPlacement.Top"
                         Class="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-50 w-48 rounded-md border bg-background p-3 text-foreground shadow-md"
                         OverlayClass="fixed inset-0 z-40">
            <p class="text-sm">Appears above the trigger</p>
        </MPopoverContent>
    </MPopover>
    <MPopover Class="relative inline-block">
        <MPopoverTrigger Class="inline-flex items-center rounded-md border px-3 py-1.5 text-sm hover:bg-accent">Bottom</MPopoverTrigger>
        <MPopoverContent Placement="PopoverPlacement.Bottom"
                         Class="absolute top-full left-1/2 -translate-x-1/2 mt-2 z-50 w-48 rounded-md border bg-background p-3 text-foreground shadow-md"
                         OverlayClass="fixed inset-0 z-40">
            <p class="text-sm">Appears below the trigger (default)</p>
        </MPopoverContent>
    </MPopover>
    <MPopover Class="relative inline-block">
        <MPopoverTrigger Class="inline-flex items-center rounded-md border px-3 py-1.5 text-sm hover:bg-accent">Left</MPopoverTrigger>
        <MPopoverContent Placement="PopoverPlacement.Left"
                         Class="absolute right-full top-1/2 -translate-y-1/2 mr-2 z-50 w-48 rounded-md border bg-background p-3 text-foreground shadow-md"
                         OverlayClass="fixed inset-0 z-40">
            <p class="text-sm">Appears to the left</p>
        </MPopoverContent>
    </MPopover>
    <MPopover Class="relative inline-block">
        <MPopoverTrigger Class="inline-flex items-center rounded-md border px-3 py-1.5 text-sm hover:bg-accent">Right</MPopoverTrigger>
        <MPopoverContent Placement="PopoverPlacement.Right"
                         Class="absolute left-full top-1/2 -translate-y-1/2 ml-2 z-50 w-48 rounded-md border bg-background p-3 text-foreground shadow-md"
                         OverlayClass="fixed inset-0 z-40">
            <p class="text-sm">Appears to the right</p>
        </MPopoverContent>
    </MPopover>
</div>

With Form Content

Popovers can contain forms, inputs, and interactive elements.

Preview
razor
<MPopover Class="relative inline-block">
    <MPopoverTrigger Class="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90">
        Edit Profile
    </MPopoverTrigger>
    <MPopoverContent Class="absolute top-full left-1/2 -translate-x-1/2 mt-2 z-50 w-72 rounded-md border bg-background p-4 text-foreground shadow-md"
                     OverlayClass="fixed inset-0 z-40">
        <div class="flex flex-col gap-3">
            <div>
                <MLabel For="popover-name">Display Name</MLabel>
                <MInput id="popover-name" Class="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" Placeholder="Enter your name" />
            </div>
            <div>
                <MLabel For="popover-email">Email</MLabel>
                <MInput id="popover-email" Type="email" Class="mt-1 w-full rounded-md border px-3 py-1.5 text-sm" Placeholder="email@example.com" />
            </div>
            <MButton Class="inline-flex items-center justify-center rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90">Save Changes</MButton>
        </div>
    </MPopoverContent>
</MPopover>

Icon Trigger

Supply icon markup directly inside MPopoverTrigger and size the button through Class.

Preview
razor
<MPopover Class="relative inline-block">
    <MPopoverTrigger Class="inline-flex h-9 w-9 items-center justify-center rounded-full border hover:bg-accent" aria-label="More options">
        <svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
             stroke-linecap="round" viewBox="0 0 24 24" aria-hidden="true">
            <circle cx="12" cy="12" r="1"/>
            <circle cx="12" cy="5" r="1"/>
            <circle cx="12" cy="19" r="1"/>
        </svg>
    </MPopoverTrigger>
    <MPopoverContent Class="absolute top-full left-1/2 -translate-x-1/2 mt-2 z-50 w-48 rounded-md border bg-background p-3 text-foreground shadow-md"
                     OverlayClass="fixed inset-0 z-40">
        <p class="text-sm">More options menu</p>
    </MPopoverContent>
</MPopover>

Fully Custom Visual Treatment

Any visual design is achievable — gradient panels, colored borders, custom shadows — all through Class and OverlayClass.

Preview
razor
<MPopover Class="relative inline-block">
    <MPopoverTrigger Class="inline-flex items-center gap-2 rounded-lg border-2 border-violet-500 px-4 py-2 text-sm font-semibold text-violet-600 hover:bg-violet-50 dark:hover:bg-violet-950">
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
            <path d="M12 2L2 7l10 5 10-5-10-5z"/>
            <path d="M2 17l10 5 10-5"/>
            <path d="M2 12l10 5 10-5"/>
        </svg>
        Custom Popover
    </MPopoverTrigger>
    <MPopoverContent Class="absolute top-full left-0 mt-2 z-50 w-72 rounded-xl border-2 border-violet-200 bg-gradient-to-br from-violet-50 to-purple-50 p-5 shadow-xl dark:from-violet-950 dark:to-purple-950"
                     OverlayClass="fixed inset-0 z-40">
        <p class="text-sm font-semibold text-violet-700 dark:text-violet-300">Fully Custom Treatment</p>
        <p class="mt-1 text-sm text-violet-600 dark:text-violet-400">All styling is supplied via Tailwind classes on <code class="rounded bg-violet-100 px-1 text-xs dark:bg-violet-900">Class</code> and <code class="rounded bg-violet-100 px-1 text-xs dark:bg-violet-900">OverlayClass</code>. The component provides only behavioral primitives.</p>
    </MPopoverContent>
</MPopover>

API Reference

MPopover

Parameter Type Default Description
ChildContent RenderFragment? null Slot content — typically MPopoverTrigger and MPopoverContent.
Class string? null CSS classes applied to the root container. Use relative to establish a positioning context.
AdditionalAttributes Dictionary<string, object>? null Arbitrary HTML attributes passed through to the root element.

MPopoverTrigger

Parameter Type Default Description
ChildContent RenderFragment? null Button content (text, icons, etc.).
Class string? null CSS classes applied to the trigger button.
AdditionalAttributes Dictionary<string, object>? null Arbitrary HTML attributes (e.g., aria-label) passed through to the button element.

MPopoverContent

Parameter Type Default Description
ChildContent RenderFragment? null Panel content (text, forms, lists, etc.).
Placement PopoverPlacement Bottom Placement intent recorded as data-placement. Does not inject CSS — pair with matching Tailwind classes on Class.
Class string? null CSS classes for the panel element. Supply positioning, sizing, color, and shadow here.
OverlayClass string? null CSS classes for the backdrop overlay. Use fixed inset-0 z-40 to enable outside-click dismissal across the viewport.
AdditionalAttributes Dictionary<string, object>? null Arbitrary HTML attributes passed through to the panel element.

PopoverPlacement Enum

Value data-placement Tailwind classes to pair
Top top bottom-full left-1/2 -translate-x-1/2 mb-2
Bottom bottom top-full left-1/2 -translate-x-1/2 mt-2
Left left right-full top-1/2 -translate-y-1/2 mr-2
Right right left-full top-1/2 -translate-y-1/2 ml-2
Dismissal Behavior

The popover closes when:

  • Outside click: Clicking the overlay div (rendered via OverlayClass) triggers close. If OverlayClass is omitted, outside-click dismissal is disabled.
  • Escape key: Pressing Escape dismisses the popover from either the overlay or the content panel.

Accessibility

  • MPopoverTrigger renders with aria-haspopup="dialog", aria-expanded, and aria-controls pointing to the panel ID — all wired automatically
  • MPopoverContent renders with role="dialog" and a stable generated id (not aria-modal, since popover is non-modal)
  • Escape closes the popover and returns focus to the trigger
  • Tab movement is unrestricted — focus can leave the popover naturally (non-modal behavior)
  • Always provide aria-label on MPopoverTrigger for icon-only triggers
  • For blocking modal interactions, use MDialog instead
Keyboard Navigation
KeyAction
Enter / SpaceToggle popover (native button behavior)
EscapeClose popover and return focus to trigger
TabMove focus naturally — popover does not trap focus