Files
Htmx/docs/Components/DropdownMenu.md
T
2026-05-05 23:55:26 +05:00

178 lines
5.3 KiB
Markdown

# 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
```csharp
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
```csharp
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
```csharp
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)
```csharp
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:
```csharp
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`**
```html
<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`**
```csharp
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**
```csharp
[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");
}
}
```