From 7d3abdd506d836d3af07a190b32fe75d563850e8 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 22:00:03 +0200 Subject: [PATCH] Keep a list row's fill under its state layer Plan step 36 review of 9ff5a7a8. The rewrite dropped the unlayered selected rule into material.components beside the segmented fill and the row tint, so on equal or greater weight a selected item in a segmented list drew surface instead of secondary-container, and hovering, pressing or focusing any selected row (or a table's selected row) swapped its fill for a translucent tint. Each fill now names itself --md-list-row-fill, and the row's layer is its ink mixed over that fill at the state tokens, which is also C-08's second suggested fix. A disabled item inks the whole row at the disabled-content token, as the old root colour did, so the check, the leading slot and the end slot dim with the text; the three-line padding is a literal 12px like alert.css; list-rows.js named list.css. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- resources/css/components/list-item.css | 51 +++++++++++++++++--------- resources/css/components/table.css | 7 +++- resources/js/list-rows.js | 2 +- tests/Feature/Components/ListTest.php | 28 ++++++++++++++ 4 files changed, 68 insertions(+), 20 deletions(-) diff --git a/resources/css/components/list-item.css b/resources/css/components/list-item.css index d73cbaa5..d48db7ec 100644 --- a/resources/css/components/list-item.css +++ b/resources/css/components/list-item.css @@ -7,13 +7,17 @@ * `ItemLeadingSpace`/`ItemTrailingSpace`/container padding, C-14). * * `data-md-list-row` (`resources/js/list-rows.js`, shared with `` and an application's own - * table rows) and `[data-md-list-open]:focus-visible` answer with an 8%/10% on-surface state layer - * and a 3px secondary inset ring — excluded on `[data-md-card]`, which draws its own tint through - * the shared `md-state-layer` class instead (card.css). `data-md-selected` is M3's selected item, - * secondary-container filled, which also dims the description/overline/trailing/icon colour rules - * below since they exclude it — the container's own colour takes over by inheritance. A selected - * option draws a second cue, a trailing check, so selection is never colour alone (C-03). - * `aria-disabled` drops every colour to 38% on-surface and blocks the pointer; the view drops the + * table rows) and `[data-md-list-open]:focus-visible` answer with the state layer — the row's ink at + * the hover 8% and focus/press 10% state tokens — and a 3px secondary inset ring; excluded on + * `[data-md-card]`, which draws its own tint through the shared `md-state-layer` class instead + * (card.css). The layer is mixed over the row's own fill, `--md-list-row-fill` (a segmented tile's + * surface, a selected item's secondary-container, table.css's selected row), so a hovered, pressed + * or focused row keeps its fill under the tint instead of trading it for a translucent one (C-08's + * second fix). `data-md-selected` is M3's selected item, secondary-container filled, which also + * lifts the description/overline/trailing/icon colour rules below since they exclude it — the + * container's own colour takes over by inheritance. A selected option draws a second cue, a + * trailing check, so selection is never colour alone (C-03). `aria-disabled` inks the whole item + * on-surface at the disabled-content opacity (38%) and blocks the pointer; the view drops the * item's link entirely rather than leaving a focusable, activatable control behind it (C-02). * * `data-md-list="segmented"` items (M3 Expressive) are `surface` tiles (`ItemSegmentedContainerColor`, @@ -58,21 +62,35 @@ [data-md-list-item][data-md-lines='3'] { min-height: 88px; - padding-block: calc((var(--md-sys-measurement-space100) + var(--md-sys-measurement-space200)) / 2); + padding-block: 12px; align-items: flex-start; } + /* A row's fill, under its state layer. Declared on every row at no weight, so a row nested in a + filled one starts from nothing rather than inheriting its parent's fill. */ + :where([data-md-list-row]) { + --md-list-row-fill: transparent; + } + + /* M3 Expressive's segmented list gives each item its own container: ListTokens.kt:201, + `ItemSegmentedContainerColor get() = ColorSchemeKeyTokens.Surface`. Before the selected + rule, which outranks it on the same weight. */ + [data-md-list='segmented'] > [data-md-list-item] { + --md-list-row-fill: var(--md-sys-color-surface); + + background-color: var(--md-list-row-fill); + } + [data-md-list-item][data-md-selected] { - background-color: var(--md-sys-color-secondary-container); + --md-list-row-fill: var(--md-sys-color-secondary-container); + + background-color: var(--md-list-row-fill); color: var(--md-sys-color-on-secondary-container); } [data-md-list-item][aria-disabled='true'] { pointer-events: none; - } - - [data-md-list-item][aria-disabled='true'] :is([data-md-list-item-title], [data-md-list-item-overline], [data-md-list-item-description], [data-md-list-item-trailing], [data-md-list-item-icon]) { - color: color-mix(in srgb, var(--md-sys-color-on-surface) 38%, transparent); + color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent); } [data-md-list-item]:not([data-md-selected], [aria-disabled='true']) :is([data-md-list-item-overline], [data-md-list-item-description], [data-md-list-item-trailing], [data-md-list-item-icon]) { @@ -192,18 +210,18 @@ @media (hover: hover) { [data-md-list-row]:not([data-md-card]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):hover)) { - background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent); + background-color: color-mix(in srgb, currentColor calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), var(--md-list-row-fill, transparent)); } } [data-md-list-row]:not([data-md-card]):has([data-md-list-open]:focus-visible) { - background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent); + background-color: color-mix(in srgb, currentColor calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), var(--md-list-row-fill, transparent)); outline: 3px solid var(--md-sys-color-secondary); outline-offset: -3px; } [data-md-list-row]:not([data-md-card]):active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):active)) { - background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent); + background-color: color-mix(in srgb, currentColor calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), var(--md-list-row-fill, transparent)); } [data-md-list-row] [data-md-list-open]:focus-visible { @@ -211,7 +229,6 @@ } [data-md-list='segmented'] > [data-md-list-item] { - background-color: var(--md-sys-color-surface); border-radius: var(--md-sys-shape-corner-xs); } diff --git a/resources/css/components/table.css b/resources/css/components/table.css index 3092bdf2..fde8b65c 100644 --- a/resources/css/components/table.css +++ b/resources/css/components/table.css @@ -9,7 +9,8 @@ * never a fraction of it, since M3 reserves opacity for state layers and disabled. A selected row * (`aria-selected="true"`) is secondary-container. A row that opens something is `data-md-list-row` * and answers a pointer as a list row does (components/list-item.css, shared with `` and - * ``). + * ``); a selected one names its fill `--md-list-row-fill`, so that state layer is laid + * over the secondary-container rather than in place of it. * * A row is 52px: 16px above and below a body-medium line, 12px beside each cell. Density is never * applied by itself — "don't apply density by default; offer an explicit density opt-in instead, @@ -84,7 +85,9 @@ } [data-md-table] :where(tbody tr[aria-selected='true']) { - background-color: var(--md-sys-color-secondary-container); + --md-list-row-fill: var(--md-sys-color-secondary-container); + + background-color: var(--md-list-row-fill); color: var(--md-sys-color-on-secondary-container); } } diff --git a/resources/js/list-rows.js b/resources/js/list-rows.js index 1b6dbe5d..8587f31e 100644 --- a/resources/js/list-rows.js +++ b/resources/js/list-rows.js @@ -142,5 +142,5 @@ document.addEventListener('auxclick', (event) => { // iOS Safari paints `:active` on an element that is not a link or a button only // when some touch listener exists, and a row is neither — without this the press -// layer in list.css never shows on an iPhone. +// layer in list-item.css and card.css never shows on an iPhone. document.addEventListener('touchstart', () => {}, { passive: true }) diff --git a/tests/Feature/Components/ListTest.php b/tests/Feature/Components/ListTest.php index 0f03656d..eb47fa13 100644 --- a/tests/Feature/Components/ListTest.php +++ b/tests/Feature/Components/ListTest.php @@ -1,5 +1,6 @@ not->toContain('data-md-list-item-video') ->toContain('data-md-lines="1"'); }); + +it('lays a row\'s state layer over its fill, so a selected or segmented row keeps its colour under the tint', function () { + $css = ComponentStylesheet::read('list-item'); + $hover = '[data-md-list-row]:not([data-md-card]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):hover))'; + $press = '[data-md-list-row]:not([data-md-card]):active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):active))'; + + expect($css->declarations($hover, ['@media (hover: hover)'])) + ->toBe(['background-color' => 'color-mix(in srgb, currentColor calc(var(--md-sys-state-hover-state-layer-opacity) * 100%), var(--md-list-row-fill, transparent))']) + ->and($css->declarations($press)) + ->toBe(['background-color' => 'color-mix(in srgb, currentColor calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), var(--md-list-row-fill, transparent))']) + ->and($css->declarations('[data-md-list-row]:not([data-md-card]):has([data-md-list-open]:focus-visible)')) + ->toHaveKey('background-color', 'color-mix(in srgb, currentColor calc(var(--md-sys-state-focus-state-layer-opacity) * 100%), var(--md-list-row-fill, transparent))') + ->and($css->declarations(':where([data-md-list-row])'))->toBe(['--md-list-row-fill' => 'transparent']) + ->and($css->declarations("[data-md-list='segmented'] > [data-md-list-item]"))->toHaveKey('--md-list-row-fill', 'var(--md-sys-color-surface)') + ->and($css->declarations('[data-md-list-item][data-md-selected]'))->toHaveKey('--md-list-row-fill', 'var(--md-sys-color-secondary-container)') + ->and(File::get(ComponentStylesheet::path('table')))->toContain('--md-list-row-fill: var(--md-sys-color-secondary-container);'); + + // The segmented fill comes first: on the same weight, a selected tile's secondary-container wins. + expect(strpos($css->css, "[data-md-list='segmented'] > [data-md-list-item] {"))->toBeLessThan(strpos($css->css, '[data-md-list-item][data-md-selected] {')); +}); + +it('inks a disabled item as a whole at the disabled-content opacity', function () { + expect(ComponentStylesheet::read('list-item')->declarations("[data-md-list-item][aria-disabled='true']"))->toBe([ + 'pointer-events' => 'none', + 'color' => 'color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent)', + ]); +});