diff --git a/.gitignore b/.gitignore
index f9985d0..9070dda 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,7 +14,8 @@ x86/
.vs/
.vscode/
.idea/
-.claude/ # local Claude Code launch config, per-machine
+# local Claude Code launch config, per-machine
+.claude/
*.user
*.suo
diff --git a/Directory.Build.props b/Directory.Build.props
index 5fa7c8e..d1a13bc 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,6 +1,5 @@
-
latest
enable
@@ -15,7 +14,6 @@
0.1.0-alpha
-
shellui-dev
ShellUI
@@ -28,7 +26,6 @@
README.md
-
true
snupkg
@@ -42,15 +39,11 @@
false
-
-
-
-
-
+
diff --git a/README.md b/README.md
index d32b9d8..0c8758c 100644
--- a/README.md
+++ b/README.md
@@ -37,37 +37,120 @@ Not yet published. During development:
```
-Typed icons (tree-shakeable) — import the `ShellIcons.Icons` namespace **per page** (imports live in a sub-namespace so common names like `Router` or `Activity` don't collide with Blazor/system types unless you opt in):
+### Pick your API
+
+Four ways to render an icon — all produce identical SVG. Only the first two are safe to use next to a UI kit.
+
+| API | Import | Collides with UI kits? | Tree-shakeable? | Use for |
+|---|---|---|---|---|
+| **` `** suffixed component | `@using ShellIcons` | ✅ No | ✅ Yes | **Default for markup.** Reads as an icon, full parameter binding. |
+| **`@Icon.ChevronRight()`** factory | `@using ShellIcons` | ✅ No | ✅ Yes | **Icons as values** — `RenderFragment` parameters, nav-item lists, configs. |
+| ` ` dispatcher | `@using ShellIcons` | ✅ No | ❌ Roots all 1,555 | Names only known at runtime (CMS, JSON, markdown). |
+| ` ` flat component | `@using ShellIcons.Icons` | ⚠️ Yes — RZ9985 on Badge, Table, Menu, Router, Card… | ✅ Yes | Icon-only files with no UI kit imported. |
+
+One `@using ShellIcons` in `_Imports.razor` gives you the first three.
+
+#### ` ` — suffixed components (recommended for markup)
+
+Every icon is also emitted as `{Name}Icon` in the root `ShellIcons` namespace. The suffix keeps names clear of UI-kit components and tells readers "this is an icon" at a glance — the same alias `lucide-react` ships.
```razor
-@using ShellIcons.Icons
+@using ShellIcons
-
-
-
+
+
+
+
```
-Dispatcher (ships the full 1555-icon catalog — trimmer-hostile, use for dynamic lookups) — lives in `ShellIcons`:
+One exception: Lucide's `shell` icon would be `ShellIcon`, which is the dispatcher, so it has no suffixed form — use `@Icon.Shell()`.
+
+#### `@Icon.ChevronRight()` — factory (icons as values)
+
+Every icon has a static method on `Icon` returning a `RenderFragment`. Use it wherever an icon is *data* rather than markup:
```razor
-@using ShellIcons
+@* A component parameter typed RenderFragment *@
+
+
+@* A list built in C# *@
+@code {
+ private readonly (string Label, RenderFragment Icon)[] _items =
+ [
+ ("Inbox", Icon.Inbox()),
+ ("Settings", Icon.Settings(size: "16")),
+ ];
+}
+```
+A call with no arguments returns a cached fragment, so `@Icon.Plus()` in a hot render path doesn't allocate.
+
+#### ` ` — dispatcher
+
+Runtime lookup by kebab-case name, for icons whose identity is data-driven. Referencing it roots the full catalog, so prefer the two forms above when the name is known at compile time.
+
+```razor
```
-Colors inherit from CSS:
+#### ` ` — flat components (⚠️ RZ9985 trap)
+
+`@using ShellIcons.Icons` puts all 1,555 unprefixed names into your tag lookup. With a UI kit that has `Badge`, `Table`, `Router`, etc., you get RZ9985 collisions.
+
+**Workarounds that don't work** (verified by real integrators):
+
+- `@using Icon = ShellIcons.Icons` then ` ` — Razor's tag matcher ignores namespace aliases; the tag compiles as an unknown HTML element and **renders blank**.
+- Type aliases (`@using Badge = MyApp.UI.Badge`) — ignored by the tag matcher too.
+
+Use the suffixed components instead, or fully qualify: ` `.
+
+### Using icons inside component libraries
+
+**Buttons and similar containers — just put the icon in the content.** No `Icon` slot needed; the component's CSS sizes child SVGs (shadcn's `[&_svg]:size-4`):
-```html
-
-
-
+```razor
+ Next
+```
+
+**Components that place the icon somewhere specific** (input adornments, alerts, nav items) take a `RenderFragment` parameter — pass the factory:
+
+```razor
+
+```
+
+### Click handlers
+
+Put handlers on a wrapping `` — screen readers expect that. If you must attach one to the icon itself, drop the `@`:
+
+```razor
+ @* ✅ recommended *@
+ @* ✅ works *@
+ @* ❌ throws — see below *@
+```
+
+On a *component*, Razor passes `@onclick="Save"` as a plain string named `@onclick`; the browser then rejects that attribute name and the render batch fails. ShellIcons throws a clear `InvalidOperationException` instead. For the factory, the dictionary key is `"onclick"` (no `@`):
+
+```razor
+@Icon.Bell(additionalAttributes: new Dictionary
+{
+ ["onclick"] = EventCallback.Factory.Create(this, HandleClick),
+ ["data-testid"] = "notifications",
+})
+```
+
+### Colors and accessibility
+
+Colors inherit from CSS `color` (`stroke="currentColor"`):
+
+```razor
+
```
-Accessibility:
+Without `Title` icons are decorative (`aria-hidden="true"`); with it they get `role="img"` + ``:
```razor
-
+
```
## Repo layout
diff --git a/docs/ShellIcons.Docs/Components/Pages/IconsPage.razor b/docs/ShellIcons.Docs/Components/Pages/IconsPage.razor
index a7cd394..436bc5d 100644
--- a/docs/ShellIcons.Docs/Components/Pages/IconsPage.razor
+++ b/docs/ShellIcons.Docs/Components/Pages/IconsPage.razor
@@ -175,7 +175,7 @@
raf = requestAnimationFrame(filter);
});
- // Focus the search when user starts typing anywhere on the page
+ // "/" focuses the search.
document.addEventListener('keydown', (e) => {
if (e.key === '/' && document.activeElement !== input) {
e.preventDefault();
@@ -187,7 +187,8 @@
function copyIconSnippet(cell) {
const name = cell.dataset.name;
const pascal = name.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join('');
- const snippet = '<' + pascal + ' />';
+ // `shell` has no suffixed form (ShellIcon is the dispatcher).
+ const snippet = name === 'shell' ? '@Icon.Shell()' : '<' + pascal + 'Icon />';
navigator.clipboard?.writeText(snippet).then(() => {
const toast = document.getElementById('copy-toast');
toast.textContent = 'Copied: ' + snippet;
diff --git a/docs/ShellIcons.Docs/content/docs/getting-started/installation.md b/docs/ShellIcons.Docs/content/docs/getting-started/installation.md
index 4260db6..c791bf6 100644
--- a/docs/ShellIcons.Docs/content/docs/getting-started/installation.md
+++ b/docs/ShellIcons.Docs/content/docs/getting-started/installation.md
@@ -20,36 +20,30 @@ That's the whole install. No CSS import, no JS reference, no config file.
- **`net9.0`**
- **`net10.0`** works via forward-compat
-## Namespace layout
+## Import
-ShellIcons splits into two namespaces so importing them globally doesn't collide with anything Blazor or the BCL ships:
-
-| Namespace | What lives there | When to import |
-|---|---|---|
-| `ShellIcons` | `IconCore`, `ShellIcon` dispatcher | Import globally in `_Imports.razor` |
-| `ShellIcons.Icons` | All 1,555 typed icon components | Import **per page** — protects Blazor's `Router`, `System.Diagnostics.Activity`, `List`, etc. from being shadowed |
-
-**Global** — add to `Components/_Imports.razor`:
+Add one line to `Components/_Imports.razor`:
```razor
@using ShellIcons
```
-**Per page** — at the top of any `.razor` file that uses typed icons:
+That brings in everything you normally need, none of which collides with UI-kit components:
-```razor
-@page "/dashboard"
-@using ShellIcons.Icons
+| Form | Example | Use for |
+|---|---|---|
+| Suffixed components | ` ` | Markup — the default |
+| Factory | `@Icon.ChevronRight()` | Icons as values (`RenderFragment` parameters, lists) |
+| Dispatcher | ` ` | Names only known at runtime |
-
-```
+The unsuffixed components (` `) live in `ShellIcons.Icons`. Don't import that namespace globally: its 1,555 short names collide with UI-kit components such as `Badge`, `Table` and `Router` (RZ9985). See [Typed vs dispatcher](/docs/guides/typed-vs-dispatcher).
## Verify
Drop this anywhere and run:
```razor:preview
-
+
```
If you see a lightning bolt above, you're wired.
diff --git a/docs/ShellIcons.Docs/content/docs/getting-started/quick-start.md b/docs/ShellIcons.Docs/content/docs/getting-started/quick-start.md
index 6c99b2c..ff60cb6 100644
--- a/docs/ShellIcons.Docs/content/docs/getting-started/quick-start.md
+++ b/docs/ShellIcons.Docs/content/docs/getting-started/quick-start.md
@@ -12,28 +12,33 @@ order: 2
dotnet add package ShellIcons.Blazor
```
-## 2. Import the icons namespace
+## 2. Import the namespace
-At the top of any `.razor` page that will render icons:
+Once, in `Components/_Imports.razor`:
```razor
-@page "/"
-@using ShellIcons.Icons
+@using ShellIcons
```
## 3. Render icons
-Every icon is a typed component. IntelliSense the name you want — `
-
-
-
-
+
+
+
+
```
+When the icon is a *value* — a `RenderFragment` parameter on another component, or an item in a list — use the factory instead:
+
+```razor
+
+```
+
## 4. Size and stroke
```razor:preview
diff --git a/docs/ShellIcons.Docs/content/docs/guides/typed-vs-dispatcher.md b/docs/ShellIcons.Docs/content/docs/guides/typed-vs-dispatcher.md
index 299a472..8c62ff8 100644
--- a/docs/ShellIcons.Docs/content/docs/guides/typed-vs-dispatcher.md
+++ b/docs/ShellIcons.Docs/content/docs/guides/typed-vs-dispatcher.md
@@ -8,6 +8,8 @@ order: 5
ShellIcons ships two ways to render an icon. Both produce identical HTML. The difference is at build time.
+> **Which typed form?** The examples below use the flat ` ` names from `ShellIcons.Icons`. In an app that also uses a UI kit, use the suffixed twins from `ShellIcons` instead — ` ` — or the factory `@Icon.ChevronRight()`. They tree-shake the same way, and they can't collide with UI-kit components like `Badge` or `Table` (RZ9985).
+
## Typed form — the default
```razor
diff --git a/src/ShellIcons.Blazor/IconCore.cs b/src/ShellIcons.Blazor/IconCore.cs
index a1cf23c..5fbf601 100644
--- a/src/ShellIcons.Blazor/IconCore.cs
+++ b/src/ShellIcons.Blazor/IconCore.cs
@@ -29,7 +29,10 @@ public abstract class IconCore : ComponentBase
/// Accessible title. When set, the icon is announced by screen readers via <title> + role="img" + aria-labelledby . When null, the icon is decorative and gets aria-hidden="true" .
[Parameter] public string? Title { get; set; }
- /// Extra attributes forwarded to the root <svg> element (e.g. style , @onclick ).
+ ///
+ /// Extra attributes forwarded to the root <svg> (e.g. style , data-* ). Pass handlers
+ /// without the @ — onclick="@(() => Handler())" — or put them on a wrapping <button> .
+ ///
[Parameter(CaptureUnmatchedValues = true)]
public IReadOnlyDictionary? AdditionalAttributes { get; set; }
@@ -70,7 +73,10 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
}
if (AdditionalAttributes is not null)
+ {
+ ThrowOnEventDirectiveMisuse(AdditionalAttributes);
builder.AddMultipleAttributes(14, AdditionalAttributes);
+ }
if (Title is not null)
{
@@ -85,6 +91,22 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.CloseElement();
}
+ /* On a component, Razor passes @onclick="Save" as a string attribute named "@onclick";
+ the browser's setAttribute rejects that name and the render batch fails. */
+ private void ThrowOnEventDirectiveMisuse(IReadOnlyDictionary attributes)
+ {
+ foreach (var key in attributes.Keys)
+ {
+ if (key.StartsWith("@on", StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"'{key}' on icon '{IconName}' has no effect: on a component Razor passes it as a plain string, " +
+ $"not an event handler. Put the handler on a wrapping (recommended for accessibility), " +
+ $"or pass it without the @: {key[1..]}=\"@(() => Handler())\".");
+ }
+ }
+ }
+
private string ComputeStrokeWidth()
{
if (!AbsoluteStroke)
diff --git a/src/ShellIcons.Blazor/IconFragment.cs b/src/ShellIcons.Blazor/IconFragment.cs
new file mode 100644
index 0000000..85cc61c
--- /dev/null
+++ b/src/ShellIcons.Blazor/IconFragment.cs
@@ -0,0 +1,47 @@
+using Microsoft.AspNetCore.Components;
+
+namespace ShellIcons;
+
+// Shared body of the generated Icon.* factory methods, so each of them stays a one-liner.
+internal static class IconFragment
+{
+ /* All-default calls return a cached fragment (no allocation per render); otherwise only
+ non-default arguments are passed as parameters. */
+ public static RenderFragment Create(
+ string size,
+ double strokeWidth,
+ bool absoluteStroke,
+ string? cssClass,
+ string? title,
+ IReadOnlyDictionary? additionalAttributes)
+ where T : IconCore
+ {
+ if (size == "24" && strokeWidth == 2 && !absoluteStroke
+ && cssClass is null && title is null && additionalAttributes is null)
+ {
+ return Cache.Default;
+ }
+
+ return builder =>
+ {
+ builder.OpenComponent(0);
+ if (size != "24") builder.AddAttribute(1, nameof(IconCore.Size), size);
+ if (strokeWidth != 2) builder.AddAttribute(2, nameof(IconCore.StrokeWidth), strokeWidth);
+ if (absoluteStroke) builder.AddAttribute(3, nameof(IconCore.AbsoluteStroke), true);
+ if (cssClass is not null) builder.AddAttribute(4, nameof(IconCore.Class), cssClass);
+ if (title is not null) builder.AddAttribute(5, nameof(IconCore.Title), title);
+ if (additionalAttributes is not null) builder.AddMultipleAttributes(6, additionalAttributes);
+ builder.CloseComponent();
+ };
+ }
+
+ // Generic holder: a cache entry exists only for icons actually used, so the trimmer can drop the rest.
+ private static class Cache where T : IconCore
+ {
+ public static readonly RenderFragment Default = builder =>
+ {
+ builder.OpenComponent(0);
+ builder.CloseComponent();
+ };
+ }
+}
diff --git a/src/ShellIcons.Generator/IconGenerator.cs b/src/ShellIcons.Generator/IconGenerator.cs
index a901b92..b5d84ce 100644
--- a/src/ShellIcons.Generator/IconGenerator.cs
+++ b/src/ShellIcons.Generator/IconGenerator.cs
@@ -7,17 +7,8 @@
namespace ShellIcons.Generator;
-///
-/// Emits one {PascalName}.g.cs per SVG icon file passed in via AdditionalFiles ,
-/// plus a ShellIcon.g.cs dispatcher that looks up icons by kebab-case name.
-///
-/// Two source packs are supported by convention on file path:
-///
-/// files under catalog/lucide/icons/ are tagged lucide
-/// files under catalog/custom/icons/ are tagged custom
-///
-/// On name collision, custom wins and a SHELLICONS001 diagnostic is emitted.
-///
+/* Emits typed components, their {Name}Icon aliases, the ShellIcon dispatcher and the Icon factory
+ from catalog/lucide and catalog/custom SVGs. A custom icon overrides a Lucide one of the same name. */
[Generator]
public sealed class IconGenerator : IIncrementalGenerator
{
@@ -32,6 +23,20 @@ public sealed class IconGenerator : IIncrementalGenerator
defaultSeverity: DiagnosticSeverity.Info,
isEnabledByDefault: true);
+ private static readonly DiagnosticDescriptor SuffixReservedDescriptor = new(
+ id: "SHELLICONS003",
+ title: "Suffixed icon component skipped",
+ messageFormat: "Icon '{0}' has no <{1} /> suffixed component because '{1}' is a reserved ShellIcons type; use Icon.{2}() or instead",
+ category: "ShellIcons",
+ defaultSeverity: DiagnosticSeverity.Info,
+ isEnabledByDefault: true);
+
+ // Existing root-namespace types a {Name}Icon alias would clash with (Lucide's `shell` → ShellIcon).
+ private static readonly HashSet ReservedRootNames = new(System.StringComparer.Ordinal)
+ {
+ "ShellIcon", "Icon", "IconCore", "IconFragment",
+ };
+
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var svgs = context.AdditionalTextsProvider
@@ -85,7 +90,9 @@ private static void EmitAll(SourceProductionContext ctx, System.Collections.Immu
foreach (var d in ordered)
ctx.AddSource($"Icons/{d.PascalName}.g.cs", SourceText.From(EmitTypedComponent(d), Encoding.UTF8));
+ ctx.AddSource("SuffixedIcons.g.cs", SourceText.From(EmitSuffixedComponents(ctx, ordered), Encoding.UTF8));
ctx.AddSource("ShellIcon.g.cs", SourceText.From(EmitDispatcher(ordered), Encoding.UTF8));
+ ctx.AddSource("Icon.g.cs", SourceText.From(EmitFactoryDispatcher(ordered), Encoding.UTF8));
}
private static string EmitTypedComponent(IconDefinition d)
@@ -99,7 +106,7 @@ private static string EmitTypedComponent(IconDefinition d)
namespace {{IconsNamespace}};
/// Icon {{d.KebabName}} (source: {{d.SourcePack}}).
- public sealed class {{d.PascalName}} : global::ShellIcons.IconCore
+ public class {{d.PascalName}} : global::ShellIcons.IconCore
{
///
protected override string IconName => "{{d.KebabName}}";
@@ -111,6 +118,33 @@ protected override void EmitChildren(RenderTreeBuilder builder, int seq) =>
""";
}
+ private static string EmitSuffixedComponents(SourceProductionContext ctx, IconDefinition[] defs)
+ {
+ /* The suffix keeps names clear of UI-kit components (RZ9985) under a single @using ShellIcons.
+ Empty subclasses, so they cost metadata only. */
+ var sb = new StringBuilder();
+ sb.AppendLine("// ");
+ sb.AppendLine("#nullable enable");
+ sb.AppendLine();
+ sb.AppendLine($"namespace {DispatcherNamespace};");
+
+ foreach (var d in defs)
+ {
+ var suffixed = d.PascalName + "Icon";
+ if (ReservedRootNames.Contains(suffixed))
+ {
+ ctx.ReportDiagnostic(Diagnostic.Create(SuffixReservedDescriptor, Location.None, d.KebabName, suffixed, d.PascalName));
+ continue;
+ }
+
+ sb.AppendLine();
+ sb.AppendLine($"/// Icon {d.KebabName} (source: {d.SourcePack}). Suffixed alias of . ");
+ sb.AppendLine($"public sealed class {suffixed} : global::{IconsNamespace}.{d.PascalName} {{ }}");
+ }
+
+ return sb.ToString();
+ }
+
private static string EmitDispatcher(IconDefinition[] defs)
{
var sb = new StringBuilder();
@@ -123,14 +157,8 @@ private static string EmitDispatcher(IconDefinition[] defs)
sb.AppendLine();
sb.AppendLine($"namespace {DispatcherNamespace};");
sb.AppendLine();
- sb.AppendLine("/// ");
- sb.AppendLine("/// Dynamic icon dispatcher. Renders any icon in the catalog by kebab-case name:");
- sb.AppendLine("/// <ShellIcon Name=\"chevron-right\" /> .");
- sb.AppendLine("/// ");
- sb.AppendLine("/// Trimmer-hostile — referencing this component roots the full catalog. For tree-shakeable");
- sb.AppendLine("/// icons, use the typed form (e.g. <ChevronRight /> ).");
- sb.AppendLine("/// ");
- sb.AppendLine("/// ");
+ sb.AppendLine("/// Renders any icon by kebab-case name, e.g. <ShellIcon Name=\"chevron-right\" /> .");
+ sb.AppendLine("/// Referencing it keeps the whole catalog; prefer the typed components when the name is known. ");
sb.AppendLine("public sealed class ShellIcon : IconCore");
sb.AppendLine("{");
sb.AppendLine(" /// Kebab-case name of the icon to render (e.g. \"chevron-right\" ). ");
@@ -161,6 +189,42 @@ private static string EmitDispatcher(IconDefinition[] defs)
return sb.ToString();
}
+ private static string EmitFactoryDispatcher(IconDefinition[] defs)
+ {
+ /* `Icon`, not `Icons`: the plural is taken by the ShellIcons.Icons namespace.
+ Each method references only its own component, so unused icons still trim. */
+ var sb = new StringBuilder();
+ sb.AppendLine("// ");
+ sb.AppendLine("#nullable enable");
+ sb.AppendLine("using Microsoft.AspNetCore.Components;");
+ sb.AppendLine();
+ sb.AppendLine($"namespace {DispatcherNamespace};");
+ sb.AppendLine();
+ sb.AppendLine("/// ");
+ sb.AppendLine("/// Every icon as a — for icons as values (component parameters, lists).");
+ sb.AppendLine("/// ");
+ sb.AppendLine("/// @using ShellIcons");
+ sb.AppendLine("/// @Icon.Plus()");
+ sb.AppendLine("/// @Icon.Bell(size: \"16\", strokeWidth: 1.5, cssClass: \"text-primary\")");
+ sb.AppendLine("/// ");
+ sb.AppendLine("/// ");
+ sb.AppendLine("public static class Icon");
+ sb.AppendLine("{");
+
+ for (var i = 0; i < defs.Length; i++)
+ {
+ var d = defs[i];
+ var typeRef = $"global::{IconsNamespace}.{d.PascalName}";
+ sb.AppendLine();
+ sb.AppendLine($" /// Renders the {d.KebabName} icon (source: {d.SourcePack}). ");
+ sb.AppendLine($" public static RenderFragment {d.PascalName}(string size = \"24\", double strokeWidth = 2, bool absoluteStroke = false, string? cssClass = null, string? title = null, global::System.Collections.Generic.IReadOnlyDictionary? additionalAttributes = null) =>");
+ sb.AppendLine($" global::ShellIcons.IconFragment.Create<{typeRef}>(size, strokeWidth, absoluteStroke, cssClass, title, additionalAttributes);");
+ }
+
+ sb.AppendLine("}");
+ return sb.ToString();
+ }
+
private static string ToVerbatimStringLiteral(string value)
{
var escaped = value.Replace("\"", "\"\"");
diff --git a/tests/ShellIcons.Blazor.Tests/Fixtures/CollisionPage.razor b/tests/ShellIcons.Blazor.Tests/Fixtures/CollisionPage.razor
new file mode 100644
index 0000000..7d5cf28
--- /dev/null
+++ b/tests/ShellIcons.Blazor.Tests/Fixtures/CollisionPage.razor
@@ -0,0 +1,16 @@
+@namespace CollisionFixture
+@using ShellIcons
+@using FakeUiKit
+@using Microsoft.AspNetCore.Components.Routing
+
+@* If a {Name}Icon alias ever collides with a UI-kit name, this page stops compiling (RZ9985). *@
+
+New
+
+
+@Icon.Table(cssClass: "factory-icon")
+
+@code {
+ public int Clicks { get; private set; }
+ private void Increment() => Clicks++;
+}
diff --git a/tests/ShellIcons.Blazor.Tests/Fixtures/EventDirectiveMisuse.razor b/tests/ShellIcons.Blazor.Tests/Fixtures/EventDirectiveMisuse.razor
new file mode 100644
index 0000000..6cfacf1
--- /dev/null
+++ b/tests/ShellIcons.Blazor.Tests/Fixtures/EventDirectiveMisuse.razor
@@ -0,0 +1,5 @@
+@namespace CollisionFixture
+@using ShellIcons
+
+@* @onclick on a component is a string, not a handler — IconCore must throw. *@
+ { }" />
diff --git a/tests/ShellIcons.Blazor.Tests/Fixtures/FakeUiKit.cs b/tests/ShellIcons.Blazor.Tests/Fixtures/FakeUiKit.cs
new file mode 100644
index 0000000..d976b99
--- /dev/null
+++ b/tests/ShellIcons.Blazor.Tests/Fixtures/FakeUiKit.cs
@@ -0,0 +1,18 @@
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Rendering;
+
+namespace FakeUiKit;
+
+// Stand-in for a UI kit component that shares a name with a Lucide icon.
+public sealed class Badge : ComponentBase
+{
+ [Parameter] public RenderFragment? ChildContent { get; set; }
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.OpenElement(0, "span");
+ builder.AddAttribute(1, "class", "fake-badge");
+ builder.AddContent(2, ChildContent);
+ builder.CloseElement();
+ }
+}
diff --git a/tests/ShellIcons.Blazor.Tests/IconFactoryTests.cs b/tests/ShellIcons.Blazor.Tests/IconFactoryTests.cs
new file mode 100644
index 0000000..878bba1
--- /dev/null
+++ b/tests/ShellIcons.Blazor.Tests/IconFactoryTests.cs
@@ -0,0 +1,113 @@
+using Bunit;
+using Microsoft.AspNetCore.Components;
+using AngleSharp.Dom;
+
+namespace ShellIcons.Blazor.Tests;
+
+public class IconFactoryTests : IDisposable
+{
+ private readonly TestContext _ctx = new();
+
+ public void Dispose() => _ctx.Dispose();
+
+ [Fact]
+ public void Factory_RendersMatchingShape()
+ {
+ var cut = _ctx.Render(Icon.ChevronRight());
+ var path = cut.Find("svg > path");
+
+ Assert.Equal("m9 18 6-6-6-6", path.GetAttribute("d"));
+ }
+
+ [Fact]
+ public void Factory_ForwardsSizeAndStrokeWidth()
+ {
+ var svg = _ctx.Render(Icon.Zap(size: "16", strokeWidth: 1.5)).Find("svg");
+
+ Assert.Equal("16", svg.GetAttribute("width"));
+ Assert.Equal("16", svg.GetAttribute("height"));
+ Assert.Equal("1.5", svg.GetAttribute("stroke-width"));
+ }
+
+ [Fact]
+ public void Factory_ForwardsCssClass()
+ {
+ var svg = _ctx.Render(Icon.TriangleAlert(cssClass: "text-warning")).Find("svg");
+
+ Assert.Equal("text-warning", svg.GetAttribute("class"));
+ }
+
+ [Fact]
+ public void Factory_TitleSwitchesToImgRole()
+ {
+ var svg = _ctx.Render(Icon.X(title: "Close dialog")).Find("svg");
+
+ Assert.Equal("img", svg.GetAttribute("role"));
+ Assert.NotNull(svg.GetAttribute("aria-labelledby"));
+ Assert.Null(svg.GetAttribute("aria-hidden"));
+ }
+
+ [Fact]
+ public void Factory_NoTitle_IsDecorative()
+ {
+ var svg = _ctx.Render(Icon.House()).Find("svg");
+
+ Assert.Equal("true", svg.GetAttribute("aria-hidden"));
+ Assert.Null(svg.GetAttribute("role"));
+ }
+
+ [Fact]
+ public void Factory_ForwardsAdditionalAttributes()
+ {
+ var extras = new Dictionary
+ {
+ ["data-testid"] = "my-icon",
+ ["style"] = "color: crimson"
+ };
+
+ var svg = _ctx.Render(Icon.Bell(additionalAttributes: extras)).Find("svg");
+
+ Assert.Equal("my-icon", svg.GetAttribute("data-testid"));
+ Assert.Equal("color: crimson", svg.GetAttribute("style"));
+ }
+
+ [Fact]
+ public void Factory_AllDefaults_ReturnsCachedFragment()
+ {
+ // No-arg calls must not allocate a new delegate per render.
+ Assert.Same(Icon.Plus(), Icon.Plus());
+ Assert.NotSame(Icon.Plus(), Icon.Minus());
+ }
+
+ [Fact]
+ public void Factory_NonDefaultArgs_ReturnsFreshFragment()
+ {
+ Assert.NotSame(Icon.Plus(size: "16"), Icon.Plus(size: "16"));
+ }
+
+ [Fact]
+ public void Factory_DefaultArgs_RenderLucideDefaults()
+ {
+ var svg = _ctx.Render(Icon.Plus()).Find("svg");
+
+ Assert.Equal("24", svg.GetAttribute("width"));
+ Assert.Equal("2", svg.GetAttribute("stroke-width"));
+ Assert.Null(svg.GetAttribute("class"));
+ }
+
+ [Fact]
+ public void Factory_OnClickViaAdditionalAttributes_UsesPlainEventName()
+ {
+ // "onclick", not "@onclick": the @ form is Razor markup syntax only.
+ var clicks = 0;
+ var extras = new Dictionary
+ {
+ ["onclick"] = EventCallback.Factory.Create(this, () => clicks++)
+ };
+
+ var cut = _ctx.Render(Icon.Bell(additionalAttributes: extras));
+ cut.Find("svg").Click();
+
+ Assert.Equal(1, clicks);
+ }
+}
diff --git a/tests/ShellIcons.Blazor.Tests/ShellIcons.Blazor.Tests.csproj b/tests/ShellIcons.Blazor.Tests/ShellIcons.Blazor.Tests.csproj
index 8f767fa..555c1ae 100644
--- a/tests/ShellIcons.Blazor.Tests/ShellIcons.Blazor.Tests.csproj
+++ b/tests/ShellIcons.Blazor.Tests/ShellIcons.Blazor.Tests.csproj
@@ -1,4 +1,4 @@
-
+
net9.0
diff --git a/tests/ShellIcons.Blazor.Tests/SuffixedIconTests.cs b/tests/ShellIcons.Blazor.Tests/SuffixedIconTests.cs
new file mode 100644
index 0000000..7a99256
--- /dev/null
+++ b/tests/ShellIcons.Blazor.Tests/SuffixedIconTests.cs
@@ -0,0 +1,87 @@
+using Bunit;
+using AngleSharp.Dom;
+using CollisionFixture;
+
+namespace ShellIcons.Blazor.Tests;
+
+public class SuffixedIconTests : IDisposable
+{
+ private readonly TestContext _ctx = new();
+
+ public void Dispose() => _ctx.Dispose();
+
+ [Fact]
+ public void Suffixed_RendersSameShapeAsFlatComponent()
+ {
+ var path = _ctx.RenderComponent().Find("svg > path");
+ Assert.Equal("m9 18 6-6-6-6", path.GetAttribute("d"));
+ }
+
+ [Fact]
+ public void Suffixed_LivesInRootNamespace_AndDerivesFromFlatComponent()
+ {
+ Assert.Equal("ShellIcons", typeof(ChevronRightIcon).Namespace);
+ Assert.Equal(typeof(Icons.ChevronRight), typeof(ChevronRightIcon).BaseType);
+ }
+
+ [Fact]
+ public void Suffixed_CoversCatalog_ExceptReservedShell()
+ {
+ var suffixed = typeof(IconCore).Assembly.GetTypes()
+ .Where(t => t.Namespace == "ShellIcons" && t.Name.EndsWith("Icon") && t.IsSubclassOf(typeof(IconCore)))
+ .Where(t => t != typeof(ShellIcon))
+ .ToArray();
+
+ // `shell` has no alias: ShellIcon is the dispatcher.
+ Assert.Equal(ShellIcon.Names.Count - 1, suffixed.Length);
+ Assert.Contains("shell", ShellIcon.Names);
+ }
+
+ [Fact]
+ public void ShellIcon_IsStillTheDispatcher()
+ {
+ Assert.NotNull(typeof(ShellIcon).GetProperty(nameof(ShellIcon.Name)));
+ var path = _ctx.RenderComponent(p => p.Add(x => x.Name, "chevron-right")).Find("svg > path");
+ Assert.Equal("m9 18 6-6-6-6", path.GetAttribute("d"));
+ }
+
+ [Fact]
+ public void ShellSeashell_ReachableThroughFactory()
+ {
+ var svg = _ctx.Render(Icon.Shell()).Find("svg");
+ Assert.NotEmpty(svg.Children);
+ }
+
+ [Fact]
+ public void RazorFixture_CompilesNextToCollidingUiKit_AndRendersEverything()
+ {
+ var cut = _ctx.RenderComponent();
+
+ Assert.Equal("New", cut.Find("span.fake-badge").TextContent); // UI kit's Badge
+ Assert.NotEmpty(cut.Find("svg.markup-icon").Children); //
+ Assert.NotEmpty(cut.Find("svg.router-icon").Children); //
+ Assert.NotEmpty(cut.Find("svg.factory-icon").Children); // @Icon.Table()
+ Assert.Equal("16", cut.Find("svg.markup-icon").GetAttribute("width"));
+ }
+
+ [Fact]
+ public void RazorFixture_OnClickWithoutAt_ReachesTheSvg()
+ {
+ var cut = _ctx.RenderComponent();
+
+ cut.Find("svg.markup-icon").Click();
+ cut.Find("svg.markup-icon").Click();
+
+ Assert.Equal(2, cut.Instance.Clicks);
+ }
+
+ [Fact]
+ public void EventDirectiveOnComponent_ThrowsWithGuidance()
+ {
+ var ex = Assert.Throws(() => _ctx.RenderComponent());
+
+ Assert.Contains("@onclick", ex.Message);
+ Assert.Contains("wrapping Handler())\"", ex.Message);
+ }
+}