diff --git a/config/livewire-material.php b/config/livewire-material.php index a2a8b6a5..6ddfc30c 100644 --- a/config/livewire-material.php +++ b/config/livewire-material.php @@ -49,6 +49,20 @@ return [ 'variant' => 'outlined', ], + /* + |-------------------------------------------------------------------------- + | Pagination + |-------------------------------------------------------------------------- + | + | Draw Laravel's and Livewire's paginators in M3: the package's views are + | put in front of `pagination::tailwind` and `livewire::tailwind` (and + | their simple versions). An application's own published pagination views + | still win. + | + */ + + 'pagination' => true, + /* |-------------------------------------------------------------------------- | Node diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 3e8d3910..afe1af0b 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -549,6 +549,31 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (segmented buttons for settings pages). Every toggle on a page shares the store. +### ``, `` + +A data table: write plain ``, ``, ``, `` inside `` (`size="xs"` for a dense one); cell utilities (`text-end`, `whitespace-nowrap`) always win. Scrolling is yours: wrap it in `
`. A row that opens something is `data-list-row` with one `data-list-open` control; a selected row is `aria-selected="true"`. + +`Size` sorts through the Livewire property `sortBy` (`['column' => …, 'direction' => 'asc'|'desc']`; `model` names another), with `aria-sort`. + +```blade +
+ + NameSize + + @foreach ($shares as $share) + + {{ $share->name }} + {{ $share->size }} + + @endforeach + + +
+{{ $shares->links() }} +``` + +Pagination: `$paginator->links()` (Laravel and Livewire, full and simple/cursor) is drawn in M3 — current page in secondary-container, "Page 2 of 7" on a phone. Turn off with `config('livewire-material.pagination')` = `false`; published `vendor/pagination` or `vendor/livewire` views still win. + ## Testing the design ```php diff --git a/resources/css/components/table.css b/resources/css/components/table.css new file mode 100644 index 00000000..d2a82a80 --- /dev/null +++ b/resources/css/components/table.css @@ -0,0 +1,78 @@ +/* + * Data tables: `` (resources/views/components/table.blade.php), whose callers write plain + * ``, ``, `` and `` inside it. + * + * So the styling is descendant selectors on the one attribute the component sets, all inside + * `:where()` and `@layer components`: a caller's `text-end` or `whitespace-nowrap` on a cell always + * wins. Header cells in title-small on-surface-variant over an outline-variant rule, body cells in + * body-medium between faint rules. One density step tighter on a fine pointer — keyed on the + * pointer, not the width, so a touch tablet in landscape keeps rows a finger can hit. A row that + * opens something is `data-list-row` and answers a pointer as a list row does (components/list.css). + */ + +@layer components { + /* `relative` makes the table the containing block for anything absolute inside it — an `sr-only` + header label, a tooltip. Without it they escape the scroll box and widen a phone's layout + viewport. */ + [data-table] { + --cell-x: 0.75rem; + --cell-y: 0.75rem; + + position: relative; + width: 100%; + border-collapse: collapse; + color: var(--md-sys-color-on-surface); + font: var(--md-sys-typescale-body-md); + letter-spacing: var(--md-sys-typescale-body-md-tracking); + } + + [data-table][data-size="xs"] { + --cell-x: 0.5rem; + --cell-y: 0.5rem; + + font: var(--md-sys-typescale-body-sm); + letter-spacing: var(--md-sys-typescale-body-sm-tracking); + } + + @media (pointer: fine) { + [data-table] { + --cell-y: 0.5rem; + } + + [data-table][data-size="xs"] { + --cell-y: 0.25rem; + } + } + + [data-table] :where(th, td) { + padding: var(--cell-y) var(--cell-x); + text-align: start; + vertical-align: middle; + } + + [data-table] :where(thead th) { + color: var(--md-sys-color-on-surface-variant); + font: var(--md-sys-typescale-title-sm); + letter-spacing: var(--md-sys-typescale-title-sm-tracking); + white-space: nowrap; + border-bottom: 1px solid var(--md-sys-color-outline-variant); + } + + [data-table][data-size="xs"] :where(thead th) { + font: var(--md-sys-typescale-label-md); + letter-spacing: var(--md-sys-typescale-label-md-tracking); + } + + [data-table] :where(tbody tr) { + border-bottom: 1px solid color-mix(in srgb, var(--md-sys-color-outline-variant) 60%, transparent); + } + + [data-table] :where(tbody tr:last-child) { + border-bottom: 0; + } + + [data-table] :where(tbody tr[aria-selected="true"]) { + background-color: var(--md-sys-color-secondary-container); + color: var(--md-sys-color-on-secondary-container); + } +} diff --git a/resources/css/material.css b/resources/css/material.css index 7c2f9101..6f9aa261 100644 --- a/resources/css/material.css +++ b/resources/css/material.css @@ -29,6 +29,7 @@ @import './components/tabs.css'; @import './components/app-bar.css'; @import './components/toolbar.css'; +@import './components/table.css'; @layer base { html { diff --git a/resources/views/components/sort-header.blade.php b/resources/views/components/sort-header.blade.php new file mode 100644 index 00000000..7f4a3160 --- /dev/null +++ b/resources/views/components/sort-header.blade.php @@ -0,0 +1,37 @@ +{{-- A column header that sorts the table, bound to a Livewire property shaped + `['column' => …, 'direction' => 'asc'|'desc']` — `sortBy` by default, or the one `model` names. + + Pressing it sorts by this column, ascending first and then flipping; the arrow says which way, + and `aria-sort` says it to a screen reader. A column that cannot be sorted is a plain ``. + `class` lands on the `` (`text-end` for a number column moves the button with it). --}} + +@props([ + 'sortBy' => [], + 'column', + 'model' => 'sortBy', +]) + +@php + $active = ($sortBy['column'] ?? null) === $column; + $direction = $active ? (($sortBy['direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc') : null; + $next = $active && $direction === 'asc' ? 'desc' : 'asc'; +@endphp + + + + diff --git a/resources/views/components/table.blade.php b/resources/views/components/table.blade.php new file mode 100644 index 00000000..c8beea84 --- /dev/null +++ b/resources/views/components/table.blade.php @@ -0,0 +1,16 @@ +{{-- A data table. Its callers write ``, ``, `` and `` as they always do; how they + look is resources/css/components/table.css, keyed on the `data-table` attribute set here (the + top of that file says why). + + `size="xs"` for a table inside a panel inside a panel. A selected row is + ``. Horizontal scrolling stays the caller's — wrap the table in + `
` where the page needs it. A column that sorts is + ``. --}} + +@props([ + 'size' => 'sm', +]) + + + {{ $slot }} +
diff --git a/resources/views/pagination/laravel/simple-tailwind.blade.php b/resources/views/pagination/laravel/simple-tailwind.blade.php new file mode 100644 index 00000000..123d7e15 --- /dev/null +++ b/resources/views/pagination/laravel/simple-tailwind.blade.php @@ -0,0 +1,18 @@ +{{-- Laravel's simple and cursor paginator, drawn in M3: previous and next as outlined buttons + (the package puts this in front of `pagination::simple-tailwind`). --}} + +@if ($paginator->hasPages()) + +@endif diff --git a/resources/views/pagination/laravel/tailwind.blade.php b/resources/views/pagination/laravel/tailwind.blade.php new file mode 100644 index 00000000..162cb5a6 --- /dev/null +++ b/resources/views/pagination/laravel/tailwind.blade.php @@ -0,0 +1,57 @@ +{{-- Laravel's paginator (`$items->links()` outside Livewire), drawn in M3 as Livewire's is + (resources/views/pagination/livewire/tailwind.blade.php): links instead of actions, the current + page in secondary-container, "Page 2 of 7" on a phone. The package puts this in front of + `pagination::tailwind` (config `livewire-material.pagination`). --}} + +@php + $step = 'grid size-10 place-items-center rounded-corner-full type-label-lg tabular-nums'; + $live = $step.' state-layer focus-ring text-on-surface'; + $dead = $step.' text-on-surface/38'; +@endphp + +@if ($paginator->hasPages()) + +@endif diff --git a/resources/views/pagination/livewire/simple-tailwind.blade.php b/resources/views/pagination/livewire/simple-tailwind.blade.php new file mode 100644 index 00000000..7c61ac6f --- /dev/null +++ b/resources/views/pagination/livewire/simple-tailwind.blade.php @@ -0,0 +1,40 @@ +{{-- Livewire's simple and cursor paginator, drawn in M3: previous and next as outlined buttons + (the package puts this in front of `livewire::simple-tailwind`). --}} + +@php + if (! isset($scrollTo)) { + $scrollTo = 'body'; + } + + $scrollIntoViewJsSnippet = ($scrollTo !== false) + ? << + @if ($paginator->hasPages()) + + @endif +
diff --git a/resources/views/pagination/livewire/tailwind.blade.php b/resources/views/pagination/livewire/tailwind.blade.php new file mode 100644 index 00000000..bb83ef03 --- /dev/null +++ b/resources/views/pagination/livewire/tailwind.blade.php @@ -0,0 +1,76 @@ +{{-- Livewire's paginator, drawn in M3. The package puts this in front of Livewire's own + `livewire::tailwind` (config `livewire-material.pagination`), whose gray and blue classes do + not exist in an M3 theme. + + The wiring is Livewire's, unchanged: `previousPage`, `nextPage` and `gotoPage` with the page + name, and the scroll back to the top of whatever `scrollTo` names. Page numbers are 40px + icon-button targets; the current one is the selected state, secondary-container, never the + action colour — it is where you are, not what to do next. On a phone the numbers give way to + "Page 2 of 7". --}} + +@php + if (! isset($scrollTo)) { + $scrollTo = 'body'; + } + + $scrollIntoViewJsSnippet = ($scrollTo !== false) + ? << + @if ($paginator->hasPages()) + + @endif +
diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php index 1bc39933..1ec61d2b 100644 --- a/resources/views/showcase/index.blade.php +++ b/resources/views/showcase/index.blade.php @@ -22,5 +22,6 @@ @include('livewire-material::showcase.sections.chips') @include('livewire-material::showcase.sections.sliders') @include('livewire-material::showcase.sections.bars') + @include('livewire-material::showcase.sections.data') @endsection diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php index 3b148936..45d5ab3f 100644 --- a/resources/views/showcase/layout.blade.php +++ b/resources/views/showcase/layout.blade.php @@ -18,7 +18,7 @@ Livewire Material diff --git a/resources/views/showcase/sections/data.blade.php b/resources/views/showcase/sections/data.blade.php new file mode 100644 index 00000000..4f459e7f --- /dev/null +++ b/resources/views/showcase/sections/data.blade.php @@ -0,0 +1,56 @@ +@php + $examples = [ + 'Data table' => <<<'BLADE' +
+ + + + Name + Size + Expires + Actions + + + + + contract.pdf + 1.2 MB + in 1 hour + + + + design-review.mp4 + 3.4 GB + in 7 days + + + + holiday-photos.zip + 248 MB + in 3 days + + + + +
+ BLADE, + 'Pagination' => <<<'BLADE' +
+ {{ (new \Illuminate\Pagination\LengthAwarePaginator(range(1, 10), 95, 10, 3, ['path' => '#data']))->links() }} + {{ (new \Illuminate\Pagination\Paginator(range(1, 11), 10, 2, ['path' => '#data']))->links() }} +
+ BLADE, + ]; +@endphp + +
+

Data

+ +

+ <x-table>, <x-sort-header>, and Laravel's and Livewire's paginators. +

+ + @foreach ($examples as $title => $code) + + @endforeach +
diff --git a/src/LivewireMaterialServiceProvider.php b/src/LivewireMaterialServiceProvider.php index b95ddf90..9d94acfc 100644 --- a/src/LivewireMaterialServiceProvider.php +++ b/src/LivewireMaterialServiceProvider.php @@ -2,6 +2,7 @@ namespace NoNameWeb\LivewireMaterial; +use Illuminate\Contracts\View\Factory; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; @@ -21,6 +22,7 @@ class LivewireMaterialServiceProvider extends ServiceProvider $this->loadTranslationsFrom(__DIR__.'/../lang', 'livewire-material'); $this->registerComponents(); + $this->registerPagination(); $this->registerShowcase(); if ($this->app->runningInConsole()) { @@ -45,6 +47,24 @@ class LivewireMaterialServiceProvider extends ServiceProvider ); } + /** + * Put the M3 paginators in front of Laravel's and Livewire's own. Prepended to their + * namespaces rather than set as the default view, because Livewire sets its own default on + * every render; an application's published `vendor/pagination` or `vendor/livewire` views are + * looked up before any namespace path, so they still win. + */ + protected function registerPagination(): void + { + if (! config('livewire-material.pagination')) { + return; + } + + $this->callAfterResolving('view', function (Factory $view): void { + $view->prependNamespace('pagination', __DIR__.'/../resources/views/pagination/laravel'); + $view->prependNamespace('livewire', __DIR__.'/../resources/views/pagination/livewire'); + }); + } + /** * blade-icons registers a class-based of its own, and Blade resolves a * registered class alias before any anonymous component path, so ours would never diff --git a/tests/Browser/DataTest.php b/tests/Browser/DataTest.php new file mode 100644 index 00000000..41a1c960 --- /dev/null +++ b/tests/Browser/DataTest.php @@ -0,0 +1,105 @@ + 'name', 'direction' => 'asc']; + + public function render(): View + { + $files = collect(range(1, 25))->map(fn (int $n): array => ['name' => sprintf('file-%02d.zip', $n), 'size' => $n * 3 % 17]); + $files = $files->sortBy($this->sortBy['column'], SORT_REGULAR, $this->sortBy['direction'] === 'desc')->values(); + $page = $this->getPage(); + + return view('probe::data', [ + 'files' => new LengthAwarePaginator($files->forPage($page, 10)->values(), $files->count(), 10, $page), + ]); + } +} + +function dataProbe() +{ + $views = sys_get_temp_dir().'/livewire-material-data-probe'; + @mkdir($views); + file_put_contents($views.'/data.blade.php', <<<'BLADE' +
+ + + + Name + Size + + + + @foreach ($files as $file) + {{ $file['name'] }}{{ $file['size'] }} + @endforeach + + + + {{ $files->links() }} +
+ BLADE); + app('view')->addNamespace('probe', $views); + + Livewire::component('data-probe', DataProbe::class); + + Route::middleware('web')->get('/data-probe', fn () => Blade::render(<<<'BLADE' + + + + + @vite(config('livewire-material.showcase.vite')) + @livewireStyles + + + + @livewireScripts + + + BLADE)); + + return visit('/data-probe')->waitForEvent('networkidle') + ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'"); +} + +it('sorts by a column and flips the direction on a second press', function () { + $first = "document.querySelector('tbody tr td').textContent.trim()"; + + $page = dataProbe() + ->assertAttribute('#by-name', 'aria-sort', 'ascending') + ->assertScript("{$first} === 'file-01.zip'"); + + $page->click('#by-name button') + ->assertAttribute('#by-name', 'aria-sort', 'descending') + ->assertScript("{$first} === 'file-25.zip'"); + + $page->click('#by-size button') + ->assertAttribute('#by-size', 'aria-sort', 'ascending') + ->assertScript("! document.querySelector('#by-name').hasAttribute('aria-sort')"); +}); + +it('pages through Livewire results and marks the current page', function () { + $page = dataProbe() + ->assertSee('1–10 of 25') + ->assertAttribute('[data-pagination] [aria-current="page"]', 'aria-current', 'page') + ->click('[data-pagination] button[aria-label="Go to page 3"]') + ->assertSee('21–25 of 25') + ->assertSeeIn('[data-pagination] [aria-current="page"]', '3'); + + $page->click('[data-pagination] button[aria-label="Previous"]') + ->assertSee('11–20 of 25'); + + $page->resize(400, 800) + ->assertSee('Page 2 of 3'); +}); diff --git a/tests/Feature/Components/DataTest.php b/tests/Feature/Components/DataTest.php new file mode 100644 index 00000000..00aeaae1 --- /dev/null +++ b/tests/Feature/Components/DataTest.php @@ -0,0 +1,68 @@ +blade('a')) + ->toContain('') + ->and((string) $this->blade(''))->toContain('data-size="xs"') + ->and((string) $this->blade(''))->toContain('data-size="sm"'); +}); + +it('sorts by its column, ascending first and then flipping', function () { + expect((string) $this->blade('Size')) + ->not->toContain('aria-sort') + ->toContain('wire:click="$set(\'sortBy\', {"column":"size","direction":"asc"})"') + ->and((string) $this->blade('Size')) + ->toContain('aria-sort="ascending"') + ->toContain('class="text-end"') + ->toContain('$set(\'order\', {"column":"size","direction":"desc"})') + ->and((string) $this->blade('Size')) + ->toContain('aria-sort="descending"') + ->toContain('"direction":"asc"'); +}); + +it('draws Laravel\'s paginators in M3', function () { + $pages = new LengthAwarePaginator(range(1, 10), 95, 10, 3, ['path' => '/shares']); + + expect((string) $pages->links()) + ->toContain('data-pagination') + ->toContain('Page 3 of 10') + ->toContain('21–30 of 95') + ->toContain('3') + ->toContain('href="/shares?page=2" rel="prev"') + ->toContain('aria-label="Go to page 4"') + ->not->toContain('text-gray'); + + expect((string) (new Paginator(range(1, 11), 10, 2, ['path' => '/shares']))->links()) + ->toContain('data-pagination') + ->toContain('href="/shares?page=1"') + ->toContain('href="/shares?page=3"') + ->toContain('Previous'); +}); + +it('draws Livewire\'s paginators in M3, wired to its page actions', function () { + Livewire::component('paging-probe', new class extends Component + { + use WithPagination; + + public function render(): string + { + return <<<'BLADE' +
+ {{ (new \Illuminate\Pagination\LengthAwarePaginator(range(1, 10), 95, 10, $this->getPage(), ['path' => '/'])) ->links() }} +
+ BLADE; + } + }); + + expect(Livewire::test('paging-probe')->html()) + ->toContain('data-pagination') + ->toContain('wire:click="gotoPage(2, \'page\')"') + ->toContain('wire:click="nextPage(\'page\')"') + ->not->toContain('text-gray'); +});