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:
co-authored by
Claude Opus 5
parent
07e008d942
commit
973cc0b68c
@@ -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>`
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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('<svg')
|
||||
->and((string) $this->blade('<x-badge value="<b>4</b>" />'))->toContain('<b>4</b>')
|
||||
->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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user