Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 1 addition & 8 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<Project>

<!-- Language + compilation defaults -->
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
Expand All @@ -15,7 +14,6 @@
<Version>0.1.0-alpha</Version>
</PropertyGroup>

<!-- Package metadata (applies to any project with IsPackable=true) -->
<PropertyGroup>
<Authors>shellui-dev</Authors>
<Company>ShellUI</Company>
Expand All @@ -28,7 +26,6 @@
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>

<!-- Symbols + Source Link — enables source-stepping in consumers' debuggers -->
<PropertyGroup>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
Expand All @@ -42,15 +39,11 @@
<IsPackable>false</IsPackable>
</PropertyGroup>

<!-- Auto-include the repo README + NOTICE in every packable project -->
<ItemGroup Condition="'$(IsPackable)' == 'true'">
<None Include="$(MSBuildThisFileDirectory)README.md" Pack="true" PackagePath="\" />
<None Include="$(MSBuildThisFileDirectory)NOTICE.md" Pack="true" PackagePath="\" />
</ItemGroup>

<!-- Source Link package (GitHub) — added to every packable project -->
<ItemGroup Condition="'$(IsPackable)' == 'true'">
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>
<!-- Source Link is built into the .NET 8+ SDK; the SourceLink package pulled in a vulnerable dependency (NU1902). -->

</Project>
111 changes: 97 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,37 +37,120 @@ Not yet published. During development:
<ProjectReference Include="path/to/src/ShellIcons.Blazor/ShellIcons.Blazor.csproj" />
```

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 |
|---|---|---|---|---|
| **`<ChevronRightIcon />`** 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. |
| `<ShellIcon Name="chevron-right" />` dispatcher | `@using ShellIcons` | ✅ No | ❌ Roots all 1,555 | Names only known at runtime (CMS, JSON, markdown). |
| `<ChevronRight />` 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.

#### `<ChevronRightIcon />` — 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

<ChevronRight />
<Zap Size="16" StrokeWidth="1.5" />
<TriangleAlert Class="text-warning" />
<ChevronRightIcon />
<ZapIcon Size="16" StrokeWidth="1.5" />
<TriangleAlertIcon Class="text-warning" />
<XIcon Title="Close dialog" />
```

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 *@
<NavItem Href="/" Label="Home" Icon="@Icon.House()" />

@* 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.

#### `<ShellIcon Name="…" />` — 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
<ShellIcon Name="chevron-right" />
<ShellIcon Name="@page.IconName" Size="20" />
```

Colors inherit from CSS:
#### `<ChevronRight />` — 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 `<Icon.Plus />` — 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: `<ShellIcons.Icons.ChevronRight />`.

### 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
<span style="color: crimson">
<TriangleAlert Size="20" />
</span>
```razor
<Button><ChevronRightIcon /> Next</Button>
```

**Components that place the icon somewhere specific** (input adornments, alerts, nav items) take a `RenderFragment` parameter — pass the factory:

```razor
<Input StartIcon="@Icon.Search()" Placeholder="Search…" />
```

### Click handlers

Put handlers on a wrapping `<button>` — screen readers expect that. If you must attach one to the icon itself, drop the `@`:

```razor
<button @onclick="Save" aria-label="Save"><SaveIcon /></button> @* ✅ recommended *@
<SaveIcon onclick="@(() => Save())" /> @* ✅ works *@
<SaveIcon @onclick="Save" /> @* ❌ 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<string, object>
{
["onclick"] = EventCallback.Factory.Create(this, HandleClick),
["data-testid"] = "notifications",
})
```

### Colors and accessibility

Colors inherit from CSS `color` (`stroke="currentColor"`):

```razor
<span style="color: crimson"><TriangleAlertIcon Size="20" /></span>
```

Accessibility:
Without `Title` icons are decorative (`aria-hidden="true"`); with it they get `role="img"` + `<title>`:

```razor
<X Title="Close dialog" />
<XIcon Title="Close dialog" />
```

## Repo layout
Expand Down
5 changes: 3 additions & 2 deletions docs/ShellIcons.Docs/Components/Pages/IconsPage.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
Expand Down
26 changes: 10 additions & 16 deletions docs/ShellIcons.Docs/content/docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>`, 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 | `<ChevronRightIcon />` | Markup — the default |
| Factory | `@Icon.ChevronRight()` | Icons as values (`RenderFragment` parameters, lists) |
| Dispatcher | `<ShellIcon Name="chevron-right" />` | Names only known at runtime |

<ChevronRight />
```
The unsuffixed components (`<ChevronRight />`) 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
<Zap Size="32" />
<ZapIcon Size="32" />
```

If you see a lightning bolt above, you're wired.
Expand Down
23 changes: 14 additions & 9 deletions docs/ShellIcons.Docs/content/docs/getting-started/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — `<Ch` will suggest `ChevronDown`, `ChevronLeft`, `ChevronRight`, `ChevronUp`, and so on.
Every icon is a component named `{Name}Icon`. IntelliSense the name you want — `<Chevron` will suggest `ChevronDownIcon`, `ChevronLeftIcon`, `ChevronRightIcon`, `ChevronUpIcon`, and so on.

```razor:preview
<PreviewRow>
<ChevronLeft />
<ChevronRight />
<ChevronDown />
<ChevronUp />
<ChevronLeftIcon />
<ChevronRightIcon />
<ChevronDownIcon />
<ChevronUpIcon />
</PreviewRow>
```

When the icon is a *value* — a `RenderFragment` parameter on another component, or an item in a list — use the factory instead:

```razor
<NavItem Href="/" Label="Home" Icon="@Icon.House()" />
```

## 4. Size and stroke

```razor:preview
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ChevronRight />` names from `ShellIcons.Icons`. In an app that also uses a UI kit, use the suffixed twins from `ShellIcons` instead — `<ChevronRightIcon />` — 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
Expand Down
24 changes: 23 additions & 1 deletion src/ShellIcons.Blazor/IconCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ public abstract class IconCore : ComponentBase
/// <summary>Accessible title. When set, the icon is announced by screen readers via &lt;title&gt; + <c>role="img"</c> + <c>aria-labelledby</c>. When null, the icon is decorative and gets <c>aria-hidden="true"</c>.</summary>
[Parameter] public string? Title { get; set; }

/// <summary>Extra attributes forwarded to the root &lt;svg&gt; element (e.g. <c>style</c>, <c>@onclick</c>).</summary>
/// <summary>
/// Extra attributes forwarded to the root &lt;svg&gt; (e.g. <c>style</c>, <c>data-*</c>). Pass handlers
/// without the <c>@</c> — <c>onclick="@(() => Handler())"</c> — or put them on a wrapping <c>&lt;button&gt;</c>.
/// </summary>
[Parameter(CaptureUnmatchedValues = true)]
public IReadOnlyDictionary<string, object>? AdditionalAttributes { get; set; }

Expand Down Expand Up @@ -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)
{
Expand All @@ -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<string, object> 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 <button {key}=\"…\"> (recommended for accessibility), " +
$"or pass it without the @: {key[1..]}=\"@(() => Handler())\".");
}
}
}

private string ComputeStrokeWidth()
{
if (!AbsoluteStroke)
Expand Down
47 changes: 47 additions & 0 deletions src/ShellIcons.Blazor/IconFragment.cs
Original file line number Diff line number Diff line change
@@ -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<T>(
string size,
double strokeWidth,
bool absoluteStroke,
string? cssClass,
string? title,
IReadOnlyDictionary<string, object>? additionalAttributes)
where T : IconCore
{
if (size == "24" && strokeWidth == 2 && !absoluteStroke
&& cssClass is null && title is null && additionalAttributes is null)
{
return Cache<T>.Default;
}

return builder =>
{
builder.OpenComponent<T>(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<T> where T : IconCore
{
public static readonly RenderFragment Default = builder =>
{
builder.OpenComponent<T>(0);
builder.CloseComponent();
};
}
}
Loading
Loading