`, ``, ` ` inside ``; the text classes on a cell (`md-text-end`, `md-nowrap`, `md-tabular`) always win over the table's own alignment. Rows are 52px — a target a finger can hit. `dense` tightens them to 36px and `size="xs"` is for a table inside a panel inside a panel (32px rows); M3 says density is always an opt-in, so neither is a default and both are yours to justify. Scrolling is yours: wrap it in an element that scrolls sideways (`overflow-x: auto` in your own CSS). A row that opens something is `data-md-list-row` with one `data-md-list-open` control; a selected row is `aria-selected="true"`.
+A data table: write plain ``, ``, ``, ` ` inside ``; the text classes on a cell (`md-text-end`, `md-nowrap`, `md-tabular`) always win over the table's own alignment. Rows are 52px — a target a finger can hit. `dense` tightens them to 36px and `size="xs"` is for a table inside a panel inside a panel (32px rows); M3 says density is always an opt-in, so neither is a default and both are yours to justify. Scrolling is yours: wrap it in an element that scrolls sideways (`overflow-x: auto` in your own CSS). A row that opens something is `data-md-list-row` with one `data-md-list-open` control; a selected row is `data-md-selected`, or `aria-selected="true"` in a table that is a grid — the only place ARIA allows it on a row.
`Size ` sorts through the Livewire property `sortBy` (`['column' => …, 'direction' => 'asc'|'desc']`; `model` names another), with `aria-sort`.
diff --git a/resources/css/components/list-item.css b/resources/css/components/list-item.css
index 2b0181a0..9e673f4b 100644
--- a/resources/css/components/list-item.css
+++ b/resources/css/components/list-item.css
@@ -15,7 +15,9 @@
* 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
+ * container's own colour takes over by inheritance. The same attribute on any other row that is
+ * not a card — an application's own ``, `` or `
` — draws the same fill and ink, as
+ * 1.x's `data-selected` did; a card keeps its own container. 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).
@@ -93,6 +95,15 @@
color: var(--md-sys-color-on-secondary-container);
}
+ /* A row written by hand — list-rows.js's contract, not an `` — selected the same
+ way. The state layers below are mixed over this fill, as they are over an item's. */
+ [data-md-list-row][data-md-selected]:not([data-md-card], [data-md-list-item]) {
+ --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;
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
diff --git a/resources/css/components/table.css b/resources/css/components/table.css
index fde8b65c..a93f369f 100644
--- a/resources/css/components/table.css
+++ b/resources/css/components/table.css
@@ -7,7 +7,8 @@
* always wins, whatever layer it comes from. Header cells in title-small on-surface-variant over an
* outline-variant rule, body cells in body-medium between outline-variant rules — the role itself,
* 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`
+ * (`data-md-selected`, or `aria-selected="true"` where the table is a grid, the only place ARIA
+ * allows it on a row) 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.
@@ -84,7 +85,7 @@
border-bottom: 0;
}
- [data-md-table] :where(tbody tr[aria-selected='true']) {
+ [data-md-table] :where(tbody tr:is([aria-selected='true'], [data-md-selected])) {
--md-list-row-fill: var(--md-sys-color-secondary-container);
background-color: var(--md-list-row-fill);
diff --git a/resources/js/list-rows.js b/resources/js/list-rows.js
index 8587f31e..33823141 100644
--- a/resources/js/list-rows.js
+++ b/resources/js/list-rows.js
@@ -9,6 +9,11 @@
* keep their own clicks. How a row looks while that happens is
* resources/css/components/card.css and list-item.css.
*
+ * `data-md-selected` on a row that is not a card — the one whose sheet is open, say — draws M3's
+ * selected state, secondary-container under on-secondary-container ink, with the state layers over
+ * it (list-item.css; `` writes it itself, and in `` a plain row takes
+ * it too, table.css). A card keeps its own container.
+ *
* Not a stretched link (`::after { inset: 0 }`): Safari makes no containing block of a , so in
* a table every overlay would cover the whole table; and not one around the row, which
* could hold no other buttons. The listeners sit on `document` and are added once.
diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php
index cbe7ed27..abc2f910 100644
--- a/tests/Browser/ContainmentTest.php
+++ b/tests/Browser/ContainmentTest.php
@@ -966,6 +966,79 @@ it('keeps a selected segmented row\'s fill under the hover and focus tint', func
->and($selectedFocusBackground)->not->toBe($selectedHoverBackground);
});
+/**
+ * An element's background and ink, "background / color", once the transitions a change started on
+ * it have run: a row moves its background on the effects springs.
+ */
+function settledPaint(string $element): string
+{
+ return "(async () => { const element = {$element}; await Promise.allSettled(element.getAnimations().map((animation) => animation.finished)); const style = getComputedStyle(element); return style.backgroundColor + ' / ' + style.color })()";
+}
+
+it('fills a selected row written by hand in secondary-container in both themes, but not a card', function () {
+ Route::middleware('web')->get('/hand-made-selected-row-probe', fn () => Blade::render(<<<'BLADE'
+
+
+
+
+ @vite(config('livewire-material.showcase.vite'))
+ @livewireStyles
+
+
+
+
The role pair
+
+
+
+ Selected, opening nothing
+
+
+
+ A card
+
+
+ @livewireScripts
+
+
+ BLADE));
+
+ $page = visit('/hand-made-selected-row-probe')->waitForEvent('networkidle')
+ ->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined'");
+
+ $swatch = settledPaint("document.getElementById('swatch')");
+ $row = settledPaint("document.getElementById('selected-row')");
+ $tableRow = settledPaint("document.getElementById('selected-table-row')");
+ $light = $page->script($swatch);
+
+ // The role pair itself, on an `` and on a plain `` in a table; a card keeps its own
+ // container, and a row that is not selected has no fill.
+ expect($page->script($row))->toBe($light)
+ ->and($page->script($tableRow))->toBe($light)
+ ->and($page->script("getComputedStyle(document.getElementById('plain-row')).backgroundColor"))->toBe('rgba(0, 0, 0, 0)')
+ ->and($page->script("getComputedStyle(document.getElementById('selected-card')).backgroundColor"))->not->toBe(explode(' / ', $light)[0]);
+
+ $page->script("document.documentElement.setAttribute('data-theme', 'dark')");
+ $dark = $page->script($swatch);
+
+ expect($dark)->not->toBe($light)
+ ->and($page->script($row))->toBe($dark)
+ ->and($page->script($tableRow))->toBe($dark);
+
+ // The hover tint is laid over the fill, so it is neither the fill nor a plain row's hover.
+ $page->hover('#selected-row');
+ $selectedHover = explode(' / ', $page->script($row))[0];
+
+ $page->hover('#plain-row');
+ $plainHover = explode(' / ', $page->script(settledPaint("document.getElementById('plain-row')")))[0];
+
+ expect($selectedHover)->not->toBe(explode(' / ', $dark)[0])
+ ->and($selectedHover)->not->toBe($plainHover)
+ ->and($page->script($row))->toBe($dark);
+});
+
/**
* Collapses to watch move: one on its own with a paragraph under it, one bound to Alpine, and a
* `name` group of two with the first open. Each body is tall enough for a height caught part-way
diff --git a/tests/Feature/Components/DataTest.php b/tests/Feature/Components/DataTest.php
index 4195f495..04610522 100644
--- a/tests/Feature/Components/DataTest.php
+++ b/tests/Feature/Components/DataTest.php
@@ -25,7 +25,7 @@ it('draws 52px rows, 36px dense ones, from its stylesheet in the components laye
expect($css)->toContain('@layer material.components')
->toMatch('/\\[data-md-table\\] \\{\\s*--cell-x: 12px;\\s*--cell-y: var\\(--md-sys-measurement-space200\\);/')
->toMatch('/\\[data-md-table\\]\\[data-md-dense\\] \\{\\s*--cell-y: var\\(--md-sys-measurement-space100\\);/')
- ->toContain('[data-md-table] :where(tbody tr[aria-selected=\'true\'])')
+ ->toContain('[data-md-table] :where(tbody tr:is([aria-selected=\'true\'], [data-md-selected]))')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/table.css';");
});