ee8797c142
Co-authored-by: Copilot <copilot@github.com>
199 lines
6.2 KiB
Markdown
199 lines
6.2 KiB
Markdown
# 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:**
|
|
1. Click on the trigger element (`[data-dropdown-trigger]`) → toggle `.hidden` on the sibling `.dropdown-menu`
|
|
2. Click outside the dropdown root → close all open dropdowns
|
|
3. `Escape` keydown → close all open dropdowns
|
|
4. Click on a `.dropdown-item` link → close the parent dropdown and follow the link
|
|
|
|
---
|
|
|
|
## Constructor signature
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
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
|
|
|
|
```csharp
|
|
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)
|
|
|
|
```csharp
|
|
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:
|
|
|
|
```csharp
|
|
// 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 `trigger` is any `IHtmxComponent` — pass a `Button`, an `Avatar`, 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`**
|
|
```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");
|
|
}
|
|
}
|
|
```
|