Draw the badge without Tailwind

Plan step 36. <x-badge> renders data-md-badge with data-md-dot,
data-md-variant (solid, tonal or outline, resolved in that order),
data-md-color and data-md-floating; badge.css draws BadgeTokens' dot and
count, the status label, each colour's pair of roles through custom
properties, and M3's anchor geometry when floating. A plain badge sets
no colour, so the caller's CSS paints it.

NavigationBarTest (navigation group) asserts the dot by data-md-dot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 14:34:24 +02:00
co-authored by Claude Opus 5
parent d4b71c519c
commit 7bdd3ef61c
6 changed files with 287 additions and 100 deletions
+1
View File
@@ -14,6 +14,7 @@
/* Actions and communication */ /* Actions and communication */
@import './components/loading.css'; @import './components/loading.css';
@import './components/tooltip.css'; @import './components/tooltip.css';
@import './components/badge.css';
/* Inputs, selection and data */ /* Inputs, selection and data */
+143
View File
@@ -0,0 +1,143 @@
/*
* <x-badge>: M3's small and large badges, and the library's status label.
*
* BadgeTokens (androidx Compose Material 3, Apache-2.0): the small badge is a 6px dot
* (`data-md-dot`), the large badge 16px tall and at least 16px wide with 4px either side of a
* `label-small` count in tabular figures, both fully round and `error` by default. A floating
* badge sits on M3's anchor geometry at the top-end corner of its `position: relative` parent: the
* dot flush in the corner, the count 2px above it with its start 12px in from the parent's end.
*
* The status label (`data-md-variant`) is not an M3 badge: 24px tall, small corner, 8px either
* side, 4px between an icon and its word, `label-medium`. `tonal` draws it in the colour's
* container, `solid` in the colour itself, `outline` as a 1px `outline` edge around
* `on-surface-variant` text — `outline` rather than `outline-variant`, because M3 asks a badge for
* 3:1 against what is behind it and `outline-variant` is the decorative role dividers use.
*
* `data-md-color` picks the pair of roles; `neutral` has no hue of its own (`on-surface-variant`
* and `surface`, or `surface-container-high` behind `on-surface-variant` text), and `plain` sets
* no colour at all, so the caller's CSS paints it.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@layer material.components {
[data-md-badge] {
--md-badge-color: var(--md-sys-color-error);
--md-badge-on-color: var(--md-sys-color-on-error);
--md-badge-container: var(--md-sys-color-error-container);
--md-badge-on-container: var(--md-sys-color-on-error-container);
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
white-space: nowrap;
}
[data-md-badge][data-md-dot] {
inline-size: 6px;
block-size: 6px;
border-radius: var(--md-sys-shape-corner-full);
}
[data-md-badge]:not([data-md-dot], [data-md-variant]) {
min-inline-size: 16px;
block-size: 16px;
border-radius: var(--md-sys-shape-corner-full);
padding-inline: var(--md-sys-measurement-space50);
font: var(--md-sys-typescale-label-sm);
letter-spacing: var(--md-sys-typescale-label-sm-tracking);
font-variation-settings: normal;
font-variant-numeric: tabular-nums;
}
[data-md-badge][data-md-variant] {
gap: var(--md-sys-measurement-space50);
block-size: 24px;
border-radius: var(--md-sys-shape-corner-sm);
padding-inline: var(--md-sys-measurement-space100);
font: var(--md-sys-typescale-label-md);
letter-spacing: var(--md-sys-typescale-label-md-tracking);
font-variation-settings: normal;
}
[data-md-badge][data-md-variant='outline'] {
border: 1px solid;
}
[data-md-badge][data-md-color='primary'] {
--md-badge-color: var(--md-sys-color-primary);
--md-badge-on-color: var(--md-sys-color-on-primary);
--md-badge-container: var(--md-sys-color-primary-container);
--md-badge-on-container: var(--md-sys-color-on-primary-container);
}
[data-md-badge][data-md-color='secondary'] {
--md-badge-color: var(--md-sys-color-secondary);
--md-badge-on-color: var(--md-sys-color-on-secondary);
--md-badge-container: var(--md-sys-color-secondary-container);
--md-badge-on-container: var(--md-sys-color-on-secondary-container);
}
[data-md-badge][data-md-color='tertiary'] {
--md-badge-color: var(--md-sys-color-tertiary);
--md-badge-on-color: var(--md-sys-color-on-tertiary);
--md-badge-container: var(--md-sys-color-tertiary-container);
--md-badge-on-container: var(--md-sys-color-on-tertiary-container);
}
[data-md-badge][data-md-color='success'] {
--md-badge-color: var(--md-sys-color-success);
--md-badge-on-color: var(--md-sys-color-on-success);
--md-badge-container: var(--md-sys-color-success-container);
--md-badge-on-container: var(--md-sys-color-on-success-container);
}
[data-md-badge][data-md-color='warning'] {
--md-badge-color: var(--md-sys-color-warning);
--md-badge-on-color: var(--md-sys-color-on-warning);
--md-badge-container: var(--md-sys-color-warning-container);
--md-badge-on-container: var(--md-sys-color-on-warning-container);
}
[data-md-badge][data-md-color='info'] {
--md-badge-color: var(--md-sys-color-info);
--md-badge-on-color: var(--md-sys-color-on-info);
--md-badge-container: var(--md-sys-color-info-container);
--md-badge-on-container: var(--md-sys-color-on-info-container);
}
[data-md-badge][data-md-color='neutral'] {
--md-badge-color: var(--md-sys-color-on-surface-variant);
--md-badge-on-color: var(--md-sys-color-surface);
--md-badge-container: var(--md-sys-color-surface-container-high);
--md-badge-on-container: var(--md-sys-color-on-surface-variant);
}
[data-md-badge]:not([data-md-color='plain']) {
background-color: var(--md-badge-color);
color: var(--md-badge-on-color);
&[data-md-variant='tonal'] {
background-color: var(--md-badge-container);
color: var(--md-badge-on-container);
}
&[data-md-variant='outline'] {
border-color: var(--md-sys-color-outline);
background-color: transparent;
color: var(--md-sys-color-on-surface-variant);
}
}
[data-md-badge][data-md-floating] {
position: absolute;
inset-block-start: calc(-1 * var(--md-sys-measurement-space25));
inset-inline-start: calc(100% - 12px);
}
[data-md-badge][data-md-floating][data-md-dot] {
inset-block-start: 0;
inset-inline: auto 0;
}
}
+23 -34
View File
@@ -7,10 +7,10 @@
Both are `error` by default, as M3 draws them, and an `outline` status label draws its edge in Both are `error` by default, as M3 draws them, and an `outline` status label draws its edge in
`outline`, not `outline-variant`: M3 asks a badge for 3:1 against what is behind it, and `outline`, not `outline-variant`: M3 asks a badge for 3:1 against what is behind it, and
`outline-variant` is the decorative role dividers use. `floating` pins one to the top-end `outline-variant` is the decorative role dividers use. `floating` pins one to the top-end
corner of a `relative` parent, on M3's anchor geometry — the dot flush in the corner, the corner of a `position: relative` parent, on M3's anchor geometry — the dot flush in the corner,
count 2px above it — an icon or an icon button: the count 2px above it — an icon or an icon button:
<span class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span> <span style="position: relative; display: inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
The status label is not an M3 badge but every app needs one: `tonal` draws the value in the 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), `solid` in the colour itself (a label that colour's container ("Expired" in error-container), `solid` in the colour itself (a label that
@@ -21,13 +21,17 @@
count (the ink an outline badge already writes in), surface-container-high with count (the ink an outline badge already writes in), surface-container-high with
on-surface-variant text when `tonal`, the outline edge when `outline`. on-surface-variant text when `tonal`, the outline edge when `outline`.
- `plain` no background, text or border colour at all, only shape, size and type, so the - `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" />`. caller's CSS paints it.
The value is `value`, or the slot, which renders as HTML — an icon beside the word: The value is `value`, or the slot, which renders as HTML — an icon beside the word:
`<x-badge tonal><x-icon name="bolt" optical="20" class="size-3" /> Pro</x-badge>`. With neither it is a dot. `<x-badge tonal><x-icon name="bolt" size="12" /> 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 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. --}} that includes it ("Notifications, 4 new"), or pass `label` here.
Drawn by resources/css/components/badge.css from `data-md-dot` (the small badge),
`data-md-variant` (a status label: `solid`, `tonal` or `outline`, the first of them that is
set, in that order), `data-md-color` and `data-md-floating`. --}}
@props([ @props([
'value' => null, 'value' => null,
@@ -54,38 +58,23 @@
$markup = false; $markup = false;
} }
$filled = [ // `solid` wins over `tonal`, and either over `outline`, as the three have always resolved.
'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary', $variant = match (true) {
'error' => 'bg-error text-on-error', 'success' => 'bg-success text-on-success', 'warning' => 'bg-warning text-on-warning', 'info' => 'bg-info text-on-info', ! $status => null,
'neutral' => 'bg-on-surface-variant text-surface', (bool) $solid => 'solid',
]; (bool) $tonal => 'tonal',
$container = [ default => 'outline',
'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, $solid => $filled[$color],
$tonal => $container[$color],
default => 'border-outline text-on-surface-variant',
}; };
$attributes = $attributes $attributes = $attributes->merge(array_filter([
->class([ 'data-md-badge' => true,
'inline-flex shrink-0 items-center justify-center whitespace-nowrap', 'data-md-dot' => $dot ? true : null,
'size-1.5 rounded-corner-full' => $dot, 'data-md-variant' => $variant,
'h-4 min-w-4 rounded-corner-full px-1 type-label-sm tabular-nums' => ! $dot && ! $status, 'data-md-color' => $color,
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status, 'data-md-floating' => $floating ? true : null,
'border' => $outline && ! $tonal && ! $solid && ! $dot,
$paint => $paint !== '',
'absolute top-0 end-0' => $floating && $dot,
'absolute -top-0.5 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
])
->merge(array_filter([
'aria-label' => $label, 'aria-label' => $label,
'aria-hidden' => $label === null && ! $status ? 'true' : null, 'aria-hidden' => $label === null && ! $status ? 'true' : null,
])); ], fn ($value): bool => $value !== null));
@endphp @endphp
<span {{ $attributes }}>@unless ($dot)@if ($markup){{ $slot }}@else{{ $text }}@endif@endunless</span> <span {{ $attributes }}>@unless ($dot)@if ($markup){{ $slot }}@else{{ $text }}@endif@endunless</span>
@@ -13,6 +13,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
dataset('action components', [ dataset('action components', [
'loading', 'loading',
'tooltip', 'tooltip',
'badge',
]); ]);
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
+116 -63
View File
@@ -1,92 +1,145 @@
<?php <?php
it('is M3\'s small badge without a value', function () { use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
expect((string) $this->blade('<x-badge />'))
->toContain('size-1.5 rounded-corner-full') /**
->toContain('bg-error text-on-error') * The attributes of the rendered badge, by name.
->toContain('aria-hidden="true"'); *
* @return array<string, string>
*/
function badgeAttributes(string $blade): array
{
preg_match('/<span\b([^>]*)>/', (string) test()->blade($blade), $tag);
preg_match_all('/([\w:-]+)="([^"]*)"/', $tag[1] ?? '', $pairs, PREG_SET_ORDER);
return collect($pairs)->mapWithKeys(fn (array $pair): array => [$pair[1] => $pair[2]])->all();
}
it('is M3\'s small badge without a value, hidden from screen readers', function () {
expect(badgeAttributes('<x-badge />'))->toBe([
'data-md-badge' => 'data-md-badge',
'data-md-dot' => 'data-md-dot',
'data-md-color' => 'error',
'aria-hidden' => 'true',
])
->and(ComponentStylesheet::read('badge')->declarations('[data-md-badge][data-md-dot]'))->toBe([
'inline-size' => '6px',
'block-size' => '6px',
'border-radius' => 'var(--md-sys-shape-corner-full)',
]);
}); });
it('is M3\'s large badge with a count, capped by max', function () { it('is M3\'s large badge with a count, capped by max', function () {
expect((string) $this->blade('<x-badge value="4" />'))->toContain('h-4 min-w-4')->toContain('>4</span>') $css = ComponentStylesheet::read('badge');
expect((string) $this->blade('<x-badge value="4" />'))->toContain('>4</span>')->not->toContain('data-md-dot')->not->toContain('data-md-variant')
->and((string) $this->blade('<x-badge value="1204" max="999" />'))->toContain('>999+</span>') ->and((string) $this->blade('<x-badge value="1204" max="999" />'))->toContain('>999+</span>')
->and((string) $this->blade('<x-badge value="12" max="999" color="primary" />'))->toContain('>12</span>')->toContain('bg-primary text-on-primary'); ->and((string) $this->blade('<x-badge value="12" max="999" color="primary" />'))->toContain('>12</span>')->toContain('data-md-color="primary"')
->and($css->declarations('[data-md-badge]:not([data-md-dot], [data-md-variant])'))->toMatchArray([
'min-inline-size' => '16px',
'block-size' => '16px',
'border-radius' => 'var(--md-sys-shape-corner-full)',
'padding-inline' => 'var(--md-sys-measurement-space50)',
'font' => 'var(--md-sys-typescale-label-sm)',
'font-variant-numeric' => 'tabular-nums',
]);
}); });
it('pins itself to the corner of an icon when floating', function () { it('pins itself to the corner of an icon when floating', function () {
expect((string) $this->blade('<x-badge floating />'))->toContain('absolute top-0 end-0') $css = ComponentStylesheet::read('badge');
->and((string) $this->blade('<x-badge value="3" floating label="3 new messages" />'))
->toContain('absolute -top-0.5') expect((string) $this->blade('<x-badge floating />'))->toContain('data-md-floating')
->toContain('aria-label="3 new messages"') ->and(badgeAttributes('<x-badge value="3" floating label="3 new messages" />'))
->not->toContain('aria-hidden'); ->toMatchArray(['data-md-floating' => 'data-md-floating', 'aria-label' => '3 new messages'])
->not->toHaveKey('aria-hidden')
->and($css->declarations('[data-md-badge][data-md-floating]'))->toBe([
'position' => 'absolute',
'inset-block-start' => 'calc(-1 * var(--md-sys-measurement-space25))',
'inset-inline-start' => 'calc(100% - 12px)',
])
->and($css->declarations('[data-md-badge][data-md-floating][data-md-dot]'))->toBe(['inset-block-start' => '0', 'inset-inline' => 'auto 0']);
}); });
it('draws a status label in a container or an outline', function () { it('draws a status label in a container or an outline, spoken like any label', function () {
expect((string) $this->blade('<x-badge value="Active" tone="success" tonal />')) $css = ComponentStylesheet::read('badge');
->toContain('h-6 gap-1 rounded-corner-sm px-2 type-label-md')
->toContain('bg-success-container text-on-success-container') expect(badgeAttributes('<x-badge value="Active" tone="success" tonal />'))
->not->toContain('aria-hidden') ->toBe(['data-md-badge' => 'data-md-badge', 'data-md-variant' => 'tonal', 'data-md-color' => 'success'])
->and((string) $this->blade('<x-badge value="Pro" outline />')) ->and(badgeAttributes('<x-badge value="Pro" outline />'))
->toContain('border border-outline text-on-surface-variant') ->toBe(['data-md-badge' => 'data-md-badge', 'data-md-variant' => 'outline', 'data-md-color' => 'error'])
->not->toContain('bg-error'); ->and($css->declarations('[data-md-badge][data-md-variant]'))->toMatchArray([
'gap' => 'var(--md-sys-measurement-space50)',
'block-size' => '24px',
'border-radius' => 'var(--md-sys-shape-corner-sm)',
'padding-inline' => 'var(--md-sys-measurement-space100)',
'font' => 'var(--md-sys-typescale-label-md)',
])
->and($css->declarations("[data-md-badge]:not([data-md-color='plain'])[data-md-variant='tonal']"))->toBe([
'background-color' => 'var(--md-badge-container)',
'color' => 'var(--md-badge-on-container)',
])
// The outline role, not outline-variant: a badge needs 3:1 against what is behind it.
->and($css->declarations("[data-md-badge]:not([data-md-color='plain'])[data-md-variant='outline']"))->toBe([
'border-color' => 'var(--md-sys-color-outline)',
'background-color' => 'transparent',
'color' => 'var(--md-sys-color-on-surface-variant)',
])
->and($css->declarations("[data-md-badge][data-md-variant='outline']"))->toBe(['border' => '1px solid']);
});
it('resolves solid over tonal, and either over outline', function () {
expect(badgeAttributes('<x-badge value="Built in" solid tonal outline color="primary" />'))->toMatchArray(['data-md-variant' => 'solid', 'data-md-color' => 'primary'])
->and(badgeAttributes('<x-badge value="Built in" tonal outline />'))->toMatchArray(['data-md-variant' => 'tonal'])
->and(badgeAttributes('<x-badge solid />'))->toMatchArray(['data-md-dot' => 'data-md-dot'])->not->toHaveKey('data-md-variant')
->and((string) $this->blade('<x-badge value="Built in" solid color="primary" />'))->toContain('>Built in<')->not->toContain('aria-hidden');
}); });
it('renders its slot as HTML, and is a dot while the slot holds nothing but comments', function () { 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>'); $html = (string) $this->blade('<x-badge tonal><x-icon name="bolt" size="12" /> Pro</x-badge>');
expect($html) expect($html)
->toContain('h-6 gap-1 rounded-corner-sm') ->toContain('data-md-variant="tonal"')
->toContain('<svg') ->toContain('<svg')
->toContain(' Pro</span>') ->toContain(' Pro</span>')
->not->toContain('&lt;svg') ->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 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 tonal>\n <!-- nothing yet -->\n</x-badge>"))->toContain('data-md-dot')
->and((string) $this->blade('<x-badge max="99">120</x-badge>'))->toContain('>99+</span>'); ->and((string) $this->blade('<x-badge max="99">120</x-badge>'))->toContain('>99+</span>');
}); });
it('draws neutral ink on every variant', function () { it('paints each colour from its pair of roles, and neutral without a hue', function (string $color, array $roles) {
expect((string) $this->blade('<x-badge color="neutral" />'))->toContain('size-1.5 rounded-corner-full bg-on-surface-variant text-surface') expect(ComponentStylesheet::read('badge')->declarations("[data-md-badge][data-md-color='{$color}']"))->toBe([
->and((string) $this->blade('<x-badge value="7" color="neutral" />'))->toContain('tabular-nums bg-on-surface-variant text-surface') '--md-badge-color' => "var(--md-sys-color-{$roles[0]})",
->and((string) $this->blade('<x-badge value="Draft" tone="neutral" tonal />'))->toContain('type-label-md bg-surface-container-high text-on-surface-variant') '--md-badge-on-color' => "var(--md-sys-color-{$roles[1]})",
->and((string) $this->blade('<x-badge value="Draft" color="neutral" outline />'))->toContain('type-label-md border border-outline text-on-surface-variant'); '--md-badge-container' => "var(--md-sys-color-{$roles[2]})",
}); '--md-badge-on-container' => "var(--md-sys-color-{$roles[3]})",
]);
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([ })->with([
'dot' => ['<x-badge color="plain" class="bg-tertiary-container text-on-tertiary-container" />', 'size-1.5 rounded-corner-full'], 'primary' => ['primary', ['primary', 'on-primary', 'primary-container', 'on-primary-container']],
'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'], 'secondary' => ['secondary', ['secondary', 'on-secondary', 'secondary-container', 'on-secondary-container']],
'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'], 'tertiary' => ['tertiary', ['tertiary', 'on-tertiary', 'tertiary-container', 'on-tertiary-container']],
'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'], 'success' => ['success', ['success', 'on-success', 'success-container', 'on-success-container']],
'warning' => ['warning', ['warning', 'on-warning', 'warning-container', 'on-warning-container']],
'info' => ['info', ['info', 'on-info', 'info-container', 'on-info-container']],
'neutral' => ['neutral', ['on-surface-variant', 'surface', 'surface-container-high', 'on-surface-variant']],
]); ]);
it('keeps its default colours, and falls back to error on a colour it does not know', function () { it('is error by default, and falls back to error on a colour it does not know', function () {
expect((string) $this->blade('<x-badge value="4" />')) $css = ComponentStylesheet::read('badge');
->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 />')) expect($css->declarations('[data-md-badge]'))->toMatchArray([
->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"') '--md-badge-color' => 'var(--md-sys-color-error)',
->and((string) $this->blade('<x-badge value="Pro" outline color="success" />')) '--md-badge-on-color' => 'var(--md-sys-color-on-error)',
->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 text-on-surface-variant"') ])
->and((string) $this->blade('<x-badge value="3" color="sport-run" />'))->toContain('bg-error text-on-error'); ->and($css->declarations("[data-md-badge]:not([data-md-color='plain'])"))->toBe([
'background-color' => 'var(--md-badge-color)',
'color' => 'var(--md-badge-on-color)',
])
->and(badgeAttributes('<x-badge value="3" color="sport-run" />'))->toMatchArray(['data-md-color' => 'error']);
}); });
it('draws a solid status label in the colour itself, spoken like any label', function () { it('leaves a plain badge, and every colour, to the caller\'s CSS', function () {
$html = (string) $this->blade('<x-badge value="Built in" solid color="primary" />'); // `plain` matches none of the colour rules; a caller's class lands on the root untouched.
expect(badgeAttributes('<x-badge value="Run" color="plain" tonal class="bg-tertiary-container text-on-tertiary-container" />'))
expect($html) ->toMatchArray(['data-md-color' => 'plain', 'data-md-variant' => 'tonal', 'class' => 'bg-tertiary-container text-on-tertiary-container']);
->toContain('bg-primary text-on-primary')
->toContain('h-6 gap-1 rounded-corner-sm px-2 type-label-md')
->not->toContain('aria-hidden')
->toContain('>Built in<')
->and((string) $this->blade('<x-badge value="Draft" solid color="neutral" />'))
->toContain('bg-on-surface-variant text-surface')
->and((string) $this->blade('<x-badge solid />'))
->toContain('size-1.5');
}); });
@@ -45,7 +45,7 @@ it('badges the icon with a dot or a count that screen readers hear', function ()
$dot = (string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" :badge="true" badge-label="New mail" />'); $dot = (string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" :badge="true" badge-label="New mail" />');
expect($count)->toContain('999+')->toContain('<span class="sr-only">, 1200</span>') expect($count)->toContain('999+')->toContain('<span class="sr-only">, 1200</span>')
->and($dot)->toContain('size-1.5')->toContain('<span class="sr-only">, New mail</span>') ->and($dot)->toContain('data-md-dot')->toContain('<span class="sr-only">, New mail</span>')
->and((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" />'))->not->toContain('sr-only'); ->and((string) $this->blade('<x-navigation-bar-item label="Inbox" icon="inbox" link="/inbox" />'))->not->toContain('sr-only');
}); });