Rewrote all the docs - more noob friendly now.
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
+103
-54
@@ -1,16 +1,31 @@
|
||||
# Creating a New Component
|
||||
|
||||
Components are the reusable building blocks of the UI. They follow the same `.htmx` + `.htmx.cs` pair pattern as pages, but they live in `Templates/Components/`, implement `IHtmxComponent`, and are never responsible for HTTP routing.
|
||||
A component is a **reusable stamp**. You design the stamp once (the `.htmx` template + `.htmx.cs` class), and then press it anywhere you need that piece of UI — on multiple pages, inside other components, even multiple times on the same page.
|
||||
|
||||
## The three component patterns
|
||||
Components are identical in structure to pages, with two key differences:
|
||||
|
||||
All existing components fall into one of three shapes. Pick the one that fits what you are building.
|
||||
1. They live in `Templates/Components/` instead of `Templates/`
|
||||
2. They are never responsible for HTTP routing — they just render HTML
|
||||
|
||||
---
|
||||
|
||||
### Pattern A — Simple slot component
|
||||
## What you want to achieve
|
||||
|
||||
Use this when every piece of output is a plain string set from outside.
|
||||
By the end of this guide you will be able to build any reusable UI piece — a styled label, a card, a form field, or a wrapper that holds other components — and drop it anywhere on a page.
|
||||
|
||||
---
|
||||
|
||||
## The three patterns
|
||||
|
||||
All components fit one of three shapes. Pick the one that matches what you are building.
|
||||
|
||||
---
|
||||
|
||||
### Pattern A — A simple label or display element
|
||||
|
||||
Use this when the component just renders a styled string. It is the simplest case.
|
||||
|
||||
**Goal:** a coloured status badge you can reuse in tables, cards, and headers.
|
||||
|
||||
```html
|
||||
<!-- Templates/Components/Badge.htmx -->
|
||||
@@ -26,13 +41,11 @@ public sealed class Badge : BadgeBase
|
||||
private readonly byte[] _labelData;
|
||||
private readonly byte[] _classesData;
|
||||
|
||||
// Compute the final class string once in the constructor,
|
||||
// encode to UTF-8 bytes, never allocate again during render
|
||||
public Badge(string label, string variant = "default")
|
||||
{
|
||||
_labelData = label.ToUtf8Bytes();
|
||||
|
||||
var variantClass = variant switch
|
||||
var variantClasses = variant switch
|
||||
{
|
||||
"secondary" => "bg-secondary text-secondary-foreground",
|
||||
"destructive" => "bg-destructive text-destructive-foreground",
|
||||
@@ -40,7 +53,7 @@ public sealed class Badge : BadgeBase
|
||||
_ => "bg-primary text-primary-foreground",
|
||||
};
|
||||
|
||||
_classesData = $"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {variantClass}"
|
||||
_classesData = $"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {variantClasses}"
|
||||
.ToUtf8Bytes();
|
||||
}
|
||||
|
||||
@@ -49,15 +62,19 @@ public sealed class Badge : BadgeBase
|
||||
}
|
||||
```
|
||||
|
||||
The key principle: **all computation happens in the constructor**. By the time `RenderLabel` is called during a request, it is just writing pre-computed bytes — no string formatting, no allocations.
|
||||
|
||||
---
|
||||
|
||||
### Pattern B — Conditionally built sections
|
||||
### Pattern B — A container with optional sections
|
||||
|
||||
Use this when parts of the template are optional (e.g. a card header that only renders when a title is provided). Build the HTML string in the constructor and store as bytes; leave the byte array empty `[]` when not needed — `WriteUtf8` on an empty span is a no-op.
|
||||
Use this when parts of the component are optional — for example a card that shows a header only when a title is provided.
|
||||
|
||||
**Goal:** a card that always shows its body, but optionally shows a header and a footer.
|
||||
|
||||
```html
|
||||
<!-- Templates/Components/Card.htmx -->
|
||||
<div class="rounded-lg border border-border bg-card text-card-foreground shadow-sm $$ExtraClasses$$">
|
||||
<div class="rounded-lg border border-border bg-card shadow-sm $$ExtraClasses$$">
|
||||
$$Header$$
|
||||
<div class="p-6 pt-0">$$Content$$</div>
|
||||
$$Footer$$
|
||||
@@ -85,7 +102,8 @@ public sealed class Card : CardBase
|
||||
_extraClassesData = extraClasses.ToUtf8Bytes();
|
||||
_contentData = content.ToUtf8Bytes();
|
||||
|
||||
// Header is only rendered when a title or description is supplied
|
||||
// Build header HTML in the constructor. If there's no title/description,
|
||||
// store an empty array — writing empty bytes is a no-op.
|
||||
_headerData = (string.IsNullOrEmpty(title) && string.IsNullOrEmpty(description))
|
||||
? []
|
||||
: BuildHeader(title, description);
|
||||
@@ -116,88 +134,119 @@ public sealed class Card : CardBase
|
||||
|
||||
---
|
||||
|
||||
### Pattern C — Component slots (embedding other components)
|
||||
### Pattern C — A wrapper that holds other components
|
||||
|
||||
Use this when a slot should itself be rendered by another `IHtmxComponent`. Store the sub-component as a property and call `component.Render(context)` from the override.
|
||||
Use this when a slot should be filled by another component rather than a string.
|
||||
|
||||
**Goal:** a tooltip wrapper — the trigger is any component, and the tooltip text floats above it on hover.
|
||||
|
||||
```html
|
||||
<!-- Templates/Components/MyWrapper.htmx -->
|
||||
<div class="wrapper p-4">
|
||||
$$Inner$$
|
||||
</div>
|
||||
<!-- Templates/Components/Tooltip.htmx -->
|
||||
<span class="relative inline-flex items-center group">
|
||||
$$Trigger$$
|
||||
<span class="absolute bottom-full mb-1.5 ... opacity-0 group-hover:opacity-100">$$Text$$</span>
|
||||
</span>
|
||||
```
|
||||
|
||||
```csharp
|
||||
// Templates/Components/MyWrapper.htmx.cs
|
||||
// Templates/Components/Tooltip.htmx.cs
|
||||
namespace Htmx.ApiDemo.Templates.Components;
|
||||
|
||||
public sealed class MyWrapper : MyWrapperBase
|
||||
public sealed class Tooltip : TooltipBase
|
||||
{
|
||||
private readonly IHtmxComponent _inner;
|
||||
private readonly IHtmxComponent _trigger;
|
||||
private readonly byte[] _textData;
|
||||
|
||||
public MyWrapper(IHtmxComponent inner)
|
||||
public Tooltip(string text, IHtmxComponent trigger)
|
||||
{
|
||||
_inner = inner;
|
||||
_textData = text.ToUtf8Bytes();
|
||||
_trigger = trigger;
|
||||
}
|
||||
|
||||
// Pass context.Next() so the recursion depth counter increments;
|
||||
// the runtime throws if nesting exceeds 512 levels
|
||||
protected override void RenderInner(HtmxRenderContext ctx)
|
||||
=> _inner.Render(ctx.Next());
|
||||
protected override void RenderText(HtmxRenderContext ctx)
|
||||
=> ctx.Writer.WriteUtf8(_textData);
|
||||
|
||||
// ctx.Next() increments the nesting depth counter.
|
||||
// The runtime throws if nesting exceeds 512 levels — this is the guard against infinite loops.
|
||||
protected override void RenderTrigger(HtmxRenderContext ctx)
|
||||
=> _trigger.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
The depth guard (`context.Next()`) is automatically enforced by the infrastructure generated in `HtmxInfrastructure.g.cs`. You do not need to check it yourself.
|
||||
|
||||
---
|
||||
|
||||
## Embedding a component in a page
|
||||
## Using a component inside a page
|
||||
|
||||
Once a component implements `IHtmxComponent`, use it from a page's code-behind by assigning an instance to an `IHtmxComponent` property and delegating `Render`:
|
||||
Once you have a component, use it from a page's code-behind. The page stores the component as a field and delegates `Render` from its slot override:
|
||||
|
||||
```csharp
|
||||
// inside MyPage.htmx.cs
|
||||
public IHtmxComponent MyBadge { get; }
|
||||
|
||||
public MyPage(...)
|
||||
// MyPage.htmx.cs
|
||||
public sealed class MyPage : MyPageBase
|
||||
{
|
||||
MyBadge = new Badge("New", variant: "secondary");
|
||||
}
|
||||
private readonly byte[] _headingData;
|
||||
private readonly IHtmxComponent _statusBadge;
|
||||
|
||||
protected override void RenderMyBadge(HtmxRenderContext ctx)
|
||||
=> MyBadge.Render(ctx.Next());
|
||||
public MyPage(string heading, string status)
|
||||
{
|
||||
_headingData = heading.ToUtf8Bytes();
|
||||
_statusBadge = new Badge(status, variant: "secondary");
|
||||
}
|
||||
|
||||
protected override void RenderHeading(HtmxRenderContext ctx)
|
||||
=> ctx.Writer.WriteUtf8(_headingData);
|
||||
|
||||
protected override void RenderStatusBadge(HtmxRenderContext ctx)
|
||||
=> _statusBadge.Render(ctx.Next());
|
||||
}
|
||||
```
|
||||
|
||||
The corresponding slot in `MyPage.htmx`:
|
||||
Template:
|
||||
|
||||
```html
|
||||
<div class="flex gap-2">
|
||||
<span>Status:</span>
|
||||
$$MyBadge$$
|
||||
<!-- MyPage.htmx -->
|
||||
<div class="flex items-center gap-3">
|
||||
<h1>$$Heading$$</h1>
|
||||
$$StatusBadge$$
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File naming and namespace rules
|
||||
## A note on HTML safety
|
||||
|
||||
| File location | Generated namespace |
|
||||
`WriteUtf8` writes raw bytes directly to the HTTP response. It does **not** HTML-encode anything.
|
||||
|
||||
- Strings you write in the constructor that come from your own code are fine — you control them.
|
||||
- Any value that comes from user input (a form field, a database value, a query parameter) **must be HTML-encoded before calling `ToUtf8Bytes()`**:
|
||||
|
||||
```csharp
|
||||
// Safe — encodes characters like < > " &
|
||||
_nameData = System.Web.HttpUtility.HtmlEncode(userInput).ToUtf8Bytes();
|
||||
```
|
||||
|
||||
Skipping this step is a cross-site scripting (XSS) vulnerability.
|
||||
|
||||
---
|
||||
|
||||
## File location and namespace
|
||||
|
||||
| File location | C# namespace |
|
||||
|---|---|
|
||||
| `Templates/Components/MyComp.htmx` | `Htmx.ApiDemo.Templates.Components` |
|
||||
| `Templates/MyPage.htmx` | `Htmx.ApiDemo.Templates` |
|
||||
|
||||
The source generator derives the namespace from the folder path relative to the project root. Keep components in `Templates/Components/` so they land in the right namespace and stay separate from page templates.
|
||||
The source generator derives the namespace from the folder path relative to the project root. Always keep components in `Templates/Components/`.
|
||||
|
||||
---
|
||||
|
||||
## HTML user content safety
|
||||
## Checklist
|
||||
|
||||
The `WriteUtf8` method writes raw bytes directly to the response. **It does not HTML-encode.**
|
||||
|
||||
- Static strings you write in the constructor are trusted — you control them.
|
||||
- Any value that comes from user input (e.g. a form field, a database string) **must be HTML-encoded before calling `ToUtf8Bytes()`**.
|
||||
|
||||
```csharp
|
||||
- [ ] `.htmx` template created in `Templates/Components/` with `$$PascalCase$$` slots
|
||||
- [ ] `.htmx.cs` class inherits the generated `XxxBase` class
|
||||
- [ ] All `RenderXxx` overrides implemented
|
||||
- [ ] Computation (string building, class selection) done in the constructor
|
||||
- [ ] User-provided strings HTML-encoded before `ToUtf8Bytes()`
|
||||
- [ ] Sub-component `Render` calls use `ctx.Next()` not bare `ctx`
|
||||
// Safe — user-supplied string is encoded first
|
||||
_displayNameData = System.Web.HttpUtility.HtmlEncode(userDisplayName).ToUtf8Bytes();
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user