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:
co-authored by
Claude Opus 5
parent
15cd306aba
commit
05c3b968ba
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)',
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user