@@ -0,0 +1,200 @@
|
||||
# Tabs
|
||||
|
||||
A tabbed interface. One tab panel is visible at a time. The active tab has a highlighted style; all others are hidden. Client-side JS switches panels without a server round-trip.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div[id].tabs-root
|
||||
div.tabs-list.flex.gap-1.border-b.mb-4 ← tab button strip
|
||||
button.tabs-trigger[data-tab={tabId}] ← one per tab; ACTIVE/INACTIVE variant
|
||||
{label}
|
||||
div.tabs-panel[data-tab={tabId}] ← one per tab; hidden or visible
|
||||
{content}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `tabs-trigger` | `px-4 py-2 text-sm font-medium rounded-t-md -mb-px` |
|
||||
| Active trigger | `bg-background border border-b-0 border-border text-foreground` |
|
||||
| Inactive trigger | `text-muted-foreground hover:text-foreground hover:bg-muted/40` |
|
||||
| `tabs-panel[hidden]` | `display: none` via the HTML `hidden` attribute |
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (`initTabs` in `components.js`)
|
||||
|
||||
Runs on `DOMContentLoaded` and `htmx:afterSwap`.
|
||||
|
||||
**Per-instance initialization:**
|
||||
|
||||
1. Guard `_tabsInit` prevents double-binding
|
||||
2. Reads all `.tabs-trigger` and `.tabs-panel` elements within the root
|
||||
3. Activates the first tab on init (removes `hidden`, applies active class)
|
||||
4. On trigger click:
|
||||
- Deactivate all panels (set `hidden`, downgrade trigger class to inactive)
|
||||
- Activate the clicked panel by matching `data-tab` attribute
|
||||
- Apply active class to the clicked trigger
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Tabs(
|
||||
string id,
|
||||
IEnumerable<(string Id, string Label, string Content)> tabs)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Root element id — must be unique per page if multiple Tabs are rendered |
|
||||
| `tabs` | List of `(Id, Label, Content)` tuples; `Id` must be unique within this instance |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Simple tabbed content
|
||||
|
||||
```csharp
|
||||
new Tabs(
|
||||
id: "settings-tabs",
|
||||
tabs: new[]
|
||||
{
|
||||
("general", "General", "<p>General settings content here.</p>"),
|
||||
("security", "Security", "<p>Security settings content here.</p>"),
|
||||
("billing", "Billing", "<p>Billing details here.</p>"),
|
||||
})
|
||||
```
|
||||
|
||||
### HTML-rich content in a tab
|
||||
|
||||
```csharp
|
||||
new Tabs(
|
||||
id: "code-tabs",
|
||||
tabs: new[]
|
||||
{
|
||||
("csharp", "C#", "<pre><code>var x = 42;</code></pre>"),
|
||||
("fsharp", "F#", "<pre><code>let x = 42</code></pre>"),
|
||||
("vb", "VB.NET", "<pre><code>Dim x As Integer = 42</code></pre>"),
|
||||
})
|
||||
```
|
||||
|
||||
### Embedding a full component in a tab
|
||||
|
||||
```csharp
|
||||
// Pre-render the inner component to HTML string
|
||||
var buf = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
new Table(headers: cols, rows: data).Render(new HtmxRenderContext(buf));
|
||||
var tableHtml = System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
|
||||
|
||||
new Tabs(
|
||||
id: "report",
|
||||
tabs: new[]
|
||||
{
|
||||
("summary", "Summary", "<p>High level numbers.</p>"),
|
||||
("detail", "Detail", tableHtml),
|
||||
})
|
||||
```
|
||||
|
||||
### Multiple independent tab groups
|
||||
|
||||
```csharp
|
||||
new Tabs(id: "tabs-a", tabs: setA)
|
||||
new Tabs(id: "tabs-b", tabs: setB)
|
||||
```
|
||||
|
||||
The `id` scopes JS initialization — each Tabs instance is independent.
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- The `Id` of each tab tuple is used as the `data-tab` attribute — keep it URL-safe and unique within the instance.
|
||||
- The first tab is always activated on page load regardless of which tab was active before navigation.
|
||||
- Tab `Content` is raw HTML — HTML-encode any user-supplied values.
|
||||
- For lazy-loaded tab content, place HTMX attributes in the `Content` string and use `hx-trigger="revealed"` to load content when the panel becomes visible.
|
||||
- Tabs do not push to the URL hash by default. If you need deep-linkable tabs, listen to the `click` event on `.tabs-trigger` elements and update `location.hash`.
|
||||
- Tabs do not push to the URL hash by default. If you need deep-linkable tabs, listen to the `click` event on `.tabs-trigger` elements and update `location.hash`.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/ProfileSettingsPage.htmx`**
|
||||
```html
|
||||
<div class="max-w-2xl mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-6">Profile settings</h1>
|
||||
$$SettingsTabs$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/ProfileSettingsPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
public sealed class ProfileSettingsPage : ProfileSettingsPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _tabs;
|
||||
|
||||
public ProfileSettingsPage(ApplicationUser user, IAntiforgery af, HttpContext ctx)
|
||||
{
|
||||
var tokens = af.GetAndStoreTokens(ctx);
|
||||
var afHtml = $"""<input type="hidden" name="{tokens.FormFieldName}" value="{tokens.RequestToken}">""";
|
||||
|
||||
// Build each tab's content as raw HTML strings rendered into the Tabs component
|
||||
var generalContent = $"""
|
||||
<form method="post" action="/profile/general">
|
||||
{afHtml}
|
||||
<label class="block mb-1 text-sm">Display name</label>
|
||||
<input name="displayName" value="{System.Net.WebUtility.HtmlEncode(user.DisplayName ?? "")}"
|
||||
class="input mb-4 w-full">
|
||||
<button class="btn" type="submit">Save</button>
|
||||
</form>
|
||||
""";
|
||||
|
||||
var securityContent = $"""
|
||||
<form method="post" action="/profile/password">
|
||||
{afHtml}
|
||||
<label class="block mb-1 text-sm">New password</label>
|
||||
<input name="newPassword" type="password" class="input mb-4 w-full">
|
||||
<button class="btn" type="submit">Change password</button>
|
||||
</form>
|
||||
""";
|
||||
|
||||
_tabs = new Components.Tabs(
|
||||
defaultValue: "general",
|
||||
tabs: new[]
|
||||
{
|
||||
new TabItem(Value: "general", Label: "General", Content: generalContent),
|
||||
new TabItem(Value: "security", Label: "Security", Content: securityContent),
|
||||
});
|
||||
}
|
||||
|
||||
protected override void RenderSettingsTabs(HtmxRenderContext ctx) => _tabs.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**GET handler**
|
||||
```csharp
|
||||
[Handler]
|
||||
[MapGet("/profile")]
|
||||
public static partial class GetProfileSettingsHandler
|
||||
{
|
||||
public record Query();
|
||||
private static async Task<IResult> HandleAsync(
|
||||
Query _, HttpContext ctx, MongoDbService db, IAntiforgery af, CancellationToken ct)
|
||||
{
|
||||
var user = await db.GetCurrentUserAsync(ctx, ct);
|
||||
return await ctx.WriteHtmxPage(
|
||||
new ProfileSettingsPage(user, af, ctx), title: "Profile settings");
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user