ee8797c142
Co-authored-by: Copilot <copilot@github.com>
216 lines
7.1 KiB
Markdown
216 lines
7.1 KiB
Markdown
# 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.
|
|
|
|
## The three component patterns
|
|
|
|
All existing components fall into one of three shapes. Pick the one that fits what you are building.
|
|
|
|
---
|
|
|
|
### Pattern A — Simple slot component
|
|
|
|
Use this when every piece of output is a plain string set from outside.
|
|
|
|
```html
|
|
<!-- Templates/Components/Badge.htmx -->
|
|
<span class="$$Classes$$">$$Label$$</span>
|
|
```
|
|
|
|
```csharp
|
|
// Templates/Components/Badge.htmx.cs
|
|
namespace Htmx.ApiDemo.Templates.Components;
|
|
|
|
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
|
|
{
|
|
"secondary" => "bg-secondary text-secondary-foreground",
|
|
"destructive" => "bg-destructive text-destructive-foreground",
|
|
"outline" => "border border-border text-foreground",
|
|
_ => "bg-primary text-primary-foreground",
|
|
};
|
|
|
|
_classesData = $"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold {variantClass}"
|
|
.ToUtf8Bytes();
|
|
}
|
|
|
|
protected override void RenderLabel(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_labelData);
|
|
protected override void RenderClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_classesData);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Pattern B — Conditionally built 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.
|
|
|
|
```html
|
|
<!-- Templates/Components/Card.htmx -->
|
|
<div class="rounded-lg border border-border bg-card text-card-foreground shadow-sm $$ExtraClasses$$">
|
|
$$Header$$
|
|
<div class="p-6 pt-0">$$Content$$</div>
|
|
$$Footer$$
|
|
</div>
|
|
```
|
|
|
|
```csharp
|
|
// Templates/Components/Card.htmx.cs
|
|
namespace Htmx.ApiDemo.Templates.Components;
|
|
|
|
public sealed class Card : CardBase
|
|
{
|
|
private readonly byte[] _extraClassesData;
|
|
private readonly byte[] _headerData;
|
|
private readonly byte[] _contentData;
|
|
private readonly byte[] _footerData;
|
|
|
|
public Card(
|
|
string content,
|
|
string title = "",
|
|
string description = "",
|
|
string footer = "",
|
|
string extraClasses = "")
|
|
{
|
|
_extraClassesData = extraClasses.ToUtf8Bytes();
|
|
_contentData = content.ToUtf8Bytes();
|
|
|
|
// Header is only rendered when a title or description is supplied
|
|
_headerData = (string.IsNullOrEmpty(title) && string.IsNullOrEmpty(description))
|
|
? []
|
|
: BuildHeader(title, description);
|
|
|
|
_footerData = string.IsNullOrEmpty(footer)
|
|
? []
|
|
: $"""<div class="flex items-center p-6 pt-0">{footer}</div>""".ToUtf8Bytes();
|
|
}
|
|
|
|
private static byte[] BuildHeader(string title, string description)
|
|
{
|
|
var sb = new System.Text.StringBuilder();
|
|
sb.Append("""<div class="flex flex-col space-y-1.5 p-6">""");
|
|
if (!string.IsNullOrEmpty(title))
|
|
sb.Append($"""<h3 class="text-2xl font-semibold leading-none tracking-tight">{title}</h3>""");
|
|
if (!string.IsNullOrEmpty(description))
|
|
sb.Append($"""<p class="text-sm text-muted-foreground">{description}</p>""");
|
|
sb.Append("</div>");
|
|
return sb.ToString().ToUtf8Bytes();
|
|
}
|
|
|
|
protected override void RenderExtraClasses(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_extraClassesData);
|
|
protected override void RenderHeader(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_headerData);
|
|
protected override void RenderContent(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_contentData);
|
|
protected override void RenderFooter(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_footerData);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Pattern C — Component slots (embedding 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.
|
|
|
|
```html
|
|
<!-- Templates/Components/MyWrapper.htmx -->
|
|
<div class="wrapper p-4">
|
|
$$Inner$$
|
|
</div>
|
|
```
|
|
|
|
```csharp
|
|
// Templates/Components/MyWrapper.htmx.cs
|
|
namespace Htmx.ApiDemo.Templates.Components;
|
|
|
|
public sealed class MyWrapper : MyWrapperBase
|
|
{
|
|
private readonly IHtmxComponent _inner;
|
|
|
|
public MyWrapper(IHtmxComponent inner)
|
|
{
|
|
_inner = inner;
|
|
}
|
|
|
|
// 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());
|
|
}
|
|
```
|
|
|
|
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
|
|
|
|
Once a component implements `IHtmxComponent`, use it from a page's code-behind by assigning an instance to an `IHtmxComponent` property and delegating `Render`:
|
|
|
|
```csharp
|
|
// inside MyPage.htmx.cs
|
|
public IHtmxComponent MyBadge { get; }
|
|
|
|
public MyPage(...)
|
|
{
|
|
MyBadge = new Badge("New", variant: "secondary");
|
|
}
|
|
|
|
protected override void RenderMyBadge(HtmxRenderContext ctx)
|
|
=> MyBadge.Render(ctx.Next());
|
|
```
|
|
|
|
The corresponding slot in `MyPage.htmx`:
|
|
|
|
```html
|
|
<div class="flex gap-2">
|
|
<span>Status:</span>
|
|
$$MyBadge$$
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
## File naming and namespace rules
|
|
|
|
| File location | Generated 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.
|
|
|
|
---
|
|
|
|
## HTML user content safety
|
|
|
|
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
|
|
// Safe — user-supplied string is encoded first
|
|
_displayNameData = System.Web.HttpUtility.HtmlEncode(userDisplayName).ToUtf8Bytes();
|
|
```
|
|
|
|
The existing `MainLayout` constructor demonstrates this for the user initials section.
|
|
|
|
---
|
|
|
|
## Checklist
|
|
|
|
- [ ] `MyComp.htmx` created in `Templates/Components/`
|
|
- [ ] `MyComp.htmx.cs` created with class inheriting `MyCompBase`
|
|
- [ ] All `$$Slot$$`s have a matching `RenderSlot` override
|
|
- [ ] User-supplied strings are HTML-encoded before `ToUtf8Bytes()`
|
|
- [ ] Build once to confirm the compiler catches any missing overrides
|