Co-authored-by: Copilot <copilot@github.com>
5.3 KiB
DropdownMenu
A button that, when clicked, opens a small floating menu of links. Like a folder label on a filing cabinet — pull it and a list of options drops down. Closes automatically when you click elsewhere or press Escape.
Quick example
new DropdownMenu(
trigger: new Button("Account", variant: "outline"),
items: new[]
{
("Profile", "/profile", false),
("Settings", "/settings", false),
("", "", true), // separator line
("Sign out", "/logout", false),
})
All the options
public DropdownMenu(
IHtmxComponent trigger,
IEnumerable<(string Label, string Href, bool IsSeparator)> items,
string position = "right")
| Parameter | What it does |
|---|---|
trigger |
The visible button that opens the menu. Any IHtmxComponent — usually a Button. |
items |
The list of menu items. Each is a (Label, Href, IsSeparator) tuple. |
position |
"right" aligns the menu to the right edge of the trigger (default). "left" aligns it to the left. |
Item tuple fields:
| Field | What it does |
|---|---|
Label |
The text shown in the menu. |
Href |
The URL to navigate to when clicked. |
IsSeparator |
Set to true to render a divider line instead of a link. Label and Href are ignored. |
Real-world examples
User account menu in the header
new DropdownMenu(
trigger: new Button("My account", variant: "ghost"),
items: new[]
{
("Profile", "/profile", false),
("Billing", "/billing", false),
("", "", true),
("Sign out", "/logout", false),
})
Row action menu in a table (three-dot icon button)
new DropdownMenu(
trigger: new Button("⋯", size: "icon", variant: "ghost"),
items: new[]
{
("Edit", $"/items/{item.Id}/edit", false),
("Delete", $"/items/{item.Id}/delete", false),
},
position: "left") // avoid overflow near the right edge of the screen
Left-aligned menu (near right side of viewport)
Use position: "left" when the trigger is close to the right edge of the screen to prevent the menu from clipping off-screen:
new DropdownMenu(
trigger: new Button("Actions"),
items: actions,
position: "left")
How it works
The menu panel is always present in the HTML but hidden with a hidden class. When the trigger is clicked, JavaScript toggles the hidden class to show it. Clicking anything outside — or pressing Escape — adds hidden back.
Because the click listener is attached to document, dropdown menus that are HTMX-swapped in work automatically.
All items are rendered as <a href="..."> links. If you need an action that POSTs data (like a delete), the cleanest approach is to route it through a confirmation Dialog.
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");
}
}