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)