diff --git a/resources/css/components.css b/resources/css/components.css index 71a24719..c46c7ed8 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -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'; diff --git a/resources/css/components/alert.css b/resources/css/components/alert.css new file mode 100644 index 00000000..8b5e0dc3 --- /dev/null +++ b/resources/css/components/alert.css @@ -0,0 +1,153 @@ +/* + * : 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); + } + } +} diff --git a/resources/views/components/alert.blade.php b/resources/views/components/alert.blade.php index e16740f4..595dddf1 100644 --- a/resources/views/components/alert.blade.php +++ b/resources/views/components/alert.blade.php @@ -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
class(['flex items-start gap-3 rounded-corner-md p-4', $colours]) }} + data-md-alert + data-md-color="{{ $color }}" + {{ $attributes }} > @if ($icon) - + @endif -
+
@if ($title) -

{{ $title }}

+

{{ $title }}

@endif @if ($description || $slot->isNotEmpty()) -
{{ $description ?? $slot }}
+
{{ $description ?? $slot }}
@endif @isset($actions) -
{{ $actions }}
+
{{ $actions }}
@endisset
@if ($dismissible) - @endif
diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php index cebf565d..0e81cdc4 100644 --- a/tests/Feature/Components/ActionStylesheetsTest.php +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -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) { diff --git a/tests/Feature/Components/AlertTest.php b/tests/Feature/Components/AlertTest.php index 4dd6b310..d592b7a3 100644 --- a/tests/Feature/Components/AlertTest.php +++ b/tests/Feature/Components/AlertTest.php @@ -1,23 +1,34 @@ blade(''); 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('toContain('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(''))->toContain('role="status"')->toContain('bg-success-container') - ->and((string) $this->blade(''))->toContain('role="status"')->toContain('bg-info-container') + expect((string) $this->blade(''))->toContain('role="status"')->toContain('data-md-color="success"') + ->and((string) $this->blade(''))->toContain('role="status"')->toContain('data-md-color="info"') ->and((string) $this->blade(''))->toContain('role="status"') ->and((string) $this->blade(''))->toContain('role="alert"'); }); +it('falls back to info for an unknown colour', function () { + expect((string) $this->blade(''))->toContain('data-md-color="info"'); +}); + it('takes actions, drops its icon on request, and can be dismissed', function () { $html = (string) $this->blade(<<<'BLADE' @@ -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, '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(''))->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)', + ]); +});