Compare commits
36
Commits
bae1df09da
...
main
+10
-25
@@ -11,28 +11,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||
with:
|
||||
php-version: '8.5'
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --no-progress --prefer-dist
|
||||
|
||||
- name: Check formatting
|
||||
run: vendor/bin/pint --test
|
||||
|
||||
feature:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -54,19 +32,26 @@ jobs:
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
# material:scheme's tests run the bundled colour script through Node.
|
||||
# material:scheme's tests run the bundled colour script through Node, and the prebuilt
|
||||
# stylesheets' staleness test rebuilds them with the installed Vite.
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --no-progress --prefer-dist
|
||||
run: |
|
||||
composer install --no-interaction --no-progress --prefer-dist
|
||||
npm ci
|
||||
|
||||
- name: Check formatting
|
||||
if: matrix.php == '8.5'
|
||||
run: vendor/bin/pint --test
|
||||
|
||||
- name: Run feature tests
|
||||
run: vendor/bin/pest --testsuite=Feature
|
||||
|
||||
# One job per engine: the floor is Chrome 125, Firefox 147 and Safari 18.4, and the
|
||||
# One job per engine: the floor is Chrome 125, Firefox 151 and Safari 18.4, and the
|
||||
# components lean on platform features (dialog, popover, anchor positioning) that
|
||||
# differ between them. Pest calls the engines chrome, firefox and safari; Playwright
|
||||
# installs them as chromium, firefox and webkit.
|
||||
|
||||
@@ -52,6 +52,10 @@ Spring motion constants (resources/css/tokens/motion.css)
|
||||
From androidx Compose Material 3, tokens/ExpressiveMotionTokens.kt.
|
||||
Copyright The Android Open Source Project. Apache License 2.0.
|
||||
|
||||
CSS reset (resources/css/foundation/reset.css)
|
||||
Derived rule for rule from Tailwind CSS 4's preflight, after modern-normalize.
|
||||
Copyright Tailwind Labs. MIT License.
|
||||
|
||||
Material Design 3 documentation (resources/boost/guidelines/material-3.blade.php,
|
||||
resources/boost/skills/material-3-design/SKILL.md, docs/reference/m3)
|
||||
Rules, tables and wording condensed from https://m3.material.io (Foundations, Styles,
|
||||
@@ -62,3 +66,4 @@ resources/boost/skills/material-3-design/SKILL.md, docs/reference/m3)
|
||||
Copyright The Android Open Source Project (Apache License 2.0).
|
||||
|
||||
A copy of the Apache License 2.0 is available at https://www.apache.org/licenses/LICENSE-2.0.
|
||||
A copy of the MIT License is available at https://opensource.org/licenses/MIT.
|
||||
|
||||
@@ -8,7 +8,7 @@ Material 3 Expressive components for Laravel and Livewire, in plain CSS.
|
||||
- Error pages and a Markdown mail theme in the same scheme.
|
||||
- A showcase of every component in the application's own scheme, a design guard for tests, and for AI agents two Laravel Boost guidelines and two skills: the library's own, and Material 3's rules and tables beside its utilities.
|
||||
|
||||
No JavaScript libraries beyond the Alpine that ships with Livewire. Browsers: Chrome 125+, Firefox 147+, Safari 18.4+.
|
||||
No JavaScript libraries beyond the Alpine that ships with Livewire. Browsers: Chrome 125+, Firefox 151+, Safari 18.4+.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -27,7 +27,7 @@ composer require nonameweb/livewire-material
|
||||
|
||||
The application's build imports from `vendor/`, so Composer packages must be installed before `npm run build` — in a Dockerfile, copy `composer.json`, run `composer install`, then build the assets.
|
||||
|
||||
The package's stylesheets are plain CSS, no build step of its own and no Tailwind anywhere in the stack. `all.css` brings the foundation and every component; an application can instead import `foundation.css` first and then the stylesheet of each component its views render (`resources/css/components/button.css`, `resources/css/layout/scaffold.css`, …), since each imports the stylesheets of the components it draws and Vite keeps a file several of them import once. The error pages need no import: they take the foundation, scheme and font from the application's build and inline their own layout's rules beside it.
|
||||
The package's stylesheets are plain CSS, no build step of its own. `all.css` brings the foundation and every component; an application can instead import `foundation.css` first and then the stylesheet of each component its views render (`resources/css/components/button.css`, `resources/css/layout/scaffold.css`, …), since each imports the stylesheets of the components it draws and Vite keeps a file several of them import once. The error pages need no import: they take the foundation, scheme and font from the application's build and inline their own layout's rules beside it.
|
||||
|
||||
```css
|
||||
/* resources/css/app.css */
|
||||
@@ -94,7 +94,7 @@ php artisan vendor:publish --tag=livewire-material-config
|
||||
- `motion.scheme` — M3's motion scheme: `expressive` (default) or `standard`, the restrained springs, written to `<html data-motion>`.
|
||||
- `profiles`, `profile` — colour profiles and the default one (see Colour profiles).
|
||||
- `fields.variant` — text fields `outlined` (default) or `filled`.
|
||||
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`).
|
||||
- `pagination` — draw Laravel's and Livewire's paginators in M3 (default `true`); the provider also sets `livewire.pagination_theme` to `material` whenever it still reads as Livewire's own default (`tailwind`), unless an application's own theme, or a component's `$paginationTheme` property or `paginationView()` method, already chose one.
|
||||
- `showcase.enabled`, `showcase.path`, `showcase.middleware`, `showcase.vite`.
|
||||
- `node` — the Node binary for `material:scheme`.
|
||||
|
||||
@@ -150,9 +150,9 @@ it('uses only what compiles', function () {
|
||||
});
|
||||
```
|
||||
|
||||
The guard reads every path it is given and fails, with `path:line` and the 2.0.0 replacement, on any Tailwind utility or variant still in a view, PHP or JS file — none compiles, since the application carries no Tailwind — and on 1.x's own utilities (`type-body-md`, `text-meta`, `rounded-corner-lg`, `focus-ring`): a layout component and prop (`flex gap-4` → `<x-row gap="space200">`), an `md-*` class (`truncate` → `md-truncate`), or a token for the application's own CSS (`rounded-lg` → `var(--md-sys-shape-corner-lg)`). A class the application's own stylesheets declare is exempt. In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, easing or duration, and on a media query off M3's 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine. It also fails on unknown Material Symbol names and Blade directives written inside component tags.
|
||||
The guard reads every path it is given and fails, with `path:line`, on a class written in a view or PHP file that no stylesheet declares — not the application's own CSS, not the package's `md-*` — whatever it came from: a utility of a framework that isn't in the stack, a typo, or a class whose rule was deleted. Without a `.css` file among the scanned paths or a `missingStylesheets()` entry to supply the application's import graph, it has no declared class to compare against and reports every one of them. In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, easing or duration, and on a media query off M3's 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine. It also fails on unknown Material Symbol names and Blade directives written inside component tags. Markdown mail components (under `mail.markdown.paths`, `resources/views/vendor/mail` by default) are the mail theme's to style: their classes are not read by the class check, and a mail theme stylesheet there is neither checked for literals nor a source of declared classes.
|
||||
|
||||
`missingStylesheets($cssEntry)` checks the entry's relative `@import` graph, followed through every package file's own imports, against the package tags the views render (unprefixed, under the configured prefix, or `<x-livewire-material::…>`) and `->links()`, and names each missing `@import` line once; a tag the application shadows with its own component is reported instead. It reads imports only, so leave `resource_path('css')` out of `scan()` while the stylesheets still hold literals. `forbidColours([...])` names roles the application leaves out, and fails wherever one (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. `forbid($pattern, $reason)` adds any pattern of your own, line by line.
|
||||
`missingStylesheets($cssEntry)` checks the entry's relative `@import` graph, followed through every package file's own imports, against the package tags the views render (unprefixed, under the configured prefix, or `<x-livewire-material::…>`), `->links()` and the rows they write by hand (`data-md-list-row` on anything but `<x-card>` needs `components/list-item.css`), and names each missing `@import` line once; a tag the application shadows with its own component is reported instead. It reads imports only, so leave `resource_path('css')` out of `scan()` while the stylesheets still hold literals — as long as `missingStylesheets()` is given an entry, its import graph still supplies the declared classes family (i) needs. `forbidColours([...])` names roles the application leaves out, and fails wherever one (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. `forbid($pattern, $reason)` adds any pattern of your own, line by line.
|
||||
|
||||
## AI agents
|
||||
|
||||
|
||||
+189
-32
@@ -1,42 +1,194 @@
|
||||
# Upgrading
|
||||
|
||||
## From 2.1.0 to 2.2.0
|
||||
|
||||
- **Run `php artisan view:clear` after upgrading.** The components are no longer registered under a
|
||||
view namespace named after a hash of their folder, which only views compiled before 1.0.1 still
|
||||
named; such a view shows the tag as text until it is compiled again.
|
||||
- **`DesignGuard`** no longer reports the 1.x package's own names (`text-meta`, `focus-ring`,
|
||||
`rounded-corner-lg`, `shadow-elevation-2`, `type-body-md`, `ease-spatial-fast`) with their 2.0.0
|
||||
replacement. A colour utility on a 1.x ink is still reported, as a colour of the application's own
|
||||
theme; the rest compile to nothing and pass unnoticed.
|
||||
- **`<x-slider>`** redraws when a script sets its input's `value`, as before, but no longer when it
|
||||
sets `valueAsNumber`; write `value` instead.
|
||||
- **The showcase's stylesheet** is served from `assets/css/showcase.{hash}.css`, prebuilt in
|
||||
`resources/dist/`, and the error page inlines prebuilt bundles too. `Stylesheets::bundle()` and
|
||||
`Stylesheets::resetCache()` are gone; nothing outside the package called them.
|
||||
- **`$store.rail.toggle()`** flips what the first rail on the page draws, as its menu button does.
|
||||
With nothing stored, `<x-scaffold>`'s rail is drawn collapsed from 840 to 1199px whatever
|
||||
`rail.default` says, and `toggle()` collapsed it again, so the first press of an application's
|
||||
shortcut changed nothing; it now expands the rail. Below 840px, where there is no room to expand
|
||||
it in the layout, `toggle()` opens and closes the modal rail (`show()`, `hide()`) instead of
|
||||
changing a choice nothing draws there. A shortcut that pressed the rail's menu button to get this
|
||||
can call `$store.rail.toggle()` again.
|
||||
- **`DesignGuard`** no longer reads a Markdown mail component's classes as Tailwind: a view under
|
||||
`mail.markdown.paths` (`resources/views/vendor/mail` by default) is drawn by the mail theme, whose
|
||||
`table`, `button` and `panel` share Tailwind's names, so only its icon names, the directives in
|
||||
its component tags and the application's own bans are checked there. A mail theme stylesheet
|
||||
under that path is no longer checked for literal values, and its classes no longer exempt the
|
||||
same names in the application's other views. A filter an application wrote around such a
|
||||
finding can go.
|
||||
- **`DesignGuard`** counts a row written by hand. `data-md-list-row` on an `<li>`, a `<div>`, a
|
||||
`<tr>` or `<x-row>` takes its hover, focus and press state layer and its selected fill from
|
||||
`components/list-item.css`, but `missingStylesheets()` and `unusedStylesheets()` read only tags
|
||||
and `->links()`: an application had the stylesheet through `table.css` or `<x-list-item>`, lost
|
||||
it without a finding when the last of those left its views, and an `@import` of `list-item.css`
|
||||
for its own rows was reported unused. A view of the application that writes `data-md-list-row`
|
||||
on anything but `<x-card>`, whose row card.css draws, now needs `list-item.css`; a selector such
|
||||
as `[data-md-list-row]` and the package's own views do not count. A test that expects no findings
|
||||
may name the `@import` to add, and a filter written around the unused report can go.
|
||||
- **A selected row written by hand** — `data-md-list-row` with `data-md-selected` on an `<li>`, a
|
||||
`<div>` or a `<tr>`, not `<x-list-item>` or `<x-card>` — is drawn in `secondary-container` with
|
||||
`on-secondary-container` ink again, as 1.x's `data-list-row` with `data-selected` was; 2.0.0
|
||||
dropped the fill without saying so. Hover, focus and press now tint the fill instead of hiding
|
||||
it. In `<x-table>` a plain row takes `data-md-selected` too, beside `aria-selected="true"`. A
|
||||
card still takes no selected fill.
|
||||
- **67 Material Symbols** in each cut (`auto_awesome`, `insights`, `tips_and_updates`,
|
||||
`battery_50` …) are drawn whole at every size. Google publishes them in 24 units (20 in the 20
|
||||
cut) with a width and height but no viewBox, and the package dropped the size, so they fitted
|
||||
only at exactly 24px and were cut off or misplaced at any other — the 20 cut every small button
|
||||
draws from included. Nothing to change.
|
||||
- **A focused text field keeps its focus edge under the pointer.** The hover rule outweighed focus,
|
||||
so a focused field the pointer rested on drew a 2px `on-surface` edge instead of `primary`, and
|
||||
a field in error `on-error-container` instead of `error` — in the filled variant too, and on a
|
||||
customizable `<x-select>` while its menu is open. An open select in error now draws `error`
|
||||
rather than `primary`. A test that read the hover colour off a focused field reads `primary`
|
||||
now.
|
||||
- **Escape closes one layer.** A dialog, menu, customizable select or searchable choice opened
|
||||
over or inside a modal `<x-drawer>`, `<x-bottom-sheet>` or modal navigation rail — or a sheet
|
||||
opened from a sheet, or inside `<x-modal>` — closed the layer under it on the same Escape, since
|
||||
each sheet closed on any Escape the window heard. Now only the topmost layer closes; a sheet
|
||||
with `close-on-escape` off still keeps the Escape from closing what is under it. A dialog or
|
||||
sheet opened from a modal sheet but rendered elsewhere on the page was also hidden from screen
|
||||
readers by the sheet's `aria-hidden`, and Tab could not move inside the dialog; both work now.
|
||||
An application's own guard around a sheet's Escape can go.
|
||||
- **A standard `<x-drawer>` sticks under the top safe area** (`--material-safe-top`, else the
|
||||
device's inset) instead of at the window's edge, so its head and close button no longer slide
|
||||
under an installed app's status bar as the page scrolls; inside `<x-scaffold>` it also sticks
|
||||
under a sticky `<x-app-bar>` in the `top` slot, which covered it. It is as tall as the window
|
||||
below that, and every side sheet keeps the bottom safe area inside its bottom padding.
|
||||
- **A standard `<x-drawer>` takes its place from the first paint.** Until Alpine started, a closed
|
||||
standard sheet was a zero-wide item that still cost its row one gap, and a sheet bound to an open
|
||||
Livewire property had no width yet, so the content beside them changed width when the script
|
||||
ran. A standard sheet is now out of the row until then, and one whose `wire:model` property is
|
||||
open is rendered open and stands at its width. One opened from an Alpine scope alone still
|
||||
appears when Alpine starts, which only the script can know.
|
||||
- **A long value in `<x-select>` ends before the arrow, with an ellipsis.** Where the browser has
|
||||
the customizable select (Chrome, Safari), the closed select drew its value in a box of its own
|
||||
that took neither `text-overflow` nor a width from the select, so a long option in a narrow
|
||||
field, or at `size="sm"` and `xs`, ran on under the arrow; the native select (Firefox) cut it at
|
||||
the arrow with no ellipsis. The select's first child is now a `<button type="button"
|
||||
wire:ignore>` holding `<selectedcontent>`, the browser's copy of the chosen option, which
|
||||
select.css cuts; a browser without the customizable select never renders it. A test that
|
||||
matched a select's markup up to its first `<option>` finds the button there.
|
||||
- **A switch beside long text in a row keeps its track.** `<x-toggle>`'s root could shrink to
|
||||
nothing as a flex item, so beside a paragraph in `<x-row justify="between">`, or with a long
|
||||
label of its own, the 52px track ran out of the row and past a card's edge on a phone.
|
||||
`<x-checkbox>` did the same with its box. Both roots now keep their automatic minimum — the
|
||||
control, and beside a label its longest word — so the text wraps instead. A `flex: none` an
|
||||
application put on the switch to hold it can go.
|
||||
- **A disabled `<x-button fab>` is not drawn on a compact window.** Below `medium` it was still
|
||||
the extended FAB, greyed out and fixed over the content; M3 never shows a disabled FAB and
|
||||
removes one whose action is unavailable, so a `fab` given `disabled` (on a link too) now renders
|
||||
`data-md-unavailable` and is `display: none` there, out of the accessibility tree with it. From
|
||||
`medium` it is the disabled filled button, as before. A `spinner` that disables the button while
|
||||
its action runs leaves the FAB on screen. An application's own `hide-below` or condition around
|
||||
a disabled fab button can go.
|
||||
- **`<x-choices searchable>`, `<x-datepicker>` and `<x-timepicker>` take `full`**, as `<x-input>`,
|
||||
`<x-textarea>`, `<x-select>` and `<x-file>` do, to take the 40rem bound off a field from
|
||||
`medium`; the prop did nothing there, so the field stopped at 40rem beside rows that filled their
|
||||
card. `<x-choices>`' chips have no bound and take `full` without a change, so a call site can
|
||||
switch `searchable` on and off. A width rule an application wrote around one of them for this
|
||||
can go.
|
||||
- **A searchable `<x-choices>`' list is as wide as its field.** It was anchored to a wrapper
|
||||
around the field, which fills its container, so from `medium`, where the field stops at 40rem,
|
||||
the list ran on past the field's end across the whole container. The field itself is the anchor
|
||||
now, and the wrapper `<div>` is gone; the list keeps hanging under the field's supporting text.
|
||||
- **A sheet or dialog that opens shows no tooltip on the control it focuses.** A modal
|
||||
`<x-drawer>`, `<x-bottom-sheet>`, the modal rail and `<x-modal>` move the focus to their first
|
||||
control as they open, and on a page loaded with the sheet open (`wire:model` already set, say
|
||||
from `?workout=` in the URL) or a layer opened from the keyboard, browsers count that focus as
|
||||
keyboard focus: the close button's plain tooltip stood over the sheet the moment it appeared, in
|
||||
Chrome, Firefox and Safari. Plain and rich tooltips now leave out focus a modal layer moves into
|
||||
itself from outside; a Tab onto the control, including the one that wraps round to it, and focus
|
||||
moved within the layer still show them. An `autofocus` an application put on another control to
|
||||
keep the focus off the close button can go.
|
||||
- **`DesignGuard`** reports any class a view or PHP file writes that no stylesheet declares —
|
||||
not the package's `md-*`, not the application's own CSS — instead of naming Tailwind's
|
||||
utilities, variants and theme colours with their 2.0.0 replacement. It now catches a dead class
|
||||
from any source: a typo, a utility of a framework that isn't in the stack, a class whose rule
|
||||
was deleted — not only Tailwind's — but a class assembled in a PHP string (`'text-'.$tone`) is
|
||||
still not seen. A test that asserted on the old Tailwind-shaped message needs the new one
|
||||
instead.
|
||||
- **The pagination views are `material.blade.php` and `simple-material.blade.php`**, no longer named
|
||||
after Tailwind. The provider sets `Paginator::defaultView('pagination::material')` and
|
||||
`defaultSimpleView('pagination::simple-material')`, and switches `livewire.pagination_theme` to
|
||||
`material` whenever it still reads as Livewire's own default (`tailwind`, including when the key
|
||||
is missing), so an application can no longer forget the config and silently render Livewire's own
|
||||
Tailwind view. An application that already chose another theme, or a component with its own
|
||||
`$paginationTheme` property or `paginationView()` method, is untouched — either still wins.
|
||||
- **`foundation.css` no longer explains how to order layers beside Tailwind.** No consumer builds
|
||||
Tailwind any more, so the paragraph about opening both entries with
|
||||
`@layer properties, theme, base, material, components, utilities;` is gone; the layer statement
|
||||
and the order it declares are otherwise unchanged.
|
||||
|
||||
## From 2.0.0 to 2.1.0
|
||||
|
||||
- **Browsers:** Firefox 151 or later (was 147), for container style queries on a custom property;
|
||||
Chrome 125 and Safari 18.4 are unchanged.
|
||||
- **`<x-navigation-rail>`** publishes its value as `--md-navigation-rail-value`, `collapsed` or
|
||||
`expanded` (M3's two rail values). Content an application puts in a rail reads it with
|
||||
`@container style(--md-navigation-rail-value: collapsed)` instead of copying the rail's
|
||||
conditions from `navigation-rail.css`.
|
||||
- **`<x-loading size="96">`** sizes the loading indicator in px, 24 to 240 (M3's responsive range),
|
||||
with the container and the shape in proportion. A width and height of the application's own
|
||||
still work.
|
||||
|
||||
## From 1.x to 2.0.0
|
||||
|
||||
2.0.0 aligns the library with Material Design 3 Expressive as Google documents it
|
||||
(m3.material.io, checked page by page; the audits are in `docs/audits/m3-alignment/`). Most of
|
||||
the change is inside the components. What reaches an application is below, in the order to do it.
|
||||
2.0.0 aligns the library with Material Design 3 Expressive as Google documents it (m3.material.io,
|
||||
checked page by page). Most of the change is inside the components. What reaches an application is
|
||||
below, in the order to do it.
|
||||
|
||||
### 1. Breakpoints are M3's window size classes
|
||||
|
||||
Tailwind's `sm:`, `md:`, `lg:`, `xl:` and `2xl:` no longer compile. The variants are
|
||||
`medium:` (600px), `expanded:` (840px), `large:` (1200px) and `extra-large:` (1600px), plus
|
||||
`max-medium:` and friends for "below"; compact is everything below `medium`. Replace every
|
||||
prefix in the application's views:
|
||||
Tailwind's `sm:`, `md:`, `lg:`, `xl:` and `2xl:` no longer compile, and there are no responsive
|
||||
variants in their place: the breakpoints are M3's window size classes — compact below 600px, then
|
||||
medium (600px), expanded (840px), large (1200px) and extra-large (1600px) — and only those. A
|
||||
layout component takes the class as a prop (`hide-below`, `hide-from`, `stack-below`, `<x-grid>`'s
|
||||
`columns` map); the application's own CSS writes the width as a range media query:
|
||||
|
||||
| Was | Becomes |
|
||||
|---|---|
|
||||
| `sm:` / `max-sm:` (640) | `medium:` / `max-medium:` (600) |
|
||||
| `md:` / `max-md:` (768) | `medium:` or `expanded:` — choose by what the layout means |
|
||||
| `lg:` / `max-lg:` (1024) | `expanded:` / `max-expanded:` (840) |
|
||||
| `xl:` / `max-xl:` (1280) | `large:` / `max-large:` (1200) |
|
||||
| `2xl:` (1536) | `extra-large:` (1600) |
|
||||
| `sm:` / `max-sm:` (640) | `medium` · `@media (width >= 600px)` / `(width < 600px)` |
|
||||
| `md:` / `max-md:` (768) | `medium` or `expanded` — choose by what the layout means |
|
||||
| `lg:` / `max-lg:` (1024) | `expanded` · `@media (width >= 840px)` / `(width < 840px)` |
|
||||
| `xl:` / `max-xl:` (1280) | `large` · `@media (width >= 1200px)` / `(width < 1200px)` |
|
||||
| `2xl:` (1536) | `extra-large` · `@media (width >= 1600px)` |
|
||||
|
||||
Scripts read the same numbers from `resources/js/breakpoints.js` (`from('expanded')`,
|
||||
`upTo('medium')`). `DesignGuard` reports every leftover prefix with its replacement.
|
||||
`<div class="hidden lg:block">` is `<x-stack hide-below="expanded">`; `flex flex-col sm:flex-row` is
|
||||
`<x-row stack-below="medium">`; `grid-cols-1 lg:grid-cols-2` is
|
||||
`<x-grid :columns="['compact' => 1, 'expanded' => 2]">`. Scripts read the same numbers from
|
||||
`resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`). `DesignGuard` reports every
|
||||
leftover prefix with its replacement.
|
||||
|
||||
### 2. Only M3's scales compile
|
||||
### 2. Only M3's scales, as tokens
|
||||
|
||||
Tailwind's default radius, shadow, text-size, weight, leading, tracking and easing utilities are
|
||||
cleared like its palette was:
|
||||
Tailwind's radius, shadow, text-size, weight, leading, tracking and easing utilities compile to
|
||||
nothing, and so do the 1.x utilities that stood for M3's scales. A text style is a class; the
|
||||
rest is a token in the application's own CSS:
|
||||
|
||||
| Was | Becomes |
|
||||
|---|---|
|
||||
| `rounded-sm` … `rounded-4xl` | `rounded-corner-xs` … `rounded-corner-xxl` (`rounded-corner-full`, `rounded-corner-none`) |
|
||||
| `shadow-sm` … `shadow-2xl` | `shadow-elevation-1` … `shadow-elevation-5` |
|
||||
| `text-xs` … `text-9xl`, `leading-*`, `tracking-*`, `font-medium` … `font-black` | one `type-*` style (`type-body-md`, `type-emphasized-title-md` …) |
|
||||
| `ease-in`, `ease-out`, `ease-in-out`, `duration-300` | `ease-spatial-*` / `ease-effects-*` with `duration-(--md-sys-motion-…-duration)` |
|
||||
| `rounded-sm` … `rounded-4xl`, `rounded-corner-*` (1.x) | `var(--md-sys-shape-corner-xs)` … `var(--md-sys-shape-corner-xxl)` (`-full`, `-none`), or `<x-surface corner="xs">` |
|
||||
| `shadow-sm` … `shadow-2xl`, `shadow-elevation-*` (1.x) | `var(--md-sys-elevation-1)` … `var(--md-sys-elevation-5)` |
|
||||
| `text-xs` … `text-9xl`, `leading-*`, `tracking-*`, `font-medium` … `font-black`, `type-*` (1.x) | one `md-type-*` class (`md-type-body-md`, `md-type-emphasized-title-md` …), or `font: var(--md-sys-typescale-body-md)` with its `-tracking` |
|
||||
| `ease-in`, `ease-out`, `ease-in-out`, `duration-300`, `ease-spatial-*` (1.x) | `var(--md-sys-motion-spatial-*)` / `var(--md-sys-motion-effects-*)` with its `-duration`, in a `transition` |
|
||||
| `gap-4`, `p-4`, `space-y-2` | `gap="space200"`, `<x-surface padding="space200">`, `<x-stack gap="space100">`, or `var(--md-sys-measurement-space200)` |
|
||||
| `state-layer`, `focus-ring`, `touch-target`, `link` (1.x) | `md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link` |
|
||||
|
||||
The 4px spacing scale is unchanged. `DesignGuard` names each one with its replacement.
|
||||
The spacing tokens are the 4px grid Tailwind's scale was (`space200` is 16px). `DesignGuard` names
|
||||
each of Tailwind's own utilities with its replacement; the 1.x names are no longer reported.
|
||||
|
||||
### 3. Regenerate the colour scheme
|
||||
|
||||
@@ -56,10 +208,13 @@ php artisan material:scheme "#4f46e5" --variant=tonal-spot # the command in th
|
||||
|
||||
### 4. Inks are roles, not opacities
|
||||
|
||||
`text-meta`, `text-quiet`, `border-chrome` and `border-divider` keep their names but now resolve
|
||||
to `on-surface-variant`, `outline`, `outline-variant` and `outline-variant`. Where the old
|
||||
translucent grey was intended, nothing to do; where a template relied on the opacity to blend
|
||||
over a colour, use the role directly.
|
||||
1.x's `text-meta`, `text-body`, `text-quiet`, `border-chrome`, `border-structure` and
|
||||
`border-divider` are gone with the rest of the utilities. Text takes the role's `md-ink-*` class —
|
||||
`md-ink-variant` (on-surface-variant) for `text-meta` and `text-body`, `md-ink-quiet` (outline) for
|
||||
`text-quiet` — and a line is `<x-divider>`, `<x-surface outlined>` or
|
||||
`var(--md-sys-color-outline-variant)` in the application's CSS. Where the old translucent grey was
|
||||
intended, the role is the same colour; where a template relied on the opacity to blend over a
|
||||
colour, use the role directly.
|
||||
|
||||
### 5. Changed defaults and props
|
||||
|
||||
@@ -111,19 +266,20 @@ over a colour, use the role directly.
|
||||
- `<x-app-shell>` is renamed `<x-scaffold>`, with no alias; it is a column with a nested row
|
||||
and gains `banner` and `fab` slots. `<x-navigation-rail>`'s `header` slot takes one
|
||||
`<x-fab label icon>` that morphs (replace the two-FAB `rail-collapsed:` swap); new `divider`
|
||||
and `fill` props. The `rail-collapsed:` variant is gone: style a rail's collapsed shape in the
|
||||
application's CSS with the conditions `resources/css/components/navigation-rail.css` lists. `data-app-shell`, `data-app-shell-bar`, `data-app-shell-actions` and
|
||||
and `fill` props. The `rail-collapsed:` variant is gone: style what the application puts in a
|
||||
rail by the rail's value, `@container style(--md-navigation-rail-value: collapsed)` (2.1.0).
|
||||
`data-app-shell`, `data-app-shell-bar`, `data-app-shell-actions` and
|
||||
`data-app-shell-banner` are `data-md-scaffold`, `data-md-scaffold-bar`, `data-md-scaffold-actions`
|
||||
and `data-md-scaffold-banner`; the skip link is `data-md-skip-link`.
|
||||
- `<x-icon optical="20">` selects the optical-size-20 cut; the components pass it for their own
|
||||
small icons, applications pass it for icons drawn at 20px or less.
|
||||
- A text field stops at 40rem wide from `medium:`, as M3 bounds fields on wider windows; a
|
||||
`max-w-*` class on the field beats it, and `full` removes it.
|
||||
- A text field stops at 40rem wide from medium (600px), as M3 bounds fields on wider windows; a
|
||||
width rule of the application's own on the field beats it, and `full` removes it.
|
||||
- Every `<x-modal>` draws a rule under its header and over its actions while its body scrolls,
|
||||
and neither when the body fits. `separator` now means "always draw both rules" and no longer
|
||||
renders two `<x-divider>` elements; the spacing between header, body and actions moved to
|
||||
M3's split gaps, so a dialog that fits is a few pixels shorter.
|
||||
- A snackbar with a `description` is 68px tall, and below `medium:` a two-line snackbar with an
|
||||
- A snackbar with a `description` is 68px tall, and below medium (600px) a two-line snackbar with an
|
||||
action puts the action under the text. Alt+G moves focus to an actioned snackbar.
|
||||
- `<x-bottom-sheet>`'s drag now follows the pointer and settles on the nearest preset height when
|
||||
`heights` or `snap` is set; without stops it behaves as before.
|
||||
@@ -203,4 +359,5 @@ whatever the page renders.
|
||||
### 9. For AI agents
|
||||
|
||||
`php artisan boost:update --discover` picks up the new `material-3` guideline and the
|
||||
`material-3-design` skill, which state M3's rules and tables beside the library's utilities.
|
||||
`material-3-design` skill, which state M3's rules and tables beside the library's classes, props
|
||||
and tokens.
|
||||
|
||||
+8
-36
@@ -1,28 +1,18 @@
|
||||
/**
|
||||
* Checks that the packaged Google Sans Flex subset still carries the variable axes the CSS uses.
|
||||
* Prints the variable axes of the packaged Google Sans Flex subset.
|
||||
*
|
||||
* Run from the repository root with `npm run check:font` (or `node bin/check-font.mjs [woff2]`).
|
||||
* tests/Feature/FontTest.php runs it through the configured `node` binary as well, because PHP
|
||||
* cannot open a woff2 without the Brotli extension.
|
||||
*
|
||||
* wght 400–700 — every typescale weight (font.css `font-weight: 400 700`, type.css's regular,
|
||||
* medium and bold reference tokens).
|
||||
* ROND 0–100 — the roundness axis every `type-emphasized-*` utility sets to 100. Re-subset
|
||||
* the font without it and the emphasized styles quietly stop being round.
|
||||
* Run from the repository root with `node bin/check-font.mjs [woff2]`.
|
||||
* tests/Feature/FontTest.php runs it through the configured `node` binary and asserts the ranges
|
||||
* the CSS needs (wght, ROND — see that test for why), because PHP cannot open a woff2 without the
|
||||
* Brotli extension.
|
||||
*
|
||||
* Output is one JSON object on stdout — {file, postscriptName, numGlyphs, axes: {tag: {name, min,
|
||||
* default, max}}} — and the exit status is 1, with the reason on stderr, when an axis is missing
|
||||
* or narrower than the range above.
|
||||
* default, max}}}. The exit status is 1, with the reason on stderr, only when the file itself
|
||||
* can't be opened.
|
||||
*/
|
||||
import { openSync } from 'fontkit'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/** The axes the stylesheets depend on, and the range each one has to cover. */
|
||||
const REQUIRED = {
|
||||
wght: { min: 400, max: 700 },
|
||||
ROND: { min: 0, max: 100 },
|
||||
}
|
||||
|
||||
const file = process.argv[2] ?? fileURLToPath(new URL('../resources/fonts/google-sans-flex/GoogleSansFlex-Latin.woff2', import.meta.url))
|
||||
|
||||
let font
|
||||
@@ -34,27 +24,9 @@ try {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const axes = font.variationAxes ?? {}
|
||||
const problems = Object.entries(REQUIRED).flatMap(([tag, range]) => {
|
||||
const axis = axes[tag]
|
||||
|
||||
if (!axis) {
|
||||
return [`${tag} is missing; the subset has ${Object.keys(axes).join(', ') || 'no variable axes'}.`]
|
||||
}
|
||||
|
||||
return axis.min > range.min || axis.max < range.max
|
||||
? [`${tag} covers ${axis.min}–${axis.max}, not the ${range.min}–${range.max} the stylesheets ask for.`]
|
||||
: []
|
||||
})
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
file,
|
||||
postscriptName: font.postscriptName,
|
||||
numGlyphs: font.numGlyphs,
|
||||
axes,
|
||||
axes: font.variationAxes ?? {},
|
||||
})}\n`)
|
||||
|
||||
if (problems.length > 0) {
|
||||
process.stderr.write(`${file}\n${problems.map((problem) => ` ${problem}`).join('\n')}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
+6
-1
@@ -53,9 +53,14 @@ for size in 24 20; do
|
||||
done
|
||||
|
||||
# Google's files carry width="24" height="24" and no fill. Drop the size so CSS decides
|
||||
# it, and paint in currentColor so an icon takes the colour of the text around it.
|
||||
# it, and paint in currentColor so an icon takes the colour of the text around it. Nearly
|
||||
# every file draws in a viewBox="0 -960 960 960"; a few carry none and draw in the units of
|
||||
# their width and height (24, or 20 in the 20 cut), so the size becomes their viewBox before
|
||||
# it goes, or the glyph would only fit at exactly that many pixels.
|
||||
normalise() {
|
||||
sed -E \
|
||||
-e '/viewBox=/!s/<svg ([^>]*)height="([0-9]+)(px)?"([^>]*)width="([0-9]+)(px)?"/<svg \1height="\2"\4width="\5" viewBox="0 0 \5 \2"/' \
|
||||
-e '/viewBox=/!s/<svg ([^>]*)width="([0-9]+)(px)?"([^>]*)height="([0-9]+)(px)?"/<svg \1width="\2"\4height="\5" viewBox="0 0 \2 \5"/' \
|
||||
-e 's/ (width|height)="[0-9]+(px)?"//g' \
|
||||
-e 's/<svg /<svg fill="currentColor" /' \
|
||||
"$1"
|
||||
|
||||
+5
-341
@@ -42,12 +42,11 @@
|
||||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/LoadingIndicator.kt
|
||||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/LoadingIndicatorTokens.kt
|
||||
* compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/SpringSimulation.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/FeatureMapping.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/FloatMapping.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Morph.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/PolygonMeasure.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/RoundedPolygon.kt (calculateMaxBounds)
|
||||
*
|
||||
* The feature matching and Morph itself (FeatureMapping.kt, FloatMapping.kt, Morph.kt,
|
||||
* PolygonMeasure.kt) are shared with progress.js: see resources/js/shapes.js.
|
||||
*
|
||||
* Copyright 2022-2024 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -65,13 +64,11 @@
|
||||
*/
|
||||
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { cubicBounds, point, pointOnCurve, split, SHAPES } from './shapes.mjs'
|
||||
import { SHAPES } from './shapes.mjs'
|
||||
import { asCubics, cubicBounds, match, pointOnCurve } from '../resources/js/shapes.js'
|
||||
|
||||
const OUTPUT = 'resources/svg/loading-indicator'
|
||||
|
||||
const DISTANCE_EPSILON = 1e-4
|
||||
const ANGLE_EPSILON = 1e-6
|
||||
|
||||
// LoadingIndicator.kt / LoadingIndicatorTokens.kt --------------------------------------
|
||||
|
||||
const SEQUENCE = ['soft-burst', 'cookie-9', 'pentagon', 'pill', 'sunny', 'cookie-4', 'oval']
|
||||
@@ -94,339 +91,6 @@ const HANDOVER_LEAD = 0.001
|
||||
/** The fastest collapse a keySpline can give: half the size within a frame, then a long tail. */
|
||||
const COLLAPSE = [0, 1, 0, 1]
|
||||
|
||||
// Utils.kt / FloatMapping.kt ------------------------------------------------------------
|
||||
|
||||
const positiveModulo = (num, mod) => ((num % mod) + mod) % mod
|
||||
const progressInRange = (progress, from, to) =>
|
||||
to >= from ? progress >= from && progress <= to : progress >= from || progress <= to
|
||||
|
||||
function progressDistance(a, b) {
|
||||
const d = Math.abs(a - b)
|
||||
|
||||
return Math.min(d, 1 - d)
|
||||
}
|
||||
|
||||
function linearMap(xValues, yValues, x) {
|
||||
const n = xValues.length
|
||||
const start = xValues.findIndex((_, i) => progressInRange(x, xValues[i], xValues[(i + 1) % n]))
|
||||
const end = (start + 1) % n
|
||||
const sizeX = positiveModulo(xValues[end] - xValues[start], 1)
|
||||
const sizeY = positiveModulo(yValues[end] - yValues[start], 1)
|
||||
const position = sizeX < 0.001 ? 0.5 : positiveModulo(x - xValues[start], 1) / sizeX
|
||||
|
||||
return positiveModulo(yValues[start] + sizeY * position, 1)
|
||||
}
|
||||
|
||||
/** DoubleMapper: maps outline progress on one shape to the other and back, from [source, target] pairs. */
|
||||
function doubleMapper(mappings) {
|
||||
const sources = mappings.map((m) => m[0])
|
||||
const targets = mappings.map((m) => m[1])
|
||||
|
||||
return { map: (x) => linearMap(sources, targets, x), mapBack: (x) => linearMap(targets, sources, x) }
|
||||
}
|
||||
|
||||
// PolygonMeasure.kt ---------------------------------------------------------------------
|
||||
|
||||
const MEASURE_SEGMENTS = 3
|
||||
|
||||
/** LengthMeasurer.closestProgressTo: [the parameter at which `threshold` length is reached, the length]. */
|
||||
function closestProgressTo(c, threshold) {
|
||||
let total = 0
|
||||
let remainder = threshold
|
||||
let previous = point(c[0], c[1])
|
||||
|
||||
for (let i = 1; i <= MEASURE_SEGMENTS; i++) {
|
||||
const progress = i / MEASURE_SEGMENTS
|
||||
const p = pointOnCurve(c, progress)
|
||||
const segment = Math.hypot(p.x - previous.x, p.y - previous.y)
|
||||
|
||||
if (segment >= remainder) {
|
||||
return [progress - (1 - remainder / segment) / MEASURE_SEGMENTS, threshold]
|
||||
}
|
||||
|
||||
remainder -= segment
|
||||
total += segment
|
||||
previous = p
|
||||
}
|
||||
|
||||
return [1, total]
|
||||
}
|
||||
|
||||
const measureCubic = (c) => closestProgressTo(c, Infinity)[1]
|
||||
const findCubicCutPoint = (c, measure) => closestProgressTo(c, measure)[0]
|
||||
|
||||
class MeasuredCubic {
|
||||
constructor(cubic, startOutlineProgress, endOutlineProgress) {
|
||||
if (endOutlineProgress < startOutlineProgress) {
|
||||
throw new Error('endOutlineProgress is expected to be equal or greater than startOutlineProgress')
|
||||
}
|
||||
|
||||
this.cubic = cubic
|
||||
this.startOutlineProgress = startOutlineProgress
|
||||
this.endOutlineProgress = endOutlineProgress
|
||||
this.measuredSize = measureCubic(cubic)
|
||||
}
|
||||
|
||||
cutAtProgress(cutOutlineProgress) {
|
||||
const bounded = Math.min(Math.max(cutOutlineProgress, this.startOutlineProgress), this.endOutlineProgress)
|
||||
const relativeProgress =
|
||||
(bounded - this.startOutlineProgress) / (this.endOutlineProgress - this.startOutlineProgress)
|
||||
const t = findCubicCutPoint(this.cubic, relativeProgress * this.measuredSize)
|
||||
const [c1, c2] = split(this.cubic, t)
|
||||
|
||||
return [
|
||||
new MeasuredCubic(c1, this.startOutlineProgress, bounded),
|
||||
new MeasuredCubic(c2, bounded, this.endOutlineProgress),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
class MeasuredPolygon {
|
||||
constructor(features, cubics, outlineProgress) {
|
||||
this.features = features
|
||||
this.cubics = []
|
||||
|
||||
let startOutlineProgress = 0
|
||||
|
||||
for (let i = 0; i < cubics.length; i++) {
|
||||
if (outlineProgress[i + 1] - outlineProgress[i] > DISTANCE_EPSILON) {
|
||||
this.cubics.push(new MeasuredCubic(cubics[i], startOutlineProgress, outlineProgress[i + 1]))
|
||||
startOutlineProgress = outlineProgress[i + 1]
|
||||
}
|
||||
}
|
||||
|
||||
this.cubics.at(-1).endOutlineProgress = 1
|
||||
}
|
||||
|
||||
static measure(polygon) {
|
||||
const cubics = []
|
||||
const featureToCubic = []
|
||||
|
||||
for (const feature of polygon.features) {
|
||||
feature.cubics.forEach((cubic, i) => {
|
||||
if (feature.type === 'corner' && i === Math.floor(feature.cubics.length / 2)) {
|
||||
featureToCubic.push([feature, cubics.length])
|
||||
}
|
||||
|
||||
cubics.push(cubic)
|
||||
})
|
||||
}
|
||||
|
||||
const measures = [0]
|
||||
|
||||
for (const cubic of cubics) {
|
||||
measures.push(measures.at(-1) + measureCubic(cubic))
|
||||
}
|
||||
|
||||
const outlineProgress = measures.map((measure) => measure / measures.at(-1))
|
||||
const features = featureToCubic.map(([feature, ix]) => ({
|
||||
progress: positiveModulo((outlineProgress[ix] + outlineProgress[ix + 1]) / 2, 1),
|
||||
feature,
|
||||
}))
|
||||
|
||||
return new MeasuredPolygon(features, cubics, outlineProgress)
|
||||
}
|
||||
|
||||
cutAndShift(cuttingPoint) {
|
||||
if (cuttingPoint < DISTANCE_EPSILON) {
|
||||
return this
|
||||
}
|
||||
|
||||
const n = this.cubics.length
|
||||
const targetIndex = this.cubics.findIndex(
|
||||
(c) => cuttingPoint >= c.startOutlineProgress && cuttingPoint <= c.endOutlineProgress,
|
||||
)
|
||||
const [b1, b2] = this.cubics[targetIndex].cutAtProgress(cuttingPoint)
|
||||
const cubics = [b2.cubic]
|
||||
|
||||
for (let i = 1; i < n; i++) {
|
||||
cubics.push(this.cubics[(i + targetIndex) % n].cubic)
|
||||
}
|
||||
|
||||
cubics.push(b1.cubic)
|
||||
|
||||
const outlineProgress = Array.from({ length: n + 2 }, (_, index) => {
|
||||
if (index === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (index === n + 1) {
|
||||
return 1
|
||||
}
|
||||
|
||||
return positiveModulo(this.cubics[(targetIndex + index - 1) % n].endOutlineProgress - cuttingPoint, 1)
|
||||
})
|
||||
|
||||
const features = this.features.map(({ progress, feature }) => ({
|
||||
progress: positiveModulo(progress - cuttingPoint, 1),
|
||||
feature,
|
||||
}))
|
||||
|
||||
return new MeasuredPolygon(features, cubics, outlineProgress)
|
||||
}
|
||||
}
|
||||
|
||||
// FeatureMapping.kt ---------------------------------------------------------------------
|
||||
|
||||
function featureRepresentativePoint(feature) {
|
||||
const first = feature.cubics[0]
|
||||
const last = feature.cubics.at(-1)
|
||||
|
||||
return point((first[0] + last[6]) / 2, (first[1] + last[7]) / 2)
|
||||
}
|
||||
|
||||
function featureDistSquared(f1, f2) {
|
||||
if (f1.type === 'corner' && f2.type === 'corner' && f1.convex !== f2.convex) {
|
||||
return Infinity
|
||||
}
|
||||
|
||||
const p1 = featureRepresentativePoint(f1)
|
||||
const p2 = featureRepresentativePoint(f2)
|
||||
|
||||
return (p1.x - p2.x) ** 2 + (p1.y - p2.y) ** 2
|
||||
}
|
||||
|
||||
function doMapping(features1, features2) {
|
||||
const distanceVertexList = []
|
||||
|
||||
for (const f1 of features1) {
|
||||
for (const f2 of features2) {
|
||||
const distance = featureDistSquared(f1.feature, f2.feature)
|
||||
|
||||
if (distance !== Infinity) {
|
||||
distanceVertexList.push({ distance, f1, f2 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Array.prototype.sort is stable, like Kotlin's sortedBy.
|
||||
distanceVertexList.sort((a, b) => a.distance - b.distance)
|
||||
|
||||
if (distanceVertexList.length === 0) {
|
||||
return [
|
||||
[0, 0],
|
||||
[0.5, 0.5],
|
||||
]
|
||||
}
|
||||
|
||||
if (distanceVertexList.length === 1) {
|
||||
const { f1, f2 } = distanceVertexList[0]
|
||||
|
||||
return [
|
||||
[f1.progress, f2.progress],
|
||||
[(f1.progress + 0.5) % 1, (f2.progress + 0.5) % 1],
|
||||
]
|
||||
}
|
||||
|
||||
const mapping = []
|
||||
const usedF1 = new Set()
|
||||
const usedF2 = new Set()
|
||||
|
||||
for (const { f1, f2 } of distanceVertexList) {
|
||||
if (usedF1.has(f1) || usedF2.has(f2)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const insertionIndex = mapping.findIndex((m) => m[0] >= f1.progress)
|
||||
const index = insertionIndex === -1 ? mapping.length : insertionIndex
|
||||
|
||||
if (index < mapping.length && mapping[index][0] === f1.progress) {
|
||||
throw new Error("There can't be two features with the same progress")
|
||||
}
|
||||
|
||||
const n = mapping.length
|
||||
|
||||
if (n >= 1) {
|
||||
const [before1, before2] = mapping[(index + n - 1) % n]
|
||||
const [after1, after2] = mapping[index % n]
|
||||
|
||||
if (
|
||||
progressDistance(f1.progress, before1) < DISTANCE_EPSILON ||
|
||||
progressDistance(f1.progress, after1) < DISTANCE_EPSILON ||
|
||||
progressDistance(f2.progress, before2) < DISTANCE_EPSILON ||
|
||||
progressDistance(f2.progress, after2) < DISTANCE_EPSILON
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (n > 1 && !progressInRange(f2.progress, before2, after2)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
mapping.splice(index, 0, [f1.progress, f2.progress])
|
||||
usedF1.add(f1)
|
||||
usedF2.add(f2)
|
||||
}
|
||||
|
||||
return mapping
|
||||
}
|
||||
|
||||
function featureMapper(features1, features2) {
|
||||
const corners = (features) => features.filter(({ feature }) => feature.type === 'corner')
|
||||
|
||||
return doubleMapper(doMapping(corners(features1), corners(features2)))
|
||||
}
|
||||
|
||||
// Morph.kt ------------------------------------------------------------------------------
|
||||
|
||||
/** Morph.match: the start and end shapes cut into pairs of matching cubics. */
|
||||
function match(p1, p2) {
|
||||
const measuredPolygon1 = MeasuredPolygon.measure(p1)
|
||||
const measuredPolygon2 = MeasuredPolygon.measure(p2)
|
||||
const mapper = featureMapper(measuredPolygon1.features, measuredPolygon2.features)
|
||||
const polygon2CutPoint = mapper.map(0)
|
||||
const bs1 = measuredPolygon1.cubics
|
||||
const bs2 = measuredPolygon2.cutAndShift(polygon2CutPoint).cubics
|
||||
const pairs = []
|
||||
|
||||
let i1 = 0
|
||||
let i2 = 0
|
||||
let b1 = bs1[i1++]
|
||||
let b2 = bs2[i2++]
|
||||
|
||||
while (b1 !== undefined && b2 !== undefined) {
|
||||
const b1a = i1 === bs1.length ? 1 : b1.endOutlineProgress
|
||||
const b2a =
|
||||
i2 === bs2.length ? 1 : mapper.mapBack(positiveModulo(b2.endOutlineProgress + polygon2CutPoint, 1))
|
||||
const minb = Math.min(b1a, b2a)
|
||||
let seg1
|
||||
let seg2
|
||||
|
||||
if (b1a > minb + ANGLE_EPSILON) {
|
||||
;[seg1, b1] = b1.cutAtProgress(minb)
|
||||
} else {
|
||||
seg1 = b1
|
||||
b1 = bs1[i1++]
|
||||
}
|
||||
|
||||
if (b2a > minb + ANGLE_EPSILON) {
|
||||
;[seg2, b2] = b2.cutAtProgress(positiveModulo(mapper.map(minb) - polygon2CutPoint, 1))
|
||||
} else {
|
||||
seg2 = b2
|
||||
b2 = bs2[i2++]
|
||||
}
|
||||
|
||||
pairs.push([seg1.cubic, seg2.cubic])
|
||||
}
|
||||
|
||||
if (b1 !== undefined || b2 !== undefined) {
|
||||
throw new Error("Expected both Polygon's Cubic to be fully matched")
|
||||
}
|
||||
|
||||
return pairs
|
||||
}
|
||||
|
||||
/** Morph.asCubics: every matched pair interpolated at `progress`, closed exactly on its first anchor. */
|
||||
function asCubics(pairs, progress) {
|
||||
const cubics = pairs.map(([start, end]) => start.map((value, i) => value + (end[i] - value) * progress))
|
||||
|
||||
cubics.at(-1)[6] = cubics[0][0]
|
||||
cubics.at(-1)[7] = cubics[0][1]
|
||||
|
||||
return cubics
|
||||
}
|
||||
|
||||
// LoadingIndicator.kt: calculateScaleFactor, processPath --------------------------------
|
||||
|
||||
/** RoundedPolygon.calculateMaxBounds: a square holding the shape in any rotation. */
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ const contrast = Number(input.contrast ?? 0)
|
||||
const harmonize = Boolean(input.harmonize ?? false)
|
||||
|
||||
if (!(contrast >= -1 && contrast < LEVELS.medium)) {
|
||||
fail(`The standard contrast level runs from -1 to below ${LEVELS.medium}, "${input.contrast}" given; medium and high are generated as their own blocks.`)
|
||||
fail(`The contrast level ${input.contrast} is the standard block's, from -1 to below ${LEVELS.medium}. Medium (${LEVELS.medium}) and high (${LEVELS.high}) are always generated beside it, under [data-contrast]; the head script picks one.`)
|
||||
}
|
||||
|
||||
const source = Hct.fromInt(argbFromHex(input.seed))
|
||||
|
||||
+6
-486
@@ -20,12 +20,9 @@
|
||||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/MaterialShapes.kt
|
||||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/ShapeUtil.kt
|
||||
* compose/ui/ui-graphics/src/commonMain/kotlin/androidx/compose/ui/graphics/Matrix.kt (rotateZ, scale)
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/CornerRounding.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Cubic.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Point.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/RoundedPolygon.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Shapes.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/Utils.kt
|
||||
*
|
||||
* The RoundedPolygon construction itself (CornerRounding, Cubic, Point, RoundedPolygon, Shapes,
|
||||
* Utils) is shared with bin/loading-indicator.mjs and progress.js: see resources/js/shapes.js.
|
||||
*
|
||||
* Copyright 2022-2024 The Android Open Source Project
|
||||
*
|
||||
@@ -45,480 +42,14 @@
|
||||
import { mkdirSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { circlePolygon, length, minus, plus, point, polygonFromVertices, regularPolygon, rounding, split, star, times, turningPoints, UNROUNDED } from '../resources/js/shapes.js'
|
||||
|
||||
const OUTPUT = 'resources/svg/shapes'
|
||||
const VIEWBOX = 100
|
||||
const FILL = 96
|
||||
|
||||
const DISTANCE_EPSILON = 1e-4
|
||||
|
||||
// Point.kt / Utils.kt ------------------------------------------------------------------
|
||||
|
||||
const point = (x, y) => ({ x, y })
|
||||
const plus = (a, b) => point(a.x + b.x, a.y + b.y)
|
||||
const minus = (a, b) => point(a.x - b.x, a.y - b.y)
|
||||
const times = (a, k) => point(a.x * k, a.y * k)
|
||||
const div = (a, k) => point(a.x / k, a.y / k)
|
||||
const dot = (a, b) => a.x * b.x + a.y * b.y
|
||||
const length = (a) => Math.sqrt(a.x * a.x + a.y * a.y)
|
||||
const rotate90 = (a) => point(-a.y, a.x)
|
||||
const clockwise = (a, b) => a.x * b.y - a.y * b.x > 0
|
||||
const lerp = (a, b, f) => (1 - f) * a + f * b
|
||||
const lerpPoint = (a, b, f) => point(lerp(a.x, b.x, f), lerp(a.y, b.y, f))
|
||||
|
||||
function direction(a) {
|
||||
const d = length(a)
|
||||
|
||||
if (!(d > 0)) {
|
||||
throw new Error("Can't get the direction of a 0-length vector")
|
||||
}
|
||||
|
||||
return div(a, d)
|
||||
}
|
||||
|
||||
const radialToCartesian = (radius, angle) => point(Math.cos(angle) * radius, Math.sin(angle) * radius)
|
||||
const convex = (previous, current, next) => clockwise(minus(current, previous), minus(next, current))
|
||||
|
||||
// Cubic.kt ------------------------------------------------------------------------------
|
||||
|
||||
/** A cubic is [anchor0X, anchor0Y, control0X, control0Y, control1X, control1Y, anchor1X, anchor1Y]. */
|
||||
const cubic = (a0, c0, c1, a1) => [a0.x, a0.y, c0.x, c0.y, c1.x, c1.y, a1.x, a1.y]
|
||||
|
||||
function straightLine(x0, y0, x1, y1) {
|
||||
return [x0, y0, lerp(x0, x1, 1 / 3), lerp(y0, y1, 1 / 3), lerp(x0, x1, 2 / 3), lerp(y0, y1, 2 / 3), x1, y1]
|
||||
}
|
||||
|
||||
function circularArc(centerX, centerY, x0, y0, x1, y1) {
|
||||
const p0d = direction(point(x0 - centerX, y0 - centerY))
|
||||
const p1d = direction(point(x1 - centerX, y1 - centerY))
|
||||
const rotatedP0 = rotate90(p0d)
|
||||
const rotatedP1 = rotate90(p1d)
|
||||
const isClockwise = dot(rotatedP0, point(x1 - centerX, y1 - centerY)) >= 0
|
||||
const cosa = dot(p0d, p1d)
|
||||
|
||||
if (cosa > 0.999) {
|
||||
return straightLine(x0, y0, x1, y1)
|
||||
}
|
||||
|
||||
const k =
|
||||
(((length(point(x0 - centerX, y0 - centerY)) * 4) / 3) *
|
||||
(Math.sqrt(2 * (1 - cosa)) - Math.sqrt(1 - cosa * cosa))) /
|
||||
(1 - cosa) *
|
||||
(isClockwise ? 1 : -1)
|
||||
|
||||
return [x0, y0, x0 + rotatedP0.x * k, y0 + rotatedP0.y * k, x1 - rotatedP1.x * k, y1 - rotatedP1.y * k, x1, y1]
|
||||
}
|
||||
|
||||
function pointOnCurve(c, t) {
|
||||
const u = 1 - t
|
||||
|
||||
return point(
|
||||
c[0] * (u * u * u) + c[2] * (3 * t * u * u) + c[4] * (3 * t * t * u) + c[6] * (t * t * t),
|
||||
c[1] * (u * u * u) + c[3] * (3 * t * u * u) + c[5] * (3 * t * t * u) + c[7] * (t * t * t),
|
||||
)
|
||||
}
|
||||
|
||||
function split(c, t) {
|
||||
const u = 1 - t
|
||||
const p = pointOnCurve(c, t)
|
||||
|
||||
return [
|
||||
[
|
||||
c[0],
|
||||
c[1],
|
||||
c[0] * u + c[2] * t,
|
||||
c[1] * u + c[3] * t,
|
||||
c[0] * (u * u) + c[2] * (2 * u * t) + c[4] * (t * t),
|
||||
c[1] * (u * u) + c[3] * (2 * u * t) + c[5] * (t * t),
|
||||
p.x,
|
||||
p.y,
|
||||
],
|
||||
[
|
||||
p.x,
|
||||
p.y,
|
||||
c[2] * (u * u) + c[4] * (2 * u * t) + c[6] * (t * t),
|
||||
c[3] * (u * u) + c[5] * (2 * u * t) + c[7] * (t * t),
|
||||
c[4] * u + c[6] * t,
|
||||
c[5] * u + c[7] * t,
|
||||
c[6],
|
||||
c[7],
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
const reverse = (c) => [c[6], c[7], c[4], c[5], c[2], c[3], c[0], c[1]]
|
||||
const zeroLength = (c) => Math.abs(c[0] - c[6]) < DISTANCE_EPSILON && Math.abs(c[1] - c[7]) < DISTANCE_EPSILON
|
||||
|
||||
/** The parameters in (0, 1) where one axis of a cubic turns: the roots of its derivative. */
|
||||
function turningPoints(c, axis) {
|
||||
const [p0, p1, p2, p3] = [c[axis], c[axis + 2], c[axis + 4], c[axis + 6]]
|
||||
const a = -p0 + 3 * p1 - 3 * p2 + p3
|
||||
const b = 2 * (p0 - 2 * p1 + p2)
|
||||
const k = p1 - p0
|
||||
const roots = []
|
||||
|
||||
if (Math.abs(a) < 1e-9) {
|
||||
if (Math.abs(b) > 1e-9) {
|
||||
roots.push(-k / b)
|
||||
}
|
||||
} else if (b * b - 4 * a * k >= 0) {
|
||||
const root = Math.sqrt(b * b - 4 * a * k)
|
||||
roots.push((-b + root) / (2 * a), (-b - root) / (2 * a))
|
||||
}
|
||||
|
||||
return roots.filter((t) => t > 1e-6 && t < 1 - 1e-6)
|
||||
}
|
||||
|
||||
/** Axis-aligned bounds of one cubic: of all four points when approximate, else of the curve itself. */
|
||||
function cubicBounds(c, approximate) {
|
||||
const xs = [c[0], c[6]]
|
||||
const ys = [c[1], c[7]]
|
||||
|
||||
if (approximate) {
|
||||
xs.push(c[2], c[4])
|
||||
ys.push(c[3], c[5])
|
||||
} else {
|
||||
turningPoints(c, 0).forEach((t) => xs.push(pointOnCurve(c, t).x))
|
||||
turningPoints(c, 1).forEach((t) => ys.push(pointOnCurve(c, t).y))
|
||||
}
|
||||
|
||||
return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]
|
||||
}
|
||||
|
||||
// CornerRounding.kt ---------------------------------------------------------------------
|
||||
|
||||
const rounding = (radius = 0, smoothing = 0) => ({ radius, smoothing })
|
||||
const UNROUNDED = rounding()
|
||||
|
||||
// RoundedPolygon.kt ---------------------------------------------------------------------
|
||||
|
||||
class RoundedCorner {
|
||||
constructor(p0, p1, p2, cornerRounding) {
|
||||
this.p0 = p0
|
||||
this.p1 = p1
|
||||
this.p2 = p2
|
||||
|
||||
const v01 = minus(p0, p1)
|
||||
const v21 = minus(p2, p1)
|
||||
const d01 = length(v01)
|
||||
const d21 = length(v21)
|
||||
|
||||
if (d01 > 0 && d21 > 0) {
|
||||
this.d1 = div(v01, d01)
|
||||
this.d2 = div(v21, d21)
|
||||
this.cornerRadius = cornerRounding?.radius ?? 0
|
||||
this.smoothing = cornerRounding?.smoothing ?? 0
|
||||
this.cosAngle = dot(this.d1, this.d2)
|
||||
this.sinAngle = Math.sqrt(1 - this.cosAngle * this.cosAngle)
|
||||
this.expectedRoundCut = this.sinAngle > 1e-3 ? (this.cornerRadius * (this.cosAngle + 1)) / this.sinAngle : 0
|
||||
} else {
|
||||
this.d1 = point(0, 0)
|
||||
this.d2 = point(0, 0)
|
||||
this.cornerRadius = 0
|
||||
this.smoothing = 0
|
||||
this.cosAngle = 0
|
||||
this.sinAngle = 0
|
||||
this.expectedRoundCut = 0
|
||||
}
|
||||
}
|
||||
|
||||
get expectedCut() {
|
||||
return (1 + this.smoothing) * this.expectedRoundCut
|
||||
}
|
||||
|
||||
getCubics(allowedCut0, allowedCut1 = allowedCut0) {
|
||||
const allowedCut = Math.min(allowedCut0, allowedCut1)
|
||||
|
||||
if (
|
||||
this.expectedRoundCut < DISTANCE_EPSILON ||
|
||||
allowedCut < DISTANCE_EPSILON ||
|
||||
this.cornerRadius < DISTANCE_EPSILON
|
||||
) {
|
||||
return [straightLine(this.p1.x, this.p1.y, this.p1.x, this.p1.y)]
|
||||
}
|
||||
|
||||
const actualRoundCut = Math.min(allowedCut, this.expectedRoundCut)
|
||||
const actualSmoothing0 = this.actualSmoothing(allowedCut0)
|
||||
const actualSmoothing1 = this.actualSmoothing(allowedCut1)
|
||||
const actualR = (this.cornerRadius * actualRoundCut) / this.expectedRoundCut
|
||||
const centerDistance = Math.sqrt(actualR * actualR + actualRoundCut * actualRoundCut)
|
||||
const center = plus(this.p1, times(direction(div(plus(this.d1, this.d2), 2)), centerDistance))
|
||||
const circleIntersection0 = plus(this.p1, times(this.d1, actualRoundCut))
|
||||
const circleIntersection2 = plus(this.p1, times(this.d2, actualRoundCut))
|
||||
const flanking0 = this.flankingCurve(
|
||||
actualRoundCut,
|
||||
actualSmoothing0,
|
||||
this.p1,
|
||||
this.p0,
|
||||
circleIntersection0,
|
||||
circleIntersection2,
|
||||
center,
|
||||
actualR,
|
||||
)
|
||||
const flanking2 = reverse(
|
||||
this.flankingCurve(
|
||||
actualRoundCut,
|
||||
actualSmoothing1,
|
||||
this.p1,
|
||||
this.p2,
|
||||
circleIntersection2,
|
||||
circleIntersection0,
|
||||
center,
|
||||
actualR,
|
||||
),
|
||||
)
|
||||
|
||||
return [
|
||||
flanking0,
|
||||
circularArc(center.x, center.y, flanking0[6], flanking0[7], flanking2[0], flanking2[1]),
|
||||
flanking2,
|
||||
]
|
||||
}
|
||||
|
||||
actualSmoothing(allowedCut) {
|
||||
if (allowedCut > this.expectedCut) {
|
||||
return this.smoothing
|
||||
}
|
||||
|
||||
if (allowedCut > this.expectedRoundCut) {
|
||||
return (this.smoothing * (allowedCut - this.expectedRoundCut)) / (this.expectedCut - this.expectedRoundCut)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
flankingCurve(actualRoundCut, smoothing, corner, sideStart, intersection, otherIntersection, circleCenter, actualR) {
|
||||
const sideDirection = direction(minus(sideStart, corner))
|
||||
const curveStart = plus(corner, times(sideDirection, actualRoundCut * (1 + smoothing)))
|
||||
const p = lerpPoint(intersection, div(plus(intersection, otherIntersection), 2), smoothing)
|
||||
const curveEnd = plus(circleCenter, times(direction(minus(p, circleCenter)), actualR))
|
||||
const circleTangent = rotate90(minus(curveEnd, circleCenter))
|
||||
const anchorEnd = lineIntersection(sideStart, sideDirection, curveEnd, circleTangent) ?? intersection
|
||||
const anchorStart = div(plus(curveStart, times(anchorEnd, 2)), 3)
|
||||
|
||||
return cubic(curveStart, anchorStart, anchorEnd, curveEnd)
|
||||
}
|
||||
}
|
||||
|
||||
function lineIntersection(p0, d0, p1, d1) {
|
||||
const rotatedD1 = rotate90(d1)
|
||||
const den = dot(d0, rotatedD1)
|
||||
|
||||
if (Math.abs(den) < DISTANCE_EPSILON) {
|
||||
return null
|
||||
}
|
||||
|
||||
const num = dot(minus(p1, p0), rotatedD1)
|
||||
|
||||
if (Math.abs(den) < DISTANCE_EPSILON * Math.abs(num)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return plus(p0, times(d0, num / den))
|
||||
}
|
||||
|
||||
/**
|
||||
* A polygon as androidx keeps it: its features (corners and the edges between them, each a
|
||||
* list of cubics) and a centre. `cubics` flattens the features exactly as RoundedPolygon does.
|
||||
*/
|
||||
class RoundedPolygon {
|
||||
constructor(features, center) {
|
||||
this.features = features
|
||||
this.center = center
|
||||
this.cubics = flatten(features, center)
|
||||
}
|
||||
|
||||
transformed(f) {
|
||||
const move = (c) => {
|
||||
const out = []
|
||||
|
||||
for (let i = 0; i < 8; i += 2) {
|
||||
const p = f(c[i], c[i + 1])
|
||||
out.push(p.x, p.y)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
return new RoundedPolygon(
|
||||
this.features.map((feature) => ({ ...feature, cubics: feature.cubics.map(move) })),
|
||||
f(this.center.x, this.center.y),
|
||||
)
|
||||
}
|
||||
|
||||
bounds(approximate) {
|
||||
const all = this.cubics.map((c) => (zeroLength(c) ? [c[0], c[1], c[0], c[1]] : cubicBounds(c, approximate)))
|
||||
|
||||
return [
|
||||
Math.min(...all.map((b) => b[0])),
|
||||
Math.min(...all.map((b) => b[1])),
|
||||
Math.max(...all.map((b) => b[2])),
|
||||
Math.max(...all.map((b) => b[3])),
|
||||
]
|
||||
}
|
||||
|
||||
normalized() {
|
||||
const [left, top, right, bottom] = this.bounds(true)
|
||||
const width = right - left
|
||||
const height = bottom - top
|
||||
const side = Math.max(width, height)
|
||||
const offsetX = (side - width) / 2 - left
|
||||
const offsetY = (side - height) / 2 - top
|
||||
|
||||
return this.transformed((x, y) => point((x + offsetX) / side, (y + offsetY) / side))
|
||||
}
|
||||
}
|
||||
|
||||
function flatten(features, center) {
|
||||
const out = []
|
||||
let firstCubic = null
|
||||
let lastCubic = null
|
||||
let firstFeatureSplitStart = null
|
||||
let firstFeatureSplitEnd = null
|
||||
|
||||
if (features.length > 0 && features[0].cubics.length === 3) {
|
||||
const [start, end] = split(features[0].cubics[1], 0.5)
|
||||
firstFeatureSplitStart = [features[0].cubics[0], start]
|
||||
firstFeatureSplitEnd = [end, features[0].cubics[2]]
|
||||
}
|
||||
|
||||
for (let i = 0; i <= features.length; i++) {
|
||||
let featureCubics
|
||||
|
||||
if (i === 0 && firstFeatureSplitEnd !== null) {
|
||||
featureCubics = firstFeatureSplitEnd
|
||||
} else if (i === features.length) {
|
||||
if (firstFeatureSplitStart === null) {
|
||||
break
|
||||
}
|
||||
|
||||
featureCubics = firstFeatureSplitStart
|
||||
} else {
|
||||
featureCubics = features[i].cubics
|
||||
}
|
||||
|
||||
for (const c of featureCubics) {
|
||||
if (!zeroLength(c)) {
|
||||
if (lastCubic !== null) {
|
||||
out.push(lastCubic)
|
||||
}
|
||||
|
||||
lastCubic = c
|
||||
|
||||
if (firstCubic === null) {
|
||||
firstCubic = c
|
||||
}
|
||||
} else if (lastCubic !== null) {
|
||||
lastCubic = [...lastCubic]
|
||||
lastCubic[6] = c[0]
|
||||
lastCubic[7] = c[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastCubic !== null && firstCubic !== null) {
|
||||
out.push([...lastCubic.slice(0, 6), firstCubic[0], firstCubic[1]])
|
||||
} else {
|
||||
out.push([center.x, center.y, center.x, center.y, center.x, center.y, center.x, center.y])
|
||||
}
|
||||
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const previous = out[(i + out.length - 1) % out.length]
|
||||
|
||||
if (
|
||||
Math.abs(out[i][0] - previous[6]) > DISTANCE_EPSILON ||
|
||||
Math.abs(out[i][1] - previous[7]) > DISTANCE_EPSILON
|
||||
) {
|
||||
throw new Error('RoundedPolygon must be contiguous')
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** RoundedPolygon(vertices, rounding, perVertexRounding, centerX, centerY) */
|
||||
function polygonFromVertices(vertices, { cornerRounding = UNROUNDED, perVertexRounding = null, center = null } = {}) {
|
||||
const n = vertices.length
|
||||
|
||||
if (n < 3) {
|
||||
throw new Error('Polygons must have at least 3 vertices')
|
||||
}
|
||||
|
||||
if (perVertexRounding !== null && perVertexRounding.length !== n) {
|
||||
throw new Error('perVertexRounding list should be either null or the same size as the number of vertices')
|
||||
}
|
||||
|
||||
const roundedCorners = vertices.map(
|
||||
(vertex, i) =>
|
||||
new RoundedCorner(vertices[(i + n - 1) % n], vertex, vertices[(i + 1) % n], perVertexRounding?.[i] ?? cornerRounding),
|
||||
)
|
||||
|
||||
const cutAdjusts = vertices.map((vertex, i) => {
|
||||
const next = (i + 1) % n
|
||||
const expectedRoundCut = roundedCorners[i].expectedRoundCut + roundedCorners[next].expectedRoundCut
|
||||
const expectedCut = roundedCorners[i].expectedCut + roundedCorners[next].expectedCut
|
||||
const sideSize = length(minus(vertex, vertices[next]))
|
||||
|
||||
if (expectedRoundCut > sideSize) {
|
||||
return [sideSize / expectedRoundCut, 0]
|
||||
}
|
||||
|
||||
if (expectedCut > sideSize) {
|
||||
return [1, (sideSize - expectedRoundCut) / (expectedCut - expectedRoundCut)]
|
||||
}
|
||||
|
||||
return [1, 1]
|
||||
})
|
||||
|
||||
const corners = roundedCorners.map((corner, i) => {
|
||||
const allowedCuts = [0, 1].map((delta) => {
|
||||
const [roundCutRatio, cutRatio] = cutAdjusts[(i + n - 1 + delta) % n]
|
||||
|
||||
return corner.expectedRoundCut * roundCutRatio + (corner.expectedCut - corner.expectedRoundCut) * cutRatio
|
||||
})
|
||||
|
||||
return corner.getCubics(allowedCuts[0], allowedCuts[1])
|
||||
})
|
||||
|
||||
const features = []
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const previous = vertices[(i + n - 1) % n]
|
||||
const next = vertices[(i + 1) % n]
|
||||
const end = corners[i].at(-1)
|
||||
const start = corners[(i + 1) % n][0]
|
||||
|
||||
features.push({ type: 'corner', convex: convex(previous, vertices[i], next), cubics: corners[i] })
|
||||
features.push({ type: 'edge', cubics: [straightLine(end[6], end[7], start[0], start[1])] })
|
||||
}
|
||||
|
||||
if (center === null) {
|
||||
center = point(
|
||||
vertices.reduce((sum, v) => sum + v.x, 0) / n,
|
||||
vertices.reduce((sum, v) => sum + v.y, 0) / n,
|
||||
)
|
||||
}
|
||||
|
||||
return new RoundedPolygon(features, center)
|
||||
}
|
||||
|
||||
// Shapes.kt -----------------------------------------------------------------------------
|
||||
|
||||
/** RoundedPolygon(numVertices, radius, centerX, centerY, rounding, perVertexRounding) */
|
||||
function regularPolygon(numVertices, { radius = 1, cornerRounding = UNROUNDED, perVertexRounding = null } = {}) {
|
||||
const vertices = Array.from({ length: numVertices }, (_, i) =>
|
||||
radialToCartesian(radius, (Math.PI / numVertices) * 2 * i),
|
||||
)
|
||||
|
||||
return polygonFromVertices(vertices, { cornerRounding, perVertexRounding, center: point(0, 0) })
|
||||
}
|
||||
|
||||
function circlePolygon(numVertices = 8, radius = 1) {
|
||||
const polygonRadius = radius / Math.cos(Math.PI / numVertices)
|
||||
|
||||
return regularPolygon(numVertices, { radius: polygonRadius, cornerRounding: rounding(radius) })
|
||||
}
|
||||
|
||||
function rectangle({ width = 2, height = 2, cornerRounding = UNROUNDED, perVertexRounding = null } = {}) {
|
||||
const [left, top, right, bottom] = [-width / 2, -height / 2, width / 2, height / 2]
|
||||
|
||||
@@ -529,17 +60,6 @@ function rectangle({ width = 2, height = 2, cornerRounding = UNROUNDED, perVerte
|
||||
})
|
||||
}
|
||||
|
||||
function star(numVerticesPerRadius, { radius = 1, innerRadius = 0.5, cornerRounding = UNROUNDED } = {}) {
|
||||
const vertices = []
|
||||
|
||||
for (let i = 0; i < numVerticesPerRadius; i++) {
|
||||
vertices.push(radialToCartesian(radius, (Math.PI / numVerticesPerRadius) * 2 * i))
|
||||
vertices.push(radialToCartesian(innerRadius, (Math.PI / numVerticesPerRadius) * (2 * i + 1)))
|
||||
}
|
||||
|
||||
return polygonFromVertices(vertices, { cornerRounding, center: point(0, 0) })
|
||||
}
|
||||
|
||||
// Matrix.kt (rotateZ, scale) and ShapeUtil.kt (RoundedPolygon.transformed(Matrix)) --------
|
||||
|
||||
function rotateZ(degrees) {
|
||||
@@ -865,8 +385,8 @@ function pathData(polygon) {
|
||||
return `${d}Z`
|
||||
}
|
||||
|
||||
/** The geometry, for other build scripts (bin/loading-indicator.mjs); importing this module writes nothing. */
|
||||
export { point, pointOnCurve, split, cubicBounds, RoundedPolygon, SHAPES }
|
||||
/** The shape catalog, for other build scripts (bin/loading-indicator.mjs); importing this module writes nothing. */
|
||||
export { SHAPES }
|
||||
|
||||
if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
mkdirSync(OUTPUT, { recursive: true })
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Builds resources/dist/, the stylesheets the package serves on its own, outside an application's
|
||||
* Vite build: the showcase's (`ShowcaseAssetController`) and the error page's
|
||||
* (`ErrorPage::layoutStyles()`, `ErrorPage::fallbackStyles()`).
|
||||
*
|
||||
* Rebuild with `npm run build:stylesheets` after changing anything under resources/css; the
|
||||
* output is committed so applications need no Node, and tests/Feature/StylesheetsBundleTest.php
|
||||
* fails while it is stale. `node bin/stylesheets.mjs <dir>` writes the same files elsewhere.
|
||||
*
|
||||
* The `@import`s are inlined by Vite's own `preprocessCSS()` — postcss-import, the same inlining an
|
||||
* application's build does, each file once, first occurrence kept — so these bundles cannot
|
||||
* order a rule differently from an application's. A relative `url()` is rebased onto
|
||||
* resources/dist/, the folder the showcase serves beside the fonts and SVGs. The fallback drops
|
||||
* every `@font-face`: without a build nothing serves the font, and the brand typeface's stack
|
||||
* (tokens/type.css) falls back to the system fonts on its own.
|
||||
*/
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, relative, resolve } from 'node:path'
|
||||
import { preprocessCSS, resolveConfig } from 'vite'
|
||||
|
||||
const css = resolve(import.meta.dirname, '../resources/css')
|
||||
const dist = resolve(import.meta.dirname, '../resources/dist')
|
||||
const out = resolve(process.argv[2] ?? dist)
|
||||
|
||||
const BUNDLES = {
|
||||
'showcase.css': ['all.css', 'showcase.css'],
|
||||
'error-page.css': ['components/error-page.css'],
|
||||
'error-page-fallback.css': ['foundation.css', 'components/error-page.css'],
|
||||
}
|
||||
|
||||
const rebase = {
|
||||
postcssPlugin: 'livewire-material',
|
||||
OnceExit(root, { result }) {
|
||||
// The prose lives in resources/css; the bundle only has to be served.
|
||||
root.walkComments((comment) => comment.remove())
|
||||
|
||||
root.walkDecls((decl) => {
|
||||
decl.value = decl.value.replace(/url\((['"]?)(?![a-z][a-z0-9+.-]*:|\/|#)([^'")]+)\1\)/gi, (_, quote, url) =>
|
||||
`url(${quote}${relative(dist, resolve(dirname(decl.source.input.file), url))}${quote})`)
|
||||
})
|
||||
|
||||
if (result.opts.from.endsWith('error-page-fallback.css')) {
|
||||
root.walkAtRules('font-face', (rule) => rule.remove())
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const config = await resolveConfig({ configFile: false, logLevel: 'error', css: { postcss: { plugins: [rebase] } } }, 'build')
|
||||
|
||||
await mkdir(out, { recursive: true })
|
||||
|
||||
for (const [name, files] of Object.entries(BUNDLES)) {
|
||||
const entry = files.map((file) => `@import './${file}';`).join('\n')
|
||||
// The entry is named after the bundle, beside the files it imports, but is never read from disk.
|
||||
const { code } = await preprocessCSS(entry, `${css}/bundle-${name}`, config)
|
||||
|
||||
await writeFile(`${out}/${name}`, `${code.trim()}\n`)
|
||||
}
|
||||
+2
-5
@@ -29,8 +29,7 @@
|
||||
"laravel/pint": "^1.32",
|
||||
"orchestra/testbench": "^11.2",
|
||||
"pestphp/pest": "^5.1",
|
||||
"pestphp/pest-plugin-browser": "^5.0",
|
||||
"pestphp/pest-plugin-laravel": "^5.0"
|
||||
"pestphp/pest-plugin-browser": "^5.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
@@ -62,9 +61,7 @@
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@build",
|
||||
"@php vendor/bin/testbench serve --ansi"
|
||||
],
|
||||
"lint": "pint",
|
||||
"test": "pest"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"sort-packages": true,
|
||||
|
||||
@@ -107,10 +107,11 @@ return [
|
||||
| Pagination
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Draw Laravel's and Livewire's paginators in M3: the package's views are
|
||||
| put in front of `pagination::tailwind` and `livewire::tailwind` (and
|
||||
| their simple versions). An application's own published pagination views
|
||||
| still win.
|
||||
| Draw Laravel's and Livewire's paginators in M3: the package's views
|
||||
| become `Paginator::defaultView()`/`defaultSimpleView()`, and
|
||||
| `livewire.pagination_theme` is taken over while it is still Livewire's
|
||||
| own default. An application's own published pagination views still
|
||||
| win, and so does a theme it chose deliberately.
|
||||
|
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,811 +0,0 @@
|
||||
# Audit: actions and communication
|
||||
|
||||
Scope: `<x-button>`, `<x-button-group>`, `<x-group>`, `<x-split-button>`, `<x-fab>`, `<x-fab-menu>`,
|
||||
`<x-fab-menu-item>`, `<x-menu>`/`<x-menu-item>`/`<x-menu-group>`/`<x-menu-separator>`, `<x-badge>`,
|
||||
`<x-alert>`, `<x-progress>`, `<x-loading>`, `<x-toast>`, `<x-tooltip>`, `<x-rich-tooltip>`,
|
||||
`<x-stat>`, `<x-empty-state>`, plus `resources/css/components/{groups,menu}.css`,
|
||||
`resources/css/tokens/{state,motion,shape}.css`, `resources/js/{menu,snackbar,tooltip,rich-tooltip,progress,figure}.js`
|
||||
and `src/Concerns/Toasts.php`.
|
||||
|
||||
Sources are named per finding. `RC-A` = `docs/reference/m3/reference-components-a.md`, `RF` =
|
||||
`reference-foundations.md`, `RS` = `reference-styles.md`, `tokens/X.kt` = the androidx Compose
|
||||
Material 3 token file in `docs/reference/m3/tokens/`. Two numbers were fetched live from androidx-main
|
||||
during this audit (`Button.kt`, `ButtonGroup.kt`) and are marked as such.
|
||||
|
||||
## Summary
|
||||
|
||||
The button family is the strongest part of this library: all five Expressive sizes, the icon-button
|
||||
width matrix, the corner/press-morph scale, the outline widths, the per-variant toggle colours and
|
||||
the per-size type roles match the androidx tokens *exactly*, digit for digit — I could not find a
|
||||
single wrong number in `<x-button>`'s own size tables, and the icon-button widths (narrow/default/wide
|
||||
× five sizes, fifteen values) are all correct. The problems are concentrated elsewhere: the split
|
||||
button reuses the connected button group's corner variables, so its inner corners are wrong at four of
|
||||
five sizes and morph in the *opposite* direction to M3 on press; the snackbar's live region is created
|
||||
together with its message (so the announcement is unreliable) and auto-dismisses even when it carries
|
||||
an action, which M3's accessibility page forbids outright; a spinner inside a filled button is drawn
|
||||
in `text-primary` on a `bg-primary` container, i.e. invisible; `<x-group>`'s segments carry no 48 px
|
||||
touch target; and `<x-menu>` has no maximum height, so a long menu runs off the viewport with no way
|
||||
to scroll it. Colour is animated on the overshooting *spatial* spring in four components, which the
|
||||
library's own `motion.css` comment says must never happen. Nothing in this group implements a
|
||||
deprecated M3 component — segmented buttons and the small FAB are correctly absent — and the two
|
||||
non-M3 pieces (alert, stat, empty state) stay inside M3's colour roles, type scale and shape scale.
|
||||
|
||||
Counts: 5 must-fix, 17 should-fix, 14 nice-to-have (36 findings).
|
||||
|
||||
## Findings
|
||||
|
||||
### ACT-01 · button / loading · A button's spinner is invisible on a filled or tonal button
|
||||
- Severity: must-fix
|
||||
- M3 says: "when embedded in another component (e.g. a button), the active indicator should match the
|
||||
label/icon color and the **track should be removed** in that context" (RC-A, Progress indicators →
|
||||
Accessibility; same rule repeated for the loading indicator: "when embedded in another component
|
||||
(e.g. a button), the indicator needs ≥3:1 against that component").
|
||||
- Library does: `resources/views/components/button.blade.php:207` renders
|
||||
`<x-loading :class="$iconSize" :label="false" />` — only a size class. `loading.blade.php:22`
|
||||
detects a caller colour with `preg_match('/(^|\s)text-(?!…)/')`, which `size-5` does not match, so
|
||||
`loading.blade.php:31` adds `text-primary`. The SVG is `fill="currentColor"`
|
||||
(`resources/svg/loading-indicator/*.svg`), so a `spinner` on `<x-button variant="filled">`
|
||||
(`bg-primary`) paints primary on primary — contrast 1:1. Same on `tonal` (primary on
|
||||
secondary-container) and on `variant="filled" color="error"`.
|
||||
- Fix: in `button.blade.php:207` pass `:class="$iconSize.' text-current'"` — `text-current` satisfies
|
||||
`loading.blade.php`'s `$coloured` test, so no `text-primary` is added and the indicator inherits the
|
||||
button's label colour.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-02 · toast · The snackbar's live region is created together with its message
|
||||
- Severity: must-fix
|
||||
- M3 says: "Announcement: use a **live region with a 'polite'/queued** announcement (not assertive) on
|
||||
Android and web" (RC-A, Snackbar → Accessibility).
|
||||
- Library does: `resources/views/components/toast.blade.php:41` wraps the whole snackbar in
|
||||
`<template x-if="current">`, and the element carrying `role` and `aria-live="polite"` is inside it
|
||||
(`toast.blade.php:45-46`). The region therefore enters the DOM with its text already present; a
|
||||
live region must exist beforehand and have its *contents* change for assistive technology to
|
||||
announce it reliably. The outer wrapper (`toast.blade.php:33`) is permanent but carries no
|
||||
`aria-live`. Secondary issue on the same element: `x-bind:role` sets `alert` (implicitly assertive)
|
||||
while `aria-live="polite"` is hard-coded beside it; the explicit `aria-live` wins, so the intent
|
||||
("errors interrupt") is not realised anyway — and M3 asks for polite regardless.
|
||||
- Fix: move `aria-live="polite"` and `aria-atomic="true"` onto the permanent wrapper at
|
||||
`toast.blade.php:33`, drop `x-bind:role` / `aria-live` from the inner div (or keep `role="status"`
|
||||
on the wrapper). The text nodes then change inside a region that was already there.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-03 · toast / snackbar.js · A snackbar with an action still auto-dismisses
|
||||
- Severity: must-fix
|
||||
- M3 says: "**Snackbars with an action must never auto-dismiss** — let people read/act at their own
|
||||
pace; snackbars without actions may auto-dismiss (common range 4-10 seconds)" (RC-A, Snackbar →
|
||||
Accessibility). The guidelines page repeats it as a design requirement: "make the snackbar
|
||||
actionable so it doesn't auto-dismiss".
|
||||
- Library does: `resources/js/snackbar.js:60` sets `timeout: sticky || toast.timeout === 0 || … ? 0 :
|
||||
(toast.timeout ?? DEFAULT_TIMEOUT_MS)` — the presence of `action` is not considered, so
|
||||
`materialToast('Share deleted', { action: { label: 'Undo', … } })` (exactly the showcase example,
|
||||
`showcase/sections/communication.blade.php:22`) disappears after 4 s. Hover/focus pauses the timer
|
||||
(`snackbar.js:97-117`), which helps a mouse user but not a screen-reader or keyboard user who has
|
||||
not yet reached it.
|
||||
- Fix: in `snackbar.js:55-63`, treat an entry with `action` as untimed: `timeout: sticky ||
|
||||
toast.action || toast.timeout === 0 … ? 0 : …`. The close button already appears for an actioned
|
||||
toast (`toast.blade.php:76`), so dismissal stays possible. Let an explicit `timeout` still override
|
||||
it if the caller insists.
|
||||
- Effort: S
|
||||
- Breaks API? no (behaviour change only; `timeout` keeps working)
|
||||
|
||||
### ACT-04 · menu · A long menu has no maximum height and cannot be scrolled
|
||||
- Severity: must-fix
|
||||
- M3 says: "Scrolling: a menu that can't fit all items **scrolls and shows a persistent scrollbar**"
|
||||
(RC-A, Menus → Behaviour and guidelines).
|
||||
- Library does: `resources/views/components/menu.blade.php:70` sets
|
||||
`min-w-28 max-w-70 overflow-visible … [inset:auto]` — there is no `max-block-size` and overflow is
|
||||
explicitly *visible*. A popover positioned with `position-area` and `inset: auto` is sized
|
||||
`fit-content`; it is not clipped to the position area, so a menu with (say) 20 items extends past
|
||||
the viewport and the items beyond the edge cannot be reached — the top layer does not scroll with
|
||||
the page. The library's own dropdown CSS already does this correctly:
|
||||
`resources/css/components/menu.css:24-25` (`max-block-size: 18rem; overflow-y: auto`) and
|
||||
`menu.css:99` for `::picker(select)`.
|
||||
- Fix: on `menu.blade.php:70` replace `overflow-visible` with
|
||||
`max-h-72 overflow-y-auto` (18rem, matching `.field-menu`), or
|
||||
`max-h-[min(18rem,calc(100vh-2rem))]`. Verify in Chrome that `position-try-fallbacks` still flips
|
||||
before the cap bites.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-05 · group / button-group · Connected segments miss M3's 48 dp target and 48 dp minimum width
|
||||
- Severity: must-fix
|
||||
- M3 says: "Each button needs a minimum **48x48dp** target; XS/S groups get extra inner padding
|
||||
specifically to preserve this — **don't reduce it**" and "Minimum widths: XS and S connected button
|
||||
groups have a 48dp target area and a **48dp minimum width**" (RC-A, Button groups → Accessibility
|
||||
and → Specs). RF:78 repeats the 48 × 48 rule, RF:82 gives "at least 48 × 48 **CSS pixels**" for web.
|
||||
- Library does: `<x-button>` solves this for itself with the pseudo-element at
|
||||
`button.blade.php:172` (`after:min-h-12 after:min-w-12` on `xs`/`sm`). `<x-group>` does **not**: its
|
||||
segments are plain `<label>`s (`group.blade.php:75-83`) sized `h-8` (xs) or `h-10` (sm)
|
||||
(`group.blade.php:48-49`) with no target expansion, so the whole `<x-group size="sm">` — the
|
||||
default, and the only form in the showcase — offers a 40 px-tall target. Separately, nothing
|
||||
enforces the 48 dp *minimum width*: a connected `<x-button-group connected>` of `sm` icon buttons
|
||||
is 40 px wide per segment (`button.blade.php:141`), and `groups.css` has no `min-width`.
|
||||
- Fix: (a) add the same pseudo-element to `group.blade.php:75-83` for `xs`/`sm`
|
||||
(`after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12 after:min-w-12
|
||||
after:-translate-x-1/2 after:-translate-y-1/2`); (b) in `groups.css`, add
|
||||
`[data-button-group='connected'][data-size='xs'] > *, [data-button-group='connected'][data-size='sm'] > * { min-inline-size: 3rem; }`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-06 · split-button · Inner corners are wrong at four of five sizes and morph the wrong way
|
||||
- Severity: should-fix
|
||||
- M3 says (RC-A, Split button → Specs, from `SplitButton{Size}Tokens.kt`, cross-checked against the
|
||||
site's own "inner corner radius" figure):
|
||||
|
||||
| Size | Inner corner (rest) | Inner corner (hover/press) |
|
||||
| --- | --- | --- |
|
||||
| XS | 4dp | 8dp |
|
||||
| S | 4dp | 12dp |
|
||||
| M | 4dp | 12dp |
|
||||
| L | 8dp | 20dp |
|
||||
| XL | 12dp | 20dp |
|
||||
|
||||
Verified directly: `tokens/SplitButtonSmallTokens.kt` — `InnerCornerCornerSize =
|
||||
CornerValueExtraSmall`, `InnerHoveredCornerCornerSize = InnerPressedCornerCornerSize =
|
||||
CornerValueMedium`; `tokens/SplitButtonMediumTokens.kt` — the same pair. The inner corner **grows**
|
||||
under the finger.
|
||||
- Library does: `resources/views/components/split-button.blade.php:39` emits
|
||||
`data-button-group="split"`, and `resources/css/components/groups.css:54-66` gives `[data-split]`
|
||||
the *connected group's* variables — `--group-inner` (rest) and `--group-inner-pressed` (pressed).
|
||||
Those are 8 px/4 px at `sm` and `md`, 16 px/12 px at `lg`, 20 px/16 px at `xl`
|
||||
(`groups.css:32-36`). So rest is 8/8/16/20 px where M3 wants 4/4/8/12, and pressed *shrinks* to
|
||||
4/4/12/16 px where M3 wants it to *grow* to 12/12/20/20. Only `xs` is right at rest (4 px), and its
|
||||
pressed value (2 px, `groups.css:32`) should be 8 px.
|
||||
- Fix: give `[data-split]` its own variables in `groups.css` rather than reusing `--group-inner*`,
|
||||
e.g. `[data-button-group='split'][data-size='sm'] { --split-inner: var(--md-sys-shape-corner-xs);
|
||||
--split-inner-pressed: var(--md-sys-shape-corner-md); }` per size, and point the `[data-split]` /
|
||||
`[data-split]:active` rules (`groups.css:54-66`) at them.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-07 · split-button · The trailing menu icon is the wrong size at every size step
|
||||
- Severity: should-fix
|
||||
- M3 says: trailing icon 22dp (XS), 22dp (S), 26dp (M), 38dp (L), 50dp (XL) (RC-A, Split button →
|
||||
Specs; `tokens/SplitButtonSmallTokens.kt` `TrailingIconSize = 22.0.dp`,
|
||||
`SplitButtonMediumTokens.kt` `= 26.0.dp`).
|
||||
- Library does: `split-button.blade.php:56-66` renders the trailing half as a plain icon-only
|
||||
`<x-button>`, which picks its icon size from the icon-button table
|
||||
(`button.blade.php:155`): 20 / 24 / 24 / 32 / 40 px. Every step is undersized (S is 24 vs 22 —
|
||||
oversized — and M/L/XL are 2/6/10 px small). The trailing *button widths* are all correct
|
||||
(48/48/56/96/136 px, `split-button.blade.php:36` plus the icon-button defaults), so only the glyph
|
||||
is off.
|
||||
- Fix: pass an explicit icon size from `split-button.blade.php`, e.g. add
|
||||
`['xs' => 'size-5.5', 'sm' => 'size-5.5', 'md' => 'size-6.5', 'lg' => 'size-9.5', 'xl' => 'size-12.5'][$size]`
|
||||
to the trailing button's `:class` (Tailwind's 4 px scale gives 22 px as `size-5.5`).
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-08 · fab · The extended FAB's icon↔label gap is too small at md and lg
|
||||
- Severity: should-fix
|
||||
- M3 says: icon↔label gap 8dp (small), **16dp** (medium), **20dp** (large) — RC-A, Extended FAB →
|
||||
Specs; verified in `tokens/ExtendedFabMediumTokens.kt` (`IconLabelSpace = 16.0.dp`) and
|
||||
`tokens/ExtendedFabLargeTokens.kt` (`IconLabelSpace = 20.0.dp`).
|
||||
- Library does: `resources/views/components/fab.blade.php:39` — `gap-2` (8 px) small ✓,
|
||||
`gap-3` (12 px) medium ✗, `gap-4` (16 px) large ✗.
|
||||
- Fix: `gap-4` for `md` and `gap-5` for `lg` in `fab.blade.php:39`. (Heights, corners, icon sizes and
|
||||
the 26 px/28 px paddings on the same line are all correct.)
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-09 · button · The xs button's horizontal padding is 12 px, not 16 dp
|
||||
- Severity: should-fix
|
||||
- M3 says: XS leading/trailing padding = **16dp** (RC-A, Buttons → Specs;
|
||||
`tokens/ButtonXSmallTokens.kt` `LeadingSpace = 16.0.dp`, `TrailingSpace = 16.0.dp`). The variants
|
||||
table on the same page makes the point twice: 24 dp is "Not recommended. Use 16dp", and 16 dp is
|
||||
the Expressive value.
|
||||
- Library does: `button.blade.php:147` — `'xs' => 'h-8 gap-2 px-3 …'` (12 px). The same 12 px is
|
||||
written into the group spacing (`groups.css:32`, `--group-pad: 0.75rem`) and into `<x-group>`'s
|
||||
segments (`group.blade.php:48`), so all three are consistently 4 px short.
|
||||
- Fix: `px-4` in `button.blade.php:147` and `group.blade.php:48`; `--group-pad: 1rem` in
|
||||
`groups.css:32`.
|
||||
- Effort: S
|
||||
- Breaks API? no (visual width of xs buttons changes by 8 px)
|
||||
|
||||
### ACT-10 · button-group · A standard group is allowed to wrap onto a second line
|
||||
- Severity: should-fix
|
||||
- M3 says: "Adaptive/resizing: groups move through layouts as **a single line and never wrap to a
|
||||
second line**; multiple groups can stack vertically but don't interact with each other" (RC-A,
|
||||
Button groups → Behaviour and guidelines).
|
||||
- Library does: `button-group.blade.php:35` adds `flex-wrap` to every non-connected group. On a
|
||||
narrow window a three-button group breaks into two rows, and the press-expansion rules in
|
||||
`groups.css:38-48` (which only look at `+` siblings) then compress a neighbour on a different line.
|
||||
- Fix: drop `'flex-wrap' => ! $connected` from `button-group.blade.php:35`. If overflow is a concern,
|
||||
M3's answer is a smaller size or fewer buttons, not wrapping.
|
||||
- Effort: S
|
||||
- Breaks API? no (a class hook disappears from the rendered output)
|
||||
|
||||
### ACT-11 · menu-item · Leading and trailing space is 12 px where the Expressive menu wants 16 dp
|
||||
- Severity: should-fix
|
||||
- M3 says: `tokens/SegmentedMenuTokens.kt` — `ItemLeadingSpace = 16.0dp`, `ItemTrailingSpace = 16.0dp`,
|
||||
`ItemBetweenSpace = 12.0dp`. (The site publishes no numbers for the Expressive vertical menu — RC-A,
|
||||
Menus → Specs says the measurements section is diagram-only — so the token file is the only source,
|
||||
and it is the one the component's own header comment cites.)
|
||||
- Library does: `menu-item.blade.php:46` — `px-3` (12 px) with `gap-3` (12 px). The gap is right; the
|
||||
side padding is the *baseline* menu's 12 dp (RC-A, Menus → Specs, baseline table), not the
|
||||
Expressive one. `menu-group.blade.php:6` and `menu-separator.blade.php:3` inherit the same 12 px.
|
||||
- Fix: `px-4` on `menu-item.blade.php:46`, and `px-4`/`mx-4` on the group label and the separator so
|
||||
the list stays aligned.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-12 · menu-separator · Divider padding is 4 px, half of M3's 8 dp
|
||||
- Severity: should-fix
|
||||
- M3 says: "Divider top/bottom padding | **8dp**; Divider height | 1dp" (RC-A, Menus → Specs).
|
||||
- Library does: `menu-separator.blade.php:3` — `my-1` (4 px) with `h-px` ✓ and
|
||||
`bg-outline-variant` ✓.
|
||||
- Fix: `my-2` in `menu-separator.blade.php:3`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-13 · menu.js · Disabled menu items cannot be focused
|
||||
- Severity: should-fix
|
||||
- M3 says: "Interactability: **disabled items can still receive focus** (just aren't selectable);
|
||||
dividers and gaps can never receive focus" (RC-A, Menus → Accessibility). The guidelines page adds
|
||||
"Disable a menu item that's conditionally unavailable rather than removing it" — the point of
|
||||
keeping it is that people can find out it exists.
|
||||
- Library does: `resources/js/menu.js:183` filters them out —
|
||||
`.filter((item) => item.getAttribute('aria-disabled') !== 'true')` — so arrow keys, Home/End and
|
||||
typeahead skip them entirely, and `menu-item.blade.php:53` also sets `pointer-events-none`. A
|
||||
disabled item is announced only if the user happens to read the menu with a virtual cursor.
|
||||
- Fix: keep disabled items in `items()` and instead refuse activation (the check already exists in
|
||||
`menu.js:237`). Two lists are cleanest: `items()` for focus movement, `items().filter(enabled)` for
|
||||
activation. `menu-item.blade.php:53` can keep `pointer-events-none` for the pointer.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-14 · fab · The FAB's icon is drawn outlined
|
||||
- Severity: should-fix
|
||||
- M3 says: "Icon (should be **filled**, not outlined…)" (RC-A, FAB → Anatomy) and "icon should be
|
||||
filled (not outlined) and unambiguous" (→ Behaviour and guidelines). The same rule is why
|
||||
`<x-button>` fills a *selected* toggle's icon.
|
||||
- Library does: `fab.blade.php:68` renders `<x-icon :name="$icon" :class="$iconSize" />` with no
|
||||
`filled`, so the Material Symbol's outlined face is used. `fab-menu.blade.php:53` has the same
|
||||
issue for the FAB-menu trigger, and `fab-menu-item.blade.php:43` for its items.
|
||||
- Fix: add `filled` to `fab.blade.php:68` and `fab-menu.blade.php:53`. (Leave the FAB-menu *items*
|
||||
outlined only if you decide they read as list items rather than FABs; M3's FAB-menu anatomy shows
|
||||
filled icons there too.)
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-15 · button / icon button · A default (non-toggle) icon button uses an outlined icon
|
||||
- Severity: should-fix
|
||||
- M3 says: "**Default icon buttons should use filled icons**; toggle buttons use outlined icon
|
||||
unselected → filled icon selected" (RC-A, Icon buttons → Behaviour and guidelines).
|
||||
- Library does: `button.blade.php:213` — `:filled="$selected === true"`. For an icon button that is
|
||||
not a toggle (`$selected === null`) that evaluates to `false`, so the outlined face is drawn where
|
||||
M3 asks for the filled one. The toggle half of the rule is implemented correctly.
|
||||
- Fix: `:filled="$selected === true || ($iconOnly && $selected === null)"` in `button.blade.php:213`.
|
||||
This is a visible, opinionated change across every icon button in an application — if the library
|
||||
prefers outlined icon buttons, say so in the header comment as a deliberate deviation rather than
|
||||
leaving it implicit.
|
||||
- Effort: S
|
||||
- Breaks API? no (visual change)
|
||||
|
||||
### ACT-16 · fab · `disabled` exists on a component M3 says must never be disabled
|
||||
- Severity: should-fix
|
||||
- M3 says: "**Never disable a FAB** — if its action is unavailable, remove the FAB entirely instead"
|
||||
(RC-A, FAB → Accessibility). RF:389 repeats it from the states side: "'if the action… is
|
||||
unavailable, the FAB shouldn't appear' (don't just disable it) — a Don't example specifically about
|
||||
FABs". The same page's inheritance table lists FABs under "Disabled … NOT inherited by" (RF:381).
|
||||
- Library does: `fab.blade.php:23` declares a `disabled` prop and `fab.blade.php:52` paints the full
|
||||
disabled treatment (`disabled:bg-on-surface/10 disabled:text-on-surface/38 disabled:shadow-none`).
|
||||
Nothing in the header comment or in SKILL.md (`### <x-fab>`, line 277) warns against it — SKILL.md
|
||||
simply lists `disabled` among the props.
|
||||
- Fix: cheapest honest fix is documentation — say in `fab.blade.php`'s header and in SKILL.md that M3
|
||||
forbids a disabled FAB and that the prop exists only for a form-submit FAB that must block a double
|
||||
submit. Removing the prop is the M3-pure option and would break callers.
|
||||
- Effort: S
|
||||
- Breaks API? no (if documented), yes (if removed)
|
||||
|
||||
### ACT-17 · toast / button · The snackbar covers the `fab` button on a phone
|
||||
- Severity: should-fix
|
||||
- M3 says: "Placement: bottom of the UI, in front of main content; **nudge upward to avoid
|
||||
overlapping FABs**/docked toolbars… Snackbars should appear **above** FABs — never in front of or
|
||||
behind one" (RC-A, Snackbar → Behaviour and guidelines).
|
||||
- Library does: both sit at the same offset. `toast.blade.php:36` —
|
||||
`fixed inset-x-4 z-50 bottom-[calc(var(--material-bottom-bar,0px)+1rem)]`; `button.blade.php:177`
|
||||
(the `fab` prop, below `sm`) — `max-sm:fixed max-sm:end-4
|
||||
max-sm:bottom-[calc(var(--material-bottom-bar,0px)+1rem)] max-sm:z-30`. The snackbar is full width
|
||||
below `sm` (`sm:w-auto` only applies from 640 px up, `toast.blade.php:51`), so it lands *on top of*
|
||||
the FAB, which is exactly the "never in front of" case. `<x-fab>` placed by the application has the
|
||||
same problem, since the toast knows nothing about it.
|
||||
- Fix: mirror the existing `--material-bottom-bar` mechanism — have `<x-button fab>` (and the
|
||||
documented wrapper for `<x-fab>`) publish `--material-fab: 4.5rem`, and make `toast.blade.php:36`
|
||||
read `bottom-[calc(var(--material-bottom-bar,0px)+var(--material-fab,0px)+1rem)]`. Document the
|
||||
variable next to `--material-bottom-extra` in SKILL.md's "Safe areas".
|
||||
- Effort: M
|
||||
- Breaks API? no (a new opt-in CSS variable)
|
||||
|
||||
### ACT-18 · toast / alert · 40 px controls inside the snackbar and the alert miss the 48 px target
|
||||
- Severity: should-fix
|
||||
- M3 says: web interaction targets are "at least 48 × 48 **CSS pixels**" (RF:82), and "Interaction
|
||||
targets must stay ≥48×48dp even when the visible element is scaled down… a 36dp-tall button can
|
||||
keep a 48dp target" (RF:561-563).
|
||||
- Library does: `toast.blade.php:73` (the action, `h-10` = 40 px), `toast.blade.php:77` (the close
|
||||
button, `size-10` = 40 px) and `alert.blade.php:61` (the dismiss button, `size-10`). None of them
|
||||
has the pseudo-element trick `<x-button>` uses at `button.blade.php:172`. The snackbar container is
|
||||
`min-h-12` (48 px) so there is no room to grow vertically without the pseudo-element.
|
||||
- Fix: add `relative after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12
|
||||
after:min-w-12 after:-translate-x-1/2 after:-translate-y-1/2` to those three buttons (they already
|
||||
carry `state-layer`, which sets `position: relative`).
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-19 · button / group / menu-item / fab-menu · Colour is animated on the overshooting spatial spring
|
||||
- Severity: should-fix
|
||||
- M3 says: "Two spring **styles**: **Spatial** (movement — x/y position, rotation, size, rounded
|
||||
corners; overshoots and bounces into place) and **Effects** (color, opacity; **no overshoot**)"
|
||||
(RS:472-473). `resources/css/tokens/motion.css:11-13` states the same rule in the library's own
|
||||
words: "effects — colour and opacity. Critically damped; **a colour must never overshoot**."
|
||||
- Library does: four components put colour on the spatial spring, whose fast variant peaks at 1.094
|
||||
(`motion.css:28-30`):
|
||||
- `button.blade.php:163` — `transition-[border-radius,background-color,color,box-shadow,padding,margin] … ease-spatial-fast`
|
||||
- `group.blade.php:77` — `transition-[border-radius,background-color,color] … ease-spatial-fast`
|
||||
- `menu-item.blade.php:48` — `transition-[border-radius,background-color] … ease-spatial-fast`
|
||||
- `fab-menu.blade.php:49` — `transition-[border-radius,background-color,color,box-shadow] … ease-spatial-default`
|
||||
- Fix: split each into two transitions, e.g. on `button.blade.php:163`
|
||||
`transition-[border-radius,padding,margin] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast`
|
||||
plus a second declaration for `background-color,color,box-shadow` with
|
||||
`duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast`. Tailwind cannot emit two
|
||||
`transition` shorthands on one element, so this wants a small utility in
|
||||
`resources/css/components/` (e.g. `.state-transition`) rather than utility classes.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-20 · toast · The snackbar draws a status icon, which M3 tells you not to
|
||||
- Severity: should-fix
|
||||
- M3 says: "**Avoid icons in a snackbar** (use a dialog instead if an icon feels necessary); avoid
|
||||
stylized text/inline links" (RC-A, Snackbar → Behaviour and guidelines). The specs page does list an
|
||||
optional icon slot, but it is the *close* affordance ("2. Icon (optional close affordance) — …the
|
||||
guidelines page anatomy instead lists 'Close button (optional)' in the same slot", RC-A, Snackbar →
|
||||
Anatomy).
|
||||
- Library does: `toast.blade.php:53-65` draws a `check_circle` / `error` / `warning` / `info` glyph
|
||||
for every typed toast, and `Toasts.php:19-37` makes typing the *only* API — `$this->success(…)`
|
||||
always sets `type: 'success'`. The glyphs are painted in the library's own
|
||||
`inverse-success/error/warning/info` roles (`scheme.css:76-79`), which is a sound extension of
|
||||
`inverse-primary`, so the colour is not the problem; the icon's presence is.
|
||||
- Fix: either drop the leading icon and let the type only choose the announcement role, or document
|
||||
the deviation in `toast.blade.php`'s header (it currently presents the icon as M3-sanctioned: "A
|
||||
type draws its state icon in the inverse state colour"). If the icon stays, keep it out of the
|
||||
accessible name — it is already `aria-hidden` via `<x-icon>`.
|
||||
- Effort: S
|
||||
- Breaks API? no (if documented), yes for the visual contract (if removed)
|
||||
|
||||
### ACT-21 · fab-menu · The trigger has no accessible name unless `label` is passed
|
||||
- Severity: should-fix
|
||||
- M3 says: "Labeling (Web): the FAB menu is a FAB + Menu combo — follow FAB accessibility guidance for
|
||||
the trigger… **the FAB's own accessibility label should describe what menu it opens**"; and for the
|
||||
close button "Label 'Toggle menu', Role Button, State Expanded/Collapsed" (RC-A, FAB menu →
|
||||
Accessibility).
|
||||
- Library does: `fab-menu.blade.php:46` writes `aria-label` only `@if ($label)`. `label` has no
|
||||
default (`fab-menu.blade.php:21`), and the only content of the button is two `<x-icon>`s, which are
|
||||
`aria-hidden="true"` by default (`icon.blade.php:27`). A `<x-fab-menu>` without `label` is therefore
|
||||
an unnamed button. `<x-menu>` has the same shape but delegates naming to the trigger the caller
|
||||
supplies, so the gap is specific to the FAB menu, which builds its own button.
|
||||
- Fix: make `label` required, or default it to `__('Toggle menu')` in `fab-menu.blade.php:21` and
|
||||
always emit `aria-label`. Note the expanded/collapsed state is already handled — `menu.js:140`
|
||||
writes `aria-haspopup`/`aria-controls`/`aria-expanded` onto the button it finds in the trigger.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-22 · rich-tooltip · The bubble is never associated with its trigger
|
||||
- Severity: should-fix
|
||||
- M3 says: "Assistive tech must be able to **receive the tooltip's message** and activate it via
|
||||
keyboard/switch input" (RC-A, Tooltips → Accessibility); "Role: **Tooltip** (or an equivalent role)
|
||||
on the container".
|
||||
- Library does: `rich-tooltip.blade.php:41-56` gives the bubble `role="tooltip"` (or `dialog` when
|
||||
`persistent`) and an `aria-label` of the title, but nothing points at it: the trigger gets no
|
||||
`aria-describedby`, and for `persistent` no `aria-haspopup="dialog"`/`aria-expanded` either. A
|
||||
screen-reader user focusing the trigger hears only the trigger's own label; the explanation — the
|
||||
whole point of a rich tooltip — is never announced. (`<x-tooltip>` is deliberately `aria-hidden`
|
||||
and documents why, which is defensible for an icon button whose `aria-label` *is* the tooltip text;
|
||||
it is not defensible for the standalone form, e.g.
|
||||
`showcase/sections/communication.blade.php:30-32`, where the tip says something the trigger does
|
||||
not.)
|
||||
- Fix: in `rich-tooltip.blade.php`, put `aria-describedby="material-rich-tooltip-{{ $key }}"` on the
|
||||
wrapper's focusable child (or document that the caller must), and for `persistent` add
|
||||
`aria-haspopup="dialog"` + an `aria-expanded` written by `rich-tooltip.js`. For the standalone
|
||||
`<x-tooltip>`, either drop `aria-hidden` and describe the trigger, or document in SKILL.md that the
|
||||
trigger must carry the same words itself.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-23 · badge · The floating badge sits 2–4 px off M3's anchor geometry
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Small badge: distance from top-trailing icon corner to bottom-leading badge corner (H×W)
|
||||
| **6×6dp**"; "Large badge: … | **14×12dp**" (RC-A, Badges → Specs). For a 24 dp icon that puts a
|
||||
small badge flush in the icon's top-trailing corner (top 0, end 0) and a large badge at
|
||||
top −2 px / start `calc(100% - 12px)`.
|
||||
- Library does: `badge.blade.php:79-80` — `absolute top-0.5 end-0.5` for the dot (2 px in on both
|
||||
axes, where M3 wants 0) and `absolute -top-1 start-[calc(100%-0.75rem)]` for the count (the `start`
|
||||
is exactly right; the top is −4 px where M3 wants −2 px).
|
||||
- Fix: `top-0 end-0` for the dot and `-top-0.5` for the count in `badge.blade.php:79-80`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-24 · badge · The `outline` status badge draws its edge in `outline-variant`
|
||||
- Severity: nice-to-have
|
||||
- M3 says: a badge "must be ≥**3:1**, same rule whether default or custom color" (RC-A, Badges →
|
||||
Accessibility). `outline-variant` is M3's *decorative* boundary role (dividers); `outline` is the
|
||||
role for a boundary that must be seen.
|
||||
- Library does: `badge.blade.php:68` — `'border-outline-variant text-on-surface-variant'`. On
|
||||
`surface` in the library's own scheme `outline-variant` is around 1.5:1, so the badge's shape is
|
||||
barely visible. (`<x-button variant="outlined">` uses the same role, but there the M3 table
|
||||
explicitly names it: "Outlined container … **Outline variant (outline)**" — RC-A, Buttons → colour
|
||||
table — so the button is inside spec and the badge, which has no M3 table, is not.)
|
||||
- Fix: `border-outline` in `badge.blade.php:68`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-25 · tooltip.js · A tooltip disappears the instant the pointer leaves
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "tooltips are **transient by default** — both plain and rich tooltips disappear **1.5
|
||||
seconds** after the cursor/focus leaves the target region; opening a new tooltip immediately closes
|
||||
any other open one (**only one tooltip visible at a time**)" (RC-A, Tooltips → Specs).
|
||||
- Library does: `resources/js/tooltip.js:40-43` hides on `pointerleave`, `pointerdown` and
|
||||
`focusout` with no delay; `rich-tooltip.js:43` uses a 200 ms grace instead of 1500 ms. Nothing
|
||||
closes other open tooltips — because the bubble is `popover="manual"`
|
||||
(`tooltip.blade.php:35`), a tooltip held open by keyboard focus stays up while a second one opens
|
||||
on hover.
|
||||
- Fix: `hide(1500)` in `tooltip.js` (add the same `delay` parameter `rich-tooltip.js:37` already
|
||||
has), and keep a module-level `let open = null` that `show()` hides before showing its own.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-26 · menu · The open/close transition is a plain fade
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Motion: menus use an **enter/exit transition tying them visually to their trigger**; the
|
||||
trigger shows a pressed state while the menu is open" (RC-A, Menus → Behaviour and guidelines).
|
||||
- Library does: `menu.blade.php:72` transitions `opacity` only — `translate` is in the
|
||||
`transition-[…]` list but no translate or scale value is ever set, so the menu simply fades in
|
||||
place. The library's own `::picker(select)` does it properly (`menu.css:116-128`: opacity + scale
|
||||
0.95 from `transform-origin: top`, on the spatial spring).
|
||||
- Fix: mirror `menu.css:116-128` on `menu.blade.php:72` — add
|
||||
`origin-top scale-95 open:scale-100 starting:open:scale-95` and put the scale on the spatial spring
|
||||
while opacity stays on effects.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-27 · menu-item · A selected item is told by colour and shape only
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "by default, selected items change **both shape and color**; default contrast between
|
||||
selected/unselected items is 3:1; it's **recommended to add yet another cue** on top (e.g. a
|
||||
checkmark) so selection isn't conveyed by color/shape alone" (RC-A, Menus → Accessibility).
|
||||
- Library does: `menu-item.blade.php:51` changes container colour and corner, and
|
||||
`menu-item.blade.php:87` fills the leading icon; `aria-checked` is set
|
||||
(`menu-item.blade.php:57`), so assistive tech is fine. No visible tick. Again the library's own
|
||||
select menu does it — `menu.css:179-188` draws a `::checkmark` at the row's end.
|
||||
- Fix: when `$selected === true` and no `iconRight` is given, render a trailing `check` icon in
|
||||
`menu-item.blade.php:105-107`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-28 · menu-item · 44 px rows, where the rest of the library's menus use 48 px
|
||||
- Severity: nice-to-have
|
||||
- M3 says: `tokens/SegmentedMenuTokens.kt` `Item = 44.0.dp` (what the component's header cites, and it
|
||||
is correct), while the baseline menu's published spec is "List item height | **48dp**" (RC-A, Menus
|
||||
→ Specs) and the same page's accessibility section asks for "≥**48×48dp** targets" inside menu item
|
||||
slots.
|
||||
- Library does: `menu-item.blade.php:46` — `min-h-11` (44 px). `resources/css/components/menu.css:55`
|
||||
(`.field-option`) and `menu.css:134` (native `option`) both use `min-block-size: 3rem` (48 px), so
|
||||
a `<x-menu>` and a `<x-select>` dropdown in the same form do not match.
|
||||
- Fix: either `min-h-12` on `menu-item.blade.php:46` (consistent, and clears the 48 px target), or
|
||||
keep 44 px and note the inconsistency deliberately. Both readings are defensible; the token is the
|
||||
more specific source, the accessibility page the more binding one.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-29 · split-button · The trailing chevron is not nudged off centre
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Menu icon offset from center when unselected (site, specs page): XS/S = **−1dp**, M =
|
||||
−2dp, L = −3dp, XL = −6dp" (RC-A, Split button → Specs) — the chevron sits slightly towards the
|
||||
leading button so the pair reads as one control.
|
||||
- Library does: `split-button.blade.php:56-66` centres the icon (`justify-center` from
|
||||
`button.blade.php:162`).
|
||||
- Fix: add a per-size negative `ms-px`/`-me-*` or `translate-x` to the trailing button's `:class` in
|
||||
`split-button.blade.php:36`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-30 · split-button · The chevron rotates on the expressive spring
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "The trailing (menu) button uses the **standard motion scheme, not expressive**, when
|
||||
rotating; it rotates 180° inward on open/close" (RC-A, Split button → Behaviour and guidelines).
|
||||
- Library does: `groups.css:85-87` — `transition: rotate var(--md-sys-motion-spatial-fast-duration)
|
||||
var(--md-sys-motion-spatial-fast)`, i.e. the expressive spring, which overshoots past 180° and
|
||||
comes back. The 180° rotation itself (`groups.css:89-90`) is right.
|
||||
- Fix: the library has no standard-scheme spring token; `--md-sys-motion-easing-standard` with
|
||||
`--md-sys-motion-duration-short` (`motion.css:52,58`) is the closest published equivalent.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-31 · fab · The small extended FAB's minimum width is 56 px, not 80 dp
|
||||
- Severity: nice-to-have
|
||||
- M3 says: baseline extended FAB "Container width | Dynamic, **80dp min**" (RC-A, Extended FAB →
|
||||
Specs). `ExtendedFabSmallTokens.kt` publishes no minimum, and the small extended FAB is the
|
||||
baseline's replacement, so 80 dp is the only number on record.
|
||||
- Library does: `fab.blade.php:39` — `min-w-14` (56 px) for `sm`, `min-w-20`/`min-w-24` for md/lg
|
||||
(which match those sizes' heights rather than any published minimum).
|
||||
- Fix: `min-w-20` (80 px) for the small extended FAB in `fab.blade.php:39`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-32 · fab-menu · Margins do not grow in large windows, and items cannot scroll
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Menu/FAB margin from screen edge | 16dp (compact/medium windows), **24dp** (large/extra-large
|
||||
windows)"; "Extra bottom margin when opened from medium FAB | 40dp… large FAB | 56dp"; "On short
|
||||
viewports (e.g. landscape phone), items can **scroll — behind the close button**, which stays
|
||||
fixed" (RC-A, FAB menu → Specs and → Behaviour).
|
||||
- Library does: `<x-fab-menu>` does not place itself (by design), and both the header comment
|
||||
(`fab-menu.blade.php:3`) and SKILL.md line 283 show a fixed `end-4 bottom-4` (16 px) with no
|
||||
large-window variant. The list (`fab-menu.blade.php:69-75`) has no `max-height`/`overflow`, so on a
|
||||
landscape phone six 56 px items plus gaps (~360 px) push the menu off screen.
|
||||
- Fix: document `sm:end-6 sm:bottom-6`-style margins in the header and SKILL.md (Tailwind has no
|
||||
1200 px breakpoint by default — see the breakpoint map), and add
|
||||
`max-h-[calc(100vh-8rem)] overflow-y-auto` to `fab-menu.blade.php:70`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-33 · fab-menu / menu · Items enter but never exit
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Motion: FAB transforms into the close button; items animate in/out via an **enter-exit
|
||||
transition**, originating from one of the FAB's trailing corners" (RC-A, FAB menu → Behaviour).
|
||||
- Library does: `fab-menu-item.blade.php:27` has `starting:translate-y-2 starting:opacity-0` for the
|
||||
entry, but the popover is hidden with `display: none` on close and the items carry no
|
||||
`transition-discrete`/`@starting-style` exit pair, so they vanish instantly. `<x-menu>` fades out
|
||||
correctly (`menu.blade.php:72` includes `display,overlay` and `transition-discrete`).
|
||||
- Fix: add `transition-[translate,opacity,display,overlay] transition-discrete` to
|
||||
`fab-menu-item.blade.php:27` and drive the closed state from the popover's `:not(:popover-open)`.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-34 · toast · Escape does not dismiss a focused snackbar
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Keyboard: Tab moves between interactive elements; **Esc dismisses the focused snackbar**";
|
||||
and "on web, provide a documented keyboard shortcut (e.g. Alt+G) to jump focus to a snackbar with an
|
||||
action" (RC-A, Snackbar → Accessibility).
|
||||
- Library does: `snackbar.js` binds no key handler; `toast.blade.php:41-82` binds only pointer and
|
||||
focus events. Escape does nothing, and there is no way to reach an actioned snackbar from the
|
||||
keyboard except by tabbing past the whole page (the host is the last element in `<body>`).
|
||||
- Fix: add a `keydown` listener in `snackbar.js:38` that calls `dismiss()` on Escape while the
|
||||
snackbar contains `document.activeElement`; optionally a documented shortcut that focuses
|
||||
`[data-toast-action]`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-35 · toast · The description line is dimmed with an opacity instead of a role
|
||||
- Severity: nice-to-have
|
||||
- M3 says: the snackbar's roles are "container = **Inverse surface**; supporting text/icon = **Inverse
|
||||
on surface**; action label = **Inverse primary**" (RC-A, Snackbar → Specs) — there is no fourth
|
||||
role, and supporting text is one block of up to two lines.
|
||||
- Library does: `toast.blade.php:69` — `type-body-md opacity-80` on the description, i.e.
|
||||
inverse-on-surface at 80 %, a colour outside the roles that also drops the contrast of the longer
|
||||
half of the message.
|
||||
- Fix: drop `opacity-80` and let both lines be `inverse-on-surface`; M3 distinguishes them by position,
|
||||
not tone.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### ACT-36 · alert · `role="alert"` on content that is present at page load
|
||||
- Severity: nice-to-have
|
||||
- M3 says: for the nearest equivalent (the snackbar) "use a **live region with a 'polite'/queued**
|
||||
announcement (**not assertive**)" (RC-A, Snackbar → Accessibility). M3 publishes no alert/banner
|
||||
component at all — the current site has no banner page; the only survivals are legacy mentions in
|
||||
the states inheritance list (RF:384) and the elevation table (RS:320).
|
||||
- Library does: `alert.blade.php:38` — `role="{{ in_array($color, ['error','warning']) ? 'alert' :
|
||||
'status' }}"`. `role="alert"` is an assertive live region; when the alert is part of the rendered
|
||||
page (the normal case — the showcase renders four of them statically,
|
||||
`showcase/sections/communication.blade.php:47-53`) some screen readers announce it over the page
|
||||
title on load, and a Livewire morph that re-renders the region can re-announce it.
|
||||
- Fix: default both to `role="status"` and add an opt-in prop (`assertive`) for the case where the
|
||||
alert really is injected in response to an action.
|
||||
- Effort: S
|
||||
- Breaks API? yes (the rendered role changes; tests asserting `role="alert"` would need updating)
|
||||
|
||||
## Deliberate deviations
|
||||
|
||||
- **The text button's label is `primary`, not `on-surface-variant`** (`button.blade.php:20-22`,
|
||||
`:100-127`). Holds up. The M3 specs table reads "Text icon & label | **Primary** | -- | --" (RC-A,
|
||||
Buttons → colour table), and the M2→M3 notes say "neutral text button no longer recommended". The
|
||||
library is right, and it is also right that an *icon-only* text button (M3's "standard icon button")
|
||||
takes `on-surface-variant` — "Standard icon | On surface variant | On surface variant | Primary"
|
||||
(RC-A, Icon buttons → colour table). Both readings are implemented in the same `match` at
|
||||
`button.blade.php:123-126`, which is exactly correct.
|
||||
- **A selected round toggle button turns square** (`button.blade.php:132-136`). Ambiguous, and the
|
||||
library picked the site's reading. The guidelines page says "By default, toggle buttons change from
|
||||
**round to square when selected**" (RC-A, Buttons → Behaviour), but `tokens/ButtonSmallTokens.kt`
|
||||
and `ButtonMediumTokens.kt` both set `SelectedContainerShapeRound = CornerFull`, i.e. a selected
|
||||
round *label* button stays round in Compose. For *icon* buttons the tokens agree with the site
|
||||
(`SmallIconButtonTokens.SelectedContainerShapeRound = CornerMedium`,
|
||||
`…ShapeSquare = CornerFull`; `LargeIconButtonTokens`/`XLargeIconButtonTokens` are the same pattern
|
||||
with ExtraLarge), and the library implements that swap exactly. Worth a line in the header comment
|
||||
saying which source won for label buttons.
|
||||
- **A selected text button takes the tonal container** (`button.blade.php:18`, `:123-126`). Holds up as
|
||||
an extension: M3 gives text buttons no toggle states at all, so anything is an invention, and
|
||||
borrowing the tonal selected colours keeps it inside the roles and satisfies the two-indicator rule.
|
||||
- **Press expansion is a fixed step per size, not 15 % of width** (`groups.css:9`, `:32-48`). Holds up.
|
||||
`ButtonGroupDefaults.ExpandedRatio = 0.15f` and the layout applies `expandedRatio * width / 2` per
|
||||
side, clamped to the neighbour's padding (fetched live from androidx-main `ButtonGroup.kt:186`,
|
||||
`:482`, `:500-511`). CSS cannot express a percentage of an intrinsic width, and every size's
|
||||
`--group-grow` (4/6/8/16/20 px) is safely below its `--group-pad`, so the neighbour never reaches
|
||||
negative padding. The animation uses `ease-spatial-fast`, matching Compose's
|
||||
`MotionSchemeKeyTokens.FastSpatial` (`ButtonGroup.kt`, defaultAnimationSpec).
|
||||
- **`--group-full` is half the height rather than `corner-full`** (`groups.css:19-23`, `:32-36`).
|
||||
Holds up, and the values are right: 16/20/28/48/68 px are exactly half of 32/40/56/96/136 dp, which
|
||||
is what `ConnectedButtonGroupSmallTokens.SelectedInnerCornerCornerSizePercent = 50%` means.
|
||||
- **The `fab` prop on `<x-button>`** (`button.blade.php:177`). Inside spec as far as the *component*
|
||||
goes: an extended FAB at 56 px, corner 16 dp, title-medium, primary-container, elevation 3 and a
|
||||
16 px margin, which is the small extended FAB exactly (`ExtendedFabSmallTokens.kt`). Two caveats:
|
||||
M3 says an extended FAB needs "**No tooltip** — the visible label already serves that role
|
||||
(explicit Don't)" while `button.blade.php:225` will happily attach one; and the elevation never
|
||||
reaches Level 4 on hover because `max-sm:shadow-elevation-3` outranks `hover:shadow-elevation-1`
|
||||
in Tailwind's variant order.
|
||||
- **`<x-group>` binds native radios/checkboxes, so the arrow keys move the choice**
|
||||
(`group.blade.php:9-10`, `:84-91`). Ambiguous. M3's button-group accessibility says "Keyboard: **Tab**
|
||||
navigates to the next button; Space or Enter activates/selects the focused button" (RC-A, Button
|
||||
groups → Accessibility), which is roving-tabindex-free but also not a radio group. The library's
|
||||
choice matches WAI-ARIA's radiogroup pattern and gives `wire:model` for free; it is a defensible
|
||||
reading of "single-select, selection-required", and the `multiple` form (checkboxes) does behave
|
||||
exactly as M3 describes.
|
||||
- **The alert exists at all** (`alert.blade.php:1-10`). Holds up, with a caveat. M3 publishes no
|
||||
banner and no alert; its own guidance routes in-page notices to a snackbar (transient, low priority)
|
||||
or a dialog (blocking, high priority) — and an "your storage is almost full" notice is neither. The
|
||||
component stays inside the foundations: container roles only, `rounded-corner-md`, title-small +
|
||||
body-medium, text buttons in the actions slot. The header comment should add *when not to* use it
|
||||
(a confirmation belongs in `<x-modal>`, a result of an action in `<x-toast>`), since the M3 site
|
||||
will not tell a reader that.
|
||||
- **`<x-stat>` and `<x-empty-state>`** (`stat.blade.php:3-8`, `empty-state.blade.php:8-11`). Hold up.
|
||||
Both draw only from M3's roles (`surface-container`, `secondary-container`/`on-secondary-container`),
|
||||
the shape scale (`rounded-corner-lg`), the Expressive emphasized type roles
|
||||
(`type-emphasized-headline-md`, which `type.css` does define) and the Expressive shape library
|
||||
(`cookie-9`). The counting figure honours reduced motion by reading
|
||||
`--md-sys-motion-spatial-slow-duration`, which `motion.css:63-74` zeroes (`figure.js:59-64`, `:75`).
|
||||
Nothing here draws a colour outside the roles.
|
||||
- **`<x-progress>`'s non-primary track colours** (`progress.blade.php:64-68`). Holds up. M3 fixes the
|
||||
track at `secondary-container` for the primary indicator (RC-A, Progress indicators → Specs), which
|
||||
the library does for `primary` and `secondary`; the extra colours take their own container, which is
|
||||
the only consistent extension.
|
||||
- **`thick` grows the container beyond M3's `WaveHeight`** (`progress.blade.php:11-13`,
|
||||
`:154-162`). Holds up: M3 publishes 10 dp only for the 4 dp stroke, "Track thickness | Configurable"
|
||||
is an Expressive configuration with no published table, and adding the extra stroke to the container
|
||||
keeps the 3 dp amplitude intact (`progress.js:1333`, `waveHeight = height - stroke`).
|
||||
|
||||
## Aligned
|
||||
|
||||
Verified correct — no need to re-check:
|
||||
|
||||
- **Button sizes**: 32/40/56/96/136 px heights, 8/8/8/12/16 px gaps, 20/20/24/32/40 px icons,
|
||||
16/24/48/64 px padding at sm/md/lg/xl, 1/1/1/2/3 px outlines (`button.blade.php:147-158` against
|
||||
RC-A Buttons → Specs and `tokens/Button*.kt`).
|
||||
- **Button corners**: square 12/12/16/28/28 px and pressed 8/8/12/16/16 px, and the pressed value is
|
||||
applied to round buttons too, as M3 requires ("round and square buttons should have the same pressed
|
||||
shape") — `button.blade.php:129-136`, `:166`.
|
||||
- **Button type roles per size**: label-large / label-large / title-medium / headline-small /
|
||||
headline-large. Confirmed live against androidx-main `Button.kt:1645-1648`
|
||||
(`buttonHeight < mediumHeight -> labelLarge; < largeHeight -> titleMedium; < xLargeHeight ->
|
||||
headlineSmall; else -> headlineLarge`).
|
||||
- **Every toggle colour pair**, all four variants × three states, matches the specs table exactly
|
||||
(`button.blade.php:118-127`): filled unselected = surface-container/on-surface-variant, tonal
|
||||
selected = secondary/on-secondary, outlined selected = inverse-surface/inverse-on-surface, elevated
|
||||
selected = primary/on-primary, standard icon button selected = primary.
|
||||
- **Icon-button widths**: all fifteen values (narrow/default/wide × XS–XL) derive exactly from the
|
||||
token paddings — 28/32/40, 32/40/52, 48/56/72, 64/96/128, 104/136/184 px (`button.blade.php:140-144`).
|
||||
- **Icon-button selected shape swap** (round ⇄ square, `button.blade.php:133`) matches
|
||||
`Small/Large/XLargeIconButtonTokens.SelectedContainerShape*` at every size. (RC-A's note that the
|
||||
swap is "inverted for XS/S/M vs L/XL" is wrong — the token files are consistent, and the library is
|
||||
right.)
|
||||
- **Disabled**: content at 38 %, container at 10 % (`button.blade.php:174-175`) matches
|
||||
`FilledButtonTokens/TonalButtonTokens/ElevatedButtonTokens.DisabledContainerOpacity = 0.1f` and
|
||||
`DisabledLabelTextOpacity = 0.38f`; elevation removed when disabled ✓.
|
||||
- **Elevation**: filled/tonal rest 0 → hover Level 1; elevated rest Level 1 → hover Level 2
|
||||
(`button.blade.php:169-170`); FAB rest Level 3 → hover Level 4 (`fab.blade.php:49`); menu Level 2
|
||||
(`menu.blade.php:70`); snackbar Level 3 (`toast.blade.php:51`); rich tooltip Level 2
|
||||
(`rich-tooltip.blade.php:50`); plain tooltip none (`tooltip.blade.php:40`). All match.
|
||||
- **48 px touch target on xs/sm buttons** (`button.blade.php:172`).
|
||||
- **State layer**: content colour at 8 % hover / 10 % focus / 10 % press, hover gated behind
|
||||
`@media (hover: hover)`, suppressed when disabled (`state.css:13-44`) — matches RF:367-375 exactly,
|
||||
including "the state layer's color equals the content's 'on' color" (it uses `currentColor`).
|
||||
- **Focus ring**: 3 px `secondary`, 2 px offset, keyboard-only (`state.css:46-53`), and
|
||||
`SegmentedMenuTokens.ItemFocusIndicatorColor = Secondary` confirms the role for menus.
|
||||
- **Standard group spacing**: 18/12/8/8/8 px (`button-group.blade.php:36-38`).
|
||||
- **Connected group**: 2 px gaps and inner corners 4/8/8/16/20 px, pressed smaller, selected at 50 %
|
||||
of the height (`button-group.blade.php:34`, `groups.css:32-36`, `:54-71`).
|
||||
- **Split button**: 2 px between halves, leading padding 12/10 (xs) and 16/12 (sm) px, symmetric
|
||||
24/48/64 px at md/lg/xl, trailing button widths 48/48/56/96/136 px, outer corners full, trailing
|
||||
half rounds fully while open, chevron rotates 180°, `text` variant excluded, menu 4 px away,
|
||||
`aria-expanded` on the trailing button, default label "More options"
|
||||
(`split-button.blade.php:33-66`, `groups.css:68-90`).
|
||||
- **FAB**: 56/80/96 px with 16/20/28 px corners and 24/28/32 px icons; primary/secondary/tertiary
|
||||
container by default with the Expressive non-container styles under `variant="filled"`; no small FAB
|
||||
and no surface style (`fab.blade.php:34-42`).
|
||||
- **FAB menu**: 56 px close button, full corner when open, 20 px close icon, 8 px to the first item,
|
||||
56 px items with full corners, 24 px icons, 8 px icon gap, 24 px padding, 4 px between items,
|
||||
elevation 3 throughout, trailing-edge alignment (`fab-menu.blade.php:48-75`,
|
||||
`fab-menu-item.blade.php:25`) — every number matches `tokens/FabMenuBaselineTokens.kt`.
|
||||
- **Menu container**: surface-container-low / tertiary-container for `vibrant`, 16 px corner,
|
||||
elevation 2, 112–280 px width (`menu.blade.php:70-79`) — matches
|
||||
`StandardMenuTokens.ContainerColor`, `VibrantMenuTokens`, `SegmentedMenuTokens.ContainerShape =
|
||||
CornerLarge`, `ContainerElevation = Level2`, and the baseline width table.
|
||||
- **Menu item**: body-large label, 20 px icons, 4 px corners opening to 12 px at the ends, selected =
|
||||
tertiary-container with a 12 px corner, body-medium description, label-small trailing text — all
|
||||
five confirmed against `SegmentedMenuTokens` (`ItemLabelTextFont = BodyLarge`, `ItemLeadingIconSize
|
||||
= 20dp`, `ItemShape = CornerExtraSmall`, `ItemFirstChildShape = CornerMedium`, `ItemSelectedShape =
|
||||
CornerMedium`, `ItemSupportingTextFont = BodyMedium`, `ItemTrailingSupportingTextFont = LabelSmall`).
|
||||
- **Menu keyboard**: Enter/Space/Down open on the first item, Up on the last, arrows/Home/End move,
|
||||
typeahead, Tab closes, Escape closes and returns focus to the trigger, activation closes unless
|
||||
`keep-open` (`menu.js:153-242`, `menu.blade.php:53-55`) — the WAI-ARIA menu-button pattern, and it
|
||||
matches M3's own keyboard table apart from submenus.
|
||||
- **`current` uses `secondary-container`** (`menu-item.blade.php:52`) — the navigation-indicator role,
|
||||
correctly distinguished from a checked choice.
|
||||
- **Badge**: 6 px dot, 16 px tall count with a 16 px minimum width and 4 px padding, label-small,
|
||||
full corners, error/on-error by default, `aria-hidden` unless labelled, `max` overflow to "999+"
|
||||
within M3's four-character limit (`badge.blade.php:74-85`) — matches `tokens/BadgeTokens.kt`.
|
||||
- **Progress**: 4 px (8 px thick) stroke, 40 px circular (48 px wavy), 4 px track gap, 4 px stop
|
||||
indicator on linear determinate only, primary indicator with a secondary-container track, 40/20/15 px
|
||||
wavelengths, 3 dp linear amplitude, round caps, reduced-motion handling, `role="progressbar"` with
|
||||
`aria-valuenow` only while determinate (`progress.blade.php:70-197`, `progress.js:61-79`, `:1333`).
|
||||
- **Loading indicator**: 48 px container, 38 px shape, primary by default,
|
||||
on-primary-container-on-primary-container when `contained`, `role="progressbar"`, rests under
|
||||
reduced motion (`loading.blade.php:27-42`) — matches `LoadingIndicatorTokens` and the contained
|
||||
colour rule exactly.
|
||||
- **Snackbar colours and type**: inverse-surface container, inverse-on-surface text, inverse-primary
|
||||
label-large action rendered as a text button, body-medium supporting text, 4 px corner, elevation 3,
|
||||
48 px single-line height, one at a time, 4 s default inside M3's 4–10 s range, hover/focus pauses,
|
||||
never steals focus (`toast.blade.php:51-73`, `snackbar.js:13`, `:84-117`) — matches
|
||||
`tokens/SnackbarTokens.kt` value for value.
|
||||
- **Plain tooltip**: inverse-surface, inverse-on-surface, body-small, 4 px corner, 8 px horizontal
|
||||
padding giving a 24 px container, 4 px from the target, no elevation, flips when there is no room
|
||||
(`tooltip.blade.php:40-45`).
|
||||
- **Rich tooltip**: surface-container, 12 px corner, elevation 2, 12/8/16 px padding, title-small
|
||||
subhead and body-medium text in on-surface-variant, label-large primary actions, persistent opens on
|
||||
press and light-dismisses, the pointer can travel onto the bubble to reach the actions
|
||||
(`rich-tooltip.blade.php:50-65`, `rich-tooltip.js:20-46`).
|
||||
- **Motion springs**: damping 0.6 / stiffness 800 fast-spatial and 1.0 / 3800 fast-effects
|
||||
(`motion.css:28-42`) match RS:524-525 exactly, and every duration is zeroed under reduced motion.
|
||||
|
||||
## Missing
|
||||
|
||||
- **Submenus** (`<x-menu>`): M3's Expressive vertical menu specifies submenus, the Left/Right arrow
|
||||
keys that open and close them, and the shape morph that marks the active one ("the focused
|
||||
submenu's corners become more rounded while others become less rounded"). Nothing in the library
|
||||
implements them; `menu.js:201-219` has no Left/Right case.
|
||||
- **Grouped menu layout by gap**: M3's Expressive "Grouped" layout separates clusters with a *gap*
|
||||
(`SegmentedMenuTokens.SegmentedGap = 2dp`, `GroupPadding = 4dp`, `GroupShape = CornerSmall`).
|
||||
`<x-menu-group>` implements the labelled form and `<x-menu-separator>` the divider form — which is
|
||||
what M3 recommends for web ("on web, use dividers to separate items") — but the gap layout itself,
|
||||
and the 2 px gap between adjacent items that `ItemShape = 4dp` corners imply, are absent.
|
||||
- **Menus as a filtering surface / embedded text field** ("autocomplete", RC-A Menus → Behaviour).
|
||||
Partly covered by `<x-choices searchable>` in another group.
|
||||
- **Square-by-default button groups**: M3 lists "Default shape | Round, **square**" as a button-group
|
||||
configuration. `<x-button-group>` has no `shape` prop; the corner variables in `groups.css` assume a
|
||||
round group, so a group of `shape="square"` buttons gets round outer corners.
|
||||
- **Selection-required / multi-select semantics on `<x-button-group connected>`**: M3 lists
|
||||
single-select, multi-select and selection-required as configurations. `<x-group>` covers
|
||||
single/multi with real inputs; the plain `<x-button-group connected>` leaves selection entirely to
|
||||
the caller's `aria-pressed`.
|
||||
- **Snackbar keyboard shortcut** to jump focus to an actioned snackbar (M3 suggests Alt+G on web).
|
||||
- **Two-line snackbar height** (68 dp in `SnackbarTokens.TwoLinesContainerHeight`, 64 dp in the site's
|
||||
prose) is not expressed — the container grows organically from `min-h-12`, which lands close but is
|
||||
not pinned.
|
||||
- **FAB ↔ extended FAB scroll collapse** ("an extended FAB can collapse to a FAB on scroll and
|
||||
re-expand at the bottom of the view"). `<x-button fab>` swaps on window width only.
|
||||
- **Adaptive menu → bottom sheet at compact** (M3: "at compact breakpoints, consider swapping a menu
|
||||
for a bottom sheet"). `<x-bottom-sheet>` exists, but nothing connects the two.
|
||||
- Deliberately absent and correctly so: segmented buttons (deprecated in favour of the connected
|
||||
button group, which `<x-group>` provides), the small FAB ("Not recommended. Use a larger size"), the
|
||||
baseline extended FAB ("Not recommended. Use small extended FAB"), surface-coloured FABs ("no longer
|
||||
recommended").
|
||||
|
||||
## Breakpoint map
|
||||
|
||||
| Component | Library breakpoint used | M3 window size class it stands in for | Gap |
|
||||
| --- | --- | --- | --- |
|
||||
| `<x-button fab>` (`button.blade.php:177`) | `max-sm` (< 640 px) → extended FAB pinned bottom-end; ≥ 640 px → filled button | Compact (< 600) gets the FAB; medium+ (≥ 600) gets the inline button | 40 px: windows 600–639 px are "medium" in M3 but still get the phone FAB |
|
||||
| `<x-button responsive>` (`button.blade.php:218`) | `max-lg` (< 1024 px) hides the label | M3 has no label-hiding rule; the nearest is "on large screens, buttons can reposition" at expanded (≥ 840) | 184 px, and the rule itself is an invention — an icon-only button at 840–1023 px loses its visible label while keeping its `aria-label` |
|
||||
| `<x-toast>` (`toast.blade.php:36-51`) | `sm` (≥ 640 px) switches from full-width to `w-auto min-w-86`, and `bottom-start` moves to `start-6` | Compact (< 600) full-width fixed insets; medium+ (≥ 600) scales horizontally for line length | 40 px. Also: M3 allows a full-width snackbar only "when the UI has no persistent nav components", and `<x-app-shell>` shows a bottom navigation bar in exactly that range |
|
||||
| `<x-fab-menu>` margins (documented, `fab-menu.blade.php:3`, SKILL.md:283) | none — a fixed `end-4 bottom-4` (16 px) in the docs | 16 dp at compact/medium, **24 dp** at large (≥ 1200) / extra-large (≥ 1600) | The 24 dp step is missing; Tailwind's `xl` is 1280 px and `2xl` is 1536 px, so neither lines up with 1200/1600 |
|
||||
| `<x-fab>` size choice | none — the caller picks `sm`/`md`/`lg` | M3: medium FAB "most recommended" for compact/medium, large FAB for expanded+ | No adaptive guidance in the component or SKILL.md; the default is the 56 px baseline FAB, which M3 calls the *smallest* of the three |
|
||||
| `<x-menu>` | none | M3: "at compact breakpoints, consider swapping a menu for a bottom sheet" | Not implemented (see Missing) |
|
||||
| `<x-button-group>`, `<x-group>`, `<x-split-button>`, `<x-badge>`, `<x-progress>`, `<x-loading>`, `<x-tooltip>`, `<x-rich-tooltip>`, `<x-alert>`, `<x-stat>`, `<x-empty-state>` | none | M3 defines no width-dependent behaviour for these (button groups are "fixed or flexible" by author choice; `<x-group>` already fills its row, which is M3's connected-group rule) | none |
|
||||
@@ -1,597 +0,0 @@
|
||||
# Audit: containment
|
||||
|
||||
## Summary
|
||||
|
||||
The containment group is the most accurate part of the library I have measured: the card variants, the
|
||||
dialog (28dp corner, 24dp padding, 560/280 width, surface-container-high, 32% scrim, headline-small), the
|
||||
divider (1px outline-variant, 16dp inset), the list heights (56/72/88) and leading sizes (24/40/56), the
|
||||
bottom sheet's 32×4 handle / 28dp top corner / 640px cap, and the carousel's ported Compose keylines all
|
||||
match the published numbers or the androidx token files. The failures are concentrated in three places:
|
||||
**accessibility of selection and disabling in lists** (colour-only selection with no `aria-selected`, a
|
||||
`disabled` item whose link is still focusable and activatable), **the bottom-sheet drag handle** (a 32×4px
|
||||
touch target where M3 requires 48dp, achieved by its 22dp padding), and **adaptive breakpoints** (the
|
||||
list-detail pane opens at `xl`/1280 where M3 puts two panes from expanded/840). Beyond those, a handful of
|
||||
measurable deviations: a cascade-layer bug that kills the state layer on segmented list rows, a basic
|
||||
dialog whose headline and action row scroll away, side-sheet actions right-aligned where M3 says left,
|
||||
carousel end padding of 0 where M3 says 16dp, a full-screen carousel that scrolls horizontally where M3's
|
||||
scrolls vertically, and cards with no per-state elevation at all. Collapse, the error pages and the mail
|
||||
theme are clean against M3's foundations and styles; the mail theme reproduces the typescale correctly in
|
||||
px and the error pages use the roles and typescale properly.
|
||||
|
||||
## Findings
|
||||
|
||||
### C-01 · bottom-sheet · The drag handle is a 32×4px target where M3 requires 48dp
|
||||
- Severity: must-fix
|
||||
- M3 says: "drag handle has an accessible **48dp hit target**" and the specs table gives "Drag handle
|
||||
padding top/bottom | 22dp" (reference-components-a.md § Bottom sheets → Specs; raw
|
||||
`components_bottom-sheets_specs.md`). Confirmed in androidx: `SheetDefaults.kt` line 788,
|
||||
`private val DragHandleVerticalPadding get() = 22.dp`, applied as
|
||||
`modifier.padding(vertical = DragHandleVerticalPadding)` around the 32×4 handle → 4 + 22 + 22 = 48dp.
|
||||
Foundations also require a ≥48×48 touch target for any interactive element.
|
||||
- Library does: `resources/views/components/bottom-sheet.blade.php:60` — the `<button>` that is the handle
|
||||
is `class="h-1 w-8 …"` (4×32px) with no padding of its own; the padding lives on the wrapper `<div>` at
|
||||
line 59 (`py-4` = 16px, not 22px), and the wrapper is not the control. The clickable/focusable target is
|
||||
therefore 32×4px, and the whole handle region is only 36px tall.
|
||||
- Fix: move the padding onto the button: `class="h-1 w-8 box-content py-[22px] …"` (or wrap with
|
||||
`py-[22px]` and give the button `before:absolute before:inset-x-0 before:-inset-y-[22px]`), and change
|
||||
the wrapper at line 59 from `py-4` to `py-0`. Total sheet-top region becomes 48px, matching M3.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C-02 · list-item · A `disabled` item's link stays keyboard-focusable and activatable
|
||||
- Severity: must-fix
|
||||
- M3 says: disabled list items carry `ItemDisabled*Opacity = 0.38` and a disabled state layer
|
||||
(`ListTokens.kt:70,75`); foundations' states model treats disabled as "not interactive". A control that
|
||||
looks disabled but still responds to Enter is an interaction bug.
|
||||
- Library does: `resources/views/components/list-item.blade.php:50` adds only
|
||||
`'pointer-events-none text-on-surface/38' => $disabled`. `pointer-events: none` blocks the pointer but
|
||||
not the keyboard; the `<a href="…" data-list-open …>` at lines 73-78 is still rendered, still in the tab
|
||||
order and still navigates on Enter. No `aria-disabled` is emitted, so a screen reader announces the item
|
||||
as an ordinary link.
|
||||
- Fix: in `list-item.blade.php`, when `$disabled` render the title as the `<p>` branch (skip the `<a>`
|
||||
entirely, or add `tabindex="-1" aria-disabled="true"` and strip `href`), drop `data-list-row` when
|
||||
disabled, and add `aria-disabled="true"` to the row `<div>` at line 41.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C-03 · list-item · `selected` is colour-only and is never announced
|
||||
- Severity: must-fix
|
||||
- M3 says: "**Indicate selection with more than color** … don't rely on color as the only visual cue" and
|
||||
"Use two visual cues to show a list item is selected, like a leading checkmark and filled color"
|
||||
(raw `components_lists_accessibility.md:26-44`). Role mapping, same page: single-select and multi-select
|
||||
lists on **Web** → container role **List box**, item role **Option**, state **Selected / Not-selected**.
|
||||
- Library does: `list-item.blade.php:44` emits only `data-selected`; `resources/css/components/list.css:46-49`
|
||||
paints `secondary-container` / `on-secondary-container`. There is no `aria-selected`, no `aria-current`,
|
||||
no icon or checkmark, and the container stays `role="list"` / `role="listitem"`
|
||||
(`list.blade.php:19`, `list-item.blade.php:41`), where `listitem` cannot carry a selected state at all.
|
||||
- Fix: two parts. (a) Add a `selectable` (or `selection="single"|"multi"`) prop to `<x-list>` that switches
|
||||
the container to `role="listbox"` and each item to `role="option" aria-selected="true|false"`; keep
|
||||
`role="list"` as the default for non-selectable lists and use `aria-current="true"` there instead of
|
||||
nothing. (b) Render a second cue when `selected` — e.g. a trailing `check` icon in
|
||||
`on-secondary-container` — or document that callers must supply one (a `leading` checkbox/radio).
|
||||
- Effort: M
|
||||
- Breaks API? yes (a new prop; `role` on the container changes for selectable lists)
|
||||
|
||||
### C-04 · drawer · No close affordance by default, which M3 states as a requirement
|
||||
- Severity: must-fix
|
||||
- M3 says: "Material **requires** a close affordance (e.g. close icon button) to always be present —
|
||||
without one, users can't predict the sheet's open/close flow or tell if it's transient or permanent"
|
||||
(reference-components-a.md § Side sheets → Accessibility). Anatomy lists the close icon button for both
|
||||
the standard and the modal side sheet.
|
||||
- Library does: `resources/views/components/drawer.blade.php:33` — `'withCloseButton' => false`. With the
|
||||
default, a side sheet renders with a headline and no close control at all; the only exits are Escape
|
||||
(which `close-on-escape=false` removes) and the scrim (which `without-backdrop-close` removes). With both
|
||||
of those off, the sheet is undismissable.
|
||||
- Fix: flip the default to `'withCloseButton' => true` in `drawer.blade.php:33`, and make the close button
|
||||
unconditional (ignore the prop) when `closeOnEscape` is false or `withoutBackdropClose` is set. Same for
|
||||
`pane` mode, which has no scrim and, by default, no Escape.
|
||||
- Effort: S
|
||||
- Breaks API? yes (the default rendering of every existing `<x-drawer>` gains a close button)
|
||||
|
||||
### C-05 · carousel · Reduced motion still resizes items
|
||||
- Severity: must-fix
|
||||
- M3 says: "When reduced motion settings are turned on, the parallax effect should be removed and carousel
|
||||
items should no longer expand as they come into view. **All items are the same size**" (quoted verbatim
|
||||
in reference-styles.md § Motion → Accessibility requirements, from
|
||||
`components/carousel/accessibility`; also reference-components-a.md § Carousel → Accessibility).
|
||||
- Library does: `resources/js/carousel.js:966` sets `const pinned = state.reducedMotion.matches` and then
|
||||
uses it only for the content pin (`const pin = pinned ? … : 0`, line 979). The mask itself
|
||||
(`const inset = clamp((size - keyline.size) / 2, 0, size / 2)`, line 978) is still written to
|
||||
`--material-carousel-inset` on every frame, so items keep growing and shrinking between keylines — the
|
||||
exact behaviour M3 says to switch off. Only the parallax half of the rule is honoured.
|
||||
- Fix: in `render()` (carousel.js ~line 966-995), when `state.reducedMotion.matches`, write `inset = 0`
|
||||
(and `shift = 0`, `opacity = 1`) for every item so all items stay at `strategy.itemSize`; keep the
|
||||
keyline maths for snap positions. M3's extra note for hero under reduced motion — "the small item shows
|
||||
only partially" — then falls out of the scroll position rather than the mask.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### C-06 · modal · A scrolling dialog scrolls its headline and its action row away
|
||||
- Severity: should-fix
|
||||
- M3 says: "Scrolling: dialog content generally shouldn't scroll; if it must, **the title stays pinned at
|
||||
top and buttons pinned at bottom**, and the dialog never scrolls with background content"
|
||||
(reference-components-a.md § Dialogs → Behaviour and guidelines).
|
||||
- Library does: `resources/views/components/modal.blade.php:66` puts `overflow-y-auto` on the outer box,
|
||||
which contains the header block (lines 81-100), the body (line 102) and the actions (lines 105-112). The
|
||||
body wrapper at line 80 is `min-h-0 flex-1` with no overflow of its own, so once the content exceeds
|
||||
`max-h-[calc(100dvh-3rem)]` everything scrolls together. Only the `fullscreen` branch pins correctly
|
||||
(`max-sm:overflow-y-auto` on the inner div, line 80).
|
||||
- Fix: in `modal.blade.php`, remove `overflow-y-auto` from line 66 (keep `overflow-hidden`), give the
|
||||
header block `shrink-0`, and move `overflow-y-auto` onto the `min-h-0 flex-1` wrapper at line 80 for all
|
||||
cases (not just `max-sm:`). The actions already have `shrink-0`. Move `p-6` from the box to the three
|
||||
regions so the pinned header/footer keep their 24dp padding.
|
||||
- Effort: S
|
||||
- Breaks API? no (`box-class` callers that relied on the outer scroll would change)
|
||||
|
||||
### C-07 · drawer · The list-detail pane opens at 1280px; M3 puts two panes from 840px
|
||||
- Severity: should-fix
|
||||
- M3 says: list-detail visible panes — "Compact (0–599): 1 pane; Medium (600–839): 1 (recommended) or 2;
|
||||
**Expanded (840+): 2**; Large (1200–1599): 2; Extra-large (1600+): 2"
|
||||
(reference-foundations-supplement.md § Canonical layout examples → List-detail).
|
||||
- Library does: `drawer.blade.php:55` (`window.matchMedia('(min-width: 80rem)')`) and the `xl:` classes at
|
||||
lines 64-65 and 100 — the pane appears only from 1280px. Between 840 and 1279px (the whole expanded class
|
||||
and most of large) the detail still opens as a modal sheet over a scrim with the list inert, which is
|
||||
M3's compact behaviour.
|
||||
- Fix: change `80rem` to `52.5rem` (840px) in `drawer.blade.php:55` and swap the `xl:` prefixes for a
|
||||
custom `expanded:` variant defined as `@custom-variant expanded (@media (min-width: 52.5rem))` in
|
||||
`resources/css/tokens/theme.css`; update `SKILL.md:463` and the drawer header comment. If that is too
|
||||
aggressive for narrow laptop layouts, make the threshold a prop (`pane-from`) with 840 as the default.
|
||||
- Effort: M
|
||||
- Breaks API? yes (pages laid out with `xl:flex xl:items-start xl:gap-6` around the drawer would need the
|
||||
same breakpoint changed; that wrapper is documented in `SKILL.md:463`)
|
||||
|
||||
### C-08 · list.css · Segmented list rows lose their hover and press state layer to the cascade
|
||||
- Severity: should-fix
|
||||
- M3 says: list items show Hovered / Focused / Pressed / Dragged states (reference-components-a.md § Lists
|
||||
→ Specs), and "Cursor: hover shows a visible cue that the item is interactive" (§ Accessibility). State
|
||||
layer opacities: hover 8%, focus 10%, pressed 10% (reference-foundations, states).
|
||||
- Library does: `resources/css/components/list.css:42-44` sets
|
||||
`[data-list='segmented'] > [data-list-item] { background-color: var(--md-sys-color-surface-container); }`
|
||||
**unlayered**, while the hover (line 22-24), focus (27-31) and press (33-35) rules live inside
|
||||
`@layer components`. `resources/css/material.css` imports `list.css` without wrapping it, so the
|
||||
unlayered declaration wins over every layered one regardless of the `:where()` specificity — a segmented
|
||||
row gets no background state layer on hover or press. Only the corner morph (lines 93-100, unlayered)
|
||||
and the focus outline (a different property) still show.
|
||||
- Fix: move the `[data-list='segmented'] > [data-list-item]` background rule (lines 42-44, a duplicate of
|
||||
the selector at 79-81) into `@layer components` alongside the state rules, or paint the state layer with
|
||||
a `::before`/`background-image` instead of `background-color` so the two never collide.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C-09 · bottom-sheet · Drag handle colour is on-surface-variant at 40%, not the role colour
|
||||
- Severity: should-fix
|
||||
- M3 says: "drag handle = On surface variant" (specs page colour roles, reference-components-a.md §
|
||||
Bottom sheets). `SheetBottomTokens.kt:30` — `DockedDragHandleColor get() = ColorSchemeKeyTokens.OnSurfaceVariant`;
|
||||
`SheetDefaults.kt:576` uses it undiluted (`color: Color = SheetBottomTokens.DockedDragHandleColor.value`),
|
||||
with no opacity multiplier.
|
||||
- Library does: `bottom-sheet.blade.php:60` — `bg-on-surface-variant/40`. The component's own header comment
|
||||
(line 10) says "a 32×4px drag handle in on-surface-variant", so the code contradicts its own doc.
|
||||
- Fix: `bg-on-surface-variant` in `bottom-sheet.blade.php:60`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C-10 · drawer · Bottom actions are right-aligned; the side-sheet spec says left
|
||||
- Severity: should-fix
|
||||
- M3 says: side sheet specs table (both standard and modal): "Bottom actions alignment (horizontal) |
|
||||
**Left**"; "Bottom actions height 72dp; top padding 16dp; bottom padding 24dp"
|
||||
(raw `components_side-sheets_specs.md:125` and `:199`).
|
||||
- Library does: `drawer.blade.php:135` — `flex shrink-0 flex-wrap items-center justify-end gap-2 pt-6`
|
||||
(right-aligned, 24px top padding, no bottom padding of its own beyond the sheet's `p-6`).
|
||||
- Fix: `justify-start` in `drawer.blade.php:135`, and `pt-4 pb-0` inside a 72px-min row
|
||||
(`min-h-18 pt-4`) to match 16/24/72. Note this deliberately differs from the dialog, whose actions are
|
||||
trailing-aligned — M3 specifies them differently.
|
||||
- Effort: S
|
||||
- Breaks API? yes (visual position of every existing drawer action row)
|
||||
|
||||
### C-11 · carousel · The full-screen layout scrolls horizontally; M3's scrolls vertically
|
||||
- Severity: should-fix
|
||||
- M3 says: "The full-screen carousel layout shows one edge-to-edge large item at a time and **scrolls
|
||||
vertically**"; "This layout works best with content that is taller than it is wide, and scrolls
|
||||
vertically. It only works in **portrait orientation in compact and medium breakpoints**. Don't use this
|
||||
layout in landscape orientation." (raw `components_carousel_guidelines.md:31,177-183`). Specs table:
|
||||
full-screen padding 0dp all round, 16dp between elements, edge-to-edge with no item radius.
|
||||
- Library does: `carousel.blade.php:127-131` renders every layout, full-screen included, as a horizontal
|
||||
`overflow-x-auto` row; `carousel-item.blade.php:35` gives every item `rounded-corner-xl` and the 28px
|
||||
clip-path. The header comment (line 22-23) describes full-screen as "one item the width of the carousel
|
||||
at a time", i.e. horizontal by design.
|
||||
- Fix: either (a) add a vertical mode for `layout="full-screen"` (`flex-col`, `overflow-y-auto`,
|
||||
`snap-y snap-mandatory`, items `h-full w-full` with no corner radius, arrow keys Up/Down) and clamp it to
|
||||
compact/medium widths, or (b) rename the layout so it does not claim to be M3's full-screen carousel and
|
||||
say so in `SKILL.md:471`. Also drop the 28px corner and the 8px gap for this layout (M3: edge-to-edge,
|
||||
16dp between elements).
|
||||
- Effort: L
|
||||
- Breaks API? yes if renamed; no for (a) if `layout="full-screen"` keeps its name
|
||||
|
||||
### C-12 · carousel · Leading/trailing padding defaults to 0 where M3 specifies 16dp
|
||||
- Severity: should-fix
|
||||
- M3 says: specs table — Multi-browse / Hero / Center-aligned hero: "Leading/trailing padding **16dp**,
|
||||
Top/bottom padding **8dp**, Padding between elements 8dp"; Uncontained: "16dp (leading only)"
|
||||
(reference-components-a.md § Carousel → Specs).
|
||||
- Library does: `carousel.blade.php:55` — `'padding' => 0`; the scroller at lines 127-131 has no vertical
|
||||
padding at all. Every showcase example (`resources/views/showcase/sections/carousel.blade.php`) therefore
|
||||
renders with 0 end padding, so items sit flush against the container edge.
|
||||
- Fix: default `'padding' => 16` in `carousel.blade.php:55` (and pass leading-only for `uncontained`, 0 for
|
||||
`full-screen`); add `py-2` to the scroller class list at line 128. Update `SKILL.md:471` where it
|
||||
documents "`padding` (px at the ends, 0)".
|
||||
- Effort: S
|
||||
- Breaks API? no (a default value changes; explicit `padding="0"` still works)
|
||||
|
||||
### C-13 · card · No elevation change on hover, focus, press or drag
|
||||
- Severity: should-fix
|
||||
- M3 says: per-state elevation from `ElevatedCardTokens.kt` / `FilledCardTokens.kt` / `OutlinedCardTokens.kt`
|
||||
(reference-components-a.md § Cards → cross-check table): elevated 1dp rest → **3dp hover** → 1dp
|
||||
focus/pressed → **8dp dragged**; filled 0 → **1dp hover** → 0 → **6dp dragged**; outlined 0 → **1dp
|
||||
hover** → 0 → 6dp dragged. The specs page shows Hovered / Focused / Pressed / Dragged / Disabled for all
|
||||
three variants (raw `components_cards_specs.md:78,128,180`).
|
||||
- Library does: `card.blade.php:34-36` sets one elevation and never changes it
|
||||
(`shadow-elevation-1` for elevated, none for filled/outlined). The only interactive response is in
|
||||
`resources/css/components/list.css:52-72`, and that is a state layer plus a corner morph — no
|
||||
`box-shadow` level change. There is no dragged state anywhere in the group.
|
||||
- Fix: add hover/focus elevation to `card.blade.php` for interactive cards — e.g. in `list.css`
|
||||
`[data-card][data-list-row]:hover { box-shadow: var(--md-sys-elevation-2), inset … }` for the elevated
|
||||
variant and `var(--md-sys-elevation-1)` for filled/outlined. Needs a variant marker on the element
|
||||
(`data-card="elevated|filled|outlined"` instead of the bare `data-card` at `card.blade.php:31`).
|
||||
- Effort: M
|
||||
- Breaks API? no (`data-card` gains a value; the bare attribute selector still matches)
|
||||
|
||||
### C-14 · list-item · Leading/trailing gap is 12px where M3 uses 16dp
|
||||
- Severity: should-fix
|
||||
- M3 says: `ListItem.kt` (androidx-main) lines 1269 and 1273 —
|
||||
`internal val LeadingContentEndPadding = 16.dp`, `internal val TrailingContentStartPadding = 16.dp`;
|
||||
`ListTokens.kt:171,338` — `ItemLeadingSpace = 16.dp`, `ItemTrailingSpace = 16.dp`.
|
||||
- Library does: `list-item.blade.php:46` — `flex items-center gap-3 px-4` (12px gap). The container padding
|
||||
(`px-4` = 16px) is correct; only the internal gaps are short.
|
||||
- Fix: `gap-4` in `list-item.blade.php:46`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C-15 · list-item · Three-line items are middle-aligned; M3 top-aligns them
|
||||
- Severity: should-fix
|
||||
- M3 says: "Alignment: elements are middle-aligned by default; **top-aligned if the item is 88dp+ or has 3+
|
||||
lines of text**" (reference-components-a.md § Lists → Specs, from the overview page). Compose confirms
|
||||
the vertical padding also changes: `ListItem.kt:1259,1261` —
|
||||
`ListItemVerticalPadding = 8.dp`, `ListItemThreeLineVerticalPadding = 12.dp`.
|
||||
- Library does: `list-item.blade.php:46` — `items-center` for every case; padding is `py-2` (8px) for one
|
||||
line and `py-2.5` (10px) for both two- and three-line items (lines 47-49), where M3 wants 8 and 12.
|
||||
- Fix: in `list-item.blade.php:45-51`, add `'items-start' => $lines === 2` (alongside `items-center` for
|
||||
the others) and change `'min-h-22 py-2.5' => $lines === 2` to `'min-h-22 py-3' => $lines === 2`; leave
|
||||
`py-2` for `$lines === 0` and use `py-2` for `$lines === 1` too.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C-16 · list · Dividers between items run edge to edge; the list token insets them 16dp
|
||||
- Severity: should-fix
|
||||
- M3 says: `ListTokens.kt:30,36` — `DividerLeadingSpace = 16.dp`, `DividerTrailingSpace = 16.dp` (top/bottom
|
||||
space 0). Guidelines: inset dividers "separate related content within one section (e.g. emails in a
|
||||
list); indented equally from both sides by default; pair with anchoring elements like icons/avatars"
|
||||
(reference-components-a.md § Divider).
|
||||
- Library does: `list.blade.php:25` — `'divide-y divide-outline-variant' => $dividers && ! $segmented`,
|
||||
which draws a full-bleed 1px rule between items. `<x-divider>` has `inset` and `middle` props, but the
|
||||
list never uses them and there is no way to ask for an inset list divider.
|
||||
- Fix: in `list.blade.php`, render the divide with a 16px inset — e.g. add
|
||||
`[&>[data-list-item]:not(:last-child)]:after` or keep `divide-y` and add `mx-4` to the rule via a small
|
||||
CSS block in `resources/css/components/list.css`: `[data-list='plain'][data-dividers] > [data-list-item]:not(:last-child) { box-shadow: inset 0 -1px 0 … }` inset by 16px. Alternatively give `<x-list>` a
|
||||
`dividers="full"|"inset"|"middle"` value instead of a boolean.
|
||||
- Effort: M
|
||||
- Breaks API? no if `dividers` stays boolean-compatible
|
||||
|
||||
### C-17 · modal · Full-screen dialog header is 64px where M3 specifies 56dp
|
||||
- Severity: should-fix
|
||||
- M3 says: full-screen dialog specs table — "Header height | **56dp**"; "Bottom action bar height | 56dp";
|
||||
"Top/left/right padding 24dp" (reference-components-a.md § Dialogs → Specs).
|
||||
- Library does: `modal.blade.php:71` — `flex h-16 shrink-0 items-center gap-1 px-1 sm:hidden` (64px), and
|
||||
the bottom action bar at line 108 is `max-sm:px-6 max-sm:py-4` around a 40px button ≈ 72px.
|
||||
- Fix: `h-14` (56px) at `modal.blade.php:71`; `max-sm:min-h-14 max-sm:py-2` at line 108. Keep `px-1` on the
|
||||
bar so the close icon button's 48px target still reaches the 24dp text margin (M3 aligns the header
|
||||
headline to 24dp from the edge; the icon button's own padding supplies it).
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C-18 · carousel · Tab lands on the container, which M3 explicitly tells you not to do
|
||||
- Severity: should-fix (two readings — see below)
|
||||
- M3 says: "When navigating to a carousel using assistive technology, use **Tab** to place initial focus on
|
||||
the **first carousel item**"; and the caption under the Don't image: "**Avoid focusing on the carousel
|
||||
container**" (raw `components_carousel_accessibility.md:130,146`). Keyboard table: "Tab or Arrows — Moves
|
||||
to the previous or next carousel item; Space or Enter — Activates the focused carousel item".
|
||||
- Library does: `carousel.blade.php:123-126` gives the scroller `role="region"`,
|
||||
`aria-roledescription="carousel"` and **`tabindex="0"`**; the items (`carousel-item.blade.php:23-32`) are
|
||||
`role="group"` with no `tabindex`, so they are never focusable and Space/Enter cannot activate one. The
|
||||
arrow keys only work while the container itself has focus (`carousel.js:1065-1068` returns early unless
|
||||
`event.target === this.$refs.scroller`). The header comment (lines 37-43) says this is deliberate:
|
||||
"WAI-ARIA's carousel pattern: the row is a focusable `region`".
|
||||
- Both readings: the library's choice satisfies WCAG 2.1.1 for a scrollable region whose content may be
|
||||
non-focusable (an `<img>`-only slide), which is the ARIA-APG "scrollable region" practice; M3's rule
|
||||
assumes every item is itself an actionable target. They conflict; M3's is the stated rule here.
|
||||
- Fix: give each `<x-carousel-item>` `tabindex="0"` and handle Arrow/Home/End/Space/Enter on the focused
|
||||
item (roving tabindex), keeping the container out of the tab order — or, if the current pattern is kept,
|
||||
record it in the "deliberate deviations" of `SKILL.md:471` with the M3 quote so a reviewer does not
|
||||
re-litigate it.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### C-19 · bottom-sheet · Default height is 90dvh; M3 caps a modal sheet's initial position at 50%
|
||||
- Severity: should-fix
|
||||
- M3 says: "Modal: … Initial vertical position is **capped at 50% of screen height**; if content exceeds
|
||||
that, it can be pulled to full screen and scrolled internally." Specs table: "Top margin 72dp; Top margin
|
||||
(window width > 640dp) 56dp; Start/end margin (window width > 640dp) 56dp"
|
||||
(reference-components-a.md § Bottom sheets).
|
||||
- Library does: `bottom-sheet.blade.php:17` — `'height' => '90dvh'`, applied as
|
||||
`max-h-(--sheet-max-height)` (line 55). There is one height and no preset-height cycling, so a sheet may
|
||||
open at 90% of the viewport where M3 would open at 50% and let the user pull it up. The 56dp side margin
|
||||
above 640px is also absent (`mx-auto … max-w-160`, line 55, with no horizontal margin).
|
||||
- Fix: default `'height' => '50dvh'` with a `max-height` ceiling of `calc(100dvh - 72px)` in
|
||||
`bottom-sheet.blade.php:17,55`, and add `sm:px-14` (56px) to the wrapper or `sm:max-w-[calc(100vw-7rem)]`.
|
||||
A second preset height, cycled by the drag handle's click, would complete M3's "selecting the drag handle
|
||||
toggles preset heights" rule; today the click only closes.
|
||||
- Effort: M
|
||||
- Breaks API? yes (default sheet height changes)
|
||||
|
||||
### C-20 · modal · No `role="alertdialog"` on a basic dialog
|
||||
- Severity: should-fix (two readings)
|
||||
- M3 says: "On web, basic dialogs should have the **alert dialog** role"; "Basic dialogs are known as alert
|
||||
dialogs on web" (raw `components_dialogs_accessibility.md:130,134`).
|
||||
- Library does: `modal.blade.php:41` uses a native `<dialog>` opened with `showModal()`
|
||||
(line 51), which the browser maps to `role="dialog"` + `aria-modal="true"`. No `role` is set, and no
|
||||
`aria-describedby` points at the supporting text (line 93).
|
||||
- Both readings: ARIA-APG restricts `alertdialog` to dialogs that "interrupt … to communicate an important
|
||||
message" and requires an `aria-describedby` message; applying it to every `<x-modal>` (including forms,
|
||||
which the `fullscreen` variant is explicitly for) would over-announce. A middle path matches both.
|
||||
- Fix: add an `alert` boolean prop to `modal.blade.php` that sets `role="alertdialog"` plus
|
||||
`aria-describedby="{{ $id }}-body"`, and use it in the showcase's destructive-confirmation example
|
||||
(`showcase/sections/containment.blade.php:73`). Add `aria-describedby` for the subtitle unconditionally.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C-21 · list · Segmented items use `surface-container`; the token says `Surface`
|
||||
- Severity: should-fix
|
||||
- M3 says: `ListTokens.kt:201` — `ItemSegmentedContainerColor get() = ColorSchemeKeyTokens.Surface`
|
||||
(`ItemContainerColor` is also `Surface`). `SegmentedGap = 2.0.dp` (line 353).
|
||||
- Library does: `resources/css/components/list.css:43` and `:79-81` —
|
||||
`background-color: var(--md-sys-color-surface-container)`. The 2px gap (`list.blade.php:24`,
|
||||
`gap-0.5`) and the 4px/16px corner morph are right; only the fill is a tone off.
|
||||
- Fix: `var(--md-sys-color-surface)` in `list.css:43`. Note this only reads as "segmented" when the page
|
||||
behind it is a container tone; if the library prefers the stronger tone, say so in the file comment,
|
||||
because the header currently claims "each item its own surface-container tile … (ListTokens)".
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C-22 · list.css · A focused segmented row does not morph to the large corner
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Interaction-state expressive shapes: hovered = Medium (12dp); **focused**/pressed/dragged/
|
||||
selected-any-state = Large (16dp)" (reference-components-a.md § Lists → Specs, cross-checked against
|
||||
`ListTokens.kt` — `ItemPressedContainerExpressiveShape` = CornerLarge,
|
||||
`ItemDraggedContainerExpressiveShape` = CornerLarge).
|
||||
- Library does: `resources/css/components/list.css:93-100` covers `:hover` (→ md) and
|
||||
`:is([data-selected], [data-list-row]:active)` (→ lg). `:focus-visible` is not in either selector, so a
|
||||
keyboard-focused segmented row keeps the 4px corner.
|
||||
- Fix: extend the selector at `list.css:99` to
|
||||
`[data-list='segmented'] > [data-list-item]:is([data-selected], [data-list-row]:active, [data-list-row]:has([data-list-open]:focus-visible))`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C-23 · card · The corner morphs 12→16 on hover, which M3 cards do not do
|
||||
- Severity: nice-to-have
|
||||
- M3 says: cards have one shape — "Shape | 12dp corner radius" for all three variants, with no press or
|
||||
hover shape listed in `ElevatedCardTokens.kt` / `FilledCardTokens.kt` / `OutlinedCardTokens.kt`
|
||||
(reference-components-a.md § Cards → Specs). Shape morph is specified for buttons, FABs and list items,
|
||||
not cards.
|
||||
- Library does: `resources/css/components/list.css:59-62` —
|
||||
`[data-card][data-list-row]:hover { border-radius: var(--md-sys-shape-corner-lg); … }`. The
|
||||
`card.blade.php` header (lines 10-14) documents this: "It answers with a state layer and its corner
|
||||
opening a step."
|
||||
- Fix: either drop the `border-radius` line at `list.css:60` (keeping the state layer, which M3 does
|
||||
require for a directly-actionable card) or keep it and note in the card header that this is an Expressive
|
||||
extension M3 does not specify for cards. The state layer itself is correct and should stay.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C-24 · collapse · `interpolate-size` is set but nothing animates the height
|
||||
- Severity: nice-to-have
|
||||
- M3 says: n/a — collapse is not an M3 component; foundations require only that motion respect reduced
|
||||
motion, which the token system already does (`resources/css/tokens/motion.css:63-75`).
|
||||
- Library does: `collapse.blade.php:49` adds `[interpolate-size:allow-keywords]` and the header comment
|
||||
(lines 7-8) promises "where the browser supports animating `details` content (`interpolate-size`) — a
|
||||
height that eases open". There is no `transition: height` and no `::details-content` rule anywhere in
|
||||
`resources/css/` (grepped: `interpolate-size` and `details-content` appear only in this file), so the
|
||||
content snaps open. `interpolate-size` on its own changes nothing.
|
||||
- Fix: add to a component stylesheet:
|
||||
`details.group\/collapse::details-content { block-size: 0; overflow: hidden; transition: block-size var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast), content-visibility var(--md-sys-motion-spatial-fast-duration) allow-discrete; }`
|
||||
and `details[open].group\/collapse::details-content { block-size: auto; }` — or delete the
|
||||
`interpolate-size` utility and the sentence in the header comment.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C-25 · carousel-item · The overlay label is `text-white`, outside the role set
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Layering text/icons on images is **not recommended**; if necessary, add a translucent scrim or
|
||||
a bounding shape behind the text/icon to guarantee accessible contrast" (reference-components-a.md §
|
||||
Cards → Behaviour; the same rule is the reason the carousel's item content is art). Colour must come from
|
||||
a scheme role.
|
||||
- Library does: `carousel-item.blade.php:46` — `type-title-md text-white` over
|
||||
`bg-linear-to-t from-scrim/60` (line 44). The scrim is right; `white` is a literal, not a role, so it
|
||||
does not follow a scheme or a high-contrast profile.
|
||||
- Fix: `text-inverse-on-surface` (which is near-white in a light scheme and dark in a dark one — check the
|
||||
intent) or add an explicit `--md-sys-color-on-scrim` style token if a fixed light-on-dark is wanted for
|
||||
both schemes; a literal `white` is defensible over a 60% black scrim but should be stated as such in the
|
||||
file comment.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C-26 · list-item · Leading icon stays 24px in a segmented (expressive) list
|
||||
- Severity: nice-to-have
|
||||
- M3 says: `ListTokens.kt:153,156,332,335` — `ItemLeadingIconExpressiveSize = 20.dp` /
|
||||
`ItemLeadingIconSize = 24.dp`; `ItemTrailingIconExpressiveSize = 20.dp` / `ItemTrailingIconSize = 24.dp`.
|
||||
The library's segmented list is explicitly the expressive variant (`list.blade.php:4-6`).
|
||||
- Library does: `list-item.blade.php:64` and `:97` — `size-6` (24px) in both cases, regardless of the
|
||||
parent list's mode.
|
||||
- Fix: the item does not know its parent, so use CSS:
|
||||
`[data-list='segmented'] > [data-list-item] svg { width: 20px; height: 20px }` in
|
||||
`resources/css/components/list.css`, scoped to the leading/trailing icons only.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C-27 · mail · The card corner is 24px, a value not on M3's shape scale
|
||||
- Severity: nice-to-have
|
||||
- M3 says: the corner scale is 0 / 4 / 8 / 12 / 16 / 20 / 28 / 32 / 48 (reference-styles.md § Shape; the
|
||||
library's own `resources/css/tokens/shape.css:11-20` reproduces it). 24 is not a step.
|
||||
- Library does: `resources/views/mail/theme.blade.php` `.inner-body { border-radius: 24px; }`; the panel
|
||||
uses `16px` (on-scale) and `code` uses `4px` (on-scale).
|
||||
- Fix: `border-radius: 28px` (extra-large) on `.inner-body`, matching the dialog/bottom-sheet surface tone
|
||||
this card stands in for. Everything else in the theme is on-scale.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C-28 · mail · The header app name is title-large at weight 500, not 400
|
||||
- Severity: nice-to-have
|
||||
- M3 says: title-large = weight 400 (Regular), 22/28, tracking 0 — the library's own
|
||||
`resources/css/tokens/type.css:39-41` has `--md-sys-typescale-title-lg` at regular and
|
||||
`--md-sys-typescale-emphasized-title-lg` at medium.
|
||||
- Library does: `resources/views/mail/theme.blade.php` `.header a { font-size: 22px; font-weight: 500; … }`,
|
||||
while the section comment says "the app name in title-lg". `h2` right below it uses 22/28 at weight 400,
|
||||
so the two disagree within the same file.
|
||||
- Fix: either set `font-weight: 400` on `.header a`, or change the comment to say
|
||||
title-large-**emphasized** — which is the more likely intent for a brand line and is a real M3 role.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
## Deliberate deviations
|
||||
|
||||
- **`card.blade.php:16-17` — "Do not pass a `bg-*` class … it races the card's own in Tailwind's emit
|
||||
order."** True and correctly explained; the CSS in `list.css` is unlayered for the same reason
|
||||
(`list.css:10-11`). Holds up, and is the cause of C-08 (the same unlayered trick applied to the segmented
|
||||
background, where it is not needed).
|
||||
- **`card.blade.php:10-14` / `list-rows.js` — the `data-list-row` + `data-list-open` contract instead of a
|
||||
stretched link or a wrapping `<a>`.** M3's accessibility page for cards says exactly this: "on a
|
||||
**directly actionable card** Tab moves to the next card container; on a **non-actionable card with
|
||||
actionable elements**, Tab moves through each actionable element inside before moving to the next card"
|
||||
and forbids stacking an action on an already-actionable surface. The library implements the second case
|
||||
with a single real control. Holds up, and is a better answer than M3 gives for the web.
|
||||
- **`drawer.blade.php:9-11` — "it enters on emphasized decelerate rather than a spring — a sheet anchored
|
||||
to the edge that overshot would open a gap."** M3 Expressive's motion scheme is springs for spatial
|
||||
change, but `styles/motion` keeps the emphasized-decelerate curve for exactly this kind of entrance and
|
||||
the reference records no rule against it. Holds up.
|
||||
- **`modal.blade.php:24` — "It opens on the fast spatial spring and closes at once, as M3's do."** The
|
||||
first half is fine; the second is not supported by Google's text, which says a dialog "appears via an
|
||||
**enter/exit** transition" (reference-components-a.md § Dialogs → Behaviour). Native `<dialog>` makes an
|
||||
exit transition awkward (`@starting-style` only covers entry), which is the real reason — worth saying so
|
||||
instead. Folded into no finding of its own; it is a doc accuracy point.
|
||||
- **`list.blade.php:8` — `role="list"` rather than `listbox`.** Correct for a plain list of links; it is
|
||||
only wrong once `selected` is used, which is C-03.
|
||||
- **`carousel.blade.php:37-43` — the WAI-ARIA carousel pattern rather than M3's "focus the first item".**
|
||||
See C-18; a genuine standards conflict, but M3's Don't is explicit and is not acknowledged in the code.
|
||||
- **`mail/theme.blade.php:9-26` — hexes only, light only, no `@media`, no elevation.** Every reason given
|
||||
is correct (CssToInlineStyles' `doCleanup()` does strip `@media`; Outlook does drop alpha). Accepted by
|
||||
the brief and by the constraints.
|
||||
- **`collapse.blade.php:1-2` — "Not an M3 component; built on the native `<details>`."** Correct; M3 has
|
||||
expandable list items and menu expansion but no standalone disclosure. Judged against foundations it is
|
||||
sound: 48px summary (`min-h-12`), the 8/10% state layer, the 3px secondary focus ring, reduced motion via
|
||||
the duration tokens, and the native `aria-expanded`.
|
||||
|
||||
## Aligned
|
||||
|
||||
- **Card**: filled `surface-container-highest`, elevated `surface-container-low` + elevation 1, outlined
|
||||
`surface` + 1px `outline-variant` — all three match the specs page and the token files. `rounded-corner-md`
|
||||
= 12dp; `p-4` = the 16dp left/right padding; `overflow-hidden` + a full-bleed `figure` slot is the media
|
||||
anatomy. (Card typography is not specified by M3 at all — the specs page lists no type roles — so
|
||||
`type-title-md` / `type-body-md` cannot be marked wrong.)
|
||||
- **List heights and leading sizes**: `min-h-14` / `min-h-18` / `min-h-22` = 56 / 72 / 88dp exactly
|
||||
(`ListTokens.kt:180,323,347`); avatar `size-10` = 40dp (`ItemLeadingAvatarSize`), image `size-14` = 56dp
|
||||
(`ItemLeadingImageWidth`) with `rounded-corner-sm` = the expressive `CornerSmall`; icon `size-6` = 24dp
|
||||
(baseline `ItemLeadingIconSize`); `px-4` = 16dp container padding.
|
||||
- **List type roles**: title `type-body-lg` (`ItemLabelTextFont` = BodyLarge), description `type-body-md`
|
||||
(`ItemSupportingTextFont` = BodyMedium), overline and trailing text `type-label-sm`
|
||||
(`ItemOverlineFont` / `ItemTrailingSupportingTextFont` = LabelSmall), `line-clamp-2` on the description.
|
||||
- **Selected colours**: `secondary-container` / `on-secondary-container` is right —
|
||||
`ListTokens.kt:204,281,284` (`ItemSelectedContainerColor` = SecondaryContainer,
|
||||
`ItemSelectedLabelTextColor` / `ItemSelectedLeadingIconColor` = OnSecondaryContainer). (The reference's
|
||||
specs-page note about "Primary container" is contradicted by the token file; the library follows the
|
||||
tokens.) Disabled content at 38% matches `ItemDisabled*Opacity = 0.38f`.
|
||||
- **Segmented list geometry**: 2px gap = `SegmentedGap`; 4px item corner = `ItemContainerExpressiveShape`
|
||||
(CornerExtraSmall); 16px at the list's ends and while pressed/selected = `ContainerShape` /
|
||||
`ItemSelectedContainerExpressiveShape` (CornerLarge); 12px on hover = the specs page's Medium.
|
||||
- **`data-list-row` state layer**: hover 8%, focus 10%, pressed 10% on `on-surface`, hover gated behind
|
||||
`@media (hover: hover)`; the focus ring is 3px `secondary` (matching `ListTokens.kt:39`
|
||||
`FocusIndicatorColor` = Secondary); the opener's own ring is suppressed so the row shows one indicator.
|
||||
- **Divider**: 1px (`DividerTokens.Thickness`), `outline-variant`, `inset` = 16px start / 0 end,
|
||||
`middle` = 16px both, a vertical variant, `role="separator"` with `aria-orientation`, and `decorative` to
|
||||
hide it — a complete match to the specs table.
|
||||
- **Dialog**: `surface-container-high` + `rounded-corner-xl` (28dp) + `shadow-elevation-3` =
|
||||
`DialogTokens` exactly; `p-6` = 24dp all round; `max-w-[35rem]` / `min-w-70` = 560 / 280dp;
|
||||
`backdrop:bg-scrim/32` = `ScrimTokens.ContainerOpacity = 0.32f`; `type-headline-sm` =
|
||||
`DialogTokens.HeadlineFont`; `type-body-md` on-surface-variant = `SupportingTextFont`; a 24px
|
||||
`text-secondary` hero icon that centres the headline (M3: "Alignment with icon: Center-aligned");
|
||||
`gap-2` = the 8dp between buttons; `mt-4` = the 16dp title↔body and icon↔title gaps; `pt-6` = the 24dp
|
||||
body↔actions gap; `justify-end` = trailing-edge actions, and the showcase orders Cancel before Delete
|
||||
(`showcase/sections/containment.blade.php:75-76`), which is M3's "dismissive to the left of confirming".
|
||||
`wire:ignore.self` + native `showModal()` gives top-layer, inert background, focus-in/focus-return and
|
||||
Escape for free.
|
||||
- **Bottom sheet**: `surface-container-low` (`DockedContainerColor`), `rounded-t-corner-xl` (28dp top,
|
||||
`CornerExtraLargeTop`), `shadow-elevation-1` (`DockedModalContainerElevation` = Level1),
|
||||
`max-w-160` = the 640dp max width, 32×4px handle geometry, 32% scrim, `x-trap.inert.noscroll`,
|
||||
dismissal by scrim / Escape / downward drag, and a single-pointer alternative to the drag (the handle is
|
||||
a real `<button>` with an accessible name and `role=button`, which is M3's "label only the drag handle").
|
||||
- **Side sheet**: `surface-container-low`, a 16px corner on the inner edge only
|
||||
(M3's "16dp corner radius for modal side sheets"), 400px default width = the specs max-width, `p-6` =
|
||||
24dp start/end padding, end placement by default (M3: "usually the right"), `role="dialog"` (M3's stated
|
||||
role), full height, independent vertical scroll and no horizontal scroll, `x-trap.inert.noscroll`, a
|
||||
container query on the body so contents lay out by the sheet's width.
|
||||
- **Carousel**: the Compose keyline maths is ported with attribution and a commit hash; small items clamp
|
||||
to 40-56dp (`MIN_SMALL_ITEM_SIZE` / `MAX_SMALL_ITEM_SIZE`, carousel.js:64-65); 28px item corner
|
||||
(`CarouselDefaults`); 8px between items (`gap-2` = the specs' "Padding between elements 8dp"); snap for
|
||||
multi-browse / hero / full-screen and free scroll for uncontained, exactly M3's recommendation;
|
||||
per-item "n of m" labels; controls placed **below** the row, never over it (M3's explicit Don't); RTL
|
||||
mirroring; re-measure on resize and after a morph.
|
||||
- **Collapse**: 48px summary, the shared state layer and focus ring, a chevron on the fast spatial spring,
|
||||
reduced motion via zeroed duration tokens, native disclosure semantics, `wire:ignore.self` for morphs.
|
||||
- **Error pages**: `bg-surface` / `text-on-surface`, `type-emphasized-display-lg` in `on-primary-container`
|
||||
over a `primary-container` shape (a correct contrast pair), `type-headline-md`/`lg` for the headline,
|
||||
`type-body-lg` in `on-surface-variant` for the message, a filled primary action with a text secondary
|
||||
(M3's action hierarchy), 24px page gutters, and the decorative shape's rotation gated behind
|
||||
`prefers-reduced-motion: no-preference`.
|
||||
- **Mail theme**: the typescale is reproduced correctly in px — h1 24/32/400/0 (headline-small),
|
||||
h2 22/28 (title-large), h3 16/24/500/0.15 (title-medium), p 16/24/400/0.5 (body-large),
|
||||
`p.sub` and table cells 14/20/0.25 (body-medium), table head 14/20/500/0.1 (title-small),
|
||||
footer 12/16/0.4 (body-small), the button 16/24/500/0.15 (title-medium, the Expressive medium button's
|
||||
label) with a full corner. Roles are used properly throughout (`on-surface` for emphasis,
|
||||
`on-surface-variant` for body, `outline-variant` for every rule, `surface-container-lowest` for the card
|
||||
against `surface-container` for the page), and separation is by tone rather than shadow, as M3 does.
|
||||
|
||||
## Missing
|
||||
|
||||
- **Lists**: leading **video** slot (56×100dp small, 64×114dp large — `ListTokens.kt:129,132,177`); the
|
||||
**expand/collapse** list-item interaction (M3: "items containing nested items can expand/collapse …
|
||||
container-transform"); explicit **selection modes** (single-select / multi-select / single-action /
|
||||
multi-action) with their role and keyboard mappings; **inset and middle dividers** between list items
|
||||
(only a full-bleed `dividers` boolean exists); a **dragged** state (16% layer, elevation 4) for
|
||||
reorderable lists.
|
||||
- **Cards**: per-state **elevation** (C-13) and the **dragged** state; a first-class "directly actionable
|
||||
card" that takes a `button`/`link` role — M3 says such a card gets one, and the `data-list-row` pattern
|
||||
deliberately keeps the role on the inner opener instead.
|
||||
- **Dialogs**: no divider pinned between a scrolling body and the header/actions (the `separator` prop
|
||||
scrolls with the content); no 56dp edge-margin rule for custom-positioned dialogs on large screens; no
|
||||
"discard unsaved changes" confirmation helper for the full-screen variant, which M3 requires of that
|
||||
variant.
|
||||
- **Bottom sheets**: **preset heights** and the handle's "cycle through heights on activation" behaviour
|
||||
(M3 requires a non-drag alternative whenever more than one height exists); a peek/collapsed height for
|
||||
the `standard` variant; the 56dp side margin above a 640dp window; the swap to a side sheet at expanded
|
||||
widths that M3 recommends.
|
||||
- **Side sheets**: a genuine **standard** (co-planar, non-modal, 0dp elevation) variant — `pane` is close
|
||||
but is scoped to list-detail and starts at `xl`; the **back icon button** in the modal anatomy; a divider
|
||||
above the action row; the 16dp "detached" inset M3 allows.
|
||||
- **Carousel**: the **uncontained multi-aspect-ratio** layout (added November 2025 — items from 9:16 to
|
||||
16:9); a **"Show all"** affordance opening a vertical list of every item, which M3 requires on
|
||||
vertically-scrolling pages; a vertically-scrolling full-screen layout (C-11).
|
||||
- **Divider**: the divider-with-text / subheader configuration (4dp gap to the supporting text, 8dp right
|
||||
and bottom margins in the specs table).
|
||||
|
||||
## Breakpoint map
|
||||
|
||||
| Component | Library breakpoint used | M3 window size class it stands in for | Gap |
|
||||
| --- | --- | --- | --- |
|
||||
| `<x-modal fullscreen>` — full-screen below, basic above (`modal.blade.php:61,67,71,80,83,89,93,108`) | `max-sm` → < 640px | Compact (< 600dp): "full-screen dialogs are used only in compact breakpoints" | 40px too wide — a 600-639px window (small tablet portrait, split-screen) gets a full-screen dialog where M3 wants a basic one |
|
||||
| `<x-drawer pane>` — pane vs modal sheet (`drawer.blade.php:55,64,65,100`; `xl:` = 1280px) | `xl` → ≥ 1280px | Expanded (≥ 840dp): list-detail shows **2 panes** from expanded through extra-large | 440px late — the entire expanded class (840-1199) and the bottom of large get the compact single-pane modal behaviour |
|
||||
| `<x-drawer>` — fixed width and inner corner switch on (`drawer.blade.php:97-99`) | `sm` → ≥ 640px | Compact (< 600dp) is where a modal side sheet should be full-bleed; medium (600-839) onwards it has its fixed width | 40px too wide — a 600-639px window still gets a full-width sheet |
|
||||
| `<x-bottom-sheet>` — 640px cap applied at every width (`bottom-sheet.blade.php:55`, `max-w-160`) | none (a `max-width`, not a breakpoint) | Specs: "Width: full width, up to max-width 640dp"; above a 640dp window also "Start/end margin 56dp" and "Top margin 56dp" | The 640 cap is right; the 56dp side/top margins above 640dp are missing entirely |
|
||||
| `<x-modal>` — dialog gutters (`modal.blade.php:58`, `w-[calc(100vw-3rem)]`, `max-h-[calc(100dvh-3rem)]`) | none | Guidelines: custom-positioned dialogs on larger screens "must respect a 56dp margin from screen edges" | 24px used where 56dp is specified — only binding for custom-positioned dialogs, which the component does not offer, so informational |
|
||||
| `<x-carousel controls>` (`carousel.blade.php:139`, `pointer-fine:`) | input-media query, not a width | M3 gives no breakpoint for carousel controls; it only says put them above or below the row | No gap — an input-capability query is a reasonable substitute and the placement rule is followed |
|
||||
| `<x-carousel layout="full-screen">` (`carousel.blade.php:62`) | none — rendered at every width | Compact and medium only, portrait only ("Don't use this layout in landscape orientation") | Unbounded — nothing stops the layout above 840dp or in landscape |
|
||||
@@ -1,292 +0,0 @@
|
||||
# Audit: core (tokens, scheme, theme, icons, shapes, guard, showcase foundations, AI docs)
|
||||
|
||||
Ground truth: `docs/reference/m3/styles.md`, `docs/reference/m3/foundations.md`, raw pages under `m3/raw/`.
|
||||
Library paths are relative to `/Users/surtic86/Privat/Development/livewire-material`.
|
||||
|
||||
## Summary
|
||||
|
||||
The core is in good shape: every 2025-spec colour role (the `*-dim` roles and the fixed roles included) is generated and mapped, the legacy `surface-tint`/`surface-variant` are correctly left out, the corner scale is M3 Expressive's ten steps, the five shadow levels are Google's web geometry, the six springs carry the exact Compose damping/stiffness constants, the state layer uses M3's 8/10/10 % and hover is pointer-only, the typescale sizes/line heights/weights match, and theme switching honours both the OS and the visitor's choice. The 35 shapes and the full Symbols set are ported from Google's sources.
|
||||
|
||||
The gaps are around the edges of the system rather than in it. (1) The three custom state colours (`success`/`warning`/`info`) are built with the 2021 `customColor` recipe, so they ignore the `--contrast` level and the 2025 spec's tone rules that `error` follows. (2) Only the standard contrast level exists; M3 (May 2025) makes standard/medium/high part of the colour system. (3) The semantic ink utilities (`text-meta` 60 %, `text-quiet` 38 %, `border-divider` 40 %) are M2-style opacity emphasis, which M3 replaced with `on-surface-variant`/`outline-variant`, and 38 % collides with M3's disabled opacity. (4) Tailwind's own radius, shadow, type-size, weight, easing and duration utilities still compile, so an application (or an AI agent) can silently leave the M3 scale; only colours were cleared. (5) There are no tokens or variants for M3's five breakpoints, no `dragged` state, and no shared touch-target utility. (6) Type tracking follows the older material-web numbers rather than Compose's, and the emphasized styles reuse baseline tracking. (7) The AI docs list the utilities but explain none of M3's rules for choosing between them; that is the "core concept" document the user asked for.
|
||||
|
||||
Counts: must-fix 1 · should-fix 12 · nice-to-have 11.
|
||||
|
||||
## Findings
|
||||
|
||||
### C1 · scheme command · custom colours ignore the contrast level and the 2025 spec
|
||||
- Severity: must-fix
|
||||
- M3 says: "Custom components support contrast levels simply by using Material color roles… the role's resolved value changes per contrast level automatically" (reference-styles §Color/Contrast levels; site `styles/color/roles`). Aug 2024: on-container roles became "more colourful while remaining accessible"; the 2025 spec that the library asks for (`--spec=2025`) applies those curves to `error`. `define-new-colors`: custom colours should behave like the built-in roles.
|
||||
- Library does: `resources/node/scheme.mjs` (tail) builds `success`/`warning`/`info` with material-color-utilities' `customColor()` (`pe(...)`, `blend:false`): fixed tones 40/100/90/10 in light and 80/20/30/90 in dark, no contrast curve, no spec awareness. So `php artisan material:scheme --contrast=1` raises every M3 role but leaves the three state colours at standard contrast, and their `on-*-container` pairs are tone 10/90 while `on-error-container` (2025) is `#6e0523`-style tone ~25. Visible in `resources/css/tokens/scheme.css:66-77` vs `:62-65`.
|
||||
- Fix: in `bin/scheme.mjs` (source of the bundle), define the three colours as `DynamicColor`s on their own `TonalPalette` exactly as `color_spec_2025.js` defines `error`/`onError`/`errorContainer`/`onErrorContainer` (same `ContrastCurve`s and `ToneDeltaPair`s), evaluated against the same `DynamicScheme` (so `contrastLevel`, `isDark`, `specVersion`, platform all apply). Rebuild `resources/node/scheme.mjs`; regenerate `tokens/scheme.css`, `scheme.json`, the workbench scheme. Add a `SchemeCommandTest` case: contrast 1 changes `success`.
|
||||
- Effort: M
|
||||
- Breaks API? no (role names unchanged; values change)
|
||||
|
||||
### C2 · scheme · only the standard contrast level is generated
|
||||
- Severity: should-fix
|
||||
- M3 says: "Three levels of contrast: standard, medium (3:1 minimum), high (7:1)… contrast settings apply automatically in both light and dark theme" (reference-styles §Color; `styles/color/roles`, "What's new May 2025"). Accessibility principle "Honor individuals — build in customizable features" (reference-foundations §Accessibility).
|
||||
- Library does: `SchemeCommand` (`src/Console/SchemeCommand.php:24`) takes `--contrast` once and writes a single light and dark block; nothing at runtime can raise contrast; `theme.js` has no contrast state; no `@media (prefers-contrast: more)`.
|
||||
- Fix: have `material:scheme` also emit `[data-contrast="medium"]` (contrast 0.5) and `[data-contrast="high"]` (1.0) blocks for light and dark (and per profile), plus `@media (prefers-contrast: more) { :root:not([data-contrast]) {…} }` as the OS default; add `contrast` to `$store.theme` (`resources/js/theme.js`) and to the head script's attribute set (`theme-script.blade.php`, kept across `wire:navigate` like `data-theme`); `<x-theme-toggle mode="picker">` or a new `<x-contrast-picker>` exposes it. Document in SKILL.md §Theme.
|
||||
- Effort: M
|
||||
- Breaks API? no (opt-in)
|
||||
|
||||
### C3 · theme.css · M2-style opacity inks and lines instead of M3 roles
|
||||
- Severity: should-fix
|
||||
- M3 says: "Default typography color is on surface (or on surface variant as a strong alternative)"; dividers use **outline variant**, not a lighter mix; "Always apply color roles, never static hex values"; 0.38 is the **disabled** opacity (reference-foundations §States values; reference-styles §Color rules, §Typography accessibility).
|
||||
- Library does: `resources/css/tokens/theme.css:99-106` defines `--color-meta` = on-surface 60 %, `--color-quiet` = on-surface 38 %, `--color-chrome` = outline-variant 60 %, `--color-divider` = outline-variant 40 % via `color-mix()`. `text-quiet` therefore looks exactly like disabled text (`text-on-surface/38`, used 21× in components), and `border-divider` is lighter than M3's divider. None of these utilities is used by a package component (grep: only the showcase swatches and one `border-divider`/`divide-divider`), so they exist for ReStride's templates.
|
||||
- Fix: keep the names, repoint them to roles: `--color-body` → on-surface-variant (already), `--color-meta` → on-surface-variant, `--color-quiet` → outline, `--color-structure` → outline-variant (already), `--color-chrome` → outline-variant, `--color-divider` → outline-variant. Say in SKILL.md that emphasis is a role, not an opacity, and that 38 % means disabled. If ReStride needs the lighter divider, it overrides the variable in its own `@theme`.
|
||||
- Effort: S
|
||||
- Breaks API? no (names stay; rendered colour changes in ReStride)
|
||||
|
||||
### C4 · theme.css · Tailwind's default radius, shadow, type-size, weight, easing and duration utilities still compile
|
||||
- Severity: should-fix
|
||||
- M3 says: "Do use design tokens instead of hardcoded values" (reference-foundations §Design tokens); the corner scale, elevation levels, typescale and motion tokens are the only values (reference-styles §Shape, §Elevation, §Typography rule "avoid changing type size", §Motion).
|
||||
- Library does: `theme.css:19-25` clears `--color-*` only. `rounded-lg` (Tailwind: 8 px, M3 "large" is 16 px), `rounded-xl` (12 px vs 28 px), `shadow-md`, `text-sm`/`text-lg`, `font-bold`, `leading-*`, `tracking-*`, `ease-in-out`, `duration-300` all still compile with Tailwind's values. The package's own views are clean (only `rounded-full` ×4 and `rounded-none` ×1, both fine), but consuming apps and AI agents are not stopped. `DesignGuard` does not look for them.
|
||||
- Fix: two steps. (a) Now, non-breaking: `DesignGuard` gains a "value outside the M3 scale" check for `rounded-(xs|sm|md|lg|xl|2xl|3xl|4xl)`, `shadow-(2xs|xs|sm|md|lg|xl|2xl)`, `text-(xs|sm|base|lg|xl|\dxl)`, `font-(thin|…|black)`, `leading-*`, `tracking-*`, `ease-(in|out|in-out)`, `duration-\d+`, `blur-*`? (no), each with the M3 utility to use instead. (b) In the next major: `@theme { --radius-*: initial; --shadow-*: initial; --ease-*: initial; --text-*: initial; --font-weight-*: initial; --leading-*: initial; --tracking-*: initial; }` before re-declaring the M3 ones, mirroring what was done for colour (keep `--radius-full`/`rounded-none` behaviour by declaring `--radius-full: 9999px`).
|
||||
- Effort: S (guard) + S (theme, but a major-version change)
|
||||
- Breaks API? (a) no · (b) yes for applications using Tailwind defaults
|
||||
|
||||
### C5 · type.css · tracking follows material-web, not Compose; emphasized styles reuse baseline tracking
|
||||
- Severity: should-fix
|
||||
- M3 says (reference-styles §Typography, `TypeScaleTokens.kt`): Display Large −0.2 sp; Title Medium 0.2 sp; Body Medium 0.2 sp; emphasized: Display Large 0, Title Medium 0.15, Body Large 0.15, Body Medium 0.25 (others unchanged).
|
||||
- Library does: `resources/css/tokens/type.css:21` display-lg tracking −0.015625 rem (−0.25 px, material-web); `:39` title-md 0.009375 rem (0.15 px, M3: 0.2 sp = 0.0125 rem); `:48` body-md 0.015625 rem (0.25 px, M3: 0.2 sp); every `type-emphasized-*` utility (`:101` onward) uses the baseline `-tracking` variable, so emphasized display-lg keeps −0.25 px instead of 0, emphasized body-lg keeps 0.5 px instead of 0.15.
|
||||
- Fix: add `--md-sys-typescale-emphasized-*-tracking` variables with Compose's values and use them in the emphasized utilities; correct the three baseline values to Compose's (sp/16 rem). Sub-pixel, but it is what the Expressive token file says and the header comment cites material-web, which is pre-Expressive.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C6 · type.css · regular styles do not reset `font-variation-settings`
|
||||
- Severity: nice-to-have
|
||||
- M3 says: emphasized styles are applied deliberately, per element (reference-styles §Typography rules).
|
||||
- Library does: `type-emphasized-*` sets `font-variation-settings: "ROND" 100` (`type.css:107`), an inherited property; `type-*` utilities set none, so `type-body-md` text inside an element carrying `type-emphasized-title-md` (a card slot under an emphasized wrapper) renders fully rounded.
|
||||
- Fix: every regular utility sets `font-variation-settings: normal` (or `"ROND" 0`).
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C7 · font · variable axes of the subset are unverified here
|
||||
- Severity: nice-to-have (verification)
|
||||
- M3 says: brand/plain typefaces are the product's choice; Roboto is the default (reference-styles §Typography fonts). Google Sans Flex is Google's Expressive typeface (the site's shape page: "M3 shapes and Google Sans Flex share roundness attributes"), so the choice is aligned.
|
||||
- Library does: `font.css` claims weight 400–700 and `ROND` 0–100 are kept. I could not open the woff2 (no fontTools on this machine).
|
||||
- Fix: add a test (or a `bin/` check) that reads the `fvar` table and asserts the `wght` and `ROND` axes exist, so a future re-subset cannot silently drop the axis the emphasized styles depend on.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C8 · motion.css · spring durations differ from the web equivalents Google publishes
|
||||
- Severity: should-fix (with a counter-argument)
|
||||
- M3 says (`styles/motion/overview/specs`, reference-styles §Motion "Web curve equivalents"): expressive spatial fast/default/slow = 350/500/650 ms, effects = 150/200/300 ms, each with a cubic-bezier; "springs aren't natively supported on the web, so Web should use these approximated curves with matching duration".
|
||||
- Library does: `motion.css:33-52` samples the real springs into `linear()` (more faithful than Google's cubic-bezier) but stops at the settle time: 360/440/600 ms and 150/240/330 ms. Default spatial is 12 % quicker than Google's web number, slow effects 10 % slower.
|
||||
- Fix: either (a) sample each spring over Google's published web duration (the curve reaches 1 earlier and holds, so the bounce timing is unchanged) so the `*-duration` tokens read 350/500/650/150/200/300, or (b) keep the settle times and record why in the header. Also add the six published cubic-bezier curves as `--md-sys-motion-spring-*-bezier` fallbacks for `@supports not (animation-timing-function: linear(0,1))` (Safari < 17.2 is below the floor, so this is documentation more than need). (a) is "what Google says".
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C9 · motion.css · only the Expressive motion scheme exists
|
||||
- Severity: nice-to-have
|
||||
- M3 says: two preset schemes, Expressive (default) and Standard ("minimal bounce, for utilitarian products"), swappable product-wide (reference-styles §Motion principles; `StandardMotionTokens.kt`: fast spatial 0.9/1400, default 0.9/700, slow 0.9/300; effects identical).
|
||||
- Library does: six springs, Expressive only.
|
||||
- Fix: sample the three Standard spatial springs too and let `[data-motion="standard"]` on `<html>` (or a config flag rendered by the theme script) swap the `--md-sys-motion-spatial-*` variables. Effects are the same in both schemes.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C10 · state.css · no `dragged` state, no disabled tokens, no shared touch target
|
||||
- Severity: should-fix
|
||||
- M3 says: state layer opacities hover 0.08, focus 0.10, pressed 0.10, **dragged 0.16**; disabled content 0.38; "the size of state layers is 40dp while the interactive target size is 48dp"; target spacing 8 dp (reference-foundations §States, §Accessibility values).
|
||||
- Library does: `state.css:26-40` covers hover/focus/active; nothing for dragged (cards, chips, list items and the slider are the components M3 gives a dragged state); no `--md-sys-state-*` custom properties, so components hard-code `/38`, `/12`, `/10` (21, 10 and 4 occurrences); the 48 px target is built ad hoc in `button.blade.php:167` with an `after:` pseudo-element and nowhere shared.
|
||||
- Fix: in `state.css` declare `--md-sys-state-hover-opacity: 0.08`, `focus 0.10`, `pressed 0.10`, `dragged 0.16`, `disabled-content 0.38`, `disabled-container 0.12` and use them in `state-layer` (add `&[data-dragged]::before { opacity: var(--md-sys-state-dragged-opacity) }`); add a `touch-target` utility (the `after:` pseudo-element from the button, `min 48×48`) so small icon buttons, chips, checkboxes, radios and switch share one implementation.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C11 · state.css · focus ring spec has no source on the M3 site
|
||||
- Severity: nice-to-have (documentation)
|
||||
- M3 says: only "a ring-like keyboard focus indicator" (reference-foundations §States; the site defines no thickness/offset/colour — see its "Cross-cutting gaps" 1).
|
||||
- Library does: `focus-ring` = 3 px `secondary`, offset 2 px (`state.css:47-53`), which is material-web's `md-focus-ring` default (3 px, outward offset 2 px, secondary).
|
||||
- Fix: none to the code; cite material-web in the header so nobody "corrects" it to the site's silence.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C12 · core · no tokens or variants for M3's breakpoints
|
||||
- Severity: should-fix
|
||||
- M3 says: five breakpoints — compact < 600, medium 600–839, expanded 840–1199, large 1200–1599, extra-large ≥ 1600 dp — with the navigation component, pane count and dialog/menu choice per breakpoint (reference-foundations §Layout breakpoints tables).
|
||||
- Library does: nothing in the tokens; components switch on Tailwind's `sm` 640 / `md` 768 / `lg` 1024 / `xl` 1280 (the navigation audit maps each use). An application cannot write "at the medium breakpoint" at all.
|
||||
- Fix: `theme.css` adds `--breakpoint-medium: 600px; --breakpoint-expanded: 840px; --breakpoint-large: 1200px; --breakpoint-extra-large: 1600px` (Tailwind 4 turns these into `medium:`, `expanded:`, `large:`, `extra-large:` variants; `max-medium:` etc. come for free), keeps `sm/md/lg/xl` for now, and the components migrate to the M3 variants in their own findings. Add pane tokens as custom properties: `--md-sys-layout-pane-fixed: 360px` / `412px`, `--md-sys-layout-side-sheet-max: 400px`, margins 16 px compact / 24 px otherwise (older M3 layout page values; the current site gives no numeric margin table — mark as such).
|
||||
- Effort: S (tokens) — the component migration is counted in the other audits
|
||||
- Breaks API? no
|
||||
|
||||
### C13 · DesignGuard · no check for values outside the M3 scale, hex colours, or `white`/`black`
|
||||
- Severity: should-fix
|
||||
- M3 says: "Always apply color roles, never static hex values or raw tonal-palette values" (reference-styles §Color rules).
|
||||
- Library does: `src/Testing/DesignGuard.php:26-33` flags the Tailwind palette and daisyUI colours; it does not flag arbitrary values (`bg-[#1d7afc]`, `text-[rgb(…)]`), `bg-white`/`text-black` (re-added in `theme.css:21-22` and not M3 roles; the M3 white is `surface-container-lowest` in light), or any of the non-token utilities in C4.
|
||||
- Fix: add patterns for `(bg|text|border|…)-\[#`, `-\[rgb`, `-\[hsl`, `-\[oklch`, and an opt-in `forbidAbsolutes()` for `white`/`black`; add the C4 list with a hint per match ("`rounded-lg` → `rounded-corner-lg`").
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C14 · theme-toggle · the picker is drawn as M3's deprecated segmented button
|
||||
- Severity: should-fix
|
||||
- M3 says: segmented buttons are "M3 only", replaced in M3 Expressive by the connected button group (reference-components-a §Segmented buttons / §Button groups; the plan doc lists segmented button among the six deprecated components).
|
||||
- Library does: `resources/views/components/theme-toggle.blade.php:27-49` (`mode="picker"`) draws an outlined 40 px group with `border-outline` and `aria-checked` fills — the segmented-button anatomy — while `<x-group>` (the connected button group) already exists.
|
||||
- Fix: render the picker with `<x-livewire-material::group>` (three `wire:model`-less radio options bound to `$store.theme`), or reuse its classes; keep `role="radiogroup"` and the arrow-key behaviour.
|
||||
- Effort: S
|
||||
- Breaks API? no (`data-theme-option` hooks can stay)
|
||||
|
||||
### C15 · scheme · harmonisation of the custom colours is not offered
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "you can choose to harmonize your static colors to the scheme's primary color… while retaining the semantic meaning"; "colors can stay completely static and forgo harmonization if their values are tied to literal sources such as brand colors" (`styles/color/advanced/define-new-colors`).
|
||||
- Library does: `customColor(..., blend:false)` always (scheme.mjs tail); no option.
|
||||
- Fix: `--harmonize` flag on `material:scheme` (and `harmonize` per profile) passed as `blend:true`; default off, as today. Do together with C1.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C16 · icons · one weight/grade/optical size for every context
|
||||
- Severity: nice-to-have
|
||||
- M3 says: grade −25 "for a light icon on dark background"; optical size 20 for 20 dp icons in dense/desktop layouts; 40/48 for display pairing (reference-styles §Icons axes table).
|
||||
- Library does: Material Symbols Rounded 400/0/24 only (`bin/fetch-symbols`, `icon.blade.php` header); buttons `xs`/`sm` draw 20 px icons from the 24-opsz outlines (`button.blade.php:147-149`), so strokes are ~17 % thinner than a true opsz-20 glyph; dark theme keeps grade 0.
|
||||
- Fix: none that is cheap — each extra cut is another 4,135 SVGs (~2.6 MB). Record the trade-off in the icon header and SKILL.md; if ever needed, ship `opsz20` for the outlined set only and let `<x-icon size="20">` pick it.
|
||||
- Effort: L
|
||||
- Breaks API? no
|
||||
|
||||
### C17 · scheme · `background`/`on-background` are emitted, `surface-tint`/`surface-variant` are not
|
||||
- Severity: nice-to-have (informational, aligned)
|
||||
- M3 says: background/on-background are legacy roles with the same values as surface/on-surface; surface-variant was superseded by the surface-container family (Feb 2023); surface tint is deprecated (reference-styles §Color roles table).
|
||||
- Library does: emits and maps `background`/`on-background` (`scheme.css:16-17`, `theme.css:29-30`), omits the two deprecated ones. Correct. Consider marking `bg-background` as "same as `bg-surface`; prefer surface" in SKILL.md so agents do not treat them as two surfaces.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C18 · scheme · the package default is the 2025 rendering of the baseline seed, not the site's baseline table
|
||||
- Severity: nice-to-have (informational)
|
||||
- M3 says: the baseline palette table (`PaletteTokens.kt`, site `styles/color/static/baseline`) is the 2021 spec: primary 40 = `#6750A4`, on-primary = white.
|
||||
- Library does: `tokens/scheme.css` is `#6750a4` tonal-spot through the 2025 spec (`primary #655789`, `on-primary #fdf7ff`). Both are Google's; the header says so. Nothing to fix; the showcase colour page could say "spec 2025" so a reader comparing with the site's swatches is not puzzled.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C19 · elevation · no expression of "which component rests at which level"
|
||||
- Severity: nice-to-have (documentation)
|
||||
- M3 says: level 3 = dialogs, FAB, pickers, search; level 2 = menus, scrolled app bar, navigation bar, rich tooltip, toolbar; level 1 = elevated button/card/chip, modal sheets; level 0 = everything else; +1 level on hover (reference-styles §Elevation tokens table); scrim 32 %.
|
||||
- Library does: shadow tokens only (`elevation.css`); the showcase shows five boxes with no mapping; components pick levels individually (correctly where I looked: menu 2, FAB 3, elevated button 1→2 on hover, `bg-scrim/32` ×4).
|
||||
- Fix: put the table in the new guideline/skill and in the showcase elevation section, with M3's rule "tonal separation first, shadows for floating things and interaction".
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C20 · showcase · the foundation pages show tokens but not M3's rules for them
|
||||
- Severity: nice-to-have
|
||||
- M3 says: (as in C19, plus) surface container hierarchy by emphasis; outline vs outline-variant; type roles and when emphasized is used; spatial vs effects; corner scale per component class; optical roundness (outer − padding = inner).
|
||||
- Library does: `showcase/sections/{colour,type,shape,elevation,motion,icons}.blade.php` list every utility with one sentence each; the colour page groups "Ink and lines" as if they were roles (see C3).
|
||||
- Fix: one short "when to use" paragraph per section, drawn from the same guideline text (C24), so the showcase and the AI docs cannot drift.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### C21 · theme script · `data-theme` only, no `prefers-color-scheme` in CSS — aligned
|
||||
- Severity: nice-to-have (no change)
|
||||
- M3 says: light/dark is a user preference; honour the OS and the person's choice (reference-foundations §Accessibility principles; §Color "built-in dark theme").
|
||||
- Library does: resolves `system` before first paint, follows OS changes while `system`, keeps the attributes across `wire:navigate`, declares `color-scheme` per block. Correct.
|
||||
- Fix: none. (C2 adds contrast to the same mechanism.)
|
||||
|
||||
### C22 · reduced motion · stricter than the site, aligned with its intent
|
||||
- Severity: nice-to-have (no change)
|
||||
- M3 says: no global reduced-motion rule on `styles/motion`; component pages ask for parallax/expansion to be removed under reduced motion (reference-styles §Motion accessibility).
|
||||
- Library does: zeroes every duration token (`motion.css:66-77`); carousel, progress, tabs and figure check the media query themselves. Correct; the header's "anything that animates without them is a bug" is a good rule to repeat in the guideline.
|
||||
|
||||
### C23 · icons · accessibility of `<x-icon>` — aligned
|
||||
- Severity: nice-to-have (no change)
|
||||
- M3 says: decorative icons hidden; meaningful icons labelled; don't put the role in the label (reference-foundations §Accessibility labelling).
|
||||
- Library does: `aria-hidden` by default, `role="img"` + `aria-label` when `label` is given, `focusable="false"` (`icon.blade.php:27-33`). Correct.
|
||||
|
||||
### C24 · AI docs · the M3 core concepts are not written down anywhere an agent reads
|
||||
- Severity: should-fix (this is the user's explicit request)
|
||||
- M3 says: see the list under "Concepts missing from the AI docs" below.
|
||||
- Library does: `resources/boost/guidelines/core.blade.php` (6 bullets: what the package is, no maryUI, theme script, showcase, error pages/mail) and SKILL.md §Tokens/§Theme/§Conventions list the utilities and three rules (elevation is for floating things; pair easing with duration; `dark:` follows `data-theme`). Nothing says what a role, a container level, a corner size, a type role or a spring is *for*, nor any layout/accessibility rule.
|
||||
- Fix: a second always-on guideline `resources/boost/guidelines/material-3.blade.php` (short: the rules that change what an agent writes) and a fuller `resources/boost/skills/material-3-design/SKILL.md` (the concept reference with the tables), both generated from one source or tested against each other; Boost 2.8 loads every file under `resources/boost/guidelines` and every skill directory (verified in `laravel/boost/src/Install/GuidelineComposer.php:320-351`, `SkillComposer.php:127-141`). Add the drift test.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
## Deliberate deviations
|
||||
|
||||
| Where | Deviation | Reason given | Holds up? |
|
||||
| --- | --- | --- | --- |
|
||||
| `shape.css` | `corner-full` = 9999px instead of material-web's `50cqmin` | container units need a declared container | Yes. Compose's `CircleShape` is 50 % of the smaller dimension = a stadium; 9999px gives the same on rectangles, `50%` would not. |
|
||||
| `elevation.css` | `color-mix()` instead of `hsl(from …)` | browser floor | Yes; same result. |
|
||||
| `motion.css` | springs sampled to `linear()` rather than Google's cubic-bezier approximations | closer to the physics | Yes for the curve; the durations differ from Google's published web numbers (C8). |
|
||||
| `motion.css` | reduced motion zeroes durations | one place instead of per component | Stricter than the site; consistent with "Honor individuals". |
|
||||
| `theme.css` | colour blocks are `@theme inline` | nested `data-theme` sections | Sound; verified by a browser test per the plan doc. |
|
||||
| `theme.css` | white/black re-added | absolutes | M3 has no such roles; white is `surface-container-lowest` (light). Harmless, but the guard should at least be able to flag them (C13). |
|
||||
| `theme.css` | `--color-meta/quiet/divider/chrome` opacity mixes | ReStride's templates | Does **not** hold against M3's text (C3): M3 dropped opacity-based emphasis with M3; 38 % is disabled. |
|
||||
| `type.css` | Google Sans Flex for brand and plain, `ROND` 100 on emphasized | Expressive's typeface | Holds: the site names Google Sans Flex on the shape page and leaves the typeface to the product; `ROND` is that font's own axis, not an M3 token. |
|
||||
| scheme | `surface-tint`/`surface-variant` omitted | superseded/deprecated | Holds (reference-styles §Color roles). |
|
||||
| scheme | success/warning/info via `customColor`, unharmonised | "harmonisation off" | Off is allowed; the 2021 recipe and contrast-blindness are not (C1, C15). |
|
||||
| `icon` | one Symbols cut, 400/0/24, SVG not font | any name works, no runtime download | Holds for weight; grade/optical size are lost (C16). |
|
||||
| `theme-script` | OS never read by CSS | the script resolves `system` | Holds. |
|
||||
|
||||
## Aligned (keep as is)
|
||||
|
||||
- All 2025-spec colour roles generated and mapped, `*-dim` and fixed roles included; `scheme.json` and `Scheme.php` fill missing roles from the default; every pair (`x` / `on-x`) comes from Google's algorithm.
|
||||
- Corner scale: none 0, xs 4, sm 8, md 12, lg 16, lg-increased 20, xl 28, xl-increased 32, xxl 48, full — exactly M3 Expressive's ten steps, same names.
|
||||
- Elevation: five levels with material-web's shadow geometry; tonal separation preferred; scrim at 32 % where used.
|
||||
- Motion: spatial 0.6/800, 0.8/380, 0.8/200; effects 1.0/3800, 1.0/1600, 1.0/800 — the Compose Expressive constants; legacy easing tokens present; reduced motion handled centrally and in the four scripted components.
|
||||
- Typescale: all 15 sizes, line heights and weights match `TypeScaleTokens.kt`, emphasized set present (weights right).
|
||||
- State layer: 8/10/10 %, content colour, hover only on `(hover: hover)`, disabled removes it; focus ring only on `:focus-visible`.
|
||||
- Theme: `data-theme` resolved before paint, OS followed while `system`, choice persisted, legacy keys adopted, attributes survive `wire:navigate`, `color-scheme` declared, `dark:` variant on `data-theme`.
|
||||
- Shapes: the 35 Expressive shapes ported from androidx, all fill the same box, decorative and hidden from AT.
|
||||
- Symbols: Google's own 24 px Rounded files (not the 48-opsz npm cut), outlined and filled, `currentColor`, hidden unless labelled.
|
||||
- Safe areas: `--material-safe-*` = M3's safety region.
|
||||
- DesignGuard already enforces "roles, not palette" and "no maryUI/daisyUI".
|
||||
|
||||
## Missing (M3 things the core does not offer at all)
|
||||
|
||||
- Contrast levels medium/high (C2).
|
||||
- Breakpoint tokens/variants and pane/margin tokens (C12).
|
||||
- Dragged state, disabled tokens, a shared touch target (C10).
|
||||
- Standard motion scheme (C9).
|
||||
- Harmonisation option for custom colours (C15).
|
||||
- An M3 concept guideline for agents (C24).
|
||||
|
||||
## Breakpoint map (core only)
|
||||
|
||||
| Item | Library | M3 breakpoint | Gap |
|
||||
| --- | --- | --- | --- |
|
||||
| tokens | none; Tailwind `sm` 640 / `md` 768 / `lg` 1024 / `xl` 1280 / `2xl` 1536 | compact <600 / medium 600 / expanded 840 / large 1200 / extra-large 1600 | no M3 breakpoint is expressible; nearest Tailwind values are 40–88 px off (see navigation audit for each component) |
|
||||
|
||||
## Concepts missing from the AI docs (input for the "Material 3 core concept" guideline)
|
||||
|
||||
Each line: what M3 says → whether the package docs say it today.
|
||||
|
||||
Colour
|
||||
- Roles are "paint-by-number" slots; a colour is always a role, never a hex or palette tone → docs say "every colour class names an M3 role" (yes) but not why or the hex rule.
|
||||
- Pair `x` with `on-x` only; a container is a fill, "on" is for text/icons on it; don't mix pairs (primary + secondary-container + on-surface) → no.
|
||||
- Primary = high-emphasis actions; secondary = less prominent, tonal fills; tertiary = complementary accent; error = static semantic → no.
|
||||
- Surface = page; surface-container-lowest…highest = emphasis hierarchy, not elevation; navigation on surface-container; same mapping for a region across breakpoints → no.
|
||||
- `outline` for boundaries that must read (text fields), `outline-variant` for dividers and cards; never `outline` on dividers → no.
|
||||
- Fixed/dim roles: "if you aren't sure, you probably shouldn't"; never where contrast matters → no.
|
||||
- Inverse roles are for the snackbar/inverse surfaces only → no.
|
||||
- success/warning/info are the package's custom roles, built like error; semantic colours are never dynamic-coloured away → no.
|
||||
- Low emphasis is `on-surface-variant`, 38 % means disabled; links are primary **and** underlined → no (the opposite: `text-quiet` is documented as decoration).
|
||||
- Contrast targets 4.5:1 small text, 3:1 large text/graphics, 3:1 clustered elements; disabled exempt → no.
|
||||
- Three contrast levels exist → no.
|
||||
|
||||
Elevation
|
||||
- Tonal separation first; shadows for floating elements and interaction only; the level table per component; +1 on hover; scrim 32 % → partly ("for what floats over content").
|
||||
|
||||
Shape
|
||||
- Which corner for what: full for buttons/chips/FAB-menu, xs 4 for text fields/snackbar, sm 8 for chips, md 12 for cards, lg 16 for FAB, xl 28 for dialogs/sheets/menus in Expressive, xxl 48 for large containers → no.
|
||||
- Optical roundness: inner radius = outer radius − padding → no.
|
||||
- Shapes are decoration, never semantic; use sparingly; press morph is the interaction cue → no.
|
||||
|
||||
Typography
|
||||
- Role purposes: display (big, short), headline (short, high emphasis), title (medium, short), body (paragraphs), label (inside components; buttons use label-lg) → no.
|
||||
- Emphasized styles are opt-in, for selection, primary actions, headlines, badges — not decoration → no.
|
||||
- 40–60 characters per line; tabular numbers where values change; don't change sizes when customising → no (only "never assemble text-* by hand").
|
||||
|
||||
Motion
|
||||
- Spatial springs for position/size/shape (they overshoot), effects for colour/opacity (never overshoot); fast for small elements, default for most, slow for large → partly (the pairing rule, one sentence).
|
||||
- Enter = decelerate, permanent exit = accelerate, temporary exit = emphasized; exits shorter than enters → no.
|
||||
- Reduced motion: everything through the tokens goes instant; parallax/expansion removed → no.
|
||||
|
||||
States
|
||||
- Six states; two visual indicators per state; hover 8 % / focus 10 % / press 10 % / drag 16 %; disabled 38 % content, 12 % container, no hover; state layer takes the content colour → no (utilities only).
|
||||
- 48 × 48 target, 8 dp between targets, never below 48 by default, density is opt-in → no.
|
||||
|
||||
Layout
|
||||
- Five breakpoints and what changes at each (navigation bar → collapsed rail → expanded rail; 1 → 2 → 3 panes; bottom sheet → menu; full-screen dialog → basic dialog) → no.
|
||||
- Scaffold: bars, rails, panes; safety regions; pane widths 360/412, side sheet ≤ 400 → partly (safe areas documented).
|
||||
- RTL: leading/trailing, mirror directional icons, use logical utilities (`ps-`, `ms-`, `start-`) → no.
|
||||
|
||||
Accessibility
|
||||
- Landmarks (one main/banner/contentinfo; label repeated navs; no role in the label); headings in order, one H1; dialog focus in/out; labels for icon-only controls; decorative images hidden; keyboard shortcuts need a modifier → no.
|
||||
|
||||
Icons
|
||||
- Filled = active/selected; keep one weight per group; 24 default, 20 in dense UI; label complex icons below 20; 48 target → partly (`filled` documented).
|
||||
@@ -1,692 +0,0 @@
|
||||
# Audit: inputs
|
||||
|
||||
Scope: text fields and their chrome (`form`, `field`, `input`, `password`, `textarea`, `select`, `file`),
|
||||
selection controls (`checkbox`, `radio`, `toggle`), `slider`, `chip` / `chip-set` / `choices`,
|
||||
`datepicker`, `timepicker`, `search`, and the data pieces (`table`, `sort-header`, pagination views).
|
||||
|
||||
Sources cited below: `docs/reference/m3/reference-components-b.md` (RB) and `docs/reference/m3/reference-foundations.md`
|
||||
(RF) — both built from m3.material.io; raw page dumps `docs/reference/m3/raw/components_*_specs.md`; androidx
|
||||
token files in `docs/reference/m3/tokens/*.kt`. `reference-styles.md` did not exist while this audit ran, so
|
||||
shape/type/elevation numbers come from the raw `styles_*` dumps and the token files.
|
||||
|
||||
## Summary
|
||||
|
||||
This is an unusually faithful M3 implementation. The text-field chrome, the three selection controls, the
|
||||
chip family and both pickers reproduce the androidx token numbers almost exactly — the switch matches
|
||||
`SwitchTokens` in every dimension, the date picker matches `DatePickerModalTokens` (360 × 120 header,
|
||||
40 px day in a 48 px cell, today's 1 px primary outline), and the time picker matches the site's own
|
||||
spec table for the dial, the selector handle and the time-selector boxes. The biggest problems are
|
||||
accessibility rather than geometry: `<x-search>` announces nothing when results appear and puts
|
||||
`aria-expanded` on a plain `<input type="search">` with orphan `role="listitem"` results; a disabled text
|
||||
field still lights its outline on hover because the hover declaration sits on a descendant of the element
|
||||
carrying the disabled value; and a label-less checkbox, radio or switch has an 18–52 px hit target instead
|
||||
of 48 px. Measurable deviations cluster in the slider (the M-size handle is 44 px where M3 says 52 px, the
|
||||
value indicator is a 32 × 36 pill where M3 says 44 × 48, and the stop/tick/icon colours are inverted from
|
||||
the site's roles) and in the search view (it draws the deprecated *divided* style's divider and uses
|
||||
surface-container-high where the full-screen layout should be surface-container-low). Missing entirely:
|
||||
the slider's Expressive vertical orientation, the full-screen range date picker, and any width bound on
|
||||
the search bar or a text field at medium/expanded widths. Several of the library's own androidx-token
|
||||
citations in header comments name numbers that the M3 site's spec tables contradict — all listed in
|
||||
**Token-citation disagreements** below.
|
||||
|
||||
## Findings
|
||||
|
||||
### IN-01 · search · results appear with no announcement, and the input's ARIA is invalid
|
||||
- Severity: must-fix
|
||||
- M3 says: "Screen reader must announce when suggestions/results appear (autosuggest change)" and
|
||||
"Suggestions/results use the list component and are announced as a list by screen readers (follow list
|
||||
accessibility guidelines for item labels)" — RB §Search/Accessibility (m3.material.io/components/search/accessibility).
|
||||
- Library does: `resources/views/components/search.blade.php:47-64` puts `aria-controls` and
|
||||
`aria-expanded` on a bare `<input type="search">` with no `role="combobox"`. ARIA 1.2 does not list
|
||||
`aria-expanded` as supported on `textbox`, so the state is dropped by conforming AT. The view
|
||||
(`search.blade.php:75-90`) has no role and no live region, and `[data-search-results]`
|
||||
(`search.blade.php:86`) is a plain `<div>` whose children are `<x-list-item>`s rendered as
|
||||
`role="listitem"` (`resources/views/components/list-item.blade.php:41`) — an orphan role with no
|
||||
`role="list"` owner. `grep -n 'aria-live' resources/views/components/search.blade.php` returns nothing.
|
||||
`<x-choices>` already does this correctly (`choices.blade.php:124-130,154-157`: `role="combobox"`,
|
||||
`aria-autocomplete="list"`, `role="listbox"`/`role="option"`, `aria-activedescendant`).
|
||||
- Fix: in `search.blade.php`, add `role="list"` to the `[data-search-results]` div (or wrap the slot in
|
||||
`<x-list>`), and either (a) mirror `<x-choices>`: `role="combobox" aria-autocomplete="list"` on the
|
||||
input and roving `aria-activedescendant` over the results, or (b) keep the plain search field, drop
|
||||
`aria-expanded`, and add a visually hidden `<span aria-live="polite">` that `search.js` fills with the
|
||||
result count whenever `[data-search-results]` mutates (the `MutationObserver` plumbing already exists in
|
||||
`resources/js/chips.js:99-117` as a pattern).
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### IN-02 · field · a disabled text field still lights its outline on hover
|
||||
- Severity: must-fix
|
||||
- M3 says: "**Disabled**: communicated via color change + reduced elevation; not focusable, draggable, or
|
||||
pressable; **no hover/other state layer**" — RF §States (foundations/interaction/states/applying-states).
|
||||
- Library does: `resources/css/components/field.css:390-392` declares
|
||||
`.field-box:hover { --field-edge: var(--md-sys-color-on-surface); }` **on `.field-box`**, while the
|
||||
disabled value at `field.css:423-425` is declared on `.field` and only *inherits* down. A direct
|
||||
declaration beats an inherited one regardless of selector specificity, so hovering a disabled outlined
|
||||
field paints a full-opacity `on-surface` outline instead of `on-surface/12`. The rule is also outside
|
||||
`@media (hover: hover)`, unlike the filled variant's background at `field.css:458-462`, so the state
|
||||
sticks after a tap on touch. (The filled background *is* correctly suppressed — `field.css:514-519`
|
||||
wins on specificity there; only `--field-edge` breaks.)
|
||||
- Fix: `resources/css/components/field.css:390` → wrap in `@media (hover: hover)` and scope to
|
||||
`.field:not(:has(.field-control:disabled)) .field-box:hover`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-03 · checkbox / radio / toggle · a control without a label has an 18–52 px hit target
|
||||
- Severity: must-fix
|
||||
- M3 says: checkbox "Target size 48dp", radio "Target size 48dp", switch "Target | Size | 48dp"
|
||||
(RB §Checkbox/Specs, §Radio Button/Specs, §Switch/Specs); "The size of state layers is 40dp while the
|
||||
interactive target size is 48dp" (RF §States); and for all three, "Don't apply density by default —
|
||||
this drops targets below the 48x48 CSS px minimum" (RB §Checkbox/Accessibility, §Radio/Accessibility,
|
||||
§Switch/Accessibility).
|
||||
- Library does: in `resources/views/components/checkbox.blade.php:27-55` the `<label>` wraps only
|
||||
`<span data-checkbox>` when `label` and `hint` are both blank, and `[data-checkbox]` is `1.125rem`
|
||||
square (`resources/css/components/selection.css:105-108`). The 40 px state layer is a `::before` with
|
||||
`pointer-events: none` (`selection.css:51-59`), so it adds nothing to the target. Result: an 18 × 18
|
||||
target. `[data-radio]` is 20 × 20 (`selection.css:169-172`) and `[data-switch]` is 52 × 32
|
||||
(`selection.css:225-231`) — 32 px tall, also under 48. A label-less checkbox is a real use (the
|
||||
select-all in a table header, a row checkbox), and `<x-toggle>` documents `aria-label` as the label-less
|
||||
path (`toggle.blade.php:5`), so the case is expected.
|
||||
- Fix: in `resources/css/components/selection.css`, add a pointer-catching pseudo-element rather than
|
||||
growing the visual box — e.g.
|
||||
`[data-checkbox]::after { content:""; position:absolute; inset:-0.9375rem; }` (18 → 48),
|
||||
`[data-radio]::after { inset:-0.875rem; }` (20 → 48), and
|
||||
`[data-switch]::after { content:""; position:absolute; inset:-0.5rem 0; }` (32 → 48). Keep them behind
|
||||
the input so clicks still reach it, or put `min-block-size:3rem` on the `[data-selection]` row.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-04 · slider · the M-size handle is 44 px where M3 Expressive specifies 52 dp
|
||||
- Severity: should-fix
|
||||
- M3 says: sliders/specs, "Handle height | 44dp | 44dp | **52dp** | 68dp | 108dp" for XS/S/M/L/XL
|
||||
(RB §Sliders/Specs, verbatim site table).
|
||||
- Library does: `resources/views/components/slider.blade.php:247-250` maps
|
||||
`'md' => 'h-11 group-data-focused/thumb:h-9.5'` — 44 px, the same as XS and S. The header comment at
|
||||
`slider.blade.php:44` states the intent as "handles of 44, 44, 44, 68 and 108px", citing MDC-Android's
|
||||
`md.comp.slider.*` tokens, which disagrees with the site table for M.
|
||||
- Fix: `slider.blade.php:248` → `'md' => 'h-13 group-data-focused/thumb:h-11.5'` (52 px, 46 px focused,
|
||||
keeping the 6 px focus-ring decrease the comment describes), and correct the header comment.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-05 · slider · the value indicator is a 32 × 36 pill where M3 Expressive specifies 44 × 48
|
||||
- Severity: should-fix
|
||||
- M3 says: sliders/specs, "Label container height | 44dp (all sizes)" and "Label container width | 48dp
|
||||
(all sizes)" (RB §Sliders/Specs). Container `Inverse surface`, label `Inverse on surface`, font
|
||||
`Label large` (`SliderTokens.ValueIndicatorContainerColor / ValueIndicatorLabelTextColor /
|
||||
ValueIndicatorLabelTextFont`).
|
||||
- Library does: `resources/views/components/slider.blade.php:346` — `h-8 min-w-9 ... px-2.5` = 32 px tall,
|
||||
36 px minimum width. The colours and font are right (`bg-inverse-surface text-inverse-on-surface
|
||||
type-label-lg`). The header at `slider.blade.php:39-41` explains the 32 px as Flutter's
|
||||
`RoundedRectSliderValueIndicatorShape`, i.e. a non-Expressive source.
|
||||
- Fix: `slider.blade.php:346` → `h-11 min-w-12`; then re-derive the `$labelBottom` offsets at
|
||||
`slider.blade.php:252` (they are hard-coded against a 32 px pill: `calc(50%+1.625rem)` = handle half +
|
||||
4 px gap + 32 px). Note M3 also gives `ValueIndicatorActiveBottomSpace = 12dp` in `SliderTokens.kt`
|
||||
where the library uses 4 px — worth reconciling in the same change.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### IN-06 · slider · stop indicators and the inset icon use inverted colour roles
|
||||
- Severity: should-fix
|
||||
- M3 says: the 9 slider colour roles, in anatomy order, are "1. Inverse surface 2. Inverse on surface
|
||||
3. Primary 4. **On primary** 5. Primary 6. Secondary container 7. **On secondary container**
|
||||
8. On secondary container 9. On primary" — `docs/reference/m3/raw/components_sliders_specs.md:150-160`
|
||||
(verbatim). That is: a stop/icon **on the active track** is `On primary`; a stop/icon **on the inactive
|
||||
track** is `On secondary container`.
|
||||
- Library does: `resources/views/components/slider.blade.php:227-238` inverts both —
|
||||
`$tickInk['primary'] = 'bg-primary data-active:bg-secondary-container'` (so an inactive-track tick is
|
||||
`primary` and an active-track tick is `secondary-container`), and `$iconInk['primary'] =
|
||||
'text-primary data-active:text-secondary-container'`. The end stop indicators at
|
||||
`slider.blade.php:311` use `$activeInk` (`bg-primary`) even though they sit on the inactive track.
|
||||
Contested reading: `SliderTokens.kt` has `StopIndicatorColor = SecondaryContainer` and
|
||||
`StopIndicatorColorSelected = SecondaryContainer`, which would make the active-track stop
|
||||
secondary-container (the library's choice) and the inactive-track stop invisible. The site's own colour
|
||||
list is the more coherent of the two and is the newer Expressive text.
|
||||
- Fix: in `slider.blade.php:227-238`, swap to
|
||||
`'primary' => 'bg-on-secondary-container data-active:bg-on-primary'` (and the parallel
|
||||
`text-on-…` for `$iconInk`), and give `$stops` at `slider.blade.php:311` `bg-on-secondary-container`.
|
||||
Repeat for the `secondary`/`tertiary`/`error`/`success`/`warning`/`info` rows.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-07 · search · the view draws the *divided* style's divider, which M3 Expressive deprecates
|
||||
- Severity: should-fix
|
||||
- M3 says: "| Style | Contained | -- | Available | | | Divided | Available | **Not recommended. Use
|
||||
contained.** |" and "In the divided (baseline) style, **a divider separates the search bar and
|
||||
results**" — `docs/reference/m3/raw/components_search_specs.md:106-107,134`. The divided style "Doesn't have
|
||||
the latest visual style, motion, or flexibility" (ibid. :46).
|
||||
- Library does: `resources/css/components/search.css:160` —
|
||||
`[data-search-results] { border-top: 1px solid var(--md-sys-color-outline); }`. The file's own header
|
||||
(`search.css:6-9`) describes the contained style. Second reading: Compose's `SearchBar` does draw a
|
||||
`HorizontalDivider` between the input and the content, so this may be tracking the Compose
|
||||
implementation rather than the site; the site's contained-style images show no divider.
|
||||
- Fix: `resources/css/components/search.css:160` → drop the `border-top` (keep `padding-block: 0.5rem`).
|
||||
If a separation cue is wanted, use `outline-variant`, not the darker `outline` role.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-08 · search · the full-screen layout uses the docked layout's container colour
|
||||
- Severity: should-fix
|
||||
- M3 says: "Full-screen search color roles used in light and dark themes: 1. **Surface container low**
|
||||
2. On surface variant 3. On surface variant 4. Surface container high 5. On surface variant 6. On
|
||||
surface" vs "Docked search color roles: 1. **Surface container high** …" —
|
||||
`docs/reference/m3/raw/components_search_specs.md:196-232`. RB §Search/Behaviour also warns: "Never use
|
||||
Surface container high on a Surface container background (contrast too low, blends in) — keep container
|
||||
roles at least one step apart."
|
||||
- Library does: `resources/css/components/search.css:140` sets
|
||||
`[data-search-view] { background-color: var(--md-sys-color-surface-container-high); }` for both
|
||||
presentations; `search.css:174-181` changes only geometry for `[data-full-screen]`.
|
||||
- Fix: add
|
||||
`[data-search][data-full-screen] [data-search-view] { background-color: var(--md-sys-color-surface-container-low); }`
|
||||
to `resources/css/components/search.css` around line 174.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-09 · search · the docked view has no scrim
|
||||
- Severity: should-fix
|
||||
- M3 says: "**Docked** (opens a results list below the bar **with a scrim over the rest of the content**
|
||||
— best for medium/expanded windows)" — RB §Search/Variants and configurations.
|
||||
- Library does: `resources/views/components/search.blade.php:27-37` raises the root to `z-index: 50`
|
||||
when open (`search.css:24-26`) and closes on `pointerdown.outside`, but paints nothing over the page.
|
||||
The full-screen presentation covers the page instead (`search.css:174-181`), which is correct for
|
||||
compact.
|
||||
- Fix: in `search.blade.php`, add a `<div data-search-scrim x-show="open && ! fullScreen">` before
|
||||
`[data-search-bar]` and style it in `search.css` as
|
||||
`position: fixed; inset: 0; z-index: -1; background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);`
|
||||
— the same 32 % the datepicker backdrop uses (`datepicker.css:111-113`).
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### IN-10 · timepicker · the 24-hour vertical time selector stays 96 px where M3 specifies 114 dp
|
||||
- Severity: should-fix
|
||||
- M3 says: time-pickers/specs, "Time selector container | Width | 96dp" and "| Width (24h vertical) |
|
||||
**114dp**" (RB §Time Pickers/Specs); confirmed by
|
||||
`docs/reference/m3/tokens/TimePickerTokens.kt` → `TimeSelector24HVerticalContainerWidth = 114.0.dp`.
|
||||
- Library does: `resources/css/components/timepicker.css:106-107` sets
|
||||
`[data-timepicker-box] { width: 6rem; height: 5rem; }` unconditionally. The dial already carries the
|
||||
cycle as `data-cycle="24"` on `[data-timepicker-dial]` (`timepicker.blade.php:265`), but the display
|
||||
boxes never read it. The header comment (`timepicker.blade.php:33`) names only "96×80 time selector
|
||||
boxes", so the 114 dp token was not carried over.
|
||||
- Fix: put the cycle on the display too (`timepicker.blade.php:214` → `x-bind:data-cycle="is24 ? '24' : '12'"`)
|
||||
and add `[data-timepicker-display][data-cycle="24"] [data-timepicker-box] { width: 7.125rem; }` in
|
||||
`timepicker.css`, scoped out of the landscape block at `timepicker.css:403-447`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-11 · timepicker · the selected AM/PM button is primary-container, not tertiary-container
|
||||
- Severity: should-fix
|
||||
- M3 says: the dial colour list includes "Tertiary container, On tertiary container" (RB §Time
|
||||
Pickers/Specs, 17 site roles), and `docs/reference/m3/tokens/TimePickerTokens.kt` gives
|
||||
`PeriodSelectorSelectedContainerColor = TertiaryContainer`,
|
||||
`PeriodSelectorSelectedLabelTextColor = OnTertiaryContainer`,
|
||||
`PeriodSelectorOutlineColor = Outline`, `PeriodSelectorOutlineWidth = 1.0.dp`,
|
||||
`PeriodSelectorContainerShape = CornerSmall`. The time **selector** (hour/minute), separately, is
|
||||
`PrimaryContainer` / `OnPrimaryContainer`.
|
||||
- Library does: `resources/css/components/timepicker.css:160-169` gives the selected period button
|
||||
`background-color: var(--md-sys-color-primary-container); color: var(--md-sys-color-on-primary-container)`
|
||||
— identical to the selected hour/minute box at `timepicker.css:120-124`, so the two different M3
|
||||
emphases collapse into one. It also drops the 1 dp `Outline` stroke and replaces the shared
|
||||
corner-small container with two separate pills (full → 12 px on selection). The header
|
||||
(`timepicker.blade.php:37-39`) justifies this as Compose's current `isUpdatedTimepickerToggleEnabled`
|
||||
default and mentions "not the outlined pair its tokens still describe" — but not the tertiary→primary
|
||||
colour swap, which is the part a reader would not expect.
|
||||
- Fix: either restore `tertiary-container` / `on-tertiary-container` at `timepicker.css:165-167` (keeping
|
||||
the shape morph, which is a defensible Expressive reading), or document the colour swap explicitly in
|
||||
the header comment. Recommend the former: it restores the hour/minute vs. AM/PM distinction M3 draws.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-12 · checkbox · the tick and dash icons are 16 px where M3 specifies 18 dp
|
||||
- Severity: should-fix
|
||||
- M3 says: checkbox/specs, "Icon size | 18dp"; `docs/reference/m3/tokens/CheckboxTokens.kt` →
|
||||
`IconSize = 18.0.dp` (= `ContainerSize`, i.e. the icon fills the box).
|
||||
- Library does: `resources/views/components/checkbox.blade.php:41-42` renders both
|
||||
`<x-icon name="check" class="size-4" />` and `name="remove" class="size-4"` — 16 px inside an 18 px box
|
||||
(`selection.css:105-108`). `resources/css/components/selection.css:1-3` cites `CheckboxTokens` as the
|
||||
source, so the 18 dp figure was rounded to Tailwind's nearest step.
|
||||
- Fix: `checkbox.blade.php:41-42` → `class="size-4.5"` (Tailwind 4's 4.5 = 18 px; the chip already uses
|
||||
`size-4.5` for its 18 dp icons, `chip.blade.php:233,254,282`).
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-13 · chip-set · chips are Tab stops only; M3's arrow-key navigation is missing
|
||||
- Severity: should-fix
|
||||
- M3 says: chips/accessibility keyboard table — "Tab | Moves focus to enabled chip or chip group;
|
||||
Space or Enter | Activates, selects, or deselects the focused chip; Backspace or Delete | Removes
|
||||
currently focused input chip; **Arrows | Moves focus between chips**" (RB §Chips/Accessibility), and
|
||||
"only one chip can be in focus even though many can be selected".
|
||||
- Library does: `resources/views/components/chip-set.blade.php:31-54` renders `role="group"` with every
|
||||
chip its own natural tab stop; `resources/js/chips.js` implements Backspace/Delete
|
||||
(`chips.js:38-52`) and focus hand-off on removal (`chips.js:60-79`) but binds no arrow keys. A ten-chip
|
||||
filter set therefore costs ten Tab presses to pass.
|
||||
- Fix: add an `x-on:keydown.arrow-right/left/home/end` handler to the `[data-chip-set]` div in
|
||||
`chip-set.blade.php` (both the `scroll` and wrap branches) that moves focus across
|
||||
`[data-chip]` using the existing `CONTROLS` selector from `chips.js:13`, with a roving `tabindex`
|
||||
so the set is one tab stop. Note the trade-off: a filter chip is a real `<input type="checkbox">` in a
|
||||
`<label>` (`chip.blade.php:181-189`), so roving tabindex must be applied to the inputs, not the labels.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### IN-14 · chip (input) · the remove button's target is 34 px wide against M3's 48 dp minimum
|
||||
- Severity: should-fix
|
||||
- M3 says: input chip specs, "Target size for close icon | **Min 48dp**"; and "minimum target size 48dp
|
||||
regardless of placement/density (target may extend beyond the visible chip container)" —
|
||||
RB §Chips/Specs and §Chips/Behaviour.
|
||||
- Library does: `resources/views/components/chip.blade.php:250` gives the remove button
|
||||
`after:-inset-x-2 after:-inset-y-3.75` on an 18 px icon (`size-4.5`, line 247) → 18 + 2×15 = 48 px tall
|
||||
but 18 + 2×8 = **34 px wide**.
|
||||
- Fix: `chip.blade.php:250` → `after:-inset-x-3.75 after:-inset-y-3.75` (48 × 48). The chip's own 48 px
|
||||
target strip (`chip.blade.php:93`) already overlaps the label, so the extra 7 px each side only
|
||||
overlaps the label's target, which M3 explicitly permits ("target may extend beyond the visible chip
|
||||
container"); the remove button is later in source order and sits above it.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-15 · pagination · page and step controls are 40 px targets
|
||||
- Severity: should-fix
|
||||
- M3 says: "The size of state layers is 40dp while the interactive target size is 48dp" (RF §States);
|
||||
accessibility: keep targets ≥ 48 × 48 CSS px (RF §Accessibility/Values). Pagination is not an M3
|
||||
component, so foundations are the yardstick per the brief.
|
||||
- Library does: `resources/views/pagination/livewire/tailwind.blade.php:22` —
|
||||
`$step = 'grid size-10 place-items-center …'` (40 px) is used for the page buttons, the prev/next
|
||||
buttons and the current page, with `gap-1` between them (line 35); same at
|
||||
`resources/views/pagination/laravel/tailwind.blade.php:7-9`. (The two `simple-tailwind` views are not
|
||||
affected — they use `<x-button variant="outlined">`, whose sizing belongs to the buttons group.) The
|
||||
file's own header (line 7) calls them "40px icon-button targets", conflating M3's 40 dp *state layer*
|
||||
with its 48 dp *target*.
|
||||
- Fix: keep the 40 px visual and add the target — in the two full pagination views change `$step` to
|
||||
`'relative grid size-10 place-items-center after:absolute after:-inset-1 …'`, or wrap each button in a
|
||||
48 px grid cell. Also correct the header comment.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-16 · table · density is applied by default on every fine-pointer device
|
||||
- Severity: should-fix
|
||||
- M3 says: "**Don't apply density by default** — this drops targets below the 48x48 CSS px minimum;
|
||||
offer an explicit density opt-in instead, keeping opt-out targets at ≥48x48 CSS px" — RF
|
||||
§Accessibility/Rules, repeated on checkbox, chips, radio, switch and text-field accessibility pages.
|
||||
(Data tables themselves have no M3 spec — RB §Data Tables confirms the component is absent from
|
||||
m3.material.io — so foundations govern.)
|
||||
- Library does: `resources/css/components/table.css:37-45` tightens `--cell-y` from `0.75rem` to
|
||||
`0.5rem` under `@media (pointer: fine)` with no opt-out, and to `0.25rem` at `data-size="xs"`. With
|
||||
`body-md`'s 20 px line box that is a 36 px row (28 px at `xs`). Even on a coarse pointer the default row
|
||||
is 12 + 20 + 12 = 44 px, under 48. Rows are clickable (`data-list-row`,
|
||||
`resources/js/list-rows.js`), so the row *is* a target.
|
||||
- Fix: `resources/css/components/table.css` — raise the coarse-pointer default to
|
||||
`--cell-y: 0.875rem` (14 px → 48 px rows) and turn the fine-pointer step into an opt-in
|
||||
(`[data-table][data-density="compact"]`) rather than a media query, exposed as a `density` prop on
|
||||
`resources/views/components/table.blade.php:10-12`.
|
||||
- Effort: M
|
||||
- Breaks API? yes — adds a prop and changes default row height; existing `size="xs"` keeps its meaning.
|
||||
|
||||
### IN-17 · sort-header · the sort button's hit target is about 20 px tall
|
||||
- Severity: should-fix
|
||||
- M3 says: 48 × 48 minimum target, no density by default (RF §Accessibility/Values and /Rules).
|
||||
- Library does: `resources/views/components/sort-header.blade.php:21-30` renders
|
||||
`class="group/sort focus-ring inline-flex cursor-pointer items-center gap-1 rounded-corner-xs"` with no
|
||||
padding of its own. Its box is the `title-sm` line box (20 px, `resources/css/tokens/type.css:45`) plus
|
||||
a 16 px icon — roughly 20 px tall. The surrounding `<th>` padding (`table.css:47-51`) belongs to the
|
||||
cell, not the button, so it is not part of the target.
|
||||
- Fix: `sort-header.blade.php:26` → add `-my-2 py-2 -mx-1 px-1` (or `min-h-12` with the `<th>` padding
|
||||
removed for sorting cells) so the button covers its cell.
|
||||
- Effort: S
|
||||
- Breaks API? no (the `class` prop still lands on the `<th>`)
|
||||
|
||||
### IN-18 · field · the error state has only one visual indicator
|
||||
- Severity: should-fix
|
||||
- M3 says: "**States have two visual indicators to ensure accessibility**" (RF §States/Principles), and
|
||||
for text fields specifically: error text "strongly recommended to pair with a **trailing error icon**
|
||||
for a second, non-color-dependent cue" (RB §Text Fields/Behaviour); a non-actionable error icon gets the
|
||||
accessibility label "Error." (RB §Text Fields/Accessibility).
|
||||
- Library does: `resources/views/components/field.blade.php:39-45` sets only `data-invalid`, and
|
||||
`resources/css/components/field.css:403-415,446-448` changes only colours — outline, label and support
|
||||
text all move to `error`. No icon is added. `<x-input>`'s `icon-right` slot is free, so the hook exists.
|
||||
- Fix: in `field.blade.php`, when `$messages !== []` and no `$trailing` is set, render
|
||||
`<x-icon name="error" class="field-trailing size-(--field-icon)" aria-label="{{ __('Error') }}" />` into
|
||||
the trailing position; suppress it at `data-size="xs"` where there is no room.
|
||||
- Effort: S
|
||||
- Breaks API? no (additive; callers passing `icon-right` keep theirs)
|
||||
|
||||
### IN-19 · field · a read-only field is drawn with a dashed outline
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "**Read-only fields**: same visual style as an editable field, but clearly labeled read-only"
|
||||
— RB §Text Fields/Behaviour.
|
||||
- Library does: `resources/css/components/field.css:419-421` —
|
||||
`.field[data-readonly] .field-outline { border-style: dashed; }`, set from
|
||||
`input.blade.php:38`, `textarea.blade.php:27` and `datepicker.blade.php:161`. The comment at
|
||||
`field.css:417-418` explains the intent (a date picker makes its own input read-only). The native
|
||||
`readonly` attribute does carry the state to AT, so the accessibility half of M3's rule is met; only
|
||||
"same visual style" is not.
|
||||
- Fix: either drop the dashed rule, or keep it and record it in the header comment as a deliberate
|
||||
departure. If kept, note that the date picker's *docked* field is **not** read-only
|
||||
(`datepicker.blade.php:182-189` binds typing), so only `mode="modal"`/`"input"` and caller-set
|
||||
`readonly` fields are affected.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-20 · search · the bar has no minimum or maximum width and does not widen on focus
|
||||
- Severity: nice-to-have
|
||||
- M3 says: search bar "Container | Width | Min: 360dp, max: 720dp"; docked container the same; and the
|
||||
Expressive update lists "Motion: the search bar grows wider when focused" — RB §Search/Specs and
|
||||
§Search/Status.
|
||||
- Library does: `resources/css/components/search.css:28-41` sizes `[data-search-bar]` only by height;
|
||||
width is the parent's. `[data-search-view]` (`search.css:130-147`) is `inset-inline: 0` with
|
||||
`max-height: min(40rem, 70dvh)` and no `min-height` (M3: docked height "Min: 240dp, max: 2/3 of screen
|
||||
height" — 70 dvh vs 66.7 % is close enough to ignore). No focus-widening transition.
|
||||
- Fix: `search.css:28` → add `max-width: 45rem;` (720 px) to `[data-search-bar]` and `[data-search-view]`,
|
||||
and `min-height: 15rem` (240 px) to the docked view. The focus-widen motion would need a
|
||||
`--search-width` custom property toggled by `[data-open]` and animated on the effects-default spring.
|
||||
- Effort: S (widths) / M (motion)
|
||||
- Breaks API? no
|
||||
|
||||
### IN-21 · search · the unfocused leading padding is 16 px where M3 gives 24 dp
|
||||
- Severity: nice-to-have
|
||||
- M3 says: unfocused search bar "Leading padding | Unfocused: 24dp, focused: 12dp" and "Trailing padding |
|
||||
Unfocused: 24dp, focused: 12dp"; contained focused style "Leading padding | 16dp | Trailing padding |
|
||||
16dp" — `docs/reference/m3/raw/components_search_specs.md:338-346,376-386`.
|
||||
- Library does: `resources/css/components/search.css:28-35` gives the bar `padding-inline: 0.25rem` and
|
||||
`resources/css/components/search.css:53-61` gives the leading/clear boxes `width: 3rem` — so the icon
|
||||
glyph starts 4 + 12 = 16 px from the container edge in every state.
|
||||
- Both readings: the site's measurement diagram is an image, so "leading padding" may mean edge-to-tap-
|
||||
target (24 + 12 = 36 px to the glyph, which looks wrong in a 56 dp bar) or edge-to-glyph (24 px). Under
|
||||
the second reading the focused/contained value (16 dp) is exactly what the library does and only the
|
||||
unfocused state is 8 px short.
|
||||
- Fix: if pursued, `search.css:28` → `padding-inline: 0.75rem` when `:not([data-open])` and `0.25rem`
|
||||
when `[data-open]`, transitioned on the effects-fast duration.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-22 · datepicker · Shift+M and Shift+Y do not reach the month/year dropdowns
|
||||
- Severity: nice-to-have
|
||||
- M3 says: date-pickers/accessibility keyboard table — "Shift + M | Moves to the month list dropdown;
|
||||
Shift + Y | Moves to the year list dropdown" (RB §Date Pickers/Accessibility).
|
||||
- Library does: `resources/js/datepicker.js:578-600` handles Arrows, Home, End, PageUp/PageDown (with
|
||||
Shift for ±12 months), Space and Enter; there is no Shift+M / Shift+Y branch. The menu buttons exist
|
||||
and are focusable (`datepicker.blade.php:287-296,309-318,328-337`).
|
||||
- Fix: add two cases to the `gridKey` map in `resources/js/datepicker.js:585-592` that focus
|
||||
`[data-datepicker-menu-button]` (the month one in the docked layout, the combined one in the modal).
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-23 · choices · the combobox has no Home/End keys
|
||||
- Severity: nice-to-have
|
||||
- M3 says: nothing directly — M3 has no combobox component. WAI-ARIA APG's combobox-with-listbox pattern,
|
||||
which the component already follows (`role="combobox"`, `aria-autocomplete`, `aria-activedescendant`),
|
||||
specifies Home/End within the popup.
|
||||
- Library does: `resources/views/components/choices.blade.php:140-145` binds ArrowDown, ArrowUp, Enter,
|
||||
Escape and Tab only.
|
||||
- Fix: add `x-on:keydown.home.prevent` / `.end.prevent` at `choices.blade.php:142` setting
|
||||
`active = 0` / `active = filtered.length - 1` and calling `reveal()`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-24 · slider · Space+Arrows (the large-interval step) is not implemented
|
||||
- Severity: nice-to-have
|
||||
- M3 says: sliders/accessibility keyboard table — "Space & Arrows | Increase/decrease the value by one
|
||||
**interval** or one stop indicator" (RB §Sliders/Accessibility), on top of Arrows (one value) and
|
||||
Home/End.
|
||||
- Library does: the native `<input type="range">` supplies Arrows and Home/End
|
||||
(`slider.blade.php:357-379`); the SKILL documents PageUp/PageDown as a tenth of the range
|
||||
(`SKILL.md:562`), which is an extension, not M3's Space+Arrows.
|
||||
- Fix: add a `keydown` handler in `resources/js/slider.js` that, while Space is held, multiplies the arrow
|
||||
step. Low value — PageUp/PageDown already covers the need and is the more common web idiom.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-25 · chips · no dragged state
|
||||
- Severity: nice-to-have
|
||||
- M3 says: chips are the only component in this group whose state list includes **Dragged**
|
||||
(RB §Chips/Specs, "States (site, all 4 variants): Enabled, Disabled, Hovered, Focused, Pressed,
|
||||
**Dragged**"); dragged state layer opacity is 0.16 (RF §States/Values). Input chips also support
|
||||
"reordering, moving between fields ('To' → 'CC')" (RB §Chips/Behaviour).
|
||||
- Library does: `resources/views/components/chip.blade.php:158` and the `state-layer` utility
|
||||
(`resources/css/tokens/state.css:13-45`) cover hover 8 %, focus 10 % and active 10 % only. No chip is
|
||||
draggable.
|
||||
- Fix: only worth doing alongside a reordering feature — then add
|
||||
`[draggable]:active`/`[data-dragged]` at 16 %.
|
||||
- Effort: L
|
||||
- Breaks API? no (additive)
|
||||
|
||||
### IN-26 · timepicker · the AM/PM buttons are toggle buttons, not radios
|
||||
- Severity: nice-to-have
|
||||
- M3 says: time-pickers/accessibility labelling table — "AM/PM selection | AM or PM | **Radio button (in
|
||||
list)** (Wiz/Compose) | Checkbox (in list) (Android Views)" (RB §Time Pickers/Accessibility).
|
||||
- Library does: `resources/views/components/timepicker.blade.php:239-251` and `:333-345` render two
|
||||
`<button aria-pressed>` in a `role="group"`. A screen reader hears "AM, toggle button, pressed" rather
|
||||
than "AM, radio button, selected, 1 of 2"; both convey the state, and `aria-pressed` avoids needing
|
||||
arrow-key roving, so this is defensible. Noted because the M3 table is explicit.
|
||||
- Fix: if changed, `role="radiogroup"` on the wrapper and `role="radio" aria-checked` on the buttons,
|
||||
with Left/Right arrow handling.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-27 · timepicker · the disabled period button uses a 10 % container instead of 12 %
|
||||
- Severity: nice-to-have
|
||||
- M3 says: disabled container opacity is **0.12**, disabled content 0.38 (RF §States/Values). M3 defines
|
||||
no disabled state for the period selector at all (its state list is Enabled, Hover, Focus, Pressed —
|
||||
RB §Time Pickers/Specs), so this is the library's own `min`/`max` extension and only has to stay inside
|
||||
M3's rules.
|
||||
- Library does: `resources/css/components/timepicker.css:172-174` —
|
||||
`background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent)` with the content
|
||||
at 38 % (correct).
|
||||
- Fix: `timepicker.css:173` → `12%`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-28 · radio · `inline` lays radios in a row, which M3 cautions against
|
||||
- Severity: nice-to-have
|
||||
- M3 says: radio buttons are "Always vertically stacked (never horizontal — flagged as a 'Caution', not
|
||||
an outright 'Don't')" (RB §Radio Button/Behaviour); also "Recommended for five or fewer options" and
|
||||
"One option should always be pre-selected in a radio group".
|
||||
- Library does: `resources/views/components/radio.blade.php:18,39` offers
|
||||
`inline` → `sm:flex sm:flex-wrap sm:gap-x-6`, and `value` (the pre-selection) is optional
|
||||
(`radio.blade.php:19`). Neither is wrong — M3's is a caution — but the SKILL entry
|
||||
(`SKILL.md:552`) presents `inline` without the caveat.
|
||||
- Fix: add one sentence to `SKILL.md:552` and to the `radio.blade.php` header noting M3 prefers a vertical
|
||||
stack, and that a group should normally ship a pre-selected `value`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-29 · password · the reveal button carries both a changing label and `aria-pressed`
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Interactive trailing icons need a functional label (e.g., 'Show password' / 'Hide password')"
|
||||
— RB §Text Fields/Accessibility. It says nothing about a pressed state.
|
||||
- Library does: `resources/views/components/password.blade.php:41-44` binds *both*
|
||||
`aria-label` (flipping between "Show password" and "Hide password") **and** `aria-pressed`. Screen
|
||||
readers then announce e.g. "Hide password, toggle button, pressed" — the state twice, once inverted
|
||||
relative to the label's promise. M3 names only the label pattern; the repo's own recent commit
|
||||
"Keep aria-pressed off a selected link button" shows the same concern elsewhere.
|
||||
- Fix: `password.blade.php:42,44` → drop `aria-pressed` and keep the flipping label (the M3-documented
|
||||
pattern), or keep `aria-pressed` with a fixed "Show password" label. Not both.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### IN-30 · table · the row divider is a colour outside the role set
|
||||
- Severity: nice-to-have
|
||||
- M3 says: colour is applied through the roles; RF §Design tokens frames system tokens as the sanctioned
|
||||
vocabulary and the styles pages give no "60 % of outline variant" value. Opacity modifiers in M3 are
|
||||
reserved for state layers (8/10/10/16 %) and disabled (12/38 %).
|
||||
- Library does: `resources/css/components/table.css:66-68` —
|
||||
`border-bottom: 1px solid color-mix(in srgb, var(--md-sys-color-outline-variant) 60%, transparent)`.
|
||||
(The header rule at `table.css:58` correctly uses full `outline-variant`.) Data tables have no M3 spec,
|
||||
so this is a foundations-level nit, not a spec breach.
|
||||
- Fix: `table.css:67` → `var(--md-sys-color-outline-variant)`, and let the heavier header rule read as
|
||||
heavier by keeping the header's `title-sm` weight contrast, or use `surface-container-highest` if a
|
||||
lighter rule is wanted.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
## Deliberate deviations
|
||||
|
||||
The code explains these; the verdict column is whether the stated reason survives Google's text.
|
||||
|
||||
| Where | Deviation and its stated reason | Holds up? |
|
||||
|---|---|---|
|
||||
| `field.css:8-12` | The outlined field's notch is MUI's `<fieldset>`/`<legend>` technique so the gap is right on any background | Yes. M3 does not prescribe a mechanism; the rendered result (4 dp corners, 1 dp / 2 dp focus outline, `body-small` floated label) matches `OutlinedTextFieldTokens` exactly. |
|
||||
| `field.blade.php:9-13` | `size="sm"` (40 px) / `xs` (32 px) for unlabelled toolbar controls | Yes — M3's rule is "don't apply density **by default**; offer an explicit opt-in" (RF §Accessibility). These are opt-in and documented as unlabelled-control sizes. |
|
||||
| `field.css:417-421` | Read-only fields get a dashed outline "marked on the field, not read off the control" | Partly — see IN-19; M3 says a read-only field keeps the same visual style. |
|
||||
| `select.blade.php:1-8` | The native `<select>` is kept "on purpose", with the M3 menu only where `appearance: base-select` exists | Yes. M3's exposed-dropdown guidance is about the rendered result, and the native control gives the keyboard, type-ahead and AT semantics for free. |
|
||||
| `file.blade.php:1-13` | "M3 has no file field" — the native input is kept and only `::file-selector-button` is restyled as a tonal pill | Yes. RB confirms no file-input component exists; the pill uses `secondary-container` / `on-secondary-container` with 8 % hover and 12 %/38 % disabled — inside M3's rules. |
|
||||
| `chip.blade.php:29-30` | "input chips are flat only, as in M3" | Partly. RB §Chips/Variants says "**All** chips can be elevated (default elevation 0)"; Compose has no `ElevatedInputChip`, which is presumably the real source. The restriction is safe but the stated justification is the implementation, not the spec. |
|
||||
| `chip.blade.php:88-89` | Padding is 1 px short of the token because a CSS border takes room where Compose draws it inside | Yes — a correct compensation; the rendered box matches 16/8/12/4 dp. |
|
||||
| `timepicker.blade.php:37-39` | The period selector follows Compose's `isUpdatedTimepickerToggleEnabled` default — "two separate shape-morphing toggle buttons in primary-container, not the outlined pair its tokens still describe" | Partly — see IN-11. The shape morph is a plausible Expressive reading; the tertiary→primary colour swap is undocumented and the site still lists Tertiary container. |
|
||||
| `datepicker.js:18-19` | "The keyboard is WAI-ARIA's date picker dialog: … Home and End go to the start and end of the week" | Defensible. M3's own table says "Home/End | Move to the first day of the month" (RB §Date Pickers/Accessibility) — a row the reference itself flags as terse/ambiguous. The APG behaviour is the stronger web convention; worth a one-line note in the header that M3's table differs. |
|
||||
| `slider.blade.php:39-41` | The value indicator is "M3's as Flutter draws it (`RoundedRectSliderValueIndicatorShape`): a 32px inverse-surface pill" because "Compose leaves the value label to the app" | No — see IN-05. M3 Expressive's sliders/specs table gives an explicit 44 dp × 48 dp label container that neither source was consulted for. |
|
||||
| `search.css:83-87` | `[data-search-bar] button:focus-visible { outline-offset: -3px }` instead of the package's `+2px` | Yes, in context — the ring is drawn inside a 48 px box that is flush with the 56 px bar; a +2 px ring would clip. |
|
||||
| `table.css:7-10` | Density is keyed "on the pointer, not the width, so a touch tablet in landscape keeps rows a finger can hit" | Partly — the pointer key is a good idea, but the coarse-pointer default is still 44 px (IN-16). |
|
||||
| `chip-set.blade.php:12-14` | `scroll` keeps chips on one line that scrolls sideways "as M3 lays chips out on a narrow screen" | Yes. RB §Chips/Behaviour: "place inline as a single row, wrapping to new lines when needed, or **scroll horizontally** if the field is only one row tall". |
|
||||
|
||||
### Token-citation disagreements
|
||||
|
||||
Places where a header comment's androidx/MDC figure contradicts the M3 site's spec table:
|
||||
|
||||
| File:line | Comment claims | M3 site says | Effect in code |
|
||||
|---|---|---|---|
|
||||
| `slider.blade.php:44` | "handles of 44, 44, **44**, 68 and 108px" (md.comp.slider.* tokens) | 44 / 44 / **52** / 68 / 108 (sliders/specs) | Yes — `slider.blade.php:248` is 44 px (IN-04) |
|
||||
| `slider.blade.php:40-41` | value indicator "a **32px** inverse-surface pill … 4px above the handle" (Flutter) | Label container **44 dp** high, **48 dp** wide, all sizes; `ValueIndicatorActiveBottomSpace = 12dp` | Yes — `slider.blade.php:346,252` (IN-05) |
|
||||
| `timepicker.blade.php:33` | "96×80 time selector boxes" | 96 dp, **and 114 dp for the 24-hour vertical layout** | Yes — `timepicker.css:106` (IN-10) |
|
||||
| `timepicker.blade.php:37-39` | period selector "in **primary-container**, not the outlined pair its tokens still describe" | `PeriodSelectorSelectedContainerColor = TertiaryContainer`; site colour list includes Tertiary container / On tertiary container | Yes — `timepicker.css:165-167` (IN-11) |
|
||||
| `selection.css:1-3` | cites `CheckboxTokens` | `IconSize = 18.0.dp` | Yes — `checkbox.blade.php:41-42` uses `size-4` (16 px) (IN-12) |
|
||||
| `search.css:1-10` | cites `SearchBarTokens` / `SearchViewTokens`; "surface-container-high" for the view | Docked: Surface container high ✓; **Full-screen: Surface container low** | Yes — `search.css:140` (IN-08) |
|
||||
| `pagination/livewire/tailwind.blade.php:7` | "Page numbers are **40px icon-button targets**" | 40 dp is the state layer; the target is 48 dp (RF §States) | Yes — `size-10` (IN-15) |
|
||||
| `chip.blade.php:35-38` | 32 px, small corner, label-large, 18 px icons, 24 px avatar, 16/8/8 and 12/8/4 padding | identical (chips/specs, all four variants) | No — all correct |
|
||||
| `field.css:8-12` | `FilledTextFieldTokens` / `OutlinedTextFieldTokens` | 56 dp height, `CornerExtraSmall` = 4 dp, outline 1/2 dp, indicator 1/2 dp, disabled 12 %/38 %/4 %, `BodyLarge` input, `BodySmall` supporting | No — all correct |
|
||||
| `datepicker.blade.php:39-45` | 360 px, extra-large (modal) / large (docked) corner, elevation 3, surface-container-high, 40 px days in 48 px cells, today outlined primary, range middle secondary-container | `DatePickerModalTokens`: 360 × 568, `CornerExtraLarge`, Level3, Surface container high, cell 40 × 40, today outline 1 dp, range indicator 40 dp | No — all correct (the 48 px cell is the library's own target addition, which M3's 48 dp target rule requires) |
|
||||
| `selection.css:219-223` | switch handle centres at 16 px / 36 px, sizes 16 / 24 / 28 px "measured inside the track's 2px outline" | `SwitchTokens`: track 32 × 52, outline 2, handles 16 / 24 / 28 | Numbers correct; the phrase "inside the track's 2px outline" is wrong (the handle is positioned against the track's outer box, `selection.css:249-251`) — comment only |
|
||||
|
||||
## Aligned
|
||||
|
||||
Verified correct; no need to re-check.
|
||||
|
||||
- **Text field geometry** — 56 px container, 16 px side padding (12 px with icons via `--field-start`),
|
||||
24 px leading icon, 4 dp corner, 1 px → 2 px outline/indicator on focus, `body-large` input,
|
||||
`body-small` floated label and supporting text, 4 px supporting-text top padding
|
||||
(`field.css:27-73,295-344,439-444`).
|
||||
- **Text field colours and states** — outline `outline`, hover `on-surface`, focus `primary`, filled
|
||||
container `surface-container-highest`, filled resting indicator `on-surface-variant`, filled hover
|
||||
`on-surface` 8 %, disabled outline 12 % / content 38 % / filled container 4 % — all match
|
||||
`OutlinedTextFieldTokens` / `FilledTextFieldTokens` (`field.css:33-44,390-437,450-520`).
|
||||
- **Error replaces the hint, never stacks** (`field.blade.php:73-81`), with `role="alert"` on the error —
|
||||
exactly RB §Text Fields/Behaviour and /Accessibility.
|
||||
- **Required asterisk is in the label** (`field.css:346-349`, in both the label and the notch legend) —
|
||||
RB §Text Fields/Accessibility, "the label must include the asterisk".
|
||||
- **Date input is never masked while typing** — `datepicker.js:350-366` only commits a whole, valid date;
|
||||
`parse` (`datepicker.js:274-300`) accepts any separator and optional leading zeros. This is the exact
|
||||
rule in RB §Date Pickers/Accessibility.
|
||||
- **Switch** — every dimension matches `SwitchTokens`: 52 × 32 track, 2 px outline, 16 / 24 / 28 px handle,
|
||||
40 px state layer, 16 px icons, and the full colour map (selected track `primary`, handle `on-primary`,
|
||||
hover/focus/pressed handle `primary-container`, unselected track `surface-container-highest`, handle
|
||||
`outline`, disabled 12 %/38 %) — `selection.css:219-343`.
|
||||
- **Checkbox / radio boxes** — 18 px box with 2 px `on-surface-variant` outline and 2 dp corner, 20 px
|
||||
radio with a 2 px ring, both in 40 px state layers, `primary` when selected, error variants
|
||||
(`selection.css:103-217`). Indeterminate uses a dash icon and is kept in sync through the
|
||||
`indeterminate` DOM property (`field.js:32-34,49-80`) — the only correct way to do it in HTML.
|
||||
- **State-layer opacities everywhere in the group** — 8 % hover (always behind `@media (hover: hover)`),
|
||||
10 % focus, 10 % press, tinted with the content's "on" colour: `selection.css:73-101`,
|
||||
`field.css:268-282`, `datepicker.css:250-268`, `chip.blade.php:158,249`, `state.css:13-45`.
|
||||
- **Focus ring** — 3 px `secondary` at +2 px offset, consistently (`state.css:46-53`, `field.css:274-278`,
|
||||
`selection.css:98-101,241-244`, `datepicker.css:270-275`, `timepicker.css:207-208`,
|
||||
`slider.blade.php:335`).
|
||||
- **Chips** — 32 px height, 8 dp corner, `label-large`, 18 px icons, 24 px avatar, 16/8 dp label padding
|
||||
(1 px short for the CSS border), 8 dp between elements, 8 px chip-to-chip gap, a 48 px `::after` target
|
||||
strip, filter selected `secondary-container`/`on-secondary-container`, stroke `outline-variant`
|
||||
(the Aug-2024 update), elevated on `surface-container-low` at elevation 1 → 2 on hover, disabled
|
||||
12 %/38 % (`chip.blade.php:91-125,197-198`, `chip-set.blade.php:51`).
|
||||
- **Chip keyboard removal** — Backspace/Delete on a focused input chip removes it and moves focus to the
|
||||
previous/next chip, with a `wire:key`-aware re-focus across Livewire morphs (`chips.js:38-118`). Remove
|
||||
buttons are named "Remove {label}" (`chip.blade.php:242`). Exactly RB §Chips/Accessibility.
|
||||
- **Date picker anatomy and geometry** — 360 px container, 120 px header (128 px for range),
|
||||
`headline-large` headline (`title-large` for range), `surface-container-high` at elevation 3,
|
||||
`corner-xl` modal / `corner-lg` docked, 40 px day in a 48 px cell, `body-large` day and weekday labels,
|
||||
today outlined 1 px `primary`, selected `primary`/`on-primary`, range band
|
||||
`secondary-container`/`on-secondary-container` 40 px tall, 72 × 36 year cells, divider under the header
|
||||
(`datepicker.css:64-436`).
|
||||
- **Date picker semantics** — `<table role="grid">` with `<th scope="col">`, per-cell `aria-label` naming
|
||||
the full date, `aria-selected`, `aria-current="date"`, roving `tabindex`, `aria-live` month announcement,
|
||||
and both entry methods always available — matching M3's labelling table
|
||||
(`datepicker.blade.php:284,344-378`).
|
||||
- **Time picker geometry** — 24 dp container padding, 96 × 80 time selectors in `display-large` with a
|
||||
`corner-small` container, 52 × 80 (vertical) / 216 × 38 (horizontal) period selector, 256 px dial in
|
||||
`surface-container-highest` with `body-large` numbers, 48 px selector handle, 8 px centre, 2 px track,
|
||||
96 × 72 input fields in `display-medium` — every number in RB §Time Pickers/Specs
|
||||
(`timepicker.css:50-460`).
|
||||
- **Slider** — track heights 16/24/40/56/96, corners 8/8/12/16/28, 4 px handle narrowing to 2 px on
|
||||
press/focus, 6 px active-handle spacing, 4 px stop indicators, 24/24/32 px inset icons restricted to
|
||||
M/L/XL standard sliders only (never range or centered, exactly RB §Sliders/Variants), `primary` active
|
||||
track and handle, `secondary-container` inactive track, `inverse-surface`/`inverse-on-surface` value
|
||||
indicator in `label-large`, disabled 38 %/12 % (`slider.blade.php:132-252,283-354`).
|
||||
- **Slider accessibility** — real `<input type="range">`s, so Arrows/Home/End and AT come free; a range
|
||||
slider is `role="group"` with `aria-labelledby` and "Range start"/"Range end" handle labels; the
|
||||
drawing is `aria-hidden` (`slider.blade.php:265-379`).
|
||||
- **Reduced motion** — all six spring/easing duration tokens collapse to 0 ms under
|
||||
`prefers-reduced-motion: reduce` (`resources/css/tokens/motion.css:63-75`), and every component in this
|
||||
group animates through those tokens, so nothing needs a per-component opt-out.
|
||||
- **`<x-choices>` combobox semantics** — `role="combobox"`, `aria-autocomplete="list"`, `aria-controls`,
|
||||
`aria-expanded`, `aria-activedescendant`, `role="listbox"`/`role="option"`/`aria-selected`,
|
||||
`aria-disabled` (`choices.blade.php:121-179`). This is what `<x-search>` should look like (IN-01).
|
||||
- **Search geometry** — 56 px pill, `corner-full`, `surface-container-high`, 8 % hover, `body-large`
|
||||
input, 48 px leading/clear targets, docked view `corner-xl` at elevation 3, 72 px full-screen header
|
||||
with safe-area padding, `corner-none` full-screen — all matching `SearchBarTokens` / `SearchViewTokens`
|
||||
(`search.css:20-181`). Escape releases focus, the back arrow dismisses, and the full-screen view traps
|
||||
focus and locks scroll (`search.blade.php:29-32`, `search.js:39-97`).
|
||||
- **Pagination colour choice** — the current page is `secondary-container`/`on-secondary-container`, never
|
||||
the action colour, with `aria-current="page"` (`pagination/livewire/tailwind.blade.php:55`). This is the
|
||||
right reading of RF §Interaction/Selection ("active indicator" components).
|
||||
- **Table roles** — headers `title-small` `on-surface-variant` over an `outline-variant` rule, selected
|
||||
row `secondary-container`/`on-secondary-container` keyed on `aria-selected="true"`, `aria-sort` on the
|
||||
sorted `<th>` (`table.css:53-77`, `sort-header.blade.php:20`). RF §Interaction/Selection lists data
|
||||
tables among the components that inherit selection styling, and this is that styling.
|
||||
|
||||
## Missing
|
||||
|
||||
M3 components, variants or configurations in this group that the library does not implement.
|
||||
|
||||
- **Slider — vertical orientation.** RB §Sliders/Variants: the Expressive configuration table lists
|
||||
"Orientation | Horizontal (default) | Available | Available; Vertical | -- | Available". The library is
|
||||
horizontal only (`slider.blade.php` has no orientation prop). M3's caveat that range sliders must stay
|
||||
horizontal would apply.
|
||||
- **Date pickers — the full-screen range picker.** RB §Date Pickers/Anatomy describes a 14-element
|
||||
full-screen range-selection picker with a close (×) icon button and a **Save** confirmation, used at
|
||||
compact breakpoints. `<x-datepicker range>` always uses the 360 px modal dialog
|
||||
(`datepicker.css:64-129`), which on a phone is cramped for a two-month range scroll.
|
||||
- **Search — the other two entry points.** RB §Search/Behaviour names three: search bar (implemented),
|
||||
**search app bar** (a top app bar whose whole row is the search field, opening the Search view), and
|
||||
**search icon button** (search as a secondary action that expands). Only the first exists.
|
||||
- **Search — the Expressive focus-widen motion** ("the search bar grows wider when focused",
|
||||
RB §Search/Status). See IN-20.
|
||||
- **Text fields — the character counter.** RB §Text Fields/Variants lists "supporting text + character
|
||||
counter" as a configuration of both variants, with its own accessibility label ("Character count,
|
||||
5/20"). No `<x-input>`/`<x-textarea>` prop provides it; `maxlength` passes through to the control but
|
||||
nothing is rendered.
|
||||
- **Text fields — width bounds at medium/expanded.** RB §Text Fields/Behaviour: "compact breakpoints can
|
||||
let a text field span full width; medium/expanded should bound it with flexible margins/other
|
||||
containers — **never let it span the full width of a large screen**." `<x-form>`
|
||||
(`form.blade.php:14`) is an unbounded one-column grid; nothing in the package caps a field's width.
|
||||
Currently the caller's job, undocumented in `SKILL.md:521-535`.
|
||||
- **Checkbox — the ≥ 840 dp grouping rule.** RB §Checkbox/Behaviour: "in expanded breakpoints (≥840dp),
|
||||
group checkboxes inside a contained region (e.g., a side sheet)". No affordance or documentation.
|
||||
- **Chips — the horizontal-overflow affordances.** RB §Chips/Accessibility prescribes either a leading
|
||||
"Show all" filter chip that reflows the list downward, or a leading button opening a menu of all chip
|
||||
options. `<x-chip-set scroll>` fades the scrollable edge instead — a reasonable web idiom, but neither
|
||||
of M3's two named patterns is offered.
|
||||
- **Time picker — the 24-hour inner/outer ring is implemented** (`timepicker.blade.php:126-129`), so this
|
||||
is *not* missing; noted because it is easy to assume otherwise.
|
||||
|
||||
## Breakpoint map
|
||||
|
||||
| Component | Library breakpoint used | M3 window size class it stands in for | Gap |
|
||||
|---|---|---|---|
|
||||
| `<x-search>` full-screen ↔ docked | `(max-width: 39.99rem)` = < 640 px (`resources/js/search.js:11`) | Compact < 600 dp (full-screen), medium/expanded ≥ 600 dp (docked) | 40 px late — 600–639 px gets the full-screen view where M3 wants docked |
|
||||
| `<x-datepicker>` docked → modal dialog | `(max-width: 39.99rem)` = < 640 px (`resources/js/datepicker.js:23`) | Compact < 600 dp (M3: "Used at compact breakpoints as a full-screen dialog"; docked "Used at medium/expanded") | 40 px late; also M3 wants *full-screen* at compact, the library shows a 360 px centred dialog |
|
||||
| `<x-timepicker>` landscape layout | `(orientation: landscape) and (min-width: 37rem)` = ≥ 592 px (`timepicker.css:403`) | No width class — M3 keys this on device orientation and **viewport height** (RB §Time Pickers/Behaviour) | 8 px under 600; the height-based dial shrink at `max-height: 22.75rem` / `21.625rem` (`timepicker.css:449,455`) is Compose's `ClockFaceSizeModifier`, not an M3 window class — acceptable |
|
||||
| `<x-radio inline>` | `sm:` = ≥ 640 px (`radio.blade.php:39`) | Compact < 600 dp stacks; medium+ may lay out horizontally (M3 cautions against horizontal at any width) | 40 px late, and the direction of the rule is a caution either way (IN-28) |
|
||||
| Pagination numbers vs "Page 2 of 7" | `max-sm:` / `sm:` = 640 px (`pagination/livewire/tailwind.blade.php:31-32,48,53`) | Compact < 600 dp | 40 px late — 600–639 px loses the page numbers where M3's medium class would keep them |
|
||||
| `<x-table>` density | `@media (pointer: fine)` (`table.css:37`) | None — M3 has no pointer-keyed density rule; density is an explicit user opt-in at any size | Not a size-class substitution; the problem is that it is on by default (IN-16) |
|
||||
| `<x-field>` / `<x-form>` width | none | Medium/expanded should bound a text field's width (RB §Text Fields/Behaviour) | Not implemented at all — see **Missing** |
|
||||
| `<x-chip-set scroll>` | none (a prop, not a breakpoint) | M3 expects wrap-or-scroll chosen by available width | Caller decides; no adaptive switch |
|
||||
| `<x-chip type="filter">` trailing icon | none | M3: a filter chip's trailing icon is for medium/expanded; at compact the whole chip must be the tap target | Satisfied incidentally — the whole chip is always the target (`chip.blade.php:200`) |
|
||||
| `<x-search>` docked max height | `min(40rem, 70dvh)` (`search.css:136`) | Docked container height min 240 dp, max 2/3 of screen height | 70 dvh vs 66.7 % — negligible; the missing 240 dp minimum is the real gap (IN-20) |
|
||||
|
||||
Note on the Tailwind scale generally: this group consistently uses `sm` (640 px) where M3's compact
|
||||
boundary is 600 dp. The fix is one shared media query — a `--material-compact: (max-width: 37.49rem)`
|
||||
custom media (or a `xs`-ish Tailwind screen at 600 px) referenced by `search.js:11`, `datepicker.js:23`,
|
||||
`radio.blade.php:39` and the pagination views — rather than five separate edits.
|
||||
@@ -1,637 +0,0 @@
|
||||
# Audit: navigation
|
||||
|
||||
Scope: `app-bar`, `toolbar`, `tabs`/`tab`, `navigation-bar(-item)`, `navigation-rail(-item|-section)`,
|
||||
`app-shell`, `section-nav`, `account-menu`, `theme-toggle`, `scheme-picker`, their CSS/JS, the showcase
|
||||
usage and the matching SKILL.md sections.
|
||||
|
||||
Sources abbreviated below: **RCB** = `docs/reference/m3/reference-components-b.md`; **RF** =
|
||||
`docs/reference/m3/reference-foundations.md`; **raw/** = `docs/reference/m3/raw/<page>.md`; **tok/** =
|
||||
`docs/reference/m3/tokens/<File>.kt` (androidx-main). Anything fetched live is named with its URL.
|
||||
|
||||
## Summary
|
||||
|
||||
The component-level work is unusually faithful: every numeric token I checked for the app bar
|
||||
(64/112/136/120/152dp heights, title-large → headline-medium → display-small, 4dp edge spacing), the
|
||||
navigation bar (64dp, 56×32 indicator, 40dp horizontal pill, 16dp indicator spaces, and even Compose's
|
||||
`calculateCenteredContentHorizontalPadding` formula reproduced as `calc(10% * (count + 3))`), the rail
|
||||
(96dp collapsed, 220–360dp expanded, 44dp top space, 40dp header space, 56dp expanded item, modal =
|
||||
surface-container + elevation 2 + `CornerLarge` inner edge) and both toolbars (64dp, 16dp/4–32dp docked,
|
||||
8dp/4dp + `CornerFull` floating) matches the androidx token files exactly. The catalogue is also right
|
||||
about Expressive: the deprecated bottom app bar and navigation drawer are absent, the flexible app bars
|
||||
and the flexible navigation bar are what is implemented, and the expanded rail correctly plays the
|
||||
drawer's role. The real problems are three: **the adaptive breakpoint map is Tailwind's, not M3's** — `sm`
|
||||
640 stands in for 600 and, much worse, `lg` 1024 stands in for 840, so every window from 840 to 1023px is
|
||||
denied the standard expanded rail and gets a modal that re-closes on every navigation; **two hand-rolled
|
||||
buttons (`theme-toggle`, `account-menu`) are 40×40 with no 48px target**, below M3's stated minimum, while
|
||||
the library's own `<x-button>` and the rail's menu button both do it correctly; and **a few placement
|
||||
rules are simply not wired up** — a `place="bottom"` toolbar sits on top of the app shell's navigation
|
||||
bar, a rail-nested FAB keeps elevation 3 where M3 says level 0, and `theme-toggle mode="picker"`
|
||||
hand-rolls the Expressive-deprecated segmented button even though the library already ships its
|
||||
successor (`<x-group>`).
|
||||
|
||||
## Findings
|
||||
|
||||
### N-01 · theme-toggle, account-menu · 40×40 controls with no 48px interaction target
|
||||
- Severity: must-fix
|
||||
- M3 says: "Touch target minimum **48 × 48dp**"; "Web default target size: at least 48 × 48 **CSS
|
||||
pixels**"; and, precisely for this case, "The size of state layers is 40dp while the interactive target
|
||||
size is 48dp" (RF §Accessibility Values, §States/state-layer mechanics). RF §Layout/Density repeats:
|
||||
"Interaction targets must stay ≥48×48dp even when the visible element is scaled down."
|
||||
- Library does: `resources/views/components/theme-toggle.blade.php:65` renders `inline-flex size-10` (40px)
|
||||
with no target extension; `resources/views/components/account-menu.blade.php:41` renders
|
||||
`inline-flex size-10 … overflow-hidden` (40px), likewise none. The library already knows the fix: the
|
||||
icon button applies `after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-12
|
||||
after:min-w-12 after:-translate-x-1/2 after:-translate-y-1/2` at `xs`/`sm`
|
||||
(`resources/views/components/button.blade.php:171-172`, comment "Below 48px the touch target reaches
|
||||
past the button, as M3 requires"), and the rail's menu button repeats it at
|
||||
`resources/views/components/navigation-rail.blade.php:108` (`after:size-12`).
|
||||
- Fix: add the same `after:` pseudo-target to both class lists. For `account-menu`, `overflow-hidden` on
|
||||
the button would clip the pseudo-element — move the clipping to the `<img>` (`class="size-full
|
||||
rounded-corner-full object-cover"`) and drop `overflow-hidden` from the button.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### N-02 · toolbar, app-shell · a `place="bottom"` toolbar sits on top of the navigation bar
|
||||
- Severity: must-fix
|
||||
- M3 says: "Docked toolbar and navigation bar occupy the same screen position — **never show both
|
||||
simultaneously**" (RCB §Toolbars/Behaviour; raw/components_toolbars_guidelines.md). For the floating
|
||||
case: "FAB, when present, floats above the nav bar, right-aligned — **must never overlap/cover it**"
|
||||
(RCB §Navigation Bar/Behaviour), and the toolbar occupies the same rail region as a FAB
|
||||
(raw/foundations_layout_scaffold_rails.md: "On mobile, a toolbar can float in the rail region" — the
|
||||
rail region sits *above* the navigation bar, item 1: "A toolbar sits above the navigation bar").
|
||||
- Library does: `resources/css/components/toolbar.css:75-81` pins the toolbar at
|
||||
`bottom: calc(1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))` — it ignores
|
||||
`--material-bottom-bar`, the variable the shell sets for exactly this purpose
|
||||
(`resources/views/components/app-shell.blade.php:88`). The library's FAB
|
||||
(`resources/views/components/button.blade.php:177`,
|
||||
`max-sm:bottom-[calc(var(--material-bottom-bar,0px)+1rem)]`) and the snackbar
|
||||
(`resources/views/components/toast.blade.php:36`) both do use it. A docked
|
||||
`variant="docked" place="bottom"` toolbar (`toolbar.css:91-96`, `bottom: 0`) lands squarely on the
|
||||
navigation bar with no warning anywhere in the code or in SKILL.md:754-764.
|
||||
- Fix: in `toolbar.css`, change `[data-toolbar-place="bottom"]`'s `bottom` to
|
||||
`calc(var(--material-bottom-bar, 0px) + 1rem + var(--material-safe-bottom, env(safe-area-inset-bottom)))`
|
||||
(mirroring the FAB/toast rule, which already folds the safe area into `--material-bottom-bar`), and for
|
||||
`[data-toolbar][data-variant="docked"][data-toolbar-place="bottom"]` `bottom: var(--material-bottom-bar, 0px)`.
|
||||
Add one line to SKILL.md's `<x-toolbar>` section: a docked toolbar and a navigation bar must never be on
|
||||
screen together — show the bar on primary pages, the toolbar on secondary/contextual ones.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### N-03 · navigation-rail · a FAB nested in the rail keeps elevation 3
|
||||
- Severity: must-fix
|
||||
- M3 says: "When nested within another component, such as the navigation rail, the FAB's resting
|
||||
elevation should be **level 0**" (raw/components_navigation-rail_guidelines.md:213; RCB §Navigation
|
||||
Rail/Behaviour: "the site explicitly states this contrasts with a standalone FAB's normal elevation").
|
||||
- Library does: `resources/views/components/fab.blade.php:49` always applies
|
||||
`shadow-elevation-3 hover:shadow-elevation-4`; `resources/css/components/navigation.css` contains no
|
||||
rule for a FAB inside a rail (grep for `fab` in it returns nothing). The documented pattern
|
||||
(`navigation-rail.blade.php:7-9`, SKILL.md:717-719, `showcase/shell.blade.php:48-49`) puts a FAB in the
|
||||
`header` slot, so every documented use is at elevation 3.
|
||||
- Fix: the FAB has no attribute hook today (`fab.blade.php:65` renders `<{{ $tag }} {{ $attributes }}>`
|
||||
with classes only), so add `data-fab` to its merged attributes, then add an **unlayered** rule to
|
||||
`navigation.css` — `shadow-elevation-3` is a Tailwind utility, so a `@layer components` rule would lose,
|
||||
exactly as `toolbar.css:10-12` already explains for its vibrant recolouring:
|
||||
`[data-navigation-rail-header] [data-fab], [data-navigation-rail-header] [data-fab]:hover { box-shadow: none; }`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### N-04 · theme-toggle · `mode="picker"` renders the Expressive-deprecated segmented button
|
||||
- Severity: must-fix
|
||||
- M3 says: segmented buttons "are deprecated in the expressive update"; "connected button groups should
|
||||
replace the baseline segmented button" (`docs/reference/m3/reference-components-a.md:343-345, :224, :257`).
|
||||
- Library does: `resources/views/components/theme-toggle.blade.php:22-48` hand-rolls one — a 40px
|
||||
(`h-10`) outlined stadium (`rounded-corner-full border border-outline`) whose segments share dividing
|
||||
borders (`not-first:border-s`) and whose selected segment is `bg-secondary-container` with a check icon.
|
||||
That is the `OutlinedSegmentedButtonTokens` design (40dp, `CornerFull`, 1dp outline, label-large),
|
||||
point-for-point. Its own header comment says so: "picker: M3's segmented buttons for the three
|
||||
choices". Meanwhile the library ships the successor twice over:
|
||||
`resources/views/components/group.blade.php:1-2` ("An M3 Expressive connected button group — the
|
||||
successor of the segmented button", native radios, arrow-key movement, selected segment rounds fully)
|
||||
and `resources/views/components/button-group.blade.php` (`connected`). The showcase puts the deprecated
|
||||
control in its own chrome at `showcase/layout.blade.php:148`.
|
||||
- Fix: re-implement `mode="picker"` on top of `<x-group>` with
|
||||
`:options="[['id' => 'light', 'name' => __('Light'), 'icon' => 'light_mode'], …]"` and an `x-model`-ish
|
||||
binding onto `$store.theme.choice` (a small `x-data` wrapper writing `$store.theme.set()` on change). The
|
||||
selected-segment shape morph, the radiogroup semantics and the arrow keys then come for free — which
|
||||
also resolves N-17.
|
||||
- Effort: M
|
||||
- Breaks API? no (the `mode="picker"` prop name and the `data-theme-toggle="picker"` /
|
||||
`data-theme-option` hooks can be kept; the internal markup changes, so any app CSS targeting the inner
|
||||
buttons would move)
|
||||
|
||||
### N-05 · tabs · every panel is visible until Alpine initialises
|
||||
- Severity: must-fix
|
||||
- M3 says: tabs show one destination at a time; the active indicator plus a single visible panel is the
|
||||
whole mechanism (RCB §Tabs/Anatomy, /Behaviour). Exposing all panels also breaks the ARIA tabs
|
||||
contract — only the panel for the selected tab may be rendered/visible.
|
||||
- Library does: `resources/views/components/tab.blade.php:10` hides the panel with `x-show` only, and
|
||||
there is no `x-cloak` or server-side `hidden`/`style="display:none"`. `[x-cloak]{display:none!important}`
|
||||
*is* defined (`resources/css/tokens/state.css:68-70`) but unused here. Until Alpine boots, all panels
|
||||
stack vertically (the showcase's 4-tab example renders all four bodies), and a screen reader reaching
|
||||
the page early sees four `role="tabpanel"` regions at once. The same line binds `x-bind:id`, so
|
||||
`aria-controls="…-panel"` on every tab button (`tabs.blade.php:65`) dangles until Alpine runs.
|
||||
- Fix: in `tab.blade.php`, render the id server-side the way the tab buttons already do (pass the `$id`
|
||||
down, or reuse `tabsId` via a `@aware`/slot prop) and add `x-cloak` plus a server-rendered
|
||||
`@if ($name !== $initial) style="display: none" @endif` — or simplest: keep `x-show`, add `x-cloak`, and
|
||||
add a CSS rule that un-cloaks the initially selected panel. Any of these removes both the flash and the
|
||||
dangling `aria-controls`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### N-06 · app-shell, navigation-rail · `lg` (1024px) stands in for M3's expanded breakpoint (840dp)
|
||||
- Severity: should-fix
|
||||
- M3 says: breakpoints are Compact <600, Medium 600–839, **Expanded 840–1199**, Large 1200–1599,
|
||||
Extra-large ≥1600 (RF §Layout/Breakpoints). For Expanded the recommended navigation is "**Modal or
|
||||
standard expanded navigation rail**", and the swap table gives Expanded a "Standard expanded navigation
|
||||
rail" (RF §Layout/Breakpoints tables). The rail page: "Expanded → extra-large → use a rail (never a
|
||||
bar); choose Standard vs. Modal based on space and destination count" (RCB §Navigation Rail/Behaviour).
|
||||
- Library does: the threshold is Tailwind's `lg` = 64rem = **1024px**, in three places that must agree —
|
||||
`resources/js/navigation.js:16` (`const WIDE = '(min-width: 64rem)'`), `resources/css/components/navigation.css:51`
|
||||
and `:57` (the `rail-collapsed` variant) and `:184-188` (the adaptive rail's 96px layout width). Below
|
||||
it, `materialNavigationRail.modal` is true (`navigation.js:116-118`) and `expanded` ignores
|
||||
`$store.rail.collapsed` (`:124-130`).
|
||||
**What a user sees at 840–1023px** (a landscape tablet, a half-screen desktop window, a 12.9" iPad):
|
||||
a 96px collapsed rail; pressing its menu button opens the expanded rail *as a modal over a scrim* with a
|
||||
focus trap; `document.addEventListener('livewire:navigating', … hide())` (`navigation.js:86`) closes it
|
||||
on every destination click, so the rail must be re-opened for each navigation; and the remembered
|
||||
`$store.rail` = expanded preference is silently ignored. M3 lists a standard (in-layout) expanded rail
|
||||
for this whole band.
|
||||
- Fix: change `WIDE` to `'(min-width: 52.5rem)'` (840px) and the three `64rem` media queries in
|
||||
`navigation.css` to `52.5rem`. If 840px feels too narrow for a 256px rail beside content, expose the
|
||||
threshold instead of hard-coding it: a `--material-rail-expanded-at` custom property or a `wide-at` prop
|
||||
on `<x-app-shell>`/`<x-navigation-rail mode="adaptive">`, defaulting to 840px. Update SKILL.md:665 and
|
||||
:735, which both say "to `lg`" / "from `lg`".
|
||||
- Effort: M
|
||||
- Breaks API? no (unless an app relies on the 1024px switch point visually)
|
||||
|
||||
### N-07 · app-shell, navigation-bar · `sm` (640px) stands in for M3's compact/medium boundary (600dp)
|
||||
- Severity: should-fix
|
||||
- M3 says: Compact is "Under 600dp"; Medium is "600–839dp" (RF §Layout/Breakpoints). The navigation bar
|
||||
is for "compact (<600dp) and medium (600–839dp)" (RCB §Navigation Bar/Behaviour); at Medium the swap
|
||||
table gives "Collapsed navigation rail" as the primary navigation (RF §Layout/Breakpoints).
|
||||
- Library does: two different numbers for the same boundary. The **bar's own item layout** switches at the
|
||||
right place — `@container (width >= 37.5rem)` = 600px (`navigation.css:123`, `:465`, `:519`, `:525`,
|
||||
`:555`, `:562`), correct and matching `ShortNavigationBarArrangement.Centered`. The **shell**, however,
|
||||
switches at `sm` = 40rem = 640px: `app-shell.blade.php:142` (`sm:hidden` on the bar wrapper), `:87`
|
||||
(`sm:flex`), `:88` and `:136` (the `--material-bottom-bar` padding), and `navigation.css:190-194` /
|
||||
`:235-279` (the adaptive rail is width 0 and `display:none` below 40rem).
|
||||
**What a user sees at 600–639px**: the navigation bar is still the only navigation, but it has already
|
||||
flipped to the medium horizontal (icon-beside-label, centred) configuration — so the Expressive medium
|
||||
bar exists in a 40px-wide window band and then disappears entirely at 640px, where the collapsed rail
|
||||
takes over. The two switch points should be the same number.
|
||||
- Fix: replace the `sm:`/`max-sm:` utilities in `app-shell.blade.php` (lines 87, 88, 136, 142) with
|
||||
arbitrary variants at 600px (`max-[37.5rem]:` / `min-[37.5rem]:`), or define a `compact`/`medium`
|
||||
custom variant in the theme so the whole package has one name for 600px; change the two `40rem` media
|
||||
queries in `navigation.css` to `37.5rem`. Also update the documented wrapper in
|
||||
`navigation-bar.blade.php:4` and SKILL.md:700-707, which teach `sm:hidden`.
|
||||
- Effort: M
|
||||
- Breaks API? no (the documented `sm:hidden` wrapper idiom changes in the docs)
|
||||
|
||||
### N-08 · navigation-bar-item · the horizontal (medium) item label is label-large, not label-medium
|
||||
- Severity: should-fix
|
||||
- M3 says: `NavigationBarTokens.LabelTextFont = TypographyKeyTokens.LabelMedium`
|
||||
(tok/NavigationBarTokens.kt). There is no per-layout label font: `NavigationBarHorizontalItemTokens`
|
||||
defines only `ActiveIndicatorHeight`/`LeadingSpace`/`TrailingSpace`/`IconSize`, and Compose's
|
||||
`ShortNavigationBarItem` passes `labelTextStyle = NavigationBarTokens.LabelTextFont.value` for *both*
|
||||
icon positions (androidx `ShortNavigationBar.kt:220`, fetched from
|
||||
https://raw.githubusercontent.com/androidx/androidx/androidx-main/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/ShortNavigationBar.kt).
|
||||
- Library does: `resources/css/components/navigation.css:147-148` sets
|
||||
`font: var(--md-sys-typescale-label-lg)` inside `@container (width >= 37.5rem)`. (The rail's horizontal
|
||||
item *is* label-large — `NavigationRailHorizontalItemTokens.LabelTextFont = LabelLarge` — which is
|
||||
probably where this came from; the nav bar has no such token.)
|
||||
- Fix: `navigation.css:147-148` → keep `label-md` (delete the two lines; the base `[data-navigation-pill]`
|
||||
rule at `:105-106` already sets label-medium).
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### N-09 · app-bar · the search variant's container does not follow the 312dp / 50% growth rule
|
||||
- Severity: should-fix
|
||||
- M3 says: "The search container of the search app bar should fill 100% of the space between leading and
|
||||
trailing app bar elements **until it reaches 312dp. Then, it should only grow further to fill 50% of
|
||||
that space**" (raw/components_app-bars_guidelines.md:454; RCB §Top App Bar/Behaviour).
|
||||
- Library does: `resources/css/components/app-bar.css:133-137` — `[data-app-bar-search] { flex: 1 1 0%; }`,
|
||||
i.e. 100% of the available space at every width. On a 1600px window the search bar spans ~1500px. The
|
||||
showcase works around it by hand (`showcase/layout.blade.php:60`, `class="mx-auto w-full max-w-2xl"`),
|
||||
which is evidence the component's own behaviour is wrong, and `max-w-2xl` (672px) is not the M3 curve
|
||||
either.
|
||||
- Fix: in `app-bar.css`, give the search slot the M3 curve:
|
||||
`[data-app-bar-search] { flex: 1 1 0%; max-width: calc(19.5rem + (100% - 19.5rem) / 2); margin-inline: auto; }`
|
||||
(19.5rem = 312px; below 312px `max-width` never binds so it still fills 100%). Then drop the manual
|
||||
`max-w-2xl` in the showcase layout.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### N-10 · tabs · scrollable tabs have no 52dp leading offset
|
||||
- Severity: should-fix
|
||||
- M3 says: "When using scrollable tabs, the first visible tab should be offset by **52dp** from the left
|
||||
side of the device for both web and mobile"; "Do — Offset the first scrollable tab 52dp from the leading
|
||||
edge so it's clear that more content is available" (raw/components_tabs_guidelines.md:150, :157; RCB
|
||||
§Tabs/Variants).
|
||||
- Library does: `resources/css/components/tabs.css:50-52` only sets `flex: none` on the tabs when
|
||||
`[data-scrollable]` is present; there is no leading padding anywhere on `[data-tabs-bar]`. The showcase's
|
||||
scrollable example (`showcase/sections/bars.blade.php:101-105`) starts "January" flush against the
|
||||
container edge, so nothing signals that the set scrolls.
|
||||
- Fix: `tabs.css`, add
|
||||
`[data-tabs-bar][data-scrollable] { padding-inline-start: 3.25rem; }` (52px) — logical property, so RTL
|
||||
mirrors for free.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### N-11 · app-bar · the centred headline uses fixed 56px insets and collides with two trailing actions
|
||||
- Severity: should-fix
|
||||
- M3 says: the centred configuration is the Expressive replacement for the center-aligned bar ("Merged
|
||||
into **small**. Use centered-text configuration.", RCB §Top App Bar/Status), and trailing elements are
|
||||
"up to 2 icon buttons after headline" (RCB §Top App Bar/Behaviour). The headline must not be truncated
|
||||
(raw/components_app-bars_guidelines.md:337).
|
||||
- Library does: `resources/css/components/app-bar.css:122-130` absolutely positions the centred headline
|
||||
with `inset-inline: 3.5rem` — a hard-coded 56px on each side, enough for exactly one 48px icon button.
|
||||
With the M3-sanctioned two trailing buttons (≈96px plus the 4px row padding), the headline box extends
|
||||
~40px underneath them; a long title ellipsises *behind* the buttons instead of before them. Nothing in
|
||||
the code adapts the inset to what the `navigation`/`actions` slots actually contain.
|
||||
- Fix: replace the absolute positioning with a three-column grid on the row for the centred variant —
|
||||
`[data-app-bar][data-variant="center"] [data-app-bar-row] { display: grid; grid-template-columns: 1fr auto 1fr; }`
|
||||
with the leading slot in column 1, the headline (centred, `justify-self: center`, `min-width: 0`) in
|
||||
column 2 and the trailing slot in column 3 (`justify-self: end`) — the headline then shrinks against
|
||||
whatever is actually beside it. Failing that, measure the two slots in `app-bar.js` (which already runs
|
||||
a `ResizeObserver`) and publish `--app-bar-lead`/`--app-bar-trail`.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### N-12 · toolbar · a vertical floating toolbar placed at `end` uses a 16px margin, M3 wants ≥24dp
|
||||
- Severity: should-fix
|
||||
- M3 says: "Horizontal toolbars should have a minimum 16dp margin from the edge of the window";
|
||||
"**Vertical toolbars should have a minimum 24dp margin**" / "Maintain at least a 24dp margin for
|
||||
vertical toolbars" (raw/components_toolbars_guidelines.md:334, :346, :350; RCB §Toolbars/Specs).
|
||||
- Library does: `resources/css/components/toolbar.css:83-89` —
|
||||
`[data-toolbar-place="end"] { inset-inline-end: calc(1rem + var(--material-safe-right, …)); }`, 16px for
|
||||
every toolbar placed at the end edge, and `place="end"` is documented as the placement "for a vertical
|
||||
one" (`toolbar.blade.php:6`).
|
||||
- Fix: `toolbar.css`, split the rule:
|
||||
`[data-toolbar][data-vertical][data-toolbar-place="end"], [data-toolbar-group][data-vertical][data-toolbar-place="end"] { inset-inline-end: calc(1.5rem + var(--material-safe-right, env(safe-area-inset-right))); }`
|
||||
(24px) while horizontal placements keep 16px.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### N-13 · toolbar · standard (non-filled, non-tonal) buttons inside a standard toolbar are on-surface-variant, M3 says Primary
|
||||
- Severity: should-fix (with a caveat — see both readings)
|
||||
- M3 says: standard toolbar colour list, verbatim: "1. Surface container / 2. Filled button (Primary, On
|
||||
primary) / 3. Toggle tonal button (Secondary container, On secondary container) / **4. Standard button
|
||||
(Primary)**" (raw/components_toolbars_specs.md:228-234; RCB §Toolbars/Specs). The vibrant list gives
|
||||
"Standard button (On primary container)" (:253) — which the library *does* honour.
|
||||
- Library does: `resources/css/components/toolbar.css:49-61` recolours buttons only in the vibrant case.
|
||||
In a standard toolbar the icon buttons keep the library's default icon-button ink,
|
||||
`text-on-surface-variant` (`resources/views/components/button.blade.php:116` `$quietInk`, :123 for
|
||||
icon-only text buttons) — the container's own `color: var(--md-sys-color-on-surface-variant)`
|
||||
(`toolbar.css:28`, `:37`) cannot reach them because they carry a utility.
|
||||
**The other reading**: M3's own icon-button spec gives an unselected standard icon button
|
||||
`on-surface-variant`, and the toolbar colour list is annotated from an image showing text/standard
|
||||
buttons rather than icon buttons. If the library treats the toolbar list as applying to *label* buttons
|
||||
only, its current choice is defensible. I lean to the finding because the same list's vibrant row *is*
|
||||
implemented, so the two rows are being read inconsistently.
|
||||
- Fix: if adopting the spec, add next to the vibrant rules (same unlayered block):
|
||||
`[data-toolbar]:not([data-vibrant]) [data-icon-button]:not([aria-pressed="true"]) { color: var(--md-sys-color-primary); }`.
|
||||
If keeping the current behaviour, say so in `toolbar.css`'s header comment with the reason, so the next
|
||||
reader does not re-litigate it.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### N-14 · app-shell · the app bar sits inside the content pane, beside the rail, not across the window
|
||||
- Severity: should-fix (structural; see both readings)
|
||||
- M3 says: the scaffold is bars → rails → panes, in that nesting order. "Bars can frame the page";
|
||||
"**Rails create the perimeter space surrounding panes**"
|
||||
(raw/foundations_layout_scaffold_overview.md:54-58); "Rails are the next level in layout **after bars**,
|
||||
filling the perimeter space surrounding panes"; "Rails occupy the spaces immediately adjacent to bars"
|
||||
(raw/foundations_layout_scaffold_rails.md:56, :64). And for the bar itself: "Container always spans 100%
|
||||
of window width at its default height" (RCB §Top App Bar/Behaviour).
|
||||
- Library does: `resources/views/components/app-shell.blade.php:97-139` puts `<x-navigation-rail>` as a
|
||||
flex sibling *before* a column that contains `{{ $top }}` and `<main>`. From `sm` the app bar therefore
|
||||
starts 96px (or 256px) in from the window's leading edge and is a pane-level bar, not a page-level one.
|
||||
There is no slot that renders above the rail, so an application cannot opt into the M3 arrangement
|
||||
without abandoning `<x-app-shell>`.
|
||||
**The other reading**: `NavigationSuiteScaffold` in Compose nests the whole content — top bar
|
||||
included — beside the rail, and M3's canonical-layout imagery shows both arrangements. If the library
|
||||
regards the app bar as a pane bar, the current structure is consistent; it is still worth a documented
|
||||
decision rather than an accident of markup order.
|
||||
- Fix: add a `window-top` (or `banner`) slot to `<x-app-shell>` rendered as the first child of
|
||||
`[data-app-shell]` with the shell root becoming `flex flex-col` and the rail+content row nested inside,
|
||||
so an application can choose either. Document which slot means which in SKILL.md:663-695.
|
||||
- Effort: M
|
||||
- Breaks API? no (additive slot)
|
||||
|
||||
### N-15 · account-menu · the avatar trigger has no state layer (no hover or pressed state)
|
||||
- Severity: should-fix
|
||||
- M3 says: the six states are Enabled, Disabled, Hover, Focused, Pressed, Dragged, and "States have two
|
||||
visual indicators to ensure accessibility"; the state layer's colour equals the content's "on" colour,
|
||||
at 8% hover / 10% focus / 10% pressed (RF §Interaction/States).
|
||||
- Library does: `resources/views/components/account-menu.blade.php:41` —
|
||||
`focus-ring inline-flex size-10 … rounded-corner-full bg-primary-container … text-on-primary-container`.
|
||||
It has the focus ring but no `state-layer`, so there is no hover and no pressed feedback at all. Every
|
||||
comparable trigger in the library (the rail menu button at `navigation-rail.blade.php:108`, the theme
|
||||
toggle at `theme-toggle.blade.php:65`, every `<x-button>`) carries `state-layer`.
|
||||
- Fix: add `state-layer` to the class list. Note the ordering constraint from
|
||||
`resources/css/tokens/state.css:13-27` (the utility sets `position: relative; isolation: isolate` and
|
||||
paints a `::before` at `z-index: -1`) — with `overflow-hidden` removed per N-01 this composes cleanly.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### N-16 · section-nav · secondary tabs wrapped onto a 3/4-column grid break the tab bar's divider and indicator
|
||||
- Severity: should-fix
|
||||
- M3 says: "Container always spans full width, divided into equal sections; **bottom-edge divider
|
||||
separates it from content below**" (RCB §Tabs/Behaviour). M3's stated answer to "too many tabs" is
|
||||
scrollable tabs — "scrollable allows longer labels and more tabs" (RCB §Tabs/Variants) — and its
|
||||
accessibility page explicitly blesses them: "Horizontal scrolling tabs meet accessibility requirements
|
||||
because they need to increase in width to respond to label text without affecting the layout"
|
||||
(raw/components_tabs_accessibility.md:95).
|
||||
- Library does: `resources/views/components/section-nav.blade.php:31-35, :60` reuses
|
||||
`[data-tabs-bar][data-variant="secondary"]` but applies `sm:grid sm:grid-cols-3` / `sm:grid-cols-4`, so
|
||||
from 5 items the "tab bar" becomes two or more rows. `tabs.css:19` puts the 1px outline-variant divider
|
||||
on the *bar*, so only the last row gets one and the upper rows' 2px active indicators float in mid-air
|
||||
against nothing. `[data-tab] { min-width: 5.625rem }` (`tabs.css:36`) also fights `grid-cols-4` in a
|
||||
narrow column.
|
||||
- Fix: either keep `sm:flex` with `[data-scrollable]` semantics (`flex: none` + the 52dp offset from
|
||||
N-10), which is what M3 prescribes and what the library already implements for `<x-tabs scrollable>`;
|
||||
or, if the grid is kept deliberately, move the divider to each row
|
||||
(`[data-section-nav] [data-tabs-bar] > li { border-bottom: 1px solid var(--md-sys-color-outline-variant); }`
|
||||
with the bar's own border removed) and drop the `min-width` inside the grid. See also "Deliberate
|
||||
deviations" — the stated reason for the grid does not survive M3's text.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### N-17 · theme-toggle · the picker's radiogroup keyboard handling is incomplete
|
||||
- Severity: should-fix
|
||||
- M3 says: a single-select segmented control's role is "Radiogroup"
|
||||
(`docs/reference/m3/reference-components-a.md:392`); RF §Interaction/Inputs and the per-component
|
||||
accessibility pages defer to the APG pattern, in which a radio group handles Up/Down as well as
|
||||
Left/Right and wraps from last to first.
|
||||
- Library does: `resources/views/components/theme-toggle.blade.php:38-39` handles only
|
||||
`keydown.arrow-right` / `keydown.arrow-left`, via `$el.nextElementSibling` / `previousElementSibling`,
|
||||
so there is **no wrap** (Right on "System" and Left on "Light" do nothing), **no Up/Down**, and **no
|
||||
Home/End**. `aria-checked="false"` is also rendered on all three server-side (`:33`), so before Alpine
|
||||
boots the group reports nothing checked.
|
||||
- Fix: fixed by N-04 — `<x-group>` uses native radios in a fieldset, where the browser supplies wrap,
|
||||
both axes and the roving tab stop. If N-04 is not taken, add `arrow-down`/`arrow-up`, wrap with
|
||||
`nextElementSibling ?? firstElementChild`, and render the initial `aria-checked` from the server
|
||||
default.
|
||||
- Effort: S (as part of N-04)
|
||||
- Breaks API? no
|
||||
|
||||
### N-18 · tabs · the primary active indicator is missing its 2dp side inset
|
||||
- Severity: should-fix
|
||||
- M3 says: "Primary tab active indicators are additionally **inset 2dp on each side**" (RCB §Tabs/Specs,
|
||||
from tabs/specs page text); "Active indicator minimum length 24dp".
|
||||
- Library does: `resources/css/components/tabs.css:119-129` — `inset-inline: 0` on
|
||||
`[data-tab-indicator]`, which for a primary tab is scoped to `[data-tab-content]`
|
||||
(`min-width: 1.5rem` = the 24dp minimum ✓, `tabs.css:108`). The 3px height, the `3px 3px 0 0` corners
|
||||
and the 2px/square secondary variant are all correct; only the inset is absent.
|
||||
- Fix: `tabs.css`, in the primary case: `[data-tabs-bar]:not([data-variant="secondary"]) [data-tab-indicator] { inset-inline: 2px; }`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### N-19 · navigation-bar-item · the state layer uses on-surface where the rail uses on-secondary-container
|
||||
- Severity: should-fix
|
||||
- M3 says: "The state layer's color equals the content's **'on' color**" (RF §Interaction/States,
|
||||
state-layer mechanics). For navigation items androidx is explicit and uses one colour for active *and*
|
||||
inactive: `ItemActiveHoveredStateLayer = ItemActiveFocusedStateLayer = ItemActivePressedStateLayer =
|
||||
ItemInactiveHoveredStateLayer = ItemInactiveFocusedStateLayer = ItemInactivePressedStateLayer =
|
||||
OnSecondaryContainer` (tok/NavigationRailColorTokens.kt). `NavigationBarTokens.kt` ships no state-layer
|
||||
tokens, so the rail's are the only stated ones for a navigation item.
|
||||
- Library does: two different answers for the same kind of item. The rail is right —
|
||||
`navigation.css:544-552` paints both `::before` layers with
|
||||
`var(--md-sys-color-on-secondary-container)`. The navigation bar is not: `navigation.css:504-517` leaves
|
||||
`background-color: var(--md-sys-color-on-surface)` for `[data-navigation-bar-item]`'s indicator and pill
|
||||
layers. On an active bar item the pill is secondary-container, so the hover layer is an on-surface wash
|
||||
over secondary-container rather than the on-secondary-container the content uses.
|
||||
- Fix: `navigation.css`, add `[data-navigation-bar-item] :is([data-navigation-indicator],
|
||||
[data-navigation-pill])::before { background-color: var(--md-sys-color-on-secondary-container); }` after
|
||||
the shared rule, matching the rail's treatment two blocks below.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### N-20 · tabs · the focus ring is drawn inside the tab (`outline-offset: -3px`) instead of M3's 2px out
|
||||
- Severity: nice-to-have
|
||||
- M3 says: Foundations calls the indicator only "ring-like" and gives no number (RF §Interaction/States:
|
||||
"No thickness, offset, or color token is specified anywhere in Foundations"), but the library has picked
|
||||
one convention for the whole package — `focus-ring` = 3px secondary at `outline-offset: 2px`
|
||||
(`resources/css/tokens/state.css:46-53`), and the navigation bar/rail items follow it
|
||||
(`navigation.css:554-583`).
|
||||
- Library does: `resources/css/components/tabs.css:81-85` uses `outline-offset: -3px`, drawing the ring
|
||||
inside the tab's own box. (The likely reason is `overflow-x: auto` on the bar clipping an outward ring;
|
||||
the file says nothing about it.)
|
||||
- Fix: either keep it and record the reason in `tabs.css`'s header comment, or give the bar
|
||||
`padding-block: 3px` / `scroll-padding-inline: 3px` room and switch to `outline-offset: 2px` so tabs
|
||||
match every other focusable thing in the library.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### N-21 · tabs · `aria-current="page"` gets the indicator but not the active label colour in the primary variant
|
||||
- Severity: nice-to-have
|
||||
- M3 says: primary tabs' active label is Primary; secondary tabs' is On surface (RCB §Tabs/Specs).
|
||||
- Library does: `resources/css/components/tabs.css:87-93` — the active colour rule for the primary variant
|
||||
matches only `[aria-selected="true"]`, while the secondary rule matches
|
||||
`:is([aria-selected="true"], [aria-current="page"])` and the indicator rule (`:141`) matches both. So a
|
||||
link-based tab bar (the pattern `section-nav` uses, `section-nav.blade.php:65-70`) built with
|
||||
`variant="primary"` would show the indicator but keep the label on-surface-variant.
|
||||
- Fix: `tabs.css:87` → `[data-tab]:is([aria-selected="true"], [aria-current="page"])`.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
### N-22 · navigation-rail · no vertical divider and no way to turn the container fill off
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Optional vertical divider separates rail from content, placed on the content-adjacent edge";
|
||||
"container fill can be turned off (transparent) as long as items keep ≥3:1 contrast"; "If a layout
|
||||
scrolls horizontally, the rail can scroll off-screen or remain fixed. To distinguish that content is
|
||||
scrolling underneath the rail, use a divider or add elevation to the rail"
|
||||
(RCB §Navigation Rail/Behaviour; raw/components_navigation-rail_guidelines.md:456).
|
||||
- Library does: `navigation.css:196-215` always paints `background-color: var(--md-sys-color-surface)` and
|
||||
draws no divider; there is no prop for either (`navigation-rail.blade.php:60-65` has only `mode`,
|
||||
`label`, `width`, `menu`).
|
||||
- Fix: add a `divider` boolean prop rendering `border-inline-end: 1px solid
|
||||
var(--md-sys-color-outline-variant)` on `[data-navigation-rail-panel]`, and a `fill` prop (default
|
||||
`true`) that sets `background-color: transparent` when false.
|
||||
- Effort: S
|
||||
- Breaks API? no (additive props)
|
||||
|
||||
### N-23 · navigation-rail · the FAB → extended FAB transition is a display swap, not a morph
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Rail transitions from collapsed↔expanded should animate the FAB into/out of an Extended FAB"
|
||||
(RCB §Navigation Rail/Behaviour).
|
||||
- Library does: the documented pattern is two FABs swapped by `display`
|
||||
(`navigation-rail.blade.php:42-43`, SKILL.md:737, `showcase/shell.blade.php:48-49`) — the collapsed one
|
||||
appears at once while the rail's width is still springing. The rail's own width *is* animated on the
|
||||
spatial spring (`navigation.css:171`), so the mismatch is visible.
|
||||
- Fix: a single `<x-fab>` whose label is width-animated (`grid-template-columns: 0fr → 1fr` on the label
|
||||
span, `ease-spatial-default`) inside the `rail-collapsed` variant would morph instead of swap; or at
|
||||
minimum mention the limitation in SKILL.md.
|
||||
- Effort: M
|
||||
- Breaks API? no
|
||||
|
||||
### N-24 · navigation-rail · `mode="collapsible"` has no width floor of its own
|
||||
- Severity: nice-to-have
|
||||
- M3 says: "Compact → don't use a standard rail, use a navigation bar" (RCB §Navigation Rail/Behaviour).
|
||||
- Library does: only `mode="adaptive"` is width-aware (`navigation.css:184-194`). A `collapsible` or
|
||||
`expanded` rail keeps `clamp(13.75rem, 16rem, 22.5rem)` at 360px too — two-thirds of a phone screen.
|
||||
The docs point applications at `<x-app-shell>` for adaptivity, and the showcase only uses `collapsible`
|
||||
inside a bounded demo box, so nothing in-repo hits it; an application wiring a bare
|
||||
`<x-navigation-rail mode="collapsible">` into a page layout will.
|
||||
- Fix: a one-line guard in `navigation.css` —
|
||||
`@media (width < 37.5rem) { [data-navigation-rail='collapsible'], [data-navigation-rail='expanded'] { width: 6rem; } }`
|
||||
plus the matching `rail-collapsed` variant arm — or an explicit sentence in SKILL.md:735 that these two
|
||||
modes are fixed-width by design and belong behind a `min-[37.5rem]:` wrapper.
|
||||
- Effort: S
|
||||
- Breaks API? no
|
||||
|
||||
## Deliberate deviations
|
||||
|
||||
- **Expanded rail items are full-width pills, not label-hugging** — `navigation-rail-item.blade.php:18-19`:
|
||||
"Compose's expanded item hugs its label; the package draws the full-width pill, which leaves room for
|
||||
the count at the end." **Holds up.** M3 says exactly this is allowed: the indicator "hugs the label text
|
||||
by default (**can be overridden to fill the full container width**, resembling the old navigation drawer
|
||||
style); target area always spans the full rail width" (RCB §Navigation Rail/Behaviour).
|
||||
- **No drop shadow on a scrolled app bar, colour change only** — `app-bar.css:12`, `:59-61`. **Holds up,
|
||||
and is the right reading.** androidx has `AppBarTokens.OnScrollContainerElevation = Level2`, but the
|
||||
site is explicit: "On scroll: **No drop shadow**, instead a color fill creates separation from content"
|
||||
(raw/components_app-bars_overview.md:137).
|
||||
- **Medium/large flexible height keyed on whether a subtitle is present** — `app-bar.css:33-47` maps
|
||||
medium 112/136 and large 120/152 to without/with subtitle. **Holds up**: those are exactly
|
||||
`ContainerHeight`/`LargeContainerHeight` in tok/AppBarMediumFlexibleTokens.kt and
|
||||
tok/AppBarLargeFlexibleTokens.kt, and Compose's flexible app bars pick the taller one when a subtitle is
|
||||
supplied.
|
||||
- **Rail container is `surface`, modal is `surface-container`** — `navigation.css:210`, `:229`. **Holds
|
||||
up**: tok/NavigationRailCollapsedTokens.kt `ContainerColor = Surface`;
|
||||
tok/NavigationRailExpandedTokens.kt `ModalContainerColor = SurfaceContainer`, `ModalContainerElevation =
|
||||
Level2`, `ModalContainerShape = CornerLarge` — all three reproduced exactly. (The site's colour list
|
||||
says "Surface container (optional container fill)"; androidx is the more specific source and the library
|
||||
follows it.)
|
||||
- **Every toolbar control stays in the Tab order rather than a roving tabindex** — `toolbar.js:4-5`: "Every
|
||||
control stays in the Tab order, so a control added by a Livewire render is reachable without any
|
||||
bookkeeping." **Holds up against M3** (though not against APG's toolbar pattern): M3's own accessibility
|
||||
page says "Initial focus lands on the first interactive element; **Tab (or Arrows) moves between all
|
||||
others**" (RCB §Toolbars/Accessibility).
|
||||
- **Section nav wraps onto a grid instead of scrolling** — `section-nav.blade.php:15-16`: "tabs that scroll
|
||||
hid the last sections on a tablet." **Does not hold up.** M3 prescribes scrollable tabs for exactly this
|
||||
case and its accessibility page defends them explicitly ("Horizontal scrolling tabs meet accessibility
|
||||
requirements…", raw/components_tabs_accessibility.md:95), while the grid breaks the tab bar's divider
|
||||
and strands the indicators (N-16). If the grid is kept for product reasons, it should stop presenting
|
||||
itself as `[data-tabs-bar]`.
|
||||
- **Small app-bar titles truncate with an ellipsis** — `app-bar.css:104-110`. M3 says "Don't truncate the
|
||||
headline text" *and* "Don't wrap text in a small app bar"
|
||||
(raw/components_app-bars_guidelines.md:337, :347); its actual answer is "If headline text is long, use a
|
||||
medium flexible or large flexible app bar" (:342). Truncation is the only remaining web-sane behaviour,
|
||||
so this is a reasonable forced choice — but it is undocumented. Worth one line in the header comment
|
||||
pointing authors at `variant="medium"`.
|
||||
|
||||
## Aligned
|
||||
|
||||
- **App bar**: 64 / 112 / 136 / 120 / 152px heights; title-large + label-medium (small), headline-medium +
|
||||
label-large (medium flexible), display-small + title-medium (large flexible); 4px leading/trailing space;
|
||||
0 gap between icon buttons; 16px title inset without a leading button; surface → surface-container on
|
||||
scroll; on-surface title / on-surface-variant subtitle and trailing icons / on-surface leading icon;
|
||||
square container. The pure-CSS collapse (negative sticky `top` + sticky inner row) is elegant and
|
||||
produces no layout shift; the flexible variants' row headline is `aria-hidden` so the real `<h1>` is
|
||||
never announced twice.
|
||||
- **Navigation bar**: 64px, surface-container, square, items 0 apart, 56×32 `CornerFull` indicator, 4px
|
||||
icon–label space, 6px item vertical padding, 40px horizontal indicator with 16px leading/trailing,
|
||||
24px icons, on-secondary-container active icon, secondary-container indicator, secondary active label
|
||||
(vertical) / on-secondary-container (horizontal), on-surface-variant inactive — all correct. The medium
|
||||
arrangement reproduces `calculateCenteredContentHorizontalPadding` (items occupy `10 × (count + 3)`% of
|
||||
the bar) exactly, and it switches on a **container** query at 600px, so a bar in a narrow column keeps
|
||||
the compact layout.
|
||||
- **Navigation rail**: 96px collapsed, `clamp(220px, width, 360px)` expanded, 44px top space, 40px header
|
||||
space (32 + 8), 4px between collapsed items, 64px collapsed item, 56px expanded item, 8px/4px icon–label
|
||||
spaces, 56×32 collapsed indicator, `CornerFull`. Modal = fixed, surface-container, elevation 2,
|
||||
`CornerLarge` on the inner edge, 32% scrim, `x-trap.inert.noscroll`, Escape, scrim click, focus returned
|
||||
to the menu button, closed on `livewire:navigating`. Badges move from the icon (collapsed) to the label
|
||||
end (expanded), which is M3's stated rule. State layers use on-secondary-container per
|
||||
NavigationRailColorTokens.
|
||||
- **Catalogue vs. Expressive**: no bottom app bar, no navigation drawer, no baseline medium/large app bar,
|
||||
no baseline navigation bar — all four correctly skipped, and the expanded rail (standard + modal) is
|
||||
what stands in for the drawer, exactly as M3 Expressive directs.
|
||||
- **Tabs**: 48px (64px stacked), 24px icons, 1px outline-variant divider, 3px primary indicator with
|
||||
`3px 3px 0 0` corners, 2px square secondary indicator, 24px minimum indicator length, 8px icon–label gap,
|
||||
title-small labels, primary/on-surface active colours, 8%/10% state layers, 38% disabled. Real tablist
|
||||
semantics, arrow keys + Home/End, disabled tabs skipped, `aria-selected`/`tabindex` rendered on the
|
||||
server, and the indicator moves in a view transition on the spatial spring with a reduced-motion escape.
|
||||
- **Toolbars**: docked 64px / square / surface-container / 16px ends / 4–32px between items (as a
|
||||
`clamp`); floating 64px / `CornerFull` / surface-container / 8px ends / 4px between; vibrant =
|
||||
primary-container with on-primary-container ink and a surface-container + on-surface selected button —
|
||||
all matching tok/DockedToolbarTokens.kt and tok/FloatingToolbarTokens.kt. `role="toolbar"` +
|
||||
`aria-orientation` + RTL-aware arrow keys.
|
||||
- **Motion**: all durations collapse to 0 under `prefers-reduced-motion`
|
||||
(`resources/css/tokens/motion.css:63-75`); spatial springs for size/position, effects easing for colour
|
||||
and opacity, throughout this group.
|
||||
- **Safe areas**: the app bar pads the top inset, the navigation bar and rail the bottom inset, the docked
|
||||
toolbar the bottom inset — matching M3's safety-region rule.
|
||||
- **Links vs. buttons**: navigation items are real `<a>`s with `aria-current="page"` (plus the filled icon
|
||||
and the indicator — the two-indicator rule) and degrade to `<button>` without a `link`; the section nav
|
||||
uses links with `aria-current`, not fake tabs.
|
||||
|
||||
## Missing
|
||||
|
||||
M3 variants/configurations in this group with no implementation at all:
|
||||
|
||||
- **Navigation bar, tall container (80dp)** — `NavigationBarTokens.TallContainerHeight = 80.0.dp`
|
||||
(tok/NavigationBarTokens.kt). Only the 64px short bar exists (`navigation.css:76`); no prop selects the
|
||||
tall one.
|
||||
- **Navigation bar, hide-on-scroll** — "Scrolling: hides on scroll-down, reappears on scroll-up — but never
|
||||
hide it while a screen reader is active" (RCB §Navigation Bar/Behaviour). Not implemented and not
|
||||
mentioned.
|
||||
- **Navigation rail, narrow collapsed width (80dp)** — `NavigationRailCollapsedTokens.NarrowContainerWidth
|
||||
= 80.0.dp`. Only 96px (`navigation.css:174`).
|
||||
- **Navigation rail, centre alignment** — "Alignment: top or center; on tablets prefer center alignment for
|
||||
reach. Menu icon and FAB are always top-aligned" (RCB §Navigation Rail/Behaviour). The rail is
|
||||
top-aligned only.
|
||||
- **Navigation rail, "hide when collapsed" as an explicit configuration** — it is in M3's configuration
|
||||
table ("Expanded behavior | Hide when collapsed | — | Available"). The library gets it implicitly below
|
||||
`sm` in `mode="adaptive"`, but there is no mode that hides a rail in an immersive wide layout.
|
||||
- **Docked toolbar with a FAB** — M3's configuration table lists "Other elements | With FAB" for both
|
||||
toolbars; `toolbar.blade.php:26` gates the `fab` slot on `$variant === 'floating'`.
|
||||
- **App bar, trailing-action overflow at small widths** — "trailing actions collapse into an overflow menu
|
||||
at smaller breakpoints and reappear at larger ones" (RCB §Top App Bar/Behaviour). Not implemented (and
|
||||
M3 also tells you not to put an overflow menu in the app bar in the first place, so this is genuinely
|
||||
optional).
|
||||
- **Toolbar, large-screen treatments** — "On web/large screens it can gain rounded corners and use dividers
|
||||
to organize many actions"; "medium+ breakpoints allow centering all elements or centering one key
|
||||
action while pinning others to the edges" (RCB §Toolbars/Behaviour). The toolbar has no breakpoint
|
||||
behaviour at all.
|
||||
|
||||
Correctly absent (deprecated in M3 Expressive, do **not** add): bottom app bar, navigation drawer,
|
||||
baseline medium/large top app bar, baseline navigation bar. `theme-toggle mode="picker"` is the one place
|
||||
a deprecated design slipped back in — see N-04.
|
||||
|
||||
## Breakpoint map
|
||||
|
||||
M3's classes: Compact <600, Medium 600–839, Expanded 840–1199, Large 1200–1599, Extra-large ≥1600
|
||||
(RF §Layout/Breakpoints). Tailwind's: `sm` 640, `md` 768, `lg` 1024, `xl` 1280, `2xl` 1536.
|
||||
|
||||
| Component / behaviour | Library breakpoint used | M3 class it stands in for | Gap |
|
||||
| --- | --- | --- | --- |
|
||||
| `app-shell` — bottom navigation bar shown; rail hidden, modal-only | `max-sm` < 640px (`app-shell.blade.php:142`, `:87-88`, `:136`; `navigation.css:190-194`, `:235-279`) | Compact (<600dp) | **+40px.** 600–639px is M3 Medium but is driven as Compact |
|
||||
| `navigation-bar` — item layout vertical → horizontal | container 600px (`navigation.css:123`, `:465`, `:519`, `:525`, `:555`, `:562`) | Medium (600dp) | **exact** — and a container query, so it also works in a narrow column |
|
||||
| `app-shell` — collapsed rail appears (96px), bar disappears | `sm` ≥ 640px | Medium (600–839dp) | **+40px late**; disagrees with the line above, so the horizontal bar layout only exists in a 40px band |
|
||||
| `app-shell` / `navigation-rail mode="adaptive"` — rail becomes a standard, in-layout collapsible rail; menu button toggles instead of opening a modal | `lg` ≥ 1024px (`js/navigation.js:16`; `navigation.css:51`, `:57`, `:184`) | Expanded (≥840dp) | **+184px.** 840–1023px gets a collapsed rail whose expansion is modal, scrimmed, focus-trapped and re-closed by every `wire:navigate`; the remembered "expanded" preference is ignored |
|
||||
| `app-shell` — content region `max-lg:overflow-x-clip` | `lg` 1024px (`app-shell.blade.php:136`) | Expanded (840dp) | +184px (a clipping backstop, no M3 rule attached) |
|
||||
| `section-nav` — menu picker → secondary-tab row | `sm` 640px (`section-nav.blade.php:40`, `:59`) | Compact/Medium boundary (600dp) | +40px |
|
||||
| `section-nav` — 3/4-column grid → single row | `xl` 1280px (`section-nav.blade.php:33-34`) | Large (1200dp) | +80px |
|
||||
| showcase chrome — theme picker → cycle button | `md` 768px (`showcase/layout.blade.php:148-149`) | none (no M3 rule) | n/a — showcase only |
|
||||
| `app-bar`, `toolbar`, `tabs` | none | Medium+ adaptive guidance (toolbar centring/rounded corners, app-bar action overflow) | not implemented (see Missing) |
|
||||
|
||||
**What a user actually sees in the three bands the brief asks about**
|
||||
|
||||
- **600–639px** (M3 Medium; e.g. a small tablet in portrait, a resized desktop window): no navigation rail
|
||||
at all — it is `width: 0; display: none`. The bottom navigation bar is still pinned, but it has already
|
||||
flipped to the Expressive *medium* configuration (icon beside label in a 40px secondary-container pill,
|
||||
items grouped in the middle at `10 × (count + 3)`% of the width, label-large instead of label-medium per
|
||||
N-08). Any destination marked `'bar' => false` is reachable only by an application-supplied menu button
|
||||
calling `$store.rail.show()`. At 640px the bar vanishes outright and a 96px collapsed rail appears — a
|
||||
jump, at the wrong number.
|
||||
- **840–1023px** (M3 Expanded; a landscape tablet, a half-screen desktop browser): a 96px collapsed rail
|
||||
with icon-over-label items. The menu button does **not** expand it in place — `materialNavigationRail.modal`
|
||||
is still true, so it opens the 256px rail *over* the page on a 32% scrim with a focus trap, and
|
||||
`livewire:navigating` closes it again, so choosing a destination means re-opening the rail every time.
|
||||
`$store.rail.collapsed === false` (the visitor's remembered choice, applied to `<html data-rail>` before
|
||||
first paint) has no effect here. M3 lists a standard expanded rail for this whole band.
|
||||
- **1200–1279px** (M3 Large): **no mismatch** — `lg` (1024px) is long past, so the rail is already the
|
||||
standard collapsible one, expanded by default and remembered in localStorage, which is what M3 wants at
|
||||
Large. The only thing that changes at 1280px is `section-nav`'s grid → row switch (N-07's `xl` row
|
||||
above), which is 80px later than M3's 1200dp Large boundary. Nothing in the group does anything
|
||||
different at 1600px (Extra-large), where M3 suggests considering a third pane.
|
||||
@@ -25,8 +25,6 @@ curl -sL "https://m3.material.io/sitemap.xml" | grep -o '<loc>[^<]*' | sed 's/<l
|
||||
curl -sL "https://r.jina.ai/https://m3.material.io/components/buttons/specs" # one page
|
||||
```
|
||||
|
||||
The audits that used these files are in `docs/audits/m3-alignment/`.
|
||||
|
||||
## Attribution
|
||||
|
||||
The text is Google's, from https://m3.material.io, reproduced here so the library can be
|
||||
|
||||
+2
-2
@@ -7,9 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build:scheme": "esbuild bin/scheme.mjs --bundle --platform=node --format=esm --target=node20 --minify --legal-comments=eof --outfile=resources/node/scheme.mjs",
|
||||
"build:shapes": "node bin/shapes.mjs",
|
||||
"build:stylesheets": "node bin/stylesheets.mjs",
|
||||
"build:loading": "node bin/loading-indicator.mjs",
|
||||
"build:springs": "node bin/springs.mjs",
|
||||
"check:font": "node bin/check-font.mjs"
|
||||
"build:springs": "node bin/springs.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@material/material-color-utilities": "^0.4.0",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@verbatim
|
||||
## Livewire Material
|
||||
|
||||
This application uses `nonameweb/livewire-material`: Material 3 Expressive components for Laravel and Livewire, built on Tailwind CSS. It replaces UI kits such as maryUI, daisyUI and Flux in this application.
|
||||
This application uses `nonameweb/livewire-material`: Material 3 Expressive components for Laravel and Livewire, in plain CSS. No utility classes — Tailwind, daisyUI or any other — belong here: a class your CSS does not declare does nothing, and `DesignGuard` fails it.
|
||||
|
||||
- Components are anonymous Blade components, unprefixed unless `config/livewire-material.php` sets a `prefix`. Before writing or changing a view that uses them, activate the `livewire-material-development` skill for the props, slots and traps of each component.
|
||||
- Never write maryUI tags (`<x-mary-*>`) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`…). They compile to nothing and fail silently.
|
||||
- The CSS entry imports `foundation.css` first, then the stylesheet of each component the views render (or `all.css` for all of them). A component whose stylesheet is not imported renders unstyled; `DesignGuard::missingStylesheets()` names each missing `@import`.
|
||||
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand. With colour profiles (`livewire-material.profiles`), run it without a seed after changing them; the active profile comes from `Scheme::resolveProfileUsing()`.
|
||||
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
|
||||
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
|
||||
|
||||
@@ -3,56 +3,61 @@
|
||||
|
||||
Every view in this application is Material 3 Expressive (m3.material.io), through `nonameweb/livewire-material`. These rules decide what to write; the `material-3-design` skill carries the tables, the numbers and Google's source pages behind each one — activate it before designing a screen.
|
||||
|
||||
The library is plain CSS on M3's tokens. No utility classes — Tailwind, daisyUI or any other — belong here: a class your CSS does not declare does nothing, and `DesignGuard` fails it. A view is written three ways:
|
||||
- Components and their props: `<x-button variant="filled">`, and the layout components `<x-row>`, `<x-stack>`, `<x-grid>`, `<x-feed>`, `<x-surface>` and `<x-pane>`, whose `gap` and `padding` take a spacing token (`space200`) and whose `hide-below`, `hide-from` and `stack-below` take a window size class.
|
||||
- A fixed set of classes for text and interaction on plain elements: `md-type-*`, `md-ink-*`, `md-text-*`, `md-truncate`, `md-tabular`, `md-visually-hidden`, `md-state-layer`, `md-focus-ring`, `md-touch-target` and `md-link`.
|
||||
- The application's own CSS, named by the application, whose values are `--md-sys-*` custom properties.
|
||||
|
||||
### Colour
|
||||
- A colour is always a role: `bg-primary`, `text-on-surface-variant`, `border-outline-variant`. Never a hex, an arbitrary value, a palette tone or an opacity; Tailwind's palette does not compile.
|
||||
- Pair a role only with its `on-` partner: `bg-primary text-on-primary`, `bg-secondary-container text-on-secondary-container`. That pair is the one whose contrast is guaranteed at every contrast level; mixing pairs (`bg-primary-container text-on-surface`) is not.
|
||||
- A colour is always a role: `md-ink-variant` on text, `var(--md-sys-color-outline-variant)` in the application's CSS, `color="error"` on a component. Never a hex, a palette tone or an opacity.
|
||||
- Pair a role only with its `on-` partner: a `primary` fill takes `on-primary` text, a `secondary-container` fill takes `on-secondary-container`. That pair is the one whose contrast is guaranteed at every contrast level; mixing pairs (`primary-container` under `on-surface`) is not.
|
||||
- `primary` is the one key action on a screen (a filled button; the FAB in `primary-container`). `secondary-container` is the quiet fill (tonal buttons, selected navigation, selected chips). `tertiary` is a contrasting accent, used rarely. `error`, `success`, `warning`, `info` mean state and nothing else: the `-container` for a tinted panel, the role itself for its text and icon.
|
||||
- Ink is `text-on-surface`; lower emphasis is `text-on-surface-variant` (`text-body`, `text-meta`); decoration is `text-outline` (`text-quiet`). Never dim ink with an opacity: 38% means disabled.
|
||||
- `border-outline` is a boundary that must be read (a text field, the edge of a target). `border-outline-variant` (`border-divider`, `border-structure`, `border-chrome`) is a divider or a card edge. Never `outline` on a divider.
|
||||
- Ink is `on-surface` (`md-ink`); lower emphasis is `on-surface-variant` (`md-ink-variant`); decoration is `outline` (`md-ink-quiet`). Never dim ink with an opacity: 38% means disabled.
|
||||
- `outline` is a boundary that must be read (a text field, the edge of a target). `outline-variant` is a divider or a card edge (`<x-divider>`, `<x-surface outlined>`). Never `outline` on a divider.
|
||||
- Fixed and dim roles (`primary-fixed`, `surface-dim`, …) are for a colour that must not change with the theme; if unsure, don't. Inverse roles only on an inverse surface (the snackbar).
|
||||
- A link is `text-primary` and underlined (`link`); colour alone signals nothing.
|
||||
- A link in running text is underlined (`md-link`, with `md-ink-primary`); colour alone signals nothing.
|
||||
- Contrast: 4.5:1 for text, 3:1 for large text, icons and grouped controls; disabled is exempt. Three contrast levels exist (`<html data-contrast>`: standard, medium, high) and every role changes with them — which is why only roles are allowed.
|
||||
|
||||
### Surfaces and elevation
|
||||
- The page is `bg-surface`. Panels separate by tone first: `surface-container-lowest` … `surface-container-highest` is a hierarchy of emphasis, not of height. Navigation chrome is `surface-container`; a dialog, a menu, the search bar are `surface-container-high`; a modal sheet is `surface-container-low`; a filled card is `surface-container-highest`. A region keeps its role at every width.
|
||||
- Shadows (`shadow-elevation-1…5`) are for what floats or lifts: 1 for elevated cards, buttons and modal sheets; 2 for menus, the navigation bar, a scrolled app bar; 3 for the FAB, dialogs, pickers and search; one level more on hover; nothing rests above 3. Fewer shadows carry more meaning.
|
||||
- A scrim is `bg-scrim/32`.
|
||||
- The page is `surface`. Panels separate by tone first: `surface-container-lowest` … `surface-container-highest` is a hierarchy of emphasis, not of height (`<x-surface level="surface-container-high">`). Navigation chrome is `surface-container`; a dialog, a menu, the search bar are `surface-container-high`; a modal sheet is `surface-container-low`; a filled card is `surface-container-highest`. A region keeps its role at every width.
|
||||
- Shadows (`var(--md-sys-elevation-1)` … `-5`) are for what floats or lifts: 1 for elevated cards, buttons and modal sheets; 2 for menus, the navigation bar, a scrolled app bar; 3 for the FAB, dialogs, pickers and search; one level more on hover; nothing rests above 3. Fewer shadows carry more meaning.
|
||||
- A scrim is `scrim` at 32%: `color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent)`.
|
||||
|
||||
### Shape
|
||||
- Corners come from the scale `rounded-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full}`; Tailwind's `rounded-*` does not compile.
|
||||
- Corners come from the scale `var(--md-sys-shape-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full})`, or `<x-surface corner="md">`; never a length of your own.
|
||||
- By family: `full` buttons, icon buttons, chips' avatars, badges, switches, sliders, the search bar, navigation indicators; `xs` text fields, menus, snackbars, plain tooltips; `sm` chips; `md` cards, rich tooltips; `lg` the FAB and a side sheet's inner corners; `xl` dialogs, bottom sheets, the search view, pickers, carousel items; `xxl` large hero containers.
|
||||
- Nested shapes: inner radius = outer radius − padding; never the same radius inside and out.
|
||||
- A press squares a round shape (the components do it; nothing morphs on hover). The 35 `<x-shape>`s are decoration, never meaning, used sparingly.
|
||||
|
||||
### Type
|
||||
- Every text element carries one `type-*` style: `display` for hero figures and short marketing lines; `headline` for page and section titles; `title` for card, dialog and list-section titles; `body` for paragraphs (`body-lg` for reading); `label` inside components (buttons, chips, tabs, captions). Never `text-sm`, `font-medium`, `leading-*`, `tracking-*` — they do not compile.
|
||||
- `type-emphasized-*` is opt-in: a selected item, a primary action, a headline, a badge — not decoration.
|
||||
- 40–60 characters per line; `tabular-nums` on figures that change; text must scale to 200% without loss (containers grow, rows wrap, no fixed heights on text, no ellipsis without a way to read the rest).
|
||||
- Every text element carries one `md-type-*` class: `display` for hero figures and short marketing lines; `headline` for page and section titles; `title` for card, dialog and list-section titles; `body` for paragraphs (`md-type-body-lg` for reading); `label` inside components (buttons, chips, tabs, captions). In the application's CSS a style is `font: var(--md-sys-typescale-body-md)` with its `-tracking`; never a size, weight, line height or letter spacing of your own.
|
||||
- `md-type-emphasized-*` is opt-in: a selected item, a primary action, a headline, a badge — not decoration.
|
||||
- 40–60 characters per line; `md-tabular` on figures that change; text must scale to 200% without loss (containers grow, rows wrap, no fixed heights on text, no ellipsis without a way to read the rest).
|
||||
|
||||
### Motion
|
||||
- Position, size and shape move on the spatial springs (they overshoot): `transition-transform duration-(--md-sys-motion-spatial-default-duration) ease-spatial-default` — `fast` for small elements, `slow` for large ones. Colour and opacity move on the effects springs (`ease-effects-*`), which never overshoot. Always pair an easing with its duration.
|
||||
- Position, size and shape move on the spatial springs (they overshoot): `transition: transform var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default)` — `fast` for small elements, `slow` for large ones. Colour and opacity move on the effects springs (`--md-sys-motion-effects-*`), which never overshoot. Always pair an easing with its duration.
|
||||
- Entering decelerates, a permanent exit accelerates, a temporary exit (a sheet, a drawer) takes the emphasized curve; exits are shorter than entrances.
|
||||
- Everything that moves goes through these tokens, so reduced motion makes it instant; a literal duration is a bug.
|
||||
|
||||
### States and targets
|
||||
- Interactive elements carry `state-layer focus-ring`: hover 8%, focus 10%, pressed 10%, dragged 16% (`data-dragged`) of the content colour; disabled is `disabled:text-on-surface/38 disabled:bg-on-surface/12` and has no hover. Every state shows two indicators: colour plus a shape, an outline, an icon or a word.
|
||||
- Every target is at least 48×48px with 8px between targets (`touch-target` on anything drawn smaller); a denser layout is an opt-in prop, never a default.
|
||||
- Interactive elements carry `md-state-layer md-focus-ring`: hover 8%, focus 10%, pressed 10%, dragged 16% (`data-md-dragged`) of the content colour. Disabled is content at 38% and a container at 12% of `on-surface` (`--md-sys-state-disabled-content-opacity`, `--md-sys-state-disabled-container-opacity`, through `color-mix()`), with no state layer. Every state shows two indicators: colour plus a shape, an outline, an icon or a word.
|
||||
- Every target is at least 48×48px with 8px between targets (`md-touch-target` on anything drawn smaller); a denser layout is an opt-in prop, never a default.
|
||||
- Keyboard: Tab between components, arrows within one, Enter and Space activate, Escape dismisses; a dialog takes focus and gives it back to what opened it.
|
||||
|
||||
### Layout and breakpoints
|
||||
- Widths are M3's window size classes, the only variants that compile: compact below 600px (the default), `medium:` 600, `expanded:` 840, `large:` 1200, `extra-large:` 1600, and `max-medium:` … for "below". In scripts, `from()` and `upTo()` from `resources/js/breakpoints.js`.
|
||||
- Widths are M3's window size classes and only those: compact below 600px (the default), medium 600, expanded 840, large 1200, extra-large 1600. A layout component takes them as props (`<x-row stack-below="medium">`, `<x-stack hide-from="expanded">`, `<x-grid :columns="['compact' => 1, 'expanded' => 2]">`); the application's CSS writes `@media (width >= 840px)`; a script asks `from()` and `upTo()` from `resources/js/breakpoints.js`.
|
||||
- What changes per class: compact — navigation bar, one pane, full-screen dialogs, a bottom sheet for choices; medium — collapsed rail, one pane; expanded — rail (collapsible), two panes, menus and basic dialogs; large and extra-large — the rail expanded, two panes, a third only at extra-large as a side sheet. `<x-scaffold>` does this; content lives in panes (`<x-pane>`, `<x-list-detail>` for a list's second pane), never beside the rail by hand.
|
||||
- Margins are 16px below `medium` and 24px from it; spacing sits on the 4px grid, as padding and gaps on the parent, with margins only between layout regions. A fixed pane is 360px (expanded) or 412px (large); a side sheet at most 400px.
|
||||
- Write logical properties (`ps-*`, `me-*`, `start-*`, `text-start`); directional icons mirror in RTL; charts and media controls stay LTR. Keep controls inside the safe area (`--material-safe-*`).
|
||||
- Margins are 16px below medium and 24px from it (`<x-pane>` draws them); spacing sits on the 4px grid as `space25` … `space900`, as padding and gaps on the parent, with margins only between layout regions. A fixed pane is 360px (expanded) or 412px (large); a side sheet at most 400px.
|
||||
- Write logical properties (`padding-inline-start`, `inset-inline-end`, `md-text-start`); directional icons mirror in RTL; charts and media controls stay LTR. Keep controls inside the safe area (`--material-safe-*`).
|
||||
|
||||
### Accessibility
|
||||
- Native elements first (`<button>`, `<dialog>`, `<input>`), then ARIA. One `main`, one `banner`, one `contentinfo`; every repeated `nav` labelled, without the word "navigation".
|
||||
- Headings in order from a single H1; the level is structure, the `type-*` style is appearance.
|
||||
- Headings in order from a single H1; the level is structure, the `md-type-*` class is appearance.
|
||||
- An icon-only control has an accessible name that does not include its role; decorative icons are hidden; an error is announced and tied to its field (`aria-describedby`); a toast uses a polite live region and never takes focus. A single-key shortcut needs a modifier or a focused component.
|
||||
|
||||
### Icons
|
||||
- `<x-icon name="…">` is a Material Symbol Rounded: `filled` means active or selected, `optical="20"` when drawn at 20px or less, one weight per group, the size and colour of the text beside it.
|
||||
- `<x-icon name="home">` is a Material Symbol Rounded: `filled` means active or selected, `optical="20"` when drawn at 20px or less, one weight per group, the size and colour of the text beside it.
|
||||
|
||||
### Don'ts
|
||||
- No icon in a snackbar; no disabled FAB (hide it); no horizontal radio rows; no hover morph on cards; no `outline` on dividers; no hex colours; no Tailwind breakpoints or scales; no segmented buttons, navigation drawer or bottom app bar — use `<x-button-group connected>`, the expanded rail and `<x-toolbar>`.
|
||||
- No icon in a snackbar; no disabled FAB (hide it); no horizontal radio rows; no hover morph on cards; no `outline` on dividers; no hex colours; no utility classes, and no breakpoint, radius, shadow, type size or duration off M3's scales; no segmented buttons, navigation drawer or bottom app bar — use `<x-button-group connected>`, the expanded rail and `<x-toolbar>`.
|
||||
@endverbatim
|
||||
|
||||
@@ -48,7 +48,7 @@ php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
||||
|
||||
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--spec` is the colour spec: `2025` (default, M3 Expressive) or `2021` (M3's original colour — keep it for a palette generated before Expressive; the library itself uses 2021 for variants 2025 does not define, and the header records the spec actually used). `--success`, `--warning` and `--info` set the source of the state colours; `--harmonize` pulls those three towards the seed (off by default: a state has to stay recognisable). The header of the stylesheet records the whole command, every option that differs from its default included. The command also writes `material-scheme.json` beside the stylesheet.
|
||||
|
||||
`success`, `warning` and `info` are built exactly as M3 builds `error` — dynamic colours on their own tonal palette — so they follow the scheme's spec, its dark tones and its contrast level like every other role. Use them as roles (`bg-success`, `text-on-warning-container`), never as a hex.
|
||||
`success`, `warning` and `info` are built exactly as M3 builds `error` — dynamic colours on their own tonal palette — so they follow the scheme's spec, its dark tones and its contrast level like every other role. Use them as roles (`md-ink-success` on text, `var(--md-sys-color-on-warning-container)` in your own CSS, a component's `color="warning"`), never as a hex.
|
||||
|
||||
### Contrast levels
|
||||
|
||||
@@ -90,19 +90,41 @@ Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
|
||||
|
||||
## Tokens
|
||||
|
||||
2.0.0's vocabulary is plain CSS, without Tailwind. `resources/css/foundation.css` is the one required import, first in the entry, followed by the stylesheets of the components the views render (see Setup; 1.x's `material.css` shortcut is gone). It declares the layer order `material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility`, so an application's unlayered CSS beats every package rule, and `hidden` or `x-cloak` hides any element. It brings the reset, the page's `surface` and `on-surface` in the brand typeface smoothed in grayscale (drop Tailwind's `antialiased`), every `--md-sys-*` token (spacing included: `--md-sys-measurement-space25` … `space900`, 2–72px), and `md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link`. Text on plain elements takes the fixed text classes of `resources/css/text.css` and nothing else: `md-type-{display|headline|title|body|label}-{lg|md|sm}` and `md-type-emphasized-…`; `md-ink` (on-surface), `md-ink-variant`, `md-ink-quiet`, `md-ink-primary`, `md-ink-error`, `md-ink-success`, `md-ink-warning`, `md-ink-info`, `md-ink-inverse`; `md-text-start|center|end`, `md-truncate`, `md-line-clamp-2|3`, `md-nowrap`, `md-tabular`, `md-mono`, `md-visually-hidden`. Monospace is for a value read or typed character by character (a token, a recovery code, a file name, a key): put it in `<code>`, `<kbd>` or `<samp>`, which take `--md-ref-typeface-mono` already, or give another element `md-mono` after its `md-type-*` class; `<x-input mono>` for a field. `--md-ref-typeface-mono` is the package's addition, the platform's monospace faces, not an M3 token. Every component is plain CSS now; the Tailwind names below stay for the showcase and an application's own views, until Tailwind itself leaves the package.
|
||||
2.0.0's vocabulary is plain CSS. There are no utility classes: one of 1.x's own colour, type, shape, elevation, motion or breakpoint utilities, or any other class no stylesheet declares, compiles to nothing, and the design guard fails on it (see Testing the design). `resources/css/foundation.css` is the one required import, first in the entry, followed by the stylesheets of the components the views render (see Setup; 1.x's `material.css` shortcut is gone). It declares the layer order `material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility`, so an application's unlayered CSS beats every package rule, and `hidden` or `x-cloak` hides any element. It brings the reset, the page's `surface` and `on-surface` in the brand typeface smoothed in grayscale, every `--md-sys-*` token, and the classes below. An application's views reach for four things, in this order:
|
||||
|
||||
Tailwind's default palette is cleared: every colour class names an M3 role. `text-red-600`, `bg-base-200` or `text-gray-500` compile to nothing. The rules behind the names below — which role, surface container, corner, type style, elevation level, motion spring, state and window size class to use, and M3's don'ts — are the `material-3` guideline (always on) and the `material-3-design` skill (the tables and Google's source pages); activate that skill before designing a screen.
|
||||
- **A component's own props** — `color`, `variant`, `size`, `dense` — before anything else.
|
||||
- **Layout components** for arrangement and spacing: `<x-row>`, `<x-stack>`, `<x-grid>`, `<x-feed>`, `<x-surface>` inside `<x-pane>`s (see Layout), with `gap` and `padding` as spacing tokens (`space25` … `space900`, 2–72px) and `hide-below`, `hide-from` and `stack-below` for the window size classes. Nothing arranges by class.
|
||||
- **The fixed text and interaction classes** on plain elements, and nothing else:
|
||||
- Type, from `resources/css/text.css`: `md-type-{display|headline|title|body|label}-{lg|md|sm}` and `md-type-emphasized-…` — M3's 15 styles and their emphasized twins, one weight step heavier, rounder, with their own tracking; each sets size, line height, weight and tracking together. Emphasis is deliberate and one element at a time (M3 asks for it on badges, primary buttons, selected rows, headlines), so a regular class inside an emphasized one goes back to plain.
|
||||
- Ink, from the same file: `md-ink` (on-surface), `md-ink-variant` (on-surface-variant: body copy beside a title, metadata), `md-ink-quiet` (outline: decoration only), `md-ink-primary`, `md-ink-error`, `md-ink-success`, `md-ink-warning`, `md-ink-info`, `md-ink-inverse`. On a container the ink is the container's `on-` role, which the component sets.
|
||||
- Text layout: `md-text-start|center|end`, `md-truncate`, `md-line-clamp-2|3`, `md-nowrap`, `md-tabular`, `md-mono`, `md-visually-hidden`. Monospace is for a value read or typed character by character (a token, a recovery code, a file name, a key): put it in `<code>`, `<kbd>` or `<samp>`, which take `--md-ref-typeface-mono` already, or give another element `md-mono` after its `md-type-*` class; `<x-input mono>` for a field. `--md-ref-typeface-mono` is the package's addition, the platform's monospace faces, not an M3 token.
|
||||
- Interaction, from `foundation/interaction.css`: `md-state-layer` (M3's hover, focus, press and dragged overlay in the content colour; makes the element `position: relative` and `isolation: isolate`; `data-md-dragged` draws the dragged layer), `md-focus-ring` (the keyboard focus indicator), `md-touch-target` (a 48px target around a smaller drawing), `md-link` (a link in running text, underlined; add `md-ink-primary` where it should read as primary).
|
||||
- **The application's own stylesheet** for the rest, on the tokens (`var(--md-sys-color-…)`, `-shape-corner-…`, `-elevation-…`, `-motion-spatial-…`/`-motion-effects-…` each with its own `-duration`, `-measurement-space…`, `-state-…-opacity`), never on a literal value — no white, black, hex, pixel radius, shadow, size, weight, line height or duration of your own. Which family names which role, corner, level, spring or space is the `material-3-design` skill's tables, not repeated here. `motion.scheme` in the config picks `expressive` (the default) or `standard`, written to `<html data-motion>`; a rule names a spring, never a scheme.
|
||||
- Names those tables leave out: the weights `--md-ref-typeface-weight-regular|medium|bold`; the state layers `--md-sys-state-{hover|focus|pressed|dragged}-state-layer-opacity` and `--md-sys-state-disabled-{content|container}-opacity`, mixed into a role with `color-mix()`; and the colour roles `inverse-error|success|warning|info` and `shadow`.
|
||||
|
||||
- Colour roles (`bg-*`, `text-*`, `border-*`, …): `primary`, `on-primary`, `primary-container`, `on-primary-container`, `inverse-primary`, `primary-fixed`, `primary-fixed-dim`, `on-primary-fixed`, `on-primary-fixed-variant`; the same for `secondary` and `tertiary`; `error`, `on-error`, `error-container`, `on-error-container`; `success`, `warning` and `info` with their `on-`, `-container` and `on-…-container`; `inverse-error|success|warning|info`; `surface`, `surface-dim`, `surface-bright`, `surface-container-lowest|low||high|highest`, `on-surface`, `on-surface-variant`, `inverse-surface`, `inverse-on-surface`, `outline`, `outline-variant`, `scrim`, `shadow`; plus `white` and `black`.
|
||||
- Ink and lines by meaning: `text-body` (body copy), `text-meta` (metadata), `text-quiet` (decoration only), `border-structure`, `border-chrome`, `border-divider` / `divide-divider`.
|
||||
- Type: `type-{display|headline|title|body|label}-{lg|md|sm}` and `type-emphasized-…` — M3's 15 styles and their emphasized twins, one weight step heavier, rounder, with their own tracking. Never assemble `text-*`, `leading-*` and `tracking-*` by hand; a utility carries size, line height and tracking together. Emphasis is deliberate and one element at a time (M3 asks for it on badges, primary buttons, selected rows, headlines), so a regular utility inside an emphasized one goes back to plain. The font is Google Sans Flex (`font-sans`).
|
||||
- Shape: `rounded-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full}`.
|
||||
- Elevation: `shadow-elevation-{1…5}` — for what floats over content, not for panels (a panel separates by its container tone).
|
||||
- Motion: `ease-spatial-{fast|default|slow}` (position, size, shape; springs that overshoot) and `ease-effects-{fast|default|slow}` (colour, opacity, which never overshoot). Always pair an easing with its duration: `duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast` — M3's published web durations, spatial 350/500/650 ms and effects 150/200/300 ms. `motion.scheme` in the config picks `expressive` (the default, with the bounce) or `standard` (minimal bounce), which the head script writes to `<html data-motion>` and which swaps the three spatial springs; a component names a spring, never a scheme. Reduced motion zeroes every duration in both schemes.
|
||||
- States: `state-layer` (M3's hover/focus/press overlay; makes the element `relative` and `isolate`), `focus-ring` (keyboard focus indicator), `link` (a link in running text).
|
||||
- Breakpoints are M3's window size classes, and only those: `medium:` 600px, `expanded:` 840px, `large:` 1200px, `extra-large:` 1600px, with `max-medium:` … for "below" (compact is below `medium`). Tailwind's `sm:`…`2xl:` are cleared — a `sm:` compiles to nothing — because 640px means nothing in M3. Scripts ask `resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`) so a stylesheet and a script never disagree at the boundary pixel; a component's *own* width is a container query (`@md:`), which is a different thing.
|
||||
- `dark:` follows the page's theme (`data-theme`), not the operating system.
|
||||
The rules behind these names — which role, surface container, corner, type style, elevation level, motion spring, state and window size class to use, and M3's don'ts — are the `material-3` guideline (always on) and the `material-3-design` skill (the tables and Google's source pages); activate that skill before designing a screen.
|
||||
|
||||
```css
|
||||
/* resources/css/app.css, after the imports */
|
||||
.quota-meter {
|
||||
border-radius: var(--md-sys-shape-corner-md);
|
||||
background-color: var(--md-sys-color-surface-container-high);
|
||||
padding: var(--md-sys-measurement-space200);
|
||||
transition: background-color var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
|
||||
@media (width >= 840px) {
|
||||
padding: var(--md-sys-measurement-space300);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Breakpoints are M3's window size classes, and only those (the guideline has the five numbers). A
|
||||
layout component takes them as props; your own CSS writes them as range media queries, and the
|
||||
design guard rejects any other width; a script asks `resources/js/breakpoints.js`
|
||||
(`from('expanded')`, `upTo('medium')`), so a stylesheet and a script never disagree at the
|
||||
boundary pixel. A component's *own* width is a container query (`@container (width >= 28rem)`),
|
||||
a different thing from a breakpoint.
|
||||
- The roles switch with the theme by themselves. A rule of your own that only applies in dark keys on `[data-theme='dark']` (the page's theme), never on `prefers-color-scheme` (the operating system's).
|
||||
- `x-figure` on an element holding one number counts it up on first appearance and on change.
|
||||
|
||||
## Theme
|
||||
@@ -169,7 +191,7 @@ Laravel's HTTP error pages — 403, 404, 419, 429, 500, 503, and the framework's
|
||||
|
||||
## Mail
|
||||
|
||||
Markdown mail (notifications and `markdown:` mailables) wears M3 once the application selects the theme:
|
||||
Markdown mail (notifications, and mailables built with `->markdown()`) wears M3 once the application selects the theme:
|
||||
|
||||
```dotenv
|
||||
MAIL_MARKDOWN_THEME=livewire-material::mail.theme
|
||||
@@ -216,7 +238,7 @@ M3's optical size axis redraws a symbol so its strokes look equally heavy at eve
|
||||
|
||||
### `<x-shape>`
|
||||
|
||||
One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, sized by its caller: `<x-shape name="cookie-9" class="size-40 text-secondary-container" />`. Names: `circle`, `square`, `slanted`, `arch`, `fan`, `arrow`, `semi-circle`, `oval`, `pill`, `triangle`, `diamond`, `clam-shell`, `pentagon`, `gem`, `very-sunny`, `sunny`, `cookie-4`, `cookie-6`, `cookie-7`, `cookie-9`, `cookie-12`, `ghostish`, `clover-4`, `clover-8`, `burst`, `soft-burst`, `boom`, `soft-boom`, `flower`, `puffy`, `puffy-diamond`, `pixel-circle`, `pixel-triangle`, `bun`, `heart`.
|
||||
One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`: `<x-shape name="cookie-9" size="160" style="color: var(--md-sys-color-secondary-container)" />`. `size` is its size in px (8 to 1024); without it your own CSS sizes it. Names: `circle`, `square`, `slanted`, `arch`, `fan`, `arrow`, `semi-circle`, `oval`, `pill`, `triangle`, `diamond`, `clam-shell`, `pentagon`, `gem`, `very-sunny`, `sunny`, `cookie-4`, `cookie-6`, `cookie-7`, `cookie-9`, `cookie-12`, `ghostish`, `clover-4`, `clover-8`, `burst`, `soft-burst`, `boom`, `soft-boom`, `flower`, `puffy`, `puffy-diamond`, `pixel-circle`, `pixel-triangle`, `bun`, `heart`.
|
||||
|
||||
### `<x-theme-script>`
|
||||
|
||||
@@ -240,7 +262,7 @@ Label button, icon button, toggle and responsive FAB in one component.
|
||||
| `link`, `external`, `no-wire-navigate` | | renders `<a>`, with `wire:navigate` unless external |
|
||||
| `spinner` | | `true` shows the loading indicator while its `wire:click` runs; a string names the action |
|
||||
| `tooltip`, `tooltip-left`, `tooltip-right`, `tooltip-bottom` | | plain tooltip; also the icon button's accessible name |
|
||||
| `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `expanded`; `fab` is an extended FAB on a compact window (below `medium`), a filled button from there |
|
||||
| `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `expanded`; `fab` is an extended FAB on a compact window (below `medium`), a filled button from there; a `disabled` fab is not drawn below `medium` — M3 removes a FAB whose action is unavailable — and is the disabled button from there (a `spinner` disabling it while its action runs leaves it on screen) |
|
||||
|
||||
```blade
|
||||
<x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner />
|
||||
@@ -250,7 +272,7 @@ Label button, icon button, toggle and responsive FAB in one component.
|
||||
|
||||
### `<x-tooltip>`
|
||||
|
||||
M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus, and goes 1.5s after the pointer or the focus leaves it (M3's transient tooltip); only one is on screen at a time. It is `aria-hidden`, so the trigger has to carry the same words itself — as an icon button's `aria-label` does. Where the tip says something the trigger does not, use `<x-rich-tooltip>`, which points the trigger at its text. Buttons and FABs take a `tooltip` prop instead.
|
||||
M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus, and goes 1.5s after the pointer or the focus leaves it (M3's transient tooltip); the focus a sheet, a dialog or the modal rail moves to its first control as it opens does not show it, so a close button's tooltip waits for a Tab; only one is on screen at a time. It is `aria-hidden`, so the trigger has to carry the same words itself — as an icon button's `aria-label` does. Where the tip says something the trigger does not, use `<x-rich-tooltip>`, which points the trigger at its text. Buttons and FABs take a `tooltip` prop instead.
|
||||
|
||||
### `<x-menu>`, `<x-menu-item>`, `<x-menu-group>`, `<x-menu-separator>`
|
||||
|
||||
@@ -269,7 +291,7 @@ M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link"
|
||||
</x-menu>
|
||||
```
|
||||
|
||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`, `filter`, `sheet-at-compact`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="text-sport-run"`), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`, `submenu`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls.
|
||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button, and the menu hangs on that button — a `position: fixed` trigger such as `<x-button fab>` carries it along, and a menu with no room flips to the other side, end or both), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`, `filter`, `sheet-at-compact`. `<x-menu-item>`: `label`, `icon`, `icon-class` (classes for the leading icon; a colour there paints it, a selected item's too, but not a disabled one's — `icon-class="sport-run"`, a class of your own), `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`, ticked at its end unless it has an `icon-right`), `current` (for a menu of places: marks the page you are on with `aria-current="page"` in secondary-container, never a checked choice), `badge` (`true` for a dot, or a count, at the end of the row), `disabled`, `keep-open`, `submenu`. Choosing an item closes the menu unless `keep-open`; a second press on the menu button closes it too. An open menu stays open while the Livewire component around it renders, a `keep-open` item's own `wire:click` included. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab; a `disabled` item keeps its place in that order, as M3 asks, but cannot be activated. A menu longer than the window scrolls.
|
||||
|
||||
`submenu` makes an item a menu of its own — the slot holds the nested `<x-menu-item>`s instead of a label, and they open beside it, on its end, flipping to its start where the window has no room:
|
||||
|
||||
@@ -345,14 +367,14 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
|
||||
|
||||
### `<x-fab>`
|
||||
|
||||
`<x-fab icon="add" tooltip="New share" />` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. The glyph is filled, as M3 requires of a FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself: put the page's FAB in `<x-scaffold>`'s `fab` slot, which places it at the bottom-end corner with M3's margin and clear of the navigation bar and a snackbar. `link`, `external`, `type`. There is no `disabled`: M3 says to remove a FAB whose action is unavailable, so hide it instead. `data-fab` on the root lets a place restyle a nested FAB (a rail flattens it to elevation 0).
|
||||
`<x-fab icon="add" tooltip="New share" />` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. The glyph is filled, as M3 requires of a FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself: put the page's FAB in `<x-scaffold>`'s `fab` slot, which places it at the bottom-end corner with M3's margin and clear of the navigation bar and a snackbar. `link`, `external`, `type`. There is no `disabled`: M3 says to remove a FAB whose action is unavailable, so hide it instead. `data-md-fab` on the root lets a place restyle a nested FAB (a rail flattens it to elevation 0).
|
||||
|
||||
`collapse-on-scroll` on an extended FAB with an `icon` (`<x-fab icon="edit" label="Compose" collapse-on-scroll />`) is M3's scroll behaviour: it shrinks to the FAB of its size while the window scrolls down and extends again on scroll-up or at the top of the page. The width morphs on the spatial spring and the label fades; under reduced motion it swaps outright. The label stays in the page, clipped, so the collapsed FAB keeps its accessible name. It watches the window, so it is for a FAB pinned over a scrolling page, not one inside a scrolling pane.
|
||||
|
||||
### `<x-fab-menu>`, `<x-fab-menu-item>`
|
||||
|
||||
```blade
|
||||
<div class="fixed end-4 bottom-4 large:end-6 large:bottom-6">
|
||||
<div class="new-share-menu">
|
||||
<x-fab-menu label="New">
|
||||
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
|
||||
<x-fab-menu-item label="Paste text" icon="content_paste" link="{{ route('paste') }}" />
|
||||
@@ -360,15 +382,28 @@ Attributes go to the leading button; the slot is the menu. `variant` (`filled` d
|
||||
</div>
|
||||
```
|
||||
|
||||
Two to six items open above the FAB, which turns into a close button, rising into place as it opens and sinking back as it closes; a window too short for them scrolls the list while the FAB stays put. `<x-fab-menu>`: `icon` (`add`), `label` (defaults to "Toggle menu" — the trigger has no other accessible name), `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as `<x-menu>`. The wrapper keeps M3's margin from the window edge: 16dp, 24dp from `large`.
|
||||
```css
|
||||
.new-share-menu {
|
||||
position: fixed;
|
||||
inset-inline-end: var(--md-sys-measurement-space200);
|
||||
inset-block-end: var(--md-sys-measurement-space200);
|
||||
|
||||
@media (width >= 1200px) {
|
||||
inset-inline-end: var(--md-sys-measurement-space300);
|
||||
inset-block-end: var(--md-sys-measurement-space300);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Two to six items open above the FAB, which turns into a close button, rising into place as it opens and sinking back as it closes; a window too short for them scrolls the list while the FAB stays put. `<x-fab-menu>`: `icon` (`add`), `label` (defaults to "Toggle menu" — the trigger has no other accessible name), `color`, `position` (`top-end` default). Give items the same `color`. Keyboard, and staying open through a Livewire render, as `<x-menu>`. It does not place itself: the wrapper keeps M3's margin from the window edge, 16dp, 24dp from `large`.
|
||||
|
||||
### `<x-loading>`
|
||||
|
||||
M3 Expressive's loading indicator — a shape morphing through seven Expressive shapes as it turns — for a wait of unknown length. 48px and `primary` unless sized or coloured by class; `contained` sets it on a primary-container circle. A `progressbar` named by `label` ("Loading"); `:label="false"` makes it decorative. It rests under reduced motion.
|
||||
M3 Expressive's loading indicator — a shape morphing through seven Expressive shapes as it turns — for a wait of unknown length. 48px and `primary` unless sized or coloured: `size` is its size in px, a whole number from 24 to 240 — M3's responsive range, which it asks you never to leave; scale it up on large and extra-large windows — and the container and the shape keep their ratio at every size (outside the range it is ignored, and your own CSS may size it). Colour it with your own CSS. `contained` sets it on a primary-container circle. A `progressbar` named by `label` ("Loading"); `:label="false"` makes it decorative. It rests under reduced motion.
|
||||
|
||||
```blade
|
||||
<x-loading />
|
||||
<x-loading contained class="size-8" label="Uploading" />
|
||||
<x-loading contained size="32" label="Uploading" />
|
||||
<div wire:loading.flex wire:target="upload"><x-loading /></div>
|
||||
```
|
||||
|
||||
@@ -398,7 +433,7 @@ window.addEventListener('app:update', () => location.reload())
|
||||
|
||||
### `<x-progress>`
|
||||
|
||||
M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless a `size-*` class is passed), flat or `wavy`, determinate with a `value` or indeterminate without one.
|
||||
M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless your own CSS sizes it), flat or `wavy`, determinate with a `value` or indeterminate without one.
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
@@ -421,15 +456,15 @@ M3 Expressive's progress indicator: linear (as wide as its container) or `circul
|
||||
</div>
|
||||
```
|
||||
|
||||
A value the server changes animates after a morph (the SVG is `wire:ignore`; only the root's attributes change). Under reduced motion values jump, the wave stands still and an indeterminate indicator holds one frame. A `w-*` class narrows a linear one; never pass a display or position class.
|
||||
A value the server changes animates after a morph (the SVG is `wire:ignore`; only the root's attributes change). Under reduced motion values jump, the wave stands still and an indeterminate indicator holds one frame. A width of your own on its `class` narrows a linear one (`inline-size: 12rem`); leave its `display` and `position` to it.
|
||||
|
||||
### `<x-badge>`
|
||||
|
||||
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
|
||||
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `position: relative` parent: `<span style="position: relative; display: inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
|
||||
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Built in" color="primary" solid />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container, in the colour itself (`solid`, for a label that has to stand out), or a neutral `outline` edge (the role that has to be seen, not the decorative `outline-variant` dividers use). `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`, `neutral`, `plain`; an unknown colour is `error`.
|
||||
- `color="neutral"` — neutral ink on every variant: a dot or count in on-surface-variant with surface text, `tonal` in surface-container-high with on-surface-variant text, `outline` in the outline edge with on-surface-variant text.
|
||||
- `color="plain"` — no background, text or border colour in any variant (shape, size and type stay), so the classes you pass paint it: `<x-badge value="Run" tonal color="plain" class="bg-tertiary-container text-on-tertiary-container" />`. Pass both a background and a text class; an `outline` badge's edge takes the text colour unless you pass a `border-*` colour.
|
||||
- The value is `value` or the slot; the slot renders as HTML: `<x-badge tonal><x-icon name="bolt" optical="20" class="size-3" /> Pro</x-badge>`. `value` is escaped. A slot that holds only whitespace or comments is still a dot.
|
||||
- `color="plain"` — no background, text or border colour in any variant (shape, size and type stay), so your own CSS paints it: `<x-badge value="Run" tonal color="plain" class="sport-run-label" />` with `.sport-run-label { background-color: var(--md-sys-color-tertiary-container); color: var(--md-sys-color-on-tertiary-container); }`. Set both a background and a colour; an `outline` badge's edge takes the text colour unless you set a `border-color`.
|
||||
- The value is `value` or the slot; the slot renders as HTML: `<x-badge tonal><x-icon name="bolt" size="12" /> Pro</x-badge>`. `value` is escaped. A slot that holds only whitespace or comments is still a dot.
|
||||
|
||||
### `<x-alert>`
|
||||
|
||||
@@ -455,34 +490,34 @@ A few lines of context around a trigger, with an optional `title` and `actions`
|
||||
</x-rich-tooltip>
|
||||
```
|
||||
|
||||
Shows on hover and keyboard focus and goes 1.5s after the pointer or the focus leaves, as M3 times a plain tooltip too; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). The trigger is pointed at the bubble with `aria-describedby`, so its words are read out with the control. An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`.
|
||||
Shows on hover and keyboard focus (not the focus a sheet or dialog moves to its trigger as it opens) and goes 1.5s after the pointer or the focus leaves, as M3 times a plain tooltip too; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). The trigger is pointed at the bubble with `aria-describedby`, so its words are read out with the control. An open bubble stays open while the Livewire component around it renders, its actions' `wire:click` included. `side`: `bottom` (default), `top`, `left`, `right`.
|
||||
|
||||
### `<x-stat>`
|
||||
|
||||
`<x-stat title="Shares" value="1,204" icon="link" description="12 this week" />` — a figure on a surface-container panel; the value counts up on first appearance and when it changes. The slot goes under the description (e.g. a quota's `<x-progress>`). Do not pass a `bg-*` class; wrap it.
|
||||
`<x-stat title="Shares" value="1,204" icon="link" description="12 this week" />` — a figure on a surface-container panel; the value counts up on first appearance and when it changes. The slot goes under the description (e.g. a quota's `<x-progress>`). Its fill is surface-container: do not repaint it with a class of your own; wrap it.
|
||||
|
||||
### `<x-empty-state>`
|
||||
|
||||
"Nothing here yet": `icon` on an Expressive `shape` (`cookie-9` by default), `title`, `description` or slot, and an `actions` slot. Use it for an empty collection, not for a filter that matched nothing.
|
||||
|
||||
The `illustration` slot draws the application's own artwork in place of the shape and icon (`icon` and `shape` are then unused). Size the artwork yourself; the slot's attributes go on the element around it, so its `class` sets the colour `currentColor` takes. Mark decorative SVG `aria-hidden="true"`. A slot holding only whitespace or comments leaves the shape and icon.
|
||||
The `illustration` slot draws the application's own artwork in place of the shape and icon (`icon` and `shape` are then unused). Size the artwork yourself; the slot's attributes go on the element around it, so its `style` (or a class of your own) sets the colour `currentColor` takes. Mark decorative SVG `aria-hidden="true"`. A slot holding only whitespace or comments leaves the shape and icon.
|
||||
|
||||
```blade
|
||||
<x-empty-state title="No routes yet" description="Draw one on the map.">
|
||||
<x-slot:illustration class="text-primary"><svg class="size-32" viewBox="0 0 120 120" aria-hidden="true">…</svg></x-slot:illustration>
|
||||
<x-slot:illustration style="color: var(--md-sys-color-primary)"><svg width="128" height="128" viewBox="0 0 120 120" aria-hidden="true">…</svg></x-slot:illustration>
|
||||
<x-slot:actions><x-button label="Draw a route" variant="filled" /></x-slot:actions>
|
||||
</x-empty-state>
|
||||
```
|
||||
|
||||
### `<x-card>`
|
||||
|
||||
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`, `heading` (the title's element, `h3` by default: pass `h2` for a card straight under the page's `h1`); slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). A card holds content and actions about one subject (M3); a table of many rows or a group of settings is a headed section, not a card. Do not pass `bg-*`; use `variant`.
|
||||
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`, `heading` (the title's element, `h3` by default: pass `h2` for a card straight under the page's `h1`); slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). A card holds content and actions about one subject (M3); a table of many rows or a group of settings is a headed section, not a card. Choose its fill with `variant`, not a background of your own.
|
||||
|
||||
A card or list item that opens something is a **row**: `data-md-list-row` on it and `data-md-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `<a>` or use a stretched link. A row answers with the state layer and one step of elevation; its corner does not move.
|
||||
A card or list item that opens something is a **row**: `data-md-list-row` on it and `data-md-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `<a>` or use a stretched link. A row answers with the state layer and one step of elevation; its corner does not move. `data-md-selected` on a row that is not a card — your own `<li>`, `<div>` or `<tr>`, the one whose sheet is open — fills it secondary-container with on-secondary-container ink, the state layer over the fill (`<x-list-item selected>` writes it itself); a card takes no selected fill. A row that is not a card draws its state layer and that fill from `components/list-item.css`, so the CSS entry imports it even where no `<x-list-item>` or `<x-table>` renders (the design guard's `missingStylesheets()` asks for it).
|
||||
|
||||
```blade
|
||||
<x-card variant="outlined" data-md-list-row wire:key="share-{{ $share->id }}">
|
||||
<a href="{{ route('shares.show', $share) }}" data-md-list-open wire:navigate class="type-title-md">{{ $share->name }}</a>
|
||||
<a href="{{ route('shares.show', $share) }}" data-md-list-open wire:navigate class="md-type-title-md">{{ $share->name }}</a>
|
||||
<x-slot:actions><x-button label="Copy link" wire:click="copy({{ $share->id }})" /></x-slot:actions>
|
||||
</x-card>
|
||||
```
|
||||
@@ -545,9 +580,11 @@ Props: `title`, `subtitle`, `icon` (centred hero icon), `separator` (draw the di
|
||||
|
||||
### `<x-drawer>`
|
||||
|
||||
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`; mirrored in RTL), `width` (`400px`), `with-close-button` (**default true** — M3 requires a close affordance; `:with-close-button="false"` is ignored when Escape or the scrim is off, or on a `standard` sheet), `close-on-escape` (default true), `without-backdrop-close`, `actions` slot (**left**-aligned in a 72dp row, which is what the side-sheet spec says; a dialog's are trailing-aligned). For the second pane of a list-detail layout use `<x-list-detail>` instead — `<x-drawer>` no longer has a `pane` prop. Its body is a size container — lay out inside with `@md:` (a *container* query), never a window class.
|
||||
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`; mirrored in RTL), `width` (`400px`), `with-close-button` (**default true** — M3 requires a close affordance; `:with-close-button="false"` is ignored when Escape or the scrim is off, or on a `standard` sheet), `close-on-escape` (default true), `without-backdrop-close`, `actions` slot (**left**-aligned in a 72dp row, which is what the side-sheet spec says; a dialog's are trailing-aligned). For the second pane of a list-detail layout use `<x-list-detail>` instead — `<x-drawer>` no longer has a `pane` prop. Its body is a size container — lay out inside it with a container query in your own CSS (`@container (width >= 28rem)`), never a window size class.
|
||||
|
||||
`standard` is M3's other side-sheet variant: supplementary content beside the primary content — filters, details, a list of actions — co-planar from `expanded`, flat on `surface` with 0dp elevation and no corner, the window's full height, an outline-variant rule down its inner edge instead of a scrim, nothing inert and no focus trap. Below `expanded` it is the modal sheet. Capped at M3's 400dp whatever `width` says, and it always draws the close button. Render it beside its content in a row that only lays out side by side from `expanded` (`<x-supporting-pane>`, or a caller's own row).
|
||||
Modal surfaces stack: a `<x-modal>`, menu, select or searchable choice opened inside or over a modal side sheet, bottom sheet or the modal rail — or one sheet opened from another, nested or rendered elsewhere on the page — takes its own Escape, so one press closes one layer, and the layer on top stays readable to a screen reader while the sheet under it keeps the rest of the page hidden. Render the dialog wherever suits the page; nothing needs to be moved inside the sheet.
|
||||
|
||||
`standard` is M3's other side-sheet variant: supplementary content beside the primary content — filters, details, a list of actions — co-planar from `expanded`, flat on `surface` with 0dp elevation and no corner, sticky and as tall as the window below the top safe area (and below `<x-scaffold>`'s sticky `top` app bar), an outline-variant rule down its inner edge instead of a scrim, nothing inert and no focus trap. Below `expanded` it is the modal sheet. Capped at M3's 400dp whatever `width` says, and it always draws the close button. Render it beside its content in a row that only lays out side by side from `expanded` (`<x-supporting-pane>`, or a caller's own row).
|
||||
|
||||
### `<x-bottom-sheet>`
|
||||
|
||||
@@ -567,7 +604,7 @@ An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page,
|
||||
</x-carousel>
|
||||
```
|
||||
|
||||
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `multi-aspect`, `full-screen` — one edge-to-edge item at a time scrolled **vertically**, which M3 gives to compact and medium windows in portrait only, and never to landscape), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, **16** — M3's specs table; leading only for `uncontained`, none for `full-screen`), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element sized `size-full`; `label` overlays a line of text; `aspect` is its ratio in a `multi-aspect` carousel. A `region` of `slide` groups named "n of m", each item a tab stop and the row itself not one, as M3 asks; from a focused item the arrow keys move one item, Home/End go to the ends and Space/Enter opens one that is not fully in view. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
|
||||
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `multi-aspect`, `full-screen` — one edge-to-edge item at a time scrolled **vertically**, which M3 gives to compact and medium windows in portrait only, and never to landscape), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, **16** — M3's specs table; leading only for `uncontained`, none for `full-screen`), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element your own CSS sizes to fill it (`inline-size: 100%; block-size: 100%`); `label` overlays a line of text; `aspect` is its ratio in a `multi-aspect` carousel. A `region` of `slide` groups named "n of m", each item a tab stop and the row itself not one, as M3 asks; from a focused item the arrow keys move one item, Home/End go to the ends and Space/Enter opens one that is not fully in view. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
|
||||
|
||||
`layout="multi-aspect"` is M3's uncontained multi-aspect-ratio layout (November 2025): each `<x-carousel-item aspect="16/9">` keeps its own ratio at the row's `height`, held inside M3's 9:16-to-16:9 range, so the widths come from the art. Only use it when the items really do have various widths. It is a plain flex row with uncontained scrolling — no keylines and no masks, since an arrangement of one item size cannot describe it — while the buttons, the arrow keys, Home/End and bring-into-view still work, from resting positions measured off the DOM.
|
||||
|
||||
@@ -626,14 +663,14 @@ A one-column grid of fields with an `actions` slot at the foot, end-aligned (the
|
||||
|
||||
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
|
||||
|
||||
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant` (and all but `<x-file>` a `hint-class`, classes added to the hint: `hint-class="text-warning"`), and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint, sets `aria-invalid`, and puts an `error` icon at the end of the row as M3's second indicator (not on `size="xs"`, and not when the field already trails something — `icon-right`, `clearable`, `copyable`). `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant` (and all but `<x-file>` a `hint-class`, classes added to the hint: `hint-class="md-ink-warning"`), and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint, sets `aria-invalid`, and puts an `error` icon at the end of the row as M3's second indicator (not on `size="xs"`, and not when the field already trails something — `icon-right`, `clearable`, `copyable`). `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||
|
||||
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `counter`, `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
|
||||
- `<x-password>`: a reveal button; `icon`, `size`.
|
||||
- `<x-textarea>`: grows from `rows` (3) to `max-rows`, then scrolls; `:autogrow="false"` for a fixed, hand-resizable one; `counter`.
|
||||
- Width: M3 asks that a text field never span the full width of a large screen, so from `medium` (600px) every field stops at **40rem**; below that it fills its pane. A `max-w-*` class on the component narrows or widens it, and `full` (on `<x-field>`, `<x-input>`, `<x-textarea>`) takes the bound off for a field that really is the width of its pane — a search row, an editor. `<x-search>`'s bar carries M3's own bound, 720px.
|
||||
- Width: M3 asks that a text field never span the full width of a large screen, so from `medium` (600px) every field stops at **40rem**; below that it fills its pane. A width rule of your own on its `class` (`max-inline-size: 24rem`) narrows or widens it, because your unlayered CSS outranks the package's, and `full` (on `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`, `<x-choices searchable>`, `<x-datepicker>` and `<x-timepicker>`) takes the bound off for a field that really is the width of its pane — a search row, an editor. `<x-search>`'s bar carries M3's own bound, 720px.
|
||||
- `counter` (on `<x-input>` and `<x-textarea>`) puts M3's character counter at the end of the supporting-text row, beside the hint or the error: `n/max`, counted on every keystroke against the field's own `maxlength`, and in the error colour once the value is past it. It needs `maxlength` — without one there is nothing to count against and nothing is drawn. It is said as "Character count, 5/20" from a polite region a second after typing stops.
|
||||
- `<x-select>`: native `<select>` (M3 menu where the browser supports customizable selects). `options` as `['id' => …, 'name' => …, 'disabled' => bool]`, `option-value`, `option-label`, `placeholder` + `placeholder-value`, or `<option>`s in the slot; `icon`, `size`.
|
||||
- `<x-select>`: native `<select>` (M3 menu where the browser supports customizable selects). `options` as `['id' => …, 'name' => …, 'disabled' => bool]`, `option-value`, `option-label`, `placeholder` + `placeholder-value`, or `<option>`s in the slot; `icon`, `size`. A value too long for the field ends before the arrow with an ellipsis; the customizable select draws it in a `<button>` holding `<selectedcontent>`, which the view renders as the select's first child.
|
||||
- `<x-file>`: native file input; errors from `photos` and `photos.*`. Show previews of what was chosen yourself.
|
||||
- `<x-field id="…" label="…" :messages="$messages">` wraps a custom control given `data-md-field-control`; only for controls the package does not have.
|
||||
|
||||
@@ -641,9 +678,10 @@ M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire
|
||||
|
||||
M3 selection controls on native inputs; the whole row is the label.
|
||||
|
||||
- `<x-checkbox label hint right indeterminate />` — `indeterminate` for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render). Grouping is yours: from `expanded` (840px) M3 wants a set of related checkboxes gathered into a contained region rather than one long column, so wrap the set in `<div class="grid gap-4 expanded:grid-cols-2">` (or a card or side sheet) under a heading that names what the group asks.
|
||||
- `<x-checkbox label hint right indeterminate />` — `indeterminate` for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render). Grouping is yours: from `expanded` (840px) M3 wants a set of related checkboxes gathered into a contained region rather than one long column, so wrap the set in `<x-grid :columns="['compact' => 1, 'expanded' => 2]" gap="space200">` (or a card or side sheet) under a heading that names what the group asks.
|
||||
- `<x-radio label wire:model :options inline />` — options `['id', 'name', 'hint', 'disabled']` (`option-value`, `option-label`, `option-hint`); `value` checks an option without `wire:model`; `name` names an unbound group. M3 stacks radios and cautions against a row at any width, so reach for `inline` only for two or three short labels; it also wants five options or fewer and one of them chosen when the page loads.
|
||||
- `<x-toggle label hint right icons />` — M3 switch (`role="switch"`); `icons` puts a check and a cross on the handle, `icons="selected"` only the check. Without `label`, pass `aria-label`.
|
||||
- Beside text in a row — a setting's words and its switch in `<x-row justify="between">` — a switch keeps its 52px track and a checkbox its box: the text beside it wraps, and so does the control's own label.
|
||||
|
||||
```blade
|
||||
<x-checkbox label="All files" :checked="count($selected) === $files->count()" :indeterminate="$selected && count($selected) < $files->count()" wire:click="toggleAll" />
|
||||
@@ -676,12 +714,12 @@ M3 Expressive's slider on native `<input type="range">`s (one per handle), so th
|
||||
| `orientation` | `horizontal` | `vertical` stands it up: the value grows upwards, the value label sits beside the handle, Up and Down move it. Ignored with `range` — M3 keeps range sliders horizontal |
|
||||
| `disabled` | `false` | |
|
||||
|
||||
Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless a `w-*` class is passed.
|
||||
Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless your own CSS narrows it.
|
||||
|
||||
A vertical slider is as wide as a horizontal one is tall and as long as the wrapper it is in, so **give it a height** — `class="h-64"`, which the label and hint share. Without one it is 192px long.
|
||||
A vertical slider is as wide as a horizontal one is tall and as long as the wrapper it is in, so **give it a height** — a `block-size` rule of your own on its `class`, which the label and hint share. Without one it is 192px long.
|
||||
|
||||
```blade
|
||||
<x-slider label="Volume" orientation="vertical" wire:model.live="volume" class="h-64" />
|
||||
<x-slider label="Volume" orientation="vertical" wire:model.live="volume" style="block-size: 16rem" />
|
||||
```
|
||||
|
||||
### `<x-datepicker>`
|
||||
@@ -700,7 +738,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
|
||||
| `mode` | `docked` | `docked`: type a date (in the locale's numeric format) or pick one from a calendar under the field, which opens as a dialog on a compact window (below `medium`), as M3 asks; `modal`: the field opens a calendar dialog; `input`: the dialog opens on a text field. Both dialogs switch between calendar and typing |
|
||||
| `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field. On a compact window (below `medium`) it opens as M3's full-screen range picker: an app bar with a close button and **Save**, the range as the headline, and the months in one scrolling list instead of stepped one at a time |
|
||||
| `min`, `max` | `null` | `Y-m-d` or a date; days outside are disabled and the keyboard stays inside |
|
||||
| `label`, `hint`, `icon`, `variant`, `size` | | the field's |
|
||||
| `label`, `hint`, `icon`, `variant`, `size`, `full` | | the field's |
|
||||
| `value` | `null` | the initial value without `wire:model` |
|
||||
| `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) |
|
||||
| `clearable` | `false` | a button that empties the field (both ends of a range) once it holds a date |
|
||||
@@ -727,7 +765,7 @@ M3's time picker in a modal dialog, opened from a read-only text field (a press,
|
||||
| `min`, `max` | | `H:i`, inclusive; `min` later than `max` spans midnight. Outside values are greyed out and refused in the picker — validate on the server as well |
|
||||
| `clearable` | `false` | a button that empties the field |
|
||||
| `name` | | posts the value from a hidden input |
|
||||
| `label`, `hint`, `icon`, `variant`, `size` | | the field's; `required`, `disabled` and `placeholder` reach its input |
|
||||
| `label`, `hint`, `icon`, `variant`, `size`, `full` | | the field's; `required`, `disabled` and `placeholder` reach its input |
|
||||
|
||||
Errors under the `wire:model` name replace the hint. The dialog is `wire:ignore`: a Livewire render leaves an open picker open with its draft. Never name a Livewire property `$slot`: it renders empty in the component's view.
|
||||
|
||||
@@ -736,7 +774,7 @@ Errors under the `wire:model` name replace the hint. The dialog is `wire:ignore`
|
||||
Choosing from a list, with typed values (an array of integers stays integers). `options` (`id`, `name`, `disabled`; `option-value`, `option-label`), `label`, `hint`, `single`. Errors for the property and its items replace the hint.
|
||||
|
||||
- Default: filter chips, every option on screen — `single` for choice chips.
|
||||
- `searchable`: a text field that filters a menu as you type (single value; arrow keys, Enter, Escape); `icon`, `variant`, `placeholder`. Its list is a popover, so it is never clipped by a card.
|
||||
- `searchable`: a text field that filters a menu as you type (single value; arrow keys, Enter, Escape); `icon`, `variant`, `placeholder`, `full`. Its list is a popover, so it is never clipped by a card.
|
||||
|
||||
```blade
|
||||
<x-choices label="Days you are free" wire:model.live="days" :options="$weekdays" />
|
||||
@@ -758,7 +796,7 @@ M3 search bar that opens into a search view: docked under the bar from `medium`
|
||||
</x-search>
|
||||
```
|
||||
|
||||
The docked view overlaps what is under it; never place a search inside an element with `overflow-hidden` (a card), which clips it. The bar is never wider than M3's 720px and grows to that width while it is focused; for M3's 360px resting bar, wrap it in an element carrying `style="--search-width: 22.5rem"`.
|
||||
The docked view overlaps what is under it; never place a search inside an element with `overflow: hidden` (a card), which clips it. The bar is never wider than M3's 720px and grows to that width while it is focused; for M3's 360px resting bar, wrap it in an element carrying `style="--search-width: 22.5rem"`.
|
||||
|
||||
- `trigger="icon"` is M3's other entry point — search as a secondary action: one 48px search icon button that expands into the full-screen view at any width (so `docked` does not apply) and gives the button its focus back on close. Put it in a toolbar or an app bar row where a bar would not fit.
|
||||
- The `suggestions` slot is shown in the view until the first keystroke — recent or popular searches — and the results slot takes over once something is typed. The live region counts whichever list is on screen and names suggestions as such.
|
||||
@@ -808,13 +846,13 @@ A visitor who has pressed the menu button keeps that choice in both standard ban
|
||||
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
|
||||
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
|
||||
]">
|
||||
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
|
||||
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="md-type-title-lg">SealShare</a></x-slot:brand>
|
||||
<x-slot:rail-footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
|
||||
</x-slot:rail-footer>
|
||||
<x-slot:top>
|
||||
{{-- the page's app bar; its menu button opens the modal rail on a phone --}}
|
||||
<span class="medium:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>
|
||||
<x-stack as="span" hide-from="medium"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></x-stack>
|
||||
</x-slot:top>
|
||||
<x-slot:fab>
|
||||
<x-fab icon="add" tooltip="New share" link="{{ route('upload') }}" />
|
||||
@@ -829,9 +867,9 @@ A visitor who has pressed the menu button keeps that choice in both standard ban
|
||||
- `fab` places an `<x-fab>` as Compose's Scaffold does: fixed at the bottom-end corner, 16px from the window's edges on a compact window and 24px from `medium`, above the navigation bar and the bottom safe area, and lifted above a snackbar while one shows (M3: a snackbar appears above a FAB, never in front of or behind it). In focus order it comes after the page's bar and before the page. Use it or a FAB in `rail-header`, never both: M3 allows one FAB on a screen.
|
||||
- `banner` or `top`: M3's scaffold is bars, then rails, then panes. An application-wide bar — one search, one account menu, the same on every page — goes in `banner` and the rail starts under it; a bar that titles the page goes in `top`, beside the rail. Never both. A banner that pins itself to the top of the window says how tall it is (`style="--material-banner: 4rem"` on `<x-scaffold>`), so the rail sticks under it instead of behind it.
|
||||
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. On a compact window nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `medium`).
|
||||
- `--material-margin` is M3's window margin (16px compact, 24px from `medium`) and the content region already carries it, so a page inside the scaffold writes no gutters of its own, and a pane or canonical layout inside draws none; something that must reach the window's edges opts out with `-mx-(--material-margin)`.
|
||||
- `--material-margin` is M3's window margin (16px compact, 24px from `medium`) and the content region already carries it, so a page inside the scaffold writes no gutters of its own, and a pane or canonical layout inside draws none; something that must reach the window's edges opts out with `margin-inline: calc(var(--material-margin) * -1)` in your own CSS.
|
||||
- On a compact window the scaffold sets `--material-bottom-bar` (the bar, the bottom safe area and `--material-bottom-extra`), so the snackbar, the FAB and the page's bottom padding clear the bar; pad anything else you pin to the bottom with it. See Safe areas.
|
||||
- The content region is `max-expanded:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
|
||||
- Below `expanded` the content region clips what overflows sideways (`overflow-x: clip`). Never give a page wrapper `overflow-x: hidden`: it turns the region into a scroll container and breaks every `position: sticky` inside.
|
||||
|
||||
#### `<x-pane>`
|
||||
|
||||
@@ -950,15 +988,24 @@ Children in columns inside a pane.
|
||||
|
||||
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
|
||||
|
||||
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`<x-scaffold>` does):
|
||||
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; pin it in a wrapper and hide it where a rail takes over (`<x-scaffold>` does both):
|
||||
|
||||
```blade
|
||||
<div class="fixed inset-x-0 bottom-0 z-30 medium:hidden">
|
||||
<x-stack class="bottom-navigation" hide-from="medium">
|
||||
<x-navigation-bar>
|
||||
<x-navigation-bar-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" :active="request()->routeIs('shares.*')" badge="3" />
|
||||
<x-navigation-bar-item label="Upload" icon="upload" link="{{ route('upload') }}" />
|
||||
</x-navigation-bar>
|
||||
</div>
|
||||
</x-stack>
|
||||
```
|
||||
|
||||
```css
|
||||
.bottom-navigation {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
inset-block-end: 0;
|
||||
z-index: 30;
|
||||
}
|
||||
```
|
||||
|
||||
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"), `tall` (M3's 80px container, which keeps the icon over the label at every width — `<x-scaffold tall-bar>` picks it, and the bottom offset grows with it), `hide-on-scroll` (M3's scrolling behaviour: the bar slides out on a scroll down and springs back on a scroll up, never before the first screenful and never while a snackbar, bottom sheet or drawer is on screen; focus reaching it brings it back. `<x-scaffold hide-bar-on-scroll>` picks it, and `--material-bottom-bar` goes down and comes back with the bar, so a `fab` button and the snackbar keep their distance from it). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
|
||||
@@ -968,9 +1015,9 @@ M3 Expressive's flexible navigation bar, for three to five destinations. It does
|
||||
M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (a 56px full-width pill, icon beside label, count at the end).
|
||||
|
||||
```blade
|
||||
<div class="flex min-h-dvh">
|
||||
<x-row align="stretch" style="min-block-size: 100dvh">
|
||||
<x-navigation-rail mode="collapsible">
|
||||
<x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
|
||||
<x-slot:brand><span class="md-type-title-lg">SealShare</span></x-slot:brand>
|
||||
<x-slot:header><x-fab label="New share" icon="add" /></x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" active badge="3" />
|
||||
@@ -983,16 +1030,25 @@ M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (
|
||||
</x-slot:footer>
|
||||
</x-navigation-rail>
|
||||
|
||||
<main class="min-w-0 flex-1">…</main>
|
||||
</div>
|
||||
<main style="flex: 1; min-inline-size: 0">…</main>
|
||||
</x-row>
|
||||
```
|
||||
|
||||
- `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (`<x-scaffold>`'s, one rail per window size class: hidden and opened as a modal on a compact window, collapsed and opened as a modal at `medium`, a standard rail from `expanded` — collapsed there, expanded from `large`).
|
||||
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px — or the word `narrow` for M3's other *collapsed* width, 80px against the default 96, where the items are their icons alone; the labels stay in the accessibility tree and a narrow rail still expands to 16rem), `align` (`top` default, or `center` for M3's centred destinations — preferred on a tablet; the menu button, brand and FAB stay at the top and the footer at the foot), `hide-when-collapsed` (M3's immersive expanded behaviour, `collapsible` and `adaptive` only: collapsing the rail takes it out of the layout instead of narrowing it, and `$store.rail.show()` brings it back expanded over a scrim — so put a menu button in the app bar; the rail's own button then docks it again. Not below `medium` for a collapsible rail nor at `medium` for an adaptive one, where the window rather than the visitor collapses it and M3's collapsed rail may never hide), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`), `divider` (M3's optional vertical divider on the page's side — use it when the page scrolls under a fixed rail), `fill` (`false` for a transparent container, which M3 allows while the items keep 3:1 contrast). Slots: `brand` (beside the menu button, expanded only), `header` (one `<x-fab label icon>`, which the rail morphs into an extended FAB and back as it expands — it also rests at elevation 0, as M3 asks of a nested FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
|
||||
- Anything else inside a rail can take both shapes by matching the rail's own state directly — `data-md-navigation-rail`'s value, `:not([data-md-open])`, and the window band each mode collapses in, the same selectors `resources/css/components/navigation-rail.css` uses for every branch of "collapsed" (that file, not this one, is where the numbers live). The Tailwind `rail-collapsed:` variant this used to teach no longer exists: the package's own CSS is plain now. Nothing that shows while collapsed may be wider than 96px.
|
||||
- A `collapsible` rail is held to the collapsed 96px below `medium` (600px), where M3 says to use a navigation bar rather than a standard rail. `collapsed` and `expanded` are fixed-width by design: wrap one in a `medium:` element if it must not show on a phone.
|
||||
- Anything else inside a rail takes both shapes from the rail's value. The rail publishes M3's two values (Compose's `WideNavigationRailValue`) as `--md-navigation-rail-value`, `collapsed` or `expanded`, from the first paint and at the same moment as its own items change shape, whatever made it so (mode, window size class, the visitor's choice); a rail open over a scrim reads `expanded`. Every descendant inherits it, so your own CSS asks a style query rather than repeating the rail's conditions. Outside a rail it is unset and neither value matches. Style queries on a custom property need Chrome 111, Safari 18 or Firefox 151. Nothing that shows while collapsed may be wider than 96px.
|
||||
|
||||
```css
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
.account-summary {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- A `collapsible` rail is held to the collapsed 96px below `medium` (600px), where M3 says to use a navigation bar rather than a standard rail. `collapsed` and `expanded` are fixed-width by design: wrap one in `<x-stack hide-below="medium">` if it must not show on a phone.
|
||||
- `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way.
|
||||
- `$store.rail`: `collapsed`, `toggle()`, `collapse()`, `expand()` (the remembered choice; `auto` is true while nothing is stored, so an adaptive rail takes its window size class's default instead, and the first choice clears it), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php` → `rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
|
||||
- `$store.rail`: `collapsed`, `collapse()`, `expand()` (the remembered choice; `auto` is true while nothing is stored, so an adaptive rail takes its window size class's default instead, and the first choice clears it), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`), and `toggle()` — for a keyboard shortcut — which flips what the first rail on the page draws, as its menu button does: the choice where the rail stands in the layout (from `expanded` for `<x-scaffold>`'s, where a collapsed rail with nothing stored expands), the modal where the window leaves it no room. `config/livewire-material.php` → `rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
|
||||
|
||||
### `<x-app-bar>`
|
||||
|
||||
@@ -1015,7 +1071,7 @@ M3 Expressive top app bar, sticky by default (`:sticky="false"` to scroll away),
|
||||
]" />
|
||||
```
|
||||
|
||||
A collapsing bar needs the window to scroll: no ancestor with `overflow-hidden`/`overflow-auto` (`overflow-x-clip` is fine).
|
||||
A collapsing bar needs the window to scroll: no ancestor with `overflow: hidden` or `overflow: auto` (`overflow-x: clip` is fine).
|
||||
|
||||
### `<x-toolbar>`
|
||||
|
||||
@@ -1087,19 +1143,19 @@ A choice of colour profile (see Colour profiles): a swatch per generated profile
|
||||
|
||||
### `<x-table>`, `<x-sort-header>`
|
||||
|
||||
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>`; cell utilities (`text-end`, `whitespace-nowrap`) always win. 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 `<div class="overflow-x-auto">`. 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 `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>`; 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.
|
||||
|
||||
`<x-sort-header column="size" :sort-by="$sortBy">Size</x-sort-header>` sorts through the Livewire property `sortBy` (`['column' => …, 'direction' => 'asc'|'desc']`; `model` names another), with `aria-sort`.
|
||||
|
||||
```blade
|
||||
<div class="overflow-x-auto">
|
||||
<div style="overflow-x: auto">
|
||||
<x-table>
|
||||
<thead><tr><x-sort-header column="name" :sort-by="$sortBy">Name</x-sort-header><th class="text-end">Size</th></tr></thead>
|
||||
<thead><tr><x-sort-header column="name" :sort-by="$sortBy">Name</x-sort-header><th class="md-text-end">Size</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach ($shares as $share)
|
||||
<tr data-md-list-row wire:key="share-{{ $share->id }}">
|
||||
<td><a href="{{ route('shares.show', $share) }}" data-md-list-open wire:navigate>{{ $share->name }}</a></td>
|
||||
<td class="text-end tabular-nums">{{ $share->size }}</td>
|
||||
<td class="md-text-end md-tabular">{{ $share->size }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
@@ -1108,7 +1164,7 @@ A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>`;
|
||||
{{ $shares->links() }}
|
||||
```
|
||||
|
||||
Pagination: `$paginator->links()` (Laravel and Livewire, full and simple/cursor) is drawn in M3 — current page in secondary-container, "Page 2 of 7" on a phone. Turn off with `config('livewire-material.pagination')` = `false`; published `vendor/pagination` or `vendor/livewire` views still win.
|
||||
Pagination: `$paginator->links()` (Laravel and Livewire, full and simple/cursor) is drawn in M3 — current page in secondary-container, "Page 2 of 7" on a phone. Turn off with `config('livewire-material.pagination')` = `false`; published `vendor/pagination` or `vendor/livewire` views still win. The provider also sets `livewire.pagination_theme` to `material` whenever it still reads as Livewire's own default (`tailwind`); an application's own theme, or a component's `$paginationTheme` property or `paginationView()` method, still wins.
|
||||
|
||||
## Testing the design
|
||||
|
||||
@@ -1124,45 +1180,23 @@ it('uses only what compiles', function () {
|
||||
});
|
||||
```
|
||||
|
||||
It reads every path it is given and fails with `path:line` on unknown symbol names, Blade directives written inside a component tag (where they do not compile), and every Tailwind utility or variant — none compiles in a Tailwind-free application — or 1.x utility, each with its 2.0.0 replacement. A class the application's own stylesheets declare is exempt, and so is every `md-*` class:
|
||||
|
||||
| Written | Use |
|
||||
| --- | --- |
|
||||
| `sm:`, `md:`, `lg:`, `xl:`, `2xl:` (and `max-`) | `@media (width >= 600px)` …, or a layout component's `hide-below`/`hide-from`/`stack-below` prop |
|
||||
| `hover:`, `dark:`, `group-hover:`, `rtl:` … | `md-state-layer`/`md-focus-ring`, `[data-theme='dark']`, `:has()`, `:is([dir='rtl'], [dir='rtl'] *)` in your own CSS |
|
||||
| `flex`, `flex-col`, `flex-wrap`, `items-center`, `justify-between` | `<x-row>`, `<x-stack>`, `<x-row wrap>`, `align="center"`, `justify="between"` |
|
||||
| `grid`, `grid-cols-3` | `<x-grid :columns="3">` (or a per-breakpoint map), `<x-feed>` for cards |
|
||||
| `gap-4`, `space-y-4`, `p-4`, `mt-2` | the M3 step at 4px per unit: `gap="space200"` (16px), `<x-stack gap="space200">`, `<x-surface padding="space200">`, `var(--md-sys-measurement-space100)` |
|
||||
| `w-full`, `max-w-5xl`, `size-6` | `<x-pane width>`, `<x-icon size>`, or a length in your own CSS — M3 keeps no size scale |
|
||||
| `hidden` | a layout component's `hide-below`/`hide-from` prop, the `hidden` attribute, or `x-show` |
|
||||
| `block`, `relative`, `inset-0`, `z-10`, `overflow-hidden`, `shrink-0`, `border`, `opacity-50`, `cursor-pointer` … | the rule in your own CSS |
|
||||
| `text-center`, `truncate`, `sr-only`, `whitespace-nowrap`, `line-clamp-2`, `tabular-nums` | `md-text-center`, `md-truncate`, `md-visually-hidden`, `md-nowrap`, `md-line-clamp-2`, `md-tabular` |
|
||||
| `type-body-md`, `focus-ring`, `state-layer`, `link` (1.x) | `md-type-body-md`, `md-focus-ring`, `md-state-layer`, `md-link` |
|
||||
| `text-on-surface-variant`, `text-primary`, `text-meta` … | `md-ink-variant`, `md-ink-primary` … |
|
||||
| `border-outline-variant`, `border-divider` | `<x-divider>` or `<x-surface outlined>` |
|
||||
| `bg-surface-container` | `<x-surface level="surface-container">` |
|
||||
| `bg-primary`, `border-error`, `text-red-500` … | `var(--md-sys-color-*)` |
|
||||
| `bg-white`, `text-black` | a surface or `on-` role — M3 has no white or black |
|
||||
| `text-on-surface/60`, `bg-on-surface/8` | `md-ink-variant`/`md-ink-quiet`, or `md-state-layer` for an overlay |
|
||||
| `rounded-lg`, `rounded-corner-lg` (1.x) | `var(--md-sys-shape-corner-lg)`, or `<x-surface corner="lg">` |
|
||||
| `shadow-md`, `shadow-elevation-2` (1.x) | `var(--md-sys-elevation-2)` |
|
||||
| `text-sm`, `leading-6`, `tracking-wide`, `font-medium` | one of the `md-type-*`/`md-type-emphasized-*` classes |
|
||||
| `ease-in-out`, `ease-spatial-fast` (1.x), `duration-300` | `var(--md-sys-motion-spatial-fast)` with its `-duration`, in your own `transition` |
|
||||
| `bg-[#1d7afc]`, `w-[320px]`, `[--x:1]` | an M3 role, or the value in your own CSS |
|
||||
It reads every path it is given and fails with `path:line` on unknown symbol names, Blade directives written inside a component tag (where they do not compile), and a class no stylesheet declares — not your own CSS, not the package's `md-*` — whatever it came from: a leftover utility, a typo, a class whose rule was deleted. Without a `.css` file among the scanned paths or a `missingStylesheets()` entry to supply your import graph, it has no declared class to compare against and reports every one of them.
|
||||
|
||||
In the `.css` files it is given (`material-scheme.css` skipped) it fails on a literal colour, radius, shadow, font, font size, weight, line height, letter spacing, easing or duration, and on a media query width off 600/840/1200/1600px; a `var()`, or a `calc()`/`min()`/`max()`/`clamp()` built on one, is fine.
|
||||
|
||||
`missingStylesheets($cssEntry)` follows the entry's relative `@import`s through every package file's own imports (`split-button.css` counts `button.css` and `menu.css` too; `tailwindcss` or a font URL is skipped) and checks them against the package tags a scanned view renders (plain, prefixed or `<x-livewire-material::…>`) and `->links()`; each missing stylesheet names its `@import` line once, and a tag the application shadows with its own component is reported instead — the application's component wins in Blade. It reads imports only: leave `resource_path('css')` out of `scan()` until the stylesheets are on tokens. `unusedStylesheets($cssEntry)` is the other way round: a package stylesheet the entry imports directly that no scanned view needs, even through a needed stylesheet's own imports, named at its `@import` line to remove (an entry importing `all.css` is left alone). `forbidColours([...])` fails wherever a left-out role (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. `forbid($pattern, $reason)` adds a pattern of your own.
|
||||
Markdown mail components — whatever lies under `config('mail.markdown.paths')`, `resources/views/vendor/mail` by default — are drawn by the mail theme, not by your CSS entry. So the guard skips the class check there (it still reads their icon names, the directives in their component tags and your own bans), and a mail theme stylesheet under that path, which has to write literal values, is neither checked for literals nor a source of declared classes.
|
||||
|
||||
`missingStylesheets($cssEntry)` follows the entry's relative `@import`s through every package file's own imports (`split-button.css` counts `button.css` and `menu.css` too; a package name or a font URL is skipped) and checks them against the package tags a scanned view renders (plain, prefixed or `<x-livewire-material::…>`), `->links()`, and a row a view of yours writes by hand (`data-md-list-row` on anything but `<x-card>`, which needs `components/list-item.css`; the package's own views are not read for it); each missing stylesheet names its `@import` line once, and a tag the application shadows with its own component is reported instead — the application's component wins in Blade. It reads imports only: leave `resource_path('css')` out of `scan()` until the stylesheets are on tokens, as long as this entry is what `missingStylesheets()` reads — its import graph is still what supplies the declared classes above. `unusedStylesheets($cssEntry)` is the other way round: a package stylesheet the entry imports directly that no scanned view needs — no tag, `->links()` or row written by hand — even through a needed stylesheet's own imports, named at its `@import` line to remove (an entry importing `all.css` is left alone). `forbidColours([...])` fails wherever a left-out role (with its `on-` and container roles) is still written: `var(--md-sys-color-…)` in CSS or an inline `style`, its `md-ink-*` class, or a component's `color`/`tone` prop. `forbid($pattern, $reason)` adds a pattern of your own.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}::name>` when `config('livewire-material.prefix')` is set; `<x-livewire-material::name>` always works.
|
||||
- Write class names out whole. Tailwind cannot compile `'text-'.$tone` or `type-{{ $size }}`, and the design guard cannot read them.
|
||||
- Write class names out whole: the design guard reads a line at a time and cannot see `'md-ink-'.$tone` or `md-type-{{ $size }}`, so a class built at runtime goes unchecked. Where the choice is data, put it in a `data-*` attribute your own CSS keys on.
|
||||
- The showcase at `/material` (local only, `MATERIAL_SHOWCASE=true` to force it) renders every token and component.
|
||||
|
||||
## Livewire traps
|
||||
|
||||
- Blade directives do not compile inside a component tag's attributes: `<x-foo x-show="ok(@js($value))">` reaches the browser as literal text. On a component tag use `{{ }}` and `:prop` bindings — `@js($value)` is `{{ \Illuminate\Support\Js::from($value) }}`, `@class([...])` is `:class="\Illuminate\Support\Arr::toCssClasses([...])"` — or put the Alpine on a plain element inside the slot. `DesignGuard` names the form for each directive.
|
||||
- Never pass `hidden`, a display utility or a position (`absolute`, `relative`) to a component: it is merged beside the component's own and whichever Tailwind emits last wins. Wrap the component in an element that carries it. A variant that only hides (`max-medium:hidden`) is safe.
|
||||
- Blade directives do not compile inside a component tag's attributes: `x-show="ok(@js($value))"` on a component tag reaches the browser as literal text. On a component tag use `{{ }}` and `:prop` bindings — `@js($value)` is `{{ \Illuminate\Support\Js::from($value) }}`, `@class([...])` is `:class="\Illuminate\Support\Arr::toCssClasses([...])"` — or put the Alpine on a plain element inside the slot. `DesignGuard` names the form for each directive.
|
||||
- To hide a component, give it the `hidden` attribute (it wins over any `display`, foundation/hidden.css), `x-show`, or a layout component's `hide-below`/`hide-from` around it. A `display` or `position` of your own on a component's `class` wins too — your unlayered CSS outranks every package rule — but it replaces the one the component draws itself with, so put it on a wrapper instead.
|
||||
- `$attributes->wire('model')->value()` is `false`, not `null`, when there is no `wire:model`, and `filled(false)` is true. Normalise with `?: null`.
|
||||
- End every statement in a multi-line Alpine attribute with `;`: an inline `@if … @endif` inside it swallows the newline after it.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: material-3-design
|
||||
description: Material 3 Expressive's design system as Livewire Material implements it — colour roles and surface containers, elevation, shape, type, motion, states and targets, window size classes, spacing, icons, accessibility — each M3 name beside the utility that draws it and Google's source page, for deciding how a screen should look and behave before writing it.
|
||||
description: Material 3 Expressive's design system as Livewire Material implements it — colour roles and surface containers, elevation, shape, type, motion, states and targets, window size classes, spacing, icons, accessibility — each M3 name beside the class, prop or token that draws it and Google's source page, for deciding how a screen should look and behave before writing it.
|
||||
---
|
||||
|
||||
# Material 3 design
|
||||
@@ -9,57 +9,59 @@ description: Material 3 Expressive's design system as Livewire Material implemen
|
||||
|
||||
Use this skill when deciding how a screen, panel or control should look or behave — which colour, container, corner, type style, motion, breakpoint or spacing — in an application that requires `nonameweb/livewire-material`, and when reviewing a view against Material 3. The props and slots of each component are in the `livewire-material-development` skill; this one is the design language they implement. The rules an agent must always follow are in the `material-3` guideline; the tables here are what those rules compress.
|
||||
|
||||
Every table pairs the M3 name with the library's utility or attribute. The tokens are CSS variables (`--md-sys-color-*`, `--md-sys-typescale-*`, `--md-sys-shape-*`, `--md-sys-elevation-*`, `--md-sys-motion-*`, `--md-sys-state-*`, `--md-sys-measurement-*`, `--md-ref-typeface-*`) and the utilities read them, so a template names a utility and never a variable or a value.
|
||||
Every table pairs the M3 name with what the library gives for it. The library is plain CSS with no utility classes, so that is one of three things: a component or layout component prop (`color="error"`, `<x-surface level="surface-container">`, `gap="space200"`), one of the fixed text and interaction classes (`md-type-*`, `md-ink-*`, `md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link`), or a token the application's own CSS reads with `var()`. The tokens are CSS custom properties (`--md-sys-color-*`, `--md-sys-typescale-*`, `--md-sys-shape-*`, `--md-sys-elevation-*`, `--md-sys-motion-*`, `--md-sys-state-*`, `--md-sys-measurement-*`, `--md-ref-typeface-*`), so a stylesheet names a token and never a value.
|
||||
|
||||
## Colour
|
||||
|
||||
A colour scheme is generated from one seed by Google's colour science (`php artisan material:scheme`); every role below is a slot in that scheme, light and dark, at three contrast levels. A template names a role and nothing else — never a hex, a palette tone, an arbitrary value or an opacity — because only a role follows the theme, the contrast level and a colour profile.
|
||||
A colour scheme is generated from one seed by Google's colour science (`php artisan material:scheme`); every role below is a slot in that scheme, light and dark, at three contrast levels. A view names a role and nothing else — never a hex, a palette tone or an opacity — because only a role follows the theme, the contrast level and a colour profile.
|
||||
|
||||
### Roles
|
||||
|
||||
| Role | Purpose | Its `on-` pair | Utility |
|
||||
| Role | Purpose | Its `on-` pair | In this library |
|
||||
| --- | --- | --- | --- |
|
||||
| primary | High-emphasis fills, text and icons: the key action on a screen | on-primary | `bg-primary text-on-primary`, `text-primary` |
|
||||
| primary-container | A standout fill for key components (FAB, an emphasised panel) | on-primary-container | `bg-primary-container text-on-primary-container` |
|
||||
| primary-dim | A darker primary for a pressed or contrasting fill (2025 spec) | on-primary | `bg-primary-dim` |
|
||||
| secondary | Less prominent fills, text and icons | on-secondary | `bg-secondary text-on-secondary` |
|
||||
| secondary-container | The recessive fill: tonal buttons, selected navigation, selected chips | on-secondary-container | `bg-secondary-container text-on-secondary-container` |
|
||||
| tertiary | A complementary accent, used sparingly for contrast | on-tertiary | `bg-tertiary text-on-tertiary` |
|
||||
| tertiary-container | The complementary fill | on-tertiary-container | `bg-tertiary-container text-on-tertiary-container` |
|
||||
| error | Urgency and errors; static, does not follow dynamic colour | on-error | `text-error`, `bg-error text-on-error` |
|
||||
| error-container | An error panel | on-error-container | `bg-error-container text-on-error-container` |
|
||||
| success, warning, info | This library's custom state colours, built like error on the 2025 spec, with `-container` and `on-` pairs | on-success … | `text-success`, `bg-warning-container text-on-warning-container` |
|
||||
| surface | The page background | on-surface | `bg-surface text-on-surface` |
|
||||
| on-surface-variant | Lower-emphasis text and icons on any surface | — | `text-on-surface-variant`, `text-body`, `text-meta` |
|
||||
| outline | A boundary that must be read: a text field, a target's edge (3:1 against surface) | — | `border-outline`, `text-outline`, `text-quiet` |
|
||||
| outline-variant | Decorative lines: dividers, card edges | — | `border-outline-variant`, `border-divider`, `border-structure`, `border-chrome`, `divide-divider` |
|
||||
| inverse-surface | A surface that contrasts with its surroundings (the snackbar) | inverse-on-surface | `bg-inverse-surface text-inverse-on-surface` |
|
||||
| inverse-primary | An action on an inverse surface (the snackbar's action) | — | `text-inverse-primary` |
|
||||
| scrim | Behind a modal, at 32% | — | `bg-scrim/32` |
|
||||
| shadow | The shadow colour, inside `shadow-elevation-*` | — | — |
|
||||
| surface-dim, surface-bright | Add-on surfaces that keep their relative brightness in both themes | on-surface | `bg-surface-dim`, `bg-surface-bright` |
|
||||
| primary-fixed, primary-fixed-dim, on-primary-fixed, on-primary-fixed-variant (and secondary, tertiary) | Add-on roles with the same tone in light and dark; for a colour that must not change with the theme; never where contrast matters | — | `bg-primary-fixed text-on-primary-fixed` |
|
||||
| primary | High-emphasis fills, text and icons: the key action on a screen | on-primary | `var(--md-sys-color-primary)`, `md-ink-primary`, `<x-button variant="filled">` |
|
||||
| primary-container | A standout fill for key components (FAB, an emphasised panel) | on-primary-container | `var(--md-sys-color-primary-container)` |
|
||||
| primary-dim | A darker primary for a pressed or contrasting fill (2025 spec) | on-primary | `var(--md-sys-color-primary-dim)` |
|
||||
| secondary | Less prominent fills, text and icons | on-secondary | `var(--md-sys-color-secondary)` |
|
||||
| secondary-container | The recessive fill: tonal buttons, selected navigation, selected chips | on-secondary-container | `var(--md-sys-color-secondary-container)`, `<x-button variant="tonal">` |
|
||||
| tertiary | A complementary accent, used sparingly for contrast | on-tertiary | `var(--md-sys-color-tertiary)`, `color="tertiary"` |
|
||||
| tertiary-container | The complementary fill | on-tertiary-container | `var(--md-sys-color-tertiary-container)` |
|
||||
| error | Urgency and errors; static, does not follow dynamic colour | on-error | `md-ink-error`, `var(--md-sys-color-error)`, `color="error"` |
|
||||
| error-container | An error panel | on-error-container | `var(--md-sys-color-error-container)`, `<x-alert color="error">` |
|
||||
| success, warning, info | This library's custom state colours, built like error on the 2025 spec, with `-container` and `on-` pairs | on-success … | `md-ink-success`, `var(--md-sys-color-warning-container)`, `color="info"` |
|
||||
| surface | The page background | on-surface | the page itself (the foundation paints it), `<x-surface level="surface">` |
|
||||
| on-surface-variant | Lower-emphasis text and icons on any surface | — | `md-ink-variant` |
|
||||
| outline | A boundary that must be read: a text field, a target's edge (3:1 against surface) | — | `md-ink-quiet`, `var(--md-sys-color-outline)` |
|
||||
| outline-variant | Decorative lines: dividers, card edges | — | `<x-divider>`, `<x-surface outlined>`, `var(--md-sys-color-outline-variant)` |
|
||||
| inverse-surface | A surface that contrasts with its surroundings (the snackbar) | inverse-on-surface | `var(--md-sys-color-inverse-surface)` with `md-ink-inverse` |
|
||||
| inverse-primary | An action on an inverse surface (the snackbar's action) | — | `var(--md-sys-color-inverse-primary)` |
|
||||
| scrim | Behind a modal, at 32% | — | `color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent)` |
|
||||
| shadow | The shadow colour, inside every `--md-sys-elevation-*` | — | — |
|
||||
| surface-dim, surface-bright | Add-on surfaces that keep their relative brightness in both themes | on-surface | `<x-surface level="surface-dim">`, `<x-surface level="surface-bright">` |
|
||||
| primary-fixed, primary-fixed-dim, on-primary-fixed, on-primary-fixed-variant (and secondary, tertiary) | Add-on roles with the same tone in light and dark; for a colour that must not change with the theme; never where contrast matters | — | `var(--md-sys-color-primary-fixed)` with `var(--md-sys-color-on-primary-fixed)` |
|
||||
|
||||
Pairing: a role's `on-` pair is the only combination whose contrast is guaranteed at every contrast level. `bg-primary text-on-primary` and `bg-secondary-container text-on-secondary-container` are right; `bg-primary-container text-on-surface` or `bg-secondary-container text-primary` are not, and break as the contrast level rises. Google: "Pair and layer color roles only as intended … Don't mix roles improperly."
|
||||
Pairing: a role's `on-` pair is the only combination whose contrast is guaranteed at every contrast level. A `primary` fill under `on-primary` text and a `secondary-container` fill under `on-secondary-container` are right; `primary-container` under `on-surface`, or `secondary-container` under `primary`, are not, and break as the contrast level rises. A component sets its own pair; the application's CSS writes both halves (`background-color: var(--md-sys-color-primary); color: var(--md-sys-color-on-primary)`). Google: "Pair and layer color roles only as intended … Don't mix roles improperly."
|
||||
|
||||
### Surface containers
|
||||
|
||||
A hierarchy of emphasis, not of height: the tone separates panels before any shadow does, and a region keeps its role at every breakpoint (body always `surface`, navigation always `surface-container`).
|
||||
|
||||
| Role | Use | Utility |
|
||||
| Role | Use | In this library |
|
||||
| --- | --- | --- |
|
||||
| surface | The page | `bg-surface` |
|
||||
| surface-container-lowest | The most recessed panel; an elevated card's body in dark themes | `bg-surface-container-lowest` |
|
||||
| surface-container-low | An elevated card, a modal bottom or side sheet, the full-screen search view | `bg-surface-container-low` |
|
||||
| surface-container | Navigation bar and rail, docked and floating toolbars, menus, the segmented list | `bg-surface-container` |
|
||||
| surface-container-high | Dialogs, the search bar, date and time pickers, a rich tooltip | `bg-surface-container-high` |
|
||||
| surface-container-highest | A filled card, a filled text field, a filled chip's selected state | `bg-surface-container-highest` |
|
||||
| surface | The page | the page itself, `<x-surface level="surface">` |
|
||||
| surface-container-lowest | The most recessed panel; an elevated card's body in dark themes | `<x-surface level="surface-container-lowest">` |
|
||||
| surface-container-low | An elevated card, a modal bottom or side sheet, the full-screen search view | `<x-surface level="surface-container-low">` |
|
||||
| surface-container | Navigation bar and rail, docked and floating toolbars, menus, the segmented list | `<x-surface>` (the default level) |
|
||||
| surface-container-high | Dialogs, the search bar, date and time pickers, a rich tooltip | `<x-surface level="surface-container-high">` |
|
||||
| surface-container-highest | A filled card, a filled text field, a filled chip's selected state | `<x-surface level="surface-container-highest">` |
|
||||
|
||||
In the application's CSS each is `var(--md-sys-color-surface-container-low)` and so on; the ink on every one of them is `on-surface`.
|
||||
|
||||
### Emphasis and lines
|
||||
|
||||
- Default ink is `on-surface`; lower emphasis is `on-surface-variant`; decoration is `outline`. Emphasis is never an opacity: M3 reserves 38% (`/38`) for disabled content and 12% (`/12`) for a disabled container.
|
||||
- Default ink is `on-surface` (`md-ink`); lower emphasis is `on-surface-variant` (`md-ink-variant`); decoration is `outline` (`md-ink-quiet`). Emphasis is never an opacity: M3 reserves 38% (`--md-sys-state-disabled-content-opacity`) for disabled content and 12% (`--md-sys-state-disabled-container-opacity`) for a disabled container.
|
||||
- `outline` for a boundary that has to be perceived (a text field's edge, a target's edge — 3:1 against the surface); `outline-variant` for dividers and the edge of a card or any component holding several elements. Google: "Don't use the outline color for dividers … use outline variant instead." `outline-variant` may edge a chip or a button only because the content inside already carries the contrast.
|
||||
- A hyperlink in running text is `primary` (or `tertiary` for a quieter link) **and** underlined: the `link` utility.
|
||||
- A hyperlink in running text is `primary` (or `tertiary` for a quieter link) **and** underlined: `md-link` with `md-ink-primary`.
|
||||
|
||||
### Contrast
|
||||
|
||||
@@ -69,7 +71,7 @@ A hierarchy of emphasis, not of height: the tone separates panels before any sha
|
||||
| Medium | 3:1 minimum everywhere, without halation | `<html data-contrast="medium">` |
|
||||
| High | 7:1 | `<html data-contrast="high">`, or the visitor's OS setting (`theme.contrast.default` = `system`) |
|
||||
|
||||
Every role changes with the level automatically; a component built from roles needs nothing else. Disabled states are exempt from contrast. A colour outside the roles (a hex, `white`, `black`) does not change and is the one thing that breaks a contrast level.
|
||||
Every role changes with the level automatically; a component built from roles needs nothing else. Disabled states are exempt from contrast. A colour outside the roles (a hex, white, black) does not change and is the one thing that breaks a contrast level.
|
||||
|
||||
Sources: https://m3.material.io/styles/color/roles · https://m3.material.io/styles/color/system/how-the-system-works · https://m3.material.io/styles/color/advanced/apply-colors · https://m3.material.io/foundations/designing/color-contrast
|
||||
|
||||
@@ -77,18 +79,18 @@ Sources: https://m3.material.io/styles/color/roles · https://m3.material.io/sty
|
||||
|
||||
M3 separates surfaces by tone first; a shadow says that something floats over the content or is being interacted with. "When it comes to applying shadows, less is more."
|
||||
|
||||
| Level | Shadow | Rests here | Utility |
|
||||
| Level | Shadow | Rests here | In this library |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | none | The page, cards (filled, outlined), buttons (filled, tonal, outlined), button groups, icon buttons, lists, chips, tabs, sliders, the rail, a docked side sheet, a carousel, a full-screen dialog, a FAB inside the rail, an app bar at rest | — |
|
||||
| 1 | 1dp | Elevated cards, elevated buttons and chips, modal bottom and side sheets, a banner | `shadow-elevation-1` |
|
||||
| 2 | 3dp | Menus, the navigation bar, a scrolled app bar, toolbars, rich tooltips | `shadow-elevation-2` |
|
||||
| 3 | 6dp | FAB and extended FAB, the FAB menu's close button, dialogs, date and time pickers, the search bar | `shadow-elevation-3` |
|
||||
| 4 | 8dp | Interaction only: a level-3 element on hover or while dragged | `shadow-elevation-4` |
|
||||
| 5 | 12dp | Interaction only | `shadow-elevation-5` |
|
||||
| 1 | 1dp | Elevated cards, elevated buttons and chips, modal bottom and side sheets, a banner | `box-shadow: var(--md-sys-elevation-1)` |
|
||||
| 2 | 3dp | Menus, the navigation bar, a scrolled app bar, toolbars, rich tooltips | `var(--md-sys-elevation-2)` |
|
||||
| 3 | 6dp | FAB and extended FAB, the FAB menu's close button, dialogs, date and time pickers, the search bar | `var(--md-sys-elevation-3)` |
|
||||
| 4 | 8dp | Interaction only: a level-3 element on hover or while dragged | `var(--md-sys-elevation-4)` |
|
||||
| 5 | 12dp | Interaction only | `var(--md-sys-elevation-5)` |
|
||||
|
||||
- Hover lifts an element one level (a FAB 3 → 4, an elevated card 1 → 2); focus and selection may too; a raised element lowers when something higher appears.
|
||||
- Overlapping panels take different surface-container roles to show separation; the roles are not tied to the levels.
|
||||
- A scrim (`bg-scrim/32`) brings focus to a modal over a large surface; it is never a substitute for a shadow on a small floating element.
|
||||
- A scrim (`scrim` at 32%) brings focus to a modal over a large surface; it is never a substitute for a shadow on a small floating element.
|
||||
- On a dark surface a shadow is nearly invisible, so the tone does the work there.
|
||||
|
||||
Sources: https://m3.material.io/styles/elevation/overview · https://m3.material.io/styles/elevation/applying-elevation · https://m3.material.io/styles/elevation/tokens
|
||||
@@ -97,20 +99,20 @@ Sources: https://m3.material.io/styles/elevation/overview · https://m3.material
|
||||
|
||||
### The corner scale
|
||||
|
||||
| Style | Value | Utility |
|
||||
| Style | Value | In this library |
|
||||
| --- | --- | --- |
|
||||
| None | 0 | `rounded-corner-none` |
|
||||
| Extra small | 4px | `rounded-corner-xs` |
|
||||
| Small | 8px | `rounded-corner-sm` |
|
||||
| Medium | 12px | `rounded-corner-md` |
|
||||
| Large | 16px | `rounded-corner-lg` |
|
||||
| Large increased | 20px | `rounded-corner-lg-increased` |
|
||||
| Extra large | 28px | `rounded-corner-xl` |
|
||||
| Extra large increased | 32px | `rounded-corner-xl-increased` |
|
||||
| Extra extra large | 48px | `rounded-corner-xxl` |
|
||||
| Full | a stadium or circle | `rounded-corner-full` |
|
||||
| None | 0 | `var(--md-sys-shape-corner-none)`, `corner="none"` |
|
||||
| Extra small | 4px | `var(--md-sys-shape-corner-xs)`, `corner="xs"` |
|
||||
| Small | 8px | `var(--md-sys-shape-corner-sm)`, `corner="sm"` |
|
||||
| Medium | 12px | `var(--md-sys-shape-corner-md)`, `corner="md"` |
|
||||
| Large | 16px | `var(--md-sys-shape-corner-lg)`, `corner="lg"` |
|
||||
| Large increased | 20px | `var(--md-sys-shape-corner-lg-increased)`, `corner="lg-increased"` |
|
||||
| Extra large | 28px | `var(--md-sys-shape-corner-xl)`, `corner="xl"` |
|
||||
| Extra large increased | 32px | `var(--md-sys-shape-corner-xl-increased)`, `corner="xl-increased"` |
|
||||
| Extra extra large | 48px | `var(--md-sys-shape-corner-xxl)`, `corner="xxl"` |
|
||||
| Full | a stadium or circle | `var(--md-sys-shape-corner-full)`, `corner="full"` |
|
||||
|
||||
Tailwind's `rounded-sm`, `rounded-lg`, `rounded-full`, … do not compile. The `-t`, `-s`, `-e` sides work (`rounded-t-corner-xl` for a bottom sheet).
|
||||
`corner` is `<x-surface>`'s prop. In the application's CSS a corner is `border-radius` on a token, and one side at a time a logical longhand (`border-start-start-radius` and `border-start-end-radius` for a bottom sheet's top); a length of your own is off the scale.
|
||||
|
||||
### Corner by component
|
||||
|
||||
@@ -139,26 +141,26 @@ Tailwind's `rounded-sm`, `rounded-lg`, `rounded-full`, … do not compile. The `
|
||||
- Optical roundness: a shape nested in a rounded container takes inner radius = outer radius − padding (48 − 14 = 34), never the container's own radius.
|
||||
- Large and full corners do not belong on information-dense containers (cards, tables, text fields).
|
||||
- A press squares a round shape and rounds a square one (the components carry the morph on the fast spatial spring); nothing morphs on hover.
|
||||
- The 35 Expressive shapes (`<x-shape name="…">`, also the loading indicator and the standard button group's press shape) are decoration for emphasis and delight — never a carrier of meaning, never behind text-heavy content, and used sparingly.
|
||||
- The 35 Expressive shapes (`<x-shape name="cookie-9">`, also the loading indicator and the standard button group's press shape) are decoration for emphasis and delight — never a carrier of meaning, never behind text-heavy content, and used sparingly.
|
||||
|
||||
Sources: https://m3.material.io/styles/shape/corner-radius-scale · https://m3.material.io/styles/shape/shape-morph · https://m3.material.io/styles/shape/overview-principles
|
||||
|
||||
## Type
|
||||
|
||||
The typeface is Google Sans Flex for brand and plain styles (`font-sans`, `--md-ref-typeface-brand`); an application may replace it after importing the stylesheet. Each style is one utility that sets size, line height, weight, family and tracking together; `text-sm`, `font-medium`, `leading-*` and `tracking-*` do not compile.
|
||||
The typeface is Google Sans Flex for brand and plain styles (`--md-ref-typeface-brand`, `--md-ref-typeface-plain`); an application may replace it after importing the stylesheet. Each style is one class that sets size, line height, weight, family and tracking together — or, in the application's CSS, `font: var(--md-sys-typescale-body-md)` with `letter-spacing: var(--md-sys-typescale-body-md-tracking)`. A size, weight, line height or letter spacing of your own is off the scale.
|
||||
|
||||
| Role | Style | Size / line | Weight | Utility | Use for |
|
||||
| Role | Style | Size / line | Weight | In this library | Use for |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Display | large / medium / small | 57/64 · 45/52 · 36/44 | 400 | `type-display-lg` … | hero figures, one short marketing line; never running text |
|
||||
| Headline | large / medium / small | 32/40 · 28/36 · 24/32 | 400 | `type-headline-lg` … | page titles, section titles, a dialog's headline (`headline-sm`) |
|
||||
| Title | large / medium / small | 22/28 · 16/24 · 14/20 | 400 / 500 / 500 | `type-title-lg` … | app bar title (`lg`), card and list-section titles (`md`), dense headers (`sm`) |
|
||||
| Body | large / medium / small | 16/24 · 14/20 · 12/16 | 400 | `type-body-lg` … | paragraphs (`lg` for reading, `md` in components), supporting text (`sm`) |
|
||||
| Label | large / medium / small | 14/20 · 12/16 · 11/16 | 500 | `type-label-lg` … | buttons and tabs (`lg`), chips and navigation (`md`), captions and badges (`sm`) |
|
||||
| Display | large / medium / small | 57/64 · 45/52 · 36/44 | 400 | `md-type-display-lg` … | hero figures, one short marketing line; never running text |
|
||||
| Headline | large / medium / small | 32/40 · 28/36 · 24/32 | 400 | `md-type-headline-lg` … | page titles, section titles, a dialog's headline (`headline-sm`) |
|
||||
| Title | large / medium / small | 22/28 · 16/24 · 14/20 | 400 / 500 / 500 | `md-type-title-lg` … | app bar title (`lg`), card and list-section titles (`md`), dense headers (`sm`) |
|
||||
| Body | large / medium / small | 16/24 · 14/20 · 12/16 | 400 | `md-type-body-lg` … | paragraphs (`lg` for reading, `md` in components), supporting text (`sm`) |
|
||||
| Label | large / medium / small | 14/20 · 12/16 · 11/16 | 500 | `md-type-label-lg` … | buttons and tabs (`lg`), chips and navigation (`md`), captions and badges (`sm`) |
|
||||
|
||||
- `type-emphasized-*` is the same size and line height one weight step heavier (400 → 500, 500 → 700), fully rounded in Google Sans Flex, with its own tracking. M3 uses it deliberately, never by default: a selected list or menu item, a button's label on a primary action, an extended FAB, a badge, a headline given editorial weight.
|
||||
- `md-type-emphasized-*` (`--md-sys-typescale-emphasized-*`) is the same size and line height one weight step heavier (400 → 500, 500 → 700), fully rounded in Google Sans Flex, with its own tracking. M3 uses it deliberately, never by default: a selected list or menu item, a button's label on a primary action, an extended FAB, a badge, a headline given editorial weight.
|
||||
- Tracking follows Compose's `TypeScaleTokens`: display-large −0.2, title-medium 0.2, title-small 0.1, body-large 0.5, body-medium 0.2, body-small 0.4, label-large 0.1, label-medium and small 0.5 (sp; rem = sp/16); the emphasized set tightens a few (display-large 0, title-medium 0.15, body-large 0.15, body-medium 0.25).
|
||||
- Line length 40–60 characters (`max-w-prose` is close; `max-w-[60ch]` is exact). Figures that change take `tabular-nums`.
|
||||
- Text must scale to 200%: containers grow, side-by-side controls stack, padding stays; components without text (progress, checkboxes) do not scale. Truncate to an ellipsis only when the full text is one tooltip or link away.
|
||||
- Line length 40–60 characters (`max-inline-size: 60ch` in the application's CSS). Figures that change take `md-tabular`.
|
||||
- Text must scale to 200%: containers grow, side-by-side controls stack, padding stays; components without text (progress, checkboxes) do not scale. Truncate to an ellipsis (`md-truncate`) only when the full text is one tooltip or link away.
|
||||
- When customising, change the typeface or tracking, never the sizes: component layout depends on them.
|
||||
|
||||
Sources: https://m3.material.io/styles/typography/type-scale-tokens · https://m3.material.io/styles/typography/applying-type · https://m3.material.io/styles/typography/fonts · https://m3.material.io/foundations/writing/text-resizing · https://m3.material.io/foundations/writing/text-truncation
|
||||
@@ -167,82 +169,84 @@ Sources: https://m3.material.io/styles/typography/type-scale-tokens · https://m
|
||||
|
||||
M3 Expressive moves on physics: every transition is a spring, and the library samples each spring into a CSS `linear()` easing paired with a duration. Use the pair together, or the curve is stretched over the wrong time.
|
||||
|
||||
| Spring | Damping / stiffness | Duration | Utility | For |
|
||||
| Spring | Damping / stiffness | Duration | In this library | For |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Spatial fast | 0.6 / 800 | 350ms | `ease-spatial-fast duration-(--md-sys-motion-spatial-fast-duration)` | small elements: a button's press morph, a switch, a chip |
|
||||
| Spatial default | 0.8 / 380 | 500ms | `ease-spatial-default duration-(--md-sys-motion-spatial-default-duration)` | most position, size and shape changes |
|
||||
| Spatial slow | 0.8 / 200 | 650ms | `ease-spatial-slow duration-(--md-sys-motion-spatial-slow-duration)` | large surfaces: a sheet, a pane, a full-screen transition |
|
||||
| Effects fast | 1.0 / 3800 | 150ms | `ease-effects-fast duration-(--md-sys-motion-effects-fast-duration)` | state layers, small fades |
|
||||
| Effects default | 1.0 / 1600 | 200ms | `ease-effects-default duration-(--md-sys-motion-effects-default-duration)` | most colour and opacity changes |
|
||||
| Effects slow | 1.0 / 800 | 300ms | `ease-effects-slow duration-(--md-sys-motion-effects-slow-duration)` | large fades, a scrim |
|
||||
| Spatial fast | 0.6 / 800 | 350ms | `var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast)` | small elements: a button's press morph, a switch, a chip |
|
||||
| Spatial default | 0.8 / 380 | 500ms | `var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default)` | most position, size and shape changes |
|
||||
| Spatial slow | 0.8 / 200 | 650ms | `var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow)` | large surfaces: a sheet, a pane, a full-screen transition |
|
||||
| Effects fast | 1.0 / 3800 | 150ms | `var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast)` | state layers, small fades |
|
||||
| Effects default | 1.0 / 1600 | 200ms | `var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default)` | most colour and opacity changes |
|
||||
| Effects slow | 1.0 / 800 | 300ms | `var(--md-sys-motion-effects-slow-duration) var(--md-sys-motion-effects-slow)` | large fades, a scrim |
|
||||
|
||||
- Spatial springs are underdamped and overshoot — that bounce is what reads as Expressive — so they carry only position, size and shape. Effects springs are critically damped and carry colour and opacity, which must never overshoot. A `transition-all` mixes the two and is wrong.
|
||||
A transition names the property, then the pair: `transition: transform var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default), opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast)`.
|
||||
|
||||
- Spatial springs are underdamped and overshoot — that bounce is what reads as Expressive — so they carry only position, size and shape. Effects springs are critically damped and carry colour and opacity, which must never overshoot. A transition on `all` mixes the two and is wrong.
|
||||
- The Standard motion scheme (`<html data-motion="standard">`, config `motion.scheme`) swaps the spatial springs for stiffer ones with almost no bounce (0.9 / 1400, 700, 300; 350, 500, 750ms) for utilitarian products; effects are shared.
|
||||
- Direction: something entering decelerates (`ease-emphasized-decelerate`, or a spatial spring from off-screen), a permanent exit accelerates (`ease-emphasized-accelerate`), a temporary exit that can be recalled (a drawer, a sheet) takes `ease-emphasized`; exits are shorter than entrances, and larger areas move longer.
|
||||
- The cubic-bezier set (`ease-standard`, `ease-emphasized`, `ease-emphasized-decelerate`, `ease-emphasized-accelerate`, `--md-sys-motion-duration-short|medium|long`) is for the few transitions whose duration is fixed from outside: a view transition, an animated scroll.
|
||||
- Reduced motion zeroes every duration token, so anything animated through them turns instant; a literal `duration-300` or a keyframe with its own timing ignores the visitor's setting and is a bug. Container transforms, parallax and expansions are removed, not slowed.
|
||||
- Direction: something entering decelerates (`--md-sys-motion-easing-emphasized-decelerate`, or a spatial spring from off-screen), a permanent exit accelerates (`--md-sys-motion-easing-emphasized-accelerate`), a temporary exit that can be recalled (a drawer, a sheet) takes `--md-sys-motion-easing-emphasized`; exits are shorter than entrances, and larger areas move longer.
|
||||
- The cubic-bezier set (`--md-sys-motion-easing-standard`, `-emphasized`, `-emphasized-decelerate`, `-emphasized-accelerate`, with `--md-sys-motion-duration-short|medium|long`) is for the few transitions whose duration is fixed from outside: a view transition, an animated scroll.
|
||||
- Reduced motion zeroes every duration token, so anything animated through them turns instant; a literal `300ms`, or a keyframe animation with its own timing, ignores the visitor's setting and is a bug. Container transforms, parallax and expansions are removed, not slowed.
|
||||
|
||||
Sources: https://m3.material.io/styles/motion/overview · https://m3.material.io/styles/motion/overview/specs · https://m3.material.io/styles/motion/easing-and-duration/tokens-specs · https://m3.material.io/styles/motion/transitions/transition-patterns
|
||||
|
||||
## States and targets
|
||||
|
||||
| State | Layer | Utility or hook | Also |
|
||||
| State | Layer | Class or hook | Also |
|
||||
| --- | --- | --- | --- |
|
||||
| Enabled | none | — | |
|
||||
| Hover | 8% of the content colour | `state-layer` (pointer devices only) | one level of elevation on floating elements |
|
||||
| Focused | 10% | `state-layer focus-ring` (keyboard focus: a 3px `secondary` ring, 2px out) | only one focused element at a time |
|
||||
| Pressed | 10% | `state-layer` (`:active`) | the shape morph on buttons |
|
||||
| Dragged | 16% | `state-layer` with `data-dragged` | one level of elevation |
|
||||
| Disabled | content 38%, container 12%, no state layer, not focusable | `disabled:text-on-surface/38 disabled:bg-on-surface/12` | exempt from contrast; a FAB is hidden rather than disabled |
|
||||
| Hover | 8% of the content colour | `md-state-layer` (pointer devices only) | one level of elevation on floating elements |
|
||||
| Focused | 10% | `md-state-layer md-focus-ring` (keyboard focus: a 3px `secondary` ring, 2px out) | only one focused element at a time |
|
||||
| Pressed | 10% | `md-state-layer` (`:active`) | the shape morph on buttons |
|
||||
| Dragged | 16% | `md-state-layer` with `data-md-dragged` | one level of elevation |
|
||||
| Disabled | content 38%, container 12%, no state layer, not focusable | `color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent)`, and the container likewise with `--md-sys-state-disabled-container-opacity` | exempt from contrast; a FAB is hidden rather than disabled |
|
||||
| Selected | the `secondary-container` pair, a filled icon, the emphasized style | component props (`selected`, `aria-selected`, `aria-pressed`) | combines with hover, focus and press |
|
||||
|
||||
- The state layer takes the content's `on-` colour (on `secondary-container` it is `on-secondary-container`), is 40px on a 48px target, and only one shows at a time.
|
||||
- The state layer takes the content's `on-` colour (on `secondary-container` it is `on-secondary-container`), is 40px on a 48px target, and only one shows at a time. `md-state-layer` draws it in `currentColor` as a `::before`, so the element becomes `position: relative`.
|
||||
- Every state shows two indicators, so a colour change alone is never a state: add a shape, an outline, an icon, a weight or a word (`aria-selected` plus the container, an error colour plus an icon and a message).
|
||||
- Targets: 48×48px minimum, 8px between targets, on every device; `touch-target` extends a smaller drawing to 48px. Density is an opt-in prop (`dense`) that steps padding by 4px and never applies to menus, snackbars, dialogs or settings controls, and never takes a target below 48px.
|
||||
- Targets: 48×48px minimum, 8px between targets, on every device; `md-touch-target` extends a smaller drawing to 48px. Density is an opt-in prop (`dense`) that steps padding by 4px and never applies to menus, snackbars, dialogs or settings controls, and never takes a target below 48px.
|
||||
- Keyboard: Tab and Shift+Tab between components in DOM order, arrows within a component (menu, tabs, grid, radio group), Enter and Space activate, Escape dismisses; a dialog moves focus in on open and back to its opener on close.
|
||||
|
||||
Sources: https://m3.material.io/foundations/interaction/states/state-layers · https://m3.material.io/foundations/interaction/states/applying-states · https://m3.material.io/foundations/designing/structure · https://m3.material.io/foundations/layout/grids-spacing/density
|
||||
|
||||
## Layout and breakpoints
|
||||
|
||||
Layout keys on the width of the window, in M3's five window size classes; they are the only responsive variants that compile, and `resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`) gives scripts the same numbers.
|
||||
Layout keys on the width of the window, in M3's five window size classes and only those. A layout component names the class in a prop (`hide-below`, `hide-from`, `stack-below`, `<x-grid>`'s `columns` map); the application's CSS writes the width as a range media query; a script asks `resources/js/breakpoints.js` (`from('expanded')`, `upTo('medium')`) for the same numbers.
|
||||
|
||||
| Class | Width | Variant | Navigation | Panes | Dialogs and choices | Margins |
|
||||
| Class | Width | Prop value · CSS | Navigation | Panes | Dialogs and choices | Margins |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| Compact | below 600px | the default; `max-medium:` for "only here" | navigation bar; the rail opens as a modal | 1 | full-screen or basic dialog; a bottom sheet for choices | 16px |
|
||||
| Medium | 600–839px | `medium:` | collapsed rail (96px) | 1, or 2 for low-density content at 50% each | basic dialog; a menu for choices | 24px |
|
||||
| Expanded | 840–1199px | `expanded:` | rail, collapsed or expanded, collapsible | 2 recommended; a fixed pane 360px | basic dialog; menu | 24px |
|
||||
| Large | 1200–1599px | `large:` | rail expanded | 2; a fixed pane 412px | basic dialog; menu | 24px |
|
||||
| Extra-large | 1600px and up | `extra-large:` | rail expanded | 2, or 3 with a standard side sheet (at most 400px) | basic dialog; menu | 24px |
|
||||
| Compact | below 600px | the default; `hide-from="medium"` for "only here" · `@media (width < 600px)` | navigation bar; the rail opens as a modal | 1 | full-screen or basic dialog; a bottom sheet for choices | 16px |
|
||||
| Medium | 600–839px | `medium` · `@media (width >= 600px)` | collapsed rail (96px) | 1, or 2 for low-density content at 50% each | basic dialog; a menu for choices | 24px |
|
||||
| Expanded | 840–1199px | `expanded` · `@media (width >= 840px)` | rail, collapsed or expanded, collapsible | 2 recommended; a fixed pane 360px | basic dialog; menu | 24px |
|
||||
| Large | 1200–1599px | `large` · `@media (width >= 1200px)` | rail expanded | 2; a fixed pane 412px | basic dialog; menu | 24px |
|
||||
| Extra-large | 1600px and up | `extra-large` · `@media (width >= 1600px)` | rail expanded | 2, or 3 with a standard side sheet (at most 400px) | basic dialog; menu | 24px |
|
||||
|
||||
- `<x-scaffold>` implements the navigation column; `<x-list-detail>` is the second pane of a list-detail layout from `expanded:`, `<x-supporting-pane>` puts a supporting pane (360px, beside the focus pane) from `expanded:` and below it before that. Moving up a class, ask what to reveal, divide into panes, resize, reposition or swap — never swap a component for one that does not do the same job.
|
||||
- `<x-scaffold>` implements the navigation column; `<x-pane>` is a content region with the margins above; `<x-list-detail>` is the second pane of a list-detail layout from expanded, `<x-supporting-pane>` puts a supporting pane (360px, beside the focus pane) from expanded and below it before that. Moving up a class, ask what to reveal, divide into panes, resize, reposition or swap — never swap a component for one that does not do the same job.
|
||||
- Scaffold: bars (app bar at the top, navigation bar at the bottom: 3–5 destinations), rails (the navigation rail, toolbars, the FAB, on the leading edge), panes (all content), around a safety region that stays clear of the device's own chrome (`--material-safe-top|bottom|left|right`).
|
||||
- Canonical layouts: feed (a grid of cards that gains columns per class), list-detail (one pane on compact, two from expanded; a back button only in single-pane mode, a selected row only in two-pane mode), supporting pane (two thirds focus, one third support).
|
||||
- Bidirectionality: write logical properties (`ps-4`, `me-2`, `start-0`, `text-start`, `border-s`); leading and trailing icons swap, directional icons (back, send) mirror, the rail moves to the right; charts, media controls, clocks and Hebrew progress bars stay left-to-right.
|
||||
- Canonical layouts: feed (`<x-feed>`, a grid of cards that gains columns as the room grows), list-detail (one pane on compact, two from expanded; a back button only in single-pane mode, a selected row only in two-pane mode), supporting pane (two thirds focus, one third support).
|
||||
- Bidirectionality: write logical properties (`padding-inline-start`, `margin-inline-end`, `inset-inline-start`, `border-inline-start`, `md-text-start`); `<x-row>` runs in the inline direction and mirrors by itself; leading and trailing icons swap, directional icons (back, send) mirror, the rail moves to the right; charts, media controls, clocks and Hebrew progress bars stay left-to-right.
|
||||
|
||||
Sources: https://m3.material.io/foundations/layout/breakpoints/overview · https://m3.material.io/foundations/layout/breakpoints/compact (medium, expanded, large-extra-large) · https://m3.material.io/foundations/layout/scaffold/overview · https://m3.material.io/foundations/layout/canonical-examples/overview · https://m3.material.io/foundations/layout/bidirectionality-rtl
|
||||
|
||||
## Spacing
|
||||
|
||||
M3's spacing tokens are multiples of an 8px base on a 4px grid; Tailwind's spacing scale is that grid, so `p-4` is `space200`.
|
||||
M3's spacing tokens are multiples of an 8px base on a 4px grid. A layout component takes the token's name (`gap="space200"`, `<x-surface padding="space300">`); the application's CSS reads it (`var(--md-sys-measurement-space200)`).
|
||||
|
||||
| Token | Value | Utility |
|
||||
| Token | Value | In this library |
|
||||
| --- | --- | --- |
|
||||
| space25 | 2px | `p-0.5`, `gap-0.5` |
|
||||
| space50 | 4px | `p-1` |
|
||||
| space75 | 6px | `p-1.5` |
|
||||
| space100 | 8px (the base) | `p-2`, `gap-2` |
|
||||
| space125 | 10px | `p-2.5` |
|
||||
| space200 | 16px | `p-4` — a component's padding, compact margins |
|
||||
| space300 | 24px | `p-6` — a dialog's padding, margins from `medium:` |
|
||||
| space400 | 32px | `p-8` |
|
||||
| space500 | 40px | `p-10` |
|
||||
| space600 | 48px | `p-12` — a target |
|
||||
| space700 | 56px | `p-14` |
|
||||
| space800 | 64px | `p-16` |
|
||||
| space900 | 72px | `p-18` |
|
||||
| space25 | 2px | `space25` |
|
||||
| space50 | 4px | `space50` |
|
||||
| space75 | 6px | `space75` |
|
||||
| space100 | 8px (the base) | `space100` |
|
||||
| space125 | 10px | `space125` |
|
||||
| space200 | 16px | `space200` — a component's padding, compact margins |
|
||||
| space300 | 24px | `space300` — a dialog's padding, margins from medium |
|
||||
| space400 | 32px | `space400` |
|
||||
| space500 | 40px | `space500` |
|
||||
| space600 | 48px | `space600` — a target |
|
||||
| space700 | 56px | `space700` |
|
||||
| space800 | 64px | `space800` |
|
||||
| space900 | 72px | `space900` |
|
||||
|
||||
- Padding and gaps live on the parent (`p-4 gap-2` on the container), never as margins on children; a margin is for space beyond a container's padding or between layout regions.
|
||||
- Padding and gaps live on the parent (`<x-surface padding="space200">` around `<x-stack gap="space100">`), never as margins on children; a margin is for space beyond a container's padding or between layout regions.
|
||||
- Spacing does not scale with text: at 200% text size the same padding and gaps stay.
|
||||
- Name a gap by what it separates when a component has several (icon–label 8px, label–supporting text 4px).
|
||||
|
||||
@@ -250,16 +254,16 @@ Sources: https://m3.material.io/styles/spacing/overview · https://m3.material.i
|
||||
|
||||
## Icons
|
||||
|
||||
`<x-icon name="…">` draws a Material Symbol Rounded (weight 400, grade 0), outlined or `filled`, at optical size 24 or `optical="20"`.
|
||||
`<x-icon name="lock">` draws a Material Symbol Rounded (weight 400, grade 0), outlined or `filled`, at optical size 24 or 20.
|
||||
|
||||
| Axis | Values | In this library |
|
||||
| --- | --- | --- |
|
||||
| Fill | 0 outlined, 1 filled | `filled` — active, selected or on state (a selected navigation item, a FAB's icon, a checked filter chip) |
|
||||
| Weight | 100–700; never below 200 at 24px | 400 for every icon; one weight per group |
|
||||
| Grade | −25 on dark backgrounds, 0 otherwise, positive for emphasis | 0 |
|
||||
| Optical size | 20 dense, 24 standard, 40–48 with display type | `optical="20"` when drawn at 20px or less (small buttons, chips, dense lists), 24 otherwise |
|
||||
| Optical size | 20 dense, 24 standard, 40–48 with display type | `size="20"` and below pick the 20 cut (small buttons, chips, dense lists); `optical="20"` for an icon sized by the application's own CSS |
|
||||
|
||||
- An icon beside text takes the text's size and colour (`size-5` beside `type-label-lg`, `size-6` beside body) and the same optical weight; its baseline sits about 11.5% of the text size below the text's.
|
||||
- An icon beside text takes the text's size and colour (`size="20"` beside `md-type-label-lg`, 24 beside body) and the same optical weight; its baseline sits about 11.5% of the text size below the text's.
|
||||
- Icons stay flat and forward-facing, on the pixel grid, inside their 20px live area of the 24px canvas.
|
||||
- An icon-only control has an accessible name (`aria-label`, or a tooltip that names it); a decorative icon is `aria-hidden`; a complex icon drawn below 20px needs a label beside it.
|
||||
|
||||
@@ -267,32 +271,22 @@ Sources: https://m3.material.io/styles/icons/overview · https://m3.material.io/
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Native elements before ARIA: `<button>`, `<a href>`, `<dialog>`, `<input>`, `<select>`; a styled `div` that fakes one needs everything re-implemented and tested.
|
||||
- Landmarks: one `main`, one `banner`, one `contentinfo` per page; `nav`, `search`, `complementary`, `form`, `region` labelled when they repeat, never with their own role in the label ("Primary", not "Primary navigation").
|
||||
- Headings: one H1 for the page, then H2–H6 in order without skipping; the level is the document's structure, `type-*` is the appearance, and they need not match.
|
||||
- Names: an interactive icon, image or ambiguous button ("Save", "Learn more") has a name that says what it does, without the word "button"; decorative images are `alt=""` or `aria-hidden`.
|
||||
- Focus: DOM order is reading order; a dialog moves focus to its first meaningful control and returns it to the opener; grouped controls are one Tab stop with arrows inside; a keyboard shortcut is two keys, or a single key only while its component is focused.
|
||||
- Announcements: an error is tied to its field (`aria-invalid`, `aria-describedby`) and announced; a snackbar is a polite live region that never steals focus and stays while it carries an action; a loading state has a name.
|
||||
- Contrast and states: 4.5:1 text, 3:1 large text and icons and grouped controls, disabled exempt; every state has two indicators; targets 48px with 8px between; text scales to 200%; motion honours reduced motion.
|
||||
The guideline's own Accessibility line has the rule; beyond it: every repeated landmark —
|
||||
`search`, `complementary`, `form`, `region`, not just `nav` — is labelled the same way; an
|
||||
ambiguous button ("Save", "Learn more") needs a name that says what it does, not just what kind
|
||||
of control it is; DOM order is reading order, a dialog returns focus to its opener, and a group of
|
||||
related controls is one Tab stop with the arrows moving inside it; an invalid field also carries
|
||||
`aria-invalid`, and a loading state has a name too.
|
||||
|
||||
Sources: https://m3.material.io/foundations/overview/principles · https://m3.material.io/foundations/designing/structure · https://m3.material.io/foundations/designing/flow · https://m3.material.io/foundations/designing/elements · https://m3.material.io/foundations/overview/assistive-technology
|
||||
|
||||
## Don'ts
|
||||
|
||||
What Google's pages say not to do, and this library follows:
|
||||
|
||||
| Don't | Because | Instead |
|
||||
| --- | --- | --- |
|
||||
| Put an icon in a snackbar | a snackbar is a short message with at most one action | `<x-toast>` types choose the announcement, not a picture |
|
||||
| Disable a FAB | "if the action is unavailable, the FAB shouldn't appear" | hide it |
|
||||
| Lay radio buttons in a row | a row reads as one control | a vertical group; chips or a connected button group for a horizontal choice |
|
||||
| Morph a card's corners on hover | shape morphs mark a press or a selection | the state layer and one level of elevation |
|
||||
| Use `outline` on a divider | dividers carry no contrast requirement and read too heavy | `border-divider` (`outline-variant`) |
|
||||
| Use a hex, `white`, `black` or an opacity for ink | it ignores theme, contrast level and profile | a role |
|
||||
| Use Tailwind's breakpoints or scales | they are not M3's values | `medium:` … and `type-*`, `rounded-corner-*`, `shadow-elevation-*`, `ease-*` |
|
||||
| Use segmented buttons, a navigation drawer or a bottom app bar | deprecated in M3 Expressive | `<x-button-group connected>`, the expanded rail, `<x-toolbar>` |
|
||||
| Truncate without a way to read the rest | an ellipsis alone is not accessible | wrap, grow the container, or a tooltip |
|
||||
| Animate with a literal duration | it ignores reduced motion | the paired tokens |
|
||||
The guideline's Don'ts, Type and Motion bullets name them; where they name no replacement — a vertical
|
||||
group or chips for radios in a row, `<x-divider>` for the outline case, the `md-type-*`/`md-ink-*`
|
||||
classes and `--md-sys-*` tokens for the utility-class case, wrap/grow/a tooltip instead of a bare
|
||||
ellipsis, the paired motion tokens instead of a literal duration — the components and layout
|
||||
sections above have it.
|
||||
|
||||
## Attribution
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/*
|
||||
* Livewire Material for an application that wants everything: the foundation, every layout
|
||||
* stylesheet and every component stylesheet, each imported once. The blocks below are the same
|
||||
* groups `components.css` and `layout.css` used before this file replaced both of them (plan step
|
||||
* 37) — one block per audit stream, so a stylesheet still lands in a predictable place — plus the
|
||||
* Layout block those two files didn't share.
|
||||
* groups `components.css` and `layout.css` used before this file replaced both of them — one
|
||||
* block per group, so a stylesheet still lands in a predictable place — plus the Layout block
|
||||
* those two files didn't share.
|
||||
*
|
||||
* This is not the recommended way to bring the package's CSS in: importing `all.css` pulls in
|
||||
* every component's rules whether an application's views render them or not. The recommended path
|
||||
@@ -38,6 +38,7 @@
|
||||
/* Foundation components */
|
||||
@import './components/icon.css';
|
||||
@import './components/shape.css';
|
||||
@import './components/color.css';
|
||||
|
||||
/* Actions and communication */
|
||||
@import './components/loading.css';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/*
|
||||
* <x-account-menu>: the avatar trigger of an `<x-menu>`, its name-and-email header and its theme
|
||||
* item — an M3 "extra" the audit found already inside the foundations (docs/audits/m3-alignment/
|
||||
* navigation.md): a 40px trigger reaching M3's 48px target through `md-touch-target` (N-01), with
|
||||
* item — not one of M3's own catalogued components, so its states are judged against the general
|
||||
* foundations instead: a 40px trigger reaching M3's 48px target through `md-touch-target`, with
|
||||
* `md-state-layer` for the hover and pressed feedback every other trigger in the library already
|
||||
* has (N-15) and `md-focus-ring` for its keyboard ring. The avatar image sits behind the state
|
||||
* has and `md-focus-ring` for its keyboard ring. The avatar image sits behind the state
|
||||
* layer's `::before` (z-index -1, foundation/interaction.css) at z-index -2, so a hover or a press
|
||||
* still washes over the picture instead of under it; nothing clips the pseudo-target the touch
|
||||
* class draws past the 40px circle.
|
||||
|
||||
@@ -9,12 +9,13 @@
|
||||
* neutral (surface-container-high, on-surface — it has no state colour of its own). The dismiss
|
||||
* button is drawn at 40px, pulled 8px out into the alert's padding, and renders the foundation's
|
||||
* `md-state-layer`, `md-focus-ring` and `md-touch-target` (foundation/interaction.css) to reach
|
||||
* M3's 48px target (ACT-18); nothing here copies their rules.
|
||||
* M3's 48px target; nothing here copies their rules.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@import './icon.css';
|
||||
@import './color.css';
|
||||
|
||||
@layer material.components {
|
||||
[data-md-alert] {
|
||||
@@ -23,38 +24,8 @@
|
||||
gap: 12px;
|
||||
border-radius: var(--md-sys-shape-corner-md);
|
||||
padding: var(--md-sys-measurement-space200);
|
||||
background-color: var(--md-sys-color-info-container);
|
||||
color: var(--md-sys-color-on-info-container);
|
||||
}
|
||||
|
||||
[data-md-alert][data-md-color='success'] {
|
||||
background-color: var(--md-sys-color-success-container);
|
||||
color: var(--md-sys-color-on-success-container);
|
||||
}
|
||||
|
||||
[data-md-alert][data-md-color='warning'] {
|
||||
background-color: var(--md-sys-color-warning-container);
|
||||
color: var(--md-sys-color-on-warning-container);
|
||||
}
|
||||
|
||||
[data-md-alert][data-md-color='error'] {
|
||||
background-color: var(--md-sys-color-error-container);
|
||||
color: var(--md-sys-color-on-error-container);
|
||||
}
|
||||
|
||||
[data-md-alert][data-md-color='primary'] {
|
||||
background-color: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
}
|
||||
|
||||
[data-md-alert][data-md-color='secondary'] {
|
||||
background-color: var(--md-sys-color-secondary-container);
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
[data-md-alert][data-md-color='tertiary'] {
|
||||
background-color: var(--md-sys-color-tertiary-container);
|
||||
color: var(--md-sys-color-on-tertiary-container);
|
||||
background-color: var(--md-container, var(--md-sys-color-info-container));
|
||||
color: var(--md-on-container, var(--md-sys-color-on-info-container));
|
||||
}
|
||||
|
||||
[data-md-alert][data-md-color='neutral'] {
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
* small 64px, title-large title (label-medium subtitle), 4px from the edges, the title 16px
|
||||
* from the start or straight after the navigation icon
|
||||
* center the same, the title centred — a three-column grid, so it shrinks against whatever is
|
||||
* beside it rather than ellipsising underneath it (N-11: a fixed inset fit exactly one
|
||||
* of M3's two trailing buttons)
|
||||
* beside it rather than ellipsising underneath it: a fixed inset fit exactly one
|
||||
* of M3's two trailing buttons
|
||||
* medium 112px (136px with a subtitle): the 64px row of icons over a headline-medium title
|
||||
* large 120px (152px with a subtitle): a display-small title
|
||||
* search the row holds a search bar, full width to 312px and then half of what is left (N-09)
|
||||
* search the row holds a search bar, full width to 312px and then half of what is left
|
||||
*
|
||||
* The container is the surface, and surface-container once content scrolls under it. A medium or
|
||||
* large bar collapses into the small one without script moving anything: the bar is sticky at a
|
||||
@@ -169,7 +169,7 @@
|
||||
|
||||
/* Centred: a three-column row — leading, headline, trailing — so the headline is centred in the
|
||||
window and shrinks against whatever is actually beside it. M3 allows two trailing icon
|
||||
buttons, which a fixed inset for one would have let a long title ellipsise underneath (N-11). */
|
||||
buttons, which a fixed inset for one would have let a long title ellipsise underneath. */
|
||||
[data-md-app-bar][data-md-variant='center'] [data-md-app-bar-row] {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
@@ -196,7 +196,7 @@
|
||||
}
|
||||
|
||||
/* The search bar fills the space between the leading and trailing elements until it is 312dp
|
||||
wide, and then grows into only half of what is left (N-09). The percentage is of the row
|
||||
wide, and then grows into only half of what is left. The percentage is of the row
|
||||
rather than of that space, which CSS cannot name without measuring it; below 312px the cap
|
||||
never binds, so a phone still gets the whole row. */
|
||||
[data-md-app-bar-search] {
|
||||
|
||||
@@ -20,12 +20,14 @@
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@import './color.css';
|
||||
|
||||
@layer material.components {
|
||||
[data-md-badge] {
|
||||
--md-badge-color: var(--md-sys-color-error);
|
||||
--md-badge-on-color: var(--md-sys-color-on-error);
|
||||
--md-badge-container: var(--md-sys-color-error-container);
|
||||
--md-badge-on-container: var(--md-sys-color-on-error-container);
|
||||
--md-badge-color: var(--md-color, var(--md-sys-color-error));
|
||||
--md-badge-on-color: var(--md-on-color, var(--md-sys-color-on-error));
|
||||
--md-badge-container: var(--md-container, var(--md-sys-color-error-container));
|
||||
--md-badge-on-container: var(--md-on-container, var(--md-sys-color-on-error-container));
|
||||
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
@@ -65,48 +67,6 @@
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
[data-md-badge][data-md-color='primary'] {
|
||||
--md-badge-color: var(--md-sys-color-primary);
|
||||
--md-badge-on-color: var(--md-sys-color-on-primary);
|
||||
--md-badge-container: var(--md-sys-color-primary-container);
|
||||
--md-badge-on-container: var(--md-sys-color-on-primary-container);
|
||||
}
|
||||
|
||||
[data-md-badge][data-md-color='secondary'] {
|
||||
--md-badge-color: var(--md-sys-color-secondary);
|
||||
--md-badge-on-color: var(--md-sys-color-on-secondary);
|
||||
--md-badge-container: var(--md-sys-color-secondary-container);
|
||||
--md-badge-on-container: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
[data-md-badge][data-md-color='tertiary'] {
|
||||
--md-badge-color: var(--md-sys-color-tertiary);
|
||||
--md-badge-on-color: var(--md-sys-color-on-tertiary);
|
||||
--md-badge-container: var(--md-sys-color-tertiary-container);
|
||||
--md-badge-on-container: var(--md-sys-color-on-tertiary-container);
|
||||
}
|
||||
|
||||
[data-md-badge][data-md-color='success'] {
|
||||
--md-badge-color: var(--md-sys-color-success);
|
||||
--md-badge-on-color: var(--md-sys-color-on-success);
|
||||
--md-badge-container: var(--md-sys-color-success-container);
|
||||
--md-badge-on-container: var(--md-sys-color-on-success-container);
|
||||
}
|
||||
|
||||
[data-md-badge][data-md-color='warning'] {
|
||||
--md-badge-color: var(--md-sys-color-warning);
|
||||
--md-badge-on-color: var(--md-sys-color-on-warning);
|
||||
--md-badge-container: var(--md-sys-color-warning-container);
|
||||
--md-badge-on-container: var(--md-sys-color-on-warning-container);
|
||||
}
|
||||
|
||||
[data-md-badge][data-md-color='info'] {
|
||||
--md-badge-color: var(--md-sys-color-info);
|
||||
--md-badge-on-color: var(--md-sys-color-on-info);
|
||||
--md-badge-container: var(--md-sys-color-info-container);
|
||||
--md-badge-on-container: var(--md-sys-color-on-info-container);
|
||||
}
|
||||
|
||||
[data-md-badge][data-md-color='neutral'] {
|
||||
--md-badge-color: var(--md-sys-color-on-surface-variant);
|
||||
--md-badge-on-color: var(--md-sys-color-surface);
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
* [data-md-bottom-sheet-probe] hidden, measures a stop's px height for a drag to settle on
|
||||
* [data-md-bottom-sheet-panel] surface-container-low, extra-large top corners, elevation 1;
|
||||
* data-md-preset while `heights`/`snap` gives it stops
|
||||
* [data-md-bottom-sheet-handle] the drag target and M3's 48dp hit area (C-01)
|
||||
* [data-md-bottom-sheet-handle] the drag target and M3's 48dp hit area
|
||||
* [data-md-bottom-sheet-grip] the 32×4px bar, the shared classes' touch target
|
||||
* [data-md-bottom-sheet-announce] a live region, only while stops exist
|
||||
* [data-md-bottom-sheet-body], [data-md-bottom-sheet-title], [data-md-bottom-sheet-actions]
|
||||
*
|
||||
* 640px max width, centred; 28px top corners only (`DockedContainerShape` = CornerExtraLargeTop);
|
||||
* 50dvh default open height under a ceiling of the screen less M3's 72dp top margin (C-19). It
|
||||
* 50dvh default open height under a ceiling of the screen less M3's 72dp top margin. It
|
||||
* rises on emphasized decelerate and only falls on emphasized accelerate — the asymmetric timing a
|
||||
* more specific selector for the open state supplies, since a CSS transition always takes its
|
||||
* duration and easing from the *after-change* style (the technique resources/css/components/
|
||||
@@ -31,21 +31,24 @@
|
||||
*
|
||||
* The scrim fades out as it fades in, and the state rules match the root's own scrim and panel,
|
||||
* so a sheet nested in an open one (a menu's sheet at compact inside a sheet) keeps its own state.
|
||||
* Both keep `x-show`, and the view's `x-transition` holds their `display` through the exit rather
|
||||
* than `allow-discrete`, which Firefox does not honour for `display` (see drawer.css); Alpine holds
|
||||
* for the first `transition-duration` listed, so the closing slide is listed first.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@layer material.components {
|
||||
/* The scrim fades out while the view's `x-transition` holds its `display` (see drawer.css). */
|
||||
[data-md-bottom-sheet-scrim] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
|
||||
opacity: 0;
|
||||
transition-property: opacity, display;
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--md-sys-motion-effects-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-effects-default);
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
[data-md-bottom-sheet][data-md-open] > [data-md-bottom-sheet-scrim] {
|
||||
@@ -85,10 +88,9 @@
|
||||
box-shadow: var(--md-sys-elevation-1);
|
||||
padding-bottom: var(--material-safe-bottom, env(safe-area-inset-bottom));
|
||||
translate: 0 100%;
|
||||
transition-property: translate, display;
|
||||
transition-property: translate;
|
||||
transition-duration: var(--md-sys-motion-effects-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
[data-md-bottom-sheet][data-md-open] > [data-md-bottom-sheet-panel] {
|
||||
@@ -107,14 +109,14 @@
|
||||
spring, independent of the translate that opens and closes the sheet. */
|
||||
[data-md-bottom-sheet-panel][data-md-preset] {
|
||||
height: var(--sheet-max-height);
|
||||
transition-property: translate, height, display;
|
||||
transition-duration: var(--md-sys-motion-effects-default-duration), var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-effects-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate), var(--md-sys-motion-spatial-default), var(--md-sys-motion-easing-emphasized-accelerate);
|
||||
transition-property: translate, height;
|
||||
transition-duration: var(--md-sys-motion-effects-default-duration), var(--md-sys-motion-spatial-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate), var(--md-sys-motion-spatial-default);
|
||||
}
|
||||
|
||||
[data-md-bottom-sheet][data-md-open] > [data-md-bottom-sheet-panel][data-md-preset] {
|
||||
transition-duration: var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate), var(--md-sys-motion-spatial-default), var(--md-sys-motion-easing-emphasized-decelerate);
|
||||
transition-duration: var(--md-sys-motion-spatial-default-duration), var(--md-sys-motion-spatial-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate), var(--md-sys-motion-spatial-default);
|
||||
}
|
||||
|
||||
[data-md-bottom-sheet-handle] {
|
||||
|
||||
@@ -143,10 +143,7 @@
|
||||
[data-md-button-group='connected'] > * {
|
||||
--md-button-group-corner: var(--md-button-group-inner);
|
||||
|
||||
border-start-start-radius: var(--md-button-group-corner);
|
||||
border-end-start-radius: var(--md-button-group-corner);
|
||||
border-start-end-radius: var(--md-button-group-corner);
|
||||
border-end-end-radius: var(--md-button-group-corner);
|
||||
border-radius: var(--md-button-group-corner);
|
||||
}
|
||||
|
||||
[data-md-button-group='connected'] > :active {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* lg 96px 48px 12px headline-sm 32 / 32px 2px xl lg
|
||||
* xl 136px 64px 16px headline-lg 40 / 40px 3px xl lg
|
||||
*
|
||||
* (ButtonXSmall…XLargeTokens; the xs padding is the 16px Phase D settled on, ACT-09.) An icon button
|
||||
* (ButtonXSmall…XLargeTokens; the xs padding is the 16px Phase D settled on.) An icon button
|
||||
* (`data-md-icon-button`) is as tall and `narrow`, `default` or `wide` across by
|
||||
* XSmall…XLargeIconButtonTokens: 28/32/40, 32/40/52, 48/56/72, 64/96/128 and 104/136/184px. The
|
||||
* views size the glyphs through `<x-icon size>`; `--md-button-icon` carries the same size for the
|
||||
@@ -30,7 +30,7 @@
|
||||
* plain declaration. Primary's tonal button is secondary-container and its standard icon button
|
||||
* on-surface-variant, as M3 draws them; the text button's label is primary, as Compose draws it,
|
||||
* not the token's on-surface-variant, which its own source marks as wrong; a selected text button
|
||||
* takes the tonal container (the library's deliberate deviations, docs/audits/m3-alignment).
|
||||
* takes the tonal container (a deliberate deviation from the token).
|
||||
*
|
||||
* Motion: corners, padding, margins and `bottom` on the fast spatial spring, colour and shadow on
|
||||
* the fast effects spring beside it — a colour must never overshoot (tokens/motion.css).
|
||||
@@ -44,7 +44,9 @@
|
||||
* primary-container at elevation 3 (ExtendedFabPrimaryTokens) — lifted clear of a bottom bar and
|
||||
* of a snackbar on screen (`--material-bottom-bar`, `--material-snackbar-height`), because M3 puts a
|
||||
* snackbar above a FAB and never over one. From `medium` it is the button it was written as.
|
||||
* `data-md-responsive` on the label hides it below `expanded` (840px).
|
||||
* `data-md-unavailable`, a `fab` given `disabled`, is not drawn below `medium`: M3 never shows a
|
||||
* disabled FAB, but removes one whose action is unavailable. From `medium` it is the disabled
|
||||
* button. `data-md-responsive` on the label hides it below `expanded` (840px).
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
@@ -52,13 +54,14 @@
|
||||
@import './icon.css';
|
||||
@import './loading.css';
|
||||
@import './tooltip.css';
|
||||
@import './color.css';
|
||||
|
||||
@layer material.components {
|
||||
[data-md-button] {
|
||||
--md-button-color: var(--md-sys-color-primary);
|
||||
--md-button-on-color: var(--md-sys-color-on-primary);
|
||||
--md-button-tone: var(--md-sys-color-secondary-container);
|
||||
--md-button-on-tone: var(--md-sys-color-on-secondary-container);
|
||||
--md-button-color: var(--md-color, var(--md-sys-color-primary));
|
||||
--md-button-on-color: var(--md-on-color, var(--md-sys-color-on-primary));
|
||||
--md-button-tone: var(--md-container, var(--md-sys-color-secondary-container));
|
||||
--md-button-on-tone: var(--md-on-container, var(--md-sys-color-on-secondary-container));
|
||||
--md-button-tone-selected: var(--md-sys-color-secondary);
|
||||
--md-button-on-tone-selected: var(--md-sys-color-on-secondary);
|
||||
--md-button-quiet: var(--md-sys-color-on-surface-variant);
|
||||
@@ -221,49 +224,14 @@
|
||||
border-radius: var(--md-button-pressed);
|
||||
}
|
||||
|
||||
/* Colour roles. */
|
||||
[data-md-button][data-md-color='secondary'] {
|
||||
--md-button-color: var(--md-sys-color-secondary);
|
||||
--md-button-on-color: var(--md-sys-color-on-secondary);
|
||||
/* Colour roles: every hue but primary comes straight off the shared table (color.css). Its
|
||||
`primary` entry is plain primary-container, so the one hue this file draws differently keeps
|
||||
a small override of its own. */
|
||||
[data-md-button][data-md-color='primary'] {
|
||||
--md-button-tone: var(--md-sys-color-secondary-container);
|
||||
--md-button-on-tone: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
[data-md-button][data-md-color='tertiary'] {
|
||||
--md-button-color: var(--md-sys-color-tertiary);
|
||||
--md-button-on-color: var(--md-sys-color-on-tertiary);
|
||||
--md-button-tone: var(--md-sys-color-tertiary-container);
|
||||
--md-button-on-tone: var(--md-sys-color-on-tertiary-container);
|
||||
}
|
||||
|
||||
[data-md-button][data-md-color='error'] {
|
||||
--md-button-color: var(--md-sys-color-error);
|
||||
--md-button-on-color: var(--md-sys-color-on-error);
|
||||
--md-button-tone: var(--md-sys-color-error-container);
|
||||
--md-button-on-tone: var(--md-sys-color-on-error-container);
|
||||
}
|
||||
|
||||
[data-md-button][data-md-color='success'] {
|
||||
--md-button-color: var(--md-sys-color-success);
|
||||
--md-button-on-color: var(--md-sys-color-on-success);
|
||||
--md-button-tone: var(--md-sys-color-success-container);
|
||||
--md-button-on-tone: var(--md-sys-color-on-success-container);
|
||||
}
|
||||
|
||||
[data-md-button][data-md-color='warning'] {
|
||||
--md-button-color: var(--md-sys-color-warning);
|
||||
--md-button-on-color: var(--md-sys-color-on-warning);
|
||||
--md-button-tone: var(--md-sys-color-warning-container);
|
||||
--md-button-on-tone: var(--md-sys-color-on-warning-container);
|
||||
}
|
||||
|
||||
[data-md-button][data-md-color='info'] {
|
||||
--md-button-color: var(--md-sys-color-info);
|
||||
--md-button-on-color: var(--md-sys-color-on-info);
|
||||
--md-button-tone: var(--md-sys-color-info-container);
|
||||
--md-button-on-tone: var(--md-sys-color-on-info-container);
|
||||
}
|
||||
|
||||
/* Every colour but primary selects into the colour itself and is quiet in it. */
|
||||
[data-md-button]:not([data-md-color='primary']) {
|
||||
--md-button-tone-selected: var(--md-button-color);
|
||||
@@ -397,5 +365,12 @@
|
||||
inline-size: 24px;
|
||||
block-size: 24px;
|
||||
}
|
||||
|
||||
/* M3 never disables a FAB: "if its action is unavailable, remove the FAB entirely"
|
||||
(docs/reference/m3/components-actions-communication-containment.md § FAB). Not drawn,
|
||||
it is out of the accessibility tree and the Tab order too. */
|
||||
[data-md-button][data-md-compact-fab][data-md-unavailable] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
* OutlinedCardTokens.kt, androidx Compose Material 3, Apache-2.0; docs/reference/m3
|
||||
* § Cards → Specs) — filled (surface-container-highest), elevated (surface-container-low at
|
||||
* elevation 1) and outlined (surface, an outline-variant edge); one 12dp corner for all three,
|
||||
* which never morphs (M3 specifies shape morph for buttons, FABs and list items, never cards —
|
||||
* docs/audits/m3-alignment/containment.md, C-23).
|
||||
* which never morphs (M3 specifies shape morph for buttons, FABs and list items, never cards).
|
||||
*
|
||||
* Every card renders the shared `md-state-layer` class, and a row (`data-md-list-row`,
|
||||
* `resources/js/list-rows.js`) `md-focus-ring` too (foundation/interaction.css), for its tint and
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/*
|
||||
* <x-carousel-item>: one slide of an <x-carousel>, masked and shifted by the keyline maths
|
||||
* carousel.css's header describes, unless the row is full-screen or multi-aspect, where nothing
|
||||
* masks it at all (docs/reference/m3/components-actions-communication-containment.md § Carousel;
|
||||
* docs/audits/m3-alignment/containment.md, C-11, C-12, C-25).
|
||||
* masks it at all (docs/reference/m3/components-actions-communication-containment.md § Carousel).
|
||||
*
|
||||
* [data-md-carousel-item] the tab stop (C-18), a focusable `group`, `md-focus-ring`
|
||||
* [data-md-carousel-item] the tab stop, a focusable `group`, `md-focus-ring`
|
||||
* [data-md-carousel-surface] the masked, shifted, extra-large-corner (28px) box
|
||||
* [data-md-carousel-content] the slot (an <img>, cropped)
|
||||
* [data-md-carousel-label] the `label` prop's words, over a scrim gradient
|
||||
@@ -26,7 +25,7 @@
|
||||
* The label's ink is a literal `white`, not a role: it sits over `--md-sys-color-scrim`, which is
|
||||
* black in every scheme, theme and contrast level, so white is the one ink that always has the
|
||||
* scrim's contrast — a role (`inverse-on-surface`) is dark ink in a dark scheme and fails there
|
||||
* (C-25, deliberately kept, as the component's original header explained).
|
||||
* (deliberately kept).
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* and sizes as inline custom properties every scroll frame; this file draws everything the script
|
||||
* does not — the row's layout per layout, and the controls — and carousel-item.css draws the
|
||||
* item and its mask (docs/reference/m3/components-actions-communication-containment.md
|
||||
* § Carousel; docs/audits/m3-alignment/containment.md).
|
||||
* § Carousel).
|
||||
*
|
||||
* [data-md-carousel] the row wrapper; its value is the layout
|
||||
* [data-md-carousel-probe] hidden, measures the preferred item width for the script
|
||||
@@ -17,12 +17,12 @@
|
||||
* [data-md-carousel-previous], [data-md-carousel-next]
|
||||
*
|
||||
* Specs table: 16dp leading/trailing padding, 8dp top/bottom and between items, for every layout
|
||||
* but uncontained (leading only) and full-screen (0, edge to edge — C-12); a `>` combinator
|
||||
* but uncontained (leading only) and full-screen (0, edge to edge); a `>` combinator
|
||||
* throughout, never a bare descendant one, because a carousel item can itself hold a nested
|
||||
* carousel (a `multi-aspect` gallery inside a `hero` slide) whose own root would otherwise match
|
||||
* its parent's rules too. Full-screen scrolls vertically, one edge-to-edge item at a time, capped
|
||||
* at the 840px medium window it is meant for (C-11); reduced motion needs no rule here at all —
|
||||
* the script writes zero inset, shift and full opacity itself for every layout (C-05).
|
||||
* at the 840px medium window it is meant for; reduced motion needs no rule here at all —
|
||||
* the script writes zero inset, shift and full opacity itself for every layout.
|
||||
*
|
||||
* The previous/next icons mirror in RTL by scaling the whole button, as the row's arrow keys and
|
||||
* scroll direction already do (`resources/js/carousel.js`'s own `state.rtl`), rather than through
|
||||
@@ -53,10 +53,6 @@
|
||||
[data-md-carousel] > [data-md-carousel-scroller] {
|
||||
display: flex;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-carousel='full-screen'] > [data-md-carousel-scroller] {
|
||||
|
||||
@@ -22,8 +22,10 @@
|
||||
@import './icon.css';
|
||||
|
||||
@layer material.components {
|
||||
/* In a flex row the root keeps its automatic minimum, the box and beside a label its longest
|
||||
word, so the text beside it wraps before the box is pushed out of the row (toggle.css). */
|
||||
[data-md-checkbox] {
|
||||
min-width: 0;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
[data-md-checkbox-box] {
|
||||
|
||||
@@ -76,55 +76,28 @@
|
||||
|
||||
/* ---- Assist, filter and suggestion: the chip is the control ------------------------------ */
|
||||
|
||||
/* The state layer and the 48px target are the foundation's `md-state-layer md-touch-target`
|
||||
classes now (chip.blade.php, as group and scheme-picker's own controls render them;
|
||||
foundation/interaction.css), at the same opacities and off the same `currentColor` this
|
||||
chip always painted them from. Three extras stay chip's own: a filter chip's label is never
|
||||
itself `:focus-visible` — the checkbox inside it is, caught with `:has()`, the same way the
|
||||
ring below already had to; `data-md-disabled` marks a chip with no native `disabled` to
|
||||
catch (a link, or a toggle button with no press of its own). */
|
||||
[data-md-chip]:not([data-md-chip='input']) {
|
||||
isolation: isolate;
|
||||
padding-inline: 15px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
|
||||
/* The 48px target, past the chip's 32px. */
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: 50%;
|
||||
height: var(--md-sys-measurement-space600);
|
||||
translate: 0 -50%;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background-color: currentColor;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover::before {
|
||||
opacity: var(--md-sys-state-hover-state-layer-opacity);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible::before,
|
||||
&:has(:focus-visible)::before {
|
||||
opacity: var(--md-sys-state-focus-state-layer-opacity);
|
||||
}
|
||||
|
||||
&:active::before {
|
||||
opacity: var(--md-sys-state-pressed-state-layer-opacity);
|
||||
}
|
||||
|
||||
&:is(:focus-visible, :has(:focus-visible)) {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:is(:disabled, [aria-disabled='true'], [data-md-disabled])::before {
|
||||
&[data-md-disabled]::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
* <x-collapse>: not an M3 component — M3 has expandable list items and menu expansion but no
|
||||
* standalone disclosure — built on the native `<details>` so it opens without script, keeps its
|
||||
* state through a Livewire morph, and is announced as a disclosure by the browser. Judged against
|
||||
* foundations rather than any M3 component spec (docs/audits/m3-alignment/containment.md, C-24).
|
||||
* foundations rather than any M3 component spec, since M3 has no standalone disclosure.
|
||||
*
|
||||
* `interpolate-size: allow-keywords` makes `<details>`'s `0 -> auto` block-size an animatable pair,
|
||||
* so `::details-content` (the pseudo-element that holds everything after the summary) can transition
|
||||
* `block-size` on the fast spatial spring, the same one that turns the chevron; `content-visibility`
|
||||
* goes with it, `allow-discrete`, so the content stays rendered while it closes rather than
|
||||
* vanishing on the first frame. Under reduced motion the duration token is zero
|
||||
* (tokens/motion.css), so the section snaps open with nothing else to do.
|
||||
* The height eases open and shut on the fast spatial spring, the same one that turns the chevron,
|
||||
* from resources/js/collapse.js: it animates the `<details>`' own `block-size`. This file cannot —
|
||||
* `interpolate-size` (for `0 -> auto`) is Chrome's alone, and Firefox does not hold the content's
|
||||
* `content-visibility` through a close — so Firefox and Safari used to snap open and shut. While a
|
||||
* close runs, `open` is still set and `data-md-collapse-closing` turns the chevron back at its
|
||||
* start. Under reduced motion the duration token is zero (tokens/motion.css), and the section
|
||||
* snaps, as the browser draws it.
|
||||
*
|
||||
* `data-md-variant="filled"` is a surface-container tile with a large corner; the summary inherits
|
||||
* it (`border-radius: inherit`) so the shared `md-state-layer`'s own `::before`, which also
|
||||
@@ -27,27 +28,11 @@
|
||||
@import './icon.css';
|
||||
|
||||
@layer material.components {
|
||||
[data-md-collapse] {
|
||||
interpolate-size: allow-keywords;
|
||||
}
|
||||
|
||||
[data-md-collapse][data-md-variant='filled'] {
|
||||
border-radius: var(--md-sys-shape-corner-lg);
|
||||
background-color: var(--md-sys-color-surface-container);
|
||||
}
|
||||
|
||||
[data-md-collapse]::details-content {
|
||||
block-size: 0;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
block-size var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast),
|
||||
content-visibility var(--md-sys-motion-spatial-fast-duration) allow-discrete;
|
||||
}
|
||||
|
||||
[data-md-collapse][open]::details-content {
|
||||
block-size: auto;
|
||||
}
|
||||
|
||||
[data-md-collapse-summary] {
|
||||
display: flex;
|
||||
min-block-size: var(--md-sys-measurement-space600);
|
||||
@@ -82,7 +67,7 @@
|
||||
transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast);
|
||||
}
|
||||
|
||||
[data-md-collapse][open] > [data-md-collapse-summary] > [data-md-collapse-chevron] {
|
||||
[data-md-collapse][open]:not([data-md-collapse-closing]) > [data-md-collapse-summary] > [data-md-collapse-chevron] {
|
||||
rotate: 180deg;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* The colour-role table M3's semantic colours share, so a component that offers the same set of
|
||||
* roles does not repeat it: `data-md-color` picks primary (most components' default, drawn
|
||||
* without the attribute), secondary, tertiary, error, success, warning or info, and this sets the
|
||||
* four generic variables a component's own stylesheet reads — `--md-color`/`--md-on-color` the
|
||||
* role itself, `--md-container`/`--md-on-container` its tonal container.
|
||||
*
|
||||
* A component gives its own prefixed variables (`--md-badge-color`, …) a fallback of its default
|
||||
* role, `var(--md-color, var(--md-sys-color-<default>))`, so the generic ones only take over once
|
||||
* `data-md-color` is actually written on that element. The four are registered as not inheriting,
|
||||
* so a component inside a coloured one (a badge on an error button) still falls back to its own
|
||||
* default rather than taking its ancestor's role. A component whose primary
|
||||
* deliberately reads secondary-container instead of primary-container (button.css, slider.css,
|
||||
* progress.css) keeps a small override of its own for an explicit `data-md-color="primary"` too:
|
||||
* this file's `primary` entry is the plain M3 mapping every other role, and every other component,
|
||||
* wants.
|
||||
*
|
||||
* A component that supports only part of this set (fab.css's primary/secondary/tertiary) does not
|
||||
* import this file: wiring it in would make every role it leaves out suddenly take effect through
|
||||
* the bare `[data-md-color]` selector below, a behaviour change rather than a rewrite.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@layer material.components {
|
||||
@property --md-color {
|
||||
syntax: '*';
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@property --md-on-color {
|
||||
syntax: '*';
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@property --md-container {
|
||||
syntax: '*';
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@property --md-on-container {
|
||||
syntax: '*';
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
[data-md-color='primary'] {
|
||||
--md-color: var(--md-sys-color-primary);
|
||||
--md-on-color: var(--md-sys-color-on-primary);
|
||||
--md-container: var(--md-sys-color-primary-container);
|
||||
--md-on-container: var(--md-sys-color-on-primary-container);
|
||||
}
|
||||
|
||||
[data-md-color='secondary'] {
|
||||
--md-color: var(--md-sys-color-secondary);
|
||||
--md-on-color: var(--md-sys-color-on-secondary);
|
||||
--md-container: var(--md-sys-color-secondary-container);
|
||||
--md-on-container: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
[data-md-color='tertiary'] {
|
||||
--md-color: var(--md-sys-color-tertiary);
|
||||
--md-on-color: var(--md-sys-color-on-tertiary);
|
||||
--md-container: var(--md-sys-color-tertiary-container);
|
||||
--md-on-container: var(--md-sys-color-on-tertiary-container);
|
||||
}
|
||||
|
||||
[data-md-color='error'] {
|
||||
--md-color: var(--md-sys-color-error);
|
||||
--md-on-color: var(--md-sys-color-on-error);
|
||||
--md-container: var(--md-sys-color-error-container);
|
||||
--md-on-container: var(--md-sys-color-on-error-container);
|
||||
}
|
||||
|
||||
[data-md-color='success'] {
|
||||
--md-color: var(--md-sys-color-success);
|
||||
--md-on-color: var(--md-sys-color-on-success);
|
||||
--md-container: var(--md-sys-color-success-container);
|
||||
--md-on-container: var(--md-sys-color-on-success-container);
|
||||
}
|
||||
|
||||
[data-md-color='warning'] {
|
||||
--md-color: var(--md-sys-color-warning);
|
||||
--md-on-color: var(--md-sys-color-on-warning);
|
||||
--md-container: var(--md-sys-color-warning-container);
|
||||
--md-on-container: var(--md-sys-color-on-warning-container);
|
||||
}
|
||||
|
||||
[data-md-color='info'] {
|
||||
--md-color: var(--md-sys-color-info);
|
||||
--md-on-color: var(--md-sys-color-on-info);
|
||||
--md-container: var(--md-sys-color-info-container);
|
||||
--md-on-container: var(--md-sys-color-on-info-container);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@
|
||||
@import './field.css';
|
||||
@import './icon.css';
|
||||
@import './button.css';
|
||||
@import './modal.css';
|
||||
|
||||
@layer material.components {
|
||||
/* ---- The support line under the field ------------------------------------------------------ */
|
||||
@@ -112,25 +113,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* The entry pop and the scrim are modal.css's shared dialog chrome, imported above. */
|
||||
[data-md-datepicker-picker]:modal {
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
transition-property: opacity, scale;
|
||||
transition-duration: var(--md-sys-motion-spatial-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-spatial-fast);
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
[data-md-datepicker-picker]:modal {
|
||||
opacity: 0;
|
||||
scale: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-datepicker-picker]:modal::backdrop {
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
|
||||
}
|
||||
|
||||
[data-md-datepicker-surface] {
|
||||
|
||||
@@ -13,14 +13,17 @@
|
||||
* [data-md-drawer-subtitle], [data-md-drawer-close]
|
||||
* [data-md-drawer-body] a size container, so its contents lay out by the sheet's own
|
||||
* width (`@md:`), never the viewport's
|
||||
* [data-md-drawer-actions] left-aligned, 72dp (C-10 — a dialog's are trailing-aligned)
|
||||
* [data-md-drawer-actions] left-aligned, 72dp — a dialog's are trailing-aligned
|
||||
*
|
||||
* 400px cap, 24dp start/end padding, a large corner on the inner edge only — from the specs
|
||||
* table. It enters on the emphasized-decelerate easing rather than a spring, because a sheet
|
||||
* anchored to an edge that overshot would open a gap, and leaves on emphasized accelerate: the
|
||||
* view keeps `x-show`, so `@starting-style` gives the entry its start and `allow-discrete` holds
|
||||
* `display` through the exit, whose timing comes from the closed state's own rule (a transition
|
||||
* takes the after-change style's). Reduced motion zeroes both duration tokens (tokens/motion.css).
|
||||
* view keeps `x-show`, so `@starting-style` gives the entry its start and the view's `x-transition`
|
||||
* holds `display` through the exit, whose timing comes from the closed state's own rule (a
|
||||
* transition takes the after-change style's). Not `allow-discrete` on `display`: Firefox does not
|
||||
* transition `display`, and in the other engines it would hold a second time after Alpine's hold.
|
||||
* Alpine holds for the first `transition-duration` listed, so each exit's longest transition is
|
||||
* listed first. Reduced motion zeroes both duration tokens (tokens/motion.css).
|
||||
* Below `expanded` it slides, from whichever edge `data-md-side` names in the document's direction;
|
||||
* once `standard` makes it co-planar it only fades (an in-flow box does not need to travel). Every
|
||||
* state rule matches the sheet and scrim as the root's children, so a sheet nested in an open one
|
||||
@@ -30,9 +33,15 @@
|
||||
* for `surface` with an outline-variant rule down its inner edge, in place of the scrim
|
||||
* (its anatomy's "Divider (optional)"). Below `expanded` (840px) it is the modal sheet — M3 caps
|
||||
* a side sheet at 400dp, and a 600px window has too little room left beside one, so the switch
|
||||
* sits at `expanded` rather than `medium`. `data-md-drawer-collapsed` (the view, from the
|
||||
* `wide` — window ≥ 840px — Alpine state) takes the standard sheet out of the layout entirely
|
||||
* while it is closed there, so the content beside it fills the space.
|
||||
* sits at `expanded` rather than `medium`. Opening it shrinks the body beside it and closing it
|
||||
* gives the room back (M3's side sheets, "Adaptive"): the root's `inline-size` springs from none to
|
||||
* the sheet's width — the sheet itself keeps its width, sits at the root's far edge and is clipped,
|
||||
* so it is uncovered from its inner edge — together with a negative margin as wide as the flex
|
||||
* parent's gap (`--md-drawer-gap`, which the view measures), so the content beside does not jump by
|
||||
* the gap when the root leaves the layout. `data-md-drawer-collapsed` (the view, while the window is
|
||||
* ≥ 840px and the sheet is closed) then takes the root out of the layout entirely, but only once the
|
||||
* exit has run: the view's `closing` holds it, where it once cut the fade and the resize off on
|
||||
* their first frame.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
@@ -41,17 +50,19 @@
|
||||
@import './divider.css';
|
||||
|
||||
@layer material.components {
|
||||
/* The scrim fades in and, while `display` is held by `allow-discrete`, back out. */
|
||||
/* The scrim fades in from `@starting-style` and back out while the view's `x-transition` holds
|
||||
its `display` for this duration. Not `allow-discrete` on `display`: Firefox does not transition
|
||||
`display`, so the scrim vanished there, and in the other engines it would hold a second time
|
||||
after Alpine's hold. */
|
||||
[data-md-drawer-scrim] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
|
||||
opacity: 0;
|
||||
transition-property: opacity, display;
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--md-sys-motion-effects-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-effects-default);
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
[data-md-drawer][data-md-open] > [data-md-drawer-scrim] {
|
||||
@@ -78,14 +89,14 @@
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: var(--md-sys-measurement-space300);
|
||||
padding-block-end: calc(var(--md-sys-measurement-space300) + var(--material-safe-bottom, env(safe-area-inset-bottom)));
|
||||
background-color: var(--md-sys-color-surface-container-low);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
box-shadow: var(--md-sys-elevation-1);
|
||||
translate: var(--sheet-offset) 0;
|
||||
transition-property: translate, display;
|
||||
transition-property: translate;
|
||||
transition-duration: var(--md-sys-motion-effects-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
[data-md-drawer-sheet][data-md-side='end'] {
|
||||
@@ -137,26 +148,94 @@
|
||||
|
||||
/* Co-planar from expanded: sticky beside the content, no scrim, no elevation, flat on
|
||||
`surface` with an outline-variant rule down the inner edge instead. It only fades here —
|
||||
an in-flow box does not travel like an overlay does. */
|
||||
an in-flow box does not travel like an overlay does. It sticks under the top safe area
|
||||
(`--drawer-top`), as tall as the window below it, and its padding keeps its foot clear of
|
||||
the bottom one, so a status bar never covers its head once the page scrolls. */
|
||||
@media (width >= 840px) {
|
||||
[data-md-drawer][data-md-standard] {
|
||||
--drawer-top: var(--material-safe-top, env(safe-area-inset-top));
|
||||
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100dvh;
|
||||
top: var(--drawer-top);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
height: calc(100dvh - var(--drawer-top));
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
overflow: clip;
|
||||
inline-size: 0;
|
||||
margin-inline-start: calc(-1 * var(--md-drawer-gap, 0px));
|
||||
transition-property: inline-size, margin-inline;
|
||||
transition-duration: var(--md-sys-motion-effects-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
|
||||
}
|
||||
|
||||
/* In <x-scaffold>, a sticky `<x-app-bar>` in the `top` slot spans the content region, sheet
|
||||
and all, so the sheet sticks under it: a small, centre-aligned or search bar is its 64px
|
||||
row (AppBarSmallTokens.ContainerHeight) under the safe area it pads itself with; a medium
|
||||
or large bar collapses to that 64px row alone, which the safe area may still be taller
|
||||
than. */
|
||||
[data-md-scaffold-content]:has(> [data-md-app-bar][data-md-sticky]) [data-md-drawer][data-md-standard] {
|
||||
--drawer-top: calc(var(--material-safe-top, env(safe-area-inset-top)) + 64px);
|
||||
}
|
||||
|
||||
[data-md-scaffold-content]:has(> [data-md-app-bar][data-md-sticky]:is([data-md-variant='medium'], [data-md-variant='large'])) [data-md-drawer][data-md-standard] {
|
||||
--drawer-top: max(var(--material-safe-top, env(safe-area-inset-top)), 64px);
|
||||
}
|
||||
|
||||
/* A start sheet sits at the start edge, with the gap after it. */
|
||||
[data-md-drawer][data-md-standard]:where(:has(> [data-md-drawer-sheet][data-md-side='start'])) {
|
||||
justify-content: flex-start;
|
||||
margin-inline-start: 0;
|
||||
margin-inline-end: calc(-1 * var(--md-drawer-gap, 0px));
|
||||
}
|
||||
|
||||
[data-md-drawer][data-md-standard][data-md-open] {
|
||||
inline-size: min(var(--sheet-width), calc(100vw - 64px));
|
||||
margin-inline: 0;
|
||||
transition-duration: var(--md-sys-motion-spatial-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-easing-emphasized-decelerate);
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
[data-md-drawer][data-md-standard][data-md-open] {
|
||||
inline-size: 0;
|
||||
margin-inline-start: calc(-1 * var(--md-drawer-gap, 0px));
|
||||
}
|
||||
|
||||
[data-md-drawer][data-md-standard][data-md-open]:where(:has(> [data-md-drawer-sheet][data-md-side='start'])) {
|
||||
margin-inline-start: 0;
|
||||
margin-inline-end: calc(-1 * var(--md-drawer-gap, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-drawer][data-md-standard][data-md-drawer-collapsed] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Before the view has settled, a sheet not rendered open is out of the layout already. The
|
||||
view writes `data-md-drawer-collapsed` and measures the row's gap only once Alpine runs,
|
||||
so until then a closed sheet would be a zero-wide flex item that still costs its row one
|
||||
gap, and the content beside it would widen when the script started. A sheet whose
|
||||
`wire:model` property is open is rendered `data-md-open` and stands at its width. */
|
||||
[data-md-drawer][data-md-standard]:not([data-md-open], [data-md-drawer-settled]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Until the view settles (`data-md-drawer-settled`, two frames after Alpine starts), the
|
||||
state the page loads with is drawn at once: a sheet that starts open does not grow in. */
|
||||
[data-md-drawer][data-md-standard]:not([data-md-drawer-settled]),
|
||||
[data-md-drawer][data-md-standard]:not([data-md-drawer-settled]) > [data-md-drawer-sheet] {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
[data-md-drawer][data-md-standard] > [data-md-drawer-scrim] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-md-drawer][data-md-standard] > [data-md-drawer-sheet] {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
top: 0;
|
||||
z-index: auto;
|
||||
height: 100%;
|
||||
@@ -165,7 +244,7 @@
|
||||
background-color: var(--md-sys-color-surface);
|
||||
box-shadow: none;
|
||||
opacity: 0;
|
||||
transition-property: opacity, display;
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--md-sys-motion-effects-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-easing-emphasized-accelerate);
|
||||
}
|
||||
|
||||
@@ -20,22 +20,22 @@
|
||||
* The page carries this file itself: `src/Support/ErrorPage.php` inlines it, with its imports, into
|
||||
* every error page (`Stylesheets::bundle()`) — beside the application's Vite tags when there is a
|
||||
* build (`layoutStyles()`), inside the whole fallback stylesheet when there is none
|
||||
* (`fallbackStyles()`, plan step 40) — so an application imports nothing for its error pages
|
||||
* (plan step 46). The body is still matched only when it holds the layout
|
||||
* (`body:has(> [data-md-error-page])`): an application that imports `all.css` has this file in
|
||||
* its bundle beside every other page, where a bare `body` rule would restyle them all.
|
||||
* (`fallbackStyles()`) — so an application imports nothing for its error pages. The body is still
|
||||
* matched only when it holds the layout (`body:has(> [data-md-error-page])`): an application that
|
||||
* imports `all.css` has this file in its bundle beside every other page, where a bare `body` rule
|
||||
* would restyle them all.
|
||||
*
|
||||
* The page's font is the foundation's, `--md-ref-typeface-brand` on `html` (foundation/base.css),
|
||||
* as the old `font-sans` was; only the fallback, which has no `@font-face`, uses a system stack.
|
||||
*
|
||||
* Colours from the roles (`docs/reference/m3/styles.md` § Typography, "Accessibility
|
||||
* requirements": surface/on-surface); the display code over its primary-container shape is the
|
||||
* pairing `docs/audits/m3-alignment/containment.md`'s "Aligned" section confirms; 24px page
|
||||
* gutters and the 600px growth of the art and headline are the same numbers the audit found
|
||||
* clean. `--md-sys-typescale-emphasized-display-lg` is the code's weight and roundedness; the
|
||||
* shape's 60s linear turn has no motion token (`--md-sys-motion-*` pairs a duration with a spring
|
||||
* for a state change, not an ambient loop) and stays a literal, gated the way the audit's
|
||||
* "Aligned" section already found it: behind `prefers-reduced-motion: no-preference`.
|
||||
* requirements": surface/on-surface); the display code over its primary-container shape is a
|
||||
* correct on-primary-container/primary-container contrast pairing; 24px page gutters and the
|
||||
* 600px growth of the art and headline follow the same spacing rules as every other M3 surface in
|
||||
* the library. `--md-sys-typescale-emphasized-display-lg` is the code's weight and roundedness;
|
||||
* the shape's 60s linear turn has no motion token (`--md-sys-motion-*` pairs a duration with a
|
||||
* spring for a state change, not an ambient loop) and stays a literal, gated behind
|
||||
* `prefers-reduced-motion: no-preference` like every other ambient animation in the library.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
* (foundation/interaction.css); its 56px already meets M3's target, so it needs no
|
||||
* `md-touch-target`.
|
||||
*
|
||||
* The entry is `@starting-style` on the item itself; the exit needs the state to be readable
|
||||
* while the popover is on its way out, which `[data-md-fab-menu-popover]:not(:popover-open) > *`
|
||||
* (fab-menu.css) drives from the popover's own open state — the popover keeps `display: flex` for
|
||||
* the length of its own discrete transition, so the item has somewhere to animate to (ACT-33).
|
||||
* The entry is `@starting-style` on the item itself; the exit is the popover's exit copy
|
||||
* (resources/js/popover-exit.js), whose items fab-menu.css sets to the closed values, so the item
|
||||
* has somewhere to animate to.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
@@ -42,13 +41,9 @@
|
||||
user-select: none;
|
||||
translate: 0 0;
|
||||
opacity: 1;
|
||||
transition-property: translate, opacity, display, overlay;
|
||||
transition-duration:
|
||||
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-effects-default-duration),
|
||||
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration);
|
||||
transition-timing-function:
|
||||
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-effects-default), linear, linear;
|
||||
transition-behavior: allow-discrete;
|
||||
transition-property: translate, opacity;
|
||||
transition-duration: var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-effects-default-duration);
|
||||
transition-timing-function: var(--md-sys-motion-spatial-fast), var(--md-sys-motion-effects-default);
|
||||
|
||||
@starting-style {
|
||||
translate: 0 var(--md-sys-measurement-space100);
|
||||
|
||||
@@ -4,21 +4,21 @@
|
||||
* FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): the trigger is a 56px FAB,
|
||||
* `data-md-color`'s container, large corner, elevation 3, rising to elevation 4 under a hovering
|
||||
* pointer; while its list is open (`aria-expanded`) it turns fully round and paints the colour
|
||||
* itself, on the default spatial and effects springs (ACT-19 splits the two). It renders the
|
||||
* itself, on the default spatial and effects springs (split between the two). It renders the
|
||||
* foundation's `md-state-layer` and `md-focus-ring` (foundation/interaction.css); its 56px already
|
||||
* meets M3's target, so it needs no `md-touch-target`. The list sits
|
||||
* `data-md-position`'s corner of the trigger, 8px padding — the room the close button keeps below
|
||||
* the first item — and scrolls past `calc(100dvh - 128px)` on a short window, behind the close
|
||||
* button, which stays fixed (ACT-32).
|
||||
* button, which stays fixed.
|
||||
*
|
||||
* The items (fab-menu-item.css) rise into place as the list opens and sink back as it closes: the
|
||||
* entry is `@starting-style` on the item itself; the exit needs the state to be readable while the
|
||||
* popover is on its way out, which `[data-md-fab-menu-popover]:not(:popover-open) > *` is — the
|
||||
* popover keeps `display: flex` for the length of its own discrete transition, so the items have
|
||||
* somewhere to animate to (ACT-33). The Tailwind-era rule sat outside the layers to beat the
|
||||
* item's utilities; inside the layer it still beats fab-menu-item.css's resting `translate: 0 0`
|
||||
* and `opacity: 1`, on specificity — an attribute and a pseudo-class against the item's one
|
||||
* attribute — whichever order the two files are bundled in.
|
||||
* entry is `@starting-style` on the item itself; the exit is the popover's exit copy
|
||||
* (`data-md-popover-exit`, resources/js/popover-exit.js), which stays on screen after the popover
|
||||
* has closed, so the items have somewhere to animate to in every engine, Firefox included.
|
||||
* `[data-md-fab-menu-popover]:not(:popover-open) > *` and the copy's `[data-md-popover-closing] > *`
|
||||
* are the closed values; inside the layer they beat fab-menu-item.css's resting `translate: 0 0`
|
||||
* and `opacity: 1` on specificity — two attributes, or an attribute and a pseudo-class, against the
|
||||
* item's one attribute — whichever order the two files are bundled in.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
@@ -112,9 +112,6 @@
|
||||
border-width: 0;
|
||||
background-color: transparent;
|
||||
padding: var(--md-sys-measurement-space100);
|
||||
transition-property: display, overlay;
|
||||
transition-duration: var(--md-sys-motion-spatial-fast-duration);
|
||||
transition-behavior: allow-discrete;
|
||||
|
||||
&:popover-open {
|
||||
display: flex;
|
||||
@@ -149,7 +146,8 @@
|
||||
position-area: bottom span-right;
|
||||
}
|
||||
|
||||
[data-md-fab-menu-popover]:not(:popover-open) > * {
|
||||
[data-md-fab-menu-popover]:not(:popover-open) > *,
|
||||
[data-md-fab-menu-popover][data-md-popover-closing] > * {
|
||||
translate: 0 var(--md-sys-measurement-space100);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -62,9 +62,9 @@
|
||||
* under half the `large` breakpoint's 1200px, a little over half the `expanded` one's, and holds
|
||||
* about the 70 characters body-large reads best at — a measure of text, so it is in rem and grows
|
||||
* with the text. It is a ceiling, not a width — a narrower pane still gets a narrower field. A
|
||||
* width rule from the call site beats it, because an application's CSS and a utility both
|
||||
* outrank this layer, and `full` takes it off for a field that really is the width of its pane (a
|
||||
* search-and-filter row, an editor). Below `medium` nothing is bounded. */
|
||||
* width rule from the call site beats it, because an application's CSS outranks this layer, and
|
||||
* `full` takes it off for a field that really is the width of its pane (a search-and-filter row,
|
||||
* an editor). Below `medium` nothing is bounded. */
|
||||
@media (width >= 600px) {
|
||||
[data-md-field]:not([data-md-full]) {
|
||||
max-width: 40rem;
|
||||
@@ -126,11 +126,6 @@
|
||||
font-family: var(--md-ref-typeface-mono);
|
||||
}
|
||||
|
||||
[data-md-field-control]::placeholder {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Autofill paints its own background, which on a card is a pale block in the
|
||||
wrong colour. Delaying the transition for a week keeps the field's own. */
|
||||
[data-md-field-control]:-webkit-autofill {
|
||||
@@ -318,14 +313,18 @@
|
||||
|
||||
/* Hover only where a pointer can hover, and never on a disabled field: M3 gives a disabled
|
||||
control no state layer at all, and the disabled edge below is inherited, so a declaration
|
||||
here would beat it whatever the selector weighs. */
|
||||
here would beat it whatever the selector weighs. M3 layers focus over hover, so a focused
|
||||
field keeps its focus edge under the pointer: the condition is weightless (`:where()`, where
|
||||
`:not(:has(…))` would weigh as much as its argument), and each state drawn over hover
|
||||
outweighs it — focus (0,3,0) over hover (0,2,0), the error's focus (0,4,0) over the error's
|
||||
hover (0,3,0), and select.css's open select (0,3,1) over both. */
|
||||
@media (hover: hover) {
|
||||
[data-md-field]:not(:has([data-md-field-control]:disabled)) [data-md-field-box]:hover {
|
||||
:where([data-md-field]:not(:has([data-md-field-control]:disabled))) [data-md-field-box]:hover {
|
||||
--field-edge: var(--md-sys-color-on-surface);
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-field-box]:focus-within {
|
||||
[data-md-field] [data-md-field-box]:focus-within {
|
||||
--field-edge: var(--md-sys-color-primary);
|
||||
--field-ink: var(--md-sys-color-primary);
|
||||
}
|
||||
@@ -340,7 +339,7 @@
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
[data-md-field][data-md-invalid]:not(:has([data-md-field-control]:disabled)) [data-md-field-box]:hover {
|
||||
:where([data-md-field]:not(:has([data-md-field-control]:disabled)))[data-md-invalid] [data-md-field-box]:hover {
|
||||
--field-edge: var(--md-sys-color-on-error-container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,18 +97,9 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The radio or checkbox stays in the page, for the form, the keyboard and a screen reader. */
|
||||
& > input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
/* The radio or checkbox stays in the page, for the form, the keyboard and a screen reader
|
||||
— `md-visually-hidden` on the element itself (text.css), which outranks this layer
|
||||
whatever wins the specificity, `material.text` sitting above `material.components`. */
|
||||
}
|
||||
|
||||
[data-md-group][data-md-inline] [data-md-group-segment] {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 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).
|
||||
* `ItemLeadingSpace`/`ItemTrailingSpace`/container padding).
|
||||
*
|
||||
* `data-md-list-row` (`resources/js/list-rows.js`, shared with `<x-card>` and an application's own
|
||||
* table rows) and `[data-md-list-open]:focus-visible` answer with the state layer — the row's ink at
|
||||
@@ -12,23 +12,25 @@
|
||||
* `[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
|
||||
* or focused row keeps its fill under the tint instead of trading it for a translucent one.
|
||||
* `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
|
||||
* container's own colour takes over by inheritance. The same attribute on any other row that is
|
||||
* not a card — an application's own `<li>`, `<div>` or `<tr>` — 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. `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).
|
||||
* item's link entirely rather than leaving a focusable, activatable control behind it.
|
||||
*
|
||||
* `data-md-list="segmented"` items (M3 Expressive) are `surface-container` tiles (one tone up from
|
||||
* `ItemSegmentedContainerColor`, so they read against the package's own `surface` page;
|
||||
* 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
|
||||
* `ItemSegmentedContainerColor`, so they read against the package's own `surface` page),
|
||||
* 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. Leading
|
||||
* icons there are drawn at 20px, not scaled down from 24 — list-item.blade.php passes `size` to
|
||||
* `<x-icon>` from the parent's `@aware(['segmented'])`, so no CSS override is needed here (C-26).
|
||||
* `<x-icon>` from the parent's `@aware(['segmented'])`, so no CSS override is needed here.
|
||||
*
|
||||
* `data-md-dividers` on the list insets its rule 16px from both ends (`DividerLeadingSpace`/
|
||||
* `DividerTrailingSpace`, C-16) rather than full-bleed.
|
||||
* `DividerTrailingSpace`) rather than full-bleed.
|
||||
*
|
||||
* Leading media: `data-md-list-item-avatar` is 40px, full corner, initials in primary-container
|
||||
* when it is a `<span>`, an image otherwise; `data-md-list-item-image` is a 56px small-corner
|
||||
@@ -93,6 +95,15 @@
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
/* A row written by hand — list-rows.js's contract, not an `<x-list-item>` — 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);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*
|
||||
* `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.
|
||||
* § Lists → Accessibility) — a role takes no CSS of its own.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
*
|
||||
* The drawing is resources/svg/loading-indicator/, ported from androidx Compose Material 3's
|
||||
* LoadingIndicator in bin/loading-indicator.mjs (Apache-2.0): LoadingIndicatorTokens' 38px
|
||||
* indicator in a 48px container, which is the size here unless the caller's CSS sizes it. It is
|
||||
* indicator in a 48px container, which is the size here unless `size` (`--md-loading-size`, 24 to
|
||||
* 240px, M3's responsive range) or the caller's CSS sizes it; the SVG scales with its box, so the
|
||||
* container and the shape keep their ratio at every size. It is
|
||||
* drawn in the text colour, `primary` (ActiveIndicatorColor) unless the caller colours it;
|
||||
* `contained` puts it on a `primary-container` circle in `on-primary-container`
|
||||
* (ContainedContainerColor, ContainedIndicatorColor), for a spinner over content.
|
||||
@@ -21,8 +23,8 @@
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: 48px;
|
||||
block-size: 48px;
|
||||
inline-size: var(--md-loading-size, 48px);
|
||||
block-size: var(--md-loading-size, 48px);
|
||||
color: var(--md-sys-color-primary);
|
||||
|
||||
& > svg {
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
* the trigger and popover of a nested menu beside it.
|
||||
*
|
||||
* SegmentedMenuTokens (androidx Compose Material 3, Apache-2.0): 48px row (M3's published "List
|
||||
* item height" wins over the token's own 44dp `Item`, docs/audits/m3-alignment/actions.md §
|
||||
* ACT-28), 16px either side (`ItemLeadingSpace`/`ItemTrailingSpace`, § ACT-11), 12px between the
|
||||
* icon, the label and the trailing content (`ItemBetweenSpace`, off the 8dp grid), 4px corners
|
||||
* (`ItemShape`) that open to 12px at either end of the list (`ItemFirstChildShape`/
|
||||
* `ItemLastChildShape` — the library's own list ends, 12px, win over `GroupShape`'s 8dp so a
|
||||
* cluster's ends and a list's ends match, menu-group.css). A selected row
|
||||
* (`role="menuitemcheckbox"`, the tick beside the colour and shape § ACT-27) takes
|
||||
* `ItemSelectedShape`'s 12px corner in tertiary-container; `current` — a menu of places rather
|
||||
* than choices — takes the same 12px corner in secondary-container, the navigation-indicator
|
||||
* role. `description` (`ItemSupportingTextFont`) grows the row by 8px top and bottom; `shortcut`
|
||||
* (`ItemTrailingSupportingTextFont`) sits at the end. Every quiet part of the row — the icon, the
|
||||
* description, the trailing text — inks together as `--md-menu-item-ink`; corner and colour are
|
||||
* two springs, not one (§ ACT-19), and `icon-class` paints only the leading icon, over that ink
|
||||
* but never over disabled. The row renders the foundation's `md-state-layer` and `md-focus-ring`
|
||||
* item height" wins over the token's own 44dp `Item`), 16px either side
|
||||
* (`ItemLeadingSpace`/`ItemTrailingSpace`), 12px between the icon, the label and the trailing
|
||||
* content (`ItemBetweenSpace`, off the 8dp grid), 4px corners (`ItemShape`) that open to 12px at
|
||||
* either end of the list (`ItemFirstChildShape`/`ItemLastChildShape` — the library's own list
|
||||
* ends, 12px, win over `GroupShape`'s 8dp so a cluster's ends and a list's ends match,
|
||||
* menu-group.css). A selected row (`role="menuitemcheckbox"`, the tick beside the colour and
|
||||
* shape) takes `ItemSelectedShape`'s 12px corner in tertiary-container; `current` — a menu of
|
||||
* places rather than choices — takes the same 12px corner in secondary-container, the
|
||||
* navigation-indicator role. `description` (`ItemSupportingTextFont`) grows the row by 8px top
|
||||
* and bottom; `shortcut` (`ItemTrailingSupportingTextFont`) sits at the end. Every quiet part of
|
||||
* the row — the icon, the description, the trailing text — inks together as
|
||||
* `--md-menu-item-ink`; corner and colour are two springs, not one — a colour must never
|
||||
* overshoot — and `icon-class` paints only the leading icon, over that ink but never over
|
||||
* disabled. The row renders the foundation's `md-state-layer` and `md-focus-ring`
|
||||
* (foundation/interaction.css) — no `md-touch-target`, since the row is already 48px tall — and
|
||||
* keeps only one refinement: the ring reads inward (-3px), because a row sits edge to edge in the
|
||||
* list and an outward one would run past it. A disabled item stays focusable and keeps its ring,
|
||||
@@ -36,6 +36,7 @@
|
||||
|
||||
@import './icon.css';
|
||||
@import './badge.css';
|
||||
@import './menu.css';
|
||||
|
||||
@layer material.components {
|
||||
[data-md-menu-item] {
|
||||
@@ -145,44 +146,13 @@
|
||||
}
|
||||
|
||||
/* The submenu popover: a menu of its own, sharing the container colour of the list it opens
|
||||
from. The fallbacks are the standard menu's, for a submenu inside some other list. */
|
||||
from. Its size, radius, padding, shadow and pop-and-fade entry/exit are menu.css's shared
|
||||
popover chrome (imported above); only its axis and its position are its own — inline,
|
||||
beside the item, rather than the standard menu's block axis under its trigger. */
|
||||
[data-md-submenu] {
|
||||
inset: auto;
|
||||
margin: 0;
|
||||
margin-inline: var(--md-sys-measurement-space50);
|
||||
min-inline-size: 112px;
|
||||
max-inline-size: 280px;
|
||||
max-block-size: min(288px, calc(100dvh - 32px));
|
||||
overflow-y: auto;
|
||||
border-width: 0;
|
||||
border-radius: var(--md-sys-shape-corner-lg);
|
||||
padding: var(--md-sys-measurement-space50);
|
||||
background-color: var(--material-menu-surface, var(--md-sys-color-surface-container-low));
|
||||
color: var(--material-menu-ink, var(--md-sys-color-on-surface));
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
margin: 0 var(--md-sys-measurement-space50);
|
||||
transform-origin: top;
|
||||
position-area: inline-end span-block-end;
|
||||
position-try-fallbacks: flip-inline;
|
||||
opacity: 0;
|
||||
scale: 0.95;
|
||||
transition-property: opacity, scale, display, overlay;
|
||||
transition-duration:
|
||||
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration),
|
||||
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration);
|
||||
transition-timing-function:
|
||||
var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast), linear, linear;
|
||||
transition-behavior: allow-discrete;
|
||||
|
||||
&:popover-open {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
&:popover-open {
|
||||
opacity: 0;
|
||||
scale: 0.95;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,13 +20,13 @@
|
||||
*
|
||||
* `[data-md-menu]` is `<x-menu>`'s root, holding the trigger and the popover; the popover itself
|
||||
* is `[data-md-menu-popover]`, capped at 288px so a long menu scrolls instead of running off the
|
||||
* top layer's edge (ACT-04), and transitions in by growing from its trigger's corner while it
|
||||
* fades, on the spatial and effects springs respectively (ACT-26). `data-md-vibrant` swaps its
|
||||
* top layer's edge, and transitions in by growing from its trigger's corner while it
|
||||
* fades, on the spatial and effects springs respectively. `data-md-vibrant` swaps its
|
||||
* container for tertiary-container; a nested submenu inherits the colour through
|
||||
* `--material-menu-surface`/`--material-menu-ink`, which the sheet's own copy of the list falls
|
||||
* back to instead (it is not a descendant of the popover).
|
||||
*
|
||||
* `sheet-at-compact`'s bottom sheet is `<x-bottom-sheet>` (still Tailwind): `[data-md-menu-sheet]`
|
||||
* `sheet-at-compact`'s bottom sheet is `<x-bottom-sheet>` (bottom-sheet.css): `[data-md-menu-sheet]`
|
||||
* cancels its 24px padding and restyles a submenu to open in place under its item instead of
|
||||
* beside it (M3 calls submenus "best suited to large screens").
|
||||
*
|
||||
@@ -50,13 +50,14 @@
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
[data-md-menu-popover] {
|
||||
--material-menu-surface: var(--md-sys-color-surface-container-low);
|
||||
--material-menu-ink: var(--md-sys-color-on-surface);
|
||||
|
||||
/* The popover chrome a submenu shares (menu-item.css, which imports this file for it): size
|
||||
limits, the surface's own colour (a submenu with no ancestor popover falls back to the
|
||||
standard menu's own), radius, padding, shadow, and the pop-and-fade entry/exit — M3's
|
||||
effects-fast fade behind the spatial-fast scale. Each writes its own whole `margin`, so no
|
||||
rule here depends on which file a bundler puts first. */
|
||||
[data-md-menu-popover],
|
||||
[data-md-submenu] {
|
||||
inset: auto;
|
||||
margin: 0;
|
||||
margin-block: var(--md-sys-measurement-space50);
|
||||
min-inline-size: 112px;
|
||||
max-inline-size: 280px;
|
||||
max-block-size: min(288px, calc(100dvh - 32px));
|
||||
@@ -64,21 +65,16 @@
|
||||
border-width: 0;
|
||||
border-radius: var(--md-sys-shape-corner-lg);
|
||||
padding: var(--md-sys-measurement-space50);
|
||||
background-color: var(--material-menu-surface);
|
||||
color: var(--material-menu-ink);
|
||||
background-color: var(--material-menu-surface, var(--md-sys-color-surface-container-low));
|
||||
color: var(--material-menu-ink, var(--md-sys-color-on-surface));
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
|
||||
opacity: 0;
|
||||
scale: 0.95;
|
||||
transition-property: opacity, scale, display, overlay;
|
||||
transition-duration:
|
||||
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-spatial-fast-duration),
|
||||
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration);
|
||||
transition-timing-function:
|
||||
var(--md-sys-motion-effects-fast), var(--md-sys-motion-spatial-fast), linear, linear;
|
||||
transition-behavior: allow-discrete;
|
||||
transition-property: opacity, scale;
|
||||
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);
|
||||
|
||||
&:popover-open {
|
||||
&:popover-open:not([data-md-popover-closing]) {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
@@ -91,6 +87,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-menu-popover] {
|
||||
--material-menu-surface: var(--md-sys-color-surface-container-low);
|
||||
--material-menu-ink: var(--md-sys-color-on-surface);
|
||||
|
||||
margin: var(--md-sys-measurement-space50) 0;
|
||||
position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;
|
||||
}
|
||||
|
||||
[data-md-menu-popover]:has(> [data-md-menu-filter]) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
*
|
||||
* 560/280px width (max/min), 48px scrim margin, 28px corner and 24dp padding all match the specs
|
||||
* page; a compact window's full-screen dialog swaps the box for the whole screen with a 56px bar
|
||||
* (C-17: this used to be 64px) instead of the headline block. It opens on the fast spatial spring
|
||||
* (this used to be 64px) instead of the headline block. It opens on the fast spatial spring
|
||||
* and closes at once — a native `<dialog>` makes an exit transition awkward, since
|
||||
* `@starting-style` only ever supplies an entry.
|
||||
*
|
||||
* The dividers (§ Dialogs → Anatomy, C-06): a 1px outline-variant rule under the pinned head and
|
||||
* The dividers (§ Dialogs → Anatomy): a 1px outline-variant rule under the pinned head and
|
||||
* over the pinned actions, each only while the body has more content hidden on its side.
|
||||
* resources/js/dialog.js marks the `<dialog>` with data-md-overflow-top/-bottom as the body
|
||||
* scrolls; `data-md-modal-divider` says an element takes part at all — the head and the actions
|
||||
@@ -30,9 +30,11 @@
|
||||
* `> [data-md-modal-box] >`, as the divider marks do: a basic dialog opened from inside a
|
||||
* full-screen dialog's body is a descendant of it, and must keep its own title and padding.
|
||||
*
|
||||
* `[data-md-modal-body]:focus-visible` is Chrome's own focusable scroll container catching the
|
||||
* dialog's first focus when nothing inside can take it — M3's 3px secondary indicator, drawn
|
||||
* inside the edge because the box's rounded, overflow-hidden corner would clip one drawn outside.
|
||||
* `[data-md-modal-body]:focus-visible` is the scrolling body catching the dialog's first focus when
|
||||
* nothing inside can take it — natively in Chrome, whose scroll containers are focusable, and
|
||||
* through `materialShowModal()` (resources/js/dialog.js) in Firefox and WebKit — M3's 3px secondary
|
||||
* indicator, drawn inside the edge because the box's rounded, overflow-hidden corner would clip one
|
||||
* drawn outside.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
@@ -51,6 +53,19 @@
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
/* Dialog chrome shared with the time picker's dialog and a modal date picker (timepicker.css,
|
||||
* datepicker.css, both import this file for it): M3's spatial-fast pop and a 32%-scrim
|
||||
* backdrop, entering only — a native `<dialog>` makes an exit transition awkward, since
|
||||
* `@starting-style` only ever supplies an entry. A plain selector list, not `:is()`: `:is()`
|
||||
* would flatten every branch to the specificity of `[data-md-datepicker-picker]:modal`, the
|
||||
* one with a pseudo-class, and outrank whatever else the modal or the time picker's dialog is
|
||||
* layered against.
|
||||
*/
|
||||
[data-md-modal],
|
||||
[data-md-timepicker-dialog],
|
||||
[data-md-datepicker-picker]:modal {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
transition-property: opacity, scale;
|
||||
@@ -59,13 +74,17 @@
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
[data-md-modal] {
|
||||
[data-md-modal],
|
||||
[data-md-timepicker-dialog],
|
||||
[data-md-datepicker-picker]:modal {
|
||||
opacity: 0;
|
||||
scale: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-modal]::backdrop {
|
||||
[data-md-modal]::backdrop,
|
||||
[data-md-timepicker-dialog]::backdrop,
|
||||
[data-md-datepicker-picker]:modal::backdrop {
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
|
||||
}
|
||||
|
||||
@@ -98,7 +117,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* The phone-only header bar: M3's full-screen dialog header, 56dp (C-17). */
|
||||
/* The phone-only header bar: M3's full-screen dialog header, 56dp. */
|
||||
[data-md-modal-bar] {
|
||||
display: flex;
|
||||
height: var(--md-sys-measurement-space700);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* wide. From 600px icon and label share a 40px horizontal indicator with 16px leading and
|
||||
* trailing space; the label stays label-medium — `NavigationBarTokens.LabelTextFont` is the bar's
|
||||
* only label token, and Compose's `ShortNavigationBarItem` passes it for both icon positions (the
|
||||
* *rail's* horizontal item is label-large, a different component's token; N-08). An active item is
|
||||
* *rail's* horizontal item is label-large, a different component's token). An active item is
|
||||
* secondary-container behind on-secondary-container (icon and, from 600px, label); its indicator's
|
||||
* fill is a background image so it can grow from its centre (navigation-item.css) rather than
|
||||
* stretch the icon.
|
||||
|
||||
@@ -78,14 +78,11 @@
|
||||
* A bar that has slid off the bottom of the window is no longer there to clear, so the offset
|
||||
* everything pinned to the bottom reads drops to the bottom safe area and whatever the
|
||||
* application has docked on the bar — a `fab` button, the snackbar and the page's own bottom
|
||||
* padding all follow it down and come back up with it. Layered now, unlike before this
|
||||
* rewrite: <x-scaffold> used to publish --material-bottom-bar with a Tailwind utility, which
|
||||
* no rule in any layer could outrank regardless of specificity; now scaffold.css publishes it
|
||||
* itself in `material.layout` (layout/scaffold.css), a layer this file's own
|
||||
* `material.components` always outranks by declaration order alone — the same reason
|
||||
* toolbar.css's and fab.css's overrides only have to beat another `material.components` rule,
|
||||
* not chase a Tailwind utility out of the cascade. Keyed on `data-md-scaffold`, the hook the
|
||||
* scaffold renders on its root, not `data-app-shell`, which that rewrite also retired.
|
||||
* padding all follow it down and come back up with it. `<x-scaffold>` publishes
|
||||
* `--material-bottom-bar` itself, in `material.layout` (layout/scaffold.css), a layer this
|
||||
* file's own `material.components` always outranks by declaration order alone — the same
|
||||
* reason toolbar.css's and fab.css's overrides only have to beat another `material.components`
|
||||
* rule. Keyed on `data-md-scaffold`, the hook the scaffold renders on its root.
|
||||
*/
|
||||
[data-md-scaffold]:has([data-md-navigation-bar][data-md-hide-on-scroll][data-md-hidden]) {
|
||||
--material-bottom-bar: calc(var(--material-safe-bottom, env(safe-area-inset-bottom)) + var(--material-bottom-extra, 0px));
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* `NavigationRailColorTokens.kt` gives one colour for all six states, active or not:
|
||||
* `ItemActiveHoveredStateLayer = … = ItemInactivePressedStateLayer = OnSecondaryContainer`.
|
||||
* `NavigationBarTokens.kt` states no state-layer tokens of its own, so the bar's item takes the
|
||||
* rail's (N-19). Hover only where a pointer can hover, so a touch screen does not keep the last
|
||||
* rail's. Hover only where a pointer can hover, so a touch screen does not keep the last
|
||||
* hover after a tap; state.css's own opacities (8% hover, 10% focus and press) rather than the
|
||||
* package's `md-state-layer` class, because the element focused and pressed is the whole item
|
||||
* while the layer is drawn only on the smaller indicator or pill inside it — `md-state-layer`
|
||||
|
||||
@@ -108,14 +108,7 @@
|
||||
/* ---- Collapsed: icon over label, the 56×32 indicator, badge back on the icon. ---- */
|
||||
|
||||
[data-md-navigation-rail-item] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
@@ -127,136 +120,13 @@
|
||||
font: var(--md-sys-typescale-label-md);
|
||||
letter-spacing: var(--md-sys-typescale-label-md-tracking);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: auto;
|
||||
min-height: 64px;
|
||||
margin-inline: 0;
|
||||
padding: 6px 4px;
|
||||
border-radius: 0;
|
||||
font: var(--md-sys-typescale-label-md);
|
||||
letter-spacing: var(--md-sys-typescale-label-md-tracking);
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: auto;
|
||||
min-height: 64px;
|
||||
margin-inline: 0;
|
||||
padding: 6px 4px;
|
||||
border-radius: 0;
|
||||
font: var(--md-sys-typescale-label-md);
|
||||
letter-spacing: var(--md-sys-typescale-label-md-tracking);
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: auto;
|
||||
min-height: 64px;
|
||||
margin-inline: 0;
|
||||
padding: 6px 4px;
|
||||
border-radius: 0;
|
||||
font: var(--md-sys-typescale-label-md);
|
||||
letter-spacing: var(--md-sys-typescale-label-md-tracking);
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: auto;
|
||||
min-height: 64px;
|
||||
margin-inline: 0;
|
||||
padding: 6px 4px;
|
||||
border-radius: 0;
|
||||
font: var(--md-sys-typescale-label-md);
|
||||
letter-spacing: var(--md-sys-typescale-label-md-tracking);
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-item] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
--navigation-item-layer: 0;
|
||||
--navigation-indicator-layer: var(--navigation-layer);
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
--navigation-item-layer: 0;
|
||||
--navigation-indicator-layer: var(--navigation-layer);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
--navigation-item-layer: 0;
|
||||
--navigation-indicator-layer: var(--navigation-layer);
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
--navigation-item-layer: 0;
|
||||
--navigation-indicator-layer: var(--navigation-layer);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
--navigation-item-layer: 0;
|
||||
--navigation-indicator-layer: var(--navigation-layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-item] [data-md-navigation-label] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
flex: none;
|
||||
display: -webkit-box;
|
||||
max-width: 100%;
|
||||
@@ -265,381 +135,52 @@
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
flex: none;
|
||||
display: -webkit-box;
|
||||
max-width: 100%;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
flex: none;
|
||||
display: -webkit-box;
|
||||
max-width: 100%;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
flex: none;
|
||||
display: -webkit-box;
|
||||
max-width: 100%;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
flex: none;
|
||||
display: -webkit-box;
|
||||
max-width: 100%;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-item][data-md-active] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
background-image: none;
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
background-image: none;
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
background-image: none;
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
background-image: none;
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
background-image: none;
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-item] [data-md-navigation-indicator] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
width: 56px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-item][data-md-active] [data-md-navigation-indicator] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
background-image: linear-gradient(var(--md-sys-color-secondary-container), var(--md-sys-color-secondary-container));
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-item]:focus-visible {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-item]:focus-visible [data-md-navigation-indicator] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
outline: 3px solid var(--md-sys-color-secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-badge-end],
|
||||
[data-md-navigation-badge-icon] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-badge-icon] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,46 +14,9 @@
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-section]:not(:first-child) {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-heading] {
|
||||
@@ -67,48 +30,9 @@
|
||||
letter-spacing: var(--md-sys-typescale-title-sm-tracking);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-heading] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* docs/reference/m3/components-navigation-selection-inputs.md § Navigation Rail).
|
||||
*
|
||||
* [data-md-navigation-rail="collapsed|expanded|collapsible|modal|adaptive"] data-md-open
|
||||
* [data-md-navigation-rail-scrim] modal and adaptive rails, open only
|
||||
* data-md-closing="sheet|scrim", while it exits
|
||||
* [data-md-navigation-rail-scrim] modal and adaptive rails, open or closing only
|
||||
* [data-md-navigation-rail-panel] the <nav>
|
||||
* [data-md-navigation-rail-header] never scrolls
|
||||
* [data-md-navigation-rail-menu-row] the menu button and the brand
|
||||
@@ -18,9 +19,8 @@
|
||||
* [data-md-navigation-rail-item] navigation-rail-item.css
|
||||
* [data-md-navigation-rail-footer] never scrolls
|
||||
*
|
||||
* **Collapsed**, reproduced from the Tailwind-era custom variant named `rail-collapsed`: a rail (and
|
||||
* anything in it) is drawn collapsed while any of these hold, each its own rule below wherever a
|
||||
* property differs by shape — a fixed `collapsed` mode; a `collapsible` rail the visitor collapsed
|
||||
* **Collapsed**: a rail (and anything in it) is drawn collapsed while any of these hold, which the
|
||||
* rail's own two rules below publish as `--md-navigation-rail-value` for everything inside it — a fixed `collapsed` mode; a `collapsible` rail the visitor collapsed
|
||||
* (`<html data-rail="collapsed">`, set before the first paint by `<x-theme-script>`) and not
|
||||
* currently open over the page; a `modal` rail not open; below `medium` (600px) a `collapsible`
|
||||
* rail regardless of choice, M3's floor ("compact → use a navigation bar, not a standard rail");
|
||||
@@ -34,20 +34,36 @@
|
||||
* `resources/js/navigation.js` reads the same numbers, so the menu button and the drawing agree at
|
||||
* every width.
|
||||
*
|
||||
* **Closing**: nothing here transitions `display`, which Firefox cannot do even with
|
||||
* `allow-discrete`, so a panel sliding off the window and a fading scrim were cut to nothing there.
|
||||
* `resources/js/navigation.js` sets `data-md-closing` on a rail that was open over the page until
|
||||
* its exit transitions have run — `sheet` when the panel leaves the window (a compact adaptive
|
||||
* rail, or one that hides when collapsed), `scrim` when the panel stands in the layout again — and
|
||||
* the rules near the end of the file keep what is leaving displayed meanwhile, in every engine.
|
||||
* The collapsed branches do not read it: a closing rail is already collapsed, which is what
|
||||
* `--md-navigation-rail-value` says the moment it starts to close.
|
||||
*
|
||||
* The first set of branches also publishes the answer: `--md-navigation-rail-value` is `expanded`
|
||||
* on every rail and `collapsed` wherever those branches hold — M3's two rail values, Compose's
|
||||
* WideNavigationRailValue — so what an application puts in a rail reads it with
|
||||
* `@container style(--md-navigation-rail-value: collapsed)` rather than copying the conditions. It
|
||||
* is an unregistered custom property, so it inherits into every descendant and is unset outside a
|
||||
* rail, where neither value matches.
|
||||
*
|
||||
* `data-md-width="narrow"` is M3's other collapsed width, `NarrowContainerWidth` 80px against the
|
||||
* default 96 (`CollapsedContainerWidth`) — a variable, so every rule below that has its own reason
|
||||
* to name a collapsed width does not have to know which one applies.
|
||||
*
|
||||
* `data-md-divider` and `data-md-fill="false"` are both Missing until this rewrite: an optional
|
||||
* vertical divider on the page's edge, M3's own answer to a page scrolling under a fixed rail, and
|
||||
* a transparent container as long as the items keep 3:1 contrast (N-22). `data-md-align="center"`
|
||||
* a transparent container as long as the items keep 3:1 contrast. `data-md-align="center"`
|
||||
* is M3's other alignment, preferred on a tablet for reach — only the destinations move; the
|
||||
* header, footer and menu button stay at their ends, and `safe` gives the top back once there are
|
||||
* more destinations than fit.
|
||||
*
|
||||
* The rail-header FAB rules at the end draw M3's nested-FAB elevation (level 0, not a standalone
|
||||
* FAB's 3 — N-03) and morph it into an extended FAB as the rail opens, rather than swapping two by
|
||||
* `display` (N-23). Both sit in this file's own `material.components` layer and win by specificity
|
||||
* FAB's 3) and morph it into an extended FAB as the rail opens, rather than swapping two by
|
||||
* `display`. Both sit in this file's own `material.components` layer and win by specificity
|
||||
* alone, whichever copy of fab.css a build places last: the flat shadow outranks fab.css's resting
|
||||
* and hover shadows, and the collapsed FAB's `gap` and minimum width outrank fab.css's extended-size
|
||||
* and collapse-on-scroll rules, which would otherwise tie with them.
|
||||
@@ -66,6 +82,7 @@
|
||||
[data-md-navigation-rail] {
|
||||
--navigation-rail-expanded-width: clamp(220px, var(--navigation-rail-width, 256px), 360px);
|
||||
--navigation-rail-collapsed-width: 96px;
|
||||
--md-navigation-rail-value: expanded;
|
||||
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
@@ -81,17 +98,20 @@
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
width: var(--navigation-rail-collapsed-width);
|
||||
--md-navigation-rail-value: collapsed;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
width: var(--navigation-rail-collapsed-width);
|
||||
--md-navigation-rail-value: collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
width: var(--navigation-rail-collapsed-width);
|
||||
--md-navigation-rail-value: collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +121,7 @@
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
width: var(--navigation-rail-collapsed-width);
|
||||
--md-navigation-rail-value: collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +131,7 @@
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
width: var(--navigation-rail-collapsed-width);
|
||||
--md-navigation-rail-value: collapsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,14 +187,7 @@
|
||||
alone; the label is taken out of the drawing, not out of the page (navigation-rail-item.css
|
||||
renders it always — it is what names the destination). */
|
||||
[data-md-navigation-rail][data-md-width='narrow'] [data-md-navigation-rail-item] [data-md-navigation-label] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
@@ -182,64 +197,6 @@
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* A modal rail keeps its collapsed width in the layout while it is open over it, as Compose's
|
||||
@@ -262,7 +219,7 @@
|
||||
|
||||
/* "Compact → don't use a standard rail, use a navigation bar." A `collapsible` rail takes
|
||||
the width the visitor chose, which on a 360px screen would be two-thirds of it, so it is
|
||||
floored at its collapsed width (N-24). `collapsed` and `expanded` mean what they say and
|
||||
floored at its collapsed width. `collapsed` and `expanded` mean what they say and
|
||||
are left alone: wrap one in an application's own `medium:` element if it must not show
|
||||
on a phone. */
|
||||
[data-md-navigation-rail='collapsible'] {
|
||||
@@ -293,17 +250,18 @@
|
||||
/* M3's two optional container treatments: "optional vertical divider separates rail from
|
||||
content, placed on the content-adjacent edge" — also its answer to a page scrolling under a
|
||||
fixed rail — and "container fill can be turned off (transparent) as long as items keep ≥3:1
|
||||
contrast" (N-22). Neither applies to a rail open over a scrim, a surface over the page. */
|
||||
[data-md-navigation-rail][data-md-divider]:not([data-md-open]) > [data-md-navigation-rail-panel] {
|
||||
contrast". Neither applies to a rail open over a scrim, a surface over the page. */
|
||||
[data-md-navigation-rail][data-md-divider]:not([data-md-open], [data-md-closing='sheet']) > [data-md-navigation-rail-panel] {
|
||||
border-inline-end: 1px solid var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
[data-md-navigation-rail][data-md-fill='false']:not([data-md-open]) > [data-md-navigation-rail-panel] {
|
||||
[data-md-navigation-rail][data-md-fill='false']:not([data-md-open], [data-md-closing='sheet']) > [data-md-navigation-rail-panel] {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Open: expanded over a scrim, in surface-container with a large corner at its inner edge. */
|
||||
[data-md-navigation-rail][data-md-open] > [data-md-navigation-rail-panel] {
|
||||
/* Open: expanded over a scrim, in surface-container with a large corner at its inner edge — and
|
||||
still, while a panel that is leaving the window slides off it (`data-md-closing="sheet"`). */
|
||||
[data-md-navigation-rail]:is([data-md-open], [data-md-closing='sheet']) > [data-md-navigation-rail-panel] {
|
||||
position: fixed;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
@@ -338,8 +296,7 @@
|
||||
box-shadow: var(--md-sys-elevation-2);
|
||||
translate: -100% 0;
|
||||
transition:
|
||||
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
|
||||
display var(--md-sys-motion-effects-default-duration) allow-discrete;
|
||||
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate);
|
||||
|
||||
&:is([dir='rtl'], [dir='rtl'] *) {
|
||||
translate: 100% 0;
|
||||
@@ -350,8 +307,7 @@
|
||||
display: flex;
|
||||
translate: 0 0;
|
||||
transition:
|
||||
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate),
|
||||
display var(--md-sys-motion-spatial-default-duration) allow-discrete;
|
||||
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate);
|
||||
|
||||
@starting-style {
|
||||
translate: -100% 0;
|
||||
@@ -372,94 +328,23 @@
|
||||
the collapsed branches, which stop the moment the rail is open — that is what makes it the
|
||||
expanded rail that comes back, on the unconditional rule below. */
|
||||
[data-md-navigation-rail][data-md-hide-when-collapsed] > [data-md-navigation-rail-panel] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
display: none;
|
||||
translate: -100% 0;
|
||||
transition:
|
||||
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
|
||||
display var(--md-sys-motion-effects-default-duration) allow-discrete;
|
||||
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate);
|
||||
|
||||
&:is([dir='rtl'], [dir='rtl'] *) {
|
||||
translate: 100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
display: none;
|
||||
translate: -100% 0;
|
||||
transition:
|
||||
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
|
||||
display var(--md-sys-motion-effects-default-duration) allow-discrete;
|
||||
|
||||
&:is([dir='rtl'], [dir='rtl'] *) {
|
||||
translate: 100% 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
display: none;
|
||||
translate: -100% 0;
|
||||
transition:
|
||||
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
|
||||
display var(--md-sys-motion-effects-default-duration) allow-discrete;
|
||||
|
||||
&:is([dir='rtl'], [dir='rtl'] *) {
|
||||
translate: 100% 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
display: none;
|
||||
translate: -100% 0;
|
||||
transition:
|
||||
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
|
||||
display var(--md-sys-motion-effects-default-duration) allow-discrete;
|
||||
|
||||
&:is([dir='rtl'], [dir='rtl'] *) {
|
||||
translate: 100% 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
display: none;
|
||||
translate: -100% 0;
|
||||
transition:
|
||||
translate var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-easing-emphasized-accelerate),
|
||||
display var(--md-sys-motion-effects-default-duration) allow-discrete;
|
||||
|
||||
&:is([dir='rtl'], [dir='rtl'] *) {
|
||||
translate: 100% 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-rail][data-md-hide-when-collapsed][data-md-open] > [data-md-navigation-rail-panel] {
|
||||
display: flex;
|
||||
translate: 0 0;
|
||||
transition:
|
||||
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate),
|
||||
display var(--md-sys-motion-spatial-default-duration) allow-discrete;
|
||||
translate var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-easing-emphasized-decelerate);
|
||||
|
||||
@starting-style {
|
||||
translate: -100% 0;
|
||||
@@ -474,6 +359,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Closing: what leaves stays drawn while it animates out. The exits above transition `translate`
|
||||
and the scrim's `opacity` only — not `display`, which Firefox cannot transition even with
|
||||
`allow-discrete`, so the slide and the fade were cut to nothing there — and
|
||||
resources/js/navigation.js sets `data-md-closing` on the rail until those transitions have
|
||||
run: `sheet` when the panel leaves the window, `scrim` when only the scrim was over the page.
|
||||
One attribute more than every collapsed branch, so a hold outranks the `display: none` those
|
||||
branches draw. */
|
||||
[data-md-navigation-rail][data-md-hide-when-collapsed][data-md-closing='sheet'] > [data-md-navigation-rail-panel] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
[data-md-navigation-rail='adaptive'][data-md-closing='sheet'] > [data-md-navigation-rail-panel] {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* The two bands the configuration above does not reach, because there it is the window and not
|
||||
the visitor that collapses the rail: below `medium` for a collapsible rail — already held at
|
||||
its collapsed width above — and `medium` itself for the adaptive one, which M3 gives a
|
||||
@@ -508,9 +410,11 @@
|
||||
display: none;
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default),
|
||||
display var(--md-sys-motion-effects-default-duration) allow-discrete;
|
||||
transition: opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
||||
}
|
||||
|
||||
[data-md-navigation-rail][data-md-closing] > [data-md-navigation-rail-scrim] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
[data-md-navigation-rail][data-md-open] > [data-md-navigation-rail-scrim] {
|
||||
@@ -557,46 +461,9 @@
|
||||
}
|
||||
|
||||
[data-md-navigation-rail][data-md-width='narrow'] [data-md-navigation-rail-header]:has([data-md-navigation-rail-menu]) [data-md-navigation-rail-menu-row] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-menu] {
|
||||
@@ -624,137 +491,24 @@
|
||||
|
||||
[data-md-navigation-rail-menu-collapse-icon],
|
||||
[data-md-navigation-rail-menu-expand-icon] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-menu-expand-icon] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-brand] {
|
||||
min-width: 0;
|
||||
flex: 1 1 0%;
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-brand] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The FAB row: 20px either side in the default 96px rail, 12px in a narrow 80 while collapsed
|
||||
@@ -769,46 +523,9 @@
|
||||
}
|
||||
|
||||
[data-md-navigation-rail][data-md-width='narrow'] [data-md-navigation-rail-fab-row] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-destinations] {
|
||||
@@ -824,50 +541,10 @@
|
||||
[data-md-navigation-rail-footer] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-destinations],
|
||||
[data-md-navigation-rail-footer] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-footer] {
|
||||
@@ -887,7 +564,7 @@
|
||||
/*
|
||||
* A FAB in the rail's header rests at elevation 0, not the 3 a standalone FAB has
|
||||
* (§ Navigation Rail/Behaviour: "when nested within another component, such as the navigation
|
||||
* rail, the FAB's resting elevation should be level 0" — N-03). The doubled `[data-md-fab]`
|
||||
* rail, the FAB's resting elevation should be level 0"). The doubled `[data-md-fab]`
|
||||
* attribute only adds specificity, over fab.css's `[data-md-fab]:hover` too, as
|
||||
* `[data-md-navigation-rail-item][data-md-navigation-rail-item]::before`
|
||||
* (navigation-rail-item.css) does for the same reason.
|
||||
@@ -899,7 +576,7 @@
|
||||
|
||||
/*
|
||||
* "Rail transitions from collapsed to expanded should animate the FAB into and out of an
|
||||
* extended FAB" (N-23) — so the header takes one `<x-fab label icon>` and morphs it, rather
|
||||
* extended FAB" — so the header takes one `<x-fab label icon>` and morphs it, rather
|
||||
* than swapping two by `display`, which showed the collapsed one at once while the rail's
|
||||
* width was still springing. The label's width springs on the rail's own spatial spring; with
|
||||
* the gap and the extended FAB's own minimum width both zeroed, `aspect-ratio` squares what is
|
||||
@@ -915,109 +592,21 @@
|
||||
transition:
|
||||
max-width var(--md-sys-motion-spatial-default-duration) var(--md-sys-motion-spatial-default),
|
||||
opacity var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
|
||||
}
|
||||
|
||||
[data-md-navigation-rail-header] [data-md-fab] > span {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* `[data-md-extended]` doubled for the same reason as the shadow's `[data-md-fab]` above: it
|
||||
outranks fab.css's `[data-md-fab][data-md-extended][data-md-size]` gap and its collapsed
|
||||
collapse-on-scroll FAB's minimum width, three attributes each. */
|
||||
[data-md-navigation-rail-header] [data-md-fab][data-md-extended][data-md-extended] {
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'],
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]),
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
min-inline-size: 0;
|
||||
aspect-ratio: 1;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]), [data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
min-inline-size: 0;
|
||||
aspect-ratio: 1;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]), [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
min-inline-size: 0;
|
||||
aspect-ratio: 1;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (840px <= width < 1200px) {
|
||||
&:where(
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
min-inline-size: 0;
|
||||
aspect-ratio: 1;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where(
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]),
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *
|
||||
) {
|
||||
min-inline-size: 0;
|
||||
aspect-ratio: 1;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/*
|
||||
* The paginators, Laravel's and Livewire's, drawn in M3 (resources/views/pagination/**): the package
|
||||
* puts its views in front of the frameworks' `tailwind` and `simple-tailwind` ones, whose names they
|
||||
* keep.
|
||||
* The paginators, Laravel's and Livewire's, drawn in M3 (resources/views/pagination/**): named
|
||||
* `material`/`simple-material`, put in front of the frameworks' own namespaces and set as
|
||||
* Laravel's default view (`Paginator::defaultView()`/`defaultSimpleView()`) and, where Livewire
|
||||
* still reads its own default, as its `pagination_theme`.
|
||||
*
|
||||
* The numbered paginator is a row: on its start, what the page holds in body-small on-surface-variant
|
||||
* — "21–30 of 95", or "Page 3 of 10" below `medium` (600px), where the page numbers give way and only
|
||||
|
||||
@@ -11,9 +11,8 @@
|
||||
*
|
||||
* Linear mirrors in a right-to-left document unconditionally — M3's own rule for this indicator,
|
||||
* not the `mirror-rtl` a caller opts an icon into. The sizing here is a default only: an
|
||||
* application's own class, unlayered or in Tailwind's `utilities` layer, always outranks
|
||||
* `material.components`, so `w-*`/`size-*` still narrows or grows the indicator exactly as it did
|
||||
* before this file existed.
|
||||
* application's own unlayered class always outranks `material.components`, so an `inline-size` or
|
||||
* `block-size` of its own narrows or grows the indicator.
|
||||
*
|
||||
* resources/js/progress.js reads and writes `data-md-value`, `data-md-max`, `data-md-circular`,
|
||||
* `data-md-wavy` and `data-md-thick` — ProgressIndicator.kt, WavyProgressIndicator.kt and its
|
||||
@@ -24,10 +23,12 @@
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@import './color.css';
|
||||
|
||||
@layer material.components {
|
||||
[data-md-progress] {
|
||||
color: var(--md-sys-color-primary);
|
||||
stroke: var(--md-sys-color-secondary-container);
|
||||
color: var(--md-color, var(--md-sys-color-primary));
|
||||
stroke: var(--md-container, var(--md-sys-color-secondary-container));
|
||||
|
||||
& > svg {
|
||||
display: block;
|
||||
@@ -81,33 +82,9 @@
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
[data-md-progress][data-md-color='secondary'] {
|
||||
color: var(--md-sys-color-secondary);
|
||||
/* Every other hue comes from the shared colour-role table (color.css): its own `primary` entry
|
||||
is plain primary-container, so the one hue this file draws differently keeps its override. */
|
||||
[data-md-progress][data-md-color='primary'] {
|
||||
stroke: var(--md-sys-color-secondary-container);
|
||||
}
|
||||
|
||||
[data-md-progress][data-md-color='tertiary'] {
|
||||
color: var(--md-sys-color-tertiary);
|
||||
stroke: var(--md-sys-color-tertiary-container);
|
||||
}
|
||||
|
||||
[data-md-progress][data-md-color='error'] {
|
||||
color: var(--md-sys-color-error);
|
||||
stroke: var(--md-sys-color-error-container);
|
||||
}
|
||||
|
||||
[data-md-progress][data-md-color='success'] {
|
||||
color: var(--md-sys-color-success);
|
||||
stroke: var(--md-sys-color-success-container);
|
||||
}
|
||||
|
||||
[data-md-progress][data-md-color='warning'] {
|
||||
color: var(--md-sys-color-warning);
|
||||
stroke: var(--md-sys-color-warning-container);
|
||||
}
|
||||
|
||||
[data-md-progress][data-md-color='info'] {
|
||||
color: var(--md-sys-color-info);
|
||||
stroke: var(--md-sys-color-info-container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@
|
||||
*
|
||||
* Placed by CSS anchor positioning on `data-md-side`, corner-to-corner so the 312px bubble has
|
||||
* room to spread past a narrow trigger; `position-try-fallbacks` flips it when the window has no
|
||||
* room, same shape as tooltip.css's plain tooltip. It fades in and out on the fast effects spring,
|
||||
* `allow-discrete` keeping `display` and `overlay` alive for the fade out and `@starting-style`
|
||||
* giving the fade in a start.
|
||||
* room, same shape as tooltip.css's plain tooltip. It fades in and out on the fast effects spring:
|
||||
* `@starting-style` gives the fade in a start, and the fade out is its exit copy's
|
||||
* (`data-md-popover-exit`, resources/js/popover-exit.js), which `data-md-popover-closing` turns
|
||||
* back to the closed opacity.
|
||||
*
|
||||
* resources/js/rich-tooltip.js shows and hides the bubble and keeps the trigger described with
|
||||
* `aria-describedby` — and, while `persistent`, `aria-haspopup`/`aria-expanded` (ACT-22) — none of
|
||||
* `aria-describedby` — and, while `persistent`, `aria-haspopup`/`aria-expanded` — none of
|
||||
* which this stylesheet draws.
|
||||
*/
|
||||
|
||||
@@ -41,12 +42,11 @@
|
||||
text-align: start;
|
||||
white-space: normal;
|
||||
opacity: 0;
|
||||
transition-property: opacity, display, overlay;
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--md-sys-motion-effects-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-effects-fast);
|
||||
transition-behavior: allow-discrete;
|
||||
|
||||
&:popover-open {
|
||||
&:popover-open:not([data-md-popover-closing]) {
|
||||
opacity: 1;
|
||||
|
||||
@starting-style {
|
||||
|
||||
@@ -84,17 +84,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
& > input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
/* The radio stays in the page, for the form, the keyboard and a screen reader —
|
||||
`md-visually-hidden` on the element itself (text.css), which outranks this layer
|
||||
whatever wins the specificity, `material.text` sitting above `material.components`. */
|
||||
}
|
||||
|
||||
[data-md-scheme-picker-swatches] {
|
||||
|
||||
@@ -45,7 +45,10 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-md-search][data-md-open] {
|
||||
/* Above the page while open, and while the view leaves: full screen through `data-md-full-screen`,
|
||||
docked through the `md-transition` class Alpine's `x-transition` carries for the length of the
|
||||
view's exit, so later positioned content on the page never covers the fading view. */
|
||||
[data-md-search]:is([data-md-open], [data-md-full-screen], :has(> [data-md-search-view].md-transition)) {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
@@ -56,10 +59,9 @@
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
|
||||
transition-property: opacity, display;
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--md-sys-motion-effects-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-effects-fast);
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
@@ -68,6 +70,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Closing (or turning full screen), the scrim fades back out while the view's `x-transition`
|
||||
holds its `display`: not `allow-discrete`, which Firefox does not honour for `display`. */
|
||||
[data-md-search]:is(:not([data-md-open]), [data-md-full-screen]) > [data-md-search-scrim] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-md-search-bar] {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@@ -160,11 +168,6 @@
|
||||
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
||||
}
|
||||
|
||||
[data-md-search-input]::placeholder {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-md-search-input]::-webkit-search-cancel-button,
|
||||
[data-md-search-input]::-webkit-search-decoration {
|
||||
appearance: none;
|
||||
@@ -201,10 +204,9 @@
|
||||
background-color: var(--md-sys-color-surface-container-high);
|
||||
box-shadow: var(--md-sys-elevation-3);
|
||||
transform-origin: top;
|
||||
transition-property: opacity, scale, display;
|
||||
transition-property: opacity, scale;
|
||||
transition-duration: var(--md-sys-motion-spatial-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-spatial-fast);
|
||||
transition-behavior: allow-discrete;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
@@ -214,6 +216,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Closing, the view goes back the way it came, into the bar, while its `x-transition` holds
|
||||
`display` for the same duration. */
|
||||
[data-md-search]:not([data-md-open]) > [data-md-search-view] {
|
||||
opacity: 0;
|
||||
scale: 1 0.9;
|
||||
}
|
||||
|
||||
/* M3: the docked container is at least 240px tall — once there is something in it to be tall
|
||||
about; a search with nothing to show stays the height of its bar. */
|
||||
[data-md-search-view]:has([data-md-search-results], [data-md-search-suggestions]) {
|
||||
@@ -268,7 +277,7 @@
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface-variant) calc(var(--md-sys-state-pressed-state-layer-opacity) * 100%), transparent);
|
||||
}
|
||||
|
||||
[data-md-search][data-md-trigger='icon']:not([data-md-open]) [data-md-search-bar] {
|
||||
[data-md-search][data-md-trigger='icon']:not([data-md-open], [data-md-full-screen]) [data-md-search-bar] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -283,6 +292,17 @@
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Leaving full screen (`data-md-full-screen` outlives `data-md-open` by the view's exit,
|
||||
resources/js/search.js): the header bar fades with the view, on the view's own spring, rather
|
||||
than going back to its resting pill — or to nothing, behind the icon — on the first frame. */
|
||||
[data-md-search][data-md-full-screen]:not([data-md-open]) [data-md-search-bar] {
|
||||
opacity: 0;
|
||||
background-color: transparent;
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--md-sys-motion-spatial-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-spatial-fast);
|
||||
}
|
||||
|
||||
[data-md-search][data-md-full-screen] [data-md-search-view] {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* <x-section-nav>: the navigation inside one area of an application — M3's secondary tabs from
|
||||
* `medium` (600px), five or more of them the scrollable tab bar M3's own accessibility page
|
||||
* blesses rather than a grid that would break the tab bar's divider and strand the upper rows'
|
||||
* indicators against nothing (docs/audits/m3-alignment/navigation.md N-16); below `medium`, where a
|
||||
* row of them never fits, a button naming the current section opens the same list as a menu.
|
||||
* indicators against nothing; below `medium`, where a row of them never fits, a button naming the
|
||||
* current section opens the same list as a menu.
|
||||
*
|
||||
* [data-md-section-nav] min-inline-size: 0
|
||||
* [data-md-section-nav-picker] hidden from medium
|
||||
@@ -14,7 +14,7 @@
|
||||
*
|
||||
* Reuses `<x-tabs>`'s own hooks and stylesheet rather than a tab bar of its own: a link marked
|
||||
* `aria-current="page"` takes the same active indicator and colour tabs.css already draws for
|
||||
* `aria-selected="true"` (N-21), so nothing here repeats a rule tabs.css owns; the only addition is
|
||||
* `aria-selected="true"`, so nothing here repeats a rule tabs.css owns; the only addition is
|
||||
* this bar's own list items — flex rows, so tabs.css's `li > [data-md-tab]` share of the width
|
||||
* applies, and `min-inline-size: 0`, so a long title in a fixed row wraps to its second line and
|
||||
* truncates there (tabs.css, M3's "may wrap to a max second line if needed with truncation")
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
* while the list is open, and the arrow turns over. The open list itself is M3's menu, drawn by
|
||||
* components/menu.css.
|
||||
*
|
||||
* A value too long for the field ends before the arrow with an ellipsis. The native select cuts its
|
||||
* own text at its end padding; the customizable one draws the value in a box of its shadow tree
|
||||
* that no author style reaches, so the view gives it a `<button>` holding `<selectedcontent>`, the
|
||||
* browser's copy of the chosen option, and the ellipsis is drawn there.
|
||||
*
|
||||
* The field's root carries `data-md-select`.
|
||||
*/
|
||||
|
||||
@@ -22,7 +27,9 @@
|
||||
/* A select covers its whole field — out over the padding and the leading icon at
|
||||
the start, under the arrow to the end — and its own padding puts the value back
|
||||
where it was. So a press anywhere on the field opens it, and the list, which
|
||||
the browser anchors to the select, is the field's width (components/menu.css). */
|
||||
the browser anchors to the select, is the field's width (components/menu.css).
|
||||
A value longer than the field is cut at that padding, with an ellipsis, rather
|
||||
than running on under the arrow. */
|
||||
select[data-md-field-control] {
|
||||
--field-end: calc(var(--field-icon) + var(--field-gap) + var(--field-pad));
|
||||
|
||||
@@ -30,6 +37,9 @@
|
||||
cursor: pointer;
|
||||
margin-inline: calc(-1 * var(--field-start)) calc(-1 * var(--field-end));
|
||||
padding-inline: var(--field-start) var(--field-end);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
select[data-md-field-control]:disabled {
|
||||
@@ -39,8 +49,10 @@
|
||||
/* Where the browser has a customizable select, the closed select is its own
|
||||
button: without the border, padding and arrow that button brings, it is the
|
||||
same field as before. While its list is open the field reads as focused —
|
||||
said from `:open`, because focus inside the list is in the top layer — and
|
||||
the arrow turns over, as the searchable choices' does. */
|
||||
said from `:open`, because focus inside the list is in the top layer — in
|
||||
the error colour when the field is in error, as a focused one is — and the
|
||||
arrow turns over, as the searchable choices' does. Both outweigh field.css's
|
||||
hover, so the pointer left on the field does not take the focus edge away. */
|
||||
@supports (appearance: base-select) {
|
||||
select[data-md-field-control] {
|
||||
appearance: base-select;
|
||||
@@ -50,6 +62,15 @@
|
||||
padding-block: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* The customizable select's value is `<selectedcontent>`, the only part of the
|
||||
closed select an author style reaches: the box the browser draws otherwise
|
||||
takes neither `text-overflow` nor a width from the select, and ran on under
|
||||
the arrow. As the select's flex item it may shrink to the room left. */
|
||||
select[data-md-field-control] selectedcontent {
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -68,6 +89,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-field][data-md-invalid] [data-md-field-box]:has(select[data-md-field-control]:open) {
|
||||
--field-edge: var(--md-sys-color-error);
|
||||
--field-ink: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
[data-md-field]:has(select[data-md-field-control]:open) [data-md-field-arrow] {
|
||||
rotate: 180deg;
|
||||
}
|
||||
|
||||
@@ -45,13 +45,14 @@
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@import './icon.css';
|
||||
@import './color.css';
|
||||
|
||||
@layer material.components {
|
||||
[data-md-slider] {
|
||||
--slider-active: var(--md-sys-color-primary);
|
||||
--slider-inactive: var(--md-sys-color-secondary-container);
|
||||
--slider-on-active: var(--md-sys-color-on-primary);
|
||||
--slider-on-inactive: var(--md-sys-color-on-secondary-container);
|
||||
--slider-active: var(--md-color, var(--md-sys-color-primary));
|
||||
--slider-inactive: var(--md-container, var(--md-sys-color-secondary-container));
|
||||
--slider-on-active: var(--md-on-color, var(--md-sys-color-on-primary));
|
||||
--slider-on-inactive: var(--md-on-container, var(--md-sys-color-on-secondary-container));
|
||||
--slider-disabled-active: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
|
||||
--slider-disabled-inactive: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-container-opacity) * 100%), transparent);
|
||||
--slider-across: 48px;
|
||||
@@ -84,44 +85,11 @@
|
||||
--slider-handle: 108px;
|
||||
}
|
||||
|
||||
[data-md-slider][data-md-color='secondary'] {
|
||||
--slider-active: var(--md-sys-color-secondary);
|
||||
--slider-on-active: var(--md-sys-color-on-secondary);
|
||||
}
|
||||
|
||||
[data-md-slider][data-md-color='tertiary'] {
|
||||
--slider-active: var(--md-sys-color-tertiary);
|
||||
--slider-inactive: var(--md-sys-color-tertiary-container);
|
||||
--slider-on-active: var(--md-sys-color-on-tertiary);
|
||||
--slider-on-inactive: var(--md-sys-color-on-tertiary-container);
|
||||
}
|
||||
|
||||
[data-md-slider][data-md-color='error'] {
|
||||
--slider-active: var(--md-sys-color-error);
|
||||
--slider-inactive: var(--md-sys-color-error-container);
|
||||
--slider-on-active: var(--md-sys-color-on-error);
|
||||
--slider-on-inactive: var(--md-sys-color-on-error-container);
|
||||
}
|
||||
|
||||
[data-md-slider][data-md-color='success'] {
|
||||
--slider-active: var(--md-sys-color-success);
|
||||
--slider-inactive: var(--md-sys-color-success-container);
|
||||
--slider-on-active: var(--md-sys-color-on-success);
|
||||
--slider-on-inactive: var(--md-sys-color-on-success-container);
|
||||
}
|
||||
|
||||
[data-md-slider][data-md-color='warning'] {
|
||||
--slider-active: var(--md-sys-color-warning);
|
||||
--slider-inactive: var(--md-sys-color-warning-container);
|
||||
--slider-on-active: var(--md-sys-color-on-warning);
|
||||
--slider-on-inactive: var(--md-sys-color-on-warning-container);
|
||||
}
|
||||
|
||||
[data-md-slider][data-md-color='info'] {
|
||||
--slider-active: var(--md-sys-color-info);
|
||||
--slider-inactive: var(--md-sys-color-info-container);
|
||||
--slider-on-active: var(--md-sys-color-on-info);
|
||||
--slider-on-inactive: var(--md-sys-color-on-info-container);
|
||||
/* Every other hue comes from the shared colour-role table (color.css): its own `primary` entry
|
||||
is plain primary-container, so the one hue this file draws differently keeps its override. */
|
||||
[data-md-slider][data-md-color='primary'] {
|
||||
--slider-inactive: var(--md-sys-color-secondary-container);
|
||||
--slider-on-inactive: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
/* Disabled: the colours give way to on-surface at M3's disabled opacities. */
|
||||
|
||||
@@ -66,10 +66,7 @@
|
||||
[data-md-split-button] [data-md-split] {
|
||||
--md-split-button-corner: var(--md-split-button-inner);
|
||||
|
||||
border-start-start-radius: var(--md-split-button-corner);
|
||||
border-end-start-radius: var(--md-split-button-corner);
|
||||
border-start-end-radius: var(--md-split-button-corner);
|
||||
border-end-end-radius: var(--md-split-button-corner);
|
||||
border-radius: var(--md-split-button-corner);
|
||||
}
|
||||
|
||||
[data-md-split-button] [data-md-split]:active {
|
||||
|
||||
@@ -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 `<x-card>` and
|
||||
* `<x-list-item>`); 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);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* [data-md-tab-content] [data-md-icon] (optional), [data-md-tab-label],
|
||||
* [data-md-tab-indicator]
|
||||
* [data-md-tab-label] [data-md-tab-text], [data-md-badge] (optional)
|
||||
* [data-md-tab-panel] role="tabpanel", hidden server-side until chosen (N-05)
|
||||
* [data-md-tab-panel] role="tabpanel", hidden server-side until chosen
|
||||
*
|
||||
* "Labels: single row by default; may wrap to a max second line if needed with truncation, or use
|
||||
* scrollable tabs to give longer titles more room" (§ Tabs, "Behaviour and guidelines"). A fixed
|
||||
@@ -25,7 +25,7 @@
|
||||
*
|
||||
* The active indicator is drawn in every tab and shown under the chosen one, so it is right before
|
||||
* Alpine starts and after a morph: 3px with rounded top corners under the content of a primary tab
|
||||
* (at least 24px wide, inset 2px at each side — N-18), 2px across the whole of a secondary one.
|
||||
* (at least 24px wide, inset 2px at each side), 2px across the whole of a secondary one.
|
||||
* When the choice changes, a view transition moves it from the old tab to the new (resources/js/tabs.js),
|
||||
* as M3's slides.
|
||||
*
|
||||
@@ -33,10 +33,10 @@
|
||||
* rest of the package draws 3px thick 2px outside the element (tokens/state.css). `--tabs-ring` is
|
||||
* the room that takes: the bar carries it as padding on every side, and the state layer and the
|
||||
* indicator reach back out into it, so the divider still sits against the tabs and the indicator
|
||||
* still sits on the divider (N-20). That makes the bar 10px taller than M3's 48px tab. A scrollable
|
||||
* set's first tab is offset 52dp from the leading edge, so it reads as scrollable (N-10). A link
|
||||
* still sits on the divider. That makes the bar 10px taller than M3's 48px tab. A scrollable
|
||||
* set's first tab is offset 52dp from the leading edge, so it reads as scrollable. A link
|
||||
* marked `aria-current="page"` (`<x-section-nav>`'s tabs) takes the active indicator and colour
|
||||
* exactly as `aria-selected="true"` does (N-21).
|
||||
* exactly as `aria-selected="true"` does.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
@@ -63,7 +63,7 @@
|
||||
}
|
||||
|
||||
/* "Offset the first scrollable tab 52dp from the leading edge so it's clear that more content
|
||||
is available" (N-10). Logical, so a right-to-left page mirrors it. */
|
||||
is available". Logical, so a right-to-left page mirrors it. */
|
||||
[data-md-tabs-bar][data-md-scrollable] {
|
||||
padding-inline-start: 52px;
|
||||
scroll-padding-inline-start: 52px;
|
||||
@@ -141,7 +141,7 @@
|
||||
}
|
||||
|
||||
/* A link-based bar marks the page with aria-current, not aria-selected; both are the chosen
|
||||
tab, and both take the variant's active colour (N-21). */
|
||||
tab, and both take the variant's active colour. */
|
||||
[data-md-tab]:is([aria-selected='true'], [aria-current='page']) {
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
@@ -196,7 +196,7 @@
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Primary indicators are inset 2dp at each side (N-18); both variants sit on the divider, which
|
||||
/* Primary indicators are inset 2dp at each side; both variants sit on the divider, which
|
||||
the ring's room has pushed below the tab. */
|
||||
[data-md-tab-indicator] {
|
||||
position: absolute;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
/*
|
||||
* <x-theme-toggle>: `mode="toggle"|"cycle"` an icon button between light, dark (and system for
|
||||
* cycle); `mode="picker"|"contrast"` a connected group over native radios (docs/audits/m3-alignment/
|
||||
* navigation.md N-04, N-17 — the Expressive-deprecated segmented button's successor,
|
||||
* `<x-group>`, already fixes both there). This file only draws the icon button; the group's own
|
||||
* shape, colour and 48px minimum below `medium` are group.css's.
|
||||
* cycle); `mode="picker"|"contrast"` a connected group over native radios — the Expressive-
|
||||
* deprecated segmented button's successor, `<x-group>`, already gives it the keyboard handling and
|
||||
* shape a hand-rolled control would have to reimplement. This file only draws the icon button; the
|
||||
* group's own shape, colour and 48px minimum below `medium` are group.css's.
|
||||
*
|
||||
* [data-md-theme-toggle="toggle"|"cycle"] the icon button: 40px reaching 48px through
|
||||
* md-touch-target (N-01), morphing corner
|
||||
* md-touch-target, morphing corner
|
||||
* [data-md-theme-toggle="picker"|"contrast"] the group's own wrapper (`role="group"`)
|
||||
*
|
||||
* The icon button also carries `data-md-icon-button`, the hook `resources/css/components/
|
||||
* toolbar.css` matches generically (not `[data-md-button][data-md-icon-button]`, as button.css
|
||||
* requires): a theme toggle placed in a toolbar's `actions` takes the toolbar's own icon colour
|
||||
* (N-13) the same way one of its `<x-button icon>` children would.
|
||||
* the same way one of its `<x-button icon>` children would.
|
||||
*
|
||||
* The corner morphs from full to `sm` while pressed on the fast spatial spring — M3 Expressive's
|
||||
* pressed-state shape change, the same motion `<x-button>`'s own icon buttons use
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
@import './field.css';
|
||||
@import './icon.css';
|
||||
@import './button.css';
|
||||
@import './modal.css';
|
||||
|
||||
@layer material.components {
|
||||
@property --timepicker-angle {
|
||||
@@ -77,7 +78,8 @@
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
/* The dialog: M3's spatial-fast pop, a 32%-scrim backdrop, no default border or padding. */
|
||||
/* The dialog's size, no default border or padding; the entry pop and the scrim are
|
||||
modal.css's shared dialog chrome, imported above. */
|
||||
[data-md-timepicker-dialog] {
|
||||
max-width: calc(100vw - var(--md-sys-measurement-space400));
|
||||
max-height: calc(100dvh - var(--md-sys-measurement-space400));
|
||||
@@ -87,22 +89,6 @@
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
transition-property: opacity, scale;
|
||||
transition-duration: var(--md-sys-motion-spatial-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-spatial-fast);
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
[data-md-timepicker-dialog] {
|
||||
opacity: 0;
|
||||
scale: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-timepicker-dialog]::backdrop {
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
|
||||
}
|
||||
|
||||
[data-md-timepicker-surface] {
|
||||
|
||||
@@ -15,19 +15,19 @@
|
||||
* as M3 nudges a snackbar clear of a docked toolbar and never in front of navigation;
|
||||
* `data-md-position="bottom-start"` moves it to the start edge, with M3's margin growing from `medium`. resources/js/snackbar.js
|
||||
* publishes the snackbar's own height as `--material-snackbar-height`, so a FAB (fab.css) can
|
||||
* lift clear of it — M3 puts a snackbar above a FAB, never in front of or behind one (ACT-17).
|
||||
* lift clear of it — M3 puts a snackbar above a FAB, never in front of or behind one.
|
||||
* Below `medium` it is full width; from there it hugs its line length, which an application's own
|
||||
* class can still bound.
|
||||
*
|
||||
* The action and the close button are both drawn at 40px and render the foundation's
|
||||
* `md-state-layer`, `md-focus-ring` and `md-touch-target` (foundation/interaction.css) to reach
|
||||
* M3's 48px target (ACT-18); nothing here copies their rules.
|
||||
* M3's 48px target; nothing here copies their rules.
|
||||
*
|
||||
* It enters on a translate and a fade on the fast spatial spring, `@starting-style` giving both a
|
||||
* start. No state icon: M3 says to avoid one in a snackbar (ACT-20). The live region is the host
|
||||
* itself, in the page before any message is, never the snackbar that comes and goes (ACT-02);
|
||||
* start. No state icon: M3 says to avoid one in a snackbar. The live region is the host
|
||||
* itself, in the page before any message is, never the snackbar that comes and goes;
|
||||
* resources/js/snackbar.js queues, times and dismisses a toast, including on Escape while it
|
||||
* holds the focus and on Alt+G, which moves the focus to one carrying an action (ACT-34).
|
||||
* holds the focus and on Alt+G, which moves the focus to one carrying an action.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@@ -23,8 +23,13 @@
|
||||
@import './icon.css';
|
||||
|
||||
@layer material.components {
|
||||
/* In a flex row, a setting's text beside its switch, the root keeps its automatic minimum:
|
||||
the track, and beside a label the track, the gap and the label's longest word. So the text
|
||||
beside a bare switch, or the switch's own label, wraps before the track gives way; a root
|
||||
free to shrink below 52px left the track running out of it, and off a card's edge on a
|
||||
phone. */
|
||||
[data-md-toggle] {
|
||||
min-width: 0;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
/* The positions below are the handle's centres less half its size, measured inside the track's
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* 8px at its ends and 4px between controls; `vertical` stands it on end
|
||||
*
|
||||
* Placed over the page, a horizontal toolbar keeps 16dp from the window's edge and a vertical one
|
||||
* 24dp, which is M3's minimum for each (N-12).
|
||||
* 24dp, which is M3's minimum for each.
|
||||
*
|
||||
* On web and large screens a docked toolbar "can be rounded", and dividers can organise its
|
||||
* controls into groups: `rounded` does the first from `expanded` (840px), and a divider in the
|
||||
@@ -23,7 +23,7 @@
|
||||
*
|
||||
* `<x-button>` and `<x-fab>` draw their own ink from `data-md-color`/`data-md-variant`, not a
|
||||
* utility, so a toolbar's recolouring rules — vibrant's icon buttons, a standard toolbar's primary
|
||||
* ones (N-13), a docked FAB's flattened shadow — sit in this file's own `material.components`
|
||||
* ones, a docked FAB's flattened shadow — sit in this file's own `material.components`
|
||||
* layer like everything else, each more specific than the button.css or fab.css rule it overrides,
|
||||
* whichever file an application imports first. The recolouring leaves a disabled button alone, so
|
||||
* button.css's disabled colours still apply inside a toolbar.
|
||||
@@ -82,7 +82,7 @@
|
||||
}
|
||||
|
||||
/* M3's colour list for a standard toolbar ends "Standard button (Primary)", as the vibrant list
|
||||
ends "Standard button (On primary container)" — the row above (N-13). Both rows name the
|
||||
ends "Standard button (On primary container)" — the row above. Both rows name the
|
||||
*standard* button, so a button that fills its own container is left alone: it carries the
|
||||
matching label colour (`filled` on-primary, `tonal` on-tone, either selected), and painting
|
||||
that label primary put a primary icon on a primary fill — the current page's button in a
|
||||
@@ -140,7 +140,7 @@
|
||||
* A toolbar at the bottom shares the screen region with a navigation bar, so it clears the one
|
||||
* <x-scaffold> publishes as --material-bottom-bar, exactly as the FAB and the snackbar do —
|
||||
* `max()`, not a sum, because that height already swallows the bottom safe area; without a bar
|
||||
* the safe area alone applies (N-02). A *docked* toolbar and a navigation bar must never be on
|
||||
* the safe area alone applies. A *docked* toolbar and a navigation bar must never be on
|
||||
* screen together at all; the offset is the backstop, not a licence.
|
||||
*/
|
||||
[data-md-toolbar-place='bottom'] {
|
||||
@@ -160,7 +160,7 @@
|
||||
}
|
||||
|
||||
/* "Horizontal toolbars should have a minimum 16dp margin from the edge of the window. Vertical
|
||||
toolbars should have a minimum 24dp margin" (N-12). */
|
||||
toolbars should have a minimum 24dp margin". */
|
||||
[data-md-toolbar][data-md-vertical][data-md-toolbar-place='end'],
|
||||
[data-md-toolbar-group][data-md-vertical][data-md-toolbar-place='end'] {
|
||||
inset-inline-end: calc(var(--md-sys-measurement-space300) + var(--material-safe-right, env(safe-area-inset-right)));
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
* text in `inverse-on-surface`, 8px either side and 4px above and below, 200px wide at most, 4px
|
||||
* from its anchor. It never takes the pointer, so it can stand over the control it names.
|
||||
*
|
||||
* It fades in and out on the fast effects spring; `allow-discrete` keeps `display` and `overlay`
|
||||
* alive until the fade out has been seen, and `@starting-style` gives the fade in a start.
|
||||
* resources/js/tooltip.js shows and hides it.
|
||||
* It fades in and out on the fast effects spring: `@starting-style` gives the fade in a start, and
|
||||
* the fade out is its exit copy's (`data-md-popover-exit`, resources/js/popover-exit.js), which
|
||||
* `data-md-popover-closing` turns back to the closed opacity. resources/js/tooltip.js shows and
|
||||
* hides it.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
@@ -38,12 +39,11 @@
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition-property: opacity, display, overlay;
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--md-sys-motion-effects-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-effects-fast);
|
||||
transition-behavior: allow-discrete;
|
||||
|
||||
&:popover-open {
|
||||
&:popover-open:not([data-md-popover-closing]) {
|
||||
opacity: 1;
|
||||
|
||||
@starting-style {
|
||||
|
||||
Vendored
+9
-6
@@ -10,7 +10,8 @@
|
||||
* Every rule of the package sits in a sub-layer of `material`, in this order, lowest first:
|
||||
*
|
||||
* material.reset foundation/reset.css — the browser's defaults taken back
|
||||
* material.tokens foundation/tokens.css — every `--md-sys-*` and `--md-ref-*` property
|
||||
* material.tokens tokens/*.css, imported here directly — every `--md-sys-*` and
|
||||
* `--md-ref-*` property
|
||||
* material.base foundation/base.css, foundation/interaction.css — the page, the font
|
||||
* face, and md-state-layer, md-focus-ring, md-touch-target, md-link
|
||||
* material.layout the layout components: scaffold, panes, the canonical layouts
|
||||
@@ -23,17 +24,19 @@
|
||||
* therefore outranks every package rule, whatever the selector — an application's broad selector
|
||||
* (`button { … }`) restyles the components too. foundation/hidden.css keeps its two `!important`
|
||||
* rules outside the layers, so `hidden` and `x-cloak` hide an element whatever sets its display.
|
||||
*
|
||||
* An application still building Tailwind keeps it in a separate entry and opens both entries with
|
||||
* `@layer properties, theme, base, material, components, utilities;`, so the `material` layers sit
|
||||
* above Tailwind's preflight and below its utilities (resources/css/tailwind.css's header).
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@import './foundation/reset.css';
|
||||
@import './foundation/hidden.css';
|
||||
@import './foundation/tokens.css';
|
||||
@import './tokens/scheme.css';
|
||||
@import './tokens/shape.css';
|
||||
@import './tokens/elevation.css';
|
||||
@import './tokens/motion.css';
|
||||
@import './tokens/type.css';
|
||||
@import './tokens/state.css';
|
||||
@import './tokens/spacing.css';
|
||||
@import './foundation/base.css';
|
||||
@import './foundation/interaction.css';
|
||||
@import './text.css';
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* Text is smoothed in grayscale where a platform offers the choice (macOS browsers): Google Sans
|
||||
* Flex is drawn for it, and subpixel smoothing thickens its strokes on light text over a dark
|
||||
* surface. Every surface the package draws — the showcase, the error pages — is set this way,
|
||||
* and so is an application, which drops Tailwind's `antialiased` class with nothing to replace.
|
||||
* and so is an application, with no class of its own needed.
|
||||
*
|
||||
* In `material.base`, above the reset and below every component and text class.
|
||||
*/
|
||||
|
||||
@@ -22,11 +22,10 @@
|
||||
* M3 never lets colour alone mark a link (docs/reference/m3/styles.md § Typography); give it
|
||||
* `md-ink-primary` where it should also read as primary.
|
||||
*
|
||||
* The same declarations as the `state-layer`, `focus-ring`, `touch-target` and `link` utilities
|
||||
* (tokens/utilities.css) the components still written in Tailwind use, but for two: the dragged
|
||||
* hook is `data-md-dragged` where the utility reads `data-dragged`, and `md-touch-target` is px
|
||||
* where the utility reads 3rem. In `material.base`, so a component's own rules, the text classes
|
||||
* and an application's rules all outrank them.
|
||||
* They replace 1.x's `state-layer`, `focus-ring`, `touch-target` and `link` utilities, but for two
|
||||
* changes: the dragged hook is `data-md-dragged` where the utility read `data-dragged`, and
|
||||
* `md-touch-target` is px where the utility read 3rem. In `material.base`, so a component's own
|
||||
* rules, the text classes and an application's rules all outrank them.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
* The reset: what a browser draws before any stylesheet, taken back to a clean start.
|
||||
*
|
||||
* Rule for rule the reset the components were built on, Tailwind CSS 4's preflight (MIT,
|
||||
* © Tailwind Labs, after modern-normalize), so a component looks the same whether an application
|
||||
* still builds Tailwind or not: every box `border-box` with a `0 solid` border and no margin or
|
||||
* padding, headings and lists unstyled, form controls inheriting font, colour and tracking with no
|
||||
* background or radius, media as blocks that never overflow their parent. Three differences:
|
||||
* © Tailwind Labs, after modern-normalize): every box `border-box` with a `0 solid` border and no
|
||||
* margin or padding, headings and lists unstyled, form controls inheriting font, colour and
|
||||
* tracking with no background or radius, media as blocks that never overflow their parent. Three
|
||||
* differences:
|
||||
*
|
||||
* - no typeface here: foundation/base.css sets `--md-ref-typeface-brand` on <html>;
|
||||
* - a placeholder is `on-surface-variant`, the colour of M3's text field placeholder (androidx
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
/*
|
||||
* The tokens: every `--md-sys-*` and `--md-ref-*` custom property the package reads.
|
||||
*
|
||||
* Each token file wraps its own declarations in `material.tokens`, because an `@import` cannot sit
|
||||
* inside a layer block and `layer()` on the import would nest the layer a second time. Being the
|
||||
* lowest layer but one, a token here loses to the same property declared anywhere an application
|
||||
* writes it: its generated `material-scheme.css`, unlayered, replaces the default scheme below
|
||||
* whatever its selectors, and a typeface or a spacing step is overridden the same way.
|
||||
*
|
||||
* scheme the colour roles of M3's baseline scheme, light and dark, three contrast levels
|
||||
* shape the corner scale
|
||||
* elevation the five shadow levels
|
||||
* motion the springs of both motion schemes and M3's legacy easings
|
||||
* type the 30 type styles and the brand typeface
|
||||
* state the state-layer and disabled opacities
|
||||
* spacing the measurement scale, space25 … space900
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@import '../tokens/scheme.css';
|
||||
@import '../tokens/shape.css';
|
||||
@import '../tokens/elevation.css';
|
||||
@import '../tokens/motion.css';
|
||||
@import '../tokens/type.css';
|
||||
@import '../tokens/state.css';
|
||||
@import '../tokens/spacing.css';
|
||||
@@ -21,7 +21,7 @@
|
||||
* way it goes, so it mirrors in a right-to-left document (§ Layout → Bidirectionality / RTL).
|
||||
*
|
||||
* In `material.layout`; `hide-below`/`hide-from` come from visibility.css. Imports app-bar.css and
|
||||
* button.css for the pane's own top app bar and its back button (plan step 36 carry-over).
|
||||
* button.css for the pane's own top app bar and its back button.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
* <x-scaffold>: M3's scaffold — bars, then rails, then panes (docs/reference/m3/foundations.md
|
||||
* § Layout → Scaffold) — as a column: an optional `banner` full width, a row below it holding the
|
||||
* adaptive navigation rail and the content region, the bottom navigation bar on a compact window,
|
||||
* and the snackbar host. Plan step 36 (navigation group) moves in everything the view drew with
|
||||
* Tailwind; `data-md-scaffold-fab` and the content region's `--md-layout-margin` are step 35's,
|
||||
* unchanged below.
|
||||
* and the snackbar host.
|
||||
*
|
||||
* `--material-margin` is M3's window margin, which the content region pads itself with and an
|
||||
* application's own rules can read: 16px below `medium`, 24px from it
|
||||
@@ -23,13 +21,11 @@
|
||||
* `[data-md-scaffold-content]` — the rail's sibling — is always a flex column, so the page's own
|
||||
* bar, the FAB wrapper and `<main>` stack regardless of the row's own display.
|
||||
*
|
||||
* `[data-md-scaffold-actions]`'s column layout is `rail-collapsed:flex-col`, the one Tailwind
|
||||
* variant scaffold.blade.php still used (`resources/css/tailwind.css`, removed with this rule):
|
||||
* written out branch for branch as the navigation rail's own rewrite did for its internal parts
|
||||
* (`resources/css/components/navigation-rail.css`) — the three width-independent conditions in one
|
||||
* `:where()` group, the four width-gated ones each in their own `@media` block, since CSS cannot
|
||||
* merge different queries. Only the "descendant of a collapsed rail" half of each pair applies:
|
||||
* the actions row can never be the rail element itself.
|
||||
* `[data-md-scaffold-actions]`'s column layout comes from a container style query on
|
||||
* `--md-navigation-rail-value: collapsed`: the actions row is always inside a rail, never the rail
|
||||
* element itself, so it reads that property with `@container style()` like any other application
|
||||
* content does (`resources/css/components/navigation-rail.css`), rather than copying the rail's
|
||||
* own conditions.
|
||||
*
|
||||
* `[data-md-scaffold-bar]` pins the navigation bar to the window's bottom edge below `medium`, at
|
||||
* z-index 30: over the page, under the rail's scrim (40) and the snackbar (50, toast.css). While a
|
||||
@@ -40,8 +36,7 @@
|
||||
* (toast.css), a placed toolbar (toolbar.css) and the content region's bottom padding. Without a
|
||||
* bar, or from `medium`, the scaffold publishes nothing and each reader falls back to 0px. A bar that has slid
|
||||
* away on scroll drops its own height from the variable (navigation-bar.css); that rule wins
|
||||
* because `material.components` comes after this file's `material.layout`, where before this
|
||||
* rewrite the variable was a Tailwind utility and the override had to sit outside every layer.
|
||||
* because `material.components` comes after this file's `material.layout` in the cascade.
|
||||
*
|
||||
* The content region's `<main>` is matched as the content wrapper's own child, so a `<main>` an
|
||||
* application nests inside the page takes none of the region's padding or clipping.
|
||||
@@ -137,39 +132,9 @@
|
||||
padding-block-start: var(--md-sys-measurement-space100);
|
||||
padding-inline: 20px;
|
||||
|
||||
&:where(
|
||||
[data-md-navigation-rail='collapsed'] *,
|
||||
[data-rail='collapsed'] [data-md-navigation-rail='collapsible']:not([data-md-open]) *,
|
||||
[data-md-navigation-rail='modal']:not([data-md-open]) *
|
||||
) {
|
||||
@container style(--md-navigation-rail-value: collapsed) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
&:where([data-md-navigation-rail='collapsible']:not([data-md-open]) *) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
&:where([data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 840px) {
|
||||
@media (width < 1200px) {
|
||||
&:where(:is([data-rail='collapsed'], [data-rail-auto]) [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 1200px) {
|
||||
&:where([data-rail='collapsed'] [data-md-navigation-rail='adaptive']:not([data-md-open]) *) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-scaffold-content] > main {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* The showcase's own chrome: what its pages draw beyond the layout components, the `md-*` text
|
||||
* classes and the shared interaction classes (plan step 38) — the rail's wordmark, the search box
|
||||
* and its results, a keyboard key in running text, an example's card and its code sample, and the
|
||||
* classes and the shared interaction classes — the rail's wordmark, the search box and its
|
||||
* results, a keyboard key in running text, an example's card and its code sample, and the
|
||||
* foundation pages' specimens: a colour swatch, a type-scale row, a corner swatch, an elevation
|
||||
* tile, a motion track and its dot, an icon search result and the Layout page's breakpoint cards.
|
||||
*
|
||||
@@ -144,53 +144,17 @@
|
||||
}
|
||||
|
||||
/* Shape: a corner swatch, filled with a tonal colour that is decoration only (never meaning,
|
||||
same as the corner it demonstrates), one per corner token. */
|
||||
same as the corner it demonstrates), one per corner token. The swatch is a `<x-surface>`
|
||||
hook (`data-md-surface`, layout/surface.css) for its `data-md-corner` table alone — this
|
||||
rule's own background outranks surface.css's, `material.components` over `material.layout`
|
||||
whatever either's specificity, so the swatch keeps its tonal fill rather than surface.css's
|
||||
default surface-container. */
|
||||
[data-md-showcase-shape-swatch] {
|
||||
display: block;
|
||||
height: 80px;
|
||||
background-color: var(--md-sys-color-primary-container);
|
||||
}
|
||||
|
||||
[data-md-showcase-shape-swatch][data-md-corner='none'] {
|
||||
border-radius: var(--md-sys-shape-corner-none);
|
||||
}
|
||||
|
||||
[data-md-showcase-shape-swatch][data-md-corner='xs'] {
|
||||
border-radius: var(--md-sys-shape-corner-xs);
|
||||
}
|
||||
|
||||
[data-md-showcase-shape-swatch][data-md-corner='sm'] {
|
||||
border-radius: var(--md-sys-shape-corner-sm);
|
||||
}
|
||||
|
||||
[data-md-showcase-shape-swatch][data-md-corner='md'] {
|
||||
border-radius: var(--md-sys-shape-corner-md);
|
||||
}
|
||||
|
||||
[data-md-showcase-shape-swatch][data-md-corner='lg'] {
|
||||
border-radius: var(--md-sys-shape-corner-lg);
|
||||
}
|
||||
|
||||
[data-md-showcase-shape-swatch][data-md-corner='lg-increased'] {
|
||||
border-radius: var(--md-sys-shape-corner-lg-increased);
|
||||
}
|
||||
|
||||
[data-md-showcase-shape-swatch][data-md-corner='xl'] {
|
||||
border-radius: var(--md-sys-shape-corner-xl);
|
||||
}
|
||||
|
||||
[data-md-showcase-shape-swatch][data-md-corner='xl-increased'] {
|
||||
border-radius: var(--md-sys-shape-corner-xl-increased);
|
||||
}
|
||||
|
||||
[data-md-showcase-shape-swatch][data-md-corner='xxl'] {
|
||||
border-radius: var(--md-sys-shape-corner-xxl);
|
||||
}
|
||||
|
||||
[data-md-showcase-shape-swatch][data-md-corner='full'] {
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
}
|
||||
|
||||
/* Elevation: a tile at one shadow level, on the surface-container-high M3 pairs a shadow with. */
|
||||
[data-md-showcase-elevation-tile] {
|
||||
display: flex;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
* The text classes: a fixed, documented set for text on plain elements. A component sets its own
|
||||
* type and ink; these are for the headings, paragraphs and captions an application writes around
|
||||
* them, and the set the package's own views use for text once they leave Tailwind.
|
||||
* them, and the set the package's own views use for their own text.
|
||||
*
|
||||
* Type — one per text element, the whole style at once (size, line height, weight, tracking;
|
||||
* tokens/type.css), never assembled by hand. Roles from docs/reference/m3/styles.md § Typography:
|
||||
@@ -49,8 +49,7 @@
|
||||
* gives the eye
|
||||
*
|
||||
* In `material.text`, above every component: a text class on a component's root or on an element
|
||||
* inside it wins over the component's own type and ink. The `md-type-*` declarations are the
|
||||
* `type-*` utilities' (tokens/utilities.css), which the components still written in Tailwind use.
|
||||
* inside it wins over the component's own type and ink.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* and a disabled container at 12%.
|
||||
*
|
||||
* The classes that draw them are foundation/interaction.css (`md-state-layer`, `md-focus-ring`,
|
||||
* `md-touch-target`, `md-link`), and utilities.css for the components still written in Tailwind.
|
||||
* `md-touch-target`, `md-link`).
|
||||
* The tokens sit in `material.tokens`, so an application's own unlayered declaration wins.
|
||||
*/
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
* <h2 class="md-type-title-lg">…</h2>
|
||||
* <p class="md-type-emphasized-headline-md md-tabular">…</p>
|
||||
*
|
||||
* The `md-type-*` classes are text.css; the `type-*` utilities of the components still written in
|
||||
* Tailwind are utilities.css, with the same declarations.
|
||||
* The `md-type-*` classes are text.css. An application's own CSS sets a style whole the same way,
|
||||
* `font: var(--md-sys-typescale-title-lg)` with its `-tracking` as `letter-spacing`.
|
||||
*
|
||||
* The brand typeface is Google Sans Flex (font.css). Emphasized styles are one weight step
|
||||
* heavier and fully rounded ("ROND" 100) — the axis that typeface exists for, verified by
|
||||
* `npm run check:font` — and they carry their own tracking, because M3 widens four of them and
|
||||
* takes Display Large's negative tracking back to zero.
|
||||
* tests/Feature/FontTest.php — and they carry their own tracking, because M3 widens four of them
|
||||
* and takes Display Large's negative tracking back to zero.
|
||||
*
|
||||
* `--md-ref-typeface-mono` is the package's, not M3's (M3 names a brand and a plain typeface and
|
||||
* nothing for code): the platform's own monospace faces, nothing downloaded, for what a person
|
||||
|
||||
+1802
File diff suppressed because it is too large
Load Diff
Vendored
+592
@@ -0,0 +1,592 @@
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
@layer material.components {
|
||||
[data-md-icon] {
|
||||
flex-shrink: 0;
|
||||
inline-size: var(--md-icon-size, 24px);
|
||||
block-size: var(--md-icon-size, 24px);
|
||||
}
|
||||
|
||||
[data-md-icon][data-md-mirror-rtl]:is([dir='rtl'], [dir='rtl'] *) {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
@layer material.components {
|
||||
[data-md-loading] {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
inline-size: var(--md-loading-size, 48px);
|
||||
block-size: var(--md-loading-size, 48px);
|
||||
color: var(--md-sys-color-primary);
|
||||
|
||||
& > svg {
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
& > [data-md-loading-still] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
& > [data-md-loading-animated] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
& > [data-md-loading-still] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-loading][data-md-contained] {
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
background-color: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
}
|
||||
}
|
||||
@layer material.components {
|
||||
[data-md-tooltip-anchor] {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
[data-md-tooltip] {
|
||||
inset: auto;
|
||||
margin: 0;
|
||||
max-inline-size: 200px;
|
||||
overflow: visible;
|
||||
border-width: 0;
|
||||
border-radius: var(--md-sys-shape-corner-xs);
|
||||
padding-block: var(--md-sys-measurement-space50);
|
||||
padding-inline: var(--md-sys-measurement-space100);
|
||||
background-color: var(--md-sys-color-inverse-surface);
|
||||
color: var(--md-sys-color-inverse-on-surface);
|
||||
font: var(--md-sys-typescale-body-sm);
|
||||
letter-spacing: var(--md-sys-typescale-body-sm-tracking);
|
||||
font-variation-settings: normal;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
transition-duration: var(--md-sys-motion-effects-fast-duration);
|
||||
transition-timing-function: var(--md-sys-motion-effects-fast);
|
||||
|
||||
&:popover-open:not([data-md-popover-closing]) {
|
||||
opacity: 1;
|
||||
|
||||
@starting-style {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-tooltip]:is([data-md-side='top'], [data-md-side='bottom']) {
|
||||
margin-block: var(--md-sys-measurement-space50);
|
||||
position-try-fallbacks: flip-block;
|
||||
}
|
||||
|
||||
[data-md-tooltip]:is([data-md-side='left'], [data-md-side='right']) {
|
||||
margin-inline: var(--md-sys-measurement-space50);
|
||||
position-try-fallbacks: flip-inline;
|
||||
}
|
||||
|
||||
[data-md-tooltip][data-md-side='top'] {
|
||||
position-area: top;
|
||||
}
|
||||
|
||||
[data-md-tooltip][data-md-side='bottom'] {
|
||||
position-area: bottom;
|
||||
}
|
||||
|
||||
[data-md-tooltip][data-md-side='left'] {
|
||||
position-area: left;
|
||||
}
|
||||
|
||||
[data-md-tooltip][data-md-side='right'] {
|
||||
position-area: right;
|
||||
}
|
||||
}
|
||||
@layer material.components {
|
||||
@property --md-color {
|
||||
syntax: '*';
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@property --md-on-color {
|
||||
syntax: '*';
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@property --md-container {
|
||||
syntax: '*';
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@property --md-on-container {
|
||||
syntax: '*';
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
[data-md-color='primary'] {
|
||||
--md-color: var(--md-sys-color-primary);
|
||||
--md-on-color: var(--md-sys-color-on-primary);
|
||||
--md-container: var(--md-sys-color-primary-container);
|
||||
--md-on-container: var(--md-sys-color-on-primary-container);
|
||||
}
|
||||
|
||||
[data-md-color='secondary'] {
|
||||
--md-color: var(--md-sys-color-secondary);
|
||||
--md-on-color: var(--md-sys-color-on-secondary);
|
||||
--md-container: var(--md-sys-color-secondary-container);
|
||||
--md-on-container: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
[data-md-color='tertiary'] {
|
||||
--md-color: var(--md-sys-color-tertiary);
|
||||
--md-on-color: var(--md-sys-color-on-tertiary);
|
||||
--md-container: var(--md-sys-color-tertiary-container);
|
||||
--md-on-container: var(--md-sys-color-on-tertiary-container);
|
||||
}
|
||||
|
||||
[data-md-color='error'] {
|
||||
--md-color: var(--md-sys-color-error);
|
||||
--md-on-color: var(--md-sys-color-on-error);
|
||||
--md-container: var(--md-sys-color-error-container);
|
||||
--md-on-container: var(--md-sys-color-on-error-container);
|
||||
}
|
||||
|
||||
[data-md-color='success'] {
|
||||
--md-color: var(--md-sys-color-success);
|
||||
--md-on-color: var(--md-sys-color-on-success);
|
||||
--md-container: var(--md-sys-color-success-container);
|
||||
--md-on-container: var(--md-sys-color-on-success-container);
|
||||
}
|
||||
|
||||
[data-md-color='warning'] {
|
||||
--md-color: var(--md-sys-color-warning);
|
||||
--md-on-color: var(--md-sys-color-on-warning);
|
||||
--md-container: var(--md-sys-color-warning-container);
|
||||
--md-on-container: var(--md-sys-color-on-warning-container);
|
||||
}
|
||||
|
||||
[data-md-color='info'] {
|
||||
--md-color: var(--md-sys-color-info);
|
||||
--md-on-color: var(--md-sys-color-on-info);
|
||||
--md-container: var(--md-sys-color-info-container);
|
||||
--md-on-container: var(--md-sys-color-on-info-container);
|
||||
}
|
||||
}
|
||||
@layer material.components {
|
||||
[data-md-button] {
|
||||
--md-button-color: var(--md-color, var(--md-sys-color-primary));
|
||||
--md-button-on-color: var(--md-on-color, var(--md-sys-color-on-primary));
|
||||
--md-button-tone: var(--md-container, var(--md-sys-color-secondary-container));
|
||||
--md-button-on-tone: var(--md-on-container, var(--md-sys-color-on-secondary-container));
|
||||
--md-button-tone-selected: var(--md-sys-color-secondary);
|
||||
--md-button-on-tone-selected: var(--md-sys-color-on-secondary);
|
||||
--md-button-quiet: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
--md-button-container: transparent;
|
||||
--md-button-label: var(--md-button-color);
|
||||
--md-button-outline: transparent;
|
||||
--md-button-elevation: none;
|
||||
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
block-size: var(--md-button-height);
|
||||
background-color: var(--md-button-container);
|
||||
color: var(--md-button-label);
|
||||
box-shadow: var(--md-button-elevation);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
transition-property: border-radius, padding, margin, bottom, background-color, color, box-shadow;
|
||||
transition-duration:
|
||||
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
|
||||
var(--md-sys-motion-spatial-fast-duration), var(--md-sys-motion-spatial-fast-duration),
|
||||
var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration), var(--md-sys-motion-effects-fast-duration);
|
||||
transition-timing-function:
|
||||
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
|
||||
var(--md-sys-motion-spatial-fast), var(--md-sys-motion-spatial-fast),
|
||||
var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast), var(--md-sys-motion-effects-fast);
|
||||
}
|
||||
[data-md-button][data-md-size='xs'] {
|
||||
--md-button-height: 32px;
|
||||
--md-button-padding: var(--md-sys-measurement-space200);
|
||||
--md-button-gap: var(--md-sys-measurement-space100);
|
||||
--md-button-icon: 20px;
|
||||
--md-button-outline-width: 1px;
|
||||
--md-button-square: var(--md-sys-shape-corner-md);
|
||||
--md-button-pressed: var(--md-sys-shape-corner-sm);
|
||||
--md-button-narrow: 28px;
|
||||
--md-button-default: 32px;
|
||||
--md-button-wide: 40px;
|
||||
}
|
||||
|
||||
[data-md-button][data-md-size='sm'] {
|
||||
--md-button-height: 40px;
|
||||
--md-button-padding: var(--md-sys-measurement-space200);
|
||||
--md-button-gap: var(--md-sys-measurement-space100);
|
||||
--md-button-icon: 20px;
|
||||
--md-button-outline-width: 1px;
|
||||
--md-button-square: var(--md-sys-shape-corner-md);
|
||||
--md-button-pressed: var(--md-sys-shape-corner-sm);
|
||||
--md-button-narrow: 32px;
|
||||
--md-button-default: 40px;
|
||||
--md-button-wide: 52px;
|
||||
}
|
||||
|
||||
[data-md-button][data-md-size='sm'][data-md-icon-button] {
|
||||
--md-button-icon: 24px;
|
||||
}
|
||||
|
||||
[data-md-button][data-md-size='md'] {
|
||||
--md-button-height: 56px;
|
||||
--md-button-padding: var(--md-sys-measurement-space300);
|
||||
--md-button-gap: var(--md-sys-measurement-space100);
|
||||
--md-button-icon: 24px;
|
||||
--md-button-outline-width: 1px;
|
||||
--md-button-square: var(--md-sys-shape-corner-lg);
|
||||
--md-button-pressed: var(--md-sys-shape-corner-md);
|
||||
--md-button-narrow: 48px;
|
||||
--md-button-default: 56px;
|
||||
--md-button-wide: 72px;
|
||||
}
|
||||
|
||||
[data-md-button][data-md-size='lg'] {
|
||||
--md-button-height: 96px;
|
||||
--md-button-padding: var(--md-sys-measurement-space600);
|
||||
--md-button-gap: 12px;
|
||||
--md-button-icon: 32px;
|
||||
--md-button-outline-width: 2px;
|
||||
--md-button-square: var(--md-sys-shape-corner-xl);
|
||||
--md-button-pressed: var(--md-sys-shape-corner-lg);
|
||||
--md-button-narrow: 64px;
|
||||
--md-button-default: 96px;
|
||||
--md-button-wide: 128px;
|
||||
}
|
||||
|
||||
[data-md-button][data-md-size='xl'] {
|
||||
--md-button-height: 136px;
|
||||
--md-button-padding: var(--md-sys-measurement-space800);
|
||||
--md-button-gap: var(--md-sys-measurement-space200);
|
||||
--md-button-icon: 40px;
|
||||
--md-button-outline-width: 3px;
|
||||
--md-button-square: var(--md-sys-shape-corner-xl);
|
||||
--md-button-pressed: var(--md-sys-shape-corner-lg);
|
||||
--md-button-narrow: 104px;
|
||||
--md-button-default: 136px;
|
||||
--md-button-wide: 184px;
|
||||
}
|
||||
|
||||
[data-md-button]:not([data-md-icon-button]) {
|
||||
gap: var(--md-button-gap);
|
||||
padding-inline: var(--md-button-padding);
|
||||
}
|
||||
|
||||
[data-md-button]:not([data-md-icon-button]):is([data-md-size='xs'], [data-md-size='sm']) {
|
||||
font: var(--md-sys-typescale-label-lg);
|
||||
letter-spacing: var(--md-sys-typescale-label-lg-tracking);
|
||||
font-variation-settings: normal;
|
||||
}
|
||||
|
||||
[data-md-button]:not([data-md-icon-button])[data-md-size='md'] {
|
||||
font: var(--md-sys-typescale-title-md);
|
||||
letter-spacing: var(--md-sys-typescale-title-md-tracking);
|
||||
font-variation-settings: normal;
|
||||
}
|
||||
|
||||
[data-md-button]:not([data-md-icon-button])[data-md-size='lg'] {
|
||||
font: var(--md-sys-typescale-headline-sm);
|
||||
letter-spacing: var(--md-sys-typescale-headline-sm-tracking);
|
||||
font-variation-settings: normal;
|
||||
}
|
||||
|
||||
[data-md-button]:not([data-md-icon-button])[data-md-size='xl'] {
|
||||
font: var(--md-sys-typescale-headline-lg);
|
||||
letter-spacing: var(--md-sys-typescale-headline-lg-tracking);
|
||||
font-variation-settings: normal;
|
||||
}
|
||||
|
||||
[data-md-button][data-md-icon-button] {
|
||||
--md-button-width: var(--md-button-default);
|
||||
|
||||
inline-size: var(--md-button-width);
|
||||
|
||||
&[data-md-width='narrow'] {
|
||||
--md-button-width: var(--md-button-narrow);
|
||||
}
|
||||
|
||||
&[data-md-width='wide'] {
|
||||
--md-button-width: var(--md-button-wide);
|
||||
}
|
||||
}
|
||||
:where([data-md-button]) {
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
}
|
||||
|
||||
:where([data-md-button]):where([data-md-shape='square'], [data-md-selected='true']) {
|
||||
border-radius: var(--md-button-square);
|
||||
}
|
||||
|
||||
:where([data-md-button]):where([data-md-icon-button][data-md-shape='square'][data-md-selected='true']) {
|
||||
border-radius: var(--md-sys-shape-corner-full);
|
||||
}
|
||||
|
||||
:where([data-md-button]):where(:active) {
|
||||
border-radius: var(--md-button-pressed);
|
||||
}
|
||||
[data-md-button][data-md-color='primary'] {
|
||||
--md-button-tone: var(--md-sys-color-secondary-container);
|
||||
--md-button-on-tone: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
[data-md-button]:not([data-md-color='primary']) {
|
||||
--md-button-tone-selected: var(--md-button-color);
|
||||
--md-button-on-tone-selected: var(--md-button-on-color);
|
||||
--md-button-quiet: var(--md-button-color);
|
||||
}
|
||||
[data-md-button][data-md-variant='filled'] {
|
||||
--md-button-container: var(--md-button-color);
|
||||
--md-button-label: var(--md-button-on-color);
|
||||
|
||||
&[data-md-selected='false'] {
|
||||
--md-button-container: var(--md-sys-color-surface-container);
|
||||
--md-button-label: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-button][data-md-variant='tonal'] {
|
||||
--md-button-container: var(--md-button-tone);
|
||||
--md-button-label: var(--md-button-on-tone);
|
||||
|
||||
&[data-md-selected='true'] {
|
||||
--md-button-container: var(--md-button-tone-selected);
|
||||
--md-button-label: var(--md-button-on-tone-selected);
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-button][data-md-variant='outlined'] {
|
||||
--md-button-label: var(--md-button-quiet);
|
||||
--md-button-outline: var(--md-sys-color-outline-variant);
|
||||
|
||||
border: var(--md-button-outline-width) solid var(--md-button-outline);
|
||||
|
||||
&[data-md-selected='true'] {
|
||||
--md-button-container: var(--md-sys-color-inverse-surface);
|
||||
--md-button-label: var(--md-sys-color-inverse-on-surface);
|
||||
--md-button-outline: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-button][data-md-variant='elevated'] {
|
||||
--md-button-container: var(--md-sys-color-surface-container-low);
|
||||
--md-button-elevation: var(--md-sys-elevation-1);
|
||||
|
||||
&[data-md-selected='true'] {
|
||||
--md-button-container: var(--md-button-color);
|
||||
--md-button-label: var(--md-button-on-color);
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-button][data-md-variant='text'][data-md-icon-button]:not([data-md-selected='true']) {
|
||||
--md-button-label: var(--md-button-quiet);
|
||||
}
|
||||
|
||||
[data-md-button][data-md-variant='text']:not([data-md-icon-button])[data-md-selected='true'] {
|
||||
--md-button-container: var(--md-button-tone);
|
||||
--md-button-label: var(--md-button-on-tone);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
[data-md-button]:is([data-md-variant='filled'], [data-md-variant='tonal']):hover {
|
||||
--md-button-elevation: var(--md-sys-elevation-1);
|
||||
}
|
||||
|
||||
[data-md-button][data-md-variant='elevated']:hover {
|
||||
--md-button-elevation: var(--md-sys-elevation-2);
|
||||
}
|
||||
}
|
||||
[data-md-button]:is(:disabled, [aria-disabled='true']) {
|
||||
box-shadow: none;
|
||||
color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent);
|
||||
}
|
||||
|
||||
[data-md-button]:is([data-md-variant='filled'], [data-md-variant='tonal'], [data-md-variant='elevated'], [data-md-variant='outlined'][data-md-selected='true']):is(:disabled, [aria-disabled='true']) {
|
||||
background-color: color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
|
||||
}
|
||||
|
||||
[data-md-button]:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
[data-md-button][aria-disabled='true'] {
|
||||
pointer-events: none;
|
||||
}
|
||||
[data-md-button-icon] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
[data-md-button-spinner] {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-md-button-spinner] > [data-md-loading] {
|
||||
inline-size: var(--md-button-icon);
|
||||
block-size: var(--md-button-icon);
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
@media (width < 840px) {
|
||||
[data-md-button] > [data-md-responsive] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 600px) {
|
||||
[data-md-button][data-md-compact-fab] {
|
||||
--md-button-height: 56px;
|
||||
--md-button-padding: var(--md-sys-measurement-space200);
|
||||
--md-button-gap: var(--md-sys-measurement-space100);
|
||||
|
||||
position: fixed;
|
||||
inset-inline-end: var(--md-sys-measurement-space200);
|
||||
bottom: calc(var(--material-bottom-bar, 0px) + var(--material-snackbar-height, 0px) + var(--md-sys-measurement-space200));
|
||||
z-index: 30;
|
||||
border-radius: var(--md-sys-shape-corner-lg);
|
||||
background-color: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
box-shadow: var(--md-sys-elevation-3);
|
||||
font: var(--md-sys-typescale-title-md);
|
||||
letter-spacing: var(--md-sys-typescale-title-md-tracking);
|
||||
font-variation-settings: normal;
|
||||
}
|
||||
|
||||
[data-md-button][data-md-compact-fab] [data-md-icon] {
|
||||
inline-size: 24px;
|
||||
block-size: 24px;
|
||||
}
|
||||
[data-md-button][data-md-compact-fab][data-md-unavailable] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@layer material.components {
|
||||
[data-md-shape][style*='--md-shape-size'] {
|
||||
inline-size: var(--md-shape-size);
|
||||
block-size: var(--md-shape-size);
|
||||
}
|
||||
}
|
||||
@layer material.components {
|
||||
body:has(> [data-md-error-page]) {
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
background-color: var(--md-sys-color-surface);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
[data-md-error-page] {
|
||||
display: flex;
|
||||
min-height: 100dvh;
|
||||
max-width: 576px;
|
||||
margin-inline: auto;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-inline: var(--md-sys-measurement-space300);
|
||||
padding-block: var(--md-sys-measurement-space600);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-md-error-art] {
|
||||
position: relative;
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
place-items: center;
|
||||
width: 192px;
|
||||
height: 192px;
|
||||
}
|
||||
|
||||
@media (width >= 600px) {
|
||||
[data-md-error-art] {
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-error-shape] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
[data-md-error-shape] svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--md-sys-color-primary-container);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
[data-md-error-shape] {
|
||||
animation: material-error-turn 60s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes material-error-turn {
|
||||
to {
|
||||
transform: rotate(1turn);
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-error-code] {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
font: var(--md-sys-typescale-emphasized-display-lg);
|
||||
letter-spacing: var(--md-sys-typescale-emphasized-display-lg-tracking);
|
||||
font-variation-settings: 'ROND' 100;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
[data-md-error-headline] {
|
||||
margin: var(--md-sys-measurement-space500) 0 0;
|
||||
font: var(--md-sys-typescale-headline-md);
|
||||
letter-spacing: var(--md-sys-typescale-headline-md-tracking);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
@media (width >= 600px) {
|
||||
[data-md-error-headline] {
|
||||
font: var(--md-sys-typescale-headline-lg);
|
||||
letter-spacing: var(--md-sys-typescale-headline-lg-tracking);
|
||||
}
|
||||
}
|
||||
|
||||
[data-md-error-message] {
|
||||
margin: 12px 0 0;
|
||||
max-width: 448px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font: var(--md-sys-typescale-body-lg);
|
||||
letter-spacing: var(--md-sys-typescale-body-lg-tracking);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
[data-md-error-actions] {
|
||||
display: flex;
|
||||
margin-top: var(--md-sys-measurement-space500);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
Vendored
+10252
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* `materialBottomSheet(standard, presets)`: the behaviour of `<x-bottom-sheet>`, spread into its
|
||||
* x-data alongside `open` (entangled with Livewire, or the surrounding Alpine scope's).
|
||||
* `materialBottomSheet(presets)`: the behaviour of `<x-bottom-sheet>`, spread into its x-data
|
||||
* alongside `open` (entangled with Livewire, or the surrounding Alpine scope's).
|
||||
*
|
||||
* Without preset heights the sheet is as tall as its content allows and a downward drag follows the
|
||||
* pointer: released past a quarter of the sheet's height or flicked down, it closes; otherwise it
|
||||
@@ -26,8 +26,7 @@ const TOP_MARGIN = 72
|
||||
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
|
||||
|
||||
window.materialBottomSheet = (standard = false, presets = {}) => ({
|
||||
standard,
|
||||
window.materialBottomSheet = (presets = {}) => ({
|
||||
stops: presets.stops ?? [],
|
||||
labels: presets.labels ?? {},
|
||||
start: presets.start ?? 0,
|
||||
@@ -142,10 +141,10 @@ window.materialBottomSheet = (standard = false, presets = {}) => ({
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
const end = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', end)
|
||||
window.removeEventListener('pointercancel', end)
|
||||
controller.abort()
|
||||
|
||||
this.dragging = false
|
||||
|
||||
@@ -158,9 +157,9 @@ window.materialBottomSheet = (standard = false, presets = {}) => ({
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', end)
|
||||
window.addEventListener('pointercancel', end)
|
||||
window.addEventListener('pointermove', move, { signal: controller.signal })
|
||||
window.addEventListener('pointerup', end, { signal: controller.signal })
|
||||
window.addEventListener('pointercancel', end, { signal: controller.signal })
|
||||
},
|
||||
|
||||
/** A preset-height drag let go: the nearest stop, or out past the smallest one. */
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* M3's window size classes, for scripts that ask the viewport width.
|
||||
*
|
||||
* The same four numbers as the `medium:`, `expanded:`, `large:` and `extra-large:` variants in
|
||||
* tokens/theme.css; compact is everything below `medium`. `from('expanded')` and `upTo('medium')`
|
||||
* return MediaQueryLists on the range syntax Tailwind compiles, so a script and a stylesheet
|
||||
* never disagree at the boundary pixel.
|
||||
* The same four numbers as the stylesheets' range media queries (`@media (width >= 840px)`) and the
|
||||
* layout components' `hide-below`, `hide-from` and `stack-below` props; compact is everything below
|
||||
* `medium`. `from('expanded')` and `upTo('medium')` return MediaQueryLists on that same range
|
||||
* syntax, so a script and a stylesheet never disagree at the boundary pixel.
|
||||
*/
|
||||
export const breakpoints = Object.freeze({
|
||||
medium: 600,
|
||||
|
||||
+16
-41
@@ -226,34 +226,18 @@ const isLastFocalItemAtEndOfContainer = (list, space) => {
|
||||
}
|
||||
|
||||
const firstIndexAfterFocalRangeWithSize = (list, size) => {
|
||||
for (let index = list.lastFocalIndex; index < list.keylines.length; index++) {
|
||||
if (list.keylines[index].size === size) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
const index = list.keylines.findIndex((keyline, i) => i >= list.lastFocalIndex && keyline.size === size)
|
||||
|
||||
return list.keylines.length - 1
|
||||
return index === -1 ? list.keylines.length - 1 : index
|
||||
}
|
||||
|
||||
const lastIndexBeforeFocalRangeWithSize = (list, size) => {
|
||||
for (let index = list.firstFocalIndex - 1; index >= 0; index--) {
|
||||
if (list.keylines[index].size === size) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
const index = list.keylines.findLastIndex((keyline, i) => i < list.firstFocalIndex && keyline.size === size)
|
||||
|
||||
return 0
|
||||
return index === -1 ? 0 : index
|
||||
}
|
||||
|
||||
const keylineBefore = (list, unadjustedOffset) => {
|
||||
for (let index = list.keylines.length - 1; index >= 0; index--) {
|
||||
if (list.keylines[index].unadjustedOffset < unadjustedOffset) {
|
||||
return list.keylines[index]
|
||||
}
|
||||
}
|
||||
|
||||
return list.keylines[0]
|
||||
}
|
||||
const keylineBefore = (list, unadjustedOffset) => list.keylines.findLast((keyline) => keyline.unadjustedOffset < unadjustedOffset) ?? list.keylines[0]
|
||||
|
||||
const keylineAfter = (list, unadjustedOffset) => list.keylines.find((keyline) => keyline.unadjustedOffset >= unadjustedOffset) ?? list.keylines.at(-1)
|
||||
|
||||
@@ -817,7 +801,7 @@ document.addEventListener('alpine:init', () => {
|
||||
target: null,
|
||||
targetAt: 0,
|
||||
settle: null,
|
||||
listeners: [],
|
||||
controller: null,
|
||||
mutations: null,
|
||||
resizes: null,
|
||||
reducedMotion: null,
|
||||
@@ -828,17 +812,20 @@ document.addEventListener('alpine:init', () => {
|
||||
const scroller = this.$refs.scroller
|
||||
|
||||
state.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
state.controller = new AbortController()
|
||||
|
||||
this.listen(scroller, 'scroll', () => this.scrolled(), { passive: true })
|
||||
const signal = state.controller.signal
|
||||
|
||||
scroller.addEventListener('scroll', () => this.scrolled(), { passive: true, signal })
|
||||
|
||||
// A scroll the person makes themselves is theirs to end wherever it ends.
|
||||
for (const type of ['pointerdown', 'wheel', 'touchstart']) {
|
||||
this.listen(scroller, type, () => (state.target = null), { passive: true })
|
||||
scroller.addEventListener(type, () => (state.target = null), { passive: true, signal })
|
||||
}
|
||||
this.listen(scroller, 'keydown', (event) => this.navigate(event))
|
||||
this.listen(scroller, 'focusin', (event) => this.reveal(event))
|
||||
this.listen(scroller, 'click', (event) => this.open(event))
|
||||
this.listen(state.reducedMotion, 'change', () => this.schedule())
|
||||
scroller.addEventListener('keydown', (event) => this.navigate(event), { signal })
|
||||
scroller.addEventListener('focusin', (event) => this.reveal(event), { signal })
|
||||
scroller.addEventListener('click', (event) => this.open(event), { signal })
|
||||
state.reducedMotion.addEventListener('change', () => this.schedule(), { signal })
|
||||
|
||||
state.resizes = new ResizeObserver(() => this.refresh())
|
||||
state.resizes.observe(scroller)
|
||||
@@ -859,11 +846,6 @@ document.addEventListener('alpine:init', () => {
|
||||
return element === this.$root || state.items.some((item) => item.element === element || item.surface === element)
|
||||
},
|
||||
|
||||
listen(target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options)
|
||||
state.listeners.push(() => target.removeEventListener(type, handler, options))
|
||||
},
|
||||
|
||||
/** Measures the container and items, and builds the strategy for this width. */
|
||||
refresh() {
|
||||
const root = this.$root
|
||||
@@ -1098,13 +1080,6 @@ document.addEventListener('alpine:init', () => {
|
||||
})
|
||||
},
|
||||
|
||||
/** The item nearest the current scroll position. */
|
||||
current() {
|
||||
const scroll = this.scrollOffset()
|
||||
|
||||
return state.snaps.reduce((best, snap, index) => (Math.abs(snap - scroll) < Math.abs(state.snaps[best] - scroll) ? index : best), 0)
|
||||
},
|
||||
|
||||
/** Where the carousel is headed: an unfinished scroll's target, or where it is. */
|
||||
heading() {
|
||||
const moving = state.target !== null && performance.now() - state.targetAt < TARGET_MS
|
||||
@@ -1235,7 +1210,7 @@ document.addEventListener('alpine:init', () => {
|
||||
destroy() {
|
||||
cancelAnimationFrame(state.frame)
|
||||
clearTimeout(state.settle)
|
||||
state.listeners.forEach((remove) => remove())
|
||||
state.controller?.abort()
|
||||
state.resizes?.disconnect()
|
||||
state.mutations?.disconnect()
|
||||
},
|
||||
|
||||
+5
-14
@@ -19,6 +19,8 @@
|
||||
* and Home and End go to its ends. The roving mark is re-applied whenever the set changes, because
|
||||
* a Livewire morph rewrites the chips underneath it.
|
||||
*/
|
||||
import { ringIndex } from './util.js'
|
||||
|
||||
const CONTROLS = 'button:not(:disabled), a[href]:not([tabindex="-1"]), input:not(:disabled):not([type="hidden"])'
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
@@ -216,20 +218,9 @@ document.addEventListener('alpine:init', () => {
|
||||
return
|
||||
}
|
||||
|
||||
const forwards = getComputedStyle(this.row).direction === 'rtl' ? 'ArrowLeft' : 'ArrowRight'
|
||||
const backwards = forwards === 'ArrowRight' ? 'ArrowLeft' : 'ArrowRight'
|
||||
|
||||
let next = null
|
||||
|
||||
if (event.key === forwards || event.key === 'ArrowDown') {
|
||||
next = (index + 1) % controls.length
|
||||
} else if (event.key === backwards || event.key === 'ArrowUp') {
|
||||
next = (index - 1 + controls.length) % controls.length
|
||||
} else if (event.key === 'Home') {
|
||||
next = 0
|
||||
} else if (event.key === 'End') {
|
||||
next = controls.length - 1
|
||||
}
|
||||
const rtl = getComputedStyle(this.row).direction === 'rtl'
|
||||
// Up and Down move the row too, unmirrored, beside whichever of Left and Right does.
|
||||
const next = ringIndex(event.key, index, controls.length, { rtl }) ?? ringIndex(event.key, index, controls.length, { vertical: true })
|
||||
|
||||
if (next === null) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* `<x-collapse>`'s height, eased open and shut on the fast spatial spring in every engine.
|
||||
*
|
||||
* collapse.css used to animate `<details>`' `::details-content` from `block-size: 0` to `auto`:
|
||||
* that takes `interpolate-size: allow-keywords` (Chrome only) and a `content-visibility` that holds
|
||||
* through the close (not Firefox), so Firefox and Safari snapped open and shut. A script can do what
|
||||
* the stylesheet cannot, the same way everywhere: the `<details>` itself runs a Web Animation of its
|
||||
* `block-size`, from the height it is drawn at to the height it is going to, with `overflow: clip`
|
||||
* for as long as it runs. Content below it moves with it; nothing is written into its `style`.
|
||||
*
|
||||
* - Open: `open` is set at once — the content is there, `toggle` fires, the chevron turns — and the
|
||||
* height grows from where it was to the section's natural height.
|
||||
* - Close: `open` stays set while the height shrinks to the summary's, so the content is still there
|
||||
* to be clipped; `data-md-collapse-closing` turns the chevron back at the start, and `open` goes
|
||||
* (and `toggle` fires) when the height has. `min-block-size` holds the summary whole while the
|
||||
* spring overshoots.
|
||||
* - A press mid-way turns it round from the height it has reached.
|
||||
* - A `name` group closes its open member with the same animation: that member's `name` is lifted
|
||||
* for the close, so the browser's own exclusivity does not shut it on the first frame, and put
|
||||
* back once it has closed.
|
||||
*
|
||||
* Routes: a press on the summary (a pointer, Enter or Space, all a `click`) is taken over here; the
|
||||
* Alpine and Livewire bindings call `materialCollapse(details, open)` from the view's `x-effect`.
|
||||
* Anything else that sets `open` — find-in-page revealing a match, an application's own script —
|
||||
* opens or closes it at once, as the browser does, and this follows along. Under reduced motion the
|
||||
* duration token is zero and every route is the browser's own, instant.
|
||||
*/
|
||||
import { ms } from './util.js'
|
||||
|
||||
const COLLAPSE = 'details[data-md-collapse]'
|
||||
const CLOSING = 'data-md-collapse-closing'
|
||||
|
||||
/** Where each collapse is headed (`true` open), its running animation, and a `name` lifted for its close. */
|
||||
const targets = new WeakMap()
|
||||
const animations = new WeakMap()
|
||||
const lifted = new WeakMap()
|
||||
|
||||
/** The height the section is drawn at, closed: its summary, and its own padding and border. */
|
||||
const closedHeight = (details) => {
|
||||
const style = getComputedStyle(details)
|
||||
const summary = details.querySelector(':scope > summary')
|
||||
|
||||
return (
|
||||
(summary?.getBoundingClientRect().height ?? 0) +
|
||||
parseFloat(style.paddingBlockStart) +
|
||||
parseFloat(style.paddingBlockEnd) +
|
||||
parseFloat(style.borderBlockStartWidth) +
|
||||
parseFloat(style.borderBlockEndWidth)
|
||||
)
|
||||
}
|
||||
|
||||
const restoreName = (details) => {
|
||||
if (lifted.has(details)) {
|
||||
details.setAttribute('name', lifted.get(details))
|
||||
lifted.delete(details)
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves the height from where it is drawn now to `to`, then `done`. Returns false under reduced motion. */
|
||||
const animate = (details, from, to, done) => {
|
||||
const style = getComputedStyle(details)
|
||||
const duration = ms(details, '--md-sys-motion-spatial-fast-duration') ?? 0
|
||||
|
||||
if (duration === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const floor = `${closedHeight(details)}px`
|
||||
const animation = details.animate(
|
||||
[
|
||||
{ blockSize: `${from}px`, minBlockSize: floor, overflow: 'clip' },
|
||||
{ blockSize: `${to}px`, minBlockSize: floor, overflow: 'clip' },
|
||||
],
|
||||
{ duration, easing: style.getPropertyValue('--md-sys-motion-spatial-fast').trim() || 'ease', fill: 'forwards' },
|
||||
)
|
||||
|
||||
animations.set(details, animation)
|
||||
animation.onfinish = () => {
|
||||
if (animations.get(details) !== animation) {
|
||||
return
|
||||
}
|
||||
|
||||
animations.delete(details)
|
||||
done()
|
||||
animation.cancel()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** The height the section is drawn at this moment, then without whatever animation was running. */
|
||||
const settle = (details) => {
|
||||
const height = details.getBoundingClientRect().height
|
||||
|
||||
animations.get(details)?.cancel()
|
||||
animations.delete(details)
|
||||
|
||||
return height
|
||||
}
|
||||
|
||||
const open = (details) => {
|
||||
targets.set(details, true)
|
||||
|
||||
const from = settle(details)
|
||||
|
||||
details.removeAttribute(CLOSING)
|
||||
|
||||
// The group's open member closes on the same spring, with its `name` lifted so the browser's
|
||||
// exclusivity does not shut it the moment this one opens; this one's own name comes back
|
||||
// after, when no other member of the group is open to be closed by it.
|
||||
const name = details.getAttribute('name') ?? lifted.get(details)
|
||||
|
||||
if (name) {
|
||||
details
|
||||
.getRootNode()
|
||||
.querySelectorAll(`${COLLAPSE}[open]`)
|
||||
.forEach((other) => {
|
||||
if (other !== details && (other.getAttribute('name') ?? lifted.get(other)) === name && targets.get(other) !== false) {
|
||||
close(other)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
restoreName(details)
|
||||
details.open = true
|
||||
|
||||
animate(details, from, details.getBoundingClientRect().height, () => {})
|
||||
}
|
||||
|
||||
const close = (details) => {
|
||||
targets.set(details, false)
|
||||
|
||||
const from = settle(details)
|
||||
const to = closedHeight(details)
|
||||
|
||||
const shut = () => {
|
||||
details.removeAttribute(CLOSING)
|
||||
details.open = false
|
||||
restoreName(details)
|
||||
}
|
||||
|
||||
if (details.hasAttribute('name')) {
|
||||
lifted.set(details, details.getAttribute('name'))
|
||||
details.removeAttribute('name')
|
||||
}
|
||||
|
||||
details.setAttribute(CLOSING, '')
|
||||
|
||||
if (!animate(details, from, to, shut)) {
|
||||
shut()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens (`true`) or closes a collapse on its spring; what the view's bindings call. The first call
|
||||
* for a collapse, when Alpine starts and applies the bound value, sets it at once: nothing should
|
||||
* move on page load.
|
||||
*/
|
||||
window.materialCollapse = (details, shouldOpen) => {
|
||||
shouldOpen = Boolean(shouldOpen)
|
||||
|
||||
if (!targets.has(details)) {
|
||||
targets.set(details, details.open)
|
||||
|
||||
if (details.open !== shouldOpen) {
|
||||
details.open = shouldOpen
|
||||
targets.set(details, shouldOpen)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (targets.get(details) === shouldOpen) {
|
||||
return
|
||||
}
|
||||
|
||||
shouldOpen ? open(details) : close(details)
|
||||
}
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const summary = event.target instanceof Element ? event.target.closest('summary') : null
|
||||
const details = summary?.parentElement
|
||||
|
||||
if (event.defaultPrevented || !details?.matches(COLLAPSE) || summary !== details.querySelector(':scope > summary')) {
|
||||
return
|
||||
}
|
||||
|
||||
if ((ms(details, '--md-sys-motion-spatial-fast-duration') ?? 0) === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const headedOpen = targets.has(details) ? targets.get(details) : details.open
|
||||
|
||||
headedOpen ? close(details) : open(details)
|
||||
})
|
||||
|
||||
// Whatever else sets `open` (find-in-page, a `name` group this script did not close, an
|
||||
// application's own script) is followed, unless this script is the one mid-way through a change.
|
||||
document.addEventListener(
|
||||
'toggle',
|
||||
(event) => {
|
||||
const details = event.target
|
||||
|
||||
if (details instanceof HTMLDetailsElement && details.matches(COLLAPSE) && !animations.has(details)) {
|
||||
targets.set(details, details.open)
|
||||
details.removeAttribute(CLOSING)
|
||||
}
|
||||
},
|
||||
true,
|
||||
)
|
||||
+34
-3
@@ -5,15 +5,46 @@
|
||||
* those as the dividers under the pinned header and over the pinned actions; a body that fits
|
||||
* marks neither.
|
||||
*
|
||||
* The marks go on the `<dialog>` rather than the body because the dialog is `wire:ignore.self`: a
|
||||
* Livewire morph hands the body back the server's attributes, which would wipe a mark until the
|
||||
* next scroll, but it never touches the dialog's own.
|
||||
* The marks go on the `<dialog>`, which is `wire:ignore.self`, so a Livewire morph never wipes one
|
||||
* until the next scroll; the body is `wire:ignore.self` too, for `materialShowModal()`'s
|
||||
* `tabindex`, but the dividers are the dialog's frame and modal.css reads them there.
|
||||
*
|
||||
* Measured on scroll, and whenever the body or what is in it changes size: the body when the window
|
||||
* or the dialog does (opening, too — a closed dialog has no size, and the observer reports the
|
||||
* one it opens to), and the element the component wraps around the slot when a morph, an image or
|
||||
* a disclosure makes the content taller or shorter while the body stays at its cap.
|
||||
*/
|
||||
import { expose } from './layers.js'
|
||||
|
||||
/**
|
||||
* `materialShowModal(dialog)`, how `<x-modal>` opens: `showModal()`, and then the first focus Chrome
|
||||
* gives a text-only dialog and Firefox and WebKit do not. With nothing focusable inside,
|
||||
* `showModal()` focuses the `<dialog>` itself, the HTML fallback; Chrome's scroll containers are
|
||||
* keyboard-focusable, so there the scrolling body is the focus delegate instead. A focused dialog
|
||||
* leaves a long body unscrollable from the keyboard — the arrow keys scroll what has the focus, and
|
||||
* WebKit never lets Tab reach a scroll container — so when the dialog took the focus itself and its
|
||||
* body overflows, the body is made focusable and takes it, as in Chrome, where modal.css draws the
|
||||
* inset ring. The `tabindex` goes when the dialog closes, so each opening decides afresh.
|
||||
*
|
||||
* A dialog opened from a modal sheet but rendered outside it sits inside what the sheet's
|
||||
* `x-trap.inert` hid from assistive technology, so it lifts that for as long as it is open
|
||||
* (layers.js).
|
||||
*/
|
||||
window.materialShowModal = (dialog) => {
|
||||
dialog.addEventListener('close', expose(dialog), { once: true })
|
||||
dialog.showModal()
|
||||
|
||||
const body = dialog.querySelector(':scope > [data-md-modal-box] > [data-md-modal-body]')
|
||||
|
||||
if (document.activeElement !== dialog || !body || body.scrollHeight - body.clientHeight < 1) {
|
||||
return
|
||||
}
|
||||
|
||||
body.tabIndex = 0
|
||||
body.focus()
|
||||
dialog.addEventListener('close', () => body.removeAttribute('tabindex'), { once: true })
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.directive('dialog-dividers', (el, _, { cleanup }) => {
|
||||
const dialog = el.closest('dialog')
|
||||
|
||||
+13
-23
@@ -23,29 +23,28 @@ document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialFab', () => ({
|
||||
collapsed: false,
|
||||
lastY: 0,
|
||||
ticking: false,
|
||||
listeners: [],
|
||||
frame: null,
|
||||
// Set in init(). Declared here, or Alpine writes them to the outermost x-data scope,
|
||||
// where a second FAB in the same page scope would take the first one's listener.
|
||||
schedule: null,
|
||||
|
||||
init() {
|
||||
this.lastY = Math.max(window.scrollY, 0)
|
||||
this.listen(window, 'scroll', () => this.queue(), { passive: true })
|
||||
},
|
||||
|
||||
/** One reading a frame: `scroll` fires far more often than anything can be drawn. */
|
||||
queue() {
|
||||
if (this.ticking) {
|
||||
return
|
||||
this.schedule = () => {
|
||||
this.frame ??= requestAnimationFrame(() => this.measure())
|
||||
}
|
||||
|
||||
this.ticking = true
|
||||
window.addEventListener('scroll', this.schedule, { passive: true })
|
||||
},
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.ticking = false
|
||||
this.measure()
|
||||
})
|
||||
destroy() {
|
||||
window.removeEventListener('scroll', this.schedule)
|
||||
cancelAnimationFrame(this.frame)
|
||||
},
|
||||
|
||||
measure() {
|
||||
this.frame = null
|
||||
|
||||
const y = Math.max(window.scrollY, 0)
|
||||
const moved = y - this.lastY
|
||||
|
||||
@@ -61,14 +60,5 @@ document.addEventListener('alpine:init', () => {
|
||||
this.lastY = y
|
||||
}
|
||||
},
|
||||
|
||||
listen(target, type, handler, options) {
|
||||
target.addEventListener(type, handler, options)
|
||||
this.listeners.push(() => target.removeEventListener(type, handler, options))
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.listeners.forEach((remove) => remove())
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
+11
-9
@@ -23,14 +23,21 @@ function grow(textarea) {
|
||||
textarea.style.height = `${textarea.scrollHeight + textarea.offsetHeight - textarea.clientHeight}px`
|
||||
}
|
||||
|
||||
/** `root` itself, if it matches `selector`, together with its matching descendants. */
|
||||
function matching(root, selector) {
|
||||
const descendants = [...root.querySelectorAll(selector)]
|
||||
|
||||
return root instanceof Element && root.matches(selector) ? [root, ...descendants] : descendants
|
||||
}
|
||||
|
||||
function growAll(root) {
|
||||
if (! growsByItself) {
|
||||
root.querySelectorAll(GROWING).forEach(grow)
|
||||
matching(root, GROWING).forEach(grow)
|
||||
}
|
||||
}
|
||||
|
||||
function markAll(root) {
|
||||
root.querySelectorAll(MIXED).forEach((input) => (input.indeterminate = true))
|
||||
matching(root, MIXED).forEach((input) => (input.indeterminate = true))
|
||||
}
|
||||
|
||||
if (! growsByItself) {
|
||||
@@ -63,13 +70,8 @@ new MutationObserver((records) => {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const input of [node, ...node.querySelectorAll(MIXED)].filter((element) => element.matches(MIXED))) {
|
||||
input.indeterminate = true
|
||||
}
|
||||
|
||||
if (! growsByItself) {
|
||||
;[node, ...node.querySelectorAll(GROWING)].filter((element) => element.matches(GROWING)).forEach(grow)
|
||||
}
|
||||
markAll(node)
|
||||
growAll(node)
|
||||
}
|
||||
}
|
||||
}).observe(document.documentElement, {
|
||||
|
||||
+3
-10
@@ -17,6 +17,8 @@
|
||||
* mid-count. The observer ignores the directive's own writes by comparing against the last
|
||||
* thing it wrote.
|
||||
*/
|
||||
import { ms } from './util.js'
|
||||
|
||||
const FIGURE = /[-−]?\d{1,3}(?:[,'’]\d{3})+(?:\.\d+)?|[-−]?\d+(?:\.\d+)?/g
|
||||
|
||||
const readFigure = (text) => {
|
||||
@@ -54,15 +56,6 @@ document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.directive('figure', (el, _, { cleanup }) => {
|
||||
const text = () => (el.childNodes.length === 1 && el.firstChild.nodeType === Node.TEXT_NODE ? el.firstChild : null)
|
||||
|
||||
// In milliseconds, whichever unit arrives: a minifier turns `600ms` into `.6s`, and a
|
||||
// bare parseFloat would read that as 0.6ms — an instant count.
|
||||
const duration = () => {
|
||||
const token = getComputedStyle(document.documentElement).getPropertyValue('--md-sys-motion-spatial-slow-duration').trim()
|
||||
const value = parseFloat(token) || 0
|
||||
|
||||
return token.endsWith('ms') ? value : value * 1000
|
||||
}
|
||||
|
||||
let written = text()?.data ?? null
|
||||
let frame = null
|
||||
|
||||
@@ -70,7 +63,7 @@ document.addEventListener('alpine:init', () => {
|
||||
cancelAnimationFrame(frame)
|
||||
|
||||
const node = text()
|
||||
const length = duration()
|
||||
const length = ms(document.documentElement, '--md-sys-motion-spatial-slow-duration') ?? 0
|
||||
|
||||
if (!node || !target || length === 0 || from === target.value) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Layers: how the modal surfaces stack, so one Escape dismisses one layer and the layer on top is
|
||||
* never hidden from assistive technology. The modal panels are `<x-drawer>`'s sheet below
|
||||
* `expanded`, `<x-bottom-sheet>`'s and the navigation rail's while it is open over the page, each
|
||||
* trapped with `x-trap.inert`; above or below them can be `<x-modal>`'s native `<dialog>`, and
|
||||
* inside any of them a menu, a customizable select or another list.
|
||||
*
|
||||
* `x-layer="expression"` goes on a modal panel, beside its `x-trap`, and does two things while the
|
||||
* expression is true.
|
||||
*
|
||||
* **Escape.** Each panel used to close on any Escape the window heard, so a dialog opened from a
|
||||
* sheet, a sheet opened from a sheet, a sheet inside a dialog or a list inside a sheet closed two
|
||||
* layers at once. An Escape is the panel's only when nothing has handled it yet
|
||||
* (`defaultPrevented`: a searchable choice's list, the date picker, the search view, a panel on top)
|
||||
* and the nearest open layer around its target is the panel itself — not an open `<dialog>`, a
|
||||
* popover (a menu, which its own light dismiss closes) or a customizable select's list, and not
|
||||
* another panel inside this one. A target in no layer at all (focus dropped to the body) belongs to
|
||||
* the panel opened last. The panel then claims the Escape with `preventDefault()`, which also keeps
|
||||
* a `<dialog>` around the panel from cancelling, and dispatches `material-escape` on itself for the
|
||||
* view to close on; a view that keeps its panel open on Escape still claims it, so nothing under
|
||||
* the panel closes in its place.
|
||||
*
|
||||
* **The accessibility tree.** `x-trap.inert` hides every sibling of the panel and of each of its
|
||||
* ancestors (`aria-hidden`). That is right for the page under the panel and wrong for a layer
|
||||
* opened above it from elsewhere in the document — a dialog rendered outside the sheet, or a second
|
||||
* sheet beside the first — which sits inside one of those siblings. So a layer that opens lifts
|
||||
* `aria-hidden` from its own ancestors (`expose()`), and when it closes puts it back only where a
|
||||
* panel that is still open hides that element. `<x-modal>` exposes its dialog the same way
|
||||
* (materialShowModal(), dialog.js); a modal `<dialog>` keeps the rest of the page from assistive
|
||||
* technology itself, and its own `x-trap` pauses the focus trap of the panel under it, whose Tab
|
||||
* would otherwise pull the focus back to an inert sheet.
|
||||
*
|
||||
* **The focus a layer moves.** A modal panel, `<x-modal>` and the modal rail move the focus to
|
||||
* their first control as they open (x-trap, `showModal()`), and on a page loaded with the layer
|
||||
* open, or opened from the keyboard, the browser counts that as `:focus-visible`. `openingFocus()`
|
||||
* tells that focus apart from a person's, so a tooltip does not stand over a sheet's close button
|
||||
* the moment the sheet appears (tooltip.js, rich-tooltip.js).
|
||||
*/
|
||||
|
||||
/** Everything that can hold an Escape before the window hears it, nearest first. */
|
||||
const LAYERS = 'dialog, [popover], select, [aria-modal="true"], [data-md-navigation-rail-panel]'
|
||||
|
||||
/** The panels that are open, in the order they opened. */
|
||||
const panels = []
|
||||
|
||||
const isOpen = (layer) => {
|
||||
if (layer.matches('dialog')) {
|
||||
return layer.open
|
||||
}
|
||||
|
||||
if (layer.matches('[popover]')) {
|
||||
return layer.matches(':popover-open')
|
||||
}
|
||||
|
||||
if (layer.matches('select')) {
|
||||
try {
|
||||
return layer.matches(':open')
|
||||
} catch {
|
||||
// No customizable select, so no list of the page's own to hold the Escape.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (layer.matches('[data-md-navigation-rail-panel]')) {
|
||||
return panels.includes(layer)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** The layers that move the focus into themselves as they open. */
|
||||
const MODAL_LAYERS = 'dialog, [aria-modal="true"], [data-md-navigation-rail-panel]'
|
||||
|
||||
/**
|
||||
* Whether a Tab is moving the focus: the browser moves it while the keydown is handled, and so does
|
||||
* focus-trap when it takes a Tab round a panel, so a focus that lands before the next task is the
|
||||
* Tab's.
|
||||
*/
|
||||
let tabbing = false
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Tab') {
|
||||
tabbing = true
|
||||
setTimeout(() => (tabbing = false))
|
||||
}
|
||||
}, true)
|
||||
|
||||
/** The nearest layer matching `selector` around `target` that is open, walking out past shut ones. */
|
||||
const nearestOpen = (target, selector) => {
|
||||
let layer = target instanceof Element ? target.closest(selector) : null
|
||||
|
||||
while (layer !== null && !isOpen(layer)) {
|
||||
layer = layer.parentElement?.closest(selector) ?? null
|
||||
}
|
||||
|
||||
return layer
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `focusin` is an open modal layer moving the focus into itself (see the header): the
|
||||
* focus arrives from outside the nearest open layer around its target, or from nowhere, and no Tab
|
||||
* is moving it. Focus moved within the layer, and a Tab that focus-trap brings back to its first
|
||||
* control, are a person's.
|
||||
*/
|
||||
export const openingFocus = (event) => {
|
||||
if (tabbing || !(event.target instanceof Element)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const layer = nearestOpen(event.target, MODAL_LAYERS)
|
||||
|
||||
return layer !== null && !(event.relatedTarget instanceof Node && layer.contains(event.relatedTarget))
|
||||
}
|
||||
|
||||
/** Whether an Escape keydown is this open panel's to act on (see the header). */
|
||||
const owns = (panel, event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return false
|
||||
}
|
||||
|
||||
const layer = nearestOpen(event.target, LAYERS)
|
||||
|
||||
return layer === null ? panels.at(-1) === panel : layer === panel
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifts `aria-hidden` from `layer`'s ancestors; the function it returns puts it back on those a
|
||||
* panel still open hides — a sibling of that panel or of one of its ancestors, as x-trap.inert
|
||||
* hides them.
|
||||
*/
|
||||
export const expose = (layer) => {
|
||||
const lifted = []
|
||||
|
||||
for (let element = layer.parentElement; element !== null && element !== document.body; element = element.parentElement) {
|
||||
if (element.getAttribute('aria-hidden') === 'true') {
|
||||
element.removeAttribute('aria-hidden')
|
||||
lifted.push(element)
|
||||
}
|
||||
}
|
||||
|
||||
return () => lifted
|
||||
.filter((element) => panels.some((panel) => panel !== layer && !element.contains(panel) && element.parentElement?.contains(panel)))
|
||||
.forEach((element) => element.setAttribute('aria-hidden', 'true'))
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.directive('layer', window.Alpine.skipDuringClone((el, { expression }, { effect, evaluateLater, cleanup }) => {
|
||||
const active = evaluateLater(expression)
|
||||
let restore = null
|
||||
|
||||
const release = () => {
|
||||
if (restore === null) {
|
||||
return
|
||||
}
|
||||
|
||||
panels.splice(panels.indexOf(el), 1)
|
||||
restore()
|
||||
restore = null
|
||||
}
|
||||
|
||||
const escape = (event) => {
|
||||
if (restore !== null && event.key === 'Escape' && owns(el, event)) {
|
||||
event.preventDefault()
|
||||
el.dispatchEvent(new CustomEvent('material-escape'))
|
||||
}
|
||||
}
|
||||
|
||||
effect(() => active((value) => {
|
||||
if (value && restore === null) {
|
||||
panels.push(el)
|
||||
restore = expose(el)
|
||||
} else if (!value) {
|
||||
release()
|
||||
}
|
||||
}))
|
||||
|
||||
window.addEventListener('keydown', escape)
|
||||
|
||||
cleanup(() => {
|
||||
window.removeEventListener('keydown', escape)
|
||||
release()
|
||||
})
|
||||
}))
|
||||
})
|
||||
@@ -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; `<x-list-item selected>` writes it itself, and in `<x-table>` 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 <tr>, so in
|
||||
* a table every overlay would cover the whole table; and not one <button> around the row, which
|
||||
* could hold no other buttons. The listeners sit on `document` and are added once.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import './theme.js'
|
||||
import './figure.js'
|
||||
import './popover-exit.js'
|
||||
import './tooltip.js'
|
||||
import './menu.js'
|
||||
import './fab.js'
|
||||
@@ -16,8 +17,10 @@ import './snackbar.js'
|
||||
import './rich-tooltip.js'
|
||||
import './progress.js'
|
||||
import './list-rows.js'
|
||||
import './layers.js'
|
||||
import './bottom-sheet.js'
|
||||
import './dialog.js'
|
||||
import './collapse.js'
|
||||
import './carousel.js'
|
||||
import './chips.js'
|
||||
import './field.js'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user