# 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 `
` (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 `` — 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 `` 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
MyApp
Dashboard $$UserMenu$$
$$Body$$
``` **`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 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"); } } ```