Draw the data table without Tailwind
<x-table> renders data-md-table with data-md-size and data-md-dense, and table.css moves into material.components on px and spacing tokens, its cell selectors still inside :where() so a caller's cell rule wins (plan step 36). The showcase's layout table follows the hook. A browser test pins 52px rows and 36px dense ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
4141ebe7b0
commit
25e1bb4788
@@ -29,6 +29,7 @@
|
|||||||
@import './components/choices.css';
|
@import './components/choices.css';
|
||||||
@import './components/slider.css';
|
@import './components/slider.css';
|
||||||
@import './components/search.css';
|
@import './components/search.css';
|
||||||
|
@import './components/table.css';
|
||||||
|
|
||||||
/* Containment */
|
/* Containment */
|
||||||
|
|
||||||
|
|||||||
@@ -2,26 +2,32 @@
|
|||||||
* Data tables: `<x-table>` (resources/views/components/table.blade.php), whose callers write plain
|
* Data tables: `<x-table>` (resources/views/components/table.blade.php), whose callers write plain
|
||||||
* `<thead>`, `<tr>`, `<th>` and `<td>` inside it.
|
* `<thead>`, `<tr>`, `<th>` and `<td>` inside it.
|
||||||
*
|
*
|
||||||
* So the styling is descendant selectors on the one attribute the component sets, all inside
|
* So the styling is descendant selectors on the one attribute the component sets, `data-md-table`,
|
||||||
* `:where()` and `@layer components`: a caller's `text-end` or `whitespace-nowrap` on a cell always
|
* with every cell and row selector inside `:where()`: a caller's alignment or wrapping on a cell
|
||||||
* wins. Header cells in title-small on-surface-variant over an outline-variant rule, body cells in
|
* always wins, whatever layer it comes from. Header cells in title-small on-surface-variant over an
|
||||||
* body-medium between outline-variant rules. A row that opens something is `data-list-row` and
|
* 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-list-row` and
|
||||||
* answers a pointer as a list row does (components/list.css).
|
* answers a pointer as a list row does (components/list.css).
|
||||||
*
|
*
|
||||||
* A row is 52px: 16px above and below a body-medium line. Density is never applied by itself —
|
* A row is 52px: 16px above and below a body-medium line, 12px beside each cell. Density is never
|
||||||
* "don't apply density by default; offer an explicit density opt-in instead, keeping opt-out targets
|
* applied by itself — "don't apply density by default; offer an explicit density opt-in instead,
|
||||||
* at >= 48x48 CSS px" (docs/reference/m3/foundations-supplement.md § Accessibility) — so `dense`
|
* keeping opt-out targets at >= 48x48 CSS px" (docs/reference/m3/foundations-supplement.md
|
||||||
* (`data-dense`) is the caller's decision, and it is the caller's to justify: a dense row is 36px,
|
* § Accessibility) — so `dense` (`data-md-dense`) is the caller's decision, and it is the caller's
|
||||||
* and a dense row inside an `xs` table 24px.
|
* to justify: a dense row is 36px (8px above and below). `data-md-size="xs"`, for a table inside a
|
||||||
|
* panel inside a panel, is body-small with label-medium headers, 8px around each cell (32px rows),
|
||||||
|
* and 4px above and below when dense (24px).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@layer components {
|
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||||
/* `relative` makes the table the containing block for anything absolute inside it — an `sr-only`
|
|
||||||
header label, a tooltip. Without it they escape the scroll box and widen a phone's layout
|
@layer material.components {
|
||||||
viewport. */
|
/* `position: relative` makes the table the containing block for anything absolute inside it —
|
||||||
[data-table] {
|
a visually hidden header label, a tooltip. Without it they escape the scroll box and widen a
|
||||||
--cell-x: 0.75rem;
|
phone's layout viewport. */
|
||||||
--cell-y: 1rem;
|
[data-md-table] {
|
||||||
|
--cell-x: 12px;
|
||||||
|
--cell-y: var(--md-sys-measurement-space200);
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -31,29 +37,29 @@
|
|||||||
letter-spacing: var(--md-sys-typescale-body-md-tracking);
|
letter-spacing: var(--md-sys-typescale-body-md-tracking);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-table][data-size="xs"] {
|
[data-md-table][data-md-size='xs'] {
|
||||||
--cell-x: 0.5rem;
|
--cell-x: var(--md-sys-measurement-space100);
|
||||||
--cell-y: 0.5rem;
|
--cell-y: var(--md-sys-measurement-space100);
|
||||||
|
|
||||||
font: var(--md-sys-typescale-body-sm);
|
font: var(--md-sys-typescale-body-sm);
|
||||||
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
|
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-table][data-dense] {
|
[data-md-table][data-md-dense] {
|
||||||
--cell-y: 0.5rem;
|
--cell-y: var(--md-sys-measurement-space100);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-table][data-size="xs"][data-dense] {
|
[data-md-table][data-md-size='xs'][data-md-dense] {
|
||||||
--cell-y: 0.25rem;
|
--cell-y: var(--md-sys-measurement-space50);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-table] :where(th, td) {
|
[data-md-table] :where(th, td) {
|
||||||
padding: var(--cell-y) var(--cell-x);
|
padding: var(--cell-y) var(--cell-x);
|
||||||
text-align: start;
|
text-align: start;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-table] :where(thead th) {
|
[data-md-table] :where(thead th) {
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
font: var(--md-sys-typescale-title-sm);
|
font: var(--md-sys-typescale-title-sm);
|
||||||
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
|
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
|
||||||
@@ -61,21 +67,20 @@
|
|||||||
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-table][data-size="xs"] :where(thead th) {
|
[data-md-table][data-md-size='xs'] :where(thead th) {
|
||||||
font: var(--md-sys-typescale-label-md);
|
font: var(--md-sys-typescale-label-md);
|
||||||
letter-spacing: var(--md-sys-typescale-label-md-tracking);
|
letter-spacing: var(--md-sys-typescale-label-md-tracking);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The role itself, not a fraction of it: M3 reserves opacity for state layers and disabled. */
|
[data-md-table] :where(tbody tr) {
|
||||||
[data-table] :where(tbody tr) {
|
|
||||||
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
border-bottom: 1px solid var(--md-sys-color-outline-variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-table] :where(tbody tr:last-child) {
|
[data-md-table] :where(tbody tr:last-child) {
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-table] :where(tbody tr[aria-selected="true"]) {
|
[data-md-table] :where(tbody tr[aria-selected='true']) {
|
||||||
background-color: var(--md-sys-color-secondary-container);
|
background-color: var(--md-sys-color-secondary-container);
|
||||||
color: var(--md-sys-color-on-secondary-container);
|
color: var(--md-sys-color-on-secondary-container);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@
|
|||||||
/* Inputs, selection and data — leaving in step 36 */
|
/* Inputs, selection and data — leaving in step 36 */
|
||||||
@import './components/datepicker.css';
|
@import './components/datepicker.css';
|
||||||
@import './components/timepicker.css';
|
@import './components/timepicker.css';
|
||||||
@import './components/table.css';
|
|
||||||
|
|
||||||
/* Containment — leaving in step 36 */
|
/* Containment — leaving in step 36 */
|
||||||
@import './components/list.css';
|
@import './components/list.css';
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
{{-- A data table. Its callers write `<thead>`, `<tr>`, `<th>` and `<td>` as they always do; how they
|
{{-- A data table. Its callers write `<thead>`, `<tr>`, `<th>` and `<td>` as they always do; how they
|
||||||
look is resources/css/components/table.css, keyed on the `data-table` attribute set here (the
|
look is resources/css/components/table.css, keyed on the `data-md-table` attribute set here (the
|
||||||
top of that file says why).
|
top of that file says why), with `data-md-size` and `data-md-dense`.
|
||||||
|
|
||||||
Rows are 52px, a target a finger can hit. `dense` tightens them to 36px — M3 asks that density
|
Rows are 52px, a target a finger can hit. `dense` tightens them to 36px — M3 asks that density
|
||||||
always be an opt-in and never a default, so it is the caller's decision and the caller's to
|
always be an opt-in and never a default, so it is the caller's decision and the caller's to
|
||||||
justify. `size="xs"` for a table inside a panel inside a panel (32px rows, 24px with `dense`).
|
justify. `size="xs"` for a table inside a panel inside a panel (32px rows, 24px with `dense`).
|
||||||
|
|
||||||
A selected row is `<tr aria-selected="true">`. Horizontal scrolling stays the caller's — wrap the
|
A selected row is `<tr aria-selected="true">`. Horizontal scrolling stays the caller's — wrap the
|
||||||
table in `<div class="overflow-x-auto">` where the page needs it. A column that sorts is
|
table in an element that scrolls sideways (`overflow-x: auto`) where the page needs it. A column
|
||||||
`<x-sort-header>`. --}}
|
that sorts is `<x-sort-header>`. --}}
|
||||||
|
|
||||||
@props([
|
@props([
|
||||||
'size' => 'sm',
|
'size' => 'sm',
|
||||||
'dense' => false,
|
'dense' => false,
|
||||||
])
|
])
|
||||||
|
|
||||||
<table data-table data-size="{{ $size === 'xs' ? 'xs' : 'sm' }}"{{ $dense ? ' data-dense' : '' }} {{ $attributes }}>
|
<table data-md-table data-md-size="{{ $size === 'xs' ? 'xs' : 'sm' }}"{{ $dense ? ' data-md-dense' : '' }} {{ $attributes }}>
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto rounded-corner-lg border border-structure">
|
<div class="overflow-x-auto rounded-corner-lg border border-structure">
|
||||||
<table class="w-full type-body-md" data-table>
|
<table class="w-full type-body-md" data-md-table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-start">Class</th>
|
<th class="text-start">Class</th>
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ function dataProbe()
|
|||||||
</x-table>
|
</x-table>
|
||||||
|
|
||||||
{{ $files->links() }}
|
{{ $files->links() }}
|
||||||
|
|
||||||
|
<x-table dense id="dense-table">
|
||||||
|
<tbody>
|
||||||
|
<tr><td>dense-a.zip</td><td>1</td></tr>
|
||||||
|
<tr aria-selected="true"><td>dense-b.zip</td><td>2</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</x-table>
|
||||||
</div>
|
</div>
|
||||||
BLADE);
|
BLADE);
|
||||||
app('view')->addNamespace('probe', $views);
|
app('view')->addNamespace('probe', $views);
|
||||||
@@ -103,3 +110,13 @@ it('pages through Livewire results and marks the current page', function () {
|
|||||||
$page->resize(400, 800)
|
$page->resize(400, 800)
|
||||||
->assertSee('Page 2 of 3');
|
->assertSee('Page 2 of 3');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('draws 52px rows, and 36px ones only in a table that asks to be dense', function () {
|
||||||
|
$row = fn (string $table): string => "document.querySelector('{$table} tbody tr').getBoundingClientRect().height";
|
||||||
|
|
||||||
|
dataProbe()
|
||||||
|
->assertScript("Math.abs(({$row('[data-md-table]:not([data-md-dense])')}) - 52) <= 1")
|
||||||
|
->assertScript("Math.abs(({$row('#dense-table')}) - 36) <= 1")
|
||||||
|
->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'");
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,14 +8,25 @@ use Livewire\WithPagination;
|
|||||||
|
|
||||||
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>'))
|
||||||
->toContain('<table data-table data-size="sm" class="min-w-160">')
|
->toContain('<table data-md-table data-md-size="sm" class="min-w-160">')
|
||||||
->and((string) $this->blade('<x-table size="xs" />'))->toContain('data-size="xs"')
|
->and((string) $this->blade('<x-table size="xs" />'))->toContain('data-md-size="xs"')
|
||||||
->and((string) $this->blade('<x-table size="huge" />'))->toContain('data-size="sm"');
|
->and((string) $this->blade('<x-table size="huge" />'))->toContain('data-md-size="sm"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('tightens a table only when the caller asks for it', function () {
|
it('tightens a table only when the caller asks for it', function () {
|
||||||
expect((string) $this->blade('<x-table />'))->not->toContain('data-dense')
|
expect((string) $this->blade('<x-table />'))->not->toContain('data-md-dense')
|
||||||
->and((string) $this->blade('<x-table dense />'))->toContain('data-dense');
|
->and((string) $this->blade('<x-table dense />'))->toContain('data-md-dense');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws 52px rows, 36px dense ones, from its stylesheet in the components layer', function () {
|
||||||
|
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/table.css');
|
||||||
|
|
||||||
|
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\'])')
|
||||||
|
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/table.css';")
|
||||||
|
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('table.css');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sorts by its column, ascending first and then flipping', function () {
|
it('sorts by its column, ascending first and then flipping', function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user