Cut duplicated and speculative code across the package
An over-engineering audit of the whole tree, applied in five reviewed batches. Behaviour stays the same except where UPGRADE.md says otherwise. PHP: the showcase and error-page stylesheets are prebuilt into resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import (first occurrence kept, the order an application's build gives), instead of Stylesheets::bundle() inlining imports on every request; only the import walk DesignGuard needs stays. SchemeStylesheet::withProfiles() replaces three copies of the scheme-plus-profiles loop, material:scheme leaves spec and contrast checks to the node script that already made them, and the error page's scheme cache, the hashed view namespace, the translations path with no lang/ folder and DesignGuard's 1.x-name hints are gone. JS: the androidx shape port progress.js and both bin scripts each carried lives once in resources/js/shapes.js (the generated SVGs are unchanged); util.js holds ringIndex(), ms(), reopenGuard() and remember(), which were written out several times; listeners are released through AbortController; tooltip.js's hoverPopover() serves the rich tooltip too. CSS: every rule for an element inside the navigation rail queries `--md-navigation-rail-value` instead of repeating the seven collapsed conditions under five media branches; badge, alert, progress, slider and button read one non-inheriting colour-role table (components/color.css); the dialog chrome, the submenu's popover chrome, the chip's state layer and touch target, and the visually-hidden inputs use the shared rules they copied; foundation/tokens.css is folded into foundation.css. Views: Support\Field and Support\Link replace the error-key, bound-value and link-attribute blocks copied into the fields and link components; the timepicker period group, the menu filter and the showcase head are partials; the datepicker's steppers and entry fields are loops; component docblocks no longer restate SKILL.md. Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces four per-group files, DesignGuardTest and the layout-component tests use datasets, browser tests share one ready() helper, CSS parsing lives in ComponentStylesheet alone. docs/audits and the finding IDs citing it are removed, as are pestphp/pest-plugin-laravel, the unused composer scripts and check:font; the lint job runs in the feature job, which now installs node packages so the prebuilt-stylesheet staleness test runs in CI. Feature suite 1177 passed, Chrome browser suite 299 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
471d927e64
commit
247c596c3a
@@ -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,14 +32,21 @@ 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
|
||||
|
||||
@@ -150,7 +150,7 @@ 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, a colour of the application's own Tailwind theme (`bg-brand`) included — 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. 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 as Tailwind, and a mail theme stylesheet there is neither checked for literals nor a source of exempt classes.
|
||||
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, a colour of the application's own Tailwind theme (`bg-brand`) included — none compiles, since the application carries no Tailwind — each with its replacement: 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. 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 as Tailwind, and a mail theme stylesheet there is neither checked for literals nor a source of exempt 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::…>`), `->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. `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.
|
||||
|
||||
|
||||
+16
-4
@@ -2,6 +2,18 @@
|
||||
|
||||
## From 2.1.0 to 2.1.1
|
||||
|
||||
- **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
|
||||
@@ -124,9 +136,9 @@
|
||||
|
||||
## 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
|
||||
|
||||
@@ -166,7 +178,7 @@ rest is a token in the application's own CSS:
|
||||
| `state-layer`, `focus-ring`, `touch-target`, `link` (1.x) | `md-state-layer`, `md-focus-ring`, `md-touch-target`, `md-link` |
|
||||
|
||||
The spacing tokens are the 4px grid Tailwind's scale was (`space200` is 16px). `DesignGuard` names
|
||||
each one with its replacement.
|
||||
each of Tailwind's own utilities with its replacement; the 1.x names are no longer reported.
|
||||
|
||||
### 3. Regenerate the colour scheme
|
||||
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
+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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,7 +5,6 @@ This application uses `nonameweb/livewire-material`: Material 3 Expressive compo
|
||||
|
||||
- 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.
|
||||
- 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`.
|
||||
- Never write a utility class — Tailwind's, the library's 1.x ones or daisyUI's — or a maryUI tag. Nothing defines them, so they compile to nothing and fail silently. Layout is the layout components (`<x-row>`, `<x-stack>`, `<x-grid>`, `<x-surface>`, `<x-pane>`), text is `md-type-*` and `md-ink-*`, and everything else is the application's own CSS on `--md-sys-*` custom properties.
|
||||
- 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,7 +3,7 @@
|
||||
|
||||
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, and there are no utility classes: a Tailwind class, or one of the library's 1.x utilities (bg-primary, type-body-md, medium:hidden), compiles to nothing. A view is written three ways:
|
||||
The library is plain CSS on M3's tokens, and there are no utility classes: a Tailwind class, one of the library's 1.x utilities (bg-primary, type-body-md, medium:hidden) or a daisyUI class compiles to nothing, and so does a maryUI tag. 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.
|
||||
|
||||
@@ -99,14 +99,8 @@ Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
|
||||
- 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, never on a literal value:
|
||||
- Colour roles, `var(--md-sys-color-…)`: `primary`, `on-primary`, `primary-container`, `on-primary-container`, `primary-dim`, `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`, `error-dim`; `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` (a boundary that must be read), `outline-variant` (a divider, a card's edge), `scrim`, `shadow`. There is no white or black.
|
||||
- Type: `font: var(--md-sys-typescale-body-md)` with `letter-spacing: var(--md-sys-typescale-body-md-tracking)`, never a size, weight or line height of your own. The typefaces are `--md-ref-typeface-brand` (Google Sans Flex), `--md-ref-typeface-plain` and `--md-ref-typeface-mono`; the weights `--md-ref-typeface-weight-regular|medium|bold`.
|
||||
- Shape: `var(--md-sys-shape-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full})`.
|
||||
- Elevation: `box-shadow: var(--md-sys-elevation-{1…5})` — for what floats over content, not for panels (a panel separates by its container tone).
|
||||
- Motion: `var(--md-sys-motion-spatial-{fast|default|slow})` (position, size, shape; springs that overshoot) and `var(--md-sys-motion-effects-{fast|default|slow})` (colour, opacity, which never overshoot), each always with its own `-duration`: `transition: transform var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-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 rule names a spring, never a scheme. Reduced motion zeroes every duration in both schemes.
|
||||
- Spacing: `var(--md-sys-measurement-space25)` … `var(--md-sys-measurement-space900)`.
|
||||
- States: `--md-sys-state-{hover|focus|pressed|dragged}-state-layer-opacity` and `--md-sys-state-disabled-{content|container}-opacity`, mixed into a role: `color: color-mix(in srgb, var(--md-sys-color-on-surface) calc(var(--md-sys-state-disabled-content-opacity) * 100%), transparent)`. A scrim is `color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent)`.
|
||||
- **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`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -124,7 +118,12 @@ The rules behind these names — which role, surface container, corner, type sty
|
||||
}
|
||||
```
|
||||
|
||||
- Breakpoints are M3's window size classes, and only those: compact below 600px, medium 600px, expanded 840px, large 1200px, extra-large 1600px. A layout component takes them as props; your own CSS writes them as range media queries (`@media (width >= 840px)`, `@media (width < 600px)`), 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)`), which is a different thing.
|
||||
- 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.
|
||||
|
||||
@@ -1181,32 +1180,7 @@ 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-brand`, `text-sport-run`, `border-l-zone-4/40` … (a colour your own Tailwind theme named) | your own `var(--…)`, or `color-mix(in srgb, var(--…) 40%, transparent)` |
|
||||
| `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 every Tailwind utility or variant — none compiles in a Tailwind-free application — each match printed beside its own 2.0.0 replacement (a layout component and prop, an `md-*` class, a `var(--md-sys-…)` token, or your own CSS), so run the check and read its output rather than a table here. A class the application's own stylesheets declare is exempt, and so is every `md-*` class.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -271,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, the `md-type-*` class 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`; text only a screen reader needs is `md-visually-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 | `<x-divider>` (`outline-variant`) |
|
||||
| Use a hex, white, black or an opacity for ink | it ignores theme, contrast level and profile | a role |
|
||||
| Write a utility class, or a breakpoint, radius, shadow, type size or easing of your own | nothing defines utility classes, and other values are not M3's | the layout components' props, `md-type-*` and `md-ink-*`, and `--md-sys-*` tokens at 600/840/1200/1600px in your own CSS |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 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
|
||||
* 37) — 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
|
||||
@@ -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/
|
||||
|
||||
@@ -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).
|
||||
@@ -54,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);
|
||||
@@ -223,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);
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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,7 +2,7 @@
|
||||
* <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.
|
||||
*
|
||||
* 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 —
|
||||
|
||||
@@ -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,7 +13,7 @@
|
||||
* [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
|
||||
|
||||
@@ -29,13 +29,13 @@
|
||||
* 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;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*
|
||||
* 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 (ACT-33).
|
||||
* has somewhere to animate to.
|
||||
*/
|
||||
|
||||
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
* 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 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 (ACT-33) in every engine, Firefox included.
|
||||
* 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,25 +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. 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 (C-03). `aria-disabled` inks the whole item
|
||||
* 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,40 +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;
|
||||
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:not([data-md-popover-closing]) {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
&:popover-open {
|
||||
opacity: 0;
|
||||
scale: 0.95;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
*
|
||||
* `[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).
|
||||
@@ -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,10 +65,9 @@
|
||||
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;
|
||||
@@ -87,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
|
||||
@@ -53,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;
|
||||
@@ -61,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);
|
||||
}
|
||||
|
||||
@@ -100,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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
* [data-md-navigation-rail-item] navigation-rail-item.css
|
||||
* [data-md-navigation-rail-footer] never scrolls
|
||||
*
|
||||
* **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");
|
||||
@@ -56,14 +56,14 @@
|
||||
*
|
||||
* `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.
|
||||
@@ -187,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;
|
||||
@@ -204,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
|
||||
@@ -284,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'] {
|
||||
@@ -315,7 +250,7 @@
|
||||
/* 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. */
|
||||
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);
|
||||
}
|
||||
@@ -393,14 +328,7 @@
|
||||
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:
|
||||
@@ -410,64 +338,6 @@
|
||||
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);
|
||||
|
||||
&: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);
|
||||
|
||||
&: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);
|
||||
|
||||
&: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);
|
||||
|
||||
&: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] {
|
||||
@@ -591,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] {
|
||||
@@ -658,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
|
||||
@@ -803,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] {
|
||||
@@ -858,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] {
|
||||
@@ -921,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.
|
||||
@@ -933,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
|
||||
@@ -949,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,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;
|
||||
@@ -80,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* 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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -168,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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Vendored
+9
-2
@@ -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
|
||||
@@ -33,7 +34,13 @@
|
||||
|
||||
@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';
|
||||
|
||||
@@ -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';
|
||||
@@ -24,12 +24,10 @@
|
||||
* 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.
|
||||
* variant scaffold.blade.php still used (`resources/css/tailwind.css`, removed with this rule): the
|
||||
* actions row is always inside a rail, never the rail element itself, so it reads
|
||||
* `--md-navigation-rail-value` 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
|
||||
@@ -137,39 +135,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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
*
|
||||
* 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. */
|
||||
|
||||
+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
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
* 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'
|
||||
|
||||
@@ -33,8 +35,6 @@ const targets = new WeakMap()
|
||||
const animations = new WeakMap()
|
||||
const lifted = new WeakMap()
|
||||
|
||||
const milliseconds = (value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000) || 0
|
||||
|
||||
/** The height the section is drawn at, closed: its summary, and its own padding and border. */
|
||||
const closedHeight = (details) => {
|
||||
const style = getComputedStyle(details)
|
||||
@@ -59,7 +59,7 @@ const restoreName = (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 = milliseconds(style.getPropertyValue('--md-sys-motion-spatial-fast-duration'))
|
||||
const duration = ms(details, '--md-sys-motion-spatial-fast-duration') ?? 0
|
||||
|
||||
if (duration === 0) {
|
||||
return false
|
||||
@@ -185,7 +185,7 @@ document.addEventListener('click', (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (milliseconds(getComputedStyle(details).getPropertyValue('--md-sys-motion-spatial-fast-duration')) === 0) {
|
||||
if ((ms(details, '--md-sys-motion-spatial-fast-duration') ?? 0) === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+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
|
||||
|
||||
+13
-10
@@ -85,6 +85,17 @@ document.addEventListener('keydown', (event) => {
|
||||
}
|
||||
}, 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
|
||||
@@ -96,11 +107,7 @@ export const openingFocus = (event) => {
|
||||
return false
|
||||
}
|
||||
|
||||
let layer = event.target.closest(MODAL_LAYERS)
|
||||
|
||||
while (layer !== null && !isOpen(layer)) {
|
||||
layer = layer.parentElement?.closest(MODAL_LAYERS) ?? null
|
||||
}
|
||||
const layer = nearestOpen(event.target, MODAL_LAYERS)
|
||||
|
||||
return layer !== null && !(event.relatedTarget instanceof Node && layer.contains(event.relatedTarget))
|
||||
}
|
||||
@@ -111,11 +118,7 @@ const owns = (panel, event) => {
|
||||
return false
|
||||
}
|
||||
|
||||
let layer = event.target instanceof Element ? event.target.closest(LAYERS) : null
|
||||
|
||||
while (layer !== null && !isOpen(layer)) {
|
||||
layer = layer.parentElement?.closest(LAYERS) ?? null
|
||||
}
|
||||
const layer = nearestOpen(event.target, LAYERS)
|
||||
|
||||
return layer === null ? panels.at(-1) === panel : layer === panel
|
||||
}
|
||||
|
||||
+512
-535
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@
|
||||
* `materialNavigationBar` is `<x-navigation-bar hide-on-scroll>` — see the same file's sibling.
|
||||
*/
|
||||
import { from, upTo } from './breakpoints.js'
|
||||
import { remember } from './util.js'
|
||||
|
||||
/** The root of every interactive rail on the page once its view is initialised: what `toggle()` asks. */
|
||||
const rails = new Set()
|
||||
@@ -102,11 +103,7 @@ document.addEventListener('alpine:init', () => {
|
||||
root.dataset.rail = collapsed ? 'collapsed' : 'expanded'
|
||||
root.removeAttribute('data-rail-auto')
|
||||
|
||||
try {
|
||||
localStorage.setItem(root.dataset.railKey || 'material-rail', root.dataset.rail)
|
||||
} catch {
|
||||
// Blocked storage: the rail still toggles, it just will not remember.
|
||||
}
|
||||
remember(root.dataset.railKey || 'material-rail', root.dataset.rail)
|
||||
},
|
||||
|
||||
show() {
|
||||
@@ -125,8 +122,7 @@ document.addEventListener('alpine:init', () => {
|
||||
wide: mode === 'adaptive' ? from('expanded').matches : false,
|
||||
roomy: mode === 'adaptive' ? from('large').matches : false,
|
||||
tight: mode === 'collapsible' ? upTo('medium').matches : false,
|
||||
queries: [],
|
||||
listeners: [],
|
||||
controller: null,
|
||||
|
||||
/**
|
||||
* `data-md-closing` while a rail that was open over the page leaves it: `sheet` when the
|
||||
@@ -144,6 +140,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
init() {
|
||||
rails.add(this.$root)
|
||||
this.controller = new AbortController()
|
||||
|
||||
if (mode !== 'adaptive') {
|
||||
// Below `medium` a collapsible rail is held at its collapsed width whatever the
|
||||
@@ -181,16 +178,12 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
watch(query, onChange) {
|
||||
const listener = (event) => onChange(event.matches)
|
||||
|
||||
query.addEventListener('change', listener)
|
||||
this.queries.push(query)
|
||||
this.listeners.push(listener)
|
||||
query.addEventListener('change', (event) => onChange(event.matches), { signal: this.controller.signal })
|
||||
},
|
||||
|
||||
destroy() {
|
||||
rails.delete(this.$root)
|
||||
this.queries.forEach((query, index) => query.removeEventListener('change', this.listeners[index]))
|
||||
this.controller.abort()
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,9 +36,6 @@ const longestTransition = (element) => {
|
||||
return Math.max(0, ...durations.map((duration, index) => duration + (delays[index % delays.length] || 0)))
|
||||
}
|
||||
|
||||
/** Out of the DOM, which takes it off the top layer without a `beforetoggle` or `toggle` of its own. */
|
||||
const remove = (ghost) => ghost.remove()
|
||||
|
||||
/**
|
||||
* A still copy of the popover, pinned where it is drawn. Colours come from custom properties an
|
||||
* ancestor may set (a vibrant menu's `--material-menu-surface`), which the copy loses at the end of
|
||||
@@ -97,7 +94,8 @@ const exit = ({ ghost, scrollTop }, popover) => {
|
||||
|
||||
ghosts.set(popover, ghost)
|
||||
setTimeout(() => {
|
||||
remove(ghost)
|
||||
// Out of the DOM, which takes it off the top layer without a `beforetoggle` or `toggle` of its own.
|
||||
ghost.remove()
|
||||
|
||||
if (ghosts.get(popover) === ghost) {
|
||||
ghosts.delete(popover)
|
||||
@@ -118,7 +116,7 @@ document.addEventListener(
|
||||
|
||||
if (previous) {
|
||||
ghosts.delete(popover)
|
||||
remove(previous)
|
||||
previous.remove()
|
||||
}
|
||||
|
||||
if (event.newState !== 'closed') {
|
||||
|
||||
+10
-636
@@ -33,9 +33,8 @@
|
||||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/internal/ShapeUtil.kt
|
||||
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/tokens/*ProgressIndicatorTokens.kt
|
||||
* compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/VectorizedAnimationSpec.kt (keyframes)
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/*.kt (via bin/shapes.mjs
|
||||
* and bin/loading-indicator.mjs: RoundedPolygon, CornerRounding, Cubic, Morph, FeatureMapping,
|
||||
* PolygonMeasure)
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/*.kt (via shapes.js:
|
||||
* RoundedPolygon, CornerRounding, Cubic, Morph, FeatureMapping, PolygonMeasure)
|
||||
*
|
||||
* Copyright 2022-2025 The Android Open Source Project
|
||||
*
|
||||
@@ -53,6 +52,9 @@
|
||||
* ---------------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { asCubics, circlePolygon, match, point, rounding, star as starPolygon } from './shapes.js'
|
||||
import { ms } from './util.js'
|
||||
|
||||
const SVG = 'http://www.w3.org/2000/svg'
|
||||
const WATCHED = ['data-md-value', 'data-md-max', 'data-md-circular', 'data-md-wavy', 'data-md-thick']
|
||||
|
||||
@@ -289,624 +291,6 @@ function waveSegment(from, to, halfWavelength, height, shift, middle, amplitude)
|
||||
return d
|
||||
}
|
||||
|
||||
// Shapes: bin/shapes.mjs (RoundedPolygon) --------------------------------------------------
|
||||
|
||||
const DISTANCE_EPSILON = 1e-4
|
||||
const ANGLE_EPSILON = 1e-6
|
||||
|
||||
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 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))
|
||||
const direction = (a) => div(a, length(a))
|
||||
const radialToCartesian = (radius, angle) => point(Math.cos(angle) * radius, Math.sin(angle) * radius)
|
||||
const convex = (previous, current, next) => {
|
||||
const [a, b] = [minus(current, previous), minus(next, current)]
|
||||
|
||||
return a.x * b.y - a.y * b.x > 0
|
||||
}
|
||||
|
||||
/** 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]
|
||||
|
||||
const straightLine = (x0, y0, x1, y1) => [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 clockwise = 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) *
|
||||
(clockwise ? 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
|
||||
|
||||
const rounding = (radius = 0, smoothing = 0) => ({ radius, smoothing })
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
/** RoundedPolygon.normalized: into the unit square, by the approximate (control point) bounds. */
|
||||
normalized() {
|
||||
let [left, top, right, bottom] = [Infinity, Infinity, -Infinity, -Infinity]
|
||||
|
||||
for (const c of this.cubics) {
|
||||
const count = zeroLength(c) ? 2 : 8
|
||||
|
||||
for (let i = 0; i < count; i += 2) {
|
||||
left = Math.min(left, c[i])
|
||||
right = Math.max(right, c[i])
|
||||
top = Math.min(top, c[i + 1])
|
||||
bottom = Math.max(bottom, c[i + 1])
|
||||
}
|
||||
}
|
||||
|
||||
const [width, height] = [right - left, 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
|
||||
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])
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** RoundedPolygon(vertices, rounding, perVertexRounding, centerX, centerY). */
|
||||
function polygonFromVertices(vertices, perVertexRounding, center) {
|
||||
const n = vertices.length
|
||||
const roundedCorners = vertices.map((vertex, i) => new RoundedCorner(vertices[(i + n - 1) % n], vertex, vertices[(i + 1) % n], perVertexRounding[i]))
|
||||
|
||||
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 end = corners[i].at(-1)
|
||||
const start = corners[(i + 1) % n][0]
|
||||
|
||||
features.push({ type: 'corner', convex: convex(vertices[(i + n - 1) % n], vertices[i], vertices[(i + 1) % n]), cubics: corners[i] })
|
||||
features.push({ type: 'edge', cubics: [straightLine(end[6], end[7], start[0], start[1])] })
|
||||
}
|
||||
|
||||
return new RoundedPolygon(features, center)
|
||||
}
|
||||
|
||||
/** RoundedPolygon.circle(numVertices): a regular polygon rounded all the way round. */
|
||||
function circlePolygon(vertexCount) {
|
||||
const radius = 1 / Math.cos(Math.PI / vertexCount)
|
||||
const vertices = Array.from({ length: vertexCount }, (_, i) => radialToCartesian(radius, ((Math.PI / vertexCount) * 2 * i)))
|
||||
|
||||
return polygonFromVertices(vertices, vertices.map(() => rounding(1)), point(0, 0))
|
||||
}
|
||||
|
||||
/** RoundedPolygon.star(numVerticesPerRadius, innerRadius, rounding, innerRounding). */
|
||||
function starPolygon(vertexCount, innerRadius, outerRounding, innerRounding) {
|
||||
const vertices = []
|
||||
const roundings = []
|
||||
|
||||
for (let i = 0; i < vertexCount; i++) {
|
||||
vertices.push(radialToCartesian(1, (Math.PI / vertexCount) * 2 * i), radialToCartesian(innerRadius, (Math.PI / vertexCount) * (2 * i + 1)))
|
||||
roundings.push(outerRounding, innerRounding)
|
||||
}
|
||||
|
||||
return polygonFromVertices(vertices, roundings, point(0, 0))
|
||||
}
|
||||
|
||||
// Morph: bin/loading-indicator.mjs (FloatMapping, PolygonMeasure, FeatureMapping, Morph) ---
|
||||
|
||||
const positiveModulo = (num, mod) => ((num % mod) + mod) % mod
|
||||
const progressInRange = (progress, from, to) => (to >= from ? progress >= from && progress <= to : progress >= from || progress <= to)
|
||||
const progressDistance = (a, b) => Math.min(Math.abs(a - b), 1 - Math.abs(a - b))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const MEASURE_SEGMENTS = 3
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
class MeasuredCubic {
|
||||
constructor(c, startOutlineProgress, endOutlineProgress) {
|
||||
this.cubic = c
|
||||
this.startOutlineProgress = startOutlineProgress
|
||||
this.endOutlineProgress = endOutlineProgress
|
||||
this.measuredSize = closestProgressTo(c, Infinity)[1]
|
||||
}
|
||||
|
||||
cutAtProgress(cutOutlineProgress) {
|
||||
const bounded = clamp(cutOutlineProgress, this.startOutlineProgress, this.endOutlineProgress)
|
||||
const relativeProgress = (bounded - this.startOutlineProgress) / (this.endOutlineProgress - this.startOutlineProgress)
|
||||
const t = closestProgressTo(this.cubic, relativeProgress * this.measuredSize)[0]
|
||||
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((c, i) => {
|
||||
if (feature.type === 'corner' && i === Math.floor(feature.cubics.length / 2)) {
|
||||
featureToCubic.push([feature, cubics.length])
|
||||
}
|
||||
|
||||
cubics.push(c)
|
||||
})
|
||||
}
|
||||
|
||||
const measures = [0]
|
||||
|
||||
for (const c of cubics) {
|
||||
measures.push(measures.at(-1) + closestProgressTo(c, Infinity)[1])
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
function featureDistSquared(f1, f2) {
|
||||
if (f1.type === 'corner' && f2.type === 'corner' && f1.convex !== f2.convex) {
|
||||
return Infinity
|
||||
}
|
||||
|
||||
const representative = (feature) => {
|
||||
const [first, last] = [feature.cubics[0], feature.cubics.at(-1)]
|
||||
|
||||
return point((first[0] + last[6]) / 2, (first[1] + last[7]) / 2)
|
||||
}
|
||||
|
||||
const [p1, p2] = [representative(f1), representative(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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
/** Morph.match: both shapes cut into pairs of matching cubics. */
|
||||
function match(p1, p2) {
|
||||
const measuredPolygon1 = MeasuredPolygon.measure(p1)
|
||||
const measuredPolygon2 = MeasuredPolygon.measure(p2)
|
||||
const corners = (features) => features.filter(({ feature }) => feature.type === 'corner')
|
||||
const mappings = doMapping(corners(measuredPolygon1.features), corners(measuredPolygon2.features))
|
||||
const [sources, targets] = [mappings.map((m) => m[0]), mappings.map((m) => m[1])]
|
||||
const map = (x) => linearMap(sources, targets, x)
|
||||
const mapBack = (x) => linearMap(targets, sources, x)
|
||||
const polygon2CutPoint = 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 : 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(map(minb) - polygon2CutPoint, 1))
|
||||
} else {
|
||||
seg2 = b2
|
||||
b2 = bs2[i2++]
|
||||
}
|
||||
|
||||
pairs.push([seg1.cubic, seg2.cubic])
|
||||
}
|
||||
|
||||
return pairs
|
||||
}
|
||||
|
||||
/** Morph.asCubics: every matched pair interpolated at `progress`, closed 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
|
||||
}
|
||||
|
||||
// The circular wave ---------------------------------------------------------------------
|
||||
|
||||
const circularShapes = new Map()
|
||||
@@ -915,7 +299,7 @@ const circularShapes = new Map()
|
||||
function shapesFor(vertexCount) {
|
||||
if (!circularShapes.has(vertexCount)) {
|
||||
const circle = circlePolygon(vertexCount).normalized()
|
||||
const star = starPolygon(vertexCount, 0.75, rounding(0.35, 0.4), rounding(0.5)).normalized()
|
||||
const star = starPolygon(vertexCount, { innerRadius: 0.75, cornerRounding: rounding(0.35, 0.4), innerCornerRounding: rounding(0.5) }).normalized()
|
||||
let pairs = null
|
||||
|
||||
circularShapes.set(vertexCount, {
|
||||
@@ -935,6 +319,8 @@ const GAUSS_LEGENDRE = [
|
||||
[0.9602898564975363, 0.1012285362903763],
|
||||
]
|
||||
|
||||
// Pure arithmetic rather than getTotalLength(): WebKit lays the whole page out on that call, even
|
||||
// for a detached path, and this runs every frame while the amplitude animates.
|
||||
function cubicLength(c) {
|
||||
let sum = 0
|
||||
|
||||
@@ -988,18 +374,6 @@ function circularPath(cubics, pivot, repeat, scale, cx, cy) {
|
||||
|
||||
// The component -------------------------------------------------------------------------
|
||||
|
||||
/** A duration token in milliseconds, whichever unit a minifier left it in; null when unset. */
|
||||
function tokenDuration(element, token) {
|
||||
const value = getComputedStyle(element).getPropertyValue(token).trim()
|
||||
const number = parseFloat(value)
|
||||
|
||||
if (Number.isNaN(number)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return value.endsWith('ms') ? number : number * 1000
|
||||
}
|
||||
|
||||
function svgElement(name, attributes) {
|
||||
const element = document.createElementNS(SVG, name)
|
||||
|
||||
@@ -1077,7 +451,7 @@ class Indicator {
|
||||
// Compose draws determinate and indeterminate indicators as two different composables.
|
||||
this.reset(target)
|
||||
} else if (target !== null && target !== this.target) {
|
||||
const duration = tokenDuration(element, '--md-sys-motion-effects-slow-duration') ?? VALUE_SETTLE
|
||||
const duration = ms(element, '--md-sys-motion-effects-slow-duration') ?? VALUE_SETTLE
|
||||
this.timeScale = duration > 0 && !this.motion.matches ? VALUE_SETTLE / duration : 0
|
||||
}
|
||||
|
||||
@@ -1202,7 +576,7 @@ class Indicator {
|
||||
const goal = this.target === null ? 1 : amplitudeFor(clamp(this.progress, 0, 1))
|
||||
|
||||
if (!this.amplitudeAnimation && goal !== this.amplitudeGoal) {
|
||||
const duration = reduced ? 0 : (tokenDuration(this.element, '--md-sys-motion-duration-long') ?? AMPLITUDE_DURATION)
|
||||
const duration = reduced ? 0 : (ms(this.element, '--md-sys-motion-duration-long') ?? AMPLITUDE_DURATION)
|
||||
|
||||
this.amplitudeGoal = goal
|
||||
this.morphed = true
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* that can hover, at once on keyboard focus (not the focus a sheet or dialog moves to it as it
|
||||
* opens, layers.js), and standing for 1.5s once the pointer or the focus
|
||||
* leaves, which M3 gives plain and rich tooltips alike (docs/reference/m3/
|
||||
* components-actions-communication-containment.md § Tooltips, ACT-25). The bubble is inside the
|
||||
* components-actions-communication-containment.md § Tooltips). The bubble is inside the
|
||||
* wrapper, so moving onto it to reach its actions, by pointer or by Tab, is no leave at all; Escape
|
||||
* hides it at once. Persistent: a press on the trigger opens it as a light-dismiss popover.
|
||||
*
|
||||
@@ -16,20 +16,12 @@
|
||||
* A Livewire morph strips attributes the server did not render and gives the bubble a new id, so
|
||||
* they are written again whenever the trigger is reached.
|
||||
*/
|
||||
import { openingFocus } from './layers.js'
|
||||
|
||||
const HOVER_DELAY_MS = 500
|
||||
const LEAVE_GRACE_MS = 1500
|
||||
|
||||
// A persistent bubble is a `popover="auto"`, and its trigger is outside it: the press on the
|
||||
// trigger light-dismisses the open bubble, and the click that follows would open it again, so a
|
||||
// second press never closed it. A close the browser made this recently is taken as that press, as
|
||||
// menu.js's REOPEN_GUARD_MS does; `beforetoggle` times it, because `toggle` is queued past the click.
|
||||
const REOPEN_GUARD_MS = 250
|
||||
import { hoverPopover, LEAVE_DELAY_MS } from './tooltip.js'
|
||||
import { reopenGuard } from './util.js'
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialRichTooltip', (persistent = false) => ({
|
||||
timer: null,
|
||||
stop: null,
|
||||
|
||||
init() {
|
||||
const bubble = this.$refs.bubble
|
||||
@@ -59,16 +51,12 @@ document.addEventListener('alpine:init', () => {
|
||||
wrapper.addEventListener('pointerenter', describe)
|
||||
|
||||
if (persistent) {
|
||||
let dismissedAt = -Infinity
|
||||
let closingExplicitly = false
|
||||
// A persistent bubble is a `popover="auto"`, and its trigger is outside it: the
|
||||
// press on the trigger light-dismisses the open bubble, and the click that
|
||||
// follows would open it again, so a second press never closed it.
|
||||
const guard = reopenGuard()
|
||||
|
||||
bubble.addEventListener('beforetoggle', (event) => {
|
||||
if (event.newState === 'closed' && !closingExplicitly) {
|
||||
dismissedAt = performance.now()
|
||||
}
|
||||
|
||||
closingExplicitly = false
|
||||
})
|
||||
bubble.addEventListener('beforetoggle', (event) => guard.beforeToggle(event))
|
||||
|
||||
wrapper.addEventListener('click', (event) => {
|
||||
if (bubble.contains(event.target)) {
|
||||
@@ -76,9 +64,9 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
|
||||
if (open()) {
|
||||
closingExplicitly = true
|
||||
guard.closing()
|
||||
bubble.hidePopover()
|
||||
} else if (performance.now() - dismissedAt > REOPEN_GUARD_MS) {
|
||||
} else if (guard.canOpen()) {
|
||||
bubble.showPopover()
|
||||
}
|
||||
})
|
||||
@@ -86,25 +74,20 @@ document.addEventListener('alpine:init', () => {
|
||||
return
|
||||
}
|
||||
|
||||
const show = (delay) => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => !open() && bubble.showPopover(), delay)
|
||||
}
|
||||
const popover = hoverPopover(wrapper, {
|
||||
open,
|
||||
show: () => bubble.showPopover(),
|
||||
hide: () => bubble.hidePopover(),
|
||||
focusable: (event) => event.target.matches(':focus-visible'),
|
||||
})
|
||||
|
||||
const hide = (delay = 0) => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => open() && bubble.hidePopover(), delay)
|
||||
}
|
||||
wrapper.addEventListener('focusout', (event) => !wrapper.contains(event.relatedTarget) && popover.hide(LEAVE_DELAY_MS), { signal: popover.signal })
|
||||
|
||||
wrapper.addEventListener('pointerenter', (event) => event.pointerType === 'mouse' && show(HOVER_DELAY_MS))
|
||||
wrapper.addEventListener('pointerleave', () => hide(LEAVE_GRACE_MS))
|
||||
wrapper.addEventListener('focusin', (event) => !openingFocus(event) && event.target.matches(':focus-visible') && show(0))
|
||||
wrapper.addEventListener('focusout', (event) => !wrapper.contains(event.relatedTarget) && hide(LEAVE_GRACE_MS))
|
||||
document.addEventListener('keydown', (event) => event.key === 'Escape' && hide())
|
||||
this.stop = popover.destroy
|
||||
},
|
||||
|
||||
destroy() {
|
||||
clearTimeout(this.timer)
|
||||
this.stop?.()
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
+6
-15
@@ -41,15 +41,6 @@ const RETURN_GUARD_MS = 250
|
||||
// counting, so the live region speaks once.
|
||||
const SETTLE_MS = 120
|
||||
|
||||
/** The longest `transition-duration` + `transition-delay` on an element, in ms: zero under reduced motion. */
|
||||
const longestTransition = (element) => {
|
||||
const style = getComputedStyle(element)
|
||||
const ms = (value) => parseFloat(value) * (value.trim().endsWith('ms') ? 1 : 1000)
|
||||
const delays = style.transitionDelay.split(',').map(ms)
|
||||
|
||||
return Math.max(0, ...style.transitionDuration.split(',').map((duration, index) => ms(duration) + (delays[index % delays.length] || 0)))
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialSearch', (docked = false, announce = {}, trigger = 'bar') => ({
|
||||
open: false,
|
||||
@@ -162,9 +153,9 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
/**
|
||||
* Keeps the full-screen layout for the length of the view's exit. The closed state's
|
||||
* durations are read a frame on, once they are the ones computed; reopening, which counts
|
||||
* `leavings` up, lets a pending end go by.
|
||||
* Keeps the full-screen layout for the length of the view's exit: until the animations the
|
||||
* closed state starts, a frame on, have finished (at once when none run); reopening, which
|
||||
* counts `leavings` up, lets a pending end go by.
|
||||
*/
|
||||
hold() {
|
||||
const leaving = ++this.leavings
|
||||
@@ -172,13 +163,13 @@ document.addEventListener('alpine:init', () => {
|
||||
this.leaving = true
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const duration = this.$refs.view ? longestTransition(this.$refs.view) : 0
|
||||
const animations = this.$refs.view?.getAnimations() ?? []
|
||||
|
||||
setTimeout(() => {
|
||||
Promise.allSettled(animations.map((animation) => animation.finished)).then(() => {
|
||||
if (leaving === this.leavings) {
|
||||
this.leaving = false
|
||||
}
|
||||
}, duration)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
/**
|
||||
* Shared, DOM-free geometry: androidx's graphics-shapes (RoundedPolygon — a shape built from
|
||||
* vertices with per-corner rounding, turned into cubic Béziers) and its Morph (matching two
|
||||
* shapes' cubics so they can be interpolated). Used by bin/shapes.mjs and bin/loading-indicator.mjs
|
||||
* to build the shape and loading-indicator SVGs at commit time, and by progress.js to morph the
|
||||
* circular wavy progress indicator's star and circle at runtime.
|
||||
*
|
||||
* ---------------------------------------------------------------------------------------
|
||||
* Ported from androidx (https://github.com/androidx/androidx), commit
|
||||
* 27cf9a7d5788aa0f5f2d8b6699ce279560daf326:
|
||||
*
|
||||
* 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/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/Point.kt
|
||||
* graphics/graphics-shapes/src/commonMain/kotlin/androidx/graphics/shapes/PolygonMeasure.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
|
||||
*
|
||||
* Copyright 2022-2025 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
* ---------------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const DISTANCE_EPSILON = 1e-4
|
||||
const ANGLE_EPSILON = 1e-6
|
||||
|
||||
// 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 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))
|
||||
const radialToCartesian = (radius, angle) => point(Math.cos(angle) * radius, Math.sin(angle) * radius)
|
||||
const convex = (previous, current, next) => {
|
||||
const [a, b] = [minus(current, previous), minus(next, current)]
|
||||
|
||||
return a.x * b.y - a.y * b.x > 0
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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]
|
||||
|
||||
const straightLine = (x0, y0, x1, y1) => [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 clockwise = 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) *
|
||||
(clockwise ? 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
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
|
||||
|
||||
/** 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]))]
|
||||
}
|
||||
|
||||
/** RoundedPolygon.normalized: into the unit square, by the approximate (control point) bounds. */
|
||||
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
|
||||
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) })
|
||||
}
|
||||
|
||||
/** RoundedPolygon.circle(numVertices, radius): a regular polygon rounded all the way round. */
|
||||
function circlePolygon(numVertices = 8, radius = 1) {
|
||||
const polygonRadius = radius / Math.cos(Math.PI / numVertices)
|
||||
|
||||
return regularPolygon(numVertices, { radius: polygonRadius, cornerRounding: rounding(radius) })
|
||||
}
|
||||
|
||||
/** RoundedPolygon.star(numVerticesPerRadius, radius, innerRadius, rounding, innerRounding). */
|
||||
function star(numVerticesPerRadius, { radius = 1, innerRadius = 0.5, cornerRounding = UNROUNDED, innerCornerRounding = cornerRounding } = {}) {
|
||||
const vertices = []
|
||||
const roundings = []
|
||||
|
||||
for (let i = 0; i < numVerticesPerRadius; i++) {
|
||||
vertices.push(radialToCartesian(radius, (Math.PI / numVerticesPerRadius) * 2 * i), radialToCartesian(innerRadius, (Math.PI / numVerticesPerRadius) * (2 * i + 1)))
|
||||
roundings.push(cornerRounding, innerCornerRounding)
|
||||
}
|
||||
|
||||
return polygonFromVertices(vertices, { perVertexRounding: roundings, center: point(0, 0) })
|
||||
}
|
||||
|
||||
// Morph: FloatMapping.kt, PolygonMeasure.kt, FeatureMapping.kt, Morph.kt ----------------------
|
||||
|
||||
const positiveModulo = (num, mod) => ((num % mod) + mod) % mod
|
||||
const progressInRange = (progress, from, to) => (to >= from ? progress >= from && progress <= to : progress >= from || progress <= to)
|
||||
const progressDistance = (a, b) => Math.min(Math.abs(a - b), 1 - Math.abs(a - b))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
class MeasuredCubic {
|
||||
constructor(c, startOutlineProgress, endOutlineProgress) {
|
||||
if (endOutlineProgress < startOutlineProgress) {
|
||||
throw new Error('endOutlineProgress is expected to be equal or greater than startOutlineProgress')
|
||||
}
|
||||
|
||||
this.cubic = c
|
||||
this.startOutlineProgress = startOutlineProgress
|
||||
this.endOutlineProgress = endOutlineProgress
|
||||
this.measuredSize = closestProgressTo(c, Infinity)[1]
|
||||
}
|
||||
|
||||
cutAtProgress(cutOutlineProgress) {
|
||||
const bounded = clamp(cutOutlineProgress, this.startOutlineProgress, this.endOutlineProgress)
|
||||
const relativeProgress = (bounded - this.startOutlineProgress) / (this.endOutlineProgress - this.startOutlineProgress)
|
||||
const t = closestProgressTo(this.cubic, relativeProgress * this.measuredSize)[0]
|
||||
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((c, i) => {
|
||||
if (feature.type === 'corner' && i === Math.floor(feature.cubics.length / 2)) {
|
||||
featureToCubic.push([feature, cubics.length])
|
||||
}
|
||||
|
||||
cubics.push(c)
|
||||
})
|
||||
}
|
||||
|
||||
const measures = [0]
|
||||
|
||||
for (const c of cubics) {
|
||||
measures.push(measures.at(-1) + closestProgressTo(c, Infinity)[1])
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
function featureDistSquared(f1, f2) {
|
||||
if (f1.type === 'corner' && f2.type === 'corner' && f1.convex !== f2.convex) {
|
||||
return Infinity
|
||||
}
|
||||
|
||||
const representative = (feature) => {
|
||||
const [first, last] = [feature.cubics[0], feature.cubics.at(-1)]
|
||||
|
||||
return point((first[0] + last[6]) / 2, (first[1] + last[7]) / 2)
|
||||
}
|
||||
|
||||
const [p1, p2] = [representative(f1), representative(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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/** Morph.match: both shapes cut into pairs of matching cubics. */
|
||||
function match(p1, p2) {
|
||||
const measuredPolygon1 = MeasuredPolygon.measure(p1)
|
||||
const measuredPolygon2 = MeasuredPolygon.measure(p2)
|
||||
const corners = (features) => features.filter(({ feature }) => feature.type === 'corner')
|
||||
const mappings = doMapping(corners(measuredPolygon1.features), corners(measuredPolygon2.features))
|
||||
const [sources, targets] = [mappings.map((m) => m[0]), mappings.map((m) => m[1])]
|
||||
const map = (x) => linearMap(sources, targets, x)
|
||||
const mapBack = (x) => linearMap(targets, sources, x)
|
||||
const polygon2CutPoint = 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 : 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(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 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
|
||||
}
|
||||
|
||||
export {
|
||||
point,
|
||||
plus,
|
||||
minus,
|
||||
times,
|
||||
length,
|
||||
pointOnCurve,
|
||||
split,
|
||||
turningPoints,
|
||||
cubicBounds,
|
||||
rounding,
|
||||
UNROUNDED,
|
||||
polygonFromVertices,
|
||||
regularPolygon,
|
||||
circlePolygon,
|
||||
star,
|
||||
match,
|
||||
asCubics,
|
||||
}
|
||||
+21
-26
@@ -83,7 +83,6 @@ const TICK_MIN_SPACING = 8
|
||||
const TOUCH_SLOP = 8
|
||||
|
||||
const VALUE = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')
|
||||
const VALUE_AS_NUMBER = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'valueAsNumber')
|
||||
|
||||
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
|
||||
const near = (a, b) => Math.abs(a - b) < 1e-6
|
||||
@@ -206,6 +205,7 @@ function slider(root) {
|
||||
const drawing = root.querySelector('[data-md-slider-drawing]')
|
||||
const range = inputs.length > 1
|
||||
const cleanups = []
|
||||
const controller = new AbortController()
|
||||
let queued = false
|
||||
let release = null
|
||||
// Focus this script gave an input on a press, which draws no ring until a key is pressed.
|
||||
@@ -214,8 +214,7 @@ function slider(root) {
|
||||
let spaceHeld = false
|
||||
|
||||
const listen = (target, type, handler, capture = false) => {
|
||||
target.addEventListener(type, handler, capture)
|
||||
cleanups.push(() => target.removeEventListener(type, handler, capture))
|
||||
target.addEventListener(type, handler, { capture, signal: controller.signal })
|
||||
}
|
||||
|
||||
/** The inputs' min, max and step, as a native range reads them; `step` is null for `any`. */
|
||||
@@ -379,25 +378,20 @@ function slider(root) {
|
||||
const pressed = (index) => drawing?.querySelectorAll('[data-md-slider-handle]').forEach((thumb, i) => thumb.toggleAttribute('data-md-pressed', i === index))
|
||||
|
||||
inputs.forEach((input) => {
|
||||
// A value written by script fires no event: catch it on the input's own setters.
|
||||
for (const [property, descriptor] of [['value', VALUE], ['valueAsNumber', VALUE_AS_NUMBER]]) {
|
||||
Object.defineProperty(input, property, {
|
||||
configurable: true,
|
||||
get() {
|
||||
return descriptor.get.call(this)
|
||||
},
|
||||
set(value) {
|
||||
descriptor.set.call(this, value)
|
||||
schedule()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
cleanups.push(() => {
|
||||
delete input.value
|
||||
delete input.valueAsNumber
|
||||
// A value written by script fires no event: catch it on the input's own setter.
|
||||
Object.defineProperty(input, 'value', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return VALUE.get.call(this)
|
||||
},
|
||||
set(value) {
|
||||
VALUE.set.call(this, value)
|
||||
schedule()
|
||||
},
|
||||
})
|
||||
|
||||
cleanups.push(() => delete input.value)
|
||||
|
||||
listen(input, 'focus', () => showFocus(input, focusedByPointer !== input && input.matches(':focus-visible')))
|
||||
listen(input, 'blur', () => {
|
||||
focusedByPointer = null
|
||||
@@ -510,6 +504,7 @@ function slider(root) {
|
||||
return {
|
||||
destroy() {
|
||||
release?.()
|
||||
controller.abort()
|
||||
cleanups.forEach((cleanup) => cleanup())
|
||||
},
|
||||
|
||||
@@ -613,17 +608,17 @@ function slider(root) {
|
||||
})
|
||||
}
|
||||
|
||||
const dragController = new AbortController()
|
||||
|
||||
release = () => {
|
||||
window.removeEventListener('pointermove', move)
|
||||
window.removeEventListener('pointerup', end)
|
||||
window.removeEventListener('pointercancel', end)
|
||||
dragController.abort()
|
||||
pressed(null)
|
||||
release = null
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', move)
|
||||
window.addEventListener('pointerup', end)
|
||||
window.addEventListener('pointercancel', end)
|
||||
window.addEventListener('pointermove', move, { signal: dragController.signal })
|
||||
window.addEventListener('pointerup', end, { signal: dragController.signal })
|
||||
window.addEventListener('pointercancel', end, { signal: dragController.signal })
|
||||
|
||||
if (dragging) {
|
||||
follow(start)
|
||||
|
||||
+4
-10
@@ -17,6 +17,8 @@
|
||||
* `previewScheme(name)` shows another one on this page without storing anything — the
|
||||
* application saves a choice itself, and the next full load draws what it saved.
|
||||
*/
|
||||
import { remember } from './util.js'
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
const root = document.documentElement
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
@@ -53,11 +55,7 @@ document.addEventListener('alpine:init', () => {
|
||||
root.dataset.themeChoice = choice
|
||||
root.dataset.theme = this.resolved
|
||||
|
||||
try {
|
||||
localStorage.setItem(root.dataset.themeKey || 'material-theme', choice)
|
||||
} catch {
|
||||
// Blocked storage: the page still switches, it just will not remember.
|
||||
}
|
||||
remember(root.dataset.themeKey || 'material-theme', choice)
|
||||
},
|
||||
|
||||
toggle() {
|
||||
@@ -80,11 +78,7 @@ document.addEventListener('alpine:init', () => {
|
||||
root.dataset.contrast = this.resolvedContrast
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(root.dataset.contrastKey || 'material-contrast', choice)
|
||||
} catch {
|
||||
// Blocked storage: the page still switches, it just will not remember.
|
||||
}
|
||||
remember(root.dataset.contrastKey || 'material-contrast', choice)
|
||||
},
|
||||
|
||||
previewScheme(name) {
|
||||
|
||||
+14
-25
@@ -208,13 +208,16 @@ document.addEventListener('alpine:init', () => {
|
||||
return false
|
||||
},
|
||||
|
||||
/** The allowed time nearest to one that is not, searching outwards a minute at a time. */
|
||||
nearest(time) {
|
||||
/**
|
||||
* The allowed time nearest to one that is not, searching outwards a minute at a time; `pm`
|
||||
* limits the search to that half of the day, for a period switch that lands outside it.
|
||||
*/
|
||||
nearest(time, pm = null) {
|
||||
for (let distance = 0; distance <= 720; distance++) {
|
||||
for (const candidate of [time - distance, time + distance]) {
|
||||
const wrapped = (candidate + 1440) % 1440
|
||||
|
||||
if (this.allowed(Math.floor(wrapped / 60), wrapped % 60)) {
|
||||
if ((pm === null || (wrapped >= 720) === pm) && this.allowed(Math.floor(wrapped / 60), wrapped % 60)) {
|
||||
return wrapped
|
||||
}
|
||||
}
|
||||
@@ -370,7 +373,8 @@ document.addEventListener('alpine:init', () => {
|
||||
if (this.hourAllowed(hour)) {
|
||||
this.setHour(hour)
|
||||
} else {
|
||||
const time = this.nearestInPeriod(pm, hour * 60 + this.minute)
|
||||
const raw = hour * 60 + this.minute
|
||||
const time = this.nearest(raw, pm) ?? raw
|
||||
|
||||
this.hour = Math.floor(time / 60)
|
||||
this.minute = time % 60
|
||||
@@ -395,19 +399,6 @@ document.addEventListener('alpine:init', () => {
|
||||
event.currentTarget.querySelector(`[data-md-timepicker-period-option="${pm ? 'pm' : 'am'}"]`)?.focus()
|
||||
},
|
||||
|
||||
/** The allowed time in a period nearest to `time` (periodAllowed has said there is one). */
|
||||
nearestInPeriod(pm, time) {
|
||||
let best = null
|
||||
|
||||
for (let candidate = pm ? 720 : 0; candidate < (pm ? 1440 : 720); candidate++) {
|
||||
if (this.allowed(Math.floor(candidate / 60), candidate % 60) && (best === null || Math.abs(candidate - time) < Math.abs(best - time))) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
|
||||
return best ?? time
|
||||
},
|
||||
|
||||
// ---- The dial --------------------------------------------------------------------------
|
||||
|
||||
angleFor() {
|
||||
@@ -450,10 +441,10 @@ document.addEventListener('alpine:init', () => {
|
||||
this.pick(moveEvent, false)
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
const release = (upEvent, cancelled = false) => {
|
||||
dial.removeEventListener('pointermove', move)
|
||||
dial.removeEventListener('pointerup', release)
|
||||
dial.removeEventListener('pointercancel', abandon)
|
||||
controller.abort()
|
||||
this.dragging = false
|
||||
|
||||
if (cancelled) {
|
||||
@@ -473,11 +464,9 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const abandon = (cancelEvent) => release(cancelEvent, true)
|
||||
|
||||
dial.addEventListener('pointermove', move)
|
||||
dial.addEventListener('pointerup', release)
|
||||
dial.addEventListener('pointercancel', abandon)
|
||||
dial.addEventListener('pointermove', move, { signal: controller.signal })
|
||||
dial.addEventListener('pointerup', release, { signal: controller.signal })
|
||||
dial.addEventListener('pointercancel', (cancelEvent) => release(cancelEvent, true), { signal: controller.signal })
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
+6
-15
@@ -4,12 +4,13 @@
|
||||
* on a right-to-left page; Home and End to its ends). Every control stays in the Tab order, so a
|
||||
* control added by a Livewire render is reachable without any bookkeeping.
|
||||
*/
|
||||
import { ringIndex } from './util.js'
|
||||
|
||||
const CONTROLS = 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialToolbar', (vertical = false) => ({
|
||||
move(event) {
|
||||
const keys = vertical ? { ArrowUp: -1, ArrowDown: 1 } : { ArrowLeft: -1, ArrowRight: 1 }
|
||||
const controls = [...this.$root.querySelectorAll(CONTROLS)].filter((control) => control.getClientRects().length > 0)
|
||||
const at = controls.indexOf(document.activeElement)
|
||||
|
||||
@@ -17,22 +18,12 @@ document.addEventListener('alpine:init', () => {
|
||||
return
|
||||
}
|
||||
|
||||
let next = null
|
||||
const rtl = !vertical && getComputedStyle(this.$root).direction === 'rtl'
|
||||
const next = ringIndex(event.key, at, controls.length, { rtl, vertical })
|
||||
|
||||
if (event.key === 'Home') {
|
||||
next = controls[0]
|
||||
} else if (event.key === 'End') {
|
||||
next = controls.at(-1)
|
||||
} else if (event.key in keys) {
|
||||
const rtl = !vertical && getComputedStyle(this.$root).direction === 'rtl'
|
||||
const by = keys[event.key] * (rtl ? -1 : 1)
|
||||
|
||||
next = controls[(at + by + controls.length) % controls.length]
|
||||
}
|
||||
|
||||
if (next) {
|
||||
if (next !== null) {
|
||||
event.preventDefault()
|
||||
next.focus()
|
||||
controls[next].focus()
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
+51
-34
@@ -13,25 +13,58 @@
|
||||
import { openingFocus } from './layers.js'
|
||||
|
||||
const HOVER_DELAY_MS = 500
|
||||
const LEAVE_DELAY_MS = 1500
|
||||
export const LEAVE_DELAY_MS = 1500
|
||||
|
||||
let visible = null
|
||||
|
||||
/**
|
||||
* The hover/focus/Escape timing a tooltip and a transient rich tooltip (rich-tooltip.js) share:
|
||||
* shows after HOVER_DELAY_MS on a pointer that can hover, at once on a focus `focusable` accepts
|
||||
* (never the focus layers.js moves as a sheet or dialog opens), hides after LEAVE_DELAY_MS once
|
||||
* the pointer leaves, and at once on Escape. `open`, `show` and `hide` do the actual popover call;
|
||||
* a caller adds its own extra listeners (a press, a blur) on the returned `signal`, and calls
|
||||
* `hide` from them.
|
||||
*/
|
||||
export function hoverPopover(el, { open, show, hide, focusable }) {
|
||||
const controller = new AbortController()
|
||||
const { signal } = controller
|
||||
let timer = null
|
||||
|
||||
const delayed = (action, delay) => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(action, delay)
|
||||
}
|
||||
|
||||
const showLater = (delay) => delayed(() => !open() && show(), delay)
|
||||
const hideLater = (delay = 0) => delayed(() => open() && hide(), delay)
|
||||
|
||||
el.addEventListener('pointerenter', (event) => event.pointerType === 'mouse' && showLater(HOVER_DELAY_MS), { signal })
|
||||
el.addEventListener('pointerleave', () => hideLater(LEAVE_DELAY_MS), { signal })
|
||||
el.addEventListener('focusin', (event) => !openingFocus(event) && focusable(event) && showLater(0), { signal })
|
||||
document.addEventListener('keydown', (event) => event.key === 'Escape' && hideLater(), { signal })
|
||||
|
||||
return {
|
||||
hide: hideLater,
|
||||
signal,
|
||||
destroy() {
|
||||
clearTimeout(timer)
|
||||
controller.abort()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('materialTooltip', () => ({
|
||||
timer: null,
|
||||
listeners: [],
|
||||
popover: null,
|
||||
|
||||
init() {
|
||||
const tip = this.$el
|
||||
const trigger = tip.parentElement
|
||||
|
||||
const open = () => tip.matches(':popover-open')
|
||||
|
||||
const show = (delay) => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
if (!tip.isConnected || open()) {
|
||||
const popover = (this.popover = hoverPopover(trigger, {
|
||||
open: () => tip.matches(':popover-open'),
|
||||
show: () => {
|
||||
if (!tip.isConnected) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -41,43 +74,27 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
visible = tip
|
||||
tip.showPopover()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const hide = (delay = 0) => {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
if (open()) {
|
||||
tip.hidePopover()
|
||||
}
|
||||
},
|
||||
hide: () => {
|
||||
tip.hidePopover()
|
||||
|
||||
if (visible === tip) {
|
||||
visible = null
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
},
|
||||
focusable: () => trigger.matches(':focus-within:has(:focus-visible), :focus-visible'),
|
||||
}))
|
||||
|
||||
this.listen(trigger, 'pointerenter', (event) => event.pointerType === 'mouse' && show(HOVER_DELAY_MS))
|
||||
this.listen(trigger, 'pointerleave', () => hide(LEAVE_DELAY_MS))
|
||||
this.listen(trigger, 'pointerdown', () => hide())
|
||||
this.listen(trigger, 'focusin', (event) => !openingFocus(event) && trigger.matches(':focus-within:has(:focus-visible), :focus-visible') && show(0))
|
||||
this.listen(trigger, 'focusout', () => hide(LEAVE_DELAY_MS))
|
||||
this.listen(document, 'keydown', (event) => event.key === 'Escape' && hide())
|
||||
},
|
||||
|
||||
listen(target, type, handler) {
|
||||
target.addEventListener(type, handler)
|
||||
this.listeners.push(() => target.removeEventListener(type, handler))
|
||||
trigger.addEventListener('pointerdown', () => popover.hide(), { signal: popover.signal })
|
||||
trigger.addEventListener('focusout', () => popover.hide(LEAVE_DELAY_MS), { signal: popover.signal })
|
||||
},
|
||||
|
||||
destroy() {
|
||||
clearTimeout(this.timer)
|
||||
this.popover.destroy()
|
||||
|
||||
if (visible === this.$el) {
|
||||
visible = null
|
||||
}
|
||||
|
||||
this.listeners.forEach((remove) => remove())
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Small pieces shared by more than one component, in the pattern of breakpoints.js: a plain
|
||||
* function or two, not a framework.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The index an Arrow/Home/End key moves to from `index` (which may be -1, nothing chosen yet),
|
||||
* wrapping around the ends; null when the key means nothing here. `vertical` picks Up/Down over
|
||||
* Left/Right; `rtl` mirrors Left/Right (Up/Down never mirror). A caller that answers to both axes
|
||||
* at once — a chip set's row, which also takes Up/Down — calls this twice and takes whichever
|
||||
* answers.
|
||||
*/
|
||||
export function ringIndex(key, index, length, { rtl = false, vertical = false } = {}) {
|
||||
if (key === 'Home') {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (key === 'End') {
|
||||
return length - 1
|
||||
}
|
||||
|
||||
const forward = vertical ? 'ArrowDown' : rtl ? 'ArrowLeft' : 'ArrowRight'
|
||||
const backward = vertical ? 'ArrowUp' : rtl ? 'ArrowRight' : 'ArrowLeft'
|
||||
|
||||
if (key === forward) {
|
||||
return (index + 1 + length) % length
|
||||
}
|
||||
|
||||
if (key === backward) {
|
||||
return index < 0 ? length - 1 : (index - 1 + length) % length
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** A CSS custom property's time value in milliseconds, whichever unit it is written in; null when unset. */
|
||||
export function ms(element, token) {
|
||||
const value = getComputedStyle(element).getPropertyValue(token).trim()
|
||||
const number = parseFloat(value)
|
||||
|
||||
if (Number.isNaN(number)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return value.endsWith('ms') ? number : number * 1000
|
||||
}
|
||||
|
||||
/** Writes to localStorage, quietly: a blocked or full store still lets the page work, it just won't remember. */
|
||||
export function remember(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// Blocked storage: the page still works, it just will not remember.
|
||||
}
|
||||
}
|
||||
|
||||
const REOPEN_GUARD_MS = 250
|
||||
|
||||
/**
|
||||
* A `popover="auto"` closes on the press that lands on its trigger, and the click that follows
|
||||
* would open it again. A close this recent is taken as that press. It is timed from
|
||||
* `beforetoggle`, which fires as the popover closes: `toggle` is queued, and arrives after that
|
||||
* click. Only a close the browser made on its own (light dismiss) counts — `closing()` marks one
|
||||
* this script makes as explicit, so a keyboard command that shuts it is never mistaken for that
|
||||
* race, only the browser's own light dismiss racing the click it precedes is.
|
||||
*/
|
||||
export function reopenGuard() {
|
||||
let closedAt = -Infinity
|
||||
let closingExplicitly = false
|
||||
|
||||
return {
|
||||
beforeToggle(event) {
|
||||
if (event.newState === 'closed' && !closingExplicitly) {
|
||||
closedAt = performance.now()
|
||||
}
|
||||
|
||||
closingExplicitly = false
|
||||
},
|
||||
|
||||
closing() {
|
||||
closingExplicitly = true
|
||||
},
|
||||
|
||||
canOpen() {
|
||||
return performance.now() - closedAt > REOPEN_GUARD_MS
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -6,13 +6,13 @@
|
||||
`color` (alias `tone`): `info` (the default), `success`, `warning`, `error`, `primary`,
|
||||
`secondary`, `tertiary`, or `neutral` for surface-container-high. `icon` replaces the state's
|
||||
icon; `:icon="false"` drops it. `dismissible` adds a close button that hides it in the browser,
|
||||
40px drawn with the 48px target M3 asks for (ACT-18).
|
||||
40px drawn with the 48px target M3 asks for.
|
||||
|
||||
It is a `role="status"`, whatever its colour: an alert is usually part of the page as it
|
||||
renders, and `role="alert"` is an assertive live region that some screen readers announce over
|
||||
the page title on load — and that a Livewire morph re-announces. `assertive` opts into
|
||||
`role="alert"` for the case the prop is named after: a notice put on screen in answer to
|
||||
something the person just did (ACT-36). M3 publishes no banner; the nearest thing it does
|
||||
something the person just did. M3 publishes no banner; the nearest thing it does
|
||||
publish, the snackbar, says polite and never assertive.
|
||||
|
||||
Drawn by resources/css/components/alert.css from `data-md-alert` and `data-md-color`; the
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
default => 'outline',
|
||||
};
|
||||
|
||||
$attributes = $attributes->merge(array_filter([
|
||||
$attributes = $attributes->merge([
|
||||
'data-md-badge' => true,
|
||||
'data-md-dot' => $dot ? true : null,
|
||||
'data-md-variant' => $variant,
|
||||
@@ -74,7 +74,7 @@
|
||||
'data-md-floating' => $floating ? true : null,
|
||||
'aria-label' => $label,
|
||||
'aria-hidden' => $label === null && ! $status ? 'true' : null,
|
||||
], fn ($value): bool => $value !== null));
|
||||
]);
|
||||
@endphp
|
||||
|
||||
<span {{ $attributes }}>@unless ($dot)@if ($markup){{ $slot }}@else{{ $text }}@endif@endunless</span>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
The handle's wrapper is the drag target and M3's 48dp hit area: the button's own visual bar is
|
||||
32×4px, and `md-touch-target` (foundation/interaction.css) extends it, while the wrapper's own
|
||||
22px top/bottom padding (`SheetDefaults.kt`'s `DragHandleVerticalPadding`) gives the row its
|
||||
M3-specified height regardless — 4 + 22 + 22 = 48px (C-01). The button renders the shared
|
||||
M3-specified height regardless — 4 + 22 + 22 = 48px. The button renders the shared
|
||||
`md-focus-ring` and `md-touch-target` classes rather than a hand-rolled ring: unlike the
|
||||
datepicker's day, its own visible bar is not a smaller indicator drawn inside a bigger box the
|
||||
classes cannot reach — the box the classes draw is the button's whole hit area.
|
||||
@@ -95,7 +95,7 @@
|
||||
|
||||
<div
|
||||
x-data="{
|
||||
...materialBottomSheet({{ $standard ? 'true' : 'false' }}, {{ $presets }}),
|
||||
...materialBottomSheet({{ $presets }}),
|
||||
@if ($model !== null) open: @entangle($attributes->wire('model')).live, @endif
|
||||
}"
|
||||
x-bind:data-md-open="open ? '' : null"
|
||||
|
||||
@@ -129,10 +129,7 @@
|
||||
// Read from the prop, not from `:disabled`: a spinner disables its button while the action
|
||||
// runs, and that FAB is busy, not unavailable.
|
||||
'data-md-unavailable' => $fab && $disabled ? true : null,
|
||||
'href' => $isLink ? $link : null,
|
||||
'target' => $isLink && $external ? '_blank' : null,
|
||||
'rel' => $isLink && $external ? 'noopener' : null,
|
||||
'wire:navigate' => $isLink && ! $external && ! $noWireNavigate && ! $attributes->has('wire:navigate') ? true : null,
|
||||
...\NoNameWeb\LivewireMaterial\Support\Link::attributes($isLink, $link, $external, $attributes, $noWireNavigate),
|
||||
'aria-disabled' => $inert ? 'true' : null,
|
||||
'tabindex' => $inert ? '-1' : null,
|
||||
'type' => $isLink ? null : $type,
|
||||
|
||||
@@ -33,8 +33,7 @@
|
||||
lands on the card itself. Elevation is separate from the tint — M3 gives each variant its own
|
||||
per-state box-shadow (elevated 1dp rest → 2dp hover → 1dp focus/pressed; filled and outlined
|
||||
0dp rest → 1dp hover → 0dp focus/pressed) — so it is drawn directly on `[data-md-card]`, never
|
||||
inside the shared class. Its corner does not move, because
|
||||
M3 gives a card one shape (docs/audits/m3-alignment/containment.md, C-13, C-23).
|
||||
inside the shared class. Its corner does not move, because M3 gives a card one shape.
|
||||
|
||||
`data-md-dragged` is M3's dragged card, row or not: the top of a card's elevation scale — 8dp
|
||||
elevated, 6dp filled and outlined — under the shared class's own 16% dragged tint (it already
|
||||
@@ -68,14 +67,14 @@
|
||||
@endphp
|
||||
|
||||
<div
|
||||
{{ $attributes->merge(array_filter([
|
||||
{{ $attributes->merge([
|
||||
'data-md-card' => $variant,
|
||||
'data-md-list-row' => $actionable ? true : null,
|
||||
'data-md-list-actionable' => $actionable ? true : null,
|
||||
'tabindex' => $actionable ? '0' : null,
|
||||
'role' => $role,
|
||||
'aria-label' => $actionable && filled($title) ? $title : null,
|
||||
], fn ($value): bool => $value !== null))->class([
|
||||
])->class([
|
||||
// Every card: the application sets `data-md-dragged` from script, after this renders.
|
||||
'md-state-layer',
|
||||
'md-focus-ring' => $isRow,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
for under text on an image, and the scrim is `--md-sys-color-scrim` — black in every scheme,
|
||||
theme and contrast level — so its ink is the one absolute the theme keeps, `white`; a role
|
||||
(`inverse-on-surface`) is dark ink in a dark scheme and fails on the scrim there
|
||||
(docs/audits/m3-alignment/containment.md, C-25, deliberately kept).
|
||||
(deliberately kept).
|
||||
|
||||
A focusable `group` with `aria-roledescription="slide"`, named "n of m" by the carousel
|
||||
around it. M3 puts the tab stop on the item, not on the row: Tab reaches the first item, the
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
$paddingStart = $vertical ? 0.0 : $padding;
|
||||
$paddingEnd = $vertical || $layout === 'uncontained' || $multiAspect ? 0.0 : $padding;
|
||||
|
||||
$attributes = $attributes->merge(array_filter([
|
||||
$attributes = $attributes->merge([
|
||||
'data-md-carousel' => $layout,
|
||||
'data-md-centered' => $layout === 'hero' && $centered ? true : null,
|
||||
'data-md-padding' => (string) $paddingStart,
|
||||
@@ -153,7 +153,7 @@
|
||||
$multiAspect ? "--material-carousel-pad: {$paddingStart}px" : null,
|
||||
'--material-carousel-height: '.($cssLength($height) ?? '205px'),
|
||||
])),
|
||||
], fn ($value): bool => $value !== null));
|
||||
]);
|
||||
@endphp
|
||||
|
||||
<div x-data="materialCarousel" {{ $attributes }}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user