From cd64f4f3711605580f08f97c450b2f8d820bb2ea Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Sun, 13 Sep 2026 06:09:28 +0200 Subject: [PATCH] Add buttons, menus and the rest of M3 Expressive's actions (label buttons, icon buttons and toggles in five sizes, with filled, tonal, outlined, elevated and text variants in any colour role), , with items, groups and separators, , as a connected button group, , , and . Sizes, colours and shapes come from androidx Compose Material 3's tokens; the loading indicator ports its Morph into SVG + SMIL. Menus follow WAI-ARIA's menu button pattern on popovers placed by CSS anchor positioning. Browser tests run in Chromium, Firefox and WebKit. The showcase fetches the icon names on demand: inlined, they tripped Pest's test server into HTTP 431s under Firefox. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy --- NOTICE | 5 + bin/loading-indicator.mjs | 697 ++++++++++++++++++ bin/shapes.mjs | 32 +- docs/plans/livewire-material.md | 75 +- package.json | 3 +- .../livewire-material-development/SKILL.md | 106 +++ resources/css/components/groups.css | 84 +++ resources/css/material.css | 1 + resources/js/material.js | 2 + resources/js/menu.js | 172 +++++ resources/js/tooltip.js | 57 ++ .../svg/loading-indicator/indeterminate.svg | 1 + resources/svg/loading-indicator/static.svg | 1 + .../views/components/button-group.blade.php | 42 ++ resources/views/components/button.blade.php | 226 ++++++ .../views/components/fab-menu-item.blade.php | 47 ++ resources/views/components/fab-menu.blade.php | 77 ++ resources/views/components/fab.blade.php | 78 ++ resources/views/components/group.blade.php | 97 +++ resources/views/components/loading.blade.php | 43 ++ .../views/components/menu-group.blade.php | 9 + .../views/components/menu-item.blade.php | 81 ++ .../views/components/menu-separator.blade.php | 3 + resources/views/components/menu.blade.php | 68 ++ .../views/components/split-button.blade.php | 71 ++ resources/views/components/tooltip.blade.php | 51 ++ .../showcase/components/example.blade.php | 19 + resources/views/showcase/index.blade.php | 2 + resources/views/showcase/layout.blade.php | 4 +- .../views/showcase/sections/buttons.blade.php | 139 ++++ .../views/showcase/sections/icons.blade.php | 9 +- .../views/showcase/sections/menus.blade.php | 53 ++ routes/showcase.php | 3 +- .../Controllers/ShowcaseSymbolController.php | 13 +- src/LivewireMaterialServiceProvider.php | 8 +- src/Support/SvgFile.php | 8 + tests/Browser/ActionsTest.php | 125 ++++ tests/Browser/ThemeTest.php | 1 + tests/Feature/Components/ButtonGroupTest.php | 85 +++ tests/Feature/Components/ButtonTest.php | 158 ++++ tests/Feature/Components/FabTest.php | 46 ++ tests/Feature/Components/LoadingTest.php | 50 ++ tests/Feature/Components/MenuTest.php | 71 ++ tests/Feature/Components/SplitButtonTest.php | 43 ++ tests/Feature/Components/TooltipTest.php | 29 + tests/Feature/ShowcaseTest.php | 9 + 46 files changed, 2968 insertions(+), 36 deletions(-) create mode 100644 bin/loading-indicator.mjs create mode 100644 resources/css/components/groups.css create mode 100644 resources/js/menu.js create mode 100644 resources/js/tooltip.js create mode 100644 resources/svg/loading-indicator/indeterminate.svg create mode 100644 resources/svg/loading-indicator/static.svg create mode 100644 resources/views/components/button-group.blade.php create mode 100644 resources/views/components/button.blade.php create mode 100644 resources/views/components/fab-menu-item.blade.php create mode 100644 resources/views/components/fab-menu.blade.php create mode 100644 resources/views/components/fab.blade.php create mode 100644 resources/views/components/group.blade.php create mode 100644 resources/views/components/loading.blade.php create mode 100644 resources/views/components/menu-group.blade.php create mode 100644 resources/views/components/menu-item.blade.php create mode 100644 resources/views/components/menu-separator.blade.php create mode 100644 resources/views/components/menu.blade.php create mode 100644 resources/views/components/split-button.blade.php create mode 100644 resources/views/components/tooltip.blade.php create mode 100644 resources/views/showcase/components/example.blade.php create mode 100644 resources/views/showcase/sections/buttons.blade.php create mode 100644 resources/views/showcase/sections/menus.blade.php create mode 100644 tests/Browser/ActionsTest.php create mode 100644 tests/Feature/Components/ButtonGroupTest.php create mode 100644 tests/Feature/Components/ButtonTest.php create mode 100644 tests/Feature/Components/FabTest.php create mode 100644 tests/Feature/Components/LoadingTest.php create mode 100644 tests/Feature/Components/MenuTest.php create mode 100644 tests/Feature/Components/SplitButtonTest.php create mode 100644 tests/Feature/Components/TooltipTest.php diff --git a/NOTICE b/NOTICE index d69f9b45..b2a6e79f 100644 --- a/NOTICE +++ b/NOTICE @@ -12,6 +12,11 @@ M3 Expressive shapes (resources/svg/shapes) graphics-shapes, as recorded at the top of bin/shapes.mjs. Copyright The Android Open Source Project. Apache License 2.0. +M3 Expressive loading indicator (resources/svg/loading-indicator) + Morph and LoadingIndicator ported from androidx graphics-shapes and Compose Material 3, as + recorded at the top of bin/loading-indicator.mjs. + Copyright The Android Open Source Project. Apache License 2.0. + Google Sans Flex (resources/fonts/google-sans-flex) Copyright Google LLC. SIL Open Font License 1.1 (resources/fonts/google-sans-flex/OFL.txt). Subset: Latin and Latin Extended, weight 400–700, roundness 0–100. diff --git a/bin/loading-indicator.mjs b/bin/loading-indicator.mjs new file mode 100644 index 00000000..0f4b6085 --- /dev/null +++ b/bin/loading-indicator.mjs @@ -0,0 +1,697 @@ +/** + * Regenerates resources/svg/loading-indicator: the Material 3 Expressive loading indicator. + * + * Run from the repository root with `npm run build:loading` (or `node bin/loading-indicator.mjs`). + * Maintenance only: plain Node 22+, no dependencies, and the output is deterministic, so running + * it twice changes nothing. The shape geometry comes from bin/shapes.mjs. + * + * indeterminate.svg the animated indicator: pure SVG + SMIL, no script, no ids. It is safe to + * inline any number of times on one page; there is no placeholder to replace. + * static.svg the first frame of that animation, for prefers-reduced-motion. + * + * What androidx's indeterminate LoadingIndicator draws, and how it is reproduced here: + * + * - Seven MaterialShapes morph into one another in a loop (SoftBurst, Cookie9Sided, Pentagon, Pill, + * Sunny, Cookie4Sided, Oval, back to SoftBurst). Every 650 ms a morph starts; its progress follows + * a spring (damping ratio 0.6, stiffness 200) that overshoots to ~1.095 at ~278 ms and settles. + * Each morph is androidx's `Morph` (feature-matched cubics, ported below), and since a morph is a + * plain lerp of matched control points, SMIL can interpolate it: every morph gets its own + * whose `d` runs start → overshoot → end. Compose ends the spring at its 0.1 visibility threshold + * (~297 ms, still ~9% past the target) and snaps; here the same spring is followed to rest over + * the 650 ms instead, which removes that one-frame snap. The spring is fitted with two keySplines. + * - Each morph path repeats on a 4550 ms cycle that begins at its slot (650 ms × index). Until its + * slot it has no `d` (first cycle) or a point (later cycles), so it draws nothing. When its morph + * ends, the next path starts from the very same shape, and the finished path shrinks towards the + * centre — within one frame to about half its size, then on to a point. Every shape here is + * star-shaped about its centre, so a shrunken copy lies inside the shape the next morph starts + * from, and (checked while writing this) stays at least 0.18 units inside every later frame of + * it: the copy is always covered and never seen. No visibility switching, no ids, no seam. + * - The shape turns +90° per morph on the same spring (`morphRotationTargetAngle`, starting at 90°), + * a 2600 ms cycle of four morphs that ends where it began (450° = 90°), and the whole indicator + * turns 360° every 4666 ms, linearly. Nested rotations about the centre add up, as in Compose. + * - The shapes are sized as `calculateScaleFactor` does (so they look alike while rotating) and + * scaled by ActiveIndicatorScale (38 / 48) into the 48 × 48 container; like `processPath`, every + * drawn path is re-centred on its bounds. Compose takes the control-point bounds, which differ + * between two morphs' subdivisions of the same shape and nudge it by up to 0.18 units when one + * morph hands over to the next; the exact bounds used here are the same for both, so it holds still. + * + * --------------------------------------------------------------------------------------- + * Ported from androidx (https://github.com/androidx/androidx), commit + * 27cf9a7d5788aa0f5f2d8b6699ce279560daf326: + * + * 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) + * + * Copyright 2022-2024 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. + * --------------------------------------------------------------------------------------- + */ +import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { cubicBounds, point, pointOnCurve, split, SHAPES } from './shapes.mjs' + +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'] +const CONTAINER = 48 +const ACTIVE_SIZE = 38 +const MORPH_INTERVAL = 650 +const GLOBAL_ROTATION_DURATION = 4666 +const QUARTER_ROTATION = 90 +const SPRING = { dampingRatio: 0.6, stiffness: 200 } + +/** + * How long (ms) before the next morph starts the finished path begins to shrink, so it is already + * inside the incoming shape and the two outlines do not coincide (which would draw anti-aliased + * edges twice). Chromium and WebKit solve keySplines only to about 1/(200 × dur), so there the + * shrink shows ~0.4 ms late; a frame in that window merely has slightly bolder edges. A longer lead + * would instead let the shape visibly dip before the next path takes over. + */ +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. */ +function maxBounds(polygon) { + const { x, y } = polygon.center + let maxDistSquared = 0 + + for (const c of polygon.cubics) { + const middle = pointOnCurve(c, 0.5) + const anchorDistance = (c[0] - x) ** 2 + (c[1] - y) ** 2 + const middleDistance = (middle.x - x) ** 2 + (middle.y - y) ** 2 + maxDistSquared = Math.max(maxDistSquared, anchorDistance, middleDistance) + } + + const distance = Math.sqrt(maxDistSquared) + + return [x - distance, y - distance, x + distance, y + distance] +} + +function calculateScaleFactor(polygons) { + let scaleFactor = 1 + + for (const polygon of polygons) { + const [left, top, right, bottom] = polygon.bounds(true) + const [maxLeft, maxTop, maxRight, maxBottom] = maxBounds(polygon) + const scaleX = (right - left) / (maxRight - maxLeft) + const scaleY = (bottom - top) / (maxBottom - maxTop) + scaleFactor = Math.min(scaleFactor, Math.max(scaleX, scaleY)) + } + + return scaleFactor +} + +/** processPath: scales the normalised cubics into the container and centres their exact bounds. */ +function processPath(cubics, scale) { + const scaled = cubics.map((c) => c.map((value) => value * scale)) + const bounds = scaled.map((c) => cubicBounds(c, false)) + const dx = CONTAINER / 2 - (Math.min(...bounds.map((b) => b[0])) + Math.max(...bounds.map((b) => b[2]))) / 2 + const dy = CONTAINER / 2 - (Math.min(...bounds.map((b) => b[1])) + Math.max(...bounds.map((b) => b[3]))) / 2 + + return scaled.map((c) => c.map((value, i) => value + (i % 2 === 0 ? dx : dy))) +} + +/** DrawScope.rotate about the container centre, clockwise on screen. */ +function rotated(cubics, degrees) { + const r = degrees * (Math.PI / 180) + const [s, c, o] = [Math.sin(r), Math.cos(r), CONTAINER / 2] + + return cubics.map((cubic) => + cubic.map((value, i) => { + const [x, y] = i % 2 === 0 ? [value - o, cubic[i + 1] - o] : [cubic[i - 1] - o, value - o] + + return i % 2 === 0 ? o + c * x - s * y : o + s * x + c * y + }), + ) +} + +// SpringSimulation.kt and the keySplines that stand in for it --------------------------- + +/** The spring's value `ms` after it starts from 0 at rest towards 1 (the underdamped branch of updateValues). */ +function spring(ms) { + const naturalFreq = Math.sqrt(SPRING.stiffness) + const r = -SPRING.dampingRatio * naturalFreq + const dampedFreq = naturalFreq * Math.sqrt(1 - SPRING.dampingRatio ** 2) + const t = ms / 1000 + + return 1 + Math.exp(r * t) * (-Math.cos(dampedFreq * t) + ((-r * -1) / dampedFreq) * Math.sin(dampedFreq * t)) +} + +/** When the spring peaks: half a period of its damped oscillation. */ +const PEAK_TIME = (Math.PI / (Math.sqrt(SPRING.stiffness) * Math.sqrt(1 - SPRING.dampingRatio ** 2))) * 1000 +const PEAK = spring(PEAK_TIME) + +function bezier(p1, p2, s) { + const u = 1 - s + + return 3 * u * u * s * p1 + 3 * u * s * s * p2 + s * s * s +} + +/** A keySpline's output at input `x`, finding the curve parameter by bisection (x is monotonic in it). */ +function keySpline([x1, y1, x2, y2], x) { + let lo = 0 + let hi = 1 + + for (let i = 0; i < 50; i++) { + const mid = (lo + hi) / 2 + + if (bezier(x1, x2, mid) < x) { + lo = mid + } else { + hi = mid + } + } + + return bezier(y1, y2, (lo + hi) / 2) +} + +/** + * Least-squares keySpline for one stretch of the spring, mapped onto 0–1 in time and value, by a + * deterministic pattern search over the four control values (all kept in 0–1, as SMIL requires). + */ +function fitKeySpline(fromMs, toMs, fromValue, toValue) { + const samples = Array.from({ length: 101 }, (_, i) => { + const x = i / 100 + + return [x, (spring(fromMs + x * (toMs - fromMs)) - fromValue) / (toValue - fromValue)] + }) + const error = (spline) => samples.reduce((sum, [x, y]) => sum + (keySpline(spline, x) - y) ** 2, 0) + let spline = [1 / 3, 1 / 3, 2 / 3, 2 / 3] + let best = error(spline) + + for (let step = 0.25; step > 1e-5; step /= 2) { + let improved = true + + while (improved) { + improved = false + + for (let i = 0; i < 4; i++) { + for (const delta of [step, -step]) { + const candidate = spline.with(i, Math.min(1, Math.max(0, spline[i] + delta))) + const candidateError = error(candidate) + + if (candidateError < best - 1e-12) { + spline = candidate + best = candidateError + improved = true + } + } + } + } + } + + return spline +} + +// SVG ----------------------------------------------------------------------------------- + +/** Precision of every coordinate: hundredths of a container unit. */ +const PRECISION = 100 + +function decimal(value, places) { + return String(Number(value.toFixed(places))).replace(/^(-?)0\./, '$1.') +} + +/** Joins numbers, leaving out the separator where a sign or a second decimal point already splits them. */ +function joinNumbers(numbers) { + let out = '' + + for (const text of numbers) { + const previous = out.slice(out.lastIndexOf(' ') + 1) + + if (out === '' || text.startsWith('-') || (text.startsWith('.') && previous.includes('.'))) { + out += text + } else { + out += ` ${text}` + } + } + + return out +} + +/** + * Path data in hundredths relative to the current point, rounded as absolute positions first so the + * rounding never drifts along the outline. `format` writes one count of hundredths. + */ +function pathData(cubics, format) { + const points = cubics.map((c) => c.map((value) => Math.round(value * PRECISION))) + const numbers = [] + let [x, y] = [points[0][0], points[0][1]] + + for (const c of points) { + numbers.push(c[2] - x, c[3] - y, c[4] - x, c[5] - y, c[6] - x, c[7] - y) + ;[x, y] = [c[6], c[7]] + } + + return `M${joinNumbers([points[0][0], points[0][1]].map(format))}c${joinNumbers(numbers.map(format))}Z` +} + +/** Hundredths as decimals, for a path drawn in container units. */ +const decimalHundredths = (n) => decimal(n / PRECISION, 2) + +/** + * Hundredths as integers, for a path drawn inside `scale(.01)`: the same precision as two decimals, + * and about a sixth shorter. + */ +const integerHundredths = (n) => String(n) + +/** The same command structure as a path of `count` cubics, collapsed onto the container centre. */ +function collapsedPathData(count) { + const centre = (CONTAINER / 2) * PRECISION + + return `M${centre} ${centre}c${Array(count * 6).fill(0).join(' ')}Z` +} + +const time = (value) => decimal(value, 6) +const splineText = (spline) => spline.map((value) => decimal(value, 3)).join(' ') + +const polygons = SEQUENCE.map((name) => SHAPES[name]().normalized()) +const scale = CONTAINER * calculateScaleFactor(polygons) * (ACTIVE_SIZE / CONTAINER) +const rise = fitKeySpline(0, PEAK_TIME, 0, PEAK) +const settle = fitKeySpline(PEAK_TIME, MORPH_INTERVAL, PEAK, 1) +const cycle = MORPH_INTERVAL * SEQUENCE.length +const riseTime = time(PEAK_TIME / cycle) +const collapseTime = decimal(Math.floor(((MORPH_INTERVAL - HANDOVER_LEAD) / cycle) * 1e7) / 1e7, 7) + +const morphPaths = SEQUENCE.map((_, index) => { + const pairs = match(polygons[index], polygons[(index + 1) % SEQUENCE.length]) + const values = [0, PEAK, 1].map((progress) => + pathData(processPath(asCubics(pairs, progress), scale), integerHundredths), + ) + + return ( + `` + ) +}) + +/** Four morphs of +90° bring the shape back to its starting angle. */ +const rotationMorphs = 4 +const rotationKeyTimes = [] +const rotationValues = [] + +for (let i = 0; i < rotationMorphs; i++) { + const angle = QUARTER_ROTATION * (i + 1) + rotationKeyTimes.push(i / rotationMorphs, (i + PEAK_TIME / MORPH_INTERVAL) / rotationMorphs) + rotationValues.push(angle, angle + QUARTER_ROTATION * PEAK) +} + +rotationKeyTimes.push(1) +rotationValues.push(QUARTER_ROTATION * (rotationMorphs + 1)) + +const centre = `${CONTAINER / 2} ${CONTAINER / 2}` +const morphRotation = + `` +const globalRotation = + `` +const firstFrame = rotated(processPath(polygons[0].cubics, scale), QUARTER_ROTATION) + +const open = `` +const indicator = `${morphPaths.join('')}` +const files = { + 'indeterminate.svg': `${open}${globalRotation}${morphRotation}${indicator}\n`, + 'static.svg': `${open}\n`, +} + +mkdirSync(OUTPUT, { recursive: true }) + +for (const file of readdirSync(OUTPUT)) { + if (file.endsWith('.svg')) { + rmSync(join(OUTPUT, file)) + } +} + +for (const [name, svg] of Object.entries(files)) { + writeFileSync(join(OUTPUT, name), svg) +} + +console.log( + `Wrote ${Object.entries(files) + .map(([name, svg]) => `${name} (${Buffer.byteLength(svg)} bytes)`) + .join(', ')} to ${OUTPUT}`, +) diff --git a/bin/shapes.mjs b/bin/shapes.mjs index e452c156..3ebcc7aa 100644 --- a/bin/shapes.mjs +++ b/bin/shapes.mjs @@ -42,8 +42,9 @@ * limitations under the License. * --------------------------------------------------------------------------------------- */ -import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' const OUTPUT = 'resources/svg/shapes' const VIEWBOX = 100 @@ -864,18 +865,23 @@ function pathData(polygon) { return `${d}Z` } -mkdirSync(OUTPUT, { recursive: true }) +/** The geometry, for other build scripts (bin/loading-indicator.mjs); importing this module writes nothing. */ +export { point, pointOnCurve, split, cubicBounds, RoundedPolygon, SHAPES } -for (const file of readdirSync(OUTPUT)) { - if (file.endsWith('.svg')) { - rmSync(join(OUTPUT, file)) +if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + mkdirSync(OUTPUT, { recursive: true }) + + for (const file of readdirSync(OUTPUT)) { + if (file.endsWith('.svg')) { + rmSync(join(OUTPUT, file)) + } } + + for (const [name, build] of Object.entries(SHAPES)) { + const svg = `\n` + + writeFileSync(join(OUTPUT, `${name}.svg`), svg) + } + + console.log(`Wrote ${Object.keys(SHAPES).length} shapes to ${OUTPUT}`) } - -for (const [name, build] of Object.entries(SHAPES)) { - const svg = `\n` - - writeFileSync(join(OUTPUT, `${name}.svg`), svg) -} - -console.log(`Wrote ${Object.keys(SHAPES).length} shapes to ${OUTPUT}`) diff --git a/docs/plans/livewire-material.md b/docs/plans/livewire-material.md index 600ed03d..30a667ec 100644 --- a/docs/plans/livewire-material.md +++ b/docs/plans/livewire-material.md @@ -145,7 +145,7 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`, mail colours cannot drift from the app. - **Boost guideline + `livewire-material-development` skill** in the package, and a test that fails when a component is missing from the skill. -- **Versioning: `0.x` per wave, `1.0.0` when the catalogue is complete.** +- **No version tags until the whole catalogue is done; then `1.0.0`** — the waves land on `main` untagged (decided 2026-09-13; replaces "`0.x` per wave"). - **Semantic ink and line utilities carried over from ReStride** (`text-body`, `text-meta`, `text-quiet`, `border-structure`, `border-chrome`, `border-divider`) — cheap `@theme` names ReStride's templates already use, derived from M3 roles. @@ -177,7 +177,7 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`, Actions on). SealShare's `vcs` repository entry uses that URL. 2. **Split the plan.** *Done 2026-09-13.* This file; SealShare keeps its adoption steps. -### Phase 1 — Package skeleton (`0.1.0`) +### Phase 1 — Package skeleton 3. **Repository.** `composer.json` (`nonameweb/livewire-material`, PSR-4 `NoNameWeb\LivewireMaterial\`, requires `php ^8.4`, `laravel/framework ^13`, @@ -235,7 +235,7 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`, and without `icons:cache` it scans the folders first. With ~7,800 symbols that is per request under PHP-FPM. Decided before step 13. -### Phase 2 — Foundation (`0.2.0`) +### Phase 2 — Foundation 9. **Scheme command.** `resources/node/scheme.mjs` built once in the package repo (esbuild bundle of `@material/material-color-utilities`, committed, Apache-2.0 header); `material:scheme {seed} @@ -317,7 +317,7 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`, springs and `x-figure`, and an icon search drawing matches as CSS masks from a showcase-only symbol route (relative URLs — an absolute one carries `APP_URL` and misses the dev server's port). -### Phase 3 — Actions (`0.3.0`) +### Phase 3 — Actions 19. Primitives the actions need: `loading` (M3 Expressive loading indicator, contained and not), plain `tooltip` (from a fine pointer only, not laid out while hidden), `menu` / @@ -329,18 +329,69 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`, 21. `icon-button` behaviour inside `button` (icon, no label): standard/filled/tonal/outlined, `selected` toggle (`aria-pressed`), widths. 22. `button-group` (standard and connected; `` alias with `wire:model` options), - `split-button`, `fab` (medium default, large; `size="sm"` → medium), extended FAB, and the + `split-button`, `fab` (56px default — M3's deprecated small FAB is gone, so `sm` is the baseline FAB — 80px `md`, 96px `lg`), extended FAB, and the responsive `fab` prop (extended FAB below `sm`, filled header button above — one element), `fab-menu`. -### Phase 4 — Communication (`0.4.0`) +**Phase 3 is done (2026-09-13).** What changed from the steps above: + +- **Values from androidx Compose Material 3's generated tokens** (`Button*Tokens`, + `*IconButtonTokens`, `Fab*Tokens`, `ExtendedFab*Tokens`, `FabMenuBaselineTokens`, + `ButtonGroupSmallTokens`, `ConnectedButtonGroupSmallTokens`, `SplitButton*Tokens`, + `StandardMenuTokens`, `VibrantMenuTokens`, `SegmentedMenuTokens`, `PlainTooltipTokens`, + `LoadingIndicatorTokens`), cited in each component's header. Two tokens are wrong and Compose + overrides them in code, so the package does too: the text button's label is `primary`, not + `on-surface-variant`, and the extra-small button's padding is 12px, not 16px. +- **`` is label button, icon button and toggle in one**, with `data-icon-button` on the + icon-only form. A selected round button squares off; a selected square icon button rounds. A + `corners` prop lets composite components (the split button) draw the corners themselves. +- **Group and split corners are unlayered CSS** (`resources/css/components/groups.css`): a + child button's corners are utilities, and anything in a `@layer` loses to a utility. Inner + corners ride a `--group-corner` variable so pressing and selecting change one value while the + rounded outer corners stay put. The standard group's press expansion is padding moved from the + neighbours to the pressed button (a fixed step per size); icon buttons keep their width. +- **`` stays ReStride's native-radio design** (checkboxes with `multiple`), restyled as a + connected button group — `wire:model`, `x-model` and the arrow keys need no script. +- **Tooltips and menus are popovers placed by CSS anchor positioning**, with per-render anchor + names generated in Blade (a morph updates the trigger and the popover together). `popover` + elements must never get a `display` utility (`flex`), which beats the UA's `display: none` for + a closed popover; use `open:flex`. +- **Menu keyboard is WAI-ARIA's menu button**; `aria-expanded` and the first item's focus are set + synchronously in `open()`, because the popover `toggle` event is queued and a test (or a screen + reader) reading in between saw a shut menu. **Bug found by the browser tests:** the guard + against a light-dismiss press reopening the menu compared against `closedAt = 0`, so every click + in the first 250ms after page load was swallowed; it starts at `-Infinity` now, with a test. +- **Anonymous component trap:** every prop is a local variable, so a helper variable in `@php` + must not reuse a prop's name — a local `$corners` array silently replaced the `corners` prop. +- **The loading indicator is androidx's own geometry, in SVG + SMIL** (`bin/loading-indicator.mjs`, + `resources/svg/loading-indicator/`): `Morph` is ported, so each of the seven morphs is a path + whose control points SMIL can interpolate in every engine (CSS `d:` has no WebKit support). + The spring is two keySplines (<1% error), each morph's path shrinks under its successor at the + hand-over, and the per-morph quarter turn runs on its own 2.6s cycle so the 630° per shape + cycle never needs a reset. 21.7 KB. Two deliberate differences from Compose: the spring settles + inside the 650ms instead of snapping back from 9% past, and frames centre on exact curve bounds, + so there is no 0.1–0.18 unit jump at three hand-overs. Reduced motion shows `static.svg`. +- **Showcase examples are Blade strings rendered with `Blade::render()` beside their source** + (``, an anonymous component path registered only when the showcase is + enabled), so the snippet can never disagree with what is drawn. +- **Browser tests in three engines, locally too** (Playwright's Firefox and WebKit are installed): + - WebKit, like Safari on macOS, leaves buttons out of the Tab order; focus them directly. + - Firefox counts a scripted focus as `:focus-visible` only after a key press. + - Firefox flaked ~3 runs in 4 with **HTTP 431 from Pest's in-process Amp server** on + `livewire.js`, so Alpine never started. It appeared once the showcase inlined a 60 KB list of + symbol names; the icon search now fetches `symbols.json` on `x-intersect.once`, and the suite + passed 4 of 4. Keep showcase pages lean. + - A click that lands before Alpine starts does nothing; tests wait for `networkidle`, and the + showcase's theme switch is `x-cloak` so Playwright's click waits for it. + +### Phase 4 — Communication 23. `badge` (dot, count, label; variant/colour), `progress` (linear, circular, **wavy**, determinate and indeterminate), `toast` (M3 snackbar, action, timeout, stacked), rich `tooltip`, `alert` (tinted container, icon, actions slot), `stat` (figure with `x-figure`), `empty-state`. -### Phase 5 — Containment (`0.5.0`) +### Phase 5 — Containment 24. `card` (elevated, filled, outlined; `title`, `subtitle`, `actions` slot; clickable row contract), `divider`, `list` / `list-item` (one-, two-, three-line; leading/trailing; @@ -350,7 +401,7 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`, standard; `pane` for list-detail from `xl`; `width` prop), `carousel` (multi-browse, uncontained, hero, full-screen on CSS scroll-snap), `collapse`. -### Phase 6 — Text inputs and selection (`0.6.0`) +### Phase 6 — Text inputs and selection 25. `form`, `field` (the shared shell: **outlined and filled**, floating label via `:has()`, notch, `hint` replaced by error, `aria-invalid` / `aria-describedby`, `data-*` state @@ -363,13 +414,13 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`, inputs, value label), `search` (search bar and search view, results through a Livewire property). -### Phase 7 — Pickers (`0.7.0`) +### Phase 7 — Pickers 27. `datepicker` (docked, modal, modal input; `Intl` month/day names and week start from the app locale; `min`/`max`; single and range; `wire:model` stores `Y-m-d`), `timepicker` (dial and input; 12/24h from locale; stores `H:i`). APG grid keyboard for the calendar. -### Phase 8 — Navigation (`0.8.0`) +### Phase 8 — Navigation 28. `app-bar` (small, center-aligned, medium flexible, large flexible, search app bar; sticky, scroll-elevation), `navigation-bar` (flexible), `navigation-rail` (collapsed, expanded, @@ -382,7 +433,7 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`, the theme script), content region with `wire:transition.navigate`, snackbar host. Nothing app-specific inside; apps pass destinations and extra chrome as slots. -### Phase 9 — Data, pages, mail (`0.9.0`) +### Phase 9 — Data, pages, mail 30. `table` (`.data-table`, descendant selectors, fine-pointer density, `position: relative`), `sort-header` (`sortBy` array shape, `aria-sort`), Livewire and Laravel pagination views @@ -433,7 +484,7 @@ Tracked in SealShare's `docs/plans/livewire-material.md`, after `1.0.0`. ## Risks and open questions - **Scope and time.** The whole catalogue (~45 components plus extras) comes before any app - uses it. Mitigation: waves tagged `0.x`, each reviewed in the showcase. + uses it. Mitigation: each wave is reviewed in the showcase and green on CI before the next. - **Accessibility is entirely ours** — menus, pickers, carousel, sheets. Mitigation: APG patterns, native elements first, ARIA in render tests, keyboard in browser tests across three engines. diff --git a/package.json b/package.json index 78da371c..c97ab772 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "build": "vite build", "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:shapes": "node bin/shapes.mjs", + "build:loading": "node bin/loading-indicator.mjs" }, "devDependencies": { "@material/material-color-utilities": "^0.4.0", diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index a2d8f562..11d16fcd 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -107,6 +107,112 @@ One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, size The theme decided before the first paint. Exactly once per layout, in ``, before `@vite`. No props; configured in `config/livewire-material.php`. +### `` + +Label button, icon button, toggle and responsive FAB in one component. + +| Prop | Default | | +|---|---|---| +| `label` / slot | | the words; without them and with an `icon` it is an icon button | +| `variant` | `text` | `filled`, `tonal`, `outlined`, `elevated`, `text` | +| `color` (alias `tone`) | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info` | +| `primary`, `danger`, `caution` | | shorthands: filled primary, filled error, filled warning | +| `size` | `sm` | `xs` 32px, `sm` 40px, `md` 56px, `lg` 96px, `xl` 136px | +| `shape` | `round` | or `square`; both square off further while pressed | +| `icon`, `icon-right` | | Material Symbol names | +| `width` | `default` | icon buttons only: `narrow`, `default`, `wide` | +| `selected` | `null` | `true`/`false` makes it a toggle (`aria-pressed`, selected colours and shape) | +| `link`, `external`, `no-wire-navigate` | | renders ``, with `wire:navigate` unless external | +| `spinner` | | `true` shows the loading indicator while its `wire:click` runs; a string names the action | +| `tooltip`, `tooltip-left`, `tooltip-right`, `tooltip-bottom` | | plain tooltip; also the icon button's accessible name | +| `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `lg`; `fab` is an extended FAB below `sm`, a filled button above | + +```blade + + + +``` + +### `` + +M3's plain tooltip, standalone around any trigger: ``. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus; `aria-hidden`, so the trigger still needs its own accessible name. Buttons and FABs take a `tooltip` prop instead. + +### ``, ``, ``, `` + +```blade + + + + + + + + + + + + +``` + +``: `trigger` slot (its first button or link becomes the menu button), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. ``: `label`, `icon`, `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab. + +### `` + +A row of ``s: ``. `connected` sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the `size` of the buttons inside. + +### `` + +A choice between a few options as a connected button group of native radios (checkboxes with `multiple`): + +```blade + +``` + +Props: `label`, `hint`, `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint. + +### `` + +```blade + + + +``` + +Attributes go to the leading button; the slot is the menu. `variant` (`filled` default, `tonal`, `outlined`, `elevated`), `color`, `size`, `disabled`, `spinner`, `menu-label`, `position`. + +### `` + +`` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`
`). `link`, `external`, `disabled`, `type`. + +### ``, `` + +```blade +
+ + + + +
+``` + +Two to six items open above the FAB, which turns into a close button. ``: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard as ``. + +### `` + +M3 Expressive's loading indicator — a shape morphing through seven Expressive shapes as it turns — for a wait of unknown length. 48px and `primary` unless sized or coloured by class; `contained` sets it on a primary-container circle. A `progressbar` named by `label` ("Loading"); `:label="false"` makes it decorative. It rests under reduced motion. + +```blade + + +
+``` + +`` shows a decorative one in place of its icon. + ## Testing the design ```php diff --git a/resources/css/components/groups.css b/resources/css/components/groups.css new file mode 100644 index 00000000..8256432b --- /dev/null +++ b/resources/css/components/groups.css @@ -0,0 +1,84 @@ +/* + * Button groups and split buttons: shapes that depend on a button's place among its siblings. + * + * Unlayered on purpose. Each draws its own corners and padding as utilities, and a + * rule in any @layer loses to a utility whatever its specificity; the group has to win. + * + * Standard group (``): pressing a label button widens it and narrows its + * neighbours by the same amount, so the row keeps its length — Expressive's press expansion + * (ButtonGroupDefaults.ExpandedRatio is 15%; this uses a fixed step per size). Icon buttons keep + * their width. + * + * Connected group (``, ``): 2px apart, the outer corners + * full, the inner corners small, smaller still while pressed, and a selected segment fully round + * (ConnectedButtonGroupSmallTokens and the M3 Expressive connected button group spec). + * + * Split button (``): the same idea for two halves; the trailing half turns + * round and its chevron turns over while its menu is open (SplitButton*Tokens). + */ + +[data-button-group] { + --group-inner: var(--md-sys-shape-corner-sm); + --group-inner-pressed: var(--md-sys-shape-corner-xs); +} + +[data-button-group][data-size='xs'] { --group-pad: 0.75rem; --group-grow: 4px; --group-inner: var(--md-sys-shape-corner-xs); --group-inner-pressed: 2px; } +[data-button-group][data-size='sm'] { --group-pad: 1rem; --group-grow: 6px; } +[data-button-group][data-size='md'] { --group-pad: 1.5rem; --group-grow: 8px; } +[data-button-group][data-size='lg'] { --group-pad: 3rem; --group-grow: 16px; --group-inner: var(--md-sys-shape-corner-lg); --group-inner-pressed: var(--md-sys-shape-corner-md); } +[data-button-group][data-size='xl'] { --group-pad: 4rem; --group-grow: 20px; --group-inner: var(--md-sys-shape-corner-lg-increased); --group-inner-pressed: var(--md-sys-shape-corner-lg); } + +[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) { + padding-inline: calc(var(--group-pad) + var(--group-grow)); +} + +[data-button-group='standard'] > :not([data-icon-button]):has(+ :not([data-icon-button]):active:not(:disabled, [aria-disabled='true'])) { + padding-inline-end: calc(var(--group-pad) - var(--group-grow)); +} + +[data-button-group='standard'] > :not([data-icon-button]):active:not(:disabled, [aria-disabled='true']) + :not([data-icon-button]) { + padding-inline-start: calc(var(--group-pad) - var(--group-grow)); +} + +/* + * Connected segments and split halves take their inner corner from `--group-corner`, so pressing + * or selecting changes one variable and the rounded outer corners stay put. + */ +[data-button-group='connected'] > *, +[data-split] { + --group-corner: var(--group-inner); + border-start-start-radius: var(--group-corner); + border-end-start-radius: var(--group-corner); + border-start-end-radius: var(--group-corner); + border-end-end-radius: var(--group-corner); +} + +[data-button-group='connected'] > :active, +[data-split]:active { + --group-corner: var(--group-inner-pressed); +} + +[data-button-group='connected'] > :is([aria-pressed='true'], :has(:checked)), +[data-split='trailing'][aria-expanded='true'] { + --group-corner: var(--md-sys-shape-corner-full); +} + +[data-button-group='connected'] > :first-child, +[data-split='leading'] { + border-start-start-radius: var(--md-sys-shape-corner-full); + border-end-start-radius: var(--md-sys-shape-corner-full); +} + +[data-button-group='connected'] > :last-child, +[data-split='trailing'] { + border-start-end-radius: var(--md-sys-shape-corner-full); + border-end-end-radius: var(--md-sys-shape-corner-full); +} + +[data-split='trailing'] svg { + transition: rotate var(--md-sys-motion-spatial-fast-duration) var(--md-sys-motion-spatial-fast); +} + +[data-split='trailing'][aria-expanded='true'] svg { + rotate: 180deg; +} diff --git a/resources/css/material.css b/resources/css/material.css index 79c61633..b33d21fc 100644 --- a/resources/css/material.css +++ b/resources/css/material.css @@ -20,6 +20,7 @@ @import './tokens/font.css'; @import './tokens/theme.css'; @import './tokens/state.css'; +@import './components/groups.css'; @layer base { html { diff --git a/resources/js/material.js b/resources/js/material.js index fa568329..7d4b5e23 100644 --- a/resources/js/material.js +++ b/resources/js/material.js @@ -9,3 +9,5 @@ */ import './theme.js' import './figure.js' +import './tooltip.js' +import './menu.js' diff --git a/resources/js/menu.js b/resources/js/menu.js new file mode 100644 index 00000000..993ea64f --- /dev/null +++ b/resources/js/menu.js @@ -0,0 +1,172 @@ +/** + * `materialMenu`: the behaviour of `` — WAI-ARIA's menu button pattern on a popover. + * + * The menu button is the trigger's first button or link. Its ARIA attributes are written by + * script, which a Livewire morph removes along with anything else the server did not render, + * so they are written again whenever the trigger is used. + */ +const ITEMS = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]' + +// 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. +const REOPEN_GUARD_MS = 250 + +document.addEventListener('alpine:init', () => { + window.Alpine.data('materialMenu', () => ({ + closedAt: -Infinity, + returnFocus: true, + listeners: [], + + init() { + const menu = this.$refs.menu + + this.label() + + // Only closes the browser starts — Escape, a press outside — arrive here alone; open() + // and close() have already done their part, synchronously, because this event is + // queued and a screen reader or a test reading aria-expanded in between would be told + // the menu is shut. + this.listen(menu, 'toggle', (event) => { + const opened = event.newState === 'open' + + this.control()?.setAttribute('aria-expanded', String(opened)) + + if (opened) { + return + } + + this.closedAt = performance.now() + + if (this.returnFocus && menu.contains(document.activeElement)) { + this.control()?.focus() + } + }) + + // A press outside closes the menu without pulling focus back to the trigger. + this.listen(document, 'pointerdown', (event) => { + if (!menu.contains(event.target) && !this.$refs.trigger.contains(event.target)) { + this.returnFocus = false + } + }) + }, + + control() { + return this.$refs.trigger.querySelector('button, a[href], [tabindex]') + }, + + label() { + const control = this.control() + + if (!control) { + return + } + + control.setAttribute('aria-haspopup', 'menu') + control.setAttribute('aria-controls', this.$refs.menu.id) + control.setAttribute('aria-expanded', String(this.isOpen())) + }, + + isOpen() { + return this.$refs.menu.matches(':popover-open') + }, + + open(focus = 'first') { + this.label() + + if (!this.isOpen()) { + this.$refs.menu.showPopover() + this.returnFocus = true + } + + this.control()?.setAttribute('aria-expanded', 'true') + this.focusItem(focus) + }, + + close() { + if (this.isOpen()) { + this.$refs.menu.hidePopover() + } + + this.control()?.setAttribute('aria-expanded', 'false') + }, + + toggle(focus = 'first') { + if (this.isOpen()) { + this.close() + } else if (performance.now() - this.closedAt > REOPEN_GUARD_MS) { + this.open(focus) + } + }, + + items() { + return [...this.$refs.menu.querySelectorAll(ITEMS)].filter((item) => item.getAttribute('aria-disabled') !== 'true') + }, + + focusItem(which) { + const items = this.items() + + ;(which === 'last' ? items.at(-1) : items[0])?.focus() + }, + + navigate(event) { + const items = this.items() + const current = items.indexOf(document.activeElement) + + const move = (index) => { + event.preventDefault() + items[(index + items.length) % items.length]?.focus() + } + + switch (event.key) { + case 'ArrowDown': + return move(current + 1) + case 'ArrowUp': + return move(current < 0 ? items.length - 1 : current - 1) + case 'Home': + return move(0) + case 'End': + return move(items.length - 1) + case 'Escape': + this.returnFocus = true + + return + case 'Tab': + this.returnFocus = false + this.close() + + return + } + + // Typeahead: a printable letter moves to the next item whose label starts with it. + if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { + const letter = event.key.toLowerCase() + const ordered = [...items.slice(current + 1), ...items.slice(0, current + 1)] + const match = ordered.find((item) => item.textContent.trim().toLowerCase().startsWith(letter)) + + if (match) { + event.preventDefault() + match.focus() + } + } + }, + + activate(event) { + const item = event.target.closest(ITEMS) + + if (!item || item.getAttribute('aria-disabled') === 'true' || item.hasAttribute('data-keep-open')) { + return + } + + this.close() + }, + + listen(target, type, handler) { + target.addEventListener(type, handler) + this.listeners.push(() => target.removeEventListener(type, handler)) + }, + + destroy() { + this.listeners.forEach((remove) => remove()) + }, + })) +}) diff --git a/resources/js/tooltip.js b/resources/js/tooltip.js new file mode 100644 index 00000000..ce8d1fb6 --- /dev/null +++ b/resources/js/tooltip.js @@ -0,0 +1,57 @@ +/** + * `materialTooltip`: shows an `` popover for the control it belongs to. + * + * The trigger is the popover's parent element (the button, or the standalone wrapper). Hover + * counts only for a pointer that can hover, after a short delay so a pointer passing over a + * toolbar does not flash every label; keyboard focus shows it at once. A press, leaving, blur + * and Escape hide it. + */ +const HOVER_DELAY_MS = 500 + +document.addEventListener('alpine:init', () => { + window.Alpine.data('materialTooltip', () => ({ + timer: null, + listeners: [], + + 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()) { + tip.showPopover() + } + }, delay) + } + + const hide = () => { + clearTimeout(this.timer) + + if (open()) { + tip.hidePopover() + } + } + + this.listen(trigger, 'pointerenter', (event) => event.pointerType === 'mouse' && show(HOVER_DELAY_MS)) + this.listen(trigger, 'pointerleave', hide) + this.listen(trigger, 'pointerdown', hide) + this.listen(trigger, 'focusin', () => trigger.matches(':focus-within:has(:focus-visible), :focus-visible') && show(0)) + this.listen(trigger, 'focusout', hide) + this.listen(document, 'keydown', (event) => event.key === 'Escape' && hide()) + }, + + listen(target, type, handler) { + target.addEventListener(type, handler) + this.listeners.push(() => target.removeEventListener(type, handler)) + }, + + destroy() { + clearTimeout(this.timer) + this.listeners.forEach((remove) => remove()) + }, + })) +}) diff --git a/resources/svg/loading-indicator/indeterminate.svg b/resources/svg/loading-indicator/indeterminate.svg new file mode 100644 index 00000000..e50c661c --- /dev/null +++ b/resources/svg/loading-indicator/indeterminate.svg @@ -0,0 +1 @@ + diff --git a/resources/svg/loading-indicator/static.svg b/resources/svg/loading-indicator/static.svg new file mode 100644 index 00000000..eb08dba0 --- /dev/null +++ b/resources/svg/loading-indicator/static.svg @@ -0,0 +1 @@ + diff --git a/resources/views/components/button-group.blade.php b/resources/views/components/button-group.blade.php new file mode 100644 index 00000000..a38bed0b --- /dev/null +++ b/resources/views/components/button-group.blade.php @@ -0,0 +1,42 @@ +{{-- An M3 Expressive button group: related ``s in a row. + + + + … + + + Standard (the default): the buttons stand apart, and pressing a label button widens it while + its neighbours give way. `connected`: 2px apart with small inner corners, the shape that + replaced M3's segmented button; a selected (aria-pressed) button rounds fully. Give `size` + the size of the buttons inside, so the spacing and corners match. For a choice bound to a + property, `` draws a connected group of radios. + + Spacing from the M3 Expressive spec (ButtonGroupSmallTokens: 12px at the small size). The + shapes are resources/css/components/groups.css. --}} + +@props([ + 'connected' => false, + 'size' => 'sm', + 'label' => null, +]) + +@php + $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm'; +@endphp + +
class([ + 'inline-flex items-center', + 'gap-0.5' => $connected, + 'flex-wrap' => ! $connected, + 'gap-[18px]' => ! $connected && $size === 'xs', + 'gap-3' => ! $connected && $size === 'sm', + 'gap-2' => ! $connected && in_array($size, ['md', 'lg', 'xl'], true), + ]) }} +> + {{ $slot }} +
diff --git a/resources/views/components/button.blade.php b/resources/views/components/button.blade.php new file mode 100644 index 00000000..264b04e5 --- /dev/null +++ b/resources/views/components/button.blade.php @@ -0,0 +1,226 @@ +{{-- A Material 3 Expressive button — label button, icon button or toggle — in one component. + + `variant` is M3's style: `filled`, `tonal`, `outlined`, `elevated` or `text` (the default). + `color` is the role it is drawn in: `primary` (the default), `secondary`, `tertiary`, `error`, + `success`, `warning`, `info`; `tone` is the same prop under ReStride's name. The shorthands + `primary`, `danger` and `caution` mean filled primary, filled error and filled warning. + Unknown values fall back to the defaults rather than rendering nothing. + + `size` is Expressive's scale — `xs` 32px, `sm` 40px (the default), `md` 56px, `lg` 96px, + `xl` 136px — each with its own padding, type style and icon size. `shape` is `round` (the + default) or `square`; either one squares off towards a smaller corner while pressed, on the + fast spatial spring. + + With an `icon` and no label it is an icon button: `width` is `narrow`, `default` or `wide`, + `variant="text"` is M3's standard icon button, and the tooltip or label names it for screen + readers. `selected` makes it a toggle: `true` or `false` sets `aria-pressed` and M3's + selected colours, and a selected round button turns square (a selected square icon button + turns round). Text buttons are not toggles in M3; a selected one takes the tonal container. + + Values from androidx Compose Material 3's tokens (Button*Tokens, *IconButtonTokens, + Apache-2.0); 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. + + Behaviour kept from maryUI: `link` renders an anchor with `wire:navigate` (unless `external` + or `no-wire-navigate`); `disabled` works on a link too, as `aria-disabled`; `spinner` shows + the loading indicator while the button's own `wire:click` runs (or the action named by a + string); `responsive` hides the label below `lg`; `tooltip`, `tooltip-left`, `tooltip-right` + and `tooltip-bottom` attach a plain tooltip. `fab` is a page's create action: an extended FAB + pinned in the thumb zone below `sm`, a filled button above it — one element either way. + + Never pass `hidden`, a display or a position class: the button is `inline-flex` and + `relative`, and whichever Tailwind emits last wins. Wrap it instead. --}} + +@props([ + 'label' => null, + 'icon' => null, + 'iconRight' => null, + 'link' => null, + 'external' => false, + 'noWireNavigate' => false, + 'variant' => null, + 'color' => null, + 'tone' => null, + 'primary' => false, + 'danger' => false, + 'caution' => false, + 'size' => 'sm', + 'shape' => 'round', + 'width' => 'default', + 'selected' => null, + 'spinner' => null, + 'responsive' => false, + 'tooltip' => null, + 'tooltipLeft' => null, + 'tooltipRight' => null, + 'tooltipBottom' => null, + 'fab' => false, + 'disabled' => false, + 'type' => 'button', + 'corners' => null, +]) + +@php + $variant = match (true) { + in_array($variant, ['filled', 'tonal', 'outlined', 'text', 'elevated'], true) => $variant, + $primary || $danger || $caution || $fab => 'filled', + default => 'text', + }; + + $color = match (true) { + $danger => 'error', + $caution => 'warning', + in_array($color ?? $tone, ['primary', 'secondary', 'tertiary', 'error', 'success', 'warning', 'info'], true) => $color ?? $tone, + default => 'primary', + }; + + $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm'; + $square = $shape === 'square'; + $width = in_array($width, ['narrow', 'default', 'wide'], true) ? $width : 'default'; + $iconOnly = filled($icon) && blank($label) && $slot->isEmpty(); + $isLink = filled($link); + $inert = $disabled && $isLink; + + $tip = $tooltip ?? $tooltipLeft ?? $tooltipRight ?? $tooltipBottom; + $tipSide = match (true) { + $tooltipLeft !== null => 'left', + $tooltipRight !== null => 'right', + $tooltipBottom !== null => 'bottom', + default => 'top', + }; + $anchor = $tip !== null ? '--material-button-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null; + + $spinnerTarget = match (true) { + $spinner === true, $spinner === 1, $spinner === '1' => $attributes->whereStartsWith('wire:click')->first(), + is_string($spinner) && $spinner !== '' => $spinner, + default => null, + }; + + // Every colour class written out whole, so Tailwind compiles it. + $ink = [ + 'primary' => 'text-primary', 'secondary' => 'text-secondary', 'tertiary' => 'text-tertiary', + 'error' => 'text-error', 'success' => 'text-success', 'warning' => 'text-warning', 'info' => 'text-info', + ]; + $fill = [ + 'primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', + 'tertiary' => 'bg-tertiary text-on-tertiary', 'error' => 'bg-error text-on-error', + 'success' => 'bg-success text-on-success', 'warning' => 'bg-warning text-on-warning', 'info' => 'bg-info text-on-info', + ]; + $container = [ + 'primary' => 'bg-secondary-container text-on-secondary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', + 'tertiary' => 'bg-tertiary-container text-on-tertiary-container', 'error' => 'bg-error-container text-on-error-container', + 'success' => 'bg-success-container text-on-success-container', 'warning' => 'bg-warning-container text-on-warning-container', + 'info' => 'bg-info-container text-on-info-container', + ]; + $containerSelected = ['primary' => 'bg-secondary text-on-secondary'] + $fill; + $quietInk = ['primary' => 'text-on-surface-variant'] + $ink; + + $colours = match ($variant) { + 'filled' => $selected === false ? 'bg-surface-container text-on-surface-variant' : $fill[$color], + 'tonal' => $selected === true ? $containerSelected[$color] : $container[$color], + 'outlined' => $selected === true ? 'bg-inverse-surface text-inverse-on-surface border-transparent' : 'border-outline-variant '.$quietInk[$color], + 'elevated' => $selected === true ? $fill[$color] : 'bg-surface-container-low '.$ink[$color], + 'text' => match (true) { + $iconOnly => $selected === true ? $ink[$color] : $quietInk[$color], + default => $selected === true ? $container[$color] : $ink[$color], + }, + }; + + $squareCorners = ['xs' => 'rounded-corner-md', 'sm' => 'rounded-corner-md', 'md' => 'rounded-corner-lg', 'lg' => 'rounded-corner-xl', 'xl' => 'rounded-corner-xl']; + $pressed = ['xs' => 'active:rounded-corner-sm', 'sm' => 'active:rounded-corner-sm', 'md' => 'active:rounded-corner-md', 'lg' => 'active:rounded-corner-lg', 'xl' => 'active:rounded-corner-lg']; + + $corner = match (true) { + $iconOnly && $selected === true => $square ? 'rounded-corner-full' : $squareCorners[$size], + $selected === true, $square => $squareCorners[$size], + default => 'rounded-corner-full', + }; + + $dimensions = $iconOnly + ? [ + 'xs' => ['narrow' => 'h-8 w-7', 'default' => 'size-8', 'wide' => 'h-8 w-10'], + 'sm' => ['narrow' => 'h-10 w-8', 'default' => 'size-10', 'wide' => 'h-10 w-13'], + 'md' => ['narrow' => 'h-14 w-12', 'default' => 'size-14', 'wide' => 'h-14 w-18'], + 'lg' => ['narrow' => 'h-24 w-16', 'default' => 'size-24', 'wide' => 'h-24 w-32'], + 'xl' => ['narrow' => 'h-34 w-26', 'default' => 'size-34', 'wide' => 'h-34 w-46'], + ][$size][$width] + : [ + 'xs' => 'h-8 gap-2 px-3 type-label-lg', + 'sm' => 'h-10 gap-2 px-4 type-label-lg', + 'md' => 'h-14 gap-2 px-6 type-title-md', + 'lg' => 'h-24 gap-3 px-12 type-headline-sm', + 'xl' => 'h-34 gap-4 px-16 type-headline-lg', + ][$size]; + + $iconSize = $iconOnly + ? ['xs' => 'size-5', 'sm' => 'size-6', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size] + : ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size]; + + $outline = ['xs' => 'border', 'sm' => 'border', 'md' => 'border', 'lg' => 'border-2', 'xl' => 'border-3'][$size]; + $contained = in_array($variant, ['filled', 'tonal', 'elevated'], true) || ($variant === 'outlined' && $selected === true); + + $classes = [ + 'group/button state-layer focus-ring inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap', + 'transition-[border-radius,background-color,color,box-shadow,padding,margin] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast', + $dimensions, + // A composite component (a split button's halves) passes the corners its place needs. + $corners ?? $corner.' '.$pressed[$size], + $colours, + $outline => $variant === 'outlined', + 'hover:shadow-elevation-1' => in_array($variant, ['filled', 'tonal'], true), + 'shadow-elevation-1 hover:shadow-elevation-2' => $variant === 'elevated', + // Below 48px the touch target reaches past the button, as M3 requires. + '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' => in_array($size, ['xs', 'sm'], true), + 'disabled:cursor-not-allowed disabled:shadow-none aria-disabled:pointer-events-none aria-disabled:shadow-none', + 'disabled:bg-on-surface/10 disabled:text-on-surface/38 aria-disabled:bg-on-surface/10 aria-disabled:text-on-surface/38' => $contained, + 'disabled:text-on-surface/38 aria-disabled:text-on-surface/38' => ! $contained, + // The FAB, below sm only: an extended FAB in the thumb zone, clear of a bottom bar the layout declares. + 'max-sm:fixed max-sm:end-4 max-sm:bottom-[calc(var(--material-bottom-bar,0px)+1rem)] max-sm:z-30 max-sm:h-14 max-sm:gap-2 max-sm:px-4 max-sm:rounded-corner-lg max-sm:type-title-md max-sm:bg-primary-container max-sm:text-on-primary-container max-sm:shadow-elevation-3' => $fab, + ]; + + $tag = $isLink ? 'a' : 'button'; + + $attributes = $attributes + ->class($classes) + ->merge(array_filter([ + '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, + 'aria-disabled' => $inert ? 'true' : null, + 'tabindex' => $inert ? '-1' : null, + 'type' => $isLink ? null : $type, + 'disabled' => ! $isLink && $disabled ? true : null, + 'aria-label' => $iconOnly && ! $attributes->has('aria-label') ? ($label ?? $tip) : null, + 'aria-pressed' => $selected === null ? null : ($selected ? 'true' : 'false'), + 'data-icon-button' => $iconOnly ? true : null, + 'wire:loading.attr' => $spinnerTarget ? 'disabled' : null, + 'wire:target' => $spinnerTarget, + 'style' => $anchor ? "anchor-name: {$anchor}" : null, + ], fn ($value): bool => $value !== null)); +@endphp + +<{{ $tag }} {{ $attributes }}> + @if ($spinnerTarget) + + + + @endif + + @if ($icon) + + + + @endif + + @unless ($iconOnly) + $responsive])>{{ $label ?? $slot }} + @endunless + + @if ($iconRight) + + @endif + + @if ($tip !== null) + + @endif + diff --git a/resources/views/components/fab-menu-item.blade.php b/resources/views/components/fab-menu-item.blade.php new file mode 100644 index 00000000..8015f140 --- /dev/null +++ b/resources/views/components/fab-menu-item.blade.php @@ -0,0 +1,47 @@ +{{-- One action in an ``: a 56px pill with an icon and a label, rising into place as + the menu opens. `link` makes it an anchor (with `wire:navigate` unless `external`); `color` + should match the menu's. --}} + +@props([ + 'label' => null, + 'icon' => null, + 'color' => 'primary', + 'link' => null, + 'external' => false, +]) + +@php + $isLink = filled($link); + $tag = $isLink ? 'a' : 'button'; + + $colours = [ + 'primary' => 'bg-primary-container text-on-primary-container', + 'secondary' => 'bg-secondary-container text-on-secondary-container', + 'tertiary' => 'bg-tertiary-container text-on-tertiary-container', + ][in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary']; + + $attributes = $attributes + ->class([ + 'state-layer inline-flex h-14 shrink-0 cursor-pointer items-center gap-2 rounded-corner-full px-6 whitespace-nowrap type-title-md shadow-elevation-3 outline-none', + 'focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-secondary', + 'transition-[translate,opacity] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast starting:translate-y-2 starting:opacity-0', + $colours, + ]) + ->merge(array_filter([ + 'role' => 'menuitem', + 'tabindex' => '-1', + 'type' => $isLink ? null : 'button', + 'href' => $isLink ? $link : null, + 'target' => $isLink && $external ? '_blank' : null, + 'rel' => $isLink && $external ? 'noopener' : null, + 'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null, + ], fn ($value): bool => $value !== null)); +@endphp + +<{{ $tag }} {{ $attributes }}> + @if ($icon) + + @endif + + {{ $label ?? $slot }} + diff --git a/resources/views/components/fab-menu.blade.php b/resources/views/components/fab-menu.blade.php new file mode 100644 index 00000000..12b0821a --- /dev/null +++ b/resources/views/components/fab-menu.blade.php @@ -0,0 +1,77 @@ +{{-- An M3 Expressive FAB menu: a FAB that opens into a short list of related actions. + +
+ + + + +
+ + Two to six items. The FAB (`icon`, `add` by default, in `color`'s container) turns into a + round close button in the colour itself while the list is open above it, end-aligned; the + list is a `popover="auto"` menu with the menu keyboard of ``. `label` names the FAB + for screen readers. Give the items the same `color`. + + FabMenuBaselineTokens (androidx Compose Material 3, Apache-2.0): 56px items, 4px apart, 8px + above the close button. --}} + +@props([ + 'icon' => 'add', + 'label' => null, + 'color' => 'primary', + 'position' => 'top-end', +]) + +@php + $color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary'; + $key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)); + $anchor = "--material-fab-menu-{$key}"; + + $colours = [ + 'primary' => 'bg-primary-container text-on-primary-container aria-expanded:bg-primary aria-expanded:text-on-primary', + 'secondary' => 'bg-secondary-container text-on-secondary-container aria-expanded:bg-secondary aria-expanded:text-on-secondary', + 'tertiary' => 'bg-tertiary-container text-on-tertiary-container aria-expanded:bg-tertiary aria-expanded:text-on-tertiary', + ][$color]; +@endphp + +
class('relative inline-flex') }}> + + + + + +
diff --git a/resources/views/components/fab.blade.php b/resources/views/components/fab.blade.php new file mode 100644 index 00000000..20716bf2 --- /dev/null +++ b/resources/views/components/fab.blade.php @@ -0,0 +1,78 @@ +{{-- An M3 Expressive floating action button: the screen's primary action. + + With only an `icon` it is a FAB — `size` `sm` 56px (the default; M3's deprecated small FAB is + gone, so this is the baseline FAB), `md` 80px, `lg` 96px. With a `label` it is an extended + FAB at the same three heights. `color` is `primary` (the default), `secondary` or `tertiary`, + drawn in its container, or in the colour itself with `variant="filled"`. + + It does not place itself: put it where the layout wants it, e.g. + `
`. For a + create action that is a FAB on a phone and a header button above, use ``. + + Sizes, corners and elevation from FabBaseline/Medium/LargeTokens and ExtendedFab*Tokens + (androidx Compose Material 3, Apache-2.0). --}} + +@props([ + 'icon' => null, + 'label' => null, + 'size' => 'sm', + 'color' => 'primary', + 'variant' => 'container', + 'link' => null, + 'external' => false, + 'tooltip' => null, + 'disabled' => false, + 'type' => 'button', +]) + +@php + $size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'sm'; + $color = in_array($color, ['primary', 'secondary', 'tertiary'], true) ? $color : 'primary'; + $extended = filled($label) || $slot->isNotEmpty(); + $isLink = filled($link); + + $colours = $variant === 'filled' + ? ['primary' => 'bg-primary text-on-primary', 'secondary' => 'bg-secondary text-on-secondary', 'tertiary' => 'bg-tertiary text-on-tertiary'][$color] + : ['primary' => 'bg-primary-container text-on-primary-container', 'secondary' => 'bg-secondary-container text-on-secondary-container', 'tertiary' => 'bg-tertiary-container text-on-tertiary-container'][$color]; + + $dimensions = $extended + ? ['sm' => 'h-14 min-w-14 gap-2 px-4 rounded-corner-lg type-title-md', 'md' => 'h-20 min-w-20 gap-3 px-[26px] rounded-corner-lg-increased type-title-lg', 'lg' => 'h-24 min-w-24 gap-4 px-7 rounded-corner-xl type-headline-sm'][$size] + : ['sm' => 'size-14 rounded-corner-lg', 'md' => 'size-20 rounded-corner-lg-increased', 'lg' => 'size-24 rounded-corner-xl'][$size]; + + $iconSize = ['sm' => 'size-6', 'md' => 'size-7', 'lg' => 'size-8'][$size]; + $anchor = $tooltip !== null ? '--material-fab-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)) : null; + $tag = $isLink ? 'a' : 'button'; + + $attributes = $attributes + ->class([ + 'state-layer focus-ring inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap', + 'shadow-elevation-3 hover:shadow-elevation-4 transition-[box-shadow,background-color,color] duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast', + $dimensions, + $colours, + 'disabled:cursor-not-allowed disabled:bg-on-surface/10 disabled:text-on-surface/38 disabled:shadow-none', + ]) + ->merge(array_filter([ + 'href' => $isLink ? $link : null, + 'target' => $isLink && $external ? '_blank' : null, + 'rel' => $isLink && $external ? 'noopener' : null, + 'wire:navigate' => $isLink && ! $external && ! $attributes->has('wire:navigate') ? true : null, + 'type' => $isLink ? null : $type, + 'disabled' => ! $isLink && $disabled ? true : null, + 'aria-label' => ! $extended && ! $attributes->has('aria-label') ? $tooltip : null, + 'style' => $anchor ? "anchor-name: {$anchor}" : null, + ], fn ($value): bool => $value !== null)); +@endphp + +<{{ $tag }} {{ $attributes }}> + @if ($icon) + + @endif + + @if ($extended) + {{ $label ?? $slot }} + @endif + + @if ($tooltip !== null) + + @endif + diff --git a/resources/views/components/group.blade.php b/resources/views/components/group.blade.php new file mode 100644 index 00000000..9ea8a01d --- /dev/null +++ b/resources/views/components/group.blade.php @@ -0,0 +1,97 @@ +{{-- A choice of a few options as an M3 Expressive connected button group — the successor of the + segmented button. + + + + Native radios under the segments (checkboxes with `multiple`), so `wire:model` and `x-model` + bind as on any input, the arrow keys move the choice, and a screen reader announces a group. + The chosen segment rounds fully and takes the selected colour; `variant` is `tonal` (the + default), `filled` or `outlined`, as for toggle buttons. The segments share the row unless + `inline`. An option with `'disabled' => true` greys its own segment. + + ReStride's props, kept: `label`, `hint`, `name` (needed with `x-model`, which names no + property), `options`, `option-value`, `option-label`; plus `option-icon`, `size`, `variant`, + `multiple`, `inline`. A validation message for the bound property replaces the hint. --}} + +@props([ + 'label' => null, + 'hint' => null, + 'name' => null, + 'options' => [], + 'optionValue' => 'id', + 'optionLabel' => 'name', + 'optionIcon' => 'icon', + 'size' => 'sm', + 'variant' => 'tonal', + 'multiple' => false, + 'inline' => false, +]) + +@php + $model = $attributes->whereStartsWith('wire:model')->first(); + $name ??= $model; + $messages = $model !== null && isset($errors) ? \Illuminate\Support\Arr::flatten($errors->get($model)) : []; + $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm'; + + $segment = [ + 'xs' => 'h-8 gap-2 px-3 type-label-lg', + 'sm' => 'h-10 gap-2 px-4 type-label-lg', + 'md' => 'h-14 gap-2 px-6 type-title-md', + 'lg' => 'h-24 gap-3 px-12 type-headline-sm', + 'xl' => 'h-34 gap-4 px-16 type-headline-lg', + ][$size]; + + $iconSize = ['xs' => 'size-5', 'sm' => 'size-5', 'md' => 'size-6', 'lg' => 'size-8', 'xl' => 'size-10'][$size]; + + $colours = match ($variant) { + 'filled' => 'bg-surface-container text-on-surface-variant has-checked:bg-primary has-checked:text-on-primary', + 'outlined' => 'border border-outline-variant text-on-surface-variant has-checked:border-transparent has-checked:bg-inverse-surface has-checked:text-inverse-on-surface', + default => 'bg-secondary-container text-on-secondary-container has-checked:bg-secondary has-checked:text-on-secondary', + }; +@endphp + +
only(['class', 'wire:key'])->class('min-w-0') }}> + @if (filled($label)) + {{ $label }} + @endif + +
! $inline, 'w-fit' => $inline])> + @foreach ($options as $option) + + @endforeach +
+ + @if ($messages !== []) + @foreach ($messages as $message) +

{{ $message }}

+ @endforeach + @elseif (filled($hint)) +

{{ $hint }}

+ @endif +
diff --git a/resources/views/components/loading.blade.php b/resources/views/components/loading.blade.php new file mode 100644 index 00000000..e6c925ab --- /dev/null +++ b/resources/views/components/loading.blade.php @@ -0,0 +1,43 @@ +{{-- M3 Expressive's loading indicator: a shape that morphs through seven Expressive shapes while + it turns, for a wait that has no known length. + + In the text colour — primary unless the caller colours it — and 48px unless sized. `contained` + puts it on a primary-container circle, for a spinner over content. It is a `progressbar` + named `label` ("Loading" by default); pass `:label="false"` where something else already + says what is happening, as a button's own spinner does. + + Pure SVG with SMIL, so it animates in every engine without script. Under + `prefers-reduced-motion` the shape rests. The geometry is androidx Compose Material 3's + LoadingIndicator and Morph, ported in bin/loading-indicator.mjs (Apache-2.0): + LoadingIndicatorTokens' 38px indicator in a 48px container, a morph every 650ms on a spring, + a quarter turn per morph and a full turn every 4666ms. --}} + +@props([ + 'contained' => false, + 'label' => null, +]) + +@php + $sized = preg_match('/(^|\s)(size|w|h)-/', (string) $attributes->get('class')) === 1; + $coloured = preg_match('/(^|\s)text-(?!(xs|sm|base|lg|xl|[2-9]xl|left|center|right|start|end)(\s|$))/', (string) $attributes->get('class')) === 1; + $decorative = $label === false; + $label = $decorative ? null : ($label ?? __('Loading')); + + $attributes = $attributes + ->class([ + 'inline-flex shrink-0 items-center justify-center', + 'size-12' => ! $sized, + 'rounded-corner-full bg-primary-container text-on-primary-container' => $contained, + 'text-primary' => ! $contained && ! $coloured, + ]) + ->merge(array_filter([ + 'role' => $decorative ? null : 'progressbar', + 'aria-label' => $label, + 'aria-hidden' => $decorative ? 'true' : null, + ])); +@endphp + + + {{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(true, new \Illuminate\View\ComponentAttributeBag(['class' => 'size-full motion-reduce:hidden', 'aria-hidden' => 'true', 'focusable' => 'false'])) }} + {{ \NoNameWeb\LivewireMaterial\Support\SvgFile::loadingIndicator(false, new \Illuminate\View\ComponentAttributeBag(['class' => 'hidden size-full motion-reduce:block', 'aria-hidden' => 'true', 'focusable' => 'false'])) }} + diff --git a/resources/views/components/menu-group.blade.php b/resources/views/components/menu-group.blade.php new file mode 100644 index 00000000..a5311157 --- /dev/null +++ b/resources/views/components/menu-group.blade.php @@ -0,0 +1,9 @@ +{{-- A labelled group of items in an ``: "Sort by", "Share with". --}} + +@props(['label']) + +
class('py-1 first:pt-0 last:pb-0') }}> + + + {{ $slot }} +
diff --git a/resources/views/components/menu-item.blade.php b/resources/views/components/menu-item.blade.php new file mode 100644 index 00000000..03f19bd4 --- /dev/null +++ b/resources/views/components/menu-item.blade.php @@ -0,0 +1,81 @@ +{{-- One item in an ``: an action, a link, or a choice. + + `label`, a leading `icon`, an `icon-right`, a `description` under the label and a + `shortcut` at the end (M3's trailing supporting text: "⌘C"). `link` makes it an anchor, with + `wire:navigate` unless `external` or `no-wire-navigate`. `selected` (true or false) makes it a + `menuitemcheckbox` with `aria-checked`; a selected item takes Expressive's selected shape and + tertiary-container. `disabled` keeps it in the list, out of reach. `keep-open` leaves the menu + open when it is activated — for a choice the person may want to change twice. + + 44px tall (SegmentedMenuTokens.Item), body-large label, 20px icons, 4px corners that open to + 12px at the ends of the list. --}} + +@props([ + 'label' => null, + 'icon' => null, + 'iconRight' => null, + 'description' => null, + 'shortcut' => null, + 'link' => null, + 'external' => false, + 'noWireNavigate' => false, + 'selected' => null, + 'disabled' => false, + 'keepOpen' => false, +]) + +@php + $isLink = filled($link); + $tag = $isLink ? 'a' : 'button'; + + $attributes = $attributes + ->class([ + 'group/item state-layer flex w-full min-h-11 cursor-pointer items-center gap-3 px-3 text-start outline-none', + 'rounded-corner-xs first:rounded-t-corner-md last:rounded-b-corner-md', + 'transition-[border-radius,background-color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast', + 'focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-secondary', + 'py-2' => filled($description), + 'rounded-corner-md bg-tertiary-container text-on-tertiary-container' => $selected === true, + 'pointer-events-none text-on-surface/38' => $disabled, + ]) + ->merge(array_filter([ + 'role' => $selected === null ? 'menuitem' : 'menuitemcheckbox', + 'aria-checked' => $selected === null ? null : ($selected ? 'true' : 'false'), + 'aria-disabled' => $disabled ? 'true' : null, + 'tabindex' => '-1', + 'type' => $isLink ? null : 'button', + '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, + 'data-keep-open' => $keepOpen ? true : null, + ], fn ($value): bool => $value !== null)); + + $iconInk = match (true) { + $disabled => 'text-on-surface/38', + $selected === true => 'text-on-tertiary-container', + default => 'text-on-surface-variant', + }; +@endphp + +<{{ $tag }} {{ $attributes }}> + @if ($icon) + + @endif + + + {{ $label ?? $slot }} + + @if ($description) + {{ $description }} + @endif + + + @if ($shortcut) + {{ $shortcut }} + @endif + + @if ($iconRight) + + @endif + diff --git a/resources/views/components/menu-separator.blade.php b/resources/views/components/menu-separator.blade.php new file mode 100644 index 00000000..82c3aee5 --- /dev/null +++ b/resources/views/components/menu-separator.blade.php @@ -0,0 +1,3 @@ +{{-- A line between groups of items in an ``. --}} + +
class('mx-3 my-1 h-px border-0 bg-outline-variant') }} /> diff --git a/resources/views/components/menu.blade.php b/resources/views/components/menu.blade.php new file mode 100644 index 00000000..a119fea9 --- /dev/null +++ b/resources/views/components/menu.blade.php @@ -0,0 +1,68 @@ +{{-- An M3 Expressive menu: a list of actions that opens from a trigger. + + + + + + + + + + + + The trigger's first button or link becomes the menu button (aria-haspopup, aria-expanded, + aria-controls). The list is a `popover="auto"` in the top layer, placed by CSS anchor + positioning at `position` (`bottom-start`, `bottom-end`, `top-start`, `top-end`) and flipping + when there is no room; a click outside or Escape closes it. The keyboard is WAI-ARIA's menu + button: Enter, Space or ArrowDown open on the first item, ArrowUp on the last; arrows, Home, + End and typing a letter move between items; Tab closes; activating an item closes the menu + unless the item says `keep-open`, and Escape returns focus to the trigger. + + The container is Expressive's standard menu (surface-container-low, 16px corner, elevation + 2), or `vibrant` in tertiary-container — StandardMenuTokens and VibrantMenuTokens from + androidx Compose Material 3 (Apache-2.0). --}} + +@props([ + 'label' => null, + 'position' => 'bottom-start', + 'vibrant' => false, +]) + +@php + $position = in_array($position, ['bottom-start', 'bottom-end', 'top-start', 'top-end'], true) ? $position : 'bottom-start'; + $key = \Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)); + $anchor = "--material-menu-{$key}"; +@endphp + +
class('relative inline-flex') }}> + {{ $trigger }} + + +
diff --git a/resources/views/components/split-button.blade.php b/resources/views/components/split-button.blade.php new file mode 100644 index 00000000..cf6d50df --- /dev/null +++ b/resources/views/components/split-button.blade.php @@ -0,0 +1,71 @@ +{{-- An M3 Expressive split button: an action, and a menu of its alternatives. + + + + + + + Attributes (wire:click, spinner…) go to the leading button; the slot is the menu. `variant` is + `filled` (the default), `tonal`, `outlined` or `elevated` — a text split button does not exist + in M3 — with `color` and `size` as on ``. The halves sit 2px apart; the trailing + one rounds fully and turns its chevron over while the menu is open. `menu-label` names the + trailing button and the menu for screen readers. + + Padding from SplitButton*Tokens (androidx Compose Material 3, Apache-2.0): the leading button + keeps less room on its inner side at the two smallest sizes, and the trailing button is 48px + wide there. The corners are resources/css/components/groups.css. --}} + +@props([ + 'label' => null, + 'icon' => null, + 'variant' => 'filled', + 'color' => 'primary', + 'size' => 'sm', + 'disabled' => false, + 'spinner' => null, + 'menuLabel' => null, + 'position' => 'bottom-end', +]) + +@php + $variant = in_array($variant, ['filled', 'tonal', 'outlined', 'elevated'], true) ? $variant : 'filled'; + $size = in_array($size, ['xs', 'sm', 'md', 'lg', 'xl'], true) ? $size : 'sm'; + $menuLabel ??= __('More options'); + + $leadingPadding = ['xs' => 'pe-2.5', 'sm' => 'pe-3', 'md' => '', 'lg' => '', 'xl' => ''][$size]; + $trailingWidth = ['xs' => 'w-12', 'sm' => 'w-12', 'md' => '', 'lg' => '', 'xl' => ''][$size]; +@endphp + +
only('class')->class('inline-flex items-center gap-0.5') }}> + except('class') }} + :label="$label" + :icon="$icon" + :variant="$variant" + :color="$color" + :size="$size" + :disabled="$disabled" + :spinner="$spinner" + corners="" + data-split="leading" + :class="$leadingPadding" + /> + + + + + + + {{ $slot }} + +
diff --git a/resources/views/components/tooltip.blade.php b/resources/views/components/tooltip.blade.php new file mode 100644 index 00000000..b261be4f --- /dev/null +++ b/resources/views/components/tooltip.blade.php @@ -0,0 +1,51 @@ +{{-- An M3 plain tooltip: a short label for a control, in the inverse surface. + + Two ways in. Inside a component that already names its own anchor — `` + passes `anchor` — it is a bare popover. Standalone, it wraps its trigger: + + + + It shows after a short hover on a pointer that can hover, at once on keyboard focus, and + hides on leave, blur, press and Escape. A phone has no hover, so a control there carries its + words. The bubble is a `popover="manual"` in the top layer — never clipped by an + `overflow-hidden` parent, never widening a scroll container — placed by CSS anchor + positioning on `side` (`top`, `bottom`, `left`, `right`), flipping when there is no room. + + It is aria-hidden: an icon button takes the same words as its aria-label, and a labelled + control would otherwise read them twice. --}} + +@props([ + 'text', + 'side' => 'top', + 'anchor' => null, +]) + +@php + $side = in_array($side, ['top', 'bottom', 'left', 'right'], true) ? $side : 'top'; + $standalone = $anchor === null; + $anchor ??= '--material-tooltip-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)); +@endphp + +@if ($standalone) + + {{ $slot }} +@endif + + + +@if ($standalone) + +@endif diff --git a/resources/views/showcase/components/example.blade.php b/resources/views/showcase/components/example.blade.php new file mode 100644 index 00000000..ed189269 --- /dev/null +++ b/resources/views/showcase/components/example.blade.php @@ -0,0 +1,19 @@ +{{-- One showcase example: the Blade in `code`, rendered, with the source beside it — one string, + so what is shown and what is written can never disagree. --}} + +@props(['title' => null, 'code', 'stack' => false]) + +
+ @if ($title) +

{{ $title }}

+ @endif + +
! $stack, 'space-y-4' => $stack])> + {!! \Illuminate\Support\Facades\Blade::render($code) !!} +
+ +
+ Blade +
{{ trim($code) }}
+
+
diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php index ed5201bf..cec169c2 100644 --- a/resources/views/showcase/index.blade.php +++ b/resources/views/showcase/index.blade.php @@ -12,5 +12,7 @@ @include('livewire-material::showcase.sections.elevation') @include('livewire-material::showcase.sections.motion') @include('livewire-material::showcase.sections.icons') + @include('livewire-material::showcase.sections.buttons') + @include('livewire-material::showcase.sections.menus') @endsection diff --git a/resources/views/showcase/layout.blade.php b/resources/views/showcase/layout.blade.php index 870587b1..799102a4 100644 --- a/resources/views/showcase/layout.blade.php +++ b/resources/views/showcase/layout.blade.php @@ -18,12 +18,12 @@
Livewire Material -
+
@foreach (['light' => 'Light', 'dark' => 'Dark', 'system' => 'System'] as $choice => $label)