Draw the alert without Tailwind

Plan step 36 (actions). <x-alert> renders data-md-alert with
data-md-color and data-md-alert-content/-title/-description/-actions/
-dismiss; no class list. alert.css draws the state's tinted container
colour across all eight colours, the medium corner, and the dismiss
button reaching M3's 48px target from its 40px (ACT-18) with its own
state layer, focus ring and touch target. AlertTest is rewritten on
the hooks and ComponentStylesheet.

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 16:48:37 +02:00
co-authored by Claude Opus 5
parent 15cd306aba
commit 05c3b968ba
5 changed files with 209 additions and 23 deletions
+1
View File
@@ -29,6 +29,7 @@
@import './components/rich-tooltip.css';
@import './components/toast.css';
@import './components/progress.css';
@import './components/alert.css';
/* Inputs, selection and data */
@import './components/form.css';
+153
View File
@@ -0,0 +1,153 @@
/*
* <x-alert>: a notice in the page — not an M3 component (M3's banner is gone), drawn in M3's own
* terms as the plan's deliberate-deviations note records: the state's container colour (tinted,
* never filled), a medium corner, the state's icon, title-small/body-medium text and an actions
* row of text buttons pulled back to the text's edge, the trick every text-button row in this
* package uses (chip.css, fab-menu.css, rich-tooltip.css).
*
* `data-md-color`: info (the default), success, warning, error, primary, secondary, tertiary or
* neutral (surface-container-high, on-surface — it has no state colour of its own). The dismiss
* button reaches M3's 48px target from its drawn 40px (ACT-18) with its own state layer, focus
* ring and touch target rather than the shared classes (foundation/interaction.css): its `-8px`
* margin, pulling it flush with the alert's own edge, already claims the touch target's `::after`
* inset.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-alert] {
display: flex;
align-items: flex-start;
gap: 12px;
border-radius: var(--md-sys-shape-corner-md);
padding: var(--md-sys-measurement-space200);
background-color: var(--md-sys-color-info-container);
color: var(--md-sys-color-on-info-container);
}
[data-md-alert][data-md-color='success'] {
background-color: var(--md-sys-color-success-container);
color: var(--md-sys-color-on-success-container);
}
[data-md-alert][data-md-color='warning'] {
background-color: var(--md-sys-color-warning-container);
color: var(--md-sys-color-on-warning-container);
}
[data-md-alert][data-md-color='error'] {
background-color: var(--md-sys-color-error-container);
color: var(--md-sys-color-on-error-container);
}
[data-md-alert][data-md-color='primary'] {
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
[data-md-alert][data-md-color='secondary'] {
background-color: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
}
[data-md-alert][data-md-color='tertiary'] {
background-color: var(--md-sys-color-tertiary-container);
color: var(--md-sys-color-on-tertiary-container);
}
[data-md-alert][data-md-color='neutral'] {
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface);
}
[data-md-alert-content] {
min-width: 0;
flex: 1 1 auto;
align-self: center;
}
[data-md-alert-content] > * + * {
margin-top: var(--md-sys-measurement-space50);
}
[data-md-alert-title] {
font: var(--md-sys-typescale-title-sm);
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
font-variation-settings: normal;
}
[data-md-alert-description] {
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
font-variation-settings: normal;
}
[data-md-alert-actions] {
display: flex;
flex-wrap: wrap;
gap: var(--md-sys-measurement-space100);
margin-inline-start: -12px;
padding-top: var(--md-sys-measurement-space50);
}
[data-md-alert-dismiss] {
position: relative;
isolation: isolate;
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: var(--md-sys-measurement-space500);
height: var(--md-sys-measurement-space500);
margin: -8px;
border-radius: var(--md-sys-shape-corner-full);
cursor: pointer;
outline: none;
&::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: currentColor;
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
min-width: var(--md-sys-measurement-space600);
min-height: var(--md-sys-measurement-space600);
translate: -50% -50%;
}
@media (hover: hover) {
&:hover::before {
opacity: var(--md-sys-state-hover-state-layer-opacity);
}
}
&:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
&:focus-visible::before {
opacity: var(--md-sys-state-focus-state-layer-opacity);
}
&:active::before {
opacity: var(--md-sys-state-pressed-state-layer-opacity);
}
}
}
+16 -18
View File
@@ -6,14 +6,17 @@
`color` (alias `tone`): `info` (the default), `success`, `warning`, `error`, `primary`,
`secondary`, `tertiary`, or `neutral` for surface-container-high. `icon` replaces the state's
icon; `:icon="false"` drops it. `dismissible` adds a close button that hides it in the browser,
40px drawn with the 48px target M3 asks for.
40px drawn with the 48px target M3 asks for (ACT-18).
It is a `role="status"`, whatever its colour: an alert is usually part of the page as it
renders, and `role="alert"` is an assertive live region that some screen readers announce over
the page title on load — and that a Livewire morph re-announces. `assertive` opts into
`role="alert"` for the case the prop is named after: a notice put on screen in answer to
something the person just did. M3 publishes no banner; the nearest thing it does publish, the
snackbar, says polite and never assertive. --}}
something the person just did (ACT-36). M3 publishes no banner; the nearest thing it does
publish, the snackbar, says polite and never assertive.
Drawn by resources/css/components/alert.css from `data-md-alert` and `data-md-color`; no
class list. --}}
@props([
'title' => null,
@@ -32,41 +35,36 @@
'info' => 'info', 'success' => 'check_circle', 'warning' => 'warning', 'error' => 'error',
'primary' => 'info', 'secondary' => 'info', 'tertiary' => 'info', 'neutral' => 'info',
][$color]);
$colours = [
'info' => 'bg-info-container text-on-info-container', 'success' => 'bg-success-container text-on-success-container',
'warning' => 'bg-warning-container text-on-warning-container', 'error' => 'bg-error-container text-on-error-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', 'neutral' => 'bg-surface-container-high text-on-surface',
][$color];
@endphp
<div
role="{{ $assertive ? 'alert' : 'status' }}"
@if ($dismissible) x-data="{ shown: true }" x-show="shown" x-transition.opacity @endif
{{ $attributes->class(['flex items-start gap-3 rounded-corner-md p-4', $colours]) }}
data-md-alert
data-md-color="{{ $color }}"
{{ $attributes }}
>
@if ($icon)
<x-livewire-material::icon :name="$icon" filled class="size-6" />
<x-livewire-material::icon :name="$icon" filled />
@endif
<div class="min-w-0 flex-1 space-y-1 self-center">
<div data-md-alert-content>
@if ($title)
<p class="type-title-sm">{{ $title }}</p>
<p data-md-alert-title>{{ $title }}</p>
@endif
@if ($description || $slot->isNotEmpty())
<div class="type-body-md">{{ $description ?? $slot }}</div>
<div data-md-alert-description>{{ $description ?? $slot }}</div>
@endif
@isset($actions)
<div class="-ms-3 flex flex-wrap gap-2 pt-1">{{ $actions }}</div>
<div data-md-alert-actions>{{ $actions }}</div>
@endisset
</div>
@if ($dismissible)
<button type="button" class="state-layer focus-ring touch-target -m-2 inline-flex size-10 shrink-0 items-center justify-center rounded-corner-full" aria-label="{{ __('Dismiss') }}" x-on:click="shown = false">
<x-livewire-material::icon name="close" optical="20" class="size-5" />
<button type="button" data-md-alert-dismiss aria-label="{{ __('Dismiss') }}" x-on:click="shown = false">
<x-livewire-material::icon name="close" size="20" />
</button>
@endif
</div>
@@ -28,6 +28,7 @@ dataset('action components', [
'rich-tooltip',
'toast',
'progress',
'alert',
]);
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
+38 -5
View File
@@ -1,23 +1,34 @@
<?php
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('states a notice in its state\'s container, with the state\'s icon', function () {
$html = (string) $this->blade('<x-alert title="Storage almost full" description="3.8 of 4 GB" color="warning" />');
expect($html)
->toContain('role="status"')
->toContain('bg-warning-container text-on-warning-container')
->toContain('data-md-alert')
->toContain('data-md-color="warning"')
->toContain('Storage almost full')
->toContain('3.8 of 4 GB')
->toContain('<svg');
->toContain('<svg')
->and(ComponentStylesheet::read('alert')->declarations("[data-md-alert][data-md-color='warning']"))->toBe([
'background-color' => 'var(--md-sys-color-warning-container)',
'color' => 'var(--md-sys-color-on-warning-container)',
]);
});
it('is a status whatever its colour, and interrupts only when asked to', function () {
expect((string) $this->blade('<x-alert description="Saved." tone="success" />'))->toContain('role="status"')->toContain('bg-success-container')
->and((string) $this->blade('<x-alert description="Heads up." />'))->toContain('role="status"')->toContain('bg-info-container')
expect((string) $this->blade('<x-alert description="Saved." tone="success" />'))->toContain('role="status"')->toContain('data-md-color="success"')
->and((string) $this->blade('<x-alert description="Heads up." />'))->toContain('role="status"')->toContain('data-md-color="info"')
->and((string) $this->blade('<x-alert description="Broken." color="error" />'))->toContain('role="status"')
->and((string) $this->blade('<x-alert description="Broken." color="error" assertive />'))->toContain('role="alert"');
});
it('falls back to info for an unknown colour', function () {
expect((string) $this->blade('<x-alert description="Odd." color="purple" />'))->toContain('data-md-color="info"');
});
it('takes actions, drops its icon on request, and can be dismissed', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-alert :icon="false" dismissible>
@@ -28,9 +39,31 @@ it('takes actions, drops its icon on request, and can be dismissed', function ()
expect($html)
->toContain('The connection dropped.')
->toContain('data-md-alert-actions')
->toContain('Try again')
->toContain('x-data="{ shown: true }"')
->toContain('data-md-alert-dismiss')
->toContain('aria-label="Dismiss"')
->toContain('touch-target')
->and(substr_count($html, '<svg'))->toBe(1);
});
it('reaches 48px from its 40px dismiss button', function () {
$css = ComponentStylesheet::read('alert');
expect($css->declarations('[data-md-alert-dismiss]'))->toMatchArray([
'width' => 'var(--md-sys-measurement-space500)',
'height' => 'var(--md-sys-measurement-space500)',
])
->and($css->declarations('[data-md-alert-dismiss]::after'))->toMatchArray([
'min-width' => 'var(--md-sys-measurement-space600)',
'min-height' => 'var(--md-sys-measurement-space600)',
]);
});
it('draws neutral in surface-container-high, on-surface', function () {
expect((string) $this->blade('<x-alert description="Draft." color="neutral" />'))->toContain('data-md-color="neutral"')
->and(ComponentStylesheet::read('alert')->declarations("[data-md-alert][data-md-color='neutral']"))->toBe([
'background-color' => 'var(--md-sys-color-surface-container-high)',
'color' => 'var(--md-sys-color-on-surface)',
]);
});