Draw the sort header without Tailwind

<x-sort-header> renders data-md-sort-header (data-md-active on the
sorted column) with its button and arrow as data-md-* attributes; the
button keeps md-focus-ring and md-touch-target, the arrow takes size 16,
and the hover and hint-arrow states move into components/sort-header.css
(plan step 36). A browser test presses the button at the edge of its
48px target.

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 15:07:37 +02:00
co-authored by Claude Opus 5
parent 25e1bb4788
commit c68a1fdbef
5 changed files with 85 additions and 16 deletions
+1
View File
@@ -30,6 +30,7 @@
@import './components/slider.css'; @import './components/slider.css';
@import './components/search.css'; @import './components/search.css';
@import './components/table.css'; @import './components/table.css';
@import './components/sort-header.css';
/* Containment */ /* Containment */
+58
View File
@@ -0,0 +1,58 @@
/*
* <x-sort-header>: a column header that sorts the table (resources/views/components/sort-header.blade.php),
* inside <x-table> (components/table.css), which draws the cell itself.
*
* The button is the header's own text, in the header cell's type and colour, with a 16px arrow 4px
* after it; the corner is extra-small, for the focus ring the button wears (`md-focus-ring`), and
* `md-touch-target` gives the one-line button M3's 48px target (foundation/interaction.css). The
* sorted column (`data-md-active`) reads in on-surface with its arrow showing the direction; another
* column turns on-surface only while hovered, and shows a faint upward arrow while hovered or
* focused, at 60% of the ink — a hint of what pressing would do, not a state M3 names, fading on the
* fast effects spring.
*
* [data-md-sort-header] the <th>; data-md-active, aria-sort
* [data-md-sort-header-button]
* [data-md-sort-header-arrow]
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@layer material.components {
[data-md-sort-header-button] {
display: inline-flex;
align-items: center;
gap: var(--md-sys-measurement-space50);
border-radius: var(--md-sys-shape-corner-xs);
cursor: pointer;
}
[data-md-sort-header][data-md-active] [data-md-sort-header-button] {
color: var(--md-sys-color-on-surface);
}
@media (hover: hover) {
[data-md-sort-header-button]:hover {
color: var(--md-sys-color-on-surface);
}
}
[data-md-sort-header-arrow] {
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-md-sort-header]:not([data-md-active]) [data-md-sort-header-arrow] {
opacity: 0;
}
[data-md-sort-header]:not([data-md-active]) [data-md-sort-header-button]:focus-visible [data-md-sort-header-arrow] {
opacity: 0.6;
}
@media (hover: hover) {
[data-md-sort-header]:not([data-md-active]) [data-md-sort-header-button]:hover [data-md-sort-header-arrow] {
opacity: 0.6;
}
}
}
@@ -3,10 +3,12 @@
Pressing it sorts by this column, ascending first and then flipping; the arrow says which way, 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 `<th>`. and `aria-sort` says it to a screen reader. A column that cannot be sorted is a plain `<th>`.
`class` lands on the `<th>` (`text-end` for a number column moves the button with it). `class` lands on the `<th>` (end-aligning a number column moves the button with it).
The button is only as tall as its title-small line, so it carries `touch-target` and catches The button is only as tall as its title-small line, so it carries `md-touch-target` and catches
presses over M3's 48px minimum; the cell's own padding belongs to the cell, not to it. --}} presses over M3's 48px minimum; the cell's own padding belongs to the cell, not to it. The cell
renders `data-md-sort-header` (`data-md-active` on the sorted column) and the button
`data-md-sort-header-button`, drawn by resources/css/components/sort-header.css. --}}
@props([ @props([
'sortBy' => [], 'sortBy' => [],
@@ -20,21 +22,14 @@
$next = $active && $direction === 'asc' ? 'desc' : 'asc'; $next = $active && $direction === 'asc' ? 'desc' : 'asc';
@endphp @endphp
<th @if ($active) aria-sort="{{ $direction === 'asc' ? 'ascending' : 'descending' }}" @endif {{ $attributes }}> <th data-md-sort-header @if ($active) data-md-active aria-sort="{{ $direction === 'asc' ? 'ascending' : 'descending' }}" @endif {{ $attributes }}>
<button <button
type="button" type="button"
wire:click="$set('{{ $model }}', {{ json_encode(['column' => $column, 'direction' => $next]) }})" wire:click="$set('{{ $model }}', {{ json_encode(['column' => $column, 'direction' => $next]) }})"
data-sort-header data-md-sort-header-button
@class([ class="md-focus-ring md-touch-target"
'group/sort focus-ring touch-target inline-flex cursor-pointer items-center gap-1 rounded-corner-xs',
'text-on-surface' => $active,
'hover:text-on-surface' => ! $active,
])
> >
{{ $slot }} {{ $slot }}
<x-livewire-material::icon :name="$direction === 'desc' ? 'arrow_downward' : 'arrow_upward'" optical="20" :class="\Illuminate\Support\Arr::toCssClasses([ <x-livewire-material::icon :name="$direction === 'desc' ? 'arrow_downward' : 'arrow_upward'" size="16" data-md-sort-header-arrow />
'size-4 transition-opacity duration-(--md-sys-motion-effects-fast-duration)',
'opacity-0 group-hover/sort:opacity-60 group-focus-visible/sort:opacity-60' => ! $active,
])" />
</button> </button>
</th> </th>
+6
View File
@@ -120,3 +120,9 @@ it('draws 52px rows, and 36px ones only in a table that asks to be dense', funct
->assertScript("getComputedStyle(document.querySelector('#dense-table tr[aria-selected=\"true\"]')).backgroundColor !== 'rgba(0, 0, 0, 0)'") ->assertScript("getComputedStyle(document.querySelector('#dense-table tr[aria-selected=\"true\"]')).backgroundColor !== 'rgba(0, 0, 0, 0)'")
->assertScript("getComputedStyle(document.querySelector('[data-md-table] thead th')).borderBottomWidth === '1px'"); ->assertScript("getComputedStyle(document.querySelector('[data-md-table] thead th')).borderBottomWidth === '1px'");
}); });
it('lets the sort button be pressed anywhere in its 48px target', function () {
dataProbe()
->assertScript("(() => { const button = document.querySelector('#by-size [data-md-sort-header-button]'); const after = getComputedStyle(button, '::after'); return button.getBoundingClientRect().height < 48 && after.minHeight === '48px' && after.minWidth === '48px'; })()")
->assertScript("(() => { const button = document.querySelector('#by-size [data-md-sort-header-button]'); const box = button.getBoundingClientRect(); const probe = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2 - 22); return probe === button; })()");
});
+11 -2
View File
@@ -35,7 +35,7 @@ it('sorts by its column, ascending first and then flipping', function () {
->toContain('wire:click="$set(\'sortBy\', {&quot;column&quot;:&quot;size&quot;,&quot;direction&quot;:&quot;asc&quot;})"') ->toContain('wire:click="$set(\'sortBy\', {&quot;column&quot;:&quot;size&quot;,&quot;direction&quot;:&quot;asc&quot;})"')
->and((string) $this->blade('<x-sort-header column="size" model="order" :sort-by="[\'column\' => \'size\', \'direction\' => \'asc\']" class="text-end">Size</x-sort-header>')) ->and((string) $this->blade('<x-sort-header column="size" model="order" :sort-by="[\'column\' => \'size\', \'direction\' => \'asc\']" class="text-end">Size</x-sort-header>'))
->toContain('aria-sort="ascending"') ->toContain('aria-sort="ascending"')
->toContain('class="text-end"') ->toMatch('/<th data-md-sort-header\\s+data-md-active aria-sort="ascending"\\s+class="text-end">/')
->toContain('$set(\'order\', {&quot;column&quot;:&quot;size&quot;,&quot;direction&quot;:&quot;desc&quot;})') ->toContain('$set(\'order\', {&quot;column&quot;:&quot;size&quot;,&quot;direction&quot;:&quot;desc&quot;})')
->and((string) $this->blade('<x-sort-header column="size" :sort-by="[\'column\' => \'size\', \'direction\' => \'desc\']">Size</x-sort-header>')) ->and((string) $this->blade('<x-sort-header column="size" :sort-by="[\'column\' => \'size\', \'direction\' => \'desc\']">Size</x-sort-header>'))
->toContain('aria-sort="descending"') ->toContain('aria-sort="descending"')
@@ -46,7 +46,9 @@ it('gives the sort button and every page control a 48px target', function () {
$pages = new LengthAwarePaginator(range(1, 10), 95, 10, 3, ['path' => '/shares']); $pages = new LengthAwarePaginator(range(1, 10), 95, 10, 3, ['path' => '/shares']);
expect((string) $this->blade('<x-sort-header column="size" :sort-by="[]">Size</x-sort-header>')) expect((string) $this->blade('<x-sort-header column="size" :sort-by="[]">Size</x-sort-header>'))
->toContain('touch-target') ->toMatch('/<button(?=[^>]*data-md-sort-header-button)(?=[^>]*class="md-focus-ring md-touch-target")[^>]*>/')
->toContain('--md-icon-size: 16px')
->not->toContain('data-md-active')
->and((string) $pages->links()) ->and((string) $pages->links())
->toContain('state-layer focus-ring touch-target'); ->toContain('state-layer focus-ring touch-target');
}); });
@@ -91,3 +93,10 @@ it('draws Livewire\'s paginators in M3, wired to its page actions', function ()
->toContain('wire:click="nextPage(\'page\')"') ->toContain('wire:click="nextPage(\'page\')"')
->not->toContain('text-gray'); ->not->toContain('text-gray');
}); });
it('draws the sort header from its stylesheet, its arrow hidden until the column sorts or is hovered', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/sort-header.css'))
->toContain('@layer material.components')
->toMatch('/\\[data-md-sort-header\\]:not\\(\\[data-md-active\\]\\) \\[data-md-sort-header-arrow\\] \\{\\s*opacity: 0;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/sort-header.css';");
});