ee8797c142
Co-authored-by: Copilot <copilot@github.com>
6.2 KiB
6.2 KiB
DropdownMenu
A button that reveals a floating list of links or actions when clicked. Closes when the user clicks outside or presses Escape. Positioned below the trigger by default.
HTML structure
div.relative.inline-block ← anchor for absolute positioning
{trigger rendered inline} ← any IHtmxComponent (usually a Button)
div.dropdown-menu.absolute... ← the floating panel; hidden by default
div.w-48.rounded-md.border.bg-popover.shadow-md.p-1
a.dropdown-item ← link item
hr.dropdown-separator ← separator (when isSeparator=true)
CSS mechanics
| Class | Effect |
|---|---|
hidden (initial) on panel |
Hides the dropdown until toggled by JS |
absolute z-50 |
Floats above surrounding content |
top-full mt-1 |
Placed below the trigger with a small gap |
right-0 / left-0 |
Controlled by the position parameter |
dropdown-item |
flex items-center px-2 py-1.5 text-sm rounded hover:bg-accent cursor-pointer |
JavaScript (delegated click in components.js)
Set up once on document — works for HTMX-swapped dropdowns.
Open / close toggle:
- Click on the trigger element (
[data-dropdown-trigger]) → toggle.hiddenon the sibling.dropdown-menu - Click outside the dropdown root → close all open dropdowns
Escapekeydown → close all open dropdowns- Click on a
.dropdown-itemlink → close the parent dropdown and follow the link
Constructor signature
public DropdownMenu(
IHtmxComponent trigger,
IEnumerable<(string Label, string Href, bool IsSeparator)> items,
string position = "right")
| Parameter | Description |
|---|---|
trigger |
Any IHtmxComponent — shown as the visible toggle element |
items |
Menu items; IsSeparator=true renders an <hr> (Label/Href ignored) |
position |
"right" (default) aligns panel right edge; "left" aligns left edge |
Usage examples
User menu
new DropdownMenu(
trigger: new Button("Account", variant: "outline"),
items: new[]
{
("Profile", "/profile", false),
("Settings", "/settings", false),
("", "", true), // separator
("Sign out", "/logout", false),
})
Icon-button dropdown
new DropdownMenu(
trigger: new Button("⋯", size: "icon", variant: "ghost"),
items: new[]
{
("Edit", "/items/42/edit", false),
("Delete", "/items/42/delete", false),
})
Left-aligned dropdown (useful when near the right edge of the viewport)
new DropdownMenu(
trigger: new Button("Actions"),
items: actions,
position: "left")
HTMX action items
Items use <a href> — if you need HTMX requests, override by building the HTML manually:
// Pass a synthetic IHtmxComponent for trigger and use a raw slot override
// for items that need hx-delete / hx-post, since items only support href links.
// Alternatively, use a Dialog for confirmation dialogs linked from the dropdown.
Tips and tricks
- The
triggeris anyIHtmxComponent— pass aButton, anAvatar, or any custom component. - All items are rendered as
<a href>links. For actions that should POST/DELETE, either route them through normal GET links to a form redirect, or pair them with a confirmation Dialog. - For a context menu that appears at a table row, pass
new Button("⋯", size: "icon", variant: "ghost")as the trigger. - Setting
position: "left"prevents the dropdown from overflowing the right side of the viewport when the trigger is near the right edge. - Multiple dropdowns on the same page are handled independently — clicking one will close others.
- Multiple dropdowns on the same page are handled independently — clicking one will close others.
Complete page example
Templates/UserHeaderPage.htmx
<header class="border-b px-6 py-3 flex items-center justify-between">
<a href="/" class="font-bold text-lg">MyApp</a>
<div class="flex items-center gap-4">
<a href="/dashboard" class="text-sm text-muted-foreground hover:text-foreground">Dashboard</a>
$$UserMenu$$
</div>
</header>
<main class="max-w-3xl mx-auto py-10">
$$Body$$
</main>
Templates/UserHeaderPage.htmx.cs
namespace Htmx.ApiDemo.Templates;
public sealed class UserHeaderPage : UserHeaderPageBase
{
private readonly IHtmxComponent _userMenu;
private readonly IHtmxComponent _body;
public UserHeaderPage(AppUser user, IHtmxComponent body)
{
_body = body;
var trigger = new Components.Avatar(
fallback: GetInitials(user.DisplayName),
size: "sm");
_userMenu = new Components.DropdownMenu(
trigger: trigger,
items: new[]
{
("Profile", "/profile", false),
("Settings", "/settings", false),
("", "", true), // separator
("Sign out", "/logout", false),
});
}
private static string GetInitials(string? name)
{
if (string.IsNullOrWhiteSpace(name)) return "?";
var parts = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
return parts.Length >= 2 ? $"{parts[0][0]}{parts[^1][0]}" : name[..1].ToUpperInvariant();
}
protected override void RenderUserMenu(HtmxRenderContext ctx) => _userMenu.Render(ctx.Next());
protected override void RenderBody(HtmxRenderContext ctx) => _body.Render(ctx.Next());
}
GET handler
[Handler]
[MapGet("/home")]
public static partial class GetHomeHandler
{
public record Query();
private static async Task<IResult> HandleAsync(
Query _, HttpContext ctx, MongoDbService db, CancellationToken ct)
{
var email = ctx.User.FindFirst(ClaimTypes.Email)?.Value ?? "";
var user = await db.FindByNormalizedEmailAsync(email.ToUpperInvariant(), ct)
?? new AppUser { Email = email };
// The inner body can be any page component
var innerBody = new WelcomePage(user);
var page = new UserHeaderPage(user, innerBody);
return await ctx.WriteHtmxPage(page, title: "Home");
}
}