From c68a1fdbef7fdd4f829fcaff061c755dd6aab605 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 15:07:37 +0200 Subject: [PATCH] Draw the sort header without Tailwind 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components.css | 1 + resources/css/components/sort-header.css | 58 +++++++++++++++++++ .../views/components/sort-header.blade.php | 23 +++----- tests/Browser/DataTest.php | 6 ++ tests/Feature/Components/DataTest.php | 13 ++++- 5 files changed, 85 insertions(+), 16 deletions(-) create mode 100644 resources/css/components/sort-header.css diff --git a/resources/css/components.css b/resources/css/components.css index ff8b7723..540d128e 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -30,6 +30,7 @@ @import './components/slider.css'; @import './components/search.css'; @import './components/table.css'; +@import './components/sort-header.css'; /* Containment */ diff --git a/resources/css/components/sort-header.css b/resources/css/components/sort-header.css new file mode 100644 index 00000000..5fc26812 --- /dev/null +++ b/resources/css/components/sort-header.css @@ -0,0 +1,58 @@ +/* + * : a column header that sorts the table (resources/views/components/sort-header.blade.php), + * inside (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 ; 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; + } + } +} diff --git a/resources/views/components/sort-header.blade.php b/resources/views/components/sort-header.blade.php index 7ac2cf60..d6628231 100644 --- a/resources/views/components/sort-header.blade.php +++ b/resources/views/components/sort-header.blade.php @@ -3,10 +3,12 @@ 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). + `class` lands on the `` (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 - presses over M3's 48px minimum; the cell's own padding belongs to the cell, not to it. --}} + 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. 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([ 'sortBy' => [], @@ -20,21 +22,14 @@ $next = $active && $direction === 'asc' ? 'desc' : 'asc'; @endphp - + diff --git a/tests/Browser/DataTest.php b/tests/Browser/DataTest.php index 408e7781..24a12741 100644 --- a/tests/Browser/DataTest.php +++ b/tests/Browser/DataTest.php @@ -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('[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; })()"); +}); diff --git a/tests/Feature/Components/DataTest.php b/tests/Feature/Components/DataTest.php index 32ff94f1..60de3c20 100644 --- a/tests/Feature/Components/DataTest.php +++ b/tests/Feature/Components/DataTest.php @@ -35,7 +35,7 @@ it('sorts by its column, ascending first and then flipping', function () { ->toContain('wire:click="$set(\'sortBy\', {"column":"size","direction":"asc"})"') ->and((string) $this->blade('Size')) ->toContain('aria-sort="ascending"') - ->toContain('class="text-end"') + ->toMatch('//') ->toContain('$set(\'order\', {"column":"size","direction":"desc"})') ->and((string) $this->blade('Size')) ->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']); expect((string) $this->blade('Size')) - ->toContain('touch-target') + ->toMatch('/]*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()) ->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\')"') ->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';"); +});