Rewrote all the docs - more noob friendly now.
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
+90
-108
@@ -1,72 +1,10 @@
|
||||
# Accordion
|
||||
|
||||
An expand/collapse panel list. Items are collapsed by default; one item can be pre-expanded at server render time. Client-side toggle is handled by `components.js`.
|
||||
An expand/collapse panel list — like a FAQ section or a step-by-step guide where the user reveals each answer one at a time.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.accordion-root[id] ← outer wrapper
|
||||
div.accordion-item ← one per item, border-b separator
|
||||
h3
|
||||
button.accordion-trigger ← clickable header; aria-expanded tracks state
|
||||
{title text}
|
||||
svg.accordion-chevron ← rotates 180° when open
|
||||
div.accordion-panel ← collapsible area; height/opacity driven by JS
|
||||
div.pb-4
|
||||
{content}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class / property | Effect |
|
||||
|---|---|
|
||||
| `overflow-hidden` on panel | Prevents content leaking outside the panel during animation |
|
||||
| `transition-all duration-200` on panel | Smooth height and opacity animation |
|
||||
| `height: 0; opacity: 0` (collapsed) | Starting state set server-side for closed items |
|
||||
| `height: auto; opacity: 1` (open) | Starting state for the pre-expanded item |
|
||||
| `accordion-chevron` + JS `rotate(180deg)` | Chevron rotates down when expanded |
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (`initAccordion` in `components.js`)
|
||||
|
||||
Runs on `DOMContentLoaded` and on `htmx:afterSwap` so HTMX-swapped accordions are correctly initialized.
|
||||
|
||||
**Per-instance initialization:**
|
||||
|
||||
1. Guard `root._accInitialised` prevents double-binding after re-renders
|
||||
2. For each `.accordion-trigger`, attach a `click` listener:
|
||||
- Read current state from `aria-expanded`
|
||||
- If currently open → set `panel.style.height = "0"`, `opacity = "0"`, `aria-expanded = "false"`
|
||||
- If currently closed → set `panel.style.height = scrollHeight + "px"`, `opacity = "1"`, `aria-expanded = "true"`
|
||||
3. Rotate `.accordion-chevron` via `transform: rotate(180deg)` when open
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Accordion(
|
||||
string id,
|
||||
IEnumerable<(string Title, string Content)> items,
|
||||
int openIndex = -1)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Unique element id for the root `div` |
|
||||
| `items` | List of `(Title, Content)` tuples |
|
||||
| `openIndex` | Zero-based index of the pre-expanded item; `-1` = all closed |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### All closed
|
||||
## Quick example
|
||||
|
||||
```csharp
|
||||
new Accordion(
|
||||
@@ -75,79 +13,123 @@ new Accordion(
|
||||
{
|
||||
("What is this?", "A fast HTMX app framework."),
|
||||
("Is it AOT-safe?", "Yes, fully."),
|
||||
("Do I need Node?", "Only to run the Tailwind build step."),
|
||||
("Do I need Node?", "Only for the Tailwind build step."),
|
||||
})
|
||||
```
|
||||
|
||||
### One pre-expanded
|
||||
That's it. Drop this into a page slot and you have a working FAQ section.
|
||||
|
||||
---
|
||||
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Accordion(
|
||||
string id,
|
||||
IEnumerable<(string Title, string Content)> items,
|
||||
int openIndex = -1)
|
||||
```
|
||||
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `id` | A unique identifier for this accordion on the page. If you have two accordions on the same page, they need different ids. |
|
||||
| `items` | The list of panels. Each item is a pair: the header text (`Title`) and the body content (`Content`). |
|
||||
| `openIndex` | Which panel should start open. `0` = first panel, `1` = second, `-1` = all closed (default). |
|
||||
|
||||
---
|
||||
|
||||
## Real-world examples
|
||||
|
||||
### FAQ page with the first item pre-opened
|
||||
|
||||
```csharp
|
||||
new Accordion(
|
||||
id: "setup-steps",
|
||||
id: "faq",
|
||||
items: new[]
|
||||
{
|
||||
("Step 1 — Install", "Run <code>npm install</code> in the project folder."),
|
||||
("Step 2 — Configure", "Edit <code>appsettings.json</code> with your connection string."),
|
||||
("Step 3 — Run", "Use <code>dotnet run</code> to start the server."),
|
||||
("How do I reset my password?", "Go to Settings → Security → Reset Password."),
|
||||
("How do I cancel my account?", "Contact support from the Help page."),
|
||||
("Where are my invoices?", "Under Billing in your account dashboard."),
|
||||
},
|
||||
openIndex: 0) // first answer visible on load
|
||||
```
|
||||
|
||||
### Step-by-step guide with HTML content inside items
|
||||
|
||||
Item `Content` is rendered as raw HTML, so you can use markup inside it:
|
||||
|
||||
```csharp
|
||||
new Accordion(
|
||||
id: "setup-guide",
|
||||
items: new[]
|
||||
{
|
||||
("Step 1 — Install dependencies",
|
||||
"Run <code>npm install</code> inside the <code>Htmx.ApiDemo</code> folder."),
|
||||
("Step 2 — Start MongoDB",
|
||||
"<p>Start the MongoDB service, then confirm it is running on <code>localhost:27017</code>.</p>"),
|
||||
("Step 3 — Run the app",
|
||||
"Run <code>dotnet run --project Htmx.ApiDemo</code> and open <code>http://localhost:5120</code>."),
|
||||
},
|
||||
openIndex: 0)
|
||||
```
|
||||
|
||||
### HTML content in items
|
||||
> **Important:** `Title` and `Content` are inserted as raw HTML. If either value comes from user input or a database, HTML-encode it first:
|
||||
> ```csharp
|
||||
> System.Web.HttpUtility.HtmlEncode(userTitle)
|
||||
> ```
|
||||
|
||||
```csharp
|
||||
new Accordion(
|
||||
id: "code-examples",
|
||||
items: new[]
|
||||
{
|
||||
("C# snippet", "<pre><code>var x = 1 + 1;</code></pre>"),
|
||||
("Tip", "<p>Use <strong>AOT-safe</strong> serialization patterns.</p>"),
|
||||
})
|
||||
```
|
||||
### Inside a page
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- `openIndex` only controls the initial server-rendered state. After the page loads, the user can open/close any item freely.
|
||||
- Item `Title` and `Content` strings are inserted as raw HTML — HTML-encode any user-supplied values before passing them in.
|
||||
- Multiple items can be opened simultaneously by the user — there is no "only one open at a time" constraint in the JS.
|
||||
- If you need to identify which accordion is which after a click, listen to the parent element and inspect `event.target.closest('.accordion-item')`.
|
||||
- The `id` must be unique on the page if you place more than one accordion.
|
||||
|
||||
---
|
||||
|
||||
## Complete page example
|
||||
|
||||
**`Templates/FaqPage.htmx`**
|
||||
```html
|
||||
<!-- Templates/FaqPage.htmx -->
|
||||
<div class="max-w-2xl mx-auto py-10">
|
||||
<h1 class="text-2xl font-bold mb-2">Frequently Asked Questions</h1>
|
||||
<p class="text-muted-foreground mb-8">Everything you need to know about BeepBoop.</p>
|
||||
<h1 class="text-2xl font-bold mb-6">Frequently Asked Questions</h1>
|
||||
$$FaqAccordion$$
|
||||
</div>
|
||||
```
|
||||
|
||||
**`Templates/FaqPage.htmx.cs`**
|
||||
```csharp
|
||||
namespace Htmx.ApiDemo.Templates;
|
||||
|
||||
// Templates/FaqPage.htmx.cs
|
||||
public sealed class FaqPage : FaqPageBase
|
||||
{
|
||||
private readonly IHtmxComponent _faq;
|
||||
private readonly IHtmxComponent _faqAccordion;
|
||||
|
||||
public FaqPage()
|
||||
{
|
||||
_faq = new Components.Accordion(
|
||||
_faqAccordion = new Components.Accordion(
|
||||
id: "faq",
|
||||
items: new[]
|
||||
{
|
||||
("What is BeepBoop?",
|
||||
"A fast, AOT-safe HTMX web framework built on .NET 10."),
|
||||
("Do I need Node.js?",
|
||||
"Only to run the Tailwind CSS build step during development."),
|
||||
("Is MongoDB required?",
|
||||
"No — swap in any data store you prefer."),
|
||||
("What is BeepBoop?", "A fast, AOT-safe HTMX web framework built on .NET 10."),
|
||||
("Do I need Node.js?", "Only to run the Tailwind CSS build step."),
|
||||
("Is MongoDB required?", "No — swap in any data store you prefer."),
|
||||
});
|
||||
}
|
||||
|
||||
protected override void RenderFaqAccordion(HtmxRenderContext ctx)
|
||||
=> _faqAccordion.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
The server renders all panels into the HTML. Closed panels are given `height: 0; opacity: 0` inline styles so they are invisible immediately — no layout flash. The JavaScript in `components.js` (`initAccordion`) then attaches click listeners.
|
||||
|
||||
When a panel is opened, JS reads the panel's `scrollHeight` (its natural height) and animates the inline `height` from `0` to that value alongside the opacity, giving a smooth slide-down. The chevron icon rotates 180° to point down when open.
|
||||
|
||||
If the page content is updated by HTMX, `htmx:afterSwap` re-runs the initialisation so newly swapped-in accordions also get click behaviour.
|
||||
|
||||
Users can open multiple panels simultaneously — there is no "only one open at a time" constraint.
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
- The `id` must be unique if you place more than one accordion on a page.
|
||||
- `openIndex` only controls the initial server-rendered state — the user can freely open or close any panel after that.
|
||||
- To listen for accordion interactions from another script, add a `click` listener to the parent container and check `event.target.closest('.accordion-trigger')`.
|
||||
("How do I deploy?",
|
||||
"Run <code>dotnet publish -c Release</code> for a native AOT binary."),
|
||||
});
|
||||
|
||||
+41
-59
@@ -1,35 +1,20 @@
|
||||
# Alert
|
||||
|
||||
A contextual callout box for informational or error messages. Two variants: `default` (neutral) and `destructive` (red). An optional inline SVG icon is positioned automatically.
|
||||
A coloured callout box that draws the user's attention — like a sticky note placed on top of the page. Use it to show errors, warnings, or helpful information.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
div[role=alert].{variant classes}
|
||||
{icon SVG} ← positioned absolute top-left via Tailwind arbitrary selectors
|
||||
div
|
||||
h5.font-medium ← title (always rendered)
|
||||
div.text-sm ← description (omitted when empty)
|
||||
```csharp
|
||||
new Alert(
|
||||
title: "Heads up",
|
||||
description: "Your session expires in 5 minutes.")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class / selector | Effect |
|
||||
|---|---|
|
||||
| `[&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4` | Positions any direct SVG child at top-left |
|
||||
| `[&>svg~*]:pl-7` | Adds left padding to all siblings after the SVG so text is not covered by the icon |
|
||||
| `[&>svg+div]:translate-y-[-3px]` | Vertically aligns the text div with the icon center |
|
||||
| `border-destructive/50 text-destructive` | Red destructive variant |
|
||||
|
||||
The arbitrary selector approach (`[&>svg]:*`) means you can pass any SVG and it will be positioned correctly without extra wrapper divs.
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Alert(
|
||||
@@ -39,66 +24,63 @@ public Alert(
|
||||
string icon = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `title` | Required heading text |
|
||||
| `description` | Optional body text below the title |
|
||||
| `variant` | `"default"` or `"destructive"` |
|
||||
| `icon` | Raw SVG string; omit for a text-only alert |
|
||||
| `title` | The bold heading line — always shown |
|
||||
| `description` | A second line of detail below the title — optional |
|
||||
| `variant` | `"default"` (neutral, grey border) or `"destructive"` (red) |
|
||||
| `icon` | A raw SVG string placed to the left of the text — omit for no icon |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Informational (no icon)
|
||||
### Login error
|
||||
|
||||
```csharp
|
||||
new Alert(
|
||||
title: "Heads up",
|
||||
description: "Your session expires in 5 minutes.")
|
||||
```
|
||||
|
||||
### Destructive
|
||||
|
||||
```csharp
|
||||
new Alert(
|
||||
title: "Error",
|
||||
description: "Invalid email or password.",
|
||||
title: "Sign in failed",
|
||||
description: "The email or password you entered is incorrect.",
|
||||
variant: "destructive")
|
||||
```
|
||||
|
||||
### With an icon
|
||||
### Success confirmation
|
||||
|
||||
```csharp
|
||||
new Alert(title: "Changes saved successfully.")
|
||||
```
|
||||
|
||||
### Info notice with a link in the description
|
||||
|
||||
`description` is raw HTML, so you can embed links:
|
||||
|
||||
```csharp
|
||||
new Alert(
|
||||
title: "New message",
|
||||
description: "You have 3 unread messages.",
|
||||
variant: "default",
|
||||
icon: """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07
|
||||
A19.5 19.5 0 013.07 9.77a19.79 19.79 0 01-3.07-8.67
|
||||
A2 2 0 012 .18L5 0a2 2 0 012 1.72 ..."/>
|
||||
</svg>
|
||||
""")
|
||||
title: "Maintenance scheduled",
|
||||
description: "The system will be offline on Saturday. <a href='/status' class='underline'>View status page</a>.")
|
||||
```
|
||||
|
||||
### Title-only
|
||||
### Conditional error slot on a form page
|
||||
|
||||
A common pattern is to render the alert only when there is something to show. In the page constructor:
|
||||
|
||||
```csharp
|
||||
new Alert(title: "Changes saved successfully.", variant: "default")
|
||||
// Store empty bytes when there's no error — WriteUtf8 on empty bytes is a no-op
|
||||
_errorAlertData = string.IsNullOrEmpty(errorMessage)
|
||||
? []
|
||||
: new Alert(title: "Error", description: errorMessage, variant: "destructive")
|
||||
.ToRenderedBytes();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
## How it works
|
||||
|
||||
- The icon SVG should be `h-4 w-4` — larger sizes will push text out of alignment.
|
||||
- For the `destructive` variant the icon automatically inherits `text-destructive` color via the variant class.
|
||||
- The `description` slot is a raw HTML string — you can include `<a>` links or `<code>` spans.
|
||||
- Use `Alert` inside a page's optional error slot rather than always rendering it — pass an empty byte array (`[]`) when there is no error so the slot renders nothing.
|
||||
The alert is a `<div role="alert">` — this tells screen readers to announce its content immediately when it appears on the page.
|
||||
|
||||
If you pass an `icon`, it is placed as a direct child SVG. Tailwind's arbitrary selector `[&>svg]:absolute` positions it at the top-left corner automatically, and `[&>svg~*]:pl-7` shifts all the text to the right so nothing overlaps. You do not need any wrapper divs around your SVG.
|
||||
|
||||
The icon should be `class="h-4 w-4"` — larger icons will misalign the text.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+39
-48
@@ -1,32 +1,22 @@
|
||||
# Avatar
|
||||
|
||||
A circular user avatar. Shows an image when a `src` URL is provided; falls back to a text/initials span otherwise.
|
||||
A circular user icon. Shows a profile photo when a URL is provided, or falls back to text (typically initials) on a neutral background.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
span.relative.flex.{size classes}.shrink-0.overflow-hidden.rounded-full
|
||||
img[src, alt, class] ← when src is provided
|
||||
span.flex.items-center... ← fallback when no src
|
||||
{fallback text}
|
||||
```csharp
|
||||
// Initials only
|
||||
new Avatar(fallback: "JD")
|
||||
|
||||
// With a profile photo
|
||||
new Avatar(fallback: "Jane Doe", src: "/avatars/jane.jpg")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `rounded-full overflow-hidden` | Clips content to a circle |
|
||||
| `aspect-square h-full w-full object-cover` | Image fills the circle without distortion |
|
||||
| `bg-muted text-muted-foreground` | Neutral background for the initials fallback |
|
||||
| Size `h-8 w-8` / `h-10 w-10` / `h-14 w-14` / `h-20 w-20` | sm / default / lg / xl |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Avatar(
|
||||
@@ -35,56 +25,57 @@ public Avatar(
|
||||
string size = "default")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `fallback` | Text shown when no `src` is given; also used as `alt` text on the image |
|
||||
| `src` | Optional image URL |
|
||||
| `size` | `"sm"` / `"default"` / `"lg"` / `"xl"` |
|
||||
| `fallback` | The text shown when no image is available — typically initials like `"JD"`. Also used as the `alt` attribute on the image for screen readers. |
|
||||
| `src` | URL of the profile photo. If omitted, the fallback is shown. |
|
||||
| `size` | How big the circle is: `"sm"` (32px), `"default"` (40px), `"lg"` (56px), `"xl"` (80px) |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Initials avatar
|
||||
### Initials in different sizes
|
||||
|
||||
```csharp
|
||||
new Avatar(fallback: "JD")
|
||||
new Avatar(fallback: "JD", size: "lg")
|
||||
new Avatar(fallback: "SM", size: "sm") // 32×32 — good for compact lists
|
||||
new Avatar(fallback: "JD", size: "default") // 40×40 — standard nav bar
|
||||
new Avatar(fallback: "LG", size: "lg") // 56×56 — profile card header
|
||||
new Avatar(fallback: "XL", size: "xl") // 80×80 — full profile page
|
||||
```
|
||||
|
||||
### Image avatar with fallback
|
||||
### Profile page header
|
||||
|
||||
```csharp
|
||||
new Avatar(fallback: "Jane Doe", src: "/avatars/jane.jpg", size: "default")
|
||||
new Avatar(
|
||||
fallback: user.DisplayName ?? "?",
|
||||
src: user.AvatarUrl,
|
||||
size: "xl")
|
||||
```
|
||||
|
||||
### Sizes
|
||||
The `fallback` is always required — even with a `src` — because it becomes the `alt` text on the `<img>` tag.
|
||||
|
||||
```csharp
|
||||
new Avatar(fallback: "SM", size: "sm") // 32×32
|
||||
new Avatar(fallback: "DF", size: "default") // 40×40
|
||||
new Avatar(fallback: "LG", size: "lg") // 56×56
|
||||
new Avatar(fallback: "XL", size: "xl") // 80×80
|
||||
```
|
||||
### Overlapping avatar stack (e.g. "3 team members")
|
||||
|
||||
### Inside a user card
|
||||
Wrap multiple avatars in a flex container with negative spacing:
|
||||
|
||||
```csharp
|
||||
var avatar = new Avatar(fallback: user.Initials, src: user.AvatarUrl, size: "lg");
|
||||
|
||||
// In a page's RenderUserCard override:
|
||||
protected override void RenderUserAvatar(HtmxRenderContext ctx)
|
||||
=> avatar.Render(ctx.Next());
|
||||
```html
|
||||
<div class="flex -space-x-2">
|
||||
$$Avatar1$$
|
||||
$$Avatar2$$
|
||||
$$Avatar3$$
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
## How it works
|
||||
|
||||
- Compute initials before constructing the Avatar — the component does not extract them from a full name. See `MainLayout`'s `GetInitials` helper for a reference implementation.
|
||||
- Always provide `fallback` even when you also provide `src` — it serves as the `alt` attribute for accessibility.
|
||||
- The Avatar does not handle image load errors. If you need a graceful image fallback on 404, add an `onerror="this.style.display='none'"` attribute by embedding it in the `src` or use `hxAttrs` in a subclassed version.
|
||||
- For a group of overlapping avatars (avatar stack), wrap several Avatars in a flex container with negative margin: `<div class="flex -space-x-2">`.
|
||||
The avatar is a `<span>` clipped to a circle with `rounded-full overflow-hidden`. When a `src` is given, an `<img>` fills the circle using `object-cover` so the photo does not stretch. When there is no `src`, a `<span>` with a muted background shows the fallback text centred inside the circle.
|
||||
|
||||
The component does not handle broken image URLs. If you need a fallback when an image 404s, add an `onerror` attribute in the surrounding HTML.
|
||||
|
||||
The Avatar does not extract initials from full names — do that yourself before constructing it. `"Jane Doe"` → `"JD"` is two lines of C# and is better kept in your own code.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+56
-62
@@ -1,108 +1,102 @@
|
||||
# Badge
|
||||
|
||||
A small inline label pill. Used to indicate status, category, or count. Four variants cover most use-cases.
|
||||
A small coloured pill label — the kind you see next to a status field that says "Active", "Pending", or "Error".
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
span.{base classes + variant classes}
|
||||
{text}
|
||||
```csharp
|
||||
new Badge("Active")
|
||||
new Badge("Pending", variant: "secondary")
|
||||
new Badge("Error", variant: "destructive")
|
||||
new Badge("Draft", variant: "outline")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `inline-flex items-center rounded-full` | Pill shape that sits inline with text |
|
||||
| `px-2.5 py-0.5 text-xs font-semibold` | Compact size and bold label |
|
||||
| `transition-colors` | Smooth color changes on hover |
|
||||
| `focus:ring-2 focus:ring-ring focus:ring-offset-2` | Keyboard focus outline |
|
||||
|
||||
**Variants:**
|
||||
|
||||
| Variant | Classes |
|
||||
|---|---|
|
||||
| `default` | `bg-primary text-primary-foreground hover:bg-primary/80` |
|
||||
| `secondary` | `bg-secondary text-secondary-foreground hover:bg-secondary/80` |
|
||||
| `destructive` | `bg-destructive text-destructive-foreground hover:bg-destructive/80` |
|
||||
| `outline` | `text-foreground border border-input hover:bg-accent` |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Badge(string text, string variant = "default")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `text` | Label displayed inside the badge |
|
||||
| `variant` | `"default"` / `"secondary"` / `"destructive"` / `"outline"` |
|
||||
| `text` | The label inside the pill |
|
||||
| `variant` | The colour scheme: `"default"` (primary colour), `"secondary"` (muted), `"destructive"` (red), `"outline"` (border only) |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Basic badges
|
||||
### Status column in a user table
|
||||
|
||||
When you need a Badge inside a table cell (which takes a raw HTML string), render it to a string first:
|
||||
|
||||
```csharp
|
||||
new Badge("New")
|
||||
new Badge("Beta", variant: "secondary")
|
||||
new Badge("Error", variant: "destructive")
|
||||
new Badge("Pending", variant: "outline")
|
||||
```
|
||||
|
||||
### Status indicator in a table cell
|
||||
|
||||
```csharp
|
||||
// Render to bytes and embed in table cell HTML
|
||||
var writer = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
new Badge("Active", variant: "default").Render(new HtmxRenderContext(writer));
|
||||
var badgeHtml = System.Text.Encoding.UTF8.GetString(writer.WrittenSpan);
|
||||
static string RenderBadge(string text, string variant)
|
||||
{
|
||||
var writer = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
new Badge(text, variant).Render(new HtmxRenderContext(writer));
|
||||
return System.Text.Encoding.UTF8.GetString(writer.WrittenSpan);
|
||||
}
|
||||
|
||||
new Table(
|
||||
headers: new[] { "Name", "Status" },
|
||||
rows: users.Select(u => new[] { u.DisplayName ?? "", badgeHtml }))
|
||||
headers: new[] { "Name", "Role", "Status" },
|
||||
rows: users.Select(u => new[]
|
||||
{
|
||||
u.DisplayName ?? "",
|
||||
u.Role,
|
||||
RenderBadge(u.IsActive ? "Active" : "Suspended",
|
||||
u.IsActive ? "default" : "destructive"),
|
||||
}))
|
||||
```
|
||||
|
||||
### Embedding in a page slot
|
||||
### Dynamic variant based on data
|
||||
|
||||
```csharp
|
||||
var badge = order.Status switch
|
||||
{
|
||||
"complete" => new Badge("Complete"),
|
||||
"pending" => new Badge("Pending", variant: "secondary"),
|
||||
"cancelled" => new Badge("Cancelled", variant: "destructive"),
|
||||
_ => new Badge(order.Status, variant: "outline"),
|
||||
};
|
||||
```
|
||||
|
||||
### Inside a page slot
|
||||
|
||||
```html
|
||||
<!-- MyPage.htmx -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm">Status:</span>
|
||||
<span>Status:</span>
|
||||
$$StatusBadge$$
|
||||
</div>
|
||||
```
|
||||
|
||||
```csharp
|
||||
// MyPage.htmx.cs
|
||||
public IHtmxComponent StatusBadge { get; }
|
||||
|
||||
public MyPage(string status)
|
||||
public sealed class MyPage : MyPageBase
|
||||
{
|
||||
StatusBadge = status == "active"
|
||||
? new Badge("Active")
|
||||
: new Badge("Inactive", variant: "secondary");
|
||||
}
|
||||
private readonly IHtmxComponent _statusBadge;
|
||||
|
||||
protected override void RenderStatusBadge(HtmxRenderContext ctx)
|
||||
=> StatusBadge.Render(ctx.Next());
|
||||
public MyPage(string status)
|
||||
{
|
||||
_statusBadge = new Badge(status == "active" ? "Active" : "Inactive",
|
||||
status == "active" ? "default" : "secondary");
|
||||
}
|
||||
|
||||
protected override void RenderStatusBadge(HtmxRenderContext ctx)
|
||||
=> _statusBadge.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
## How it works
|
||||
|
||||
- Badge does not have a click handler — wrap it in an `<a>` or a `Button` if you need interactivity.
|
||||
- All four variants respond to focus, so a Badge embedded inside a focusable element will show a ring.
|
||||
- For a count badge (e.g. `"3 new"`) just include the count in the text string.
|
||||
- To render a Badge inside raw HTML strings (e.g. inside a `Table` cell or `Card` content), render it eagerly to a string in the constructor rather than relying on slot rendering.
|
||||
Badge is a `<span>` with `rounded-full` giving it the pill shape. The four variants are just different combinations of background and text colour classes. Badge is a purely server-rendered display element — it has no JavaScript and no click behaviour. If you need a clickable badge, wrap it in an `<a>` tag or use a `Button` component with a `link` variant.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,66 +1,56 @@
|
||||
# Breadcrumb
|
||||
|
||||
A navigation trail showing the user's location in the app hierarchy. Items are separated by chevron icons. The last item is always rendered as plain text (current page); earlier items are links.
|
||||
A "you are here" trail — a row of links showing how the user got to the current page. Like breadcrumbs leading back through a forest.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
nav[aria-label=Breadcrumb]
|
||||
ol.flex.flex-wrap.items-center.gap-1.5.text-sm.text-muted-foreground
|
||||
li.inline-flex.items-center.gap-1.5 ← one per item
|
||||
a | span ← a = link, span = non-linked or current
|
||||
span[role=presentation, aria-hidden] ← chevron separator (omitted after last item)
|
||||
svg (3.5×3.5, chevron-right)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `text-muted-foreground` | Dimmed color for all non-current items |
|
||||
| `font-normal text-foreground` | Full-opacity color applied to the last (current) item |
|
||||
| `hover:text-foreground transition-colors` | Link hover state |
|
||||
| `flex-wrap` | Items wrap on narrow screens |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Breadcrumb(IEnumerable<(string Label, string Href)> items)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `items` | Ordered list of `(Label, Href)` tuples. The last item is always the current page. |
|
||||
|
||||
Rules:
|
||||
- The **last** item is always non-linked and rendered in full `text-foreground` color, regardless of its `Href` value.
|
||||
- Any **non-last** item with an empty `Href` is rendered as a plain `<span>` rather than a link.
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Simple three-level breadcrumb
|
||||
## Quick example
|
||||
|
||||
```csharp
|
||||
new Breadcrumb(new[]
|
||||
{
|
||||
("Home", "/"),
|
||||
("Settings", "/settings"),
|
||||
("Profile", ""), // current page — href is ignored for the last item
|
||||
("Profile", ""), // current page
|
||||
})
|
||||
```
|
||||
|
||||
### Dynamic breadcrumb from a data path
|
||||
The last item is always the current page. Its link is ignored — the component automatically renders it as plain text with full colour instead of a dimmed link.
|
||||
|
||||
---
|
||||
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Breadcrumb(IEnumerable<(string Label, string Href)> items)
|
||||
```
|
||||
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `items` | An ordered list of `(Label, Href)` pairs from root to current page. |
|
||||
|
||||
Two rules:
|
||||
- The **last item** is always rendered as plain text (current page). Its `Href` is ignored.
|
||||
- Any **non-last item** with an empty `Href` renders as a plain `<span>` — useful for non-navigable category labels.
|
||||
|
||||
---
|
||||
|
||||
## Real-world examples
|
||||
|
||||
### Three-level app navigation
|
||||
|
||||
```csharp
|
||||
new Breadcrumb(new[]
|
||||
{
|
||||
("Home", "/"),
|
||||
("Reports", "/reports"),
|
||||
("Monthly", ""), // current — href not needed
|
||||
})
|
||||
```
|
||||
|
||||
### Built dynamically from a category tree
|
||||
|
||||
```csharp
|
||||
// Build items from a category tree
|
||||
var crumbs = categoryPath
|
||||
.Select((cat, i) => (cat.Name, i < categoryPath.Count - 1 ? cat.Url : ""))
|
||||
.ToArray();
|
||||
@@ -68,41 +58,45 @@ var crumbs = categoryPath
|
||||
new Breadcrumb(crumbs)
|
||||
```
|
||||
|
||||
### Embedded in a page
|
||||
### Inside a page
|
||||
|
||||
```html
|
||||
<!-- MyPage.htmx -->
|
||||
<div class="mb-6">
|
||||
$$Breadcrumb$$
|
||||
</div>
|
||||
<!-- ArticlePage.htmx -->
|
||||
<div class="mb-6">$$Breadcrumb$$</div>
|
||||
<h1 class="text-3xl font-bold">$$ArticleTitle$$</h1>
|
||||
```
|
||||
|
||||
```csharp
|
||||
// MyPage.htmx.cs
|
||||
public IHtmxComponent Breadcrumb { get; }
|
||||
|
||||
public MyPage()
|
||||
// ArticlePage.htmx.cs
|
||||
public sealed class ArticlePage : ArticlePageBase
|
||||
{
|
||||
Breadcrumb = new Breadcrumb(new[]
|
||||
{
|
||||
("Home", "/"),
|
||||
("Reports", "/reports"),
|
||||
("Monthly", ""),
|
||||
});
|
||||
}
|
||||
private readonly IHtmxComponent _breadcrumb;
|
||||
private readonly byte[] _titleData;
|
||||
|
||||
protected override void RenderBreadcrumb(HtmxRenderContext ctx)
|
||||
=> Breadcrumb.Render(ctx.Next());
|
||||
public ArticlePage(string articleTitle, string categoryName, string categoryUrl)
|
||||
{
|
||||
_titleData = articleTitle.ToUtf8Bytes();
|
||||
_breadcrumb = new Breadcrumb(new[]
|
||||
{
|
||||
("Home", "/"),
|
||||
(categoryName, categoryUrl),
|
||||
(articleTitle, ""),
|
||||
});
|
||||
}
|
||||
|
||||
protected override void RenderBreadcrumb(HtmxRenderContext ctx)
|
||||
=> _breadcrumb.Render(ctx.Next());
|
||||
|
||||
protected override void RenderArticleTitle(HtmxRenderContext ctx)
|
||||
=> ctx.Writer.WriteUtf8(_titleData);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
## How it works
|
||||
|
||||
- Always make the last item the current page — its href is ignored anyway, and it gets the visual "active" treatment automatically.
|
||||
- If you have a non-navigable segment (e.g. a category separator with no URL), pass an empty `Href` for that item and it will render as a plain span.
|
||||
- For very deep hierarchies, consider truncating the middle items and replacing them with a `…` span — build the items list conditionally before passing to the constructor.
|
||||
- The chevron separator is `aria-hidden` so screen readers announce only the labels in sequence.
|
||||
Each item renders as a `<li>` inside an `<ol>` inside a `<nav aria-label="Breadcrumb">`. All items except the last are rendered as `<a>` links; the last is a `<span>`. Between items the component inserts a small SVG chevron that is marked `aria-hidden` so screen readers skip it and only announce the text labels.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+71
-73
@@ -1,52 +1,20 @@
|
||||
# Button
|
||||
|
||||
A styled `<button>` element. Supports six visual variants and four sizes. HTMX attributes can be injected directly via the `hxAttrs` parameter.
|
||||
A styled clickable button. Use it for form submissions, navigation actions, or triggering HTMX requests.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
button[type=$$Type$$, class=$$Classes$$, $$HxAttrs$$]
|
||||
$$Label$$
|
||||
```csharp
|
||||
new Button("Save changes", type: "submit")
|
||||
new Button("Cancel", variant: "outline")
|
||||
new Button("Delete", variant: "destructive")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
**Base classes** (always applied):
|
||||
|
||||
```
|
||||
inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium
|
||||
ring-offset-background transition-colors
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
||||
disabled:pointer-events-none disabled:opacity-50
|
||||
```
|
||||
|
||||
**Variant** appended:
|
||||
|
||||
| Variant | Added classes |
|
||||
|---|---|
|
||||
| `default` | `bg-primary text-primary-foreground hover:bg-primary/90` |
|
||||
| `destructive` | `bg-destructive text-destructive-foreground hover:bg-destructive/90` |
|
||||
| `outline` | `border border-input bg-transparent hover:bg-accent hover:text-accent-foreground` |
|
||||
| `secondary` | `bg-secondary text-secondary-foreground hover:bg-secondary/80` |
|
||||
| `ghost` | `hover:bg-accent hover:text-accent-foreground` |
|
||||
| `link` | `text-primary underline-offset-4 hover:underline` |
|
||||
|
||||
**Size** appended:
|
||||
|
||||
| Size | Added classes |
|
||||
|---|---|
|
||||
| `default` | `h-10 px-4 py-2 text-sm` |
|
||||
| `sm` | `h-9 rounded-md px-3 text-xs` |
|
||||
| `lg` | `h-11 rounded-md px-8 text-base` |
|
||||
| `icon` | `h-10 w-10` |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Button(
|
||||
@@ -57,67 +25,97 @@ public Button(
|
||||
string hxAttrs = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `label` | Button text (raw HTML — can include inline SVG) |
|
||||
| `variant` | Visual style; see table above |
|
||||
| `size` | Physical size; see table above |
|
||||
| `type` | HTML button type: `"button"` / `"submit"` / `"reset"` |
|
||||
| `hxAttrs` | Verbatim string appended as extra HTML attributes (HTMX, data-*, etc.) |
|
||||
| `label` | The button text. Can include raw HTML — useful for icons. |
|
||||
| `variant` | Visual style. See table below. |
|
||||
| `size` | How big the button is. See table below. |
|
||||
| `type` | HTML button type. Use `"submit"` for form submit buttons. Defaults to `"button"`. |
|
||||
| `hxAttrs` | Extra HTML attributes added verbatim — use this for HTMX, `disabled`, `data-*`, etc. |
|
||||
|
||||
**Variants:**
|
||||
|
||||
| Variant | Looks like |
|
||||
|---|---|
|
||||
| `default` | Filled with the primary colour — use for the main action on a page |
|
||||
| `destructive` | Red — use for irreversible actions like delete |
|
||||
| `outline` | Transparent with a border — use for secondary actions |
|
||||
| `secondary` | Muted fill — use for tertiary actions |
|
||||
| `ghost` | Invisible until hovered — use for toolbar buttons and icon actions |
|
||||
| `link` | Looks like a hyperlink with an underline on hover |
|
||||
|
||||
**Sizes:**
|
||||
|
||||
| Size | Dimensions |
|
||||
|---|---|
|
||||
| `sm` | Compact (36px tall) — good for dense UI |
|
||||
| `default` | Standard (40px tall) |
|
||||
| `lg` | Large (44px tall) — good for prominent CTAs |
|
||||
| `icon` | Square (40×40) — for icon-only buttons |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Standard actions
|
||||
### Form submit button
|
||||
|
||||
```csharp
|
||||
new Button("Save changes", type: "submit")
|
||||
new Button("Cancel", variant: "outline")
|
||||
new Button("Delete", variant: "destructive")
|
||||
new Button("Learn more", variant: "link")
|
||||
new Button("Sign in", type: "submit")
|
||||
```
|
||||
|
||||
### Sizes
|
||||
### Confirm and cancel in a dialog footer
|
||||
|
||||
```csharp
|
||||
new Button("Small", size: "sm")
|
||||
new Button("Default", size: "default")
|
||||
new Button("Large", size: "lg")
|
||||
new Button("⚙", size: "icon") // icon-only square button
|
||||
var footer = """
|
||||
{cancelButton}
|
||||
{deleteButton}
|
||||
""";
|
||||
|
||||
// Pre-render each to HTML string and embed:
|
||||
string Render(IHtmxComponent c)
|
||||
{
|
||||
var w = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
c.Render(new HtmxRenderContext(w));
|
||||
return System.Text.Encoding.UTF8.GetString(w.WrittenSpan);
|
||||
}
|
||||
|
||||
new Card(
|
||||
content: "<p>Are you sure you want to delete this item?</p>",
|
||||
footer: Render(new Button("Cancel", variant: "outline"))
|
||||
+ Render(new Button("Delete", variant: "destructive", type: "submit")))
|
||||
```
|
||||
|
||||
### HTMX trigger
|
||||
### HTMX load more button
|
||||
|
||||
```csharp
|
||||
new Button(
|
||||
"Load more",
|
||||
hxAttrs: """hx-get="/items?page=2" hx-target="#item-list" hx-swap="beforeend"""")
|
||||
variant: "outline",
|
||||
hxAttrs: """hx-get="/items?page=2" hx-target="#item-list" hx-swap="beforeend"""")
|
||||
```
|
||||
|
||||
### Submit button inside a form
|
||||
|
||||
```csharp
|
||||
new Button("Sign in", variant: "default", type: "submit", size: "default")
|
||||
```
|
||||
|
||||
### Ghost button with inline SVG icon
|
||||
### Icon-only ghost button (e.g. a refresh icon in a toolbar)
|
||||
|
||||
```csharp
|
||||
new Button(
|
||||
label: """
|
||||
<svg class="h-4 w-4" .../>
|
||||
<span>Refresh</span>
|
||||
""",
|
||||
variant: "ghost")
|
||||
label: "<svg class='h-4 w-4' ...fill or stroke SVG here/>",
|
||||
variant: "ghost",
|
||||
size: "icon")
|
||||
```
|
||||
|
||||
### Disabled appearance (via HTML)
|
||||
### Disabled state
|
||||
|
||||
The Button component does not have a `disabled` constructor parameter. Set it via `hxAttrs` if needed:
|
||||
Button does not have a `disabled` parameter. Pass it through `hxAttrs`:
|
||||
|
||||
```csharp
|
||||
new Button("Processing...", variant: "default", hxAttrs: "disabled aria-disabled='true'")
|
||||
new Button("Processing...", hxAttrs: "disabled aria-disabled='true'")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Button is a `<button>` element — straightforward HTML with Tailwind classes. The `hxAttrs` string is appended verbatim inside the opening `<button>` tag, so any valid HTML attribute works there. The `label` is inserted as raw HTML, which is how inline SVG icons are supported.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
+55
-97
@@ -1,68 +1,20 @@
|
||||
# Calendar
|
||||
|
||||
A single-date picker rendered server-side with full client-side interaction. The selected date is stored in a hidden input and submitted as part of a form. Supports three drill-down views: days → months → years.
|
||||
A date picker that lets the user click to select a single date. The selected date is stored in a hidden form input and submitted with the form. Think of it as a fancy `<input type="date">` that looks the same on every browser.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
div.calendar-root[id=cal-{id}, data-year, data-month, data-sel-day,
|
||||
data-sel-month, data-sel-year, data-view="days"]
|
||||
div.mb-3.flex.items-center.justify-between ← navigation row
|
||||
button.cal-prev ← previous month/year/decade
|
||||
button.cal-month-label ← shows "Month YYYY" / "YYYY" / decade range
|
||||
button.cal-next ← next
|
||||
div.cal-dow-row.grid.grid-cols-7 ← Sun–Sat headings (hidden in month/year views)
|
||||
div.cal-grid.grid.grid-cols-7 ← day/month/year cells, built by JS
|
||||
input.cal-hidden-input[type=hidden, name] ← holds selected date as yyyy-MM-dd
|
||||
```csharp
|
||||
new Calendar(id: "dob", name: "dateOfBirth")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `cal-day` | Base day button style (text-center, rounded, hover highlight) |
|
||||
| `cal-day-selected` | Filled primary circle on the selected day |
|
||||
| `cal-view-btn` | Base style for month/year selection buttons |
|
||||
| `cal-view-btn-selected` | Highlighted active month or year |
|
||||
| Grid is 7-column for days, 3-column for months/years | Switched via `gridTemplateColumns` inline style |
|
||||
That renders a calendar starting at today's date. When the user clicks a day, the hidden input is updated and the date is included in the form submission.
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (`initCalendar` in `components.js`)
|
||||
|
||||
State is stored entirely in `data-*` attributes on the root element. JS reads and writes these attributes — no hidden state in closures.
|
||||
|
||||
### `renderCalendar(root)` — three view modes
|
||||
|
||||
**Days view:**
|
||||
1. Reads `data-year` and `data-month` (0-based, JS-style)
|
||||
2. Calculates leading empty cells for the first weekday offset
|
||||
3. Renders numbered `<button>` elements; adds `cal-day-selected` to the matching date
|
||||
4. Each day button stores `yyyy-MM-dd` in `data-date`
|
||||
5. On click: updates `data-sel-*`, highlights the new selection, writes value to `.cal-hidden-input`, fires `calendarChange` CustomEvent
|
||||
|
||||
**Months view:**
|
||||
- Renders Jan–Dec abbreviated buttons in a 3-column grid
|
||||
- Click drills back to days view for that month
|
||||
|
||||
**Years view:**
|
||||
- Renders 12 consecutive years (decade rounded to nearest 12)
|
||||
- Click drills back to months view for that year
|
||||
|
||||
### Navigation buttons
|
||||
- Prev/Next adjust month ± 1 (wrapping year), year ± 1, or decade ± 12 depending on `data-view`
|
||||
- Month-label click drills down: days → months → years (no further drill from years)
|
||||
|
||||
### Re-initialization
|
||||
`initAll` re-queries `.calendar-root` after `htmx:afterSwap`, so HTMX-swapped calendars work correctly.
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Calendar(
|
||||
@@ -71,83 +23,89 @@ public Calendar(
|
||||
DateOnly? selected = null)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `id` | Logical id; element gets `id="cal-{id}"` |
|
||||
| `name` | Form field name for the hidden input |
|
||||
| `selected` | Pre-selected date; defaults to today |
|
||||
| `id` | A unique identifier for this calendar. The root element gets `id="cal-{id}"`. |
|
||||
| `name` | The form field name for the hidden input. Use this name in your `Command` record on the server. |
|
||||
| `selected` | The date to pre-select on render. Defaults to today. |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Basic date picker
|
||||
|
||||
```csharp
|
||||
new Calendar(id: "dob", name: "dateOfBirth")
|
||||
```
|
||||
|
||||
### Pre-selected date
|
||||
|
||||
```csharp
|
||||
new Calendar(
|
||||
id: "appointment",
|
||||
name: "appointmentDate",
|
||||
selected: new DateOnly(2026, 9, 15))
|
||||
```
|
||||
|
||||
### Inside a form
|
||||
### Appointment booking form
|
||||
|
||||
```html
|
||||
<!-- Templates/BookingForm.htmx -->
|
||||
<form method="post" action="/book">
|
||||
$$AntiforgeryToken$$
|
||||
<label class="text-sm font-medium">Pick a date</label>
|
||||
$$DatePicker$$
|
||||
<button type="submit">Book</button>
|
||||
<form method="post" action="/book" class="space-y-6">
|
||||
$$Token$$
|
||||
<div>
|
||||
<label class="text-sm font-medium">Pick a date</label>
|
||||
$$DatePicker$$
|
||||
</div>
|
||||
<button type="submit">Book appointment</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
```csharp
|
||||
// Templates/BookingForm.htmx.cs
|
||||
public IHtmxComponent DatePicker { get; }
|
||||
|
||||
public BookingForm(string? afToken = null)
|
||||
public sealed class BookingForm : BookingFormBase
|
||||
{
|
||||
DatePicker = new Calendar(id: "booking", name: "bookingDate");
|
||||
_afTokenData = /* antiforgery hidden input */;
|
||||
}
|
||||
private readonly IHtmxComponent _datePicker;
|
||||
private readonly byte[] _tokenData;
|
||||
|
||||
protected override void RenderDatePicker(HtmxRenderContext ctx)
|
||||
=> DatePicker.Render(ctx.Next());
|
||||
public BookingForm(string antiforgeryToken)
|
||||
{
|
||||
_tokenData = $"""<input type="hidden" name="__RequestVerificationToken" value="{System.Web.HttpUtility.HtmlAttributeEncode(antiforgeryToken)}" />""".ToUtf8Bytes();
|
||||
_datePicker = new Calendar(id: "booking", name: "bookingDate");
|
||||
}
|
||||
|
||||
protected override void RenderToken(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_tokenData);
|
||||
protected override void RenderDatePicker(HtmxRenderContext ctx) => _datePicker.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
**Reading the submitted value on the server:**
|
||||
**Reading the submitted date on the server:**
|
||||
|
||||
```csharp
|
||||
public record Command(
|
||||
[property: FromForm] string BookingDate // "yyyy-MM-dd"
|
||||
[property: FromForm] string BookingDate // arrives as "yyyy-MM-dd"
|
||||
);
|
||||
|
||||
// Parse:
|
||||
var date = DateOnly.ParseExact(command.BookingDate, "yyyy-MM-dd");
|
||||
```
|
||||
|
||||
### Listening for selection changes client-side
|
||||
### Pre-selected date (e.g. editing an existing booking)
|
||||
|
||||
```csharp
|
||||
new Calendar(
|
||||
id: "appointment",
|
||||
name: "appointmentDate",
|
||||
selected: existingBooking.Date)
|
||||
```
|
||||
|
||||
### Reacting to date selection in JavaScript
|
||||
|
||||
When the user picks a date, the calendar fires a `calendarChange` custom event:
|
||||
|
||||
```js
|
||||
document.getElementById('cal-appointment').addEventListener('calendarChange', e => {
|
||||
document.getElementById('cal-booking').addEventListener('calendarChange', e => {
|
||||
console.log(e.detail.date); // "2026-09-15"
|
||||
// update other UI elements based on selection
|
||||
// update price estimates, availability, etc.
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
## How it works
|
||||
|
||||
- The hidden input is always named with the `name` parameter — use this as the form field name when reading the submitted POST.
|
||||
- Months are 0-based in the JS `data-*` attributes (matching `Date` object convention) but the hidden input always stores `yyyy-MM-dd` with 1-based months.
|
||||
The calendar is rendered as static HTML by the server, with the current month's grid pre-built as `<button>` elements. JavaScript in `components.js` (`initCalendar`) takes over after the page loads:
|
||||
|
||||
- Clicking a day updates the hidden input and highlights the selected date.
|
||||
- Clicking the month/year label in the navigation row drills down: days → months → years. This lets the user jump to a different year quickly without clicking through months one at a time.
|
||||
- Prev/Next arrows move through the current view (month by month, year by year, or decade by decade).
|
||||
|
||||
All state is stored in `data-*` attributes on the root element — not in JavaScript closures. This means the calendar is fully re-initialised correctly when HTMX swaps it in.
|
||||
- If you need to clear the selection client-side, set `document.querySelector('#cal-myid .cal-hidden-input').value = ''` and remove `cal-day-selected` from any button.
|
||||
- To restrict available dates (e.g. no past dates), post-process the rendered buttons in JS using the `calendarChange` event or override the generated grid with a custom `initCalendar` extension.
|
||||
- To restrict available dates (e.g. no past dates), post-process the rendered buttons in JS using the `calendarChange` event or override the generated grid with a custom `initCalendar` extension.
|
||||
|
||||
@@ -1,68 +1,20 @@
|
||||
# CalendarRange
|
||||
|
||||
A date-range picker. The user selects a start date and then an end date. Hover preview shades the range before the second click commits it. Fires a `rangeChange` CustomEvent on every selection change.
|
||||
A date-range picker. The user clicks once to set a start date and clicks again to set an end date. While hovering, the range between start and the cursor is shaded as a preview. Great for booking forms, report filters, or anything that needs a "from / to" date pair.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
div.calr-root[id=calr-{id}, data-year, data-month, data-start, data-end, data-view="days"]
|
||||
div.mb-3.flex.items-center.justify-between ← navigation row
|
||||
button.calr-prev
|
||||
button.calr-month-label
|
||||
button.calr-next
|
||||
div.cal-dow-row.grid.grid-cols-7 ← day-of-week headings
|
||||
div.calr-grid.grid.grid-cols-7 ← day cells, rebuilt by JS on each interaction
|
||||
span.calr-label ← "start → end" or "start → pick end date"
|
||||
input.calr-hidden-start[type=hidden, name={name}-start]
|
||||
input.calr-hidden-end[type=hidden, name={name}-end]
|
||||
```csharp
|
||||
new CalendarRange(id: "vacation", name: "vacation")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `calr-day-start` | Filled primary circle on start date |
|
||||
| `calr-day-end` | Filled primary circle on end date |
|
||||
| `calr-day-mid` | Lighter primary tint for dates between start and end |
|
||||
| `calr-day-plain` | Default un-selected day style |
|
||||
|
||||
Hover preview is applied by `updateHoverClasses` by toggling the same CSS classes without rebuilding the DOM.
|
||||
This renders an empty picker. The user clicks two dates to form a range. Both dates are submitted with the form as `vacation-start` and `vacation-end`.
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (`initCalendarRange` in `components.js`)
|
||||
|
||||
### State
|
||||
|
||||
Stored in `data-start` and `data-end` attributes on the root (empty string = not selected).
|
||||
|
||||
### Click logic (`grid.onclick`)
|
||||
|
||||
1. **Nothing or both selected** → set `start = clicked`, clear `end`
|
||||
2. **Only start selected:**
|
||||
- Click after start → set `end`, fire `rangeChange`
|
||||
- Click before start → move `start` to clicked, clear `end`
|
||||
- Click on start → clear both (toggle off)
|
||||
3. Writes values to hidden inputs, fires `rangeChange` CustomEvent: `{ start: "yyyy-MM-dd", end: "yyyy-MM-dd" }`
|
||||
4. Calls `renderRange` to rebuild grid and `updateLabel` to update the text summary
|
||||
|
||||
### Hover preview (`updateHoverClasses`)
|
||||
|
||||
- Runs on `grid.onmouseover` without rebuilding the grid — only toggles CSS classes
|
||||
- Shades the tentative range from `start` to the hovered date before a click commits it
|
||||
- Cleared on `grid.onmouseleave`
|
||||
|
||||
### View navigation
|
||||
|
||||
Same as Calendar: Prev/Next, month-label click drills days → months → years. `renderRange` rebuilds the grid on each navigation.
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public CalendarRange(
|
||||
@@ -72,46 +24,32 @@ public CalendarRange(
|
||||
DateOnly? selectedEnd = null)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `id` | Logical id; element gets `id="calr-{id}"` |
|
||||
| `name` | Base form field name; hidden inputs are `{name}-start` and `{name}-end` |
|
||||
| `selectedStart` | Pre-selected start date |
|
||||
| `selectedEnd` | Pre-selected end date |
|
||||
| `id` | A unique identifier. The root element gets `id="calr-{id}"`. |
|
||||
| `name` | Base form field name. The two hidden inputs become `{name}-start` and `{name}-end`. |
|
||||
| `selectedStart` | Pre-selected start date. |
|
||||
| `selectedEnd` | Pre-selected end date. |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Empty picker
|
||||
|
||||
```csharp
|
||||
new CalendarRange(id: "vacation", name: "vacation")
|
||||
```
|
||||
|
||||
### Pre-selected range
|
||||
|
||||
```csharp
|
||||
new CalendarRange(
|
||||
id: "vacation",
|
||||
name: "vacation",
|
||||
selectedStart: new DateOnly(2026, 7, 1),
|
||||
selectedEnd: new DateOnly(2026, 7, 14))
|
||||
```
|
||||
|
||||
### Inside a form
|
||||
### Vacation request form
|
||||
|
||||
```html
|
||||
<!-- Templates/VacationForm.htmx -->
|
||||
<form method="post" action="/vacation">
|
||||
$$AntiforgeryToken$$
|
||||
<label class="text-sm font-medium">Select vacation dates</label>
|
||||
$$RangePicker$$
|
||||
<button type="submit">Request</button>
|
||||
<form method="post" action="/vacation" class="space-y-6">
|
||||
$$Token$$
|
||||
<div>
|
||||
<label class="text-sm font-medium">Select vacation dates</label>
|
||||
$$RangePicker$$
|
||||
</div>
|
||||
<button type="submit">Submit request</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
**Reading the submitted values:**
|
||||
**Reading the submitted values on the server:**
|
||||
|
||||
```csharp
|
||||
public record Command(
|
||||
@@ -119,34 +57,48 @@ public record Command(
|
||||
[property: FromForm] string VacationEnd // "yyyy-MM-dd"
|
||||
);
|
||||
|
||||
// Validate they are not empty before parsing
|
||||
if (string.IsNullOrEmpty(command.VacationStart) || string.IsNullOrEmpty(command.VacationEnd))
|
||||
return; // user did not complete the selection
|
||||
|
||||
var start = DateOnly.ParseExact(command.VacationStart, "yyyy-MM-dd");
|
||||
var end = DateOnly.ParseExact(command.VacationEnd, "yyyy-MM-dd");
|
||||
```
|
||||
|
||||
### Listening for range changes client-side
|
||||
### Pre-selected range (e.g. editing an existing request)
|
||||
|
||||
```csharp
|
||||
new CalendarRange(
|
||||
id: "vacation",
|
||||
name: "vacation",
|
||||
selectedStart: existingRequest.StartDate,
|
||||
selectedEnd: existingRequest.EndDate)
|
||||
```
|
||||
|
||||
### Reacting to selection changes in JavaScript
|
||||
|
||||
```js
|
||||
document.getElementById('calr-vacation').addEventListener('rangeChange', e => {
|
||||
console.log(e.detail.start, e.detail.end);
|
||||
// e.g. "2026-07-01", "2026-07-14"
|
||||
// Update a price estimate, nights count, etc.
|
||||
});
|
||||
```
|
||||
|
||||
### Showing a summary label elsewhere on the page
|
||||
|
||||
The `.calr-label` span inside the component automatically updates to show `start → end` or `start → pick end date`. You don't need custom JS for this.
|
||||
The `.calr-label` element inside the calendar automatically updates to show `start → end` (or `start → pick end date` while mid-selection). You do not need custom JS for the label.
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
## How it works
|
||||
|
||||
- Both hidden inputs are always submitted with the form. An empty string means the date was not selected — validate server-side before parsing.
|
||||
- The user can clear the selection by clicking the start date again after both are set.
|
||||
- To enforce a minimum range length (e.g. at least 2 nights), use the `rangeChange` event to validate client-side and show an error message.
|
||||
- The calendar always shows a single month. For a two-month range view, render two `CalendarRange` components side by side and sync their state via the `rangeChange` event.
|
||||
- The calendar always shows a single month. For a two-month range view, render two `CalendarRange` components side by side and sync their state via the `rangeChange` event.
|
||||
The click logic follows three states:
|
||||
|
||||
---
|
||||
1. **Nothing selected** — first click sets the start date, clears the end
|
||||
2. **Start selected, no end** — next click after start sets the end and fires `rangeChange`; clicking before start moves the start; clicking on start again clears both
|
||||
3. **Both selected** — any click resets and starts again from step 1
|
||||
|
||||
The hover preview does not rebuild the grid. It only toggles CSS classes on the day buttons, so it is fast even for long ranges.
|
||||
|
||||
All state is stored in `data-start` and `data-end` attributes on the root element, not in closures, so HTMX-swapped calendars re-initialise correctly.
|
||||
|
||||
## Complete page example
|
||||
|
||||
|
||||
+54
-68
@@ -1,36 +1,21 @@
|
||||
# Card
|
||||
|
||||
A styled container with optional header (title + description) and footer sections. The body content is always rendered; header and footer are conditionally included.
|
||||
A bordered box for grouping related content — like a physical card you might hold in your hand. It has three distinct zones: a header (title + subtitle), a body (your content), and a footer (usually actions).
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
div.rounded-lg.border.border-border.bg-card.text-card-foreground.shadow-sm.{extraClasses}
|
||||
div.flex.flex-col.space-y-1.5.p-6 ← header (omitted when no title/description)
|
||||
h3.text-2xl.font-semibold ← title
|
||||
p.text-sm.text-muted-foreground ← description
|
||||
div.p-6.pt-0 ← content (always present)
|
||||
{content}
|
||||
div.flex.items-center.p-6.pt-0 ← footer (omitted when empty)
|
||||
{footer}
|
||||
```csharp
|
||||
new Card(
|
||||
content: "<p>Your subscription renews on July 1.</p>",
|
||||
title: "Billing",
|
||||
description: "Current plan: Pro")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `bg-card text-card-foreground` | Pulls from CSS variables — dark mode works automatically |
|
||||
| `rounded-lg border border-border shadow-sm` | Subtle rounded box with border and drop shadow |
|
||||
| `p-6 pt-0` on content | Full padding except top (header provides the top spacing) |
|
||||
| `space-y-1.5` on header | Controlled gap between title and description |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Card(
|
||||
@@ -41,77 +26,78 @@ public Card(
|
||||
string extraClasses = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `content` | Raw HTML for the card body (always rendered) |
|
||||
| `title` | Optional heading in the header area |
|
||||
| `description` | Optional subheading below the title |
|
||||
| `footer` | Optional raw HTML in the footer area |
|
||||
| `extraClasses` | Additional Tailwind classes on the outer `div` |
|
||||
| `content` | The body of the card — always shown. Raw HTML. |
|
||||
| `title` | Optional bold heading at the top of the card. |
|
||||
| `description` | Optional smaller subtitle below the title. |
|
||||
| `footer` | Optional section at the bottom, typically holding action buttons. Raw HTML. |
|
||||
| `extraClasses` | Additional Tailwind classes on the outer `div` — useful for `max-w-sm`, `col-span-2`, etc. |
|
||||
|
||||
The header section (title + description) is omitted entirely when both are empty. Same for the footer.
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Simple content card
|
||||
|
||||
```csharp
|
||||
new Card(content: "<p>Your subscription renews on July 1.</p>")
|
||||
```
|
||||
|
||||
### Card with title and description
|
||||
### A stats card on a dashboard
|
||||
|
||||
```csharp
|
||||
new Card(
|
||||
content: "<p>Manage your billing details and invoices.</p>",
|
||||
title: "Billing",
|
||||
description: "Your current plan: Pro")
|
||||
title: "Total Users",
|
||||
description: "All registered accounts",
|
||||
content: $"<p class=\"text-4xl font-bold\">{userCount:N0}</p>")
|
||||
```
|
||||
|
||||
### Card with footer actions
|
||||
### A confirmation card with footer buttons
|
||||
|
||||
Buttons and other components need to be pre-rendered to HTML strings when used inside `content` or `footer`:
|
||||
|
||||
```csharp
|
||||
string ToHtml(IHtmxComponent c)
|
||||
{
|
||||
var w = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
c.Render(new HtmxRenderContext(w));
|
||||
return System.Text.Encoding.UTF8.GetString(w.WrittenSpan);
|
||||
}
|
||||
|
||||
new Card(
|
||||
content: "<p>Are you sure you want to cancel your account?</p>",
|
||||
title: "Delete account",
|
||||
description: "This action cannot be undone.",
|
||||
footer: """
|
||||
<button class="inline-flex h-9 rounded-md border border-input px-4 text-sm mr-2">Cancel</button>
|
||||
<button class="inline-flex h-9 rounded-md bg-destructive text-destructive-foreground px-4 text-sm">Delete</button>
|
||||
""")
|
||||
content: "<p>All your data will be permanently removed.</p>",
|
||||
footer: ToHtml(new Button("Cancel", variant: "outline"))
|
||||
+ ToHtml(new Button("Delete", variant: "destructive", type: "submit")))
|
||||
```
|
||||
|
||||
### Constrained width
|
||||
### A grid of cards
|
||||
|
||||
Cards are most commonly placed in a CSS grid in the page template:
|
||||
|
||||
```html
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
$$Card1$$
|
||||
$$Card2$$
|
||||
$$Card3$$
|
||||
</div>
|
||||
```
|
||||
|
||||
### Constrained width (e.g. a login card)
|
||||
|
||||
```csharp
|
||||
new Card(
|
||||
content: "<p>Hello world</p>",
|
||||
title: "Welcome",
|
||||
content: "...login form HTML...",
|
||||
title: "Welcome back",
|
||||
description: "Sign in to your account",
|
||||
extraClasses: "max-w-sm mx-auto")
|
||||
```
|
||||
|
||||
### Embedding a component as content
|
||||
|
||||
```csharp
|
||||
// Render a Badge to a string then embed in the card body
|
||||
var writer = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
new Badge("Active").Render(new HtmxRenderContext(writer));
|
||||
var badgeHtml = System.Text.Encoding.UTF8.GetString(writer.WrittenSpan);
|
||||
|
||||
new Card(
|
||||
content: $"<p class='mb-2'>Status:</p>{badgeHtml}",
|
||||
title: "Account")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
## How it works
|
||||
|
||||
- `content`, `footer`, title, and description are inserted as raw HTML — HTML-encode any user-supplied strings before passing them in.
|
||||
- Use `extraClasses` to set max-width, margin, or custom background without subclassing.
|
||||
- If you need a completely custom header layout, omit `title` and `description` and build the header HTML in `content`, adding `p-6` padding yourself.
|
||||
- Cards can be placed in a CSS grid: `<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">`.
|
||||
- Cards can be placed in a CSS grid: `<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">`.
|
||||
Card uses CSS variables (`bg-card`, `text-card-foreground`, `border-border`) which automatically adapt to dark mode. The header and footer sections are skipped entirely in the rendered HTML when they are not needed — they do not leave empty divs behind.
|
||||
|
||||
All strings passed to `content` and `footer` are raw HTML. HTML-encode any user-supplied values before passing them in.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+60
-74
@@ -1,55 +1,10 @@
|
||||
# Checkbox
|
||||
|
||||
A styled checkbox input with an optional visible label. Uses the `accent-primary` Tailwind class so the checkmark color follows your primary theme color.
|
||||
A styled checkbox with an optional text label. Use it in forms when you want the user to opt in or out of something — "Remember me", "I agree to the terms", or selecting items in a list.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div.flex.items-center.space-x-2
|
||||
input[type=checkbox, id, name, value, class, $$Checked$$]
|
||||
label[for={id}] ← omitted when label is empty
|
||||
{label text}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `accent-primary` | Checkmark color follows the `--color-primary` CSS variable |
|
||||
| `h-4 w-4 rounded` | Consistent 16×16 size with slightly rounded corners |
|
||||
| `cursor-pointer` | Pointer cursor on label |
|
||||
| `text-sm font-medium leading-none peer-disabled:opacity-70` | Standard label styling |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public Checkbox(
|
||||
string id,
|
||||
string label = "",
|
||||
string name = "",
|
||||
string value = "true",
|
||||
bool @checked = false)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Element id and the `for` attribute on the label |
|
||||
| `label` | Visible text next to the checkbox; omit for a standalone checkbox |
|
||||
| `name` | Form field name (required when used in a form) |
|
||||
| `value` | Submitted value when checked (default: `"true"`) |
|
||||
| `checked` | Pre-checked state |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Basic opt-in checkbox
|
||||
## Quick example
|
||||
|
||||
```csharp
|
||||
new Checkbox(
|
||||
@@ -58,7 +13,54 @@ new Checkbox(
|
||||
name: "newsletter")
|
||||
```
|
||||
|
||||
### Pre-checked
|
||||
---
|
||||
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Checkbox(
|
||||
string id,
|
||||
string label = "",
|
||||
string name = "",
|
||||
string value = "true",
|
||||
bool @checked = false)
|
||||
```
|
||||
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `id` | The element id. Also used by the `<label for="...">` so clicking the label toggles the box. |
|
||||
| `label` | Visible text next to the checkbox. Leave empty for a standalone checkbox with no label. |
|
||||
| `name` | Form field name — required if you want the value submitted with the form. |
|
||||
| `value` | The string that gets submitted when the box is checked. Defaults to `"true"`. |
|
||||
| `checked` | Pre-tick the checkbox on render. |
|
||||
|
||||
---
|
||||
|
||||
## Real-world examples
|
||||
|
||||
### Terms and conditions on a registration form
|
||||
|
||||
```csharp
|
||||
new Checkbox(
|
||||
id: "terms",
|
||||
label: "I agree to the terms of service",
|
||||
name: "terms",
|
||||
value: "accepted")
|
||||
```
|
||||
|
||||
Reading it on the server:
|
||||
|
||||
```csharp
|
||||
public record Command(
|
||||
[property: FromForm] string? Terms = null // null when unchecked, "accepted" when checked
|
||||
);
|
||||
|
||||
bool agreedToTerms = command.Terms == "accepted";
|
||||
```
|
||||
|
||||
> **Important:** HTML forms only submit checkboxes that are *checked*. An unchecked checkbox sends nothing — the field is simply absent. Always use a nullable string (`string?`) or give it a default of `null`.
|
||||
|
||||
### Remember me (pre-ticked by default)
|
||||
|
||||
```csharp
|
||||
new Checkbox(
|
||||
@@ -68,45 +70,29 @@ new Checkbox(
|
||||
checked: true)
|
||||
```
|
||||
|
||||
### No visible label
|
||||
### Multiple checkboxes in a preferences form
|
||||
|
||||
```csharp
|
||||
new Checkbox(id: "select-all", name: "selectAll")
|
||||
new Checkbox(id: "email-alerts", label: "Email alerts", name: "emailAlerts")
|
||||
new Checkbox(id: "sms-alerts", label: "SMS alerts", name: "smsAlerts")
|
||||
new Checkbox(id: "weekly-summary", label: "Weekly summary", name: "weeklySummary")
|
||||
```
|
||||
|
||||
### Custom submitted value
|
||||
|
||||
```csharp
|
||||
new Checkbox(
|
||||
id: "agree",
|
||||
label: "I agree to the terms",
|
||||
name: "terms",
|
||||
value: "accepted")
|
||||
```
|
||||
|
||||
### Reading in a form handler
|
||||
Server-side:
|
||||
|
||||
```csharp
|
||||
public record Command(
|
||||
[property: FromForm] string? Newsletter = null, // null when unchecked
|
||||
[property: FromForm] string? RememberMe = null
|
||||
[property: FromForm] string? EmailAlerts = null,
|
||||
[property: FromForm] string? SmsAlerts = null,
|
||||
[property: FromForm] string? WeeklySummary = null
|
||||
);
|
||||
|
||||
bool wantsNewsletter = command.Newsletter == "true";
|
||||
bool rememberUser = command.RememberMe == "true";
|
||||
```
|
||||
|
||||
> Note: Unchecked checkboxes are not included in form data. Always use a nullable string or a default value of `null`.
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
## How it works
|
||||
|
||||
- Because HTML forms only submit checked checkboxes, pair a checkbox with a hidden input of the same name and value `"false"` if you need the unchecked state explicitly in your command.
|
||||
- The label `for` attribute ties to the `id`, so clicking the label text toggles the checkbox — always set `id`.
|
||||
- If you need multi-select (select multiple rows in a table), use the same `name` for all checkboxes; they will be submitted as a comma-separated list or multiple values depending on form binding.
|
||||
- `accent-primary` is a modern CSS property — all current browsers support it.
|
||||
- `accent-primary` is a modern CSS property — all current browsers support it.
|
||||
Checkbox renders a native `<input type="checkbox">` styled with `accent-primary` so the checkmark colour matches your theme's primary colour. The label is a separate `<label for="{id}">` element — clicking anywhere on the label text toggles the checkbox.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+51
-92
@@ -1,53 +1,32 @@
|
||||
# Dialog
|
||||
|
||||
A modal dialog using the native HTML `<dialog>` element. Content is organized into optional title, description, body, and footer sections. Open/close is handled by client-side JS via delegated click events on `data-dialog-open` and `data-dialog-close` attributes.
|
||||
A modal pop-up window that appears on top of the page. Think of it like a small piece of paper sliding onto the desk — the rest of the page dims and you have to deal with the dialog before you can go back to work.
|
||||
|
||||
Opening and closing is handled entirely with `data-dialog-open` and `data-dialog-close` HTML attributes — no custom JavaScript needed.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
dialog[id, class=...]
|
||||
div.dialog-panel.relative.bg-background.p-6.rounded-lg.shadow-xl.w-full.max-w-md...
|
||||
button.absolute.top-4.right-4[data-dialog-close={id}] ← × close button
|
||||
h2.text-lg.font-semibold ← title (omitted when empty)
|
||||
p.text-sm.text-muted-foreground.mt-1 ← description (omitted when empty)
|
||||
div.mt-4 ← body content
|
||||
{content}
|
||||
div.mt-6.flex.justify-end.gap-2 ← footer (omitted when empty)
|
||||
{footer}
|
||||
```csharp
|
||||
new Dialog(
|
||||
id: "about-dialog",
|
||||
title: "About this app",
|
||||
content: "<p>Version 1.0 — built with .NET 10.</p>",
|
||||
footer: """<button data-dialog-close="about-dialog">Close</button>""")
|
||||
```
|
||||
|
||||
---
|
||||
Then somewhere on the page, add a trigger:
|
||||
|
||||
## CSS mechanics
|
||||
```html
|
||||
<button data-dialog-open="about-dialog">About</button>
|
||||
```
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `dialog::backdrop` (in `input.css`) | Semi-transparent black overlay behind the dialog |
|
||||
| `animate-in fade-in-0 zoom-in-95` | CSS entry animation when dialog opens |
|
||||
| `max-w-md w-full` | Responsive: full width on small screens, capped at `md` |
|
||||
| `overflow-y-auto max-h-[90vh]` | Scrollable body for tall content |
|
||||
That's it. No JavaScript needed in your templates.
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (delegated clicks in `components.js`)
|
||||
|
||||
Set up once on `document` and works for all dialogs on the page, including those HTMX-swapped in.
|
||||
|
||||
### Open
|
||||
Any element with `data-dialog-open="myDialogId"` calls `document.getElementById('myDialogId').showModal()`.
|
||||
|
||||
### Close
|
||||
Any element with `data-dialog-close="myDialogId"` calls `document.getElementById('myDialogId').close()`.
|
||||
|
||||
The `×` close button inside the dialog panel already has `data-dialog-close` set to the dialog's id.
|
||||
|
||||
Clicking the `::backdrop` (outside the panel) also closes the dialog — the click handler checks whether the click target is the `<dialog>` element itself.
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Dialog(
|
||||
@@ -58,96 +37,76 @@ public Dialog(
|
||||
string footer = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `id` | Element id — must be unique per page; also used by `data-dialog-open` |
|
||||
| `content` | Raw HTML for the dialog body |
|
||||
| `title` | Optional heading at the top of the panel |
|
||||
| `description` | Optional subheading below the title |
|
||||
| `footer` | Optional raw HTML for the bottom button row |
|
||||
| `id` | A unique identifier. Used both on the `<dialog>` element and in `data-dialog-open`. |
|
||||
| `content` | The body of the dialog. Raw HTML. |
|
||||
| `title` | Optional bold heading at the top of the dialog panel. |
|
||||
| `description` | Optional smaller text below the title. |
|
||||
| `footer` | Optional button row at the bottom. Raw HTML. |
|
||||
|
||||
The title, description, and footer sections are omitted entirely from the HTML when not provided.
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Simple information dialog
|
||||
### Confirmation before a destructive action
|
||||
|
||||
```csharp
|
||||
// In the page component:
|
||||
Dialog = new Dialog(
|
||||
id: "about-dialog",
|
||||
title: "About BeepBoop",
|
||||
description: "A fast AOT-safe HTMX framework.",
|
||||
content: "<p>Version 1.0 — built with ❤️ and .NET 10.</p>",
|
||||
footer: """<button data-dialog-close="about-dialog" class="...">Close</button>""");
|
||||
```
|
||||
|
||||
Trigger button anywhere on the page:
|
||||
Place the dialog in the page template, then trigger it from a button:
|
||||
|
||||
```html
|
||||
<button data-dialog-open="about-dialog" class="...">About</button>
|
||||
<!-- Templates/ItemsPage.htmx -->
|
||||
$$DeleteDialog$$
|
||||
<!-- ... rest of page ... -->
|
||||
<button data-dialog-open="confirm-delete">Delete item</button>
|
||||
```
|
||||
|
||||
### Confirmation dialog
|
||||
|
||||
```csharp
|
||||
new Dialog(
|
||||
// Templates/ItemsPage.htmx.cs
|
||||
_deleteDialog = new Dialog(
|
||||
id: "confirm-delete",
|
||||
title: "Delete item",
|
||||
content: "<p>This action cannot be undone.</p>",
|
||||
footer: """
|
||||
<button data-dialog-close="confirm-delete" class="...">Cancel</button>
|
||||
<button hx-delete="/items/42" hx-confirm="" data-dialog-close="confirm-delete"
|
||||
<button data-dialog-close="confirm-delete">Cancel</button>
|
||||
<button hx-delete="/items/42" data-dialog-close="confirm-delete"
|
||||
class="bg-destructive text-destructive-foreground ...">
|
||||
Delete
|
||||
</button>
|
||||
""")
|
||||
""");
|
||||
```
|
||||
|
||||
### HTMX-powered content reload
|
||||
### Dialog that loads content on demand
|
||||
|
||||
Use HTMX's `revealed` trigger to load the dialog body only when it opens:
|
||||
|
||||
```csharp
|
||||
new Dialog(
|
||||
id: "user-detail",
|
||||
title: "User details",
|
||||
content: """<div hx-get="/users/42/detail" hx-trigger="revealed"></div>""")
|
||||
```
|
||||
|
||||
The `revealed` trigger fires when the dialog becomes visible, loading content on demand.
|
||||
### Closing from outside the dialog
|
||||
|
||||
### Embedding inside a page slot
|
||||
Any element anywhere on the page can close a dialog by setting `data-dialog-close`:
|
||||
|
||||
```html
|
||||
<!-- MyPage.htmx -->
|
||||
$$DeleteDialog$$
|
||||
<button data-dialog-open="confirm-delete" class="...">Delete</button>
|
||||
```
|
||||
|
||||
```csharp
|
||||
public IHtmxComponent DeleteDialog { get; }
|
||||
|
||||
public MyPage()
|
||||
{
|
||||
DeleteDialog = new Dialog(
|
||||
id: "confirm-delete",
|
||||
title: "Confirm deletion",
|
||||
content: "<p>Are you sure?</p>",
|
||||
footer: """<button data-dialog-close="confirm-delete">Cancel</button>""");
|
||||
}
|
||||
|
||||
protected override void RenderDeleteDialog(HtmxRenderContext ctx)
|
||||
=> DeleteDialog.Render(ctx.Next());
|
||||
<button data-dialog-close="confirm-delete">Never mind</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
## How it works
|
||||
|
||||
- The `id` is used both on the `<dialog>` element and in `data-dialog-open`/`data-dialog-close` — keep it unique per page.
|
||||
- The `×` close button is always rendered; `data-dialog-close` on footer buttons is optional but improves UX.
|
||||
- Use the native `<dialog>` `close` event for any cleanup needed after dismissal: `document.getElementById('id').addEventListener('close', fn)`.
|
||||
- Dialog content, title, description, and footer are raw HTML — HTML-encode user-supplied values.
|
||||
- For dialogs that load content via HTMX, place the HTMX attributes on a child div inside `content` rather than on the `<dialog>` element itself to avoid interfering with the dialog open/close lifecycle.
|
||||
- For dialogs that load content via HTMX, place the HTMX attributes on a child div inside `content` rather than on the `<dialog>` element itself to avoid interfering with the dialog open/close lifecycle.
|
||||
Dialog uses the native HTML `<dialog>` element with `showModal()`. A backdrop (the dark overlay) comes from the browser's built-in `::backdrop` pseudo-element, styled in `input.css`.
|
||||
|
||||
JavaScript in `components.js` listens for clicks anywhere on the page. If the clicked element has `data-dialog-open`, it calls `showModal()` on the matching dialog. If it has `data-dialog-close`, it calls `close()`. Clicking outside the dialog panel (on the backdrop) also closes it.
|
||||
|
||||
Because the listener is on `document`, dialogs that are HTMX-swapped in work automatically without any re-initialisation.
|
||||
|
||||
All `content` and `footer` strings are raw HTML — HTML-encode any user-supplied values before passing them in.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,66 +1,10 @@
|
||||
# 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
## Quick example
|
||||
|
||||
```csharp
|
||||
new DropdownMenu(
|
||||
@@ -69,24 +13,70 @@ new DropdownMenu(
|
||||
{
|
||||
("Profile", "/profile", false),
|
||||
("Settings", "/settings", false),
|
||||
("", "", true), // separator
|
||||
("", "", true), // separator line
|
||||
("Sign out", "/logout", false),
|
||||
})
|
||||
```
|
||||
|
||||
### Icon-button dropdown
|
||||
---
|
||||
|
||||
## 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/42/edit", false),
|
||||
("Delete", "/items/42/delete", false),
|
||||
})
|
||||
("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 dropdown (useful when near the right edge of the viewport)
|
||||
### 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(
|
||||
@@ -95,26 +85,15 @@ new DropdownMenu(
|
||||
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
|
||||
## How it works
|
||||
|
||||
- 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.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,35 +1,23 @@
|
||||
# FileInput
|
||||
|
||||
A styled file upload field with an optional visible label and description. Supports `accept` MIME types, multiple file selection, and HTMX attributes for server-driven interactions.
|
||||
A styled file upload field. Use it when you need users to attach files to a form — profile pictures, documents, CSV imports, and so on.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
div.flex.flex-col.gap-1.5
|
||||
label[for={id}].text-sm.font-medium ← omitted when label is empty
|
||||
{label text}
|
||||
input[type=file, id, name, accept, class, $$Multiple$$, $$HxAttrs$$]
|
||||
p.text-sm.text-muted-foreground ← omitted when description is empty
|
||||
{description text}
|
||||
```csharp
|
||||
new FileInput(
|
||||
id: "avatar",
|
||||
name: "avatar",
|
||||
accept: "image/*",
|
||||
label: "Profile picture",
|
||||
description: "PNG, JPG or GIF up to 2 MB")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `file:mr-4 file:py-2 file:px-4 file:rounded-md` | Styles the browser's "Choose file" button via `::file-selector-button` |
|
||||
| `file:border-0 file:bg-primary file:text-primary-foreground` | Gives the file button the primary color |
|
||||
| `file:text-sm file:font-semibold file:cursor-pointer` | Consistent text treatment |
|
||||
| `hover:file:bg-primary/90` | Slight darkening on hover |
|
||||
| `w-full rounded-md border border-input bg-background text-sm` | Full-width field with border |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public FileInput(
|
||||
@@ -43,71 +31,70 @@ public FileInput(
|
||||
string hxAttrs = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `id` | Element id and label `for` target |
|
||||
| `name` | Form field name |
|
||||
| `accept` | MIME types or file extensions, e.g. `"image/*"` or `".pdf,.docx"` |
|
||||
| `multiple` | Allows selecting more than one file |
|
||||
| `label` | Visible label above the field |
|
||||
| `description` | Helper text below the field |
|
||||
| `extraClasses` | Additional Tailwind classes on the input |
|
||||
| `hxAttrs` | Verbatim HTMX / data attributes appended to the input |
|
||||
| `id` | The element id. Also used by the `<label for="...">`. |
|
||||
| `name` | Form field name — required if you want the file submitted with the form. |
|
||||
| `accept` | MIME types or extensions to filter the picker, e.g. `"image/*"` or `".pdf,.docx"`. Does not validate server-side. |
|
||||
| `multiple` | Allow selecting more than one file at a time. |
|
||||
| `label` | Visible text label above the field. |
|
||||
| `description` | Hint text below the field (e.g. "Max 5 MB"). |
|
||||
| `extraClasses` | Additional Tailwind classes on the `<input>` element. |
|
||||
| `hxAttrs` | Extra HTML attributes appended verbatim (HTMX, `data-*`, etc.). |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Basic single file
|
||||
### Multiple document attachments
|
||||
|
||||
```csharp
|
||||
new FileInput(
|
||||
id: "avatar",
|
||||
name: "avatar",
|
||||
accept: "image/*",
|
||||
label: "Profile picture",
|
||||
description: "PNG, JPG or GIF up to 2 MB")
|
||||
```
|
||||
|
||||
### Multiple files
|
||||
|
||||
```csharp
|
||||
new FileInput(
|
||||
id: "attachments",
|
||||
name: "attachments",
|
||||
accept: ".pdf,.docx,.xlsx",
|
||||
multiple: true,
|
||||
label: "Attachments",
|
||||
id: "attachments",
|
||||
name: "attachments",
|
||||
accept: ".pdf,.docx,.xlsx",
|
||||
multiple: true,
|
||||
label: "Attachments",
|
||||
description: "Select one or more documents")
|
||||
```
|
||||
|
||||
### HTMX auto-upload on change
|
||||
### Auto-upload on file selection (HTMX)
|
||||
|
||||
```csharp
|
||||
new FileInput(
|
||||
id: "import-csv",
|
||||
name: "csv",
|
||||
accept: ".csv",
|
||||
label: "Import CSV",
|
||||
hxAttrs: """hx-post="/import" hx-encoding="multipart/form-data" hx-target="#result" hx-trigger="change"""")
|
||||
id: "import-csv",
|
||||
name: "csv",
|
||||
accept: ".csv",
|
||||
label: "Import CSV",
|
||||
hxAttrs: """hx-post="/import" hx-encoding="multipart/form-data" hx-target="#result" hx-trigger="change"""")
|
||||
```
|
||||
|
||||
### No label
|
||||
When using HTMX for file uploads, always include `hx-encoding="multipart/form-data"` — HTMX does not infer it from the input type.
|
||||
|
||||
### Reading uploaded files in a handler
|
||||
|
||||
```csharp
|
||||
new FileInput(id: "doc", name: "document", accept: ".pdf")
|
||||
public static IResult Handle(HttpContext ctx, IFormFile? avatar)
|
||||
{
|
||||
if (avatar is null || avatar.Length == 0)
|
||||
return Results.BadRequest("No file uploaded");
|
||||
|
||||
// validate file type server-side (accept= only filters in the browser)
|
||||
var allowed = new[] { "image/jpeg", "image/png", "image/gif" };
|
||||
if (!allowed.Contains(avatar.ContentType))
|
||||
return Results.BadRequest("Invalid file type");
|
||||
|
||||
using var stream = avatar.OpenReadStream();
|
||||
// save the file...
|
||||
return Results.Ok();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
## How it works
|
||||
|
||||
- `accept` filters in the browser's file picker dialog but does not validate server-side — always validate the uploaded file type in your handler.
|
||||
- For HTMX file uploads set `hx-encoding="multipart/form-data"` in `hxAttrs`; HTMX does not infer encoding from the input type.
|
||||
- Multiple files are bound as a list: `IFormFileCollection` or `List<IFormFile>` in the handler. `FromForm` attribute on the command record field is required.
|
||||
- To show a preview of the selected image before upload, add a small JS snippet that listens to the `change` event and sets `src` on an `<img>` element via `URL.createObjectURL(e.target.files[0])`.
|
||||
- `extraClasses` is added to the `<input>` element, not the wrapper `<div>` — use it for overriding width, borders, or custom ring colors.
|
||||
- `extraClasses` is added to the `<input>` element, not the wrapper `<div>` — use it for overriding width, borders, or custom ring colors.
|
||||
FileInput renders a standard `<input type="file">`. The browser's built-in "Choose file" button is styled using `::file-selector-button` CSS pseudo-element (via Tailwind's `file:` prefix) so it matches the rest of the UI.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+39
-52
@@ -1,36 +1,23 @@
|
||||
# Input
|
||||
|
||||
A styled text input with optional label and description. Supports all standard HTML input types and HTMX attributes.
|
||||
A styled single-line text field with an optional label and hint text below it. The workhorse of any form — use it for names, emails, passwords, search queries, or any other short text value.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
div.flex.flex-col.gap-1.5
|
||||
label[for={id}].text-sm.font-medium ← omitted when label is empty
|
||||
{label text}
|
||||
input[type, id, name, placeholder, class, $$HxAttrs$$]
|
||||
p.text-sm.text-muted-foreground ← omitted when description is empty
|
||||
{description text}
|
||||
```csharp
|
||||
new Input(
|
||||
id: "email",
|
||||
name: "email",
|
||||
inputType: "email",
|
||||
placeholder: "you@example.com",
|
||||
label: "Email address")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `flex h-10 w-full rounded-md border border-input bg-background` | Full-width 40px height field with border |
|
||||
| `px-3 py-2 text-sm` | Inner padding and text size |
|
||||
| `ring-offset-background` | Focus ring offset matches the page background |
|
||||
| `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2` | Keyboard-visible focus ring |
|
||||
| `disabled:cursor-not-allowed disabled:opacity-50` | Disabled state |
|
||||
| `placeholder:text-muted-foreground` | Placeholder inherits muted color |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Input(
|
||||
@@ -44,22 +31,22 @@ public Input(
|
||||
string hxAttrs = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `id` | Element id and label `for` target |
|
||||
| `name` | Form field name |
|
||||
| `inputType` | HTML type attribute: `text` / `email` / `password` / `number` / `search` / `tel` / `url` / `date` / `time` |
|
||||
| `placeholder` | Placeholder text |
|
||||
| `label` | Visible label above the field |
|
||||
| `description` | Helper text below the field |
|
||||
| `extraClasses` | Additional Tailwind classes on the input |
|
||||
| `hxAttrs` | Verbatim HTMX / data attributes appended to the input |
|
||||
| `id` | Element id. Also used by the `<label for="...">` so clicking the label focuses the input. |
|
||||
| `name` | Form field name — required if you want the value submitted with the form. |
|
||||
| `inputType` | HTML type: `text`, `email`, `password`, `number`, `search`, `tel`, `url`, `date`, `time`. |
|
||||
| `placeholder` | Greyed-out hint inside the field before the user types. |
|
||||
| `label` | Visible text label above the field. |
|
||||
| `description` | Small hint text below the field (e.g. "At least 8 characters"). |
|
||||
| `extraClasses` | Additional Tailwind classes on the `<input>` element. |
|
||||
| `hxAttrs` | Extra HTML attributes appended verbatim. Use for HTMX, `min`/`max`, `autocomplete`, etc. |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Email and password fields
|
||||
### Login form fields
|
||||
|
||||
```csharp
|
||||
new Input(
|
||||
@@ -78,7 +65,18 @@ new Input(
|
||||
description: "At least 8 characters")
|
||||
```
|
||||
|
||||
### Search with HTMX live search
|
||||
Reading on the server:
|
||||
|
||||
```csharp
|
||||
public record Command(
|
||||
[property: FromForm] string Email,
|
||||
[property: FromForm] string Password
|
||||
);
|
||||
```
|
||||
|
||||
### Live search with HTMX
|
||||
|
||||
This fires a GET request 300ms after the user stops typing and swaps the results in:
|
||||
|
||||
```csharp
|
||||
new Input(
|
||||
@@ -89,7 +87,9 @@ new Input(
|
||||
hxAttrs: """hx-get="/search" hx-target="#results" hx-trigger="keyup changed delay:300ms"""")
|
||||
```
|
||||
|
||||
### Number input with constraints (via extraClasses / hxAttrs)
|
||||
### Number input with min/max constraints
|
||||
|
||||
Extra HTML attributes like `min` and `max` can be passed through `hxAttrs`:
|
||||
|
||||
```csharp
|
||||
new Input(
|
||||
@@ -100,24 +100,11 @@ new Input(
|
||||
hxAttrs: """min="1" max="100" step="1"""")
|
||||
```
|
||||
|
||||
### URL input
|
||||
---
|
||||
|
||||
```csharp
|
||||
new Input(
|
||||
id: "website",
|
||||
name: "websiteUrl",
|
||||
inputType: "url",
|
||||
placeholder: "https://example.com",
|
||||
label: "Website",
|
||||
description: "Include https://")
|
||||
```
|
||||
## How it works
|
||||
|
||||
### Reading in a form handler
|
||||
|
||||
```csharp
|
||||
public record Command(
|
||||
[property: FromForm] string Email,
|
||||
[property: FromForm] string Password
|
||||
Input renders a `<div>` wrapper containing an optional `<label>`, the `<input>`, and an optional description `<p>`. The label and description elements are omitted entirely from the HTML when not provided. The `hxAttrs` string is appended verbatim inside the `<input>` tag, so any valid HTML attribute can be passed through it.
|
||||
);
|
||||
```
|
||||
|
||||
|
||||
@@ -1,38 +1,20 @@
|
||||
# Pagination
|
||||
|
||||
A page navigation row with Prev/Next links and numbered page buttons. The current page is highlighted. Links are built from a URL pattern.
|
||||
A row of numbered page links — Previous, 1, 2, 3…, Next. Use it at the bottom of a list or table when there are too many items to show all at once. You give it the current page number, the total number of pages, and a URL pattern; it builds all the links automatically.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
nav[aria-label=Pagination].flex.items-center.justify-center.gap-1
|
||||
a.pag-btn[href=prevUrl, aria-label=Previous] ← disabled styling when current=1
|
||||
svg (chevron-left)
|
||||
a.pag-btn[href=url] ← one per page in the visible window
|
||||
{pageNumber} ← current page has pag-btn-active class
|
||||
span.pag-ellipsis ← rendered when pages are skipped
|
||||
a.pag-btn[href=nextUrl, aria-label=Next] ← disabled styling when current=total
|
||||
svg (chevron-right)
|
||||
```csharp
|
||||
new Pagination(current: 3, total: 10, urlPattern: "/blog?page={0}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `pag-btn` | `inline-flex h-9 w-9 items-center justify-center rounded-md border border-input bg-background text-sm hover:bg-accent` |
|
||||
| `pag-btn-active` | `bg-primary text-primary-foreground border-primary hover:bg-primary/90` |
|
||||
| `pag-ellipsis` | `inline-flex h-9 w-9 items-center justify-center text-sm text-muted-foreground` |
|
||||
| `pointer-events-none opacity-50` | Applied to Prev when `current == 1`, to Next when `current == total` |
|
||||
|
||||
The visible page window is limited to 7 buttons maximum. For large page counts the component collapses interior pages into ellipsis spans, keeping first page, last page, and the pages immediately around `current` always visible.
|
||||
This renders a navigation row with links to pages 1–10 (with ellipsis for interior pages) and the Previous/Next arrows.
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Pagination(
|
||||
@@ -41,60 +23,61 @@ public Pagination(
|
||||
string urlPattern)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `current` | 1-based current page number |
|
||||
| `total` | Total number of pages |
|
||||
| `urlPattern` | URL template with `{0}` replaced by the page number, e.g. `"/items?page={0}"` |
|
||||
| `current` | The currently active page. 1-based (the first page is `1`). |
|
||||
| `total` | The total number of pages. |
|
||||
| `urlPattern` | A URL with `{0}` where the page number goes. E.g. `"/items?page={0}"`. |
|
||||
|
||||
The visible page window is at most 7 buttons. For large page counts, interior pages collapse into ellipsis (`…`) while the first page, last page, and pages close to `current` stay visible.
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Basic pagination
|
||||
### Basic list with pagination
|
||||
|
||||
```csharp
|
||||
new Pagination(current: 3, total: 10, urlPattern: "/blog?page={0}")
|
||||
```html
|
||||
<!-- Templates/BlogPage.htmx -->
|
||||
<div class="space-y-6 mb-10">$$Posts$$</div>
|
||||
$$Pager$$
|
||||
```
|
||||
|
||||
### Preserving query parameters
|
||||
|
||||
```csharp
|
||||
// Build the URL pattern from the current request
|
||||
var query = HttpUtility.ParseQueryString(Request.QueryString.ToString());
|
||||
query["page"] = "{0}";
|
||||
var pattern = "/search?" + query.ToString();
|
||||
|
||||
new Pagination(current: page, total: totalPages, urlPattern: pattern)
|
||||
// Templates/BlogPage.htmx.cs
|
||||
_pager = new Pagination(
|
||||
current: page,
|
||||
total: totalPages,
|
||||
urlPattern: "/blog?page={0}");
|
||||
```
|
||||
|
||||
### HTMX-powered pagination (swap content without full navigation)
|
||||
### Preserving filters and sort order across pages
|
||||
|
||||
The links are standard `<a>` tags. To intercept them with HTMX, use `hx-boost` on the container or wrap in a boosted `<div>`:
|
||||
Build the URL pattern to include any query parameters that should survive page navigation:
|
||||
|
||||
```csharp
|
||||
var urlPattern = $"/users?role={role}&sort={sort}&page={{0}}";
|
||||
new Pagination(current: page, total: totalPages, urlPattern: urlPattern)
|
||||
```
|
||||
|
||||
> Note the double braces `{{0}}` to produce a literal `{0}` after string interpolation.
|
||||
|
||||
### HTMX-powered pagination (no full page reload)
|
||||
|
||||
Wrap the pagination (and the content it controls) in a `hx-boost` container:
|
||||
|
||||
```html
|
||||
<div hx-boost="true" hx-target="#item-list" hx-push-url="true">
|
||||
$$Pagination$$
|
||||
<div id="item-list">$$Items$$</div>
|
||||
$$Pager$$
|
||||
</div>
|
||||
```
|
||||
|
||||
### Single page (hides naturally)
|
||||
|
||||
```csharp
|
||||
// When total == 1, Prev and Next are both disabled and only "1" is rendered.
|
||||
new Pagination(current: 1, total: 1, urlPattern: "/items?page={0}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
## How it works
|
||||
|
||||
- The `urlPattern` uses `string.Format`-style `{0}` — do not use `{page}` or other named placeholders.
|
||||
- Page numbers are 1-based throughout — the first page is page `1`.
|
||||
- When `total` is 0 or negative the component renders nothing — guard `total > 1` in the page if you want to hide it entirely when there is only one page.
|
||||
- To preserve sort order or filters across pages, include those values in the `urlPattern` query string.
|
||||
- For very small page counts (2–3 pages), all page buttons are shown with no ellipsis.
|
||||
- For very small page counts (2–3 pages), all page buttons are shown with no ellipsis.
|
||||
All links are plain `<a href="...">` elements — no JavaScript required. The URL for each page is built by calling `string.Format(urlPattern, pageNumber)`. When `current == 1`, the Previous link is styled as disabled (pointer-events removed, opacity reduced); same for Next when `current == total`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+31
-50
@@ -1,98 +1,79 @@
|
||||
# Progress
|
||||
|
||||
A horizontal progress bar. Value is clamped to 0–100. Three sizes control the bar height.
|
||||
A horizontal bar that fills from left to right to show how complete something is. Use it for upload progress, onboarding checklists, storage usage, or anything that has a percentage value.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
div.w-full.bg-secondary.rounded-full.overflow-hidden.{size class}
|
||||
div.bg-primary.rounded-full.h-full.transition-all[style="width: {value}%"]
|
||||
```csharp
|
||||
new Progress(value: 72)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `bg-secondary` | Neutral track color |
|
||||
| `bg-primary` | Filled indicator color |
|
||||
| `rounded-full overflow-hidden` | Pill-shaped track; fills also become pill-shaped |
|
||||
| `transition-all` | Smooth animation when `width` changes |
|
||||
|
||||
**Size classes applied to the outer track:**
|
||||
|
||||
| Size | Class | Height |
|
||||
|---|---|---|
|
||||
| `sm` | `h-1.5` | 6 px |
|
||||
| `default` | `h-2.5` | 10 px |
|
||||
| `lg` | `h-4` | 16 px |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Progress(int value, string size = "default")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `value` | Fill percentage; clamped to 0–100 |
|
||||
| `size` | `"sm"` / `"default"` / `"lg"` |
|
||||
| `value` | How filled the bar is, from 0 to 100. Values outside this range are clamped automatically. |
|
||||
| `size` | Height of the bar: `"sm"` (6px), `"default"` (10px), or `"lg"` (16px). |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Inline usage
|
||||
### Disk usage inside a Card
|
||||
|
||||
```csharp
|
||||
new Progress(value: 72)
|
||||
new Progress(value: 40, size: "sm")
|
||||
new Progress(value: 100, size: "lg")
|
||||
```
|
||||
// Pre-render the Progress bar to HTML
|
||||
var w = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
new Progress(value: usedPercent, size: "lg").Render(new HtmxRenderContext(w));
|
||||
var progressHtml = System.Text.Encoding.UTF8.GetString(w.WrittenSpan);
|
||||
|
||||
### Inside a Card
|
||||
|
||||
```csharp
|
||||
new Card(
|
||||
title: "Disk usage",
|
||||
title: "Storage",
|
||||
content: $"""
|
||||
<div class="mb-2 flex justify-between text-sm">
|
||||
<span>Used</span>
|
||||
<span>{used} GB / {total} GB</span>
|
||||
<span>{usedGb} GB / {totalGb} GB</span>
|
||||
</div>
|
||||
{progressHtml}
|
||||
""")
|
||||
```
|
||||
|
||||
(Pre-render the `Progress` to a string using `HtmxRenderContext` and `ArrayBufferWriter<byte>`.)
|
||||
### Live progress bar (HTMX polling)
|
||||
|
||||
### HTMX live update
|
||||
Wrap the component in a polling `<div>` that swaps the fragment every second:
|
||||
|
||||
```html
|
||||
<div id="progress-bar"
|
||||
hx-get="/job/42/progress"
|
||||
<div id="job-progress"
|
||||
hx-get="/jobs/42/progress"
|
||||
hx-trigger="every 1s"
|
||||
hx-swap="outerHTML">
|
||||
$$ProgressBar$$
|
||||
</div>
|
||||
```
|
||||
|
||||
The endpoint returns a partial re-render of this fragment with the updated `value`.
|
||||
The handler returns a fresh render of the component with the updated value. The `transition-all` CSS on the fill makes the change smooth.
|
||||
|
||||
### Three sizes side by side
|
||||
|
||||
```csharp
|
||||
new Progress(value: 40, size: "sm") // compact, good for table rows
|
||||
new Progress(value: 60) // standard
|
||||
new Progress(value: 80, size: "lg") // prominent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
## How it works
|
||||
|
||||
- Values below 0 are treated as 0; values above 100 are treated as 100 — no manual clamping needed.
|
||||
- Use `size: "sm"` for compact UI areas such as table rows.
|
||||
- To animate progress smoothly, let `transition-all` do the work: re-render the component via HTMX on a polling interval or push updates via SSE.
|
||||
- For an indeterminate spinner, use `Skeleton` instead (it has `animate-pulse` built in).
|
||||
- For an indeterminate spinner, use `Skeleton` instead (it has `animate-pulse` built in).
|
||||
Progress is two nested `<div>` elements. The outer one is the grey track; the inner one is the filled bar. The fill width is set as an inline `style="width: {value}%"` so no JavaScript is required. The `transition-all` class makes the bar animate smoothly when the value changes via an HTMX swap.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,37 +1,26 @@
|
||||
# RadioGroup
|
||||
|
||||
A group of radio buttons sharing the same `name` attribute. Supports horizontal or vertical layout. One option can be pre-selected.
|
||||
A set of radio buttons where only one option can be selected at a time. Use it when you want the user to pick exactly one value from a short list — pricing plans, delivery options, account types.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
div.flex.flex-col.gap-1.5
|
||||
label.text-sm.font-medium ← group label (omitted when empty)
|
||||
{label}
|
||||
div.flex.{direction}.gap-3 ← flex-col or flex-row
|
||||
label.flex.items-center.gap-2.cursor-pointer ← one per option
|
||||
input[type=radio, name, value, class, $$Checked$$]
|
||||
span.text-sm
|
||||
{option label}
|
||||
```csharp
|
||||
new RadioGroup(
|
||||
name: "plan",
|
||||
label: "Select a plan",
|
||||
options: new[]
|
||||
{
|
||||
("free", "Free", true), // pre-selected
|
||||
("pro", "Pro", false),
|
||||
("teams", "Teams", false),
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `accent-primary` | Radio circle color follows `--color-primary` CSS variable |
|
||||
| `h-4 w-4` | 16×16 radio circle |
|
||||
| `cursor-pointer` | Pointer cursor on the label |
|
||||
| `flex-col` (default) | Stacks options vertically |
|
||||
| `flex-row` | Places options side by side |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public RadioGroup(
|
||||
@@ -41,32 +30,47 @@ public RadioGroup(
|
||||
string direction = "flex-col")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `name` | Shared `name` attribute for all radio inputs in the group |
|
||||
| `options` | List of `(Value, Label, Selected)` tuples |
|
||||
| `label` | Optional visible group heading above the options |
|
||||
| `direction` | `"flex-col"` (vertical, default) or `"flex-row"` (horizontal) |
|
||||
| `name` | The shared form field name for all radio buttons in the group. |
|
||||
| `options` | The list of choices. Each is a `(Value, Label, Selected)` tuple. |
|
||||
| `label` | Optional heading displayed above the options. |
|
||||
| `direction` | `"flex-col"` stacks options vertically (default). `"flex-row"` places them side by side. |
|
||||
|
||||
**Option tuple fields:**
|
||||
|
||||
| Field | What it does |
|
||||
|---|---|
|
||||
| `Value` | The string submitted when this option is selected. |
|
||||
| `Label` | The text shown next to the radio button. |
|
||||
| `Selected` | Pre-select this option on render. Only one should be `true`. |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Vertical list
|
||||
### Pricing plan selector
|
||||
|
||||
```csharp
|
||||
new RadioGroup(
|
||||
name: "plan",
|
||||
label: "Select a plan",
|
||||
name: "plan",
|
||||
label: "Choose your plan",
|
||||
options: new[]
|
||||
{
|
||||
("free", "Free", true),
|
||||
("pro", "Pro", false),
|
||||
("teams", "Teams", false),
|
||||
("free", "Free — up to 3 projects", true),
|
||||
("pro", "Pro — unlimited projects", false),
|
||||
("enterprise", "Enterprise — custom pricing", false),
|
||||
})
|
||||
```
|
||||
|
||||
### Horizontal inline options
|
||||
Reading on the server:
|
||||
|
||||
```csharp
|
||||
public record Command([property: FromForm] string Plan);
|
||||
// command.Plan == "free" | "pro" | "enterprise"
|
||||
```
|
||||
|
||||
### Horizontal size selector
|
||||
|
||||
```csharp
|
||||
new RadioGroup(
|
||||
@@ -75,26 +79,18 @@ new RadioGroup(
|
||||
direction: "flex-row",
|
||||
options: new[]
|
||||
{
|
||||
("sm", "S", false),
|
||||
("md", "M", true),
|
||||
("lg", "L", false),
|
||||
("sm", "S", false),
|
||||
("md", "M", true),
|
||||
("lg", "L", false),
|
||||
("xl", "XL", false),
|
||||
})
|
||||
```
|
||||
|
||||
### Reading in a form handler
|
||||
|
||||
```csharp
|
||||
public record Command([property: FromForm] string Plan);
|
||||
|
||||
// command.Plan == "free" | "pro" | "teams"
|
||||
```
|
||||
|
||||
### Dynamic options from database
|
||||
### Options built dynamically from the database
|
||||
|
||||
```csharp
|
||||
var options = categories
|
||||
.Select((cat, i) => (cat.Slug, cat.Name, i == 0))
|
||||
.Select((cat, i) => (cat.Slug, cat.Name, i == 0)) // first option pre-selected
|
||||
.ToArray();
|
||||
|
||||
new RadioGroup(name: "category", label: "Category", options: options)
|
||||
@@ -102,6 +98,15 @@ new RadioGroup(name: "category", label: "Category", options: options)
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Each option is a `<label>` element containing a native `<input type="radio">` and a `<span>` with the label text. Because the `<input>` is inside the `<label>`, clicking anywhere on the label text selects the option. All radio buttons in the group share the same `name` attribute — the browser ensures only one can be selected at a time.
|
||||
|
||||
The radio dot colour follows your primary theme colour via `accent-primary`.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Only one option in the group can have `Selected = true`; if multiple are marked selected the last one wins (standard HTML behavior).
|
||||
|
||||
+66
-62
@@ -1,37 +1,28 @@
|
||||
# Select
|
||||
|
||||
A styled `<select>` dropdown. Supports a pre-selected value, optional label, and optional description text. HTMX attributes can be added.
|
||||
A styled dropdown that lets the user pick one option from a list. Use it for things like country selection, category filters, or anything where the user chooses from a fixed set of values.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
div.flex.flex-col.gap-1.5
|
||||
label[for={id}].text-sm.font-medium ← omitted when label is empty
|
||||
{label}
|
||||
select[id, name, class, $$HxAttrs$$]
|
||||
option[value, $$Selected$$] ← one per option; selected="selected" when matched
|
||||
{display}
|
||||
p.text-sm.text-muted-foreground ← omitted when description is empty
|
||||
{description}
|
||||
```csharp
|
||||
new Select(
|
||||
id: "country",
|
||||
name: "country",
|
||||
label: "Country",
|
||||
options: new[]
|
||||
{
|
||||
("us", "United States"),
|
||||
("gb", "United Kingdom"),
|
||||
("ca", "Canada"),
|
||||
},
|
||||
selectedValue: "us")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `flex h-10 w-full rounded-md border border-input bg-background` | Full-width 40px select field |
|
||||
| `px-3 py-2 text-sm` | Inner padding and text size |
|
||||
| `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring` | Keyboard focus ring |
|
||||
| `disabled:cursor-not-allowed disabled:opacity-50` | Disabled state |
|
||||
| `bg-background` | Ensures the dropdown matches the page background in dark mode |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Select(
|
||||
@@ -45,59 +36,72 @@ public Select(
|
||||
string hxAttrs = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `id` | Element id and label `for` target |
|
||||
| `options` | List of `(Value, Display)` tuples |
|
||||
| `selectedValue` | Pre-selected option value; `null` = no pre-selection (first option shown) |
|
||||
| `name` | Form field name |
|
||||
| `label` | Optional visible label |
|
||||
| `description` | Optional helper text below the field |
|
||||
| `extraClasses` | Additional Tailwind classes on the `<select>` element |
|
||||
| `hxAttrs` | Verbatim HTMX / data attributes |
|
||||
| `id` | The element id. Also used by the `<label for="...">`. |
|
||||
| `options` | The list of choices. Each is a `(Value, Display)` tuple. |
|
||||
| `selectedValue` | The `Value` of the option to pre-select. Leave `null` to show the first option. |
|
||||
| `name` | Form field name — required if you want the value submitted. |
|
||||
| `label` | Visible text label above the dropdown. |
|
||||
| `description` | Small hint text below the field. |
|
||||
| `extraClasses` | Additional Tailwind classes on the `<select>` element. |
|
||||
| `hxAttrs` | Extra HTML attributes appended verbatim — use for HTMX and `data-*`. |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Country selector
|
||||
### Category filter that reloads the list on change
|
||||
|
||||
```csharp
|
||||
new Select(
|
||||
id: "country",
|
||||
name: "country",
|
||||
label: "Country",
|
||||
id: "category",
|
||||
name: "category",
|
||||
label: "Filter by category",
|
||||
options: categories.Select(c => (c.Slug, c.Name)),
|
||||
selectedValue: currentCategory,
|
||||
hxAttrs: """hx-get="/products" hx-target="#product-list" hx-trigger="change"""")
|
||||
```
|
||||
|
||||
### Dynamic options from the database (with current value pre-selected)
|
||||
|
||||
```csharp
|
||||
var options = roles.Select(r => (r.Id.ToString(), r.Name));
|
||||
|
||||
new Select(
|
||||
id: "role",
|
||||
name: "roleId",
|
||||
label: "Role",
|
||||
options: options,
|
||||
selectedValue: user.RoleId.ToString())
|
||||
```
|
||||
|
||||
Reading on the server:
|
||||
|
||||
```csharp
|
||||
public record Command([property: FromForm] string RoleId);
|
||||
```
|
||||
|
||||
### Simple yes/no choice
|
||||
|
||||
```csharp
|
||||
new Select(
|
||||
id: "active",
|
||||
name: "isActive",
|
||||
label: "Status",
|
||||
options: new[]
|
||||
{
|
||||
("us", "United States"),
|
||||
("gb", "United Kingdom"),
|
||||
("ca", "Canada"),
|
||||
("au", "Australia"),
|
||||
("true", "Active"),
|
||||
("false", "Inactive"),
|
||||
},
|
||||
selectedValue: "us")
|
||||
selectedValue: user.IsActive ? "true" : "false")
|
||||
```
|
||||
|
||||
### Dynamic options from data
|
||||
---
|
||||
|
||||
```csharp
|
||||
var options = categories.Select(c => (c.Slug, c.Name));
|
||||
## How it works
|
||||
|
||||
new Select(
|
||||
id: "category",
|
||||
name: "category",
|
||||
label: "Category",
|
||||
options: options,
|
||||
selectedValue: existingCategory)
|
||||
```
|
||||
|
||||
### HTMX on-change reload
|
||||
|
||||
```csharp
|
||||
new Select(
|
||||
id: "region",
|
||||
name: "region",
|
||||
label: "Region",
|
||||
options: regions,
|
||||
Select renders a standard `<select>` element — no custom dropdown JavaScript. The browser's native dropdown is used, which is the most accessible and reliable approach. The selected option is matched by `Value` and has `selected="selected"` set on render.
|
||||
hxAttrs: """hx-get="/cities" hx-target="#city-select" hx-trigger="change" hx-include="[name='region']"""")
|
||||
```
|
||||
|
||||
|
||||
@@ -1,36 +1,19 @@
|
||||
# Separator
|
||||
|
||||
A thin divider line. Renders as a horizontal `<hr>` or a vertical bar depending on orientation.
|
||||
A thin dividing line. Use it to visually separate sections of a page or items in a toolbar. Like a ruled line on a notepad, it gives your layout breathing room and clarity.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
**Horizontal:**
|
||||
```
|
||||
hr.border-t.border-border.my-4.{extraClasses}
|
||||
```
|
||||
|
||||
**Vertical:**
|
||||
```
|
||||
span.inline-block.border-l.border-border.mx-2.h-4.{extraClasses}
|
||||
```csharp
|
||||
new Separator() // horizontal rule
|
||||
new Separator(orientation: "vertical") // vertical bar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `border-t border-border` | Top border in the theme's border color (horizontal) |
|
||||
| `border-l border-border` | Left border in the theme's border color (vertical) |
|
||||
| `my-4` | Default vertical margin for horizontal separators |
|
||||
| `mx-2` | Default horizontal margin for vertical separators |
|
||||
| `h-4` | 16px height for vertical separators |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Separator(
|
||||
@@ -38,46 +21,57 @@ public Separator(
|
||||
string extraClasses = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `orientation` | `"horizontal"` (default) or `"vertical"` |
|
||||
| `extraClasses` | Additional Tailwind classes on the element |
|
||||
| `orientation` | `"horizontal"` (default) renders an `<hr>`. `"vertical"` renders an inline bar. |
|
||||
| `extraClasses` | Additional Tailwind classes to override spacing or colour. |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Horizontal divider
|
||||
### Between sections on a settings page
|
||||
|
||||
```csharp
|
||||
new Separator()
|
||||
```html
|
||||
<h2 class="text-lg font-semibold">Account</h2>
|
||||
<p class="text-sm text-muted-foreground">Manage your account details.</p>
|
||||
$$Sep1$$
|
||||
<h2 class="text-lg font-semibold">Notifications</h2>
|
||||
```
|
||||
|
||||
### Vertical divider in a flex toolbar
|
||||
```csharp
|
||||
_sep1 = new Separator();
|
||||
```
|
||||
|
||||
### Vertical bar in a text editor toolbar
|
||||
|
||||
```html
|
||||
<div class="flex items-center gap-2">
|
||||
<button>Bold</button>
|
||||
$$VertSep$$
|
||||
$$Sep$$
|
||||
<button>Italic</button>
|
||||
$$VertSep$$
|
||||
$$Sep$$
|
||||
<button>Underline</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
```csharp
|
||||
var VertSep = new Separator(orientation: "vertical");
|
||||
_sep = new Separator(orientation: "vertical");
|
||||
```
|
||||
|
||||
### Custom margin
|
||||
### More or less spacing
|
||||
|
||||
```csharp
|
||||
new Separator(extraClasses: "my-8") // extra vertical space
|
||||
new Separator(extraClasses: "my-0 mt-2") // override default margin
|
||||
new Separator(extraClasses: "my-8") // extra breathing room above and below
|
||||
new Separator(extraClasses: "my-2") // tighter spacing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
A horizontal separator is an `<hr>` element with a top border. A vertical separator is an inline `<span>` with a left border and a fixed height of 16px. Both use `border-border` which follows the theme's CSS variable and adapts to dark mode automatically.
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- The horizontal `Separator` is an `<hr>` element — it carries semantic meaning as a thematic break. Use it between content sections.
|
||||
|
||||
+48
-56
@@ -1,78 +1,58 @@
|
||||
# Skeleton
|
||||
|
||||
An animated loading placeholder. Use it in place of real content while data is being fetched or rendered asynchronously. The animation communicates to the user that content is loading.
|
||||
An animated grey placeholder that pulsates while real content is loading. Think of it as a rough pencil sketch of your UI — it shows the user where something will appear so the page feels responsive even before the data is ready.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
div.animate-pulse.rounded-md.bg-muted.{classes}
|
||||
```csharp
|
||||
new Skeleton("h-4 w-3/4") // a loading line of text
|
||||
new Skeleton("h-10 w-full") // a loading input field
|
||||
new Skeleton("rounded-full h-12 w-12") // a loading avatar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `animate-pulse` | Tailwind's built-in fade-in/out animation (1.5s loop) |
|
||||
| `bg-muted` | Neutral muted background color from the theme |
|
||||
| `rounded-md` | Slightly rounded corners |
|
||||
| User-supplied `classes` | Control size and shape (e.g. `h-4 w-32`, `h-10 w-full`, `rounded-full h-12 w-12`) |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Skeleton(string classes = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `classes` | Tailwind classes controlling size, shape, and spacing |
|
||||
| `classes` | Tailwind classes that control the size and shape of the placeholder. |
|
||||
|
||||
There are no other parameters. The component itself is just an animated `<div>` — you shape it entirely through CSS classes.
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Text line placeholders
|
||||
|
||||
```csharp
|
||||
new Skeleton("h-4 w-3/4 mb-2")
|
||||
new Skeleton("h-4 w-1/2")
|
||||
```
|
||||
|
||||
### Avatar placeholder
|
||||
|
||||
```csharp
|
||||
new Skeleton("rounded-full h-12 w-12")
|
||||
```
|
||||
|
||||
### Card skeleton loader
|
||||
|
||||
```csharp
|
||||
new Card(
|
||||
content: """
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Render each Skeleton eagerly to a string or use slot injection -->
|
||||
</div>
|
||||
<div class="mt-4 space-y-2">
|
||||
</div>
|
||||
""")
|
||||
```
|
||||
|
||||
### Full-width block placeholder
|
||||
|
||||
```csharp
|
||||
new Skeleton("h-10 w-full")
|
||||
```
|
||||
|
||||
### HTMX skeleton swap pattern
|
||||
### A card loading state (avatar + two text lines)
|
||||
|
||||
```html
|
||||
<div class="flex items-center gap-4 p-6">
|
||||
$$AvatarSkeleton$$
|
||||
<div class="space-y-2 flex-1">
|
||||
$$Line1$$
|
||||
$$Line2$$
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
```csharp
|
||||
_avatarSkeleton = new Skeleton("rounded-full h-10 w-10");
|
||||
_line1 = new Skeleton("h-4 w-1/2");
|
||||
_line2 = new Skeleton("h-4 w-3/4");
|
||||
```
|
||||
|
||||
### HTMX swap: show skeleton immediately, replace with real content
|
||||
|
||||
Render the skeleton into a slot. HTMX fires immediately on page load and swaps it with the real content:
|
||||
|
||||
```html
|
||||
<!-- Shown immediately; HTMX replaces with real content -->
|
||||
<div id="user-list"
|
||||
hx-get="/users"
|
||||
hx-trigger="load"
|
||||
@@ -81,13 +61,25 @@ new Skeleton("h-10 w-full")
|
||||
</div>
|
||||
```
|
||||
|
||||
The page renders the skeleton on initial load; the HTMX request fires immediately and replaces it once the data arrives.
|
||||
The skeleton appears instantly; the data loads in the background and replaces it.
|
||||
|
||||
### A full table loading state
|
||||
|
||||
```csharp
|
||||
// Stack five skeleton rows to simulate a loading table
|
||||
var rows = string.Concat(Enumerable.Range(0, 5).Select(_ =>
|
||||
{
|
||||
var w = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
new Skeleton("h-8 w-full mb-2").Render(new HtmxRenderContext(w));
|
||||
return System.Text.Encoding.UTF8.GetString(w.WrittenSpan);
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
## How it works
|
||||
|
||||
- Multiple `Skeleton` elements stacked in a `div.space-y-2` create a convincing text-block placeholder.
|
||||
Skeleton is a single `<div>` with `animate-pulse` (Tailwind's built-in pulsing animation) and `bg-muted`. You control the shape entirely through the `classes` parameter — use `h-*` and `w-*` for size, and `rounded-full` for circular shapes like avatars.
|
||||
- `rounded-full` makes a circle — useful for avatar skeletons. Combine with equal `h-*` and `w-*` values.
|
||||
- The `classes` parameter replaces the default empty string — provide complete size + spacing classes.
|
||||
- For table skeletons, render a `Table` with each cell containing a Skeleton HTML string (pre-rendered to a string via `ArrayBufferWriter<byte>`).
|
||||
|
||||
+45
-53
@@ -1,33 +1,23 @@
|
||||
# Slider
|
||||
|
||||
A styled `<input type="range">` with optional label and description. Supports min/max/step/value and HTMX attributes.
|
||||
A draggable range control. Use it when you want the user to pick a numeric value within a range — volume, brightness, price range, font size.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
div.flex.flex-col.gap-1.5
|
||||
label[for={id}].text-sm.font-medium ← omitted when label is empty
|
||||
{label}
|
||||
input[type=range, id, name, min, max, step, value, class, $$HxAttrs$$]
|
||||
p.text-sm.text-muted-foreground ← omitted when description is empty
|
||||
{description}
|
||||
```csharp
|
||||
new Slider(
|
||||
id: "volume",
|
||||
name: "volume",
|
||||
label: "Volume")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `w-full h-2 rounded-lg appearance-none cursor-pointer accent-primary` | Full-width, pill-shaped track; thumb follows primary color |
|
||||
| `bg-secondary` | Track fill color |
|
||||
| `accent-primary` | Thumb and active track color follows `--color-primary` |
|
||||
Defaults to a 0–100 range with a starting value of 50.
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Slider(
|
||||
@@ -43,60 +33,62 @@ public Slider(
|
||||
string hxAttrs = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `id` | Element id and label `for` target |
|
||||
| `name` | Form field name |
|
||||
| `min` | Minimum value (default: 0) |
|
||||
| `max` | Maximum value (default: 100) |
|
||||
| `step` | Increment step (default: 1) |
|
||||
| `value` | Initial value (default: 50) |
|
||||
| `label` | Optional visible label |
|
||||
| `description` | Optional helper text |
|
||||
| `extraClasses` | Additional Tailwind classes on the input |
|
||||
| `hxAttrs` | Verbatim HTMX / data attributes |
|
||||
| `id` | The element id. Also used by `<label for="...">`. |
|
||||
| `name` | Form field name. |
|
||||
| `min` | Lowest selectable value. |
|
||||
| `max` | Highest selectable value. |
|
||||
| `step` | How much the value changes per tick. |
|
||||
| `value` | Starting position of the thumb. |
|
||||
| `label` | Visible text label above the slider. |
|
||||
| `description` | Small hint text below the slider. |
|
||||
| `extraClasses` | Additional Tailwind classes on the `<input>`. |
|
||||
| `hxAttrs` | Extra HTML attributes appended verbatim. |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Basic 0–100 slider
|
||||
### Brightness setting (stepped)
|
||||
|
||||
```csharp
|
||||
new Slider(
|
||||
id: "volume",
|
||||
name: "volume",
|
||||
label: "Volume")
|
||||
id: "brightness",
|
||||
name: "brightness",
|
||||
min: 10,
|
||||
max: 100,
|
||||
step: 10,
|
||||
value: 70,
|
||||
label: "Brightness",
|
||||
description: "10 to 100")
|
||||
```
|
||||
|
||||
### Fixed range with step
|
||||
|
||||
```csharp
|
||||
new Slider(
|
||||
id: "brightness",
|
||||
name: "brightness",
|
||||
min: 10,
|
||||
max: 100,
|
||||
step: 10,
|
||||
value: 70,
|
||||
label: "Brightness",
|
||||
description: "10–100")
|
||||
```
|
||||
|
||||
### Live HTMX update
|
||||
### Font size with live HTMX update
|
||||
|
||||
```csharp
|
||||
new Slider(
|
||||
id: "fontSize",
|
||||
name: "fontSize",
|
||||
min: 12,
|
||||
max: 24,
|
||||
max: 32,
|
||||
value: 16,
|
||||
label: "Font size",
|
||||
hxAttrs: """hx-post="/settings/font-size" hx-trigger="change" hx-include="[name='fontSize']"""")
|
||||
hxAttrs: """hx-post="/settings/font-size" hx-trigger="change"""")
|
||||
```
|
||||
|
||||
### Reading in a form handler
|
||||
### Reading the value in a form handler
|
||||
|
||||
```csharp
|
||||
public record Command([property: FromForm] int Volume);
|
||||
// command.Volume == 0..100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Slider renders a native `<input type="range">`. The thumb and active track colour follow your primary theme colour via `accent-primary`. No JavaScript is needed — the browser handles the drag interaction natively.
|
||||
|
||||
```csharp
|
||||
public record Command([property: FromForm] int Volume);
|
||||
|
||||
+34
-62
@@ -1,46 +1,22 @@
|
||||
# Switch
|
||||
|
||||
A toggle switch (on/off). Renders as a hidden `<input type="checkbox">` with a styled track and thumb driven by JavaScript. Fires no custom events — read the underlying checkbox value in form submissions.
|
||||
An on/off toggle that looks like a physical light switch. Use it for settings where the effect is immediate or where a simple checked/unchecked checkbox would feel too plain — "Enable notifications", "Dark mode", "Maintenance mode".
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
label[for={id}].flex.items-center.gap-3.cursor-pointer
|
||||
div.switch-root.relative.w-11.h-6.rounded-full ← outer track
|
||||
input[type=checkbox, id, name, class="sr-only", $$Checked$$] ← hidden; holds true state
|
||||
div.switch-thumb.absolute.top-0.5.left-0.5... ← animated thumb
|
||||
span.text-sm.select-none ← label text (omitted when empty)
|
||||
{label}
|
||||
```csharp
|
||||
new Switch(
|
||||
id: "notifications",
|
||||
label: "Enable notifications",
|
||||
name: "enableNotifications",
|
||||
isChecked: true)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `sr-only` | Hides the real checkbox visually but keeps it accessible |
|
||||
| `switch-root` | `bg-input` (off) / `bg-primary` (on) — toggled by JS adding `switch-on` class |
|
||||
| `switch-thumb` | `h-5 w-5 rounded-full bg-background shadow transition-transform` |
|
||||
| `translate-x-5` | Added to thumb by JS when switch is on (slides right) |
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (`initSwitch` in `components.js`)
|
||||
|
||||
Runs on `DOMContentLoaded` and `htmx:afterSwap`.
|
||||
|
||||
**Per-switch initialization:**
|
||||
|
||||
1. Guard `_switchInit` prevents double-binding
|
||||
2. Sync visual state from the hidden checkbox `checked` property on load
|
||||
3. On `label` click: toggle `checked`, toggle `switch-on` on the track, toggle `translate-x-5` on the thumb
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Switch(
|
||||
@@ -50,54 +26,50 @@ public Switch(
|
||||
bool isChecked = false)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `id` | Element id for the hidden checkbox; label's `for` attribute |
|
||||
| `label` | Optional visible text to the right of the toggle |
|
||||
| `name` | Form field name for the hidden checkbox |
|
||||
| `isChecked` | Initial on/off state |
|
||||
| `id` | The element id for the hidden checkbox. |
|
||||
| `label` | Optional text shown to the right of the toggle. |
|
||||
| `name` | Form field name — required if you want the value submitted. |
|
||||
| `isChecked` | Whether the switch is on by default. |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Basic on/off toggle
|
||||
### Preferences form with multiple toggles
|
||||
|
||||
```csharp
|
||||
new Switch(
|
||||
id: "notifications",
|
||||
label: "Enable notifications",
|
||||
name: "enableNotifications",
|
||||
isChecked: true)
|
||||
new Switch(id: "email-alerts", label: "Email alerts", name: "emailAlerts", isChecked: prefs.EmailAlerts)
|
||||
new Switch(id: "push-notifs", label: "Push notifications", name: "pushNotifs", isChecked: prefs.PushNotifs)
|
||||
new Switch(id: "weekly-summary", label: "Weekly digest", name: "weeklySummary", isChecked: prefs.WeeklySummary)
|
||||
```
|
||||
|
||||
### Toggle without label
|
||||
|
||||
```csharp
|
||||
new Switch(id: "darkMode", name: "darkMode")
|
||||
```
|
||||
|
||||
### Reading in a form handler
|
||||
Reading on the server:
|
||||
|
||||
```csharp
|
||||
public record Command(
|
||||
[property: FromForm] string? EnableNotifications = null
|
||||
[property: FromForm] string? EmailAlerts = null, // null = off
|
||||
[property: FromForm] string? PushNotifs = null,
|
||||
[property: FromForm] string? WeeklySummary = null
|
||||
);
|
||||
|
||||
bool notificationsOn = command.EnableNotifications != null;
|
||||
bool emailAlerts = command.EmailAlerts != null;
|
||||
```
|
||||
|
||||
> Like all checkboxes, an unchecked switch is not included in the form submission. Use `null` as the default in your command record.
|
||||
> **Important:** Like a checkbox, an unchecked switch is not included in the form submission. Always use `string?` (nullable) with a default of `null`.
|
||||
|
||||
### HTMX auto-save on change
|
||||
### Toggle without a label (e.g. in a table row)
|
||||
|
||||
```csharp
|
||||
// The hidden checkbox is named, so wrap in a form or use hx-include:
|
||||
new Switch(
|
||||
id: "maintenance",
|
||||
name: "maintenanceMode",
|
||||
label: "Maintenance mode",
|
||||
isChecked: currentState)
|
||||
new Switch(id: $"active-{user.Id}", name: "isActive", isChecked: user.IsActive)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Switch is a styled `<label>` wrapping a hidden `<input type="checkbox">`. JavaScript in `components.js` listens for clicks and animates the visible track and thumb. The hidden checkbox holds the actual state and is what gets submitted with a form. Because it is a real checkbox under the hood, the form submission behaviour is identical to a plain Checkbox component.
|
||||
```
|
||||
|
||||
```html
|
||||
|
||||
+40
-59
@@ -1,45 +1,25 @@
|
||||
# Table
|
||||
|
||||
A styled HTML data table with a header row, optional caption, optional footer row, and one or more data rows. All data is plain strings.
|
||||
A styled HTML table with a header row, data rows, and optional caption and footer. Use it when you have a list of items with multiple columns — user lists, order history, product inventories.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
div.overflow-auto.rounded-md.border.border-border
|
||||
table.w-full.text-sm.caption-bottom
|
||||
caption.mt-4.text-sm.text-muted-foreground ← omitted when empty
|
||||
{caption}
|
||||
thead
|
||||
tr.border-b.bg-muted/50
|
||||
th.h-12.px-4.text-left.font-medium ← one per header
|
||||
{header}
|
||||
tbody
|
||||
tr.border-b.hover:bg-muted/40 ← one per row; last row has no border
|
||||
td.p-4 ← one per cell; raw HTML
|
||||
{cell}
|
||||
tfoot ← omitted when empty
|
||||
tr
|
||||
td[colspan=N].p-4.text-muted-foreground
|
||||
{footer}
|
||||
```csharp
|
||||
new Table(
|
||||
headers: new[] { "Name", "Email", "Role" },
|
||||
rows: users.Select(u => new[]
|
||||
{
|
||||
System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
|
||||
System.Web.HttpUtility.HtmlEncode(u.Email),
|
||||
u.Role
|
||||
}))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `overflow-auto` on wrapper | Horizontal scroll on small screens |
|
||||
| `bg-muted/50` on header | Slightly tinted header row |
|
||||
| `hover:bg-muted/40` on data rows | Subtle hover highlight |
|
||||
| `border-b` on rows | Row separator lines |
|
||||
| `caption-bottom` | Caption appears below the table |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Table(
|
||||
@@ -49,33 +29,27 @@ public Table(
|
||||
string footer = "")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `headers` | Column heading strings |
|
||||
| `rows` | Each inner `IEnumerable<string>` is one row; cells are raw HTML |
|
||||
| `caption` | Optional caption below the table |
|
||||
| `footer` | Optional footer cell (spans all columns) |
|
||||
| `headers` | Column heading strings. |
|
||||
| `rows` | Each inner collection is one table row. Each string in it is a cell. Cells are raw HTML. |
|
||||
| `caption` | Optional summary text displayed below the table. |
|
||||
| `footer` | Optional footer text that spans all columns. |
|
||||
|
||||
> **HTML safety:** Cell values are inserted as raw HTML. Always use `System.Web.HttpUtility.HtmlEncode()` on any user-supplied strings before passing them in.
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Basic data table
|
||||
|
||||
```csharp
|
||||
new Table(
|
||||
headers: new[] { "Name", "Email", "Role" },
|
||||
rows: users.Select(u => new[] { u.DisplayName ?? "", u.Email, u.Role }))
|
||||
```
|
||||
|
||||
### With caption and footer
|
||||
### Table with a count caption and footer note
|
||||
|
||||
```csharp
|
||||
new Table(
|
||||
headers: new[] { "Product", "Price", "Stock" },
|
||||
rows: products.Select(p => new[]
|
||||
{
|
||||
p.Name,
|
||||
System.Web.HttpUtility.HtmlEncode(p.Name),
|
||||
$"${p.Price:F2}",
|
||||
p.Stock.ToString()
|
||||
}),
|
||||
@@ -83,14 +57,15 @@ new Table(
|
||||
footer: "Prices include VAT")
|
||||
```
|
||||
|
||||
### Cells with HTML content (e.g. badges)
|
||||
### Status column with a Badge
|
||||
|
||||
Pre-render the badge to an HTML string and embed it in the cell:
|
||||
|
||||
```csharp
|
||||
// Pre-render a Badge to HTML string
|
||||
string ActiveBadge()
|
||||
string RenderBadge(string label, string variant = "default")
|
||||
{
|
||||
var buf = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
new Badge("Active").Render(new HtmxRenderContext(buf));
|
||||
new Badge(label, variant).Render(new HtmxRenderContext(buf));
|
||||
return System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
|
||||
}
|
||||
|
||||
@@ -99,28 +74,34 @@ new Table(
|
||||
rows: users.Select(u => new[]
|
||||
{
|
||||
System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
|
||||
u.IsActive ? ActiveBadge() : ""
|
||||
u.IsActive ? RenderBadge("Active", "default") : RenderBadge("Inactive", "secondary")
|
||||
}))
|
||||
```
|
||||
|
||||
### With action buttons per row
|
||||
### Row actions with HTMX edit button
|
||||
|
||||
```csharp
|
||||
string EditBtn(string id) => $"""
|
||||
<button hx-get="/users/{id}/edit" hx-target="#modal" class="text-sm underline">Edit</button>
|
||||
""";
|
||||
string EditLink(string id) =>
|
||||
$"""<button hx-get="/users/{id}/edit" hx-target="#modal" class="text-sm underline">Edit</button>""";
|
||||
|
||||
new Table(
|
||||
headers: new[] { "Name", "Actions" },
|
||||
headers: new[] { "Name", "" },
|
||||
rows: users.Select(u => new[]
|
||||
{
|
||||
System.Web.HttpUtility.HtmlEncode(u.DisplayName ?? ""),
|
||||
EditBtn(u.Id!)
|
||||
EditLink(u.Id!)
|
||||
}))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Table wraps a standard `<table>` in an `overflow-auto` container so it scrolls horizontally on small screens. Header cells use `<th>` and data cells use `<td>`. The `caption` is rendered inside a `<caption>` element below the table; the `footer` spans all columns in a `<tfoot>` row.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- Cells are raw HTML — HTML-encode any user-supplied string values before inserting them to prevent XSS.
|
||||
|
||||
+62
-63
@@ -1,50 +1,27 @@
|
||||
# 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.
|
||||
A row of clickable tabs that each reveal different content. Only one tab is visible at a time. Think of it like a filing cabinet with labelled dividers — you flip between sections without leaving the page.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
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}
|
||||
```csharp
|
||||
new Tabs(
|
||||
id: "settings-tabs",
|
||||
tabs: new[]
|
||||
{
|
||||
("general", "General", "<p>General settings here.</p>"),
|
||||
("security", "Security", "<p>Password and 2FA here.</p>"),
|
||||
("billing", "Billing", "<p>Payment details here.</p>"),
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
The first tab is active by default.
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Tabs(
|
||||
@@ -52,52 +29,74 @@ public Tabs(
|
||||
IEnumerable<(string Id, string Label, string Content)> tabs)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `id` | A unique identifier for this tabs widget. Required if you have more than one `Tabs` on the same page. |
|
||||
| `tabs` | The list of tabs. Each is a `(Id, Label, Content)` tuple. |
|
||||
|
||||
**Tab tuple fields:**
|
||||
|
||||
| Field | What it does |
|
||||
|---|---|
|
||||
| `Id` | A unique identifier for this tab within the widget. Used internally to link the trigger to the panel. |
|
||||
| `Label` | The text shown on the tab button. |
|
||||
| `Content` | The HTML content shown when this tab is active. |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Simple tabbed content
|
||||
### User profile page with tabbed sections
|
||||
|
||||
```csharp
|
||||
new Tabs(
|
||||
id: "settings-tabs",
|
||||
id: "profile-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>"),
|
||||
("overview", "Overview", $"<p>Joined {user.CreatedAt:MMMM yyyy}</p>"),
|
||||
("activity", "Activity", activityHtml),
|
||||
("settings", "Settings", settingsFormHtml),
|
||||
})
|
||||
```
|
||||
|
||||
### HTML-rich content in a tab
|
||||
### Tab containing a full component
|
||||
|
||||
Pre-render inner components to HTML strings before embedding them:
|
||||
|
||||
```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);
|
||||
string Render(IHtmxComponent c)
|
||||
{
|
||||
var buf = new System.Buffers.ArrayBufferWriter<byte>();
|
||||
c.Render(new HtmxRenderContext(buf));
|
||||
return System.Text.Encoding.UTF8.GetString(buf.WrittenSpan);
|
||||
}
|
||||
|
||||
new Tabs(
|
||||
id: "report",
|
||||
tabs: new[]
|
||||
{
|
||||
("table", "Table", Render(new Table(headers: cols, rows: rows))),
|
||||
("summary", "Summary", summaryHtml),
|
||||
})
|
||||
```
|
||||
|
||||
### Code samples in multiple languages
|
||||
|
||||
```csharp
|
||||
new Tabs(
|
||||
id: "code-example",
|
||||
tabs: new[]
|
||||
{
|
||||
("csharp", "C#", "<pre><code>var x = 42;</code></pre>"),
|
||||
("fsharp", "F#", "<pre><code>let x = 42</code></pre>"),
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
All tab panels are present in the HTML on page load. JavaScript in `components.js` hides all but the first using the HTML `hidden` attribute. When a tab button is clicked, its matching panel has `hidden` removed and all others get it added back. No server request is made — this is pure client-side switching.
|
||||
{
|
||||
("summary", "Summary", "<p>High level numbers.</p>"),
|
||||
("detail", "Detail", tableHtml),
|
||||
|
||||
+39
-51
@@ -1,37 +1,23 @@
|
||||
# Textarea
|
||||
|
||||
A styled multi-line text input with optional label, description, default value, and HTMX attributes.
|
||||
A styled multi-line text input. Use it when you need more than a single line of text — comments, descriptions, notes, bio fields, or message composition.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
div.flex.flex-col.gap-1.5
|
||||
label[for={id}].text-sm.font-medium ← omitted when label is empty
|
||||
{label}
|
||||
textarea[id, name, placeholder, rows, class, $$HxAttrs$$]
|
||||
{defaultValue}
|
||||
p.text-sm.text-muted-foreground ← omitted when description is empty
|
||||
{description}
|
||||
```csharp
|
||||
new Textarea(
|
||||
id: "comment",
|
||||
name: "comment",
|
||||
placeholder: "Write a comment…",
|
||||
label: "Comment",
|
||||
rows: 5)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `flex min-h-[80px] w-full rounded-md border border-input bg-background` | Full-width field with minimum height |
|
||||
| `px-3 py-2 text-sm` | Inner padding and text size |
|
||||
| `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring` | Keyboard focus ring |
|
||||
| `disabled:cursor-not-allowed disabled:opacity-50` | Disabled state |
|
||||
| `placeholder:text-muted-foreground` | Muted placeholder text |
|
||||
| `resize-y` | Allows vertical resize only |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Textarea(
|
||||
@@ -46,45 +32,37 @@ public Textarea(
|
||||
int rows = 3)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `id` | Element id and label `for` target |
|
||||
| `name` | Form field name |
|
||||
| `placeholder` | Placeholder text |
|
||||
| `label` | Optional visible label |
|
||||
| `description` | Optional helper text below the field |
|
||||
| `defaultValue` | Pre-filled content of the textarea |
|
||||
| `extraClasses` | Additional Tailwind classes on the textarea |
|
||||
| `hxAttrs` | Verbatim HTMX / data attributes |
|
||||
| `rows` | Number of visible rows (default: 3) |
|
||||
| `id` | The element id. Also used by the `<label for="...">`. |
|
||||
| `name` | Form field name. |
|
||||
| `placeholder` | Greyed-out hint inside the field when it is empty. |
|
||||
| `label` | Visible text label above the field. |
|
||||
| `description` | Small hint below the field (e.g. character limits). |
|
||||
| `defaultValue` | Pre-filled content. |
|
||||
| `extraClasses` | Additional Tailwind classes on the `<textarea>`. |
|
||||
| `hxAttrs` | Extra HTML attributes appended verbatim. |
|
||||
| `rows` | How many lines tall the field is initially. Default is 3. |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Comment field
|
||||
|
||||
```csharp
|
||||
new Textarea(
|
||||
id: "comment",
|
||||
name: "comment",
|
||||
placeholder: "Write a comment…",
|
||||
label: "Comment",
|
||||
rows: 5)
|
||||
```
|
||||
|
||||
### Bio field with default value
|
||||
### Bio field (editing an existing value)
|
||||
|
||||
```csharp
|
||||
new Textarea(
|
||||
id: "bio",
|
||||
name: "bio",
|
||||
label: "Bio",
|
||||
description: "Tell us about yourself (max 280 characters)",
|
||||
defaultValue: user.Bio ?? "")
|
||||
description: "Max 280 characters",
|
||||
defaultValue: System.Web.HttpUtility.HtmlEncode(user.Bio ?? ""),
|
||||
rows: 4)
|
||||
```
|
||||
|
||||
### Auto-expand with HTMX
|
||||
### Auto-growing field (expands as the user types)
|
||||
|
||||
Pass a small `oninput` handler through `hxAttrs`:
|
||||
|
||||
```csharp
|
||||
new Textarea(
|
||||
@@ -95,9 +73,19 @@ new Textarea(
|
||||
hxAttrs: """oninput="this.style.height=''; this.style.height=this.scrollHeight+'px'"""")
|
||||
```
|
||||
|
||||
### Auto-save on input
|
||||
### Reading on the server
|
||||
|
||||
```csharp
|
||||
public record Command(
|
||||
[property: FromForm] string Bio
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Textarea renders a standard HTML `<textarea>` element. The `defaultValue` is placed between the opening and closing tags (not in `value` like an `<input>`). Always HTML-encode any user-supplied `defaultValue` before passing it in.
|
||||
new Textarea(
|
||||
id: "draft",
|
||||
name: "content",
|
||||
|
||||
@@ -1,53 +1,18 @@
|
||||
# TimePicker
|
||||
|
||||
A styled time picker. The user selects hours, minutes, and optionally AM/PM. The component always writes the selected time as `HH:MM` (24-hour) to the hidden input, regardless of whether 12-hour display mode is used. Optionally renders a visible label and description.
|
||||
A styled time selector with separate dropdowns for hours and minutes (and optionally AM/PM). The selected time is always stored in a hidden input as `HH:MM` in 24-hour format, regardless of whether you show the 12-hour display mode.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
div.flex.flex-col.gap-1.5
|
||||
label.text-sm.font-medium ← omitted when empty
|
||||
{label}
|
||||
div.flex.items-center.gap-1.rounded-md.border.border-input.bg-background.px-3.py-2
|
||||
select.timepicker-h[name={name}-h] ← hour select (1–12 or 0–23)
|
||||
span.text-muted-foreground :
|
||||
select.timepicker-m[name={name}-m] ← minute select (00–59)
|
||||
select.timepicker-ampm[name={name}-ampm] ← AM/PM (12h mode only)
|
||||
input.sr-only[type=hidden, name={name}] ← hidden input holding HH:MM
|
||||
p.text-sm.text-muted-foreground ← omitted when empty
|
||||
{description}
|
||||
```csharp
|
||||
new TimePicker(name: "startTime", label: "Start time")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `rounded-md border border-input bg-background` | Consistent styling with other form fields |
|
||||
| `sr-only` on hidden input | Hidden visually but included in form submission |
|
||||
| `appearance-none` on `<select>` elements | Removes native browser dropdown arrow for uniform style |
|
||||
| `focus:outline-none` on selects | Focus ring deferred to the wrapper `div` |
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (`syncTime` in `components.js`)
|
||||
|
||||
Runs on `DOMContentLoaded` and `htmx:afterSwap`.
|
||||
|
||||
### `syncTime(wrapper)`
|
||||
|
||||
1. Finds `.timepicker-h`, `.timepicker-m`, `.timepicker-ampm`, and the hidden `input`
|
||||
2. On any `change` event across the three visible selects:
|
||||
- Reads hour, minute, and AM/PM values
|
||||
- Converts 12h → 24h if AM/PM select is present
|
||||
- Writes `HH:MM` to the hidden input
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public TimePicker(
|
||||
@@ -58,49 +23,24 @@ public TimePicker(
|
||||
bool use12h = false)
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `name` | Form field name; hidden input gets this name, visible selects get `{name}-h`, `{name}-m`, `{name}-ampm` |
|
||||
| `selected` | Pre-selected time as `"HH:MM"` (24h format); defaults to current time |
|
||||
| `label` | Optional visible label |
|
||||
| `description` | Optional helper text |
|
||||
| `use12h` | If `true`, shows AM/PM select and hour range 1–12 |
|
||||
| `name` | Form field name. The hidden input gets this name and always holds a `HH:MM` value. The visible selects get `{name}-h`, `{name}-m`, `{name}-ampm`. |
|
||||
| `selected` | Pre-selected time as `"HH:MM"` in 24-hour format. Defaults to the current time. |
|
||||
| `label` | Visible text label above the picker. |
|
||||
| `description` | Small hint text below the picker. |
|
||||
| `use12h` | Show 12-hour mode with an AM/PM dropdown. The hidden input still stores 24h format. |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Basic time picker (24h)
|
||||
|
||||
```csharp
|
||||
new TimePicker(name: "startTime", label: "Start time")
|
||||
```
|
||||
|
||||
### 12-hour mode
|
||||
|
||||
```csharp
|
||||
new TimePicker(
|
||||
name: "meetingTime",
|
||||
label: "Meeting time",
|
||||
use12h: true)
|
||||
```
|
||||
|
||||
### Pre-selected time
|
||||
|
||||
```csharp
|
||||
new TimePicker(
|
||||
name: "alarmTime",
|
||||
selected: "07:30",
|
||||
label: "Alarm",
|
||||
use12h: true)
|
||||
```
|
||||
|
||||
### Inside a form
|
||||
### Appointment booking with start and end times
|
||||
|
||||
```html
|
||||
<!-- ScheduleForm.htmx -->
|
||||
<form method="post" action="/schedule">
|
||||
$$AntiforgeryToken$$
|
||||
<form method="post" action="/schedule" class="space-y-4">
|
||||
$$Token$$
|
||||
$$StartTime$$
|
||||
$$EndTime$$
|
||||
<button type="submit">Save</button>
|
||||
@@ -108,16 +48,40 @@ new TimePicker(
|
||||
```
|
||||
|
||||
```csharp
|
||||
public ScheduleForm()
|
||||
{
|
||||
StartTime = new TimePicker(name: "startTime", label: "Start", selected: "09:00");
|
||||
EndTime = new TimePicker(name: "endTime", label: "End", selected: "17:00");
|
||||
}
|
||||
// ScheduleForm.htmx.cs
|
||||
_startTime = new TimePicker(name: "startTime", label: "Start", selected: "09:00");
|
||||
_endTime = new TimePicker(name: "endTime", label: "End", selected: "17:00");
|
||||
```
|
||||
|
||||
**Reading the submitted values:**
|
||||
Reading on the server:
|
||||
|
||||
```csharp
|
||||
public record Command(
|
||||
[property: FromForm] string StartTime, // "HH:MM"
|
||||
[property: FromForm] string EndTime
|
||||
);
|
||||
|
||||
var start = TimeOnly.ParseExact(command.StartTime, "HH:mm");
|
||||
var end = TimeOnly.ParseExact(command.EndTime, "HH:mm");
|
||||
```
|
||||
|
||||
### 12-hour display mode with a pre-selected time
|
||||
|
||||
```csharp
|
||||
new TimePicker(
|
||||
name: "alarmTime",
|
||||
selected: "07:30",
|
||||
label: "Alarm time",
|
||||
use12h: true)
|
||||
```
|
||||
|
||||
The user sees `7:30 AM` in the dropdowns, but `07:30` is what gets submitted.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
TimePicker renders three `<select>` elements (hours, minutes, and optionally AM/PM) styled to look like a single field, plus a hidden `<input>` that holds the combined value. JavaScript in `components.js` listens for changes on any of the three selects and writes the correctly formatted `HH:MM` value to the hidden input, converting from 12h to 24h when needed.
|
||||
public record Command(
|
||||
[property: FromForm] string StartTime, // "09:00"
|
||||
[property: FromForm] string EndTime // "17:00"
|
||||
|
||||
+39
-72
@@ -1,104 +1,73 @@
|
||||
# Toast
|
||||
|
||||
A transient notification that appears in the bottom-right corner (or wherever `ToastViewport` is placed), auto-dismisses after a configurable duration, and can be dismissed manually.
|
||||
A small pop-up notification that appears in the corner of the screen, stays briefly, and then fades out on its own. Use it to give users confirmation after an action — "Saved!", "Error: could not connect", "Profile updated".
|
||||
|
||||
Toasts are triggered **client-side** via `window.showToast(...)` from JavaScript — they are not server-rendered components like most others. The `Toast` component class produces the initial toast markup for use as a static template or in the ToastViewport; in practice most toasts are created dynamically by the JS API.
|
||||
Unlike most components, toasts are triggered from **JavaScript**, not from the server-rendered template.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure (dynamically created by JS)
|
||||
|
||||
```
|
||||
div.toast[role=alert, aria-live=polite, data-variant]
|
||||
div.flex.items-start.gap-3
|
||||
div.flex-1
|
||||
p.font-medium.text-sm ← title
|
||||
p.text-sm.text-muted-foreground ← description (omitted when empty)
|
||||
button.ml-auto[aria-label=Dismiss] ← × close button
|
||||
svg (×)
|
||||
```
|
||||
|
||||
The outer `div.toast` is appended to the `ToastViewport` container by JS and removed after `duration` ms.
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `toast` | Defined in `input.css`: `w-80 rounded-lg border bg-background p-4 shadow-lg pointer-events-auto` |
|
||||
| `toast-enter` / `toast-exit` | CSS keyframe animation classes applied by JS for slide-in/fade-out |
|
||||
| `data-variant="default"` | Border `border-border` |
|
||||
| `data-variant="destructive"` | Border `border-destructive`, title `text-destructive` |
|
||||
| `data-variant="success"` | Border `border-green-500` |
|
||||
|
||||
---
|
||||
|
||||
## JavaScript (`showToast` in `components.js`)
|
||||
## Quick example
|
||||
|
||||
```js
|
||||
window.showToast({
|
||||
title: "Operation complete", // required
|
||||
description: "All items saved.", // optional
|
||||
variant: "success", // "default" | "destructive" | "success"
|
||||
duration: 4000 // milliseconds before auto-dismiss
|
||||
title: "Saved!",
|
||||
variant: "success",
|
||||
duration: 3000
|
||||
});
|
||||
```
|
||||
|
||||
**Implementation steps:**
|
||||
|
||||
1. Build the toast `div` element with the classes and markup described above
|
||||
2. Apply `toast-enter` class → CSS slide-in animation plays
|
||||
3. Append to the `ToastViewport` (`#toast-viewport` by default, or the first `.toast-viewport` found)
|
||||
4. After `duration` ms, apply `toast-exit` class → CSS fade-out animation plays
|
||||
5. After fade-out completes, remove the element from the DOM
|
||||
6. Dismiss button click runs the same fade-out + remove cycle immediately
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Toast(
|
||||
string title,
|
||||
string description = "",
|
||||
string variant = "default")
|
||||
```js
|
||||
window.showToast({
|
||||
title: string, // required
|
||||
description: string, // optional — shown below the title
|
||||
variant: string, // "default" | "destructive" | "success"
|
||||
duration: number // milliseconds before auto-dismiss (default: 4000)
|
||||
})
|
||||
```
|
||||
|
||||
The constructor builds a static initial toast element. Most use-cases call `window.showToast(...)` from JS instead.
|
||||
|
||||
| Parameter | Description |
|
||||
| Option | What it does |
|
||||
|---|---|
|
||||
| `title` | Required notification heading |
|
||||
| `description` | Optional body text |
|
||||
| `variant` | `"default"` / `"destructive"` / `"success"` |
|
||||
| `title` | The main notification text. |
|
||||
| `description` | Optional secondary text below the title. |
|
||||
| `variant` | `"default"` = neutral; `"destructive"` = red border (errors); `"success"` = green border. |
|
||||
| `duration` | How long the toast stays visible before fading out. |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Trigger from JavaScript after an HTMX event
|
||||
### Show a toast after an HTMX request completes
|
||||
|
||||
```js
|
||||
document.body.addEventListener('htmx:afterRequest', function (e) {
|
||||
if (e.detail.successful) {
|
||||
window.showToast({ title: 'Saved', variant: 'success', duration: 3000 });
|
||||
window.showToast({ title: 'Changes saved', variant: 'success', duration: 3000 });
|
||||
} else {
|
||||
window.showToast({ title: 'Error', description: 'Could not save.', variant: 'destructive' });
|
||||
window.showToast({ title: 'Something went wrong', description: 'Please try again.', variant: 'destructive' });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Trigger from a server response header
|
||||
### Trigger from the server via a response header
|
||||
|
||||
Add a response header `HX-Trigger` in your handler:
|
||||
Add an `HX-Trigger` response header in your handler to fire a custom event:
|
||||
|
||||
```csharp
|
||||
ctx.Response.Headers.Append("HX-Trigger",
|
||||
"""{"showToast":{"title":"Saved!","variant":"success","duration":3000}}""");
|
||||
"""{
|
||||
"showToast": {
|
||||
"title": "Profile updated",
|
||||
"variant": "success",
|
||||
"duration": 3000
|
||||
}
|
||||
}""");
|
||||
```
|
||||
|
||||
Client-side listener:
|
||||
Then listen for it on the client:
|
||||
|
||||
```js
|
||||
document.body.addEventListener('showToast', function (e) {
|
||||
@@ -106,17 +75,15 @@ document.body.addEventListener('showToast', function (e) {
|
||||
});
|
||||
```
|
||||
|
||||
### Server-rendered initial toast (rare)
|
||||
|
||||
```csharp
|
||||
// Used as a slot inside a page that always shows a greeting on first load:
|
||||
protected override void RenderWelcomeToast(HtmxRenderContext ctx)
|
||||
=> new Toast("Welcome back!", "Your dashboard is ready.", "success").Render(ctx.Next());
|
||||
```
|
||||
This is the cleanest pattern for server-triggered toasts — the server decides the message and variant, the client handles the display.
|
||||
|
||||
---
|
||||
|
||||
## Tips and tricks
|
||||
## How it works
|
||||
|
||||
`window.showToast` creates a new `<div>` with the toast content and appends it to the `ToastViewport` container. A CSS animation slides it in. After `duration` ms, a fade-out animation plays and then the element is removed from the DOM. The dismiss button (×) triggers the same fade-out immediately.
|
||||
|
||||
You must have a `ToastViewport` component in your layout for toasts to appear. See [ToastViewport.md](./ToastViewport.md).
|
||||
|
||||
- Always place a single `ToastViewport` in your main layout so toasts have a container to render into. See [ToastViewport.md](ToastViewport.md).
|
||||
- Use the `HX-Trigger` header pattern to trigger toasts from HTMX responses — it keeps toast logic on the server without requiring extra HTMX endpoints.
|
||||
|
||||
@@ -1,48 +1,14 @@
|
||||
# ToastViewport
|
||||
|
||||
The fixed container that holds all `Toast` notifications. Place exactly one `ToastViewport` in your main layout (e.g. `MainLayout.htmx`). The viewport is invisible when empty and stacks toasts upward as they are added.
|
||||
The fixed container where toast notifications appear. Place exactly one `ToastViewport` in your main layout — it sits in the corner of the screen and is invisible when no toasts are showing. New toasts stack upward as they are added.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
|
||||
```
|
||||
div[id={id}].toast-viewport.fixed.bottom-4.right-4.z-50.flex.flex-col-reverse.gap-2.w-80.pointer-events-none
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Class | Effect |
|
||||
|---|---|
|
||||
| `fixed bottom-4 right-4` | Anchored to the bottom-right corner of the viewport |
|
||||
| `z-50` | Floats above all other content including dialogs and dropdowns |
|
||||
| `flex flex-col-reverse gap-2` | New toasts appear on top; older ones push downward |
|
||||
| `w-80` | Matches the default toast width |
|
||||
| `pointer-events-none` | The container itself doesn't capture clicks — toasts set `pointer-events-auto` individually |
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
|
||||
```csharp
|
||||
public ToastViewport(string id = "toast-viewport")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|---|---|
|
||||
| `id` | Element id (default: `"toast-viewport"`). `components.js` queries `#toast-viewport` by default — only change this if you also update the JS lookup. |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
|
||||
### Place in MainLayout
|
||||
## Quick example
|
||||
|
||||
```html
|
||||
<!-- MainLayout.htmx -->
|
||||
<body class="...">
|
||||
<body>
|
||||
<main>$$Body$$</main>
|
||||
$$ToastViewport$$
|
||||
</body>
|
||||
@@ -50,34 +16,30 @@ public ToastViewport(string id = "toast-viewport")
|
||||
|
||||
```csharp
|
||||
// MainLayout.htmx.cs
|
||||
public IHtmxComponent ToastViewport { get; } = new ToastViewport();
|
||||
|
||||
protected override void RenderToastViewport(HtmxRenderContext ctx)
|
||||
=> ToastViewport.Render(ctx.Next());
|
||||
_toastViewport = new ToastViewport();
|
||||
```
|
||||
|
||||
### Custom id (advanced)
|
||||
That's all. Every call to `window.showToast(...)` will now display in the bottom-right corner of the screen.
|
||||
|
||||
---
|
||||
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
new ToastViewport(id: "notifications-container")
|
||||
public ToastViewport(string id = "toast-viewport")
|
||||
```
|
||||
|
||||
Then update the JS lookup:
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `id` | The element id. `components.js` looks for `#toast-viewport` by default. Only change this if you also update the JavaScript. |
|
||||
|
||||
```js
|
||||
// In components.js or a custom script:
|
||||
const viewport = document.getElementById('notifications-container');
|
||||
```
|
||||
---
|
||||
|
||||
### Custom position (bottom-left)
|
||||
## How it works
|
||||
|
||||
The position is set by Tailwind classes on the rendered element. To change position, subclass the component or pass `extraClasses` if supported, or override the `toast-viewport` class in your `input.css`:
|
||||
ToastViewport renders a single fixed `<div>` anchored to the bottom-right of the screen. It has `pointer-events: none` so it doesn't block clicks on the page behind it. Individual toasts set `pointer-events: auto` so their dismiss buttons are still clickable.
|
||||
|
||||
```css
|
||||
.toast-viewport {
|
||||
bottom: 1rem;
|
||||
left: 1rem;
|
||||
right: auto;
|
||||
Toasts are appended to this element by `window.showToast()` and removed after their duration expires.
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+38
-47
@@ -1,48 +1,24 @@
|
||||
# Tooltip
|
||||
|
||||
A text hint that appears on hover. Implemented entirely in CSS using Tailwind's `group` and `group-hover` utilities — no JavaScript required.
|
||||
A small text hint that appears when the user hovers over an element. Use it to label icon buttons, clarify abbreviations, or explain options that don't have visible text.
|
||||
|
||||
Tooltips are implemented entirely in CSS — no JavaScript required.
|
||||
|
||||
---
|
||||
|
||||
## HTML structure
|
||||
## Quick example
|
||||
|
||||
```
|
||||
span.relative.inline-flex.items-center.group
|
||||
{trigger component rendered inline}
|
||||
span.tooltip-text.absolute.z-50.px-2.py-1.text-xs.rounded.bg-foreground.text-background
|
||||
.whitespace-nowrap.pointer-events-none
|
||||
.opacity-0.group-hover:opacity-100.transition-opacity.duration-150
|
||||
.{position classes}
|
||||
{tooltip text}
|
||||
```csharp
|
||||
new Tooltip(
|
||||
text: "Delete item",
|
||||
trigger: new Button("🗑", size: "icon", variant: "ghost"))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CSS mechanics
|
||||
|
||||
| Utility | Effect |
|
||||
|---|---|
|
||||
| `group` on wrapper | Enables `group-hover:*` utilities on descendants |
|
||||
| `opacity-0` | Tooltip invisible at rest |
|
||||
| `group-hover:opacity-100` | Tooltip fades in when the wrapper (group) is hovered |
|
||||
| `transition-opacity duration-150` | 150ms fade animation |
|
||||
| `pointer-events-none` | Tooltip itself doesn't interfere with hover detection |
|
||||
| `bg-foreground text-background` | Dark-on-light / light-on-dark automatically via CSS variables |
|
||||
| `whitespace-nowrap` | Prevents the tooltip from wrapping |
|
||||
| `z-50` | Floats above surrounding content |
|
||||
|
||||
**Position classes by `position` parameter:**
|
||||
|
||||
| Position | Classes |
|
||||
|---|---|
|
||||
| `top` (default) | `bottom-full mb-1.5 left-1/2 -translate-x-1/2` |
|
||||
| `bottom` | `top-full mt-1.5 left-1/2 -translate-x-1/2` |
|
||||
| `left` | `right-full mr-1.5 top-1/2 -translate-y-1/2` |
|
||||
| `right` | `left-full ml-1.5 top-1/2 -translate-y-1/2` |
|
||||
Hover over the button and the label "Delete item" appears above it.
|
||||
|
||||
---
|
||||
|
||||
## Constructor signature
|
||||
## All the options
|
||||
|
||||
```csharp
|
||||
public Tooltip(
|
||||
@@ -51,33 +27,48 @@ public Tooltip(
|
||||
string position = "top")
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
| Parameter | What it does |
|
||||
|---|---|
|
||||
| `text` | Tooltip label (plain text; HTML not supported) |
|
||||
| `trigger` | Any `IHtmxComponent` that acts as the hover target |
|
||||
| `position` | `"top"` / `"bottom"` / `"left"` / `"right"` |
|
||||
| `text` | The tooltip text. Plain text only — no HTML. |
|
||||
| `trigger` | Any `IHtmxComponent` — this is the element the user hovers over. |
|
||||
| `position` | Where the tooltip appears: `"top"` (default), `"bottom"`, `"left"`, or `"right"`. |
|
||||
|
||||
---
|
||||
|
||||
## Usage examples
|
||||
## Real-world examples
|
||||
|
||||
### Icon button with tooltip
|
||||
### Icon buttons in a toolbar
|
||||
|
||||
```csharp
|
||||
new Tooltip(text: "Bold", trigger: new Button("B", size: "icon", variant: "ghost"))
|
||||
new Tooltip(text: "Italic", trigger: new Button("I", size: "icon", variant: "ghost"))
|
||||
new Tooltip(text: "Save", trigger: new Button("💾", size: "icon", variant: "ghost"))
|
||||
```
|
||||
|
||||
### Right-aligned tooltip (near the left edge of the UI)
|
||||
|
||||
```csharp
|
||||
new Tooltip(
|
||||
text: "Delete item",
|
||||
trigger: new Button("🗑", size: "icon", variant: "ghost"))
|
||||
text: "View help documentation",
|
||||
trigger: new Button("?", size: "icon", variant: "outline"),
|
||||
position: "right")
|
||||
```
|
||||
|
||||
### Top/bottom/left/right positions
|
||||
### Below the element
|
||||
|
||||
```csharp
|
||||
new Tooltip(text: "Above", trigger: new Button("Hover me"), position: "top")
|
||||
new Tooltip(text: "Below", trigger: new Button("Hover me"), position: "bottom")
|
||||
new Tooltip(text: "Left", trigger: new Button("Hover me"), position: "left")
|
||||
new Tooltip(text: "Right", trigger: new Button("Hover me"), position: "right")
|
||||
new Tooltip(
|
||||
text: "This cannot be undone",
|
||||
trigger: new Button("Delete", variant: "destructive"),
|
||||
position: "bottom")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
Tooltip wraps the trigger in a `<span class="group">`. The tooltip text is an absolutely positioned `<span>` inside that wrapper with `opacity-0` by default and `group-hover:opacity-100` to fade it in. Because this is pure Tailwind CSS, there is no JavaScript involved and no initialisation needed for HTMX-swapped content.
|
||||
|
||||
### Tooltip on an Avatar
|
||||
|
||||
```csharp
|
||||
|
||||
Reference in New Issue
Block a user