Put the badge on M3's anchor geometry and a visible edge

A floating dot sat 2px inside the icon's top-trailing corner where M3 puts it
flush, and a floating count 4px above it where M3 says 2px. An `outline` status
label drew its edge in outline-variant, the decorative role dividers use, which
is about 1.5:1 on surface — M3 asks a badge for 3:1, so it takes `outline`.
Plan step 18, actions.md ACT-23, ACT-24.

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
2026-09-14 06:11:52 +02:00
co-authored by Claude Opus 5
parent c33d90c653
commit 56d82b71f3
3 changed files with 16 additions and 13 deletions
@@ -371,8 +371,8 @@ A value the server changes animates after a morph (the SVG is `wire:ignore`; onl
### `<x-badge>` ### `<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 />` — 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="Built in" color="primary" solid />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container, in the colour itself (`solid`, for a label that has to stand out), or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`, `neutral`, `plain`; an unknown colour is `error`. - `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Built in" color="primary" solid />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container, in the colour itself (`solid`, for a label that has to stand out), or a neutral `outline` edge (the role that has to be seen, not the decorative `outline-variant` dividers use). `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="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 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. - `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. - 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.
+9 -6
View File
@@ -4,8 +4,11 @@
- `<x-badge />` the small badge, a 6px dot: something new, no number. - `<x-badge />` the small badge, a 6px dot: something new, no number.
- `<x-badge value="3" />` the large badge, 16px tall, label-small: a count. `max` caps what is - `<x-badge value="3" />` the large badge, 16px tall, label-small: a count. `max` caps what is
shown ("99+"). shown ("99+").
Both are `error` by default, as M3 draws them. `floating` pins one to the top-end corner of a Both are `error` by default, as M3 draws them, and an `outline` status label draws its edge in
`relative` parent an icon or an icon button: `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
corner of a `relative` parent, on M3's anchor geometry the dot flush in the corner, 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 class="relative inline-flex"><x-icon name="notifications" /><x-badge value="4" floating /></span>
@@ -16,7 +19,7 @@
and two without a hue of their own: and two without a hue of their own:
- `neutral` neutral ink on every variant: on-surface-variant with surface text as a dot or - `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 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`. 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 classes paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`.
@@ -65,7 +68,7 @@
$color === 'plain' => '', $color === 'plain' => '',
! $status, $solid => $filled[$color], ! $status, $solid => $filled[$color],
$tonal => $container[$color], $tonal => $container[$color],
default => 'border-outline-variant text-on-surface-variant', default => 'border-outline text-on-surface-variant',
}; };
$attributes = $attributes $attributes = $attributes
@@ -76,8 +79,8 @@
'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status, 'h-6 gap-1 rounded-corner-sm px-2 type-label-md' => $status,
'border' => $outline && ! $tonal && ! $solid && ! $dot, 'border' => $outline && ! $tonal && ! $solid && ! $dot,
$paint => $paint !== '', $paint => $paint !== '',
'absolute top-0.5 end-0.5' => $floating && $dot, 'absolute top-0 end-0' => $floating && $dot,
'absolute -top-1 start-[calc(100%-0.75rem)]' => $floating && ! $dot, 'absolute -top-0.5 start-[calc(100%-0.75rem)]' => $floating && ! $dot,
]) ])
->merge(array_filter([ ->merge(array_filter([
'aria-label' => $label, 'aria-label' => $label,
+5 -5
View File
@@ -14,9 +14,9 @@ it('is M3\'s large badge with a count, capped by max', function () {
}); });
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.5 end-0.5') expect((string) $this->blade('<x-badge floating />'))->toContain('absolute top-0 end-0')
->and((string) $this->blade('<x-badge value="3" floating label="3 new messages" />')) ->and((string) $this->blade('<x-badge value="3" floating label="3 new messages" />'))
->toContain('absolute -top-1') ->toContain('absolute -top-0.5')
->toContain('aria-label="3 new messages"') ->toContain('aria-label="3 new messages"')
->not->toContain('aria-hidden'); ->not->toContain('aria-hidden');
}); });
@@ -27,7 +27,7 @@ it('draws a status label in a container or an outline', function () {
->toContain('bg-success-container text-on-success-container') ->toContain('bg-success-container text-on-success-container')
->not->toContain('aria-hidden') ->not->toContain('aria-hidden')
->and((string) $this->blade('<x-badge value="Pro" outline />')) ->and((string) $this->blade('<x-badge value="Pro" outline />'))
->toContain('border border-outline-variant text-on-surface-variant') ->toContain('border border-outline text-on-surface-variant')
->not->toContain('bg-error'); ->not->toContain('bg-error');
}); });
@@ -48,7 +48,7 @@ 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') 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="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" 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'); ->and((string) $this->blade('<x-badge value="Draft" color="neutral" outline />'))->toContain('type-label-md border border-outline text-on-surface-variant');
}); });
it('leaves a plain badge to the caller\'s colour classes', function (string $badge, string $shape) { it('leaves a plain badge to the caller\'s colour classes', function (string $badge, string $shape) {
@@ -73,7 +73,7 @@ it('keeps its default colours, and falls back to error on a colour it does not k
->and((string) $this->blade('<x-badge value="Active" tonal />')) ->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"') ->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" />')) ->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"') ->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((string) $this->blade('<x-badge value="3" color="sport-run" />'))->toContain('bg-error text-on-error');
}); });