Draw the page steps' state layer from the paginator stylesheet

Plan step 36. The paginators put md-state-layer, md-focus-ring and
md-touch-target on their live steps, where the rest of the actions and
communication group (button, FAB, menu item, snackbar, alert) writes no
class list and carries those declarations keyed on its own hooks, as
the plan's data-md-* rule and the brief's "text classes, nothing else"
ask. pagination.css now draws them on a link or button step, the layer
hidden while Livewire disables previous and next; the views render no
class at all, which the render tests pin.

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 17:08:54 +02:00
co-authored by Claude Opus 5
parent f53c559c40
commit 32313e0fd9
4 changed files with 84 additions and 18 deletions
+58 -6
View File
@@ -6,11 +6,12 @@
* The numbered paginator is a row: on its start, what the page holds in body-small on-surface-variant * The numbered paginator is a row: on its start, what the page holds in body-small on-surface-variant
* — "2130 of 95", or "Page 3 of 10" below `medium` (600px), where the page numbers give way and only * — "2130 of 95", or "Page 3 of 10" below `medium` (600px), where the page numbers give way and only
* previous and next stay — and on its end the steps, 4px apart. A step is a 40px circle in label-large * previous and next stay — and on its end the steps, 4px apart. A step is a 40px circle in label-large
* with tabular figures, M3's icon-button geometry: a live one in on-surface with the state layer, the * with tabular figures, M3's icon-button geometry: a live one (a link or a button) in on-surface with
* focus ring and a 48px target (`md-state-layer`, `md-focus-ring`, `md-touch-target`, * the state layer, the focus ring and a 48px target — the declarations of the foundation's
* foundation/interaction.css); the current page is the selected state, secondary-container, never the * `md-state-layer`, `md-focus-ring` and `md-touch-target` (foundation/interaction.css), keyed on the
* action colour — it is where you are, not what to do next; an unreachable one (`aria-disabled`) is * step itself as button.css does, because the views write no class list; the current page is the
* on-surface at 38%. The chevrons mirror in a right-to-left page. The simple paginator is the same * selected state, secondary-container, never the action colour — it is where you are, not what to do
* next; an unreachable one (`aria-disabled`) is on-surface at 38%. The chevrons mirror in a right-to-left page. The simple paginator is the same
* row holding two outlined buttons. * row holding two outlined buttons.
* *
* [data-md-pagination] the <nav> * [data-md-pagination] the <nav>
@@ -59,8 +60,59 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
button[data-md-pagination-step] { :is(a, button)[data-md-pagination-step] {
position: relative;
isolation: isolate;
cursor: pointer; 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);
transform: 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);
}
/* Livewire disables previous and next while a page loads. */
&:disabled::before {
display: none;
}
} }
[data-md-pagination-step][aria-current='page'] { [data-md-pagination-step][aria-current='page'] {
@@ -17,7 +17,7 @@
<x-livewire-material::icon name="chevron_left" mirror-rtl /> <x-livewire-material::icon name="chevron_left" mirror-rtl />
</span> </span>
@else @else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" data-md-pagination-step class="md-state-layer md-focus-ring md-touch-target" aria-label="{{ __('Previous') }}"> <a href="{{ $paginator->previousPageUrl() }}" rel="prev" data-md-pagination-step aria-label="{{ __('Previous') }}">
<x-livewire-material::icon name="chevron_left" mirror-rtl /> <x-livewire-material::icon name="chevron_left" mirror-rtl />
</a> </a>
@endif @endif
@@ -32,14 +32,14 @@
@if ($page == $paginator->currentPage()) @if ($page == $paginator->currentPage())
<span aria-current="page" data-md-pagination-step data-md-pagination-page>{{ $page }}</span> <span aria-current="page" data-md-pagination-step data-md-pagination-page>{{ $page }}</span>
@else @else
<a href="{{ $url }}" data-md-pagination-step data-md-pagination-page class="md-state-layer md-focus-ring md-touch-target" aria-label="{{ __('Go to page :page', ['page' => $page]) }}">{{ $page }}</a> <a href="{{ $url }}" data-md-pagination-step data-md-pagination-page aria-label="{{ __('Go to page :page', ['page' => $page]) }}">{{ $page }}</a>
@endif @endif
@endforeach @endforeach
@endif @endif
@endforeach @endforeach
@if ($paginator->hasMorePages()) @if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next" data-md-pagination-step class="md-state-layer md-focus-ring md-touch-target" aria-label="{{ __('Next') }}"> <a href="{{ $paginator->nextPageUrl() }}" rel="next" data-md-pagination-step aria-label="{{ __('Next') }}">
<x-livewire-material::icon name="chevron_right" mirror-rtl /> <x-livewire-material::icon name="chevron_right" mirror-rtl />
</a> </a>
@else @else
@@ -5,9 +5,9 @@
The wiring is Livewire's, unchanged: `previousPage`, `nextPage` and `gotoPage` with the page 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. A page number is drawn as a name, and the scroll back to the top of whatever `scrollTo` names. A page number is drawn as a
40px state layer and catches presses over M3's 48px target (`md-touch-target`); the current one 40px state layer and catches presses over M3's 48px target; the current one is the selected
is the selected state, secondary-container, never the action colour — it is where you are, not state, secondary-container, never the action colour — it is where you are, not what to do next.
what to do next. Below `medium` (600px) the numbers give way to "Page 2 of 7". --}} Below `medium` (600px) the numbers give way to "Page 2 of 7". --}}
@php @php
if (! isset($scrollTo)) { if (! isset($scrollTo)) {
@@ -35,7 +35,7 @@
<x-livewire-material::icon name="chevron_left" mirror-rtl /> <x-livewire-material::icon name="chevron_left" mirror-rtl />
</span> </span>
@else @else
<button type="button" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.'.$paginator->getPageName() }}" data-md-pagination-step class="md-state-layer md-focus-ring md-touch-target" aria-label="{{ __('Previous') }}"> <button type="button" wire:click="previousPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="previousPage{{ $paginator->getPageName() == 'page' ? '' : '.'.$paginator->getPageName() }}" data-md-pagination-step aria-label="{{ __('Previous') }}">
<x-livewire-material::icon name="chevron_left" mirror-rtl /> <x-livewire-material::icon name="chevron_left" mirror-rtl />
</button> </button>
@endif @endif
@@ -51,7 +51,7 @@
@if ($page == $paginator->currentPage()) @if ($page == $paginator->currentPage())
<span aria-current="page" data-md-pagination-step>{{ $page }}</span> <span aria-current="page" data-md-pagination-step>{{ $page }}</span>
@else @else
<button type="button" wire:click="gotoPage({{ $page }}, '{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" data-md-pagination-step class="md-state-layer md-focus-ring md-touch-target" aria-label="{{ __('Go to page :page', ['page' => $page]) }}">{{ $page }}</button> <button type="button" wire:click="gotoPage({{ $page }}, '{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" data-md-pagination-step aria-label="{{ __('Go to page :page', ['page' => $page]) }}">{{ $page }}</button>
@endif @endif
</span> </span>
@endforeach @endforeach
@@ -59,7 +59,7 @@
@endforeach @endforeach
@if ($paginator->hasMorePages()) @if ($paginator->hasMorePages())
<button type="button" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.'.$paginator->getPageName() }}" data-md-pagination-step class="md-state-layer md-focus-ring md-touch-target" aria-label="{{ __('Next') }}"> <button type="button" wire:click="nextPage('{{ $paginator->getPageName() }}')" x-on:click="{{ $scrollIntoViewJsSnippet }}" wire:loading.attr="disabled" dusk="nextPage{{ $paginator->getPageName() == 'page' ? '' : '.'.$paginator->getPageName() }}" data-md-pagination-step aria-label="{{ __('Next') }}">
<x-livewire-material::icon name="chevron_right" mirror-rtl /> <x-livewire-material::icon name="chevron_right" mirror-rtl />
</button> </button>
@else @else
+17 -3
View File
@@ -5,6 +5,7 @@ use Illuminate\Pagination\Paginator;
use Livewire\Component; use Livewire\Component;
use Livewire\Livewire; use Livewire\Livewire;
use Livewire\WithPagination; use Livewire\WithPagination;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
it('marks a table for its styles and takes a size', function () { it('marks a table for its styles and takes a size', function () {
expect((string) $this->blade('<x-table class="min-w-160"><tbody><tr><td>a</td></tr></tbody></x-table>')) expect((string) $this->blade('<x-table class="min-w-160"><tbody><tr><td>a</td></tr></tbody></x-table>'))
@@ -50,7 +51,19 @@ it('gives the sort button and every page control a 48px target', function () {
->toContain('--md-icon-size: 16px') ->toContain('--md-icon-size: 16px')
->not->toContain('data-md-active') ->not->toContain('data-md-active')
->and((string) $pages->links()) ->and((string) $pages->links())
->toContain('data-md-pagination-step class="md-state-layer md-focus-ring md-touch-target"'); ->toContain('<a href="/shares?page=4" data-md-pagination-step data-md-pagination-page aria-label="Go to page 4">')
->and(ComponentStylesheet::read('pagination')->declarations('[data-md-pagination-step]'))->toMatchArray([
'width' => 'var(--md-sys-measurement-space500)',
'height' => 'var(--md-sys-measurement-space500)',
])
->and(ComponentStylesheet::read('pagination')->declarations(':is(a, button)[data-md-pagination-step]::after'))->toMatchArray([
'min-width' => 'var(--md-sys-measurement-space600)',
'min-height' => 'var(--md-sys-measurement-space600)',
])
->and(ComponentStylesheet::read('pagination')->declarations(':is(a, button)[data-md-pagination-step]:focus-visible'))->toBe([
'outline' => '3px solid var(--md-sys-color-secondary)',
'outline-offset' => '2px',
]);
}); });
it('draws Laravel\'s paginators in M3', function () { it('draws Laravel\'s paginators in M3', function () {
@@ -65,7 +78,7 @@ it('draws Laravel\'s paginators in M3', function () {
->toContain('aria-label="Go to page 4"') ->toContain('aria-label="Go to page 4"')
->toContain('data-md-mirror-rtl') ->toContain('data-md-mirror-rtl')
->not->toContain('text-gray') ->not->toContain('text-gray')
->not->toMatch('/class="(?!md-)[^"]*"/'); ->not->toContain('class="');
expect((string) (new Paginator(range(1, 11), 10, 2, ['path' => '/shares']))->links()) expect((string) (new Paginator(range(1, 11), 10, 2, ['path' => '/shares']))->links())
->toContain('data-md-pagination') ->toContain('data-md-pagination')
@@ -94,7 +107,8 @@ it('draws Livewire\'s paginators in M3, wired to its page actions', function ()
->toContain('data-md-pagination-page') ->toContain('data-md-pagination-page')
->toContain('wire:click="gotoPage(2, \'page\')"') ->toContain('wire:click="gotoPage(2, \'page\')"')
->toContain('wire:click="nextPage(\'page\')"') ->toContain('wire:click="nextPage(\'page\')"')
->not->toContain('text-gray'); ->not->toContain('text-gray')
->not->toContain('class="');
}); });
it('draws the sort header from its stylesheet, its arrow hidden until the column sorts or is hovered', function () { it('draws the sort header from its stylesheet, its arrow hidden until the column sorts or is hovered', function () {