From 973cc0b68c952f0fe65247de48e44ca31ddb9559 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 18:02:32 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2 --- .../livewire-material-development/SKILL.md | 5 +- resources/views/components/badge.blade.php | 33 ++++++++++--- .../showcase/sections/communication.blade.php | 5 ++ tests/Feature/Components/BadgeTest.php | 46 +++++++++++++++++++ 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index e16e4965..69a51eb6 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -353,7 +353,10 @@ A value the server changes animates after a morph (the SVG is `wire:ignore`; onl ### `` - `` — M3's small badge, a dot. `` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: ``. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread"). -- ``, ``, `` — 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`. +- ``, ``, `` — 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: ``. 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: ` Pro`. `value` is escaped. A slot that holds only whitespace or comments is still a dot. ### `` diff --git a/resources/views/components/badge.blade.php b/resources/views/components/badge.blade.php index f7c4b04f..00dec897 100644 --- a/resources/views/components/badge.blade.php +++ b/resources/views/components/badge.blade.php @@ -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: ``. + + The value is `value`, or the slot, which renders as HTML — an icon beside the word: + ` Pro`. 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 -@unless ($dot){{ $text }}@endunless +@unless ($dot)@if ($markup){{ $slot }}@else{{ $text }}@endif@endunless diff --git a/resources/views/showcase/sections/communication.blade.php b/resources/views/showcase/sections/communication.blade.php index 47c46dde..8a13f002 100644 --- a/resources/views/showcase/sections/communication.blade.php +++ b/resources/views/showcase/sections/communication.blade.php @@ -8,6 +8,11 @@ + + + + Pro + BLADE, 'Snackbars' => <<<'BLADE'
diff --git a/tests/Feature/Components/BadgeTest.php b/tests/Feature/Components/BadgeTest.php index 9442f42b..4c8cd824 100644 --- a/tests/Feature/Components/BadgeTest.php +++ b/tests/Feature/Components/BadgeTest.php @@ -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(' Pro'); + + expect($html) + ->toContain('h-6 gap-1 rounded-corner-sm') + ->toContain('toContain(' Pro') + ->not->toContain('<svg') + ->and((string) $this->blade(''))->toContain('<b>4</b>') + ->and((string) $this->blade("\n \n"))->toContain('size-1.5 rounded-corner-full') + ->and((string) $this->blade('120'))->toContain('>99+'); +}); + +it('draws neutral ink on every variant', function () { + expect((string) $this->blade(''))->toContain('size-1.5 rounded-corner-full bg-on-surface-variant text-surface') + ->and((string) $this->blade(''))->toContain('tabular-nums bg-on-surface-variant text-surface') + ->and((string) $this->blade(''))->toContain('type-label-md bg-surface-container-high text-on-surface-variant') + ->and((string) $this->blade(''))->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' => ['', 'size-1.5 rounded-corner-full'], + 'count' => ['', 'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums'], + 'tonal' => ['', 'h-6 gap-1 rounded-corner-sm px-2 type-label-md'], + 'outline' => ['', '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('')) + ->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('')) + ->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('')) + ->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(''))->toContain('bg-error text-on-error'); +});