diff --git a/resources/css/components.css b/resources/css/components.css index 6e5e555c..f965e9d3 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -14,6 +14,7 @@ /* Actions and communication */ @import './components/loading.css'; @import './components/tooltip.css'; +@import './components/badge.css'; /* Inputs, selection and data */ diff --git a/resources/css/components/badge.css b/resources/css/components/badge.css new file mode 100644 index 00000000..83f52f6e --- /dev/null +++ b/resources/css/components/badge.css @@ -0,0 +1,143 @@ +/* + * : 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; + } +} diff --git a/resources/views/components/badge.blade.php b/resources/views/components/badge.blade.php index 2f1a04ef..d0b1b11d 100644 --- a/resources/views/components/badge.blade.php +++ b/resources/views/components/badge.blade.php @@ -7,10 +7,10 @@ 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-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: + corner of a `position: relative` parent, on M3's anchor geometry — the dot flush in the corner, + the count 2px above it — an icon or an icon button: - + 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 @@ -21,13 +21,17 @@ count (the ink an outline badge already writes in), surface-container-high with 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 - caller's classes paint it: ``. + caller's CSS paints it. The value is `value`, or the slot, which renders as HTML — an icon beside the word: - ` Pro`. With neither it is a dot. + ` 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. --}} + 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([ 'value' => null, @@ -54,38 +58,23 @@ $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, $solid => $filled[$color], - $tonal => $container[$color], - default => 'border-outline text-on-surface-variant', + // `solid` wins over `tonal`, and either over `outline`, as the three have always resolved. + $variant = match (true) { + ! $status => null, + (bool) $solid => 'solid', + (bool) $tonal => 'tonal', + default => 'outline', }; - $attributes = $attributes - ->class([ - 'inline-flex shrink-0 items-center justify-center whitespace-nowrap', - '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, - '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-hidden' => $label === null && ! $status ? 'true' : null, - ])); + $attributes = $attributes->merge(array_filter([ + 'data-md-badge' => true, + 'data-md-dot' => $dot ? true : null, + 'data-md-variant' => $variant, + 'data-md-color' => $color, + 'data-md-floating' => $floating ? true : null, + 'aria-label' => $label, + 'aria-hidden' => $label === null && ! $status ? 'true' : null, + ], fn ($value): bool => $value !== null)); @endphp @unless ($dot)@if ($markup){{ $slot }}@else{{ $text }}@endif@endunless diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php index ca1a0054..777aaba1 100644 --- a/tests/Feature/Components/ActionStylesheetsTest.php +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -13,6 +13,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet; dataset('action components', [ 'loading', 'tooltip', + 'badge', ]); it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { diff --git a/tests/Feature/Components/BadgeTest.php b/tests/Feature/Components/BadgeTest.php index 4dad40a8..71902811 100644 --- a/tests/Feature/Components/BadgeTest.php +++ b/tests/Feature/Components/BadgeTest.php @@ -1,92 +1,145 @@ blade('')) - ->toContain('size-1.5 rounded-corner-full') - ->toContain('bg-error text-on-error') - ->toContain('aria-hidden="true"'); +use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet; + +/** + * The attributes of the rendered badge, by name. + * + * @return array + */ +function badgeAttributes(string $blade): array +{ + preg_match('/]*)>/', (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(''))->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 () { - expect((string) $this->blade(''))->toContain('h-4 min-w-4')->toContain('>4') + $css = ComponentStylesheet::read('badge'); + + expect((string) $this->blade(''))->toContain('>4')->not->toContain('data-md-dot')->not->toContain('data-md-variant') ->and((string) $this->blade(''))->toContain('>999+') - ->and((string) $this->blade(''))->toContain('>12')->toContain('bg-primary text-on-primary'); + ->and((string) $this->blade(''))->toContain('>12')->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 () { - expect((string) $this->blade(''))->toContain('absolute top-0 end-0') - ->and((string) $this->blade('')) - ->toContain('absolute -top-0.5') - ->toContain('aria-label="3 new messages"') - ->not->toContain('aria-hidden'); + $css = ComponentStylesheet::read('badge'); + + expect((string) $this->blade(''))->toContain('data-md-floating') + ->and(badgeAttributes('')) + ->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 () { - expect((string) $this->blade('')) - ->toContain('h-6 gap-1 rounded-corner-sm px-2 type-label-md') - ->toContain('bg-success-container text-on-success-container') - ->not->toContain('aria-hidden') - ->and((string) $this->blade('')) - ->toContain('border border-outline text-on-surface-variant') - ->not->toContain('bg-error'); +it('draws a status label in a container or an outline, spoken like any label', function () { + $css = ComponentStylesheet::read('badge'); + + expect(badgeAttributes('')) + ->toBe(['data-md-badge' => 'data-md-badge', 'data-md-variant' => 'tonal', 'data-md-color' => 'success']) + ->and(badgeAttributes('')) + ->toBe(['data-md-badge' => 'data-md-badge', 'data-md-variant' => 'outline', 'data-md-color' => '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(''))->toMatchArray(['data-md-variant' => 'solid', 'data-md-color' => 'primary']) + ->and(badgeAttributes(''))->toMatchArray(['data-md-variant' => 'tonal']) + ->and(badgeAttributes(''))->toMatchArray(['data-md-dot' => 'data-md-dot'])->not->toHaveKey('data-md-variant') + ->and((string) $this->blade(''))->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 () { - $html = (string) $this->blade(' Pro'); + $html = (string) $this->blade(' Pro'); expect($html) - ->toContain('h-6 gap-1 rounded-corner-sm') + ->toContain('data-md-variant="tonal"') ->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("\n \n"))->toContain('data-md-dot') ->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 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)-/'); +it('paints each colour from its pair of roles, and neutral without a hue', function (string $color, array $roles) { + expect(ComponentStylesheet::read('badge')->declarations("[data-md-badge][data-md-color='{$color}']"))->toBe([ + '--md-badge-color' => "var(--md-sys-color-{$roles[0]})", + '--md-badge-on-color' => "var(--md-sys-color-{$roles[1]})", + '--md-badge-container' => "var(--md-sys-color-{$roles[2]})", + '--md-badge-on-container' => "var(--md-sys-color-{$roles[3]})", + ]); })->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'], + 'primary' => ['primary', ['primary', 'on-primary', 'primary-container', 'on-primary-container']], + 'secondary' => ['secondary', ['secondary', 'on-secondary', 'secondary-container', 'on-secondary-container']], + 'tertiary' => ['tertiary', ['tertiary', 'on-tertiary', 'tertiary-container', 'on-tertiary-container']], + '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 () { - 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 text-on-surface-variant"') - ->and((string) $this->blade(''))->toContain('bg-error text-on-error'); +it('is error by default, and falls back to error on a colour it does not know', function () { + $css = ComponentStylesheet::read('badge'); + + expect($css->declarations('[data-md-badge]'))->toMatchArray([ + '--md-badge-color' => 'var(--md-sys-color-error)', + '--md-badge-on-color' => 'var(--md-sys-color-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(''))->toMatchArray(['data-md-color' => 'error']); }); -it('draws a solid status label in the colour itself, spoken like any label', function () { - $html = (string) $this->blade(''); - - expect($html) - ->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('')) - ->toContain('bg-on-surface-variant text-surface') - ->and((string) $this->blade('')) - ->toContain('size-1.5'); +it('leaves a plain badge, and every colour, to the caller\'s CSS', function () { + // `plain` matches none of the colour rules; a caller's class lands on the root untouched. + expect(badgeAttributes('')) + ->toMatchArray(['data-md-color' => 'plain', 'data-md-variant' => 'tonal', 'class' => 'bg-tertiary-container text-on-tertiary-container']); }); diff --git a/tests/Feature/Components/NavigationBarTest.php b/tests/Feature/Components/NavigationBarTest.php index 43003011..129f99dd 100644 --- a/tests/Feature/Components/NavigationBarTest.php +++ b/tests/Feature/Components/NavigationBarTest.php @@ -45,7 +45,7 @@ it('badges the icon with a dot or a count that screen readers hear', function () $dot = (string) $this->blade(''); expect($count)->toContain('999+')->toContain(', 1200') - ->and($dot)->toContain('size-1.5')->toContain(', New mail') + ->and($dot)->toContain('data-md-dot')->toContain(', New mail') ->and((string) $this->blade(''))->not->toContain('sr-only'); });