b530bb8c97
Co-authored-by: Copilot <copilot@github.com>
178 lines
5.3 KiB
Markdown
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");
|
|
}
|
|
}
|
|
```
|