diff --git a/resources/css/components.css b/resources/css/components.css index 0ab517cb..bedf3183 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -31,6 +31,7 @@ @import './components/progress.css'; @import './components/alert.css'; @import './components/stat.css'; +@import './components/empty-state.css'; /* Inputs, selection and data */ @import './components/form.css'; diff --git a/resources/css/components/empty-state.css b/resources/css/components/empty-state.css new file mode 100644 index 00000000..26577e4c --- /dev/null +++ b/resources/css/components/empty-state.css @@ -0,0 +1,69 @@ +/* + * : "you have nothing here yet" — not an M3 component; built from M3 Expressive's + * parts, a shape in secondary-container behind an icon in on-secondary-container, a title-large + * title, body-medium description at 448px and a centred row of actions. + * + * The shape is `position: absolute`, so the icon needs its own stacking context + * (`position: relative`) to paint above it in DOM order rather than losing to the shape's own — + * an application's illustration slot replaces both and sizes itself. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './icon.css'; +@import './shape.css'; + +@layer material.components { + [data-md-empty-state] { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--md-sys-measurement-space200); + padding-inline: var(--md-sys-measurement-space200); + padding-block: var(--md-sys-measurement-space500); + text-align: center; + } + + [data-md-empty-state-illustration] { + position: relative; + display: grid; + width: 112px; + height: 112px; + place-items: center; + } + + [data-md-empty-state-shape] { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + color: var(--md-sys-color-secondary-container); + } + + [data-md-empty-state-icon] { + position: relative; + color: var(--md-sys-color-on-secondary-container); + } + + [data-md-empty-state-title] { + font: var(--md-sys-typescale-title-lg); + letter-spacing: var(--md-sys-typescale-title-lg-tracking); + font-variation-settings: normal; + } + + [data-md-empty-state-description] { + max-width: 448px; + color: var(--md-sys-color-on-surface-variant); + font: var(--md-sys-typescale-body-md); + letter-spacing: var(--md-sys-typescale-body-md-tracking); + font-variation-settings: normal; + } + + [data-md-empty-state-actions] { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 12px; + padding-top: var(--md-sys-measurement-space100); + } +} diff --git a/resources/views/components/empty-state.blade.php b/resources/views/components/empty-state.blade.php index 6c6d28d7..072da1a6 100644 --- a/resources/views/components/empty-state.blade.php +++ b/resources/views/components/empty-state.blade.php @@ -12,13 +12,15 @@ An application with its own artwork puts it in the `illustration` slot, which is drawn in place of the shape and icon (both props are then unused). The slot sizes itself; its attributes go on - the element around it, so `class` can set the colour an SVG's `currentColor` takes: + the element around it, so `style` can set the colour an SVG's `currentColor` takes: - + - A slot holding only whitespace or comments counts as empty, and the shape and icon stay. --}} + A slot holding only whitespace or comments counts as empty, and the shape and icon stay. + + Drawn by resources/css/components/empty-state.css from `data-md-empty-state`; no class list. --}} @props([ 'icon' => 'inbox', @@ -27,25 +29,25 @@ 'description' => null, ]) -
class('flex flex-col items-center gap-4 px-4 py-10 text-center') }}> +
@if (isset($illustration) && $illustration->hasActualContent())
attributes }}>{{ $illustration }}
@else -
- - +
+ +
@endif @if ($title) -

{{ $title }}

+

{{ $title }}

@endif @if ($description || $slot->isNotEmpty()) -
{{ $description ?? $slot }}
+
{{ $description ?? $slot }}
@endif @isset($actions) -
{{ $actions }}
+
{{ $actions }}
@endisset
diff --git a/tests/Feature/Components/ActionStylesheetsTest.php b/tests/Feature/Components/ActionStylesheetsTest.php index 3876e49c..d17376ae 100644 --- a/tests/Feature/Components/ActionStylesheetsTest.php +++ b/tests/Feature/Components/ActionStylesheetsTest.php @@ -30,6 +30,7 @@ dataset('action components', [ 'progress', 'alert', 'stat', + 'empty-state', ]); it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) { diff --git a/tests/Feature/Components/EmptyStateTest.php b/tests/Feature/Components/EmptyStateTest.php index 840e1707..194f41a0 100644 --- a/tests/Feature/Components/EmptyStateTest.php +++ b/tests/Feature/Components/EmptyStateTest.php @@ -1,5 +1,7 @@ blade(<<<'BLADE' @@ -8,12 +10,21 @@ it('sets an icon on an Expressive shape above its words and action', function () BLADE); expect($html) - ->toContain('text-secondary-container') - ->toContain('text-on-secondary-container') - ->toContain('

No shares yet

') + ->toContain('data-md-empty-state') + ->toContain('data-md-empty-state-shape') + ->toContain('data-md-empty-state-icon') + ->toContain('

No shares yet

') + ->toContain('data-md-empty-state-description') ->toContain('Files you share appear here.') + ->toContain('data-md-empty-state-actions') ->toContain('Upload files') - ->and(substr_count($html, 'toBe(2); + ->and(substr_count($html, 'toBe(2) + ->and(ComponentStylesheet::read('empty-state')->declarations('[data-md-empty-state-shape]'))->toMatchArray([ + 'color' => 'var(--md-sys-color-secondary-container)', + ]) + ->and(ComponentStylesheet::read('empty-state')->declarations('[data-md-empty-state-icon]'))->toMatchArray([ + 'color' => 'var(--md-sys-color-on-secondary-container)', + ]); }); it('draws an illustration in place of the shape and icon', function () { @@ -25,8 +36,8 @@ it('draws an illustration in place of the shape and icon', function () { expect($html) ->toContain('
') - ->not->toContain('text-secondary-container') - ->not->toContain('text-on-secondary-container') + ->not->toContain('data-md-empty-state-shape') + ->not->toContain('data-md-empty-state-icon') ->toContain('No routes yet') ->and(substr_count($html, 'toBe(1); }); @@ -41,8 +52,8 @@ it('keeps the shape and icon while the illustration slot holds nothing but comme BLADE); expect($html) - ->toContain('text-secondary-container') - ->toContain('text-on-secondary-container') + ->toContain('data-md-empty-state-shape') + ->toContain('data-md-empty-state-icon') ->not->toContain('artwork to come') ->and(substr_count($html, 'toBe(2); });