Render badge slots as HTML and add neutral and plain badge colours

The slot was cast to a string and escaped, so an icon beside a badge's
word showed as literal markup. It renders as HTML now; `value` stays
escaped, and a slot holding only whitespace or comments is still a dot
(hasActualContent rather than isNotEmpty).

`color="neutral"` draws neutral ink on every variant: a dot or count in
on-surface-variant with surface text (the ink the outline badge already
writes in), a tonal label in surface-container-high with
on-surface-variant text, and an outline in outline-variant.

`color="plain"` emits no background, text or border colour in any
variant, keeping shape, size and type, so an application's own colour
classes paint it without racing the badge's.

Every existing colour renders the same classes as before, and an unknown
colour still falls back to error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 18:10:59 +02:00
co-authored by Claude Opus 5
parent 07e008d942
commit 973cc0b68c
4 changed files with 81 additions and 8 deletions
@@ -353,7 +353,10 @@ A value the server changes animates after a morph (the SVG is `wire:ignore`; onl
### `<x-badge>`
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`, `neutral`, `plain`; an unknown colour is `error`.
- `color="neutral"` — neutral ink on every variant: a dot or count in on-surface-variant with surface text, `tonal` in surface-container-high with on-surface-variant text, `outline` in the outline-variant edge with on-surface-variant text.
- `color="plain"` — no background, text or border colour in any variant (shape, size and type stay), so the classes you pass paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`. Pass both a background and a text class; an `outline` badge's edge takes the text colour unless you pass a `border-*` colour.
- The value is `value` or the slot; the slot renders as HTML: `<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. `value` is escaped. A slot that holds only whitespace or comments is still a dot.
### `<x-alert>`
+26 -7
View File
@@ -11,7 +11,16 @@
The status label is not an M3 badge but every app needs one: `tonal` draws the value in the
colour's container ("Expired" in error-container), `outline` in a neutral edge. `color` (alias
`tone`): `error` (the default), `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
`tone`): `error` (the default), `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`,
and two without a hue of their own:
- `neutral` — neutral ink on every variant: on-surface-variant with surface text as a dot or
count (the ink an outline badge already writes in), surface-container-high with
on-surface-variant text when `tonal`, the outline-variant edge when `outline`.
- `plain` — no background, text or border colour at all, only shape, size and type, so the
caller's classes paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`.
The value is `value`, or the slot, which renders as HTML an icon beside the word:
`<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>`. With neither it is a dot.
A count or dot says nothing to a screen reader on its own: give the icon's control a label
that includes it ("Notifications, 4 new"), or pass `label` here. --}}
@@ -28,23 +37,34 @@
])
@php
$color = in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) ? ($color ?? $tone) : 'error';
$text = $value ?? ($slot->isNotEmpty() ? trim((string) $slot) : null);
$color = in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info', 'neutral', 'plain'], true) ? ($color ?? $tone) : 'error';
// hasActualContent(): a slot holding only a comment, or an empty @foreach, is still a dot.
$markup = $value === null && $slot->hasActualContent();
$text = $value ?? ($markup ? trim((string) $slot) : null);
$dot = blank($text);
$status = ($tonal || $outline) && ! $dot;
if (! $dot && $max !== null && is_numeric($text) && (int) $text > (int) $max) {
$text = $max.'+';
$markup = false;
}
$filled = [
'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary',
'error' => 'bg-error text-on-error', 'success' => 'bg-success text-on-success', 'warning' => 'bg-warning text-on-warning', 'info' => 'bg-info text-on-info',
'neutral' => 'bg-on-surface-variant text-surface',
];
$container = [
'primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container',
'error' => 'bg-error-container text-on-error-container', 'success' => 'bg-success-container text-on-success-container', 'warning' => 'bg-warning-container text-on-warning-container', 'info' => 'bg-info-container text-on-info-container',
'neutral' => 'bg-surface-container-high text-on-surface-variant',
];
$paint = match (true) {
$color === 'plain' => '',
! $status => $filled[$color],
$tonal => $container[$color],
default => 'border-outline-variant text-on-surface-variant',
};
$attributes = $attributes
->class([
@@ -52,9 +72,8 @@
'size-1.5 rounded-corner-full' => $dot,
'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums' => ! $dot && ! $status,
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
$filled[$color] => ! $status,
$container[$color] => $tonal && ! $dot,
'border border-outline-variant text-on-surface-variant' => $outline && ! $tonal && ! $dot,
'border' => $outline && ! $tonal && ! $dot,
$paint => $paint !== '',
'absolute top-0.5 end-0.5' => $floating && $dot,
'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
])
@@ -64,4 +83,4 @@
]));
@endphp
<span {{ $attributes }}>@unless ($dot){{ $text }}@endunless</span>
<span {{ $attributes }}>@unless ($dot)@if ($markup){{ $slot }}@else{{ $text }}@endif@endunless</span>
@@ -8,6 +8,11 @@
<x-badge value="Active" color="success" tonal />
<x-badge value="Password" color="info" tonal />
<x-badge value="Pro" outline />
<x-badge value="Draft" color="neutral" tonal />
<x-badge value="Archived" color="neutral" outline />
<span class="relative inline-flex"><x-icon name="chat" /><x-badge value="2" color="neutral" floating /></span>
<x-badge tonal color="tertiary"><x-icon name="bolt" filled class="size-3" /> Pro</x-badge>
<x-badge value="Beta" tonal color="plain" class="bg-primary-fixed text-on-primary-fixed" />
BLADE,
'Snackbars' => <<<'BLADE'
<div x-data class="flex flex-wrap items-center gap-4">
+46
View File
@@ -30,3 +30,49 @@ it('draws a status label in a container or an outline', function () {
->toContain('border border-outline-variant text-on-surface-variant')
->not->toContain('bg-error');
});
it('renders its slot as HTML, and is a dot while the slot holds nothing but comments', function () {
$html = (string) $this->blade('<x-badge tonal><x-icon name="bolt" class="size-3" /> Pro</x-badge>');
expect($html)
->toContain('h-6 gap-1 rounded-corner-sm')
->toContain('<svg')
->toContain(' Pro</span>')
->not->toContain('&lt;svg')
->and((string) $this->blade('<x-badge value="<b>4</b>" />'))->toContain('&lt;b&gt;4&lt;/b&gt;')
->and((string) $this->blade("<x-badge tonal>\n <!-- nothing yet -->\n</x-badge>"))->toContain('size-1.5 rounded-corner-full')
->and((string) $this->blade('<x-badge max="99">120</x-badge>'))->toContain('>99+</span>');
});
it('draws neutral ink on every variant', function () {
expect((string) $this->blade('<x-badge color="neutral" />'))->toContain('size-1.5 rounded-corner-full bg-on-surface-variant text-surface')
->and((string) $this->blade('<x-badge value="7" color="neutral" />'))->toContain('tabular-nums bg-on-surface-variant text-surface')
->and((string) $this->blade('<x-badge value="Draft" tone="neutral" tonal />'))->toContain('type-label-md bg-surface-container-high text-on-surface-variant')
->and((string) $this->blade('<x-badge value="Draft" color="neutral" outline />'))->toContain('type-label-md border border-outline-variant text-on-surface-variant');
});
it('leaves a plain badge to the caller\'s colour classes', function (string $badge, string $shape) {
$html = (string) $this->blade($badge);
preg_match('/class="([^"]*)"/', $html, $class);
expect($class[1])
->toContain($shape)
->toEndWith('bg-tertiary-container text-on-tertiary-container')
->and(preg_replace('/bg-tertiary-container text-on-tertiary-container$/', '', $class[1]))->not->toMatch('/(^|\s)(bg|text|border)-/');
})->with([
'dot' => ['<x-badge color="plain" class="bg-tertiary-container text-on-tertiary-container" />', 'size-1.5 rounded-corner-full'],
'count' => ['<x-badge value="3" color="plain" class="bg-tertiary-container text-on-tertiary-container" />', 'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums'],
'tonal' => ['<x-badge value="Run" color="plain" tonal class="bg-tertiary-container text-on-tertiary-container" />', 'h-6 gap-1 rounded-corner-sm px-2 type-label-md'],
'outline' => ['<x-badge value="Run" color="plain" outline class="bg-tertiary-container text-on-tertiary-container" />', 'h-6 gap-1 rounded-corner-sm px-2 type-label-md border'],
]);
it('keeps its default colours, and falls back to error on a colour it does not know', function () {
expect((string) $this->blade('<x-badge value="4" />'))
->toContain('class="inline-flex shrink-0 items-center justify-center whitespace-nowrap h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums bg-error text-on-error"')
->and((string) $this->blade('<x-badge value="Active" tonal />'))
->toContain('class="inline-flex shrink-0 items-center justify-center whitespace-nowrap h-6 gap-1 rounded-corner-sm px-2 type-label-md bg-error-container text-on-error-container"')
->and((string) $this->blade('<x-badge value="Pro" outline color="success" />'))
->toContain('class="inline-flex shrink-0 items-center justify-center whitespace-nowrap h-6 gap-1 rounded-corner-sm px-2 type-label-md border border-outline-variant text-on-surface-variant"')
->and((string) $this->blade('<x-badge value="3" color="sport-run" />'))->toContain('bg-error text-on-error');
});