diff --git a/resources/css/components.css b/resources/css/components.css index d4462cac..ab39623f 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -59,5 +59,7 @@ @import './components/divider.css'; @import './components/collapse.css'; @import './components/card.css'; +@import './components/list-item.css'; +@import './components/list.css'; /* Navigation */ diff --git a/resources/css/components/list-item.css b/resources/css/components/list-item.css new file mode 100644 index 00000000..d73cbaa5 --- /dev/null +++ b/resources/css/components/list-item.css @@ -0,0 +1,237 @@ +/* + * : a headline with what leads it, what supports it and what trails it, one- two- or + * three-line by `data-md-lines` (56/72/88px, `ListTokens.kt:180,323,347`, androidx Compose + * Material 3, Apache-2.0) — the tallest element sets the height, and a three-line item top-aligns + * rather than centring (docs/reference/m3/components-actions-communication-containment.md + * § Lists → Specs). Leading/trailing gap and container padding are both `space200` (16px, + * `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 + * 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`, + * C-21), 4px corners that open to 16px at the list's own ends and while hovered (12px), pressed, + * focused or selected (16px) — the specs page's interaction-state expressive shapes (C-22). Leading + * icons there are drawn at 20px, not scaled down from 24 — list-item.blade.php passes `size` to + * `` from the parent's `@aware(['segmented'])`, so no CSS override is needed here (C-26). + * + * `data-md-dividers` on the list insets its rule 16px from both ends (`DividerLeadingSpace`/ + * `DividerTrailingSpace`, C-16) rather than full-bleed. + * + * Leading media: `data-md-list-item-avatar` is 40px, full corner, initials in primary-container + * when it is a ``, an image otherwise; `data-md-list-item-image` is a 56px small-corner + * thumbnail; `data-md-list-item-video` is M3's leading video in its two sizes, + * `data-md-size="sm"` 100×56px (a two-line item) or `"lg"` 114×64px (a three-line item), + * `LeadingVideoSmall`/`LeadingVideoLarge`. + */ + +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './icon.css'; + +@layer material.components { + [data-md-list-item] { + position: relative; + display: flex; + align-items: center; + gap: var(--md-sys-measurement-space200); + padding-inline: var(--md-sys-measurement-space200); + color: var(--md-sys-color-on-surface); + } + + [data-md-list-item][data-md-lines='1'] { + min-height: 56px; + padding-block: var(--md-sys-measurement-space100); + } + + [data-md-list-item][data-md-lines='2'] { + min-height: 72px; + padding-block: var(--md-sys-measurement-space100); + } + + [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); + align-items: flex-start; + } + + [data-md-list-item][data-md-selected] { + background-color: var(--md-sys-color-secondary-container); + 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); + } + + [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]) { + color: var(--md-sys-color-on-surface-variant); + } + + [data-md-list-item-leading], + [data-md-list-item-end] { + display: flex; + flex-shrink: 0; + align-items: center; + } + + [data-md-list-item-end] { + gap: var(--md-sys-measurement-space50); + } + + [data-md-list-item-video] { + flex-shrink: 0; + overflow: hidden; + border-radius: var(--md-sys-shape-corner-sm); + background-color: var(--md-sys-color-surface-container-highest); + + & > * { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + [data-md-list-item-video][data-md-size='sm'] { + width: 100px; + height: 56px; + } + + [data-md-list-item-video][data-md-size='lg'] { + width: 114px; + height: 64px; + } + + [data-md-list-item-avatar] { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: var(--md-sys-shape-corner-full); + object-fit: cover; + } + + span[data-md-list-item-avatar] { + display: grid; + place-items: center; + background-color: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + font: var(--md-sys-typescale-title-md); + letter-spacing: var(--md-sys-typescale-title-md-tracking); + } + + [data-md-list-item-image] { + flex-shrink: 0; + width: 56px; + height: 56px; + border-radius: var(--md-sys-shape-corner-sm); + object-fit: cover; + } + + [data-md-list-item-content] { + min-width: 0; + flex: 1 1 0%; + } + + [data-md-list-item-overline] { + font: var(--md-sys-typescale-label-sm); + letter-spacing: var(--md-sys-typescale-label-sm-tracking); + } + + [data-md-list-item-title] { + display: block; + overflow: hidden; + outline: none; + font: var(--md-sys-typescale-body-lg); + letter-spacing: var(--md-sys-typescale-body-lg-tracking); + text-overflow: ellipsis; + white-space: nowrap; + } + + [data-md-list-item-description] { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + font: var(--md-sys-typescale-body-md); + letter-spacing: var(--md-sys-typescale-body-md-tracking); + } + + [data-md-list-item-trailing] { + flex-shrink: 0; + font: var(--md-sys-typescale-label-sm); + letter-spacing: var(--md-sys-typescale-label-sm-tracking); + } + + [data-md-list][data-md-dividers] > [data-md-list-item]:not(:last-child)::after { + content: ''; + position: absolute; + inset-inline: var(--md-sys-measurement-space200); + bottom: 0; + height: 1px; + background-color: var(--md-sys-color-outline-variant); + pointer-events: none; + } + + [data-md-list-row] { + cursor: pointer; + transition-property: background-color, border-radius; + transition-duration: var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration); + transition-timing-function: var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast); + } + + @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); + } + } + + [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); + 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); + } + + [data-md-list-row] [data-md-list-open]:focus-visible { + outline: none; + } + + [data-md-list='segmented'] > [data-md-list-item] { + background-color: var(--md-sys-color-surface); + border-radius: var(--md-sys-shape-corner-xs); + } + + [data-md-list='segmented'] > [data-md-list-item]:first-child { + border-start-start-radius: var(--md-sys-shape-corner-lg); + border-start-end-radius: var(--md-sys-shape-corner-lg); + } + + [data-md-list='segmented'] > [data-md-list-item]:last-child { + border-end-start-radius: var(--md-sys-shape-corner-lg); + border-end-end-radius: var(--md-sys-shape-corner-lg); + } + + @media (hover: hover) { + [data-md-list='segmented'] > [data-md-list-item][data-md-list-row]:hover { + border-radius: var(--md-sys-shape-corner-md); + } + } + + [data-md-list='segmented'] > [data-md-list-item]:is([data-md-selected], [data-md-list-row]:active, [data-md-list-row]:has([data-md-list-open]:focus-visible)) { + border-radius: var(--md-sys-shape-corner-lg); + } +} diff --git a/resources/css/components/list.css b/resources/css/components/list.css index 0122f1ca..882d4679 100644 --- a/resources/css/components/list.css +++ b/resources/css/components/list.css @@ -1,163 +1,27 @@ /* - * Rows a person can go into (`data-list-row`, resources/js/list-rows.js), and M3 Expressive lists. + * : ``s, one under another. * - * A row answers a pointer with M3's state layer — hover 8%, press and focus 10% — but not while the - * pointer is on one of its own controls, which light only themselves. Hover only under - * `(hover: hover)`, because a touch screen keeps the last hover after a tap. The opener draws no - * focus ring inside a row; the row draws it, inset, so keyboard focus shows which row Enter opens. - * `data-selected` is M3's selected list item, in secondary-container. + * Plain by default; `data-md-list="segmented"` is M3 Expressive's list, each item its own surface + * tile `space25` (2px) apart (`SegmentedGap`, `ListTokens.kt`, androidx Compose Material 3, + * Apache-2.0) — the item's own background, corner and interaction states are list-item.css's, + * since they are the item's to draw, not the list's. * - * Unlayered where a component paints its fill as a utility (``): anything in a @layer loses - * to a utility whatever its specificity. - * - * Every selector that paints a row is `:where()`, so they all weigh nothing and the later rule - * wins: the segmented list's own fill is declared first and a row's state layer, further down, - * paints over it. Written any other way — outside the layer, or with its real specificity — the - * fill would beat the state layer and a segmented row would answer nothing. + * `selectable`/`selection` switch the container's role between `list` and `listbox` + * (list.blade.php; docs/reference/m3/components-actions-communication-containment.md + * § Lists → Accessibility, C-03) — a role takes no CSS of its own. */ -@layer components { - /* - * M3 Expressive's segmented list gives each item its own container. ListTokens.kt:201 — - * `ItemSegmentedContainerColor get() = ColorSchemeKeyTokens.Surface`. - */ - :where([data-list='segmented'] > [data-list-item]) { - background-color: var(--md-sys-color-surface); +@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; + +@import './list-item.css'; + +@layer material.components { + [data-md-list] { + display: flex; + flex-direction: column; } - /* - * ListTokens.kt:30,36 — `DividerLeadingSpace` and `DividerTrailingSpace` are 16dp, so a - * list's dividers are inset from both ends rather than full-bleed (``). - */ - :where([data-list][data-dividers] > [data-list-item]:not(:last-child))::after { - content: ''; - position: absolute; - inset-inline: 16px; - bottom: 0; - height: 1px; - background-color: var(--md-sys-color-outline-variant); - pointer-events: none; - } - - :where([data-list-row]) { - cursor: pointer; - transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast), - border-radius var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast); - } - - @media (hover: hover) { - :where([data-list-row]:not([data-card]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):hover))) { - background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent); - } - } - - :where([data-list-row]:not([data-card]):has([data-list-open]:focus-visible)) { - background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent); - outline: 3px solid var(--md-sys-color-secondary); - outline-offset: -3px; - } - - :where([data-list-row]:not([data-card]):active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):active))) { - background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent); + [data-md-list='segmented'] { + gap: var(--md-sys-measurement-space25); } } - -[data-list-row] [data-list-open]:focus-visible { - outline: none; -} - -:is([data-list-row], [data-list-item])[data-selected]:not([data-card]) { - background-color: var(--md-sys-color-secondary-container); - color: var(--md-sys-color-on-secondary-container); -} - -/* - * A card that opens answers with M3's state layer and one step of elevation, both written as one - * box-shadow because the elevated card's rest level is a `shadow-elevation-1` utility that only - * an unlayered rule can replace. Per-state elevation from the token files (the specs page's - * Hovered / Focused / Pressed rows): elevated 1 → 2 hovered → 1 focused and pressed; filled and - * outlined 0 → 1 hovered → 0. Its corner does not move: M3 gives a card one shape and specifies - * shape morph for buttons, FABs and list items, never for cards. - */ -[data-card][data-list-row] { - transition-property: background-color, box-shadow; - transition-duration: var(--md-sys-motion-spatial-default-duration); - transition-timing-function: var(--md-sys-motion-spatial-default); -} - -@media (hover: hover) { - [data-card][data-list-row]:not([data-dragged]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):hover)) { - box-shadow: var(--md-sys-elevation-1), inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent); - } - - [data-card='elevated'][data-list-row]:not([data-dragged]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):hover)) { - box-shadow: var(--md-sys-elevation-2), inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent); - } -} - -[data-card][data-list-row]:not([data-dragged]):active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):active)) { - box-shadow: inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent); -} - -[data-card='elevated'][data-list-row]:not([data-dragged]):active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):active)) { - box-shadow: var(--md-sys-elevation-1), inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent); -} - -/* - * M3's dragged card (``): the highest elevation a card ever takes, under the - * 16% dragged state layer. The token files' per-state elevation — elevated Level4 (8dp), filled - * and outlined Level3 (6dp) — and md.sys.state.dragged-state-layer-opacity from tokens/state.css - * (docs/reference/m3/components-actions-communication-containment.md § Cards → Specs). Written as - * one box-shadow with the row's other states, and named in the hover and press rules above so the - * card a pointer is holding stays at its dragged level rather than falling back to a press. - * - * The application owns the attribute: it sets `data-dragged` when its drag starts and takes it off - * on drop, since nothing in the browser tells a card it is being carried. M3 asks for a - * single-pointer alternative beside any drag, so give the card a menu with the same actions. - */ -[data-card][data-dragged] { - box-shadow: var(--md-sys-elevation-3), - inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-dragged-state-layer-opacity) * 100%), transparent); -} - -[data-card='elevated'][data-dragged] { - box-shadow: var(--md-sys-elevation-4), - inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-dragged-state-layer-opacity) * 100%), transparent); -} - -/* Focus is on the card itself when the card is the tab stop (``). */ -[data-card][data-list-row]:is(:focus-visible, :has([data-list-open]:focus-visible)) { - outline: 3px solid var(--md-sys-color-secondary); - outline-offset: 2px; -} - -/* - * M3 Expressive's segmented list (``): each item its own surface, 2px apart, - * extra-small corners that open to medium while hovered and to large at the ends and while - * focused, pressed or selected — the specs page's interaction-state expressive shapes, and - * ListTokens' `ItemPressedContainerExpressiveShape` / `ItemDraggedContainerExpressiveShape` - * (androidx Compose Material 3, Apache-2.0). - */ -[data-list='segmented'] > [data-list-item] { - border-radius: var(--md-sys-shape-corner-xs); -} - -[data-list='segmented'] > [data-list-item]:first-child { - border-start-start-radius: var(--md-sys-shape-corner-lg); - border-start-end-radius: var(--md-sys-shape-corner-lg); -} - -[data-list='segmented'] > [data-list-item]:last-child { - border-end-start-radius: var(--md-sys-shape-corner-lg); - border-end-end-radius: var(--md-sys-shape-corner-lg); -} - -@media (hover: hover) { - [data-list='segmented'] > [data-list-item][data-list-row]:hover { - border-radius: var(--md-sys-shape-corner-md); - } -} - -[data-list='segmented'] > [data-list-item]:is([data-selected], [data-list-row]:active, [data-list-row]:has([data-list-open]:focus-visible)) { - border-radius: var(--md-sys-shape-corner-lg); -} diff --git a/resources/css/components/table.css b/resources/css/components/table.css index afa4e225..3092bdf2 100644 --- a/resources/css/components/table.css +++ b/resources/css/components/table.css @@ -8,7 +8,8 @@ * 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` - * and answers a pointer as a list row does (components/list.css). + * and answers a pointer as a list row does (components/list-item.css, shared with `` and + * ``). * * 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, @@ -21,6 +22,8 @@ @layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility; +@import './list-item.css'; + @layer material.components { /* `position: relative` makes the table the containing block for anything absolute inside it — a visually hidden header label, a tooltip. Without it they escape the scroll box and widen a diff --git a/resources/css/tailwind.css b/resources/css/tailwind.css index 7e81645d..0d0876aa 100644 --- a/resources/css/tailwind.css +++ b/resources/css/tailwind.css @@ -18,7 +18,6 @@ @import './tokens/utilities.css'; /* Containment — leaving in step 36 */ -@import './components/list.css'; @import './components/dialog.css'; /* Navigation — leaving in step 36 */ diff --git a/resources/js/list-rows.js b/resources/js/list-rows.js index da02c3e5..1b6dbe5d 100644 --- a/resources/js/list-rows.js +++ b/resources/js/list-rows.js @@ -1,37 +1,27 @@ /** * A row opens from anywhere on it. * - * A row is `data-md-list-row` (or the older, unprefixed `data-list-row`, while a component that - * has not yet moved off it still renders it) — an ``, an ``, a table row — - * and exactly one control inside it is its opener, `data-md-list-open` (or `data-list-open`): the - * title button that opens a sheet, or the link that goes to the item's page. A click anywhere else - * on the row is handed to the opener, so the whole row is the target while the opener stays a real - * control — the tab stop, the name a screen reader reads, and the owner of the `wire:click` or the - * `href`. The row's other controls keep their own clicks. How a row looks while that happens is - * resources/css/components/card.css and list.css. + * A row is `data-md-list-row` — an ``, an ``, a table row — and exactly one + * control inside it is its opener, `data-md-list-open`: the title button that opens a sheet, or + * the link that goes to the item's page. A click anywhere else on the row is handed to the opener, + * so the whole row is the target while the opener stays a real control — the tab stop, the name a + * screen reader reads, and the owner of the `wire:click` or the `href`. The row's other controls + * keep their own clicks. How a row looks while that happens is + * resources/css/components/card.css and list-item.css. * * 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 ')) - ->toContain('data-selected') + ->toContain('data-md-selected') + ->toContain('data-md-list-item-leading') ->toContain('') + ->toContain('data-md-list-item-end') ->toContain(''); }); it('leads with M3\'s video, in the size the item\'s height calls for', function () { expect((string) $this->blade('')) - ->toContain('h-14 w-25') - ->toContain('rounded-corner-sm') + ->toContain('data-md-list-item-video') + ->toContain('data-md-size="sm"') ->toContain('') - ->toContain('min-h-18 py-2') + ->toContain('data-md-lines="2"') ->and((string) $this->blade('')) - ->toContain('h-16 w-28.5') - ->toContain('min-h-22 py-3') + ->toContain('data-md-size="lg"') + ->toContain('data-md-lines="3"') ->and((string) $this->blade('')) ->toContain('') - ->toContain('h-14 w-25') + ->toContain('data-md-size="sm"') ->and((string) $this->blade('')) - ->not->toContain('h-14 w-25') - ->toContain('min-h-14 py-2'); + ->not->toContain('data-md-list-item-video') + ->toContain('data-md-lines="1"'); });