Add buttons, menus and the rest of M3 Expressive's actions
tests / browser (firefox, firefox) (push) Successful in 1m54s
tests / browser (safari, webkit) (push) Successful in 2m17s
tests / lint (push) Successful in 59s
tests / feature (8.4) (push) Successful in 1m7s
tests / feature (8.5) (push) Successful in 1m0s
tests / browser (chrome, chromium) (push) Successful in 1m49s

<x-button> (label buttons, icon buttons and toggles in five sizes, with
filled, tonal, outlined, elevated and text variants in any colour role),
<x-tooltip>, <x-menu> with items, groups and separators, <x-button-group>,
<x-group> as a connected button group, <x-split-button>, <x-fab>,
<x-fab-menu> and <x-loading>. 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 06:09:28 +02:00
co-authored by Claude Opus 5
parent b48e879254
commit cd64f4f371
46 changed files with 2968 additions and 36 deletions
+5
View File
@@ -12,6 +12,11 @@ M3 Expressive shapes (resources/svg/shapes)
graphics-shapes, as recorded at the top of bin/shapes.mjs. graphics-shapes, as recorded at the top of bin/shapes.mjs.
Copyright The Android Open Source Project. Apache License 2.0. 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) Google Sans Flex (resources/fonts/google-sans-flex)
Copyright Google LLC. SIL Open Font License 1.1 (resources/fonts/google-sans-flex/OFL.txt). Copyright Google LLC. SIL Open Font License 1.1 (resources/fonts/google-sans-flex/OFL.txt).
Subset: Latin and Latin Extended, weight 400700, roundness 0100. Subset: Latin and Latin Extended, weight 400700, roundness 0100.
+697
View File
@@ -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 <path>
* 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 01 in time and value, by a
* deterministic pattern search over the four control values (all kept in 01, 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 (
`<path><animate attributeName="d" begin="${MORPH_INTERVAL * index}ms" dur="${cycle}ms" repeatCount="indefinite" ` +
`calcMode="spline" keyTimes="0;${riseTime};${collapseTime};1" ` +
`keySplines="${splineText(rise)};${splineText(settle)};${splineText(COLLAPSE)}" ` +
`values="${[...values, collapsedPathData(pairs.length)].join(';')}"/></path>`
)
})
/** 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 =
`<animateTransform attributeName="transform" type="rotate" dur="${MORPH_INTERVAL * rotationMorphs}ms" repeatCount="indefinite" ` +
`calcMode="spline" keyTimes="${rotationKeyTimes.map(time).join(';')}" ` +
`keySplines="${Array(rotationMorphs).fill(`${splineText(rise)};${splineText(settle)}`).join(';')}" ` +
`values="${rotationValues.map((angle) => `${decimal(angle, 3)} ${centre}`).join(';')}"/>`
const globalRotation =
`<animateTransform attributeName="transform" type="rotate" from="0 ${centre}" to="360 ${centre}" ` +
`dur="${GLOBAL_ROTATION_DURATION}ms" repeatCount="indefinite"/>`
const firstFrame = rotated(processPath(polygons[0].cubics, scale), QUARTER_ROTATION)
const open = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${CONTAINER} ${CONTAINER}" fill="currentColor">`
const indicator = `<g transform="scale(${decimal(1 / PRECISION, 2)})">${morphPaths.join('')}</g>`
const files = {
'indeterminate.svg': `${open}<g>${globalRotation}<g>${morphRotation}${indicator}</g></g></svg>\n`,
'static.svg': `${open}<path d="${pathData(firstFrame, decimalHundredths)}"/></svg>\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}`,
)
+13 -7
View File
@@ -42,8 +42,9 @@
* limitations under the License. * 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 { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const OUTPUT = 'resources/svg/shapes' const OUTPUT = 'resources/svg/shapes'
const VIEWBOX = 100 const VIEWBOX = 100
@@ -864,18 +865,23 @@ function pathData(polygon) {
return `${d}Z` 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 (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')) { if (file.endsWith('.svg')) {
rmSync(join(OUTPUT, file)) rmSync(join(OUTPUT, file))
} }
} }
for (const [name, build] of Object.entries(SHAPES)) { for (const [name, build] of Object.entries(SHAPES)) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${VIEWBOX} ${VIEWBOX}" fill="currentColor"><path d="${pathData(fitted(build().normalized()))}"/></svg>\n` const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${VIEWBOX} ${VIEWBOX}" fill="currentColor"><path d="${pathData(fitted(build().normalized()))}"/></svg>\n`
writeFileSync(join(OUTPUT, `${name}.svg`), svg) writeFileSync(join(OUTPUT, `${name}.svg`), svg)
} }
console.log(`Wrote ${Object.keys(SHAPES).length} shapes to ${OUTPUT}`) console.log(`Wrote ${Object.keys(SHAPES).length} shapes to ${OUTPUT}`)
}
+63 -12
View File
@@ -145,7 +145,7 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`,
mail colours cannot drift from the app. mail colours cannot drift from the app.
- **Boost guideline + `livewire-material-development` skill** in the package, and a test that - **Boost guideline + `livewire-material-development` skill** in the package, and a test that
fails when a component is missing from the skill. 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`, - **Semantic ink and line utilities carried over from ReStride** (`text-body`, `text-meta`,
`text-quiet`, `border-structure`, `border-chrome`, `border-divider`) — cheap `@theme` names `text-quiet`, `border-structure`, `border-chrome`, `border-divider`) — cheap `@theme` names
ReStride's templates already use, derived from M3 roles. 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. 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. 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 3. **Repository.** `composer.json` (`nonameweb/livewire-material`, PSR-4
`NoNameWeb\LivewireMaterial\`, requires `php ^8.4`, `laravel/framework ^13`, `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 and without `icons:cache` it scans the folders first. With ~7,800 symbols that is per
request under PHP-FPM. Decided before step 13. 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 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} `@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 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). 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 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` / 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, 21. `icon-button` behaviour inside `button` (icon, no label): standard/filled/tonal/outlined,
`selected` toggle (`aria-pressed`), widths. `selected` toggle (`aria-pressed`), widths.
22. `button-group` (standard and connected; `<x-group>` alias with `wire:model` options), 22. `button-group` (standard and connected; `<x-group>` 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), responsive `fab` prop (extended FAB below `sm`, filled header button above — one element),
`fab-menu`. `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.
- **`<x-button>` 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.
- **`<x-group>` 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.10.18 unit jump at three hand-overs. Reduced motion shows `static.svg`.
- **Showcase examples are Blade strings rendered with `Blade::render()` beside their source**
(`<x-showcase::example>`, 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**, 23. `badge` (dot, count, label; variant/colour), `progress` (linear, circular, **wavy**,
determinate and indeterminate), `toast` (M3 snackbar, action, timeout, stacked), rich determinate and indeterminate), `toast` (M3 snackbar, action, timeout, stacked), rich
`tooltip`, `alert` (tinted container, icon, actions slot), `stat` (figure with `tooltip`, `alert` (tinted container, icon, actions slot), `stat` (figure with
`x-figure`), `empty-state`. `x-figure`), `empty-state`.
### Phase 5 — Containment (`0.5.0`) ### Phase 5 — Containment
24. `card` (elevated, filled, outlined; `title`, `subtitle`, `actions` slot; clickable row 24. `card` (elevated, filled, outlined; `title`, `subtitle`, `actions` slot; clickable row
contract), `divider`, `list` / `list-item` (one-, two-, three-line; leading/trailing; 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, standard; `pane` for list-detail from `xl`; `width` prop), `carousel` (multi-browse, uncontained, hero,
full-screen on CSS scroll-snap), `collapse`. 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()`, 25. `form`, `field` (the shared shell: **outlined and filled**, floating label via `:has()`,
notch, `hint` replaced by error, `aria-invalid` / `aria-describedby`, `data-*` state 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 inputs, value label), `search` (search bar and search view, results through a Livewire
property). 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 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` 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. (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, 28. `app-bar` (small, center-aligned, medium flexible, large flexible, search app bar; sticky,
scroll-elevation), `navigation-bar` (flexible), `navigation-rail` (collapsed, expanded, 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 the theme script), content region with `wire:transition.navigate`, snackbar host. Nothing
app-specific inside; apps pass destinations and extra chrome as slots. 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`), 30. `table` (`.data-table`, descendant selectors, fine-pointer density, `position: relative`),
`sort-header` (`sortBy` array shape, `aria-sort`), Livewire and Laravel pagination views `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 ## Risks and open questions
- **Scope and time.** The whole catalogue (~45 components plus extras) comes before any app - **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 - **Accessibility is entirely ours** — menus, pickers, carousel, sheets. Mitigation: APG
patterns, native elements first, ARIA in render tests, keyboard in browser tests across patterns, native elements first, ARIA in render tests, keyboard in browser tests across
three engines. three engines.
+2 -1
View File
@@ -6,7 +6,8 @@
"build": "vite build", "build": "vite build",
"dev": "vite", "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: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": { "devDependencies": {
"@material/material-color-utilities": "^0.4.0", "@material/material-color-utilities": "^0.4.0",
@@ -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 `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`. The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`.
### `<x-button>`
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 `<a>`, with `wire:navigate` unless external |
| `spinner` | | `true` shows the loading indicator while its `wire:click` runs; a string names the action |
| `tooltip`, `tooltip-left`, `tooltip-right`, `tooltip-bottom` | | plain tooltip; also the icon button's accessible name |
| `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `lg`; `fab` is an extended FAB below `sm`, a filled button above |
```blade
<x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner />
<x-button icon="delete" tooltip="Delete share" wire:click="delete({{ $share->id }})" />
<x-button icon="favorite" aria-label="Keep" variant="tonal" :selected="$kept" wire:click="toggleKeep" />
```
### `<x-tooltip>`
M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus; `aria-hidden`, so the trigger still needs its own accessible name. Buttons and FABs take a `tooltip` prop instead.
### `<x-menu>`, `<x-menu-item>`, `<x-menu-group>`, `<x-menu-separator>`
```blade
<x-menu label="Share actions" position="bottom-end">
<x-slot:trigger>
<x-button icon="more_vert" tooltip="More" />
</x-slot:trigger>
<x-menu-group label="Sort by">
<x-menu-item label="Newest" :selected="$sort === 'newest'" wire:click="$set('sort', 'newest')" keep-open />
</x-menu-group>
<x-menu-separator />
<x-menu-item label="Settings" icon="settings" link="{{ route('settings') }}" />
<x-menu-item label="Delete" icon="delete" wire:click="delete" description="Recipients lose access" shortcut="⌘⌫" />
</x-menu>
```
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `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.
### `<x-button-group>`
A row of `<x-button>`s: `<x-button-group label="View" size="md">…</x-button-group>`. `connected` sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the `size` of the buttons inside.
### `<x-group>`
A choice between a few options as a connected button group of native radios (checkboxes with `multiple`):
```blade
<x-group label="Expires after" wire:model.live="expiry" :options="[
['id' => '1h', 'name' => '1 hour'],
['id' => '1d', 'name' => '1 day', 'icon' => 'today'],
['id' => '7d', 'name' => '7 days', 'disabled' => true],
]" hint="Recipients lose access after that" />
```
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.
### `<x-split-button>`
```blade
<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options">
<x-menu-item label="Download files one by one" wire:click="downloadEach" />
</x-split-button>
```
Attributes go to the leading button; the slot is the menu. `variant` (`filled` default, `tonal`, `outlined`, `elevated`), `color`, `size`, `disabled`, `spinner`, `menu-label`, `position`.
### `<x-fab>`
`<x-fab icon="add" tooltip="New share" />``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 (`<div class="fixed end-4 bottom-4">`). `link`, `external`, `disabled`, `type`.
### `<x-fab-menu>`, `<x-fab-menu-item>`
```blade
<div class="fixed end-4 bottom-4">
<x-fab-menu label="New">
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
<x-fab-menu-item label="Paste text" icon="content_paste" link="{{ route('paste') }}" />
</x-fab-menu>
</div>
```
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard as `<x-menu>`.
### `<x-loading>`
M3 Expressive's loading indicator — a shape morphing through seven Expressive shapes as it turns — for a wait of unknown length. 48px and `primary` unless sized or coloured by class; `contained` sets it on a primary-container circle. A `progressbar` named by `label` ("Loading"); `:label="false"` makes it decorative. It rests under reduced motion.
```blade
<x-loading />
<x-loading contained class="size-8" label="Uploading" />
<div wire:loading.flex wire:target="upload"><x-loading /></div>
```
`<x-button spinner>` shows a decorative one in place of its icon.
## Testing the design ## Testing the design
```php ```php
+84
View File
@@ -0,0 +1,84 @@
/*
* Button groups and split buttons: shapes that depend on a button's place among its siblings.
*
* Unlayered on purpose. Each <x-button> 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 (`<x-button-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 (`<x-button-group connected>`, `<x-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 (`<x-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;
}
+1
View File
@@ -20,6 +20,7 @@
@import './tokens/font.css'; @import './tokens/font.css';
@import './tokens/theme.css'; @import './tokens/theme.css';
@import './tokens/state.css'; @import './tokens/state.css';
@import './components/groups.css';
@layer base { @layer base {
html { html {
+2
View File
@@ -9,3 +9,5 @@
*/ */
import './theme.js' import './theme.js'
import './figure.js' import './figure.js'
import './tooltip.js'
import './menu.js'
+172
View File
@@ -0,0 +1,172 @@
/**
* `materialMenu`: the behaviour of `<x-menu>` 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())
},
}))
})
+57
View File
@@ -0,0 +1,57 @@
/**
* `materialTooltip`: shows an `<x-tooltip>` 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())
},
}))
})
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor"><path d="M31.49 13.69c.31.23.71.35 1.13.32.98-.07 1.96-.15 2.95-.22 1.26-.1 2.19 1.16 1.71 2.34-.38.92-.75 1.84-1.12 2.76-.32.77-.03 1.66.68 2.09.84.52 1.68 1.04 2.52 1.56 1.08.66 1.09 2.23.01 2.9-.84.52-1.69 1.05-2.53 1.57-.71.44-1 1.33-.68 2.1.38.91.75 1.82 1.13 2.74.48 1.17-.44 2.44-1.7 2.35-.99-.08-1.98-.15-2.97-.22-.83-.06-1.59.49-1.78 1.3-.24.96-.47 1.92-.7 2.88-.3 1.23-1.79 1.72-2.76.9-.76-.64-1.52-1.28-2.27-1.92-.64-.54-1.57-.54-2.21 0-.75.64-1.5 1.28-2.26 1.92-.96.83-2.45.34-2.76-.89-.23-.96-.47-1.93-.71-2.89-.2-.81-.95-1.36-1.79-1.29-.98.07-1.96.15-2.95.22-1.26.1-2.19-1.16-1.71-2.34.38-.92.75-1.84 1.12-2.76.32-.77.03-1.66-.68-2.09-.84-.52-1.68-1.04-2.52-1.56-1.08-.66-1.09-2.23-.01-2.9.84-.52 1.69-1.05 2.53-1.57.71-.44 1-1.33.68-2.1-.38-.91-.75-1.82-1.13-2.74-.48-1.17.44-2.44 1.7-2.35.99.08 1.98.15 2.97.22.83.06 1.59-.49 1.78-1.3.24-.96.47-1.92.7-2.88.3-1.23 1.79-1.72 2.76-.9.76.64 1.52 1.28 2.27 1.92.64.54 1.57.54 2.21 0 .75-.64 1.5-1.28 2.26-1.92.96-.83 2.45-.34 2.76.89.23.96.47 1.93.71 2.89.1.4.34.74.66.97Z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,42 @@
{{-- An M3 Expressive button group: related `<x-button>`s in a row.
<x-button-group size="md" label="Text formatting">
<x-button icon="format_bold" aria-label="Bold" variant="tonal" :selected="$bold" wire:click="toggleBold" />
</x-button-group>
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, `<x-group>` 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
<div
role="group"
@if ($label) aria-label="{{ $label }}" @endif
data-button-group="{{ $connected ? 'connected' : 'standard' }}"
data-size="{{ $size }}"
{{ $attributes->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 }}
</div>
+226
View File
@@ -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)
<span wire:loading.flex wire:target="{{ $spinnerTarget }}" class="items-center justify-center">
<x-loading :class="$iconSize" :label="false" />
</span>
@endif
@if ($icon)
<span class="contents" @if ($spinnerTarget) wire:loading.remove wire:target="{{ $spinnerTarget }}" @endif>
<x-icon :name="$icon" :filled="$selected === true" :class="\Illuminate\Support\Arr::toCssClasses([$iconSize, 'max-sm:size-6' => $fab])" />
</span>
@endif
@unless ($iconOnly)
<span @class(['max-lg:hidden' => $responsive])>{{ $label ?? $slot }}</span>
@endunless
@if ($iconRight)
<x-icon :name="$iconRight" :class="$iconSize" />
@endif
@if ($tip !== null)
<x-tooltip :text="$tip" :side="$tipSide" :anchor="$anchor" />
@endif
</{{ $tag }}>
@@ -0,0 +1,47 @@
{{-- One action in an `<x-fab-menu>`: 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)
<x-icon :name="$icon" class="size-6" />
@endif
<span>{{ $label ?? $slot }}</span>
</{{ $tag }}>
@@ -0,0 +1,77 @@
{{-- An M3 Expressive FAB menu: a FAB that opens into a short list of related actions.
<div class="fixed end-4 bottom-4">
<x-fab-menu label="New">
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
<x-fab-menu-item label="Upload a folder" icon="drive_folder_upload" wire:click="uploadFolder" />
</x-fab-menu>
</div>
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 `<x-menu>`. `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
<div x-data="materialMenu" {{ $attributes->class('relative inline-flex') }}>
<span x-ref="trigger" class="inline-flex" style="anchor-name: {{ $anchor }}"
x-on:click="toggle('first')"
x-on:keydown.down.prevent="open('first')"
x-on:keydown.up.prevent="open('last')"
>
<button
type="button"
@if ($label) aria-label="{{ $label }}" @endif
@class([
'group/fab state-layer focus-ring inline-flex size-14 shrink-0 cursor-pointer items-center justify-center rounded-corner-lg shadow-elevation-3 hover:shadow-elevation-4',
'transition-[border-radius,background-color,color,box-shadow] duration-(--md-sys-motion-spatial-default-duration) ease-spatial-default aria-expanded:rounded-corner-full',
$colours,
])
>
<x-icon :name="$icon" class="size-6 group-aria-expanded/fab:hidden" />
<x-icon name="close" class="hidden size-5 group-aria-expanded/fab:block" />
</button>
</span>
<div
x-ref="menu"
id="material-fab-menu-{{ $key }}"
popover="auto"
role="menu"
@if ($label) aria-label="{{ $label }}" @endif
tabindex="-1"
style="position-anchor: {{ $anchor }}"
x-on:keydown="navigate($event)"
x-on:click="activate($event)"
@class([
'm-0 flex-col gap-1 overflow-visible border-0 bg-transparent p-0 open:flex [inset:auto]',
'mb-2 items-end [position-area:top_span-left]' => $position === 'top-end',
'mb-2 items-start [position-area:top_span-right]' => $position === 'top-start',
'mt-2 items-end [position-area:bottom_span-left]' => $position === 'bottom-end',
'mt-2 items-start [position-area:bottom_span-right]' => $position === 'bottom-start',
])
>
{{ $slot }}
</div>
</div>
+78
View File
@@ -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.
`<div class="fixed end-4 bottom-4"><x-fab icon="add" tooltip="New share" /></div>`. For a
create action that is a FAB on a phone and a header button above, use `<x-button fab>`.
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)
<x-icon :name="$icon" :class="$iconSize" />
@endif
@if ($extended)
<span>{{ $label ?? $slot }}</span>
@endif
@if ($tooltip !== null)
<x-tooltip :text="$tooltip" :anchor="$anchor" />
@endif
</{{ $tag }}>
@@ -0,0 +1,97 @@
{{-- A choice of a few options as an M3 Expressive connected button group the successor of the
segmented button.
<x-group label="Theme" wire:model.live="theme" :options="[
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
['id' => 'dark', 'name' => 'Dark', 'icon' => 'dark_mode'],
]" />
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
<fieldset {{ $attributes->only(['class', 'wire:key'])->class('min-w-0') }}>
@if (filled($label))
<legend class="mb-2 type-label-lg text-on-surface-variant">{{ $label }}</legend>
@endif
<div data-button-group="connected" data-size="{{ $size }}" @class(['flex gap-0.5', 'w-full' => ! $inline, 'w-fit' => $inline])>
@foreach ($options as $option)
<label @class([
'state-layer relative flex cursor-pointer select-none items-center justify-center whitespace-nowrap',
'transition-[border-radius,background-color,color] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
'min-w-0 flex-1' => ! $inline,
$segment,
$colours,
'has-focus-visible:outline-3 has-focus-visible:outline-offset-2 has-focus-visible:outline-secondary',
'has-disabled:cursor-not-allowed has-disabled:bg-on-surface/10 has-disabled:text-on-surface/38',
])>
<input
{{ $attributes->whereStartsWith(['wire:model', 'x-model']) }}
type="{{ $multiple ? 'checkbox' : 'radio' }}"
name="{{ $multiple ? $name.'[]' : $name }}"
value="{{ data_get($option, $optionValue) }}"
@disabled(data_get($option, 'disabled'))
class="peer sr-only"
/>
@if (filled(data_get($option, $optionIcon)))
<x-icon :name="data_get($option, $optionIcon)" :class="$iconSize" />
@endif
<span class="truncate">{{ data_get($option, $optionLabel) }}</span>
</label>
@endforeach
</div>
@if ($messages !== [])
@foreach ($messages as $message)
<p class="mt-1 type-body-sm text-error">{{ $message }}</p>
@endforeach
@elseif (filled($hint))
<p class="mt-1 type-body-sm text-on-surface-variant">{{ $hint }}</p>
@endif
</fieldset>
@@ -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
<span {{ $attributes }}>
{{ \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'])) }}
</span>
@@ -0,0 +1,9 @@
{{-- A labelled group of items in an `<x-menu>`: "Sort by", "Share with". --}}
@props(['label'])
<div role="group" aria-label="{{ $label }}" {{ $attributes->class('py-1 first:pt-0 last:pb-0') }}>
<div aria-hidden="true" class="px-3 pt-2 pb-1 type-label-lg text-on-surface-variant">{{ $label }}</div>
{{ $slot }}
</div>
@@ -0,0 +1,81 @@
{{-- One item in an `<x-menu>`: 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)
<x-icon :name="$icon" :filled="$selected === true" :class="'size-5 '.$iconInk" />
@endif
<span class="min-w-0 flex-1">
<span class="block truncate type-body-lg">{{ $label ?? $slot }}</span>
@if ($description)
<span @class(['block type-body-md', $iconInk])>{{ $description }}</span>
@endif
</span>
@if ($shortcut)
<span @class(['shrink-0 type-label-sm', $iconInk])>{{ $shortcut }}</span>
@endif
@if ($iconRight)
<x-icon :name="$iconRight" :class="'size-5 '.$iconInk" />
@endif
</{{ $tag }}>
@@ -0,0 +1,3 @@
{{-- A line between groups of items in an `<x-menu>`. --}}
<hr role="separator" {{ $attributes->class('mx-3 my-1 h-px border-0 bg-outline-variant') }} />
+68
View File
@@ -0,0 +1,68 @@
{{-- An M3 Expressive menu: a list of actions that opens from a trigger.
<x-menu label="Share actions">
<x-slot:trigger>
<x-button icon="more_vert" tooltip="More" />
</x-slot:trigger>
<x-menu-item label="Copy link" icon="content_copy" wire:click="copy" />
<x-menu-separator />
<x-menu-item label="Delete" icon="delete" wire:click="delete" />
</x-menu>
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
<div x-data="materialMenu" {{ $attributes->class('relative inline-flex') }}>
<span x-ref="trigger" class="inline-flex" style="anchor-name: {{ $anchor }}"
x-on:click="toggle('first')"
x-on:keydown.down.prevent="open('first')"
x-on:keydown.up.prevent="open('last')"
>{{ $trigger }}</span>
<div
x-ref="menu"
id="material-menu-{{ $key }}"
popover="auto"
role="menu"
@if ($label) aria-label="{{ $label }}" @endif
tabindex="-1"
style="position-anchor: {{ $anchor }}"
x-on:keydown="navigate($event)"
x-on:click="activate($event)"
@class([
'm-0 min-w-28 max-w-70 overflow-visible border-0 p-1 rounded-corner-lg shadow-elevation-2 [inset:auto]',
'my-1 [position-try-fallbacks:flip-block,flip-inline]',
'opacity-0 transition-[opacity,translate,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
'bg-surface-container-low text-on-surface' => ! $vibrant,
'bg-tertiary-container text-on-tertiary-container' => $vibrant,
'[position-area:bottom_span-right]' => $position === 'bottom-start',
'[position-area:bottom_span-left]' => $position === 'bottom-end',
'[position-area:top_span-right]' => $position === 'top-start',
'[position-area:top_span-left]' => $position === 'top-end',
])
>
{{ $slot }}
</div>
</div>
@@ -0,0 +1,71 @@
{{-- An M3 Expressive split button: an action, and a menu of its alternatives.
<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options">
<x-menu-item label="Download as ZIP" wire:click="downloadZip" />
<x-menu-item label="Download files one by one" wire:click="downloadEach" />
</x-split-button>
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 `<x-button>`. 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
<div data-button-group="split" data-size="{{ $size }}" {{ $attributes->only('class')->class('inline-flex items-center gap-0.5') }}>
<x-button
{{ $attributes->except('class') }}
:label="$label"
:icon="$icon"
:variant="$variant"
:color="$color"
:size="$size"
:disabled="$disabled"
:spinner="$spinner"
corners=""
data-split="leading"
:class="$leadingPadding"
/>
<x-menu :position="$position" :label="$menuLabel">
<x-slot:trigger>
<x-button
icon="keyboard_arrow_down"
:aria-label="$menuLabel"
:variant="$variant"
:color="$color"
:size="$size"
:disabled="$disabled"
corners=""
data-split="trailing"
:class="$trailingWidth"
/>
</x-slot:trigger>
{{ $slot }}
</x-menu>
</div>
@@ -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 `<x-button tooltip="…">`
passes `anchor` it is a bare popover. Standalone, it wraps its trigger:
<x-tooltip text="Copy link"><button ></button></x-tooltip>
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)
<span class="inline-flex" style="anchor-name: {{ $anchor }}">
{{ $slot }}
@endif
<span
popover="manual"
aria-hidden="true"
x-data="materialTooltip"
style="position-anchor: {{ $anchor }}"
@class([
'pointer-events-none m-0 max-w-50 overflow-visible border-0 rounded-corner-xs bg-inverse-surface px-2 py-1 text-center whitespace-normal type-body-sm text-inverse-on-surface [inset:auto]',
'opacity-0 transition-[opacity,display,overlay] transition-discrete duration-(--md-sys-motion-effects-fast-duration) ease-effects-fast open:opacity-100 starting:open:opacity-0',
'my-1 [position-area:top] [position-try-fallbacks:flip-block]' => $side === 'top',
'my-1 [position-area:bottom] [position-try-fallbacks:flip-block]' => $side === 'bottom',
'mx-1 [position-area:left] [position-try-fallbacks:flip-inline]' => $side === 'left',
'mx-1 [position-area:right] [position-try-fallbacks:flip-inline]' => $side === 'right',
])
>{{ $text }}</span>
@if ($standalone)
</span>
@endif
@@ -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])
<div class="space-y-4 rounded-corner-lg bg-surface-container p-4">
@if ($title)
<h3 class="type-title-md">{{ $title }}</h3>
@endif
<div @class(['flex flex-wrap items-center gap-4' => ! $stack, 'space-y-4' => $stack])>
{!! \Illuminate\Support\Facades\Blade::render($code) !!}
</div>
<details>
<summary class="w-fit cursor-pointer rounded-corner-xs type-label-md text-on-surface-variant focus-ring">Blade</summary>
<pre class="mt-2 overflow-x-auto rounded-corner-sm bg-surface-container-highest p-3 type-body-sm"><code>{{ trim($code) }}</code></pre>
</details>
</div>
+2
View File
@@ -12,5 +12,7 @@
@include('livewire-material::showcase.sections.elevation') @include('livewire-material::showcase.sections.elevation')
@include('livewire-material::showcase.sections.motion') @include('livewire-material::showcase.sections.motion')
@include('livewire-material::showcase.sections.icons') @include('livewire-material::showcase.sections.icons')
@include('livewire-material::showcase.sections.buttons')
@include('livewire-material::showcase.sections.menus')
</main> </main>
@endsection @endsection
+2 -2
View File
@@ -18,12 +18,12 @@
<a href="{{ route('livewire-material.showcase') }}" class="type-title-lg">Livewire Material</a> <a href="{{ route('livewire-material.showcase') }}" class="type-title-lg">Livewire Material</a>
<nav class="flex flex-wrap gap-x-4 gap-y-1 type-label-lg text-on-surface-variant" aria-label="Sections"> <nav class="flex flex-wrap gap-x-4 gap-y-1 type-label-lg text-on-surface-variant" aria-label="Sections">
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons'] as $anchor => $section) @foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus'] as $anchor => $section)
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a> <a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
@endforeach @endforeach
</nav> </nav>
<div class="ms-auto flex rounded-corner-full border border-outline" role="group" aria-label="Theme" x-data> <div class="ms-auto flex rounded-corner-full border border-outline" role="group" aria-label="Theme" x-data x-cloak>
@foreach (['light' => 'Light', 'dark' => 'Dark', 'system' => 'System'] as $choice => $label) @foreach (['light' => 'Light', 'dark' => 'Dark', 'system' => 'System'] as $choice => $label)
<button <button
type="button" type="button"
@@ -0,0 +1,139 @@
@php
$examples = [
'Variants' => <<<'BLADE'
<x-button label="Filled" variant="filled" />
<x-button label="Tonal" variant="tonal" />
<x-button label="Outlined" variant="outlined" />
<x-button label="Elevated" variant="elevated" />
<x-button label="Text" />
BLADE,
'Sizes, with an icon' => <<<'BLADE'
<x-button label="Extra small" icon="upload" variant="filled" size="xs" />
<x-button label="Small" icon="upload" variant="filled" />
<x-button label="Medium" icon="upload" variant="filled" size="md" />
<x-button label="Large" icon="upload" variant="filled" size="lg" />
<x-button label="Extra large" icon="upload" variant="filled" size="xl" />
BLADE,
'Square, and pressed (hold the pointer down)' => <<<'BLADE'
<x-button label="Round" variant="tonal" size="md" />
<x-button label="Square" variant="tonal" size="md" shape="square" />
BLADE,
'Colours' => <<<'BLADE'
<x-button label="Primary" primary />
<x-button label="Secondary" variant="filled" color="secondary" />
<x-button label="Tertiary" variant="filled" color="tertiary" />
<x-button label="Delete" icon="delete" danger />
<x-button label="Take down" caution />
<x-button label="Success" variant="tonal" color="success" />
<x-button label="Info" variant="outlined" color="info" />
BLADE,
'Toggle buttons' => <<<'BLADE'
<x-button label="Filled" variant="filled" :selected="false" />
<x-button label="Selected" variant="filled" :selected="true" />
<x-button label="Tonal" variant="tonal" :selected="false" />
<x-button label="Selected" variant="tonal" :selected="true" />
<x-button label="Outlined" variant="outlined" :selected="false" />
<x-button label="Selected" variant="outlined" :selected="true" />
BLADE,
'Icon buttons, and their tooltips' => <<<'BLADE'
<x-button icon="favorite" tooltip="Standard" />
<x-button icon="favorite" tooltip="Filled" variant="filled" />
<x-button icon="favorite" tooltip="Tonal" variant="tonal" />
<x-button icon="favorite" tooltip="Outlined" variant="outlined" />
<x-button icon="favorite" tooltip="Selected" variant="filled" :selected="true" />
<x-button icon="favorite" tooltip="Selected, square" variant="tonal" shape="square" :selected="true" />
BLADE,
'Icon button sizes and widths' => <<<'BLADE'
<x-button icon="share" tooltip="XS narrow" variant="tonal" size="xs" width="narrow" />
<x-button icon="share" tooltip="S" variant="tonal" />
<x-button icon="share" tooltip="M wide" variant="tonal" size="md" width="wide" />
<x-button icon="share" tooltip="L" variant="tonal" size="lg" />
<x-button icon="share" tooltip="XL wide" variant="tonal" size="xl" width="wide" />
BLADE,
'Links and disabled' => <<<'BLADE'
<x-button label="Opens a page" link="#buttons" variant="outlined" />
<x-button label="Leaves the site" link="https://m3.material.io" external icon-right="open_in_new" />
<x-button label="Disabled" variant="filled" disabled />
<x-button label="Disabled link" link="#buttons" variant="tonal" disabled />
<x-button icon="delete" tooltip="Disabled" variant="outlined" disabled />
BLADE,
'Loading indicator' => <<<'BLADE'
<x-loading />
<x-loading contained />
<x-loading class="size-24 text-tertiary" label="Uploading" />
<x-loading contained class="size-8" />
BLADE,
'Button groups: standard (press a button) and connected' => <<<'BLADE'
<x-button-group label="Standard" size="md">
<x-button label="Day" variant="tonal" size="md" />
<x-button label="Week" variant="tonal" size="md" />
<x-button label="Month" variant="tonal" size="md" />
</x-button-group>
<x-button-group label="Formatting" connected>
<x-button icon="format_bold" aria-label="Bold" variant="tonal" :selected="true" />
<x-button icon="format_italic" aria-label="Italic" variant="tonal" :selected="false" />
<x-button icon="format_underlined" aria-label="Underline" variant="tonal" :selected="false" />
</x-button-group>
BLADE,
'A choice as a connected group (<x-group>)' => <<<'BLADE'
<div x-data="{ theme: 'system', days: ['mon'] }" class="grid w-full gap-6 md:grid-cols-2">
<x-group label="Theme" name="showcase-theme" x-model="theme" :options="[
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
['id' => 'dark', 'name' => 'Dark', 'icon' => 'dark_mode'],
['id' => 'system', 'name' => 'System', 'icon' => 'computer'],
]" />
<x-group label="Days" name="showcase-days" x-model="days" multiple variant="outlined" hint="Choose any" :options="[
['id' => 'mon', 'name' => 'Mon'],
['id' => 'tue', 'name' => 'Tue'],
['id' => 'wed', 'name' => 'Wed'],
['id' => 'thu', 'name' => 'Thu', 'disabled' => true],
]" />
</div>
BLADE,
'Split buttons' => <<<'BLADE'
<x-split-button label="Download all" icon="download" menu-label="Download options">
<x-menu-item label="As a ZIP" icon="folder_zip" />
<x-menu-item label="One by one" icon="download" />
</x-split-button>
<x-split-button label="Share" variant="tonal" size="md" menu-label="Share options">
<x-menu-item label="Copy link" icon="content_copy" />
<x-menu-item label="Email" icon="mail" />
</x-split-button>
<x-split-button label="Save" variant="outlined" size="xs" menu-label="Save options">
<x-menu-item label="Save as draft" />
</x-split-button>
BLADE,
'FABs and extended FABs' => <<<'BLADE'
<x-fab icon="add" tooltip="New share" />
<x-fab icon="edit" size="md" color="secondary" tooltip="Edit" />
<x-fab icon="upload" size="lg" color="tertiary" tooltip="Upload" />
<x-fab icon="add" label="New share" />
<x-fab icon="upload" label="Upload" size="md" variant="filled" />
BLADE,
'FAB menu' => <<<'BLADE'
<div class="flex h-72 w-full items-end justify-end">
<x-fab-menu label="New">
<x-fab-menu-item label="Upload files" icon="upload_file" />
<x-fab-menu-item label="Upload a folder" icon="drive_folder_upload" />
<x-fab-menu-item label="Paste text" icon="content_paste" />
</x-fab-menu>
</div>
BLADE,
];
@endphp
<section id="buttons" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Buttons</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-button&gt;</code>: label buttons, icon buttons and toggles in M3 Expressive's five sizes.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code />
@endforeach
</section>
@@ -2,16 +2,19 @@
id="icons" id="icons"
class="scroll-mt-24 space-y-6" class="scroll-mt-24 space-y-6"
x-data="{ x-data="{
names: {{ json_encode(\NoNameWeb\LivewireMaterial\Support\SvgFile::symbolNames()) }}, names: [],
base: '{{ rtrim(route('livewire-material.showcase', [], false), '/') }}/symbols',
query: '', query: '',
filled: false, filled: false,
async load() {
this.names = await (await fetch('{{ route('livewire-material.symbols', [], false) }}')).json();
},
get matches() { get matches() {
const query = this.query.trim().toLowerCase().replaceAll(' ', '_'); const query = this.query.trim().toLowerCase().replaceAll(' ', '_');
return (query === '' ? this.names : this.names.filter((name) => name.includes(query))).slice(0, 120); return (query === '' ? this.names : this.names.filter((name) => name.includes(query))).slice(0, 120);
}, },
}" }"
x-intersect.once="load()"
> >
<h2 class="type-headline-md">Icons</h2> <h2 class="type-headline-md">Icons</h2>
@@ -37,7 +40,7 @@
<span <span
class="size-6 bg-current" class="size-6 bg-current"
x-bind:style="{ x-bind:style="{
mask: `url('${base}/${filled ? 'filled' : 'outlined'}/${name}.svg') center / contain no-repeat`, mask: `url('{{ rtrim(route('livewire-material.showcase', [], false), '/') }}/symbols/${filled ? 'filled' : 'outlined'}/${name}.svg') center / contain no-repeat`,
}" }"
></span> ></span>
<code class="w-full break-all type-label-sm text-on-surface-variant" x-text="name"></code> <code class="w-full break-all type-label-sm text-on-surface-variant" x-text="name"></code>
@@ -0,0 +1,53 @@
@php
$examples = [
'A menu' => <<<'BLADE'
<x-menu label="Share actions">
<x-slot:trigger>
<x-button icon="more_vert" tooltip="More" />
</x-slot:trigger>
<x-menu-item label="Copy link" icon="content_copy" shortcut="⌘C" />
<x-menu-item label="Download" icon="download" />
<x-menu-item label="Rename" icon="edit" description="Change what recipients see" />
<x-menu-separator />
<x-menu-item label="Delete" icon="delete" />
</x-menu>
<x-menu label="Sort" position="bottom-end">
<x-slot:trigger>
<x-button label="Sort" icon-right="arrow_drop_down" variant="outlined" />
</x-slot:trigger>
<x-menu-group label="Sort by">
<x-menu-item label="Newest" :selected="true" keep-open />
<x-menu-item label="Largest" :selected="false" keep-open />
<x-menu-item label="Expiring soon" :selected="false" keep-open />
</x-menu-group>
<x-menu-separator />
<x-menu-item label="Reset" icon="restart_alt" disabled />
</x-menu>
<x-menu label="Vibrant" vibrant>
<x-slot:trigger>
<x-button label="Vibrant" variant="tonal" color="tertiary" />
</x-slot:trigger>
<x-menu-item label="Upload files" icon="upload_file" />
<x-menu-item label="Upload a folder" icon="drive_folder_upload" />
</x-menu>
BLADE,
];
@endphp
<section id="menus" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Menus</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-menu&gt;</code> with <code>&lt;x-menu-item&gt;</code>, <code>&lt;x-menu-group&gt;</code> and <code>&lt;x-menu-separator&gt;</code>.
Open one with the keyboard too: arrows, Home, End, a letter, Escape.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code />
@endforeach
</section>
+2 -1
View File
@@ -5,4 +5,5 @@ use NoNameWeb\LivewireMaterial\Http\Controllers\ShowcaseSymbolController;
Route::view('/', 'livewire-material::showcase.index')->name('showcase'); Route::view('/', 'livewire-material::showcase.index')->name('showcase');
Route::get('symbols/{style}/{name}.svg', ShowcaseSymbolController::class)->name('symbol'); Route::get('symbols.json', [ShowcaseSymbolController::class, 'index'])->name('symbols');
Route::get('symbols/{style}/{name}.svg', [ShowcaseSymbolController::class, 'show'])->name('symbol');
@@ -2,18 +2,25 @@
namespace NoNameWeb\LivewireMaterial\Http\Controllers; namespace NoNameWeb\LivewireMaterial\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\View\ComponentAttributeBag; use Illuminate\View\ComponentAttributeBag;
use InvalidArgumentException; use InvalidArgumentException;
use NoNameWeb\LivewireMaterial\Support\SvgFile; use NoNameWeb\LivewireMaterial\Support\SvgFile;
/** /**
* One Material Symbol as an SVG file, for the showcase's icon search: drawing the matches as * Material Symbols for the showcase's icon search: the list of names, fetched when the section
* CSS masks keeps 4,000 names searchable without rendering 4,000 inline SVGs. * comes into view rather than inlined into the page, and each symbol as an SVG file, drawn as a
* CSS mask, so 4,000 names stay searchable without 4,000 inline SVGs.
*/ */
class ShowcaseSymbolController class ShowcaseSymbolController
{ {
public function __invoke(string $style, string $name): Response public function index(): JsonResponse
{
return response()->json(SvgFile::symbolNames())->setMaxAge(86400)->setPublic();
}
public function show(string $style, string $name): Response
{ {
abort_unless(in_array($style, ['outlined', 'filled'], true), 404); abort_unless(in_array($style, ['outlined', 'filled'], true), 404);
+7 -1
View File
@@ -64,7 +64,13 @@ class LivewireMaterialServiceProvider extends ServiceProvider
*/ */
protected function registerShowcase(): void protected function registerShowcase(): void
{ {
if (! config('livewire-material.showcase.enabled') || $this->app->routesAreCached()) { if (! config('livewire-material.showcase.enabled')) {
return;
}
Blade::anonymousComponentPath(__DIR__.'/../resources/views/showcase/components', 'showcase');
if ($this->app->routesAreCached()) {
return; return;
} }
+8
View File
@@ -45,6 +45,14 @@ class SvgFile
return static::render($path, $attributes); return static::render($path, $attributes);
} }
/**
* M3 Expressive's loading indicator: the morphing, rotating shape, or the shape at rest.
*/
public static function loadingIndicator(bool $animated, ComponentAttributeBag $attributes): HtmlString
{
return static::render(static::directory('svg/loading-indicator/'.($animated ? 'indeterminate' : 'static').'.svg'), $attributes);
}
/** /**
* The names of every Material Symbol the package ships. * The names of every Material Symbol the package ships.
* *
+125
View File
@@ -0,0 +1,125 @@
<?php
const MORE = '#menus [aria-label="More"]';
function showcase()
{
return visit('/material')->waitForEvent('networkidle');
}
function focused(string $expression): string
{
return "document.activeElement.{$expression}";
}
it('opens a menu on the first item and walks it with the keyboard', function () {
$page = showcase()
->assertNoJavaScriptErrors()
->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true')
->assertScript(focused("textContent.trim().startsWith('Copy link')"));
$page->keys(':focus', ['ArrowDown', 'ArrowDown'])
->assertScript(focused("textContent.trim().startsWith('Rename')"));
$page->keys(':focus', 'End')
->assertScript(focused("textContent.trim().startsWith('Delete')"));
$page->keys(':focus', 'ArrowDown')
->assertScript(focused("textContent.trim().startsWith('Copy link')"));
$page->keys(':focus', 'd')
->assertScript(focused("textContent.trim().startsWith('Download')"));
$page->keys(':focus', 'Escape')
->assertAttribute(MORE, 'aria-expanded', 'false')
->assertScript(focused("getAttribute('aria-label') === 'More'"));
});
it('opens a menu from the keyboard, on the last item with ArrowUp', function () {
$page = showcase();
$page->script("document.querySelector('".MORE."').focus()");
$page->keys(':focus', 'ArrowUp')
->assertAttribute(MORE, 'aria-expanded', 'true')
->assertScript(focused("textContent.trim().startsWith('Delete')"));
});
it('opens a menu the moment the page can be used', function () {
// The guard against a light-dismiss press reopening the menu once measured from the page's
// time origin, and swallowed every click in the first quarter second.
visit('/material')
->click(MORE)
->assertAttribute(MORE, 'aria-expanded', 'true');
});
it('closes a menu when an item is chosen, but not one that keeps it open', function () {
$page = showcase();
$page->click(MORE)
->click('#menus [role="menuitem"]:has-text("Download")')
->assertAttribute(MORE, 'aria-expanded', 'false');
$page->click('#menus button:has-text("Sort")')
->click('#menus [role="menuitemcheckbox"]:has-text("Largest")')
->assertScript("document.querySelector('#menus [role=\"menu\"][aria-label=\"Sort\"]').matches(':popover-open')");
});
it('shows a tooltip on keyboard focus and hides it on Escape', function () {
$tooltip = "document.querySelector('#buttons [aria-label=\"Tonal\"] [popover]')";
$page = showcase();
// A key press first, so the browser is in keyboard modality, as a keyboard user would be —
// Firefox only counts a scripted focus as :focus-visible after one. Then focused directly
// rather than tabbed to: WebKit, like Safari on macOS, leaves buttons out of the Tab order
// unless full keyboard access is on.
$page->keys('body', 'Tab');
$page->script("document.querySelector('#buttons [aria-label=\"Tonal\"]').focus()");
$page->assertScript(focused("getAttribute('aria-label') === 'Tonal'"))
->assertScript("{$tooltip}.matches(':popover-open')");
$page->keys(':focus', 'Escape')
->assertScript("! {$tooltip}.matches(':popover-open')");
});
it('moves a connected group\'s choice with the arrow keys', function () {
$checked = fn (string $value): string => "document.querySelector('#buttons input[name=\"showcase-theme\"][value=\"{$value}\"]').checked";
$page = showcase();
$page->script("document.querySelector('#buttons input[name=\"showcase-theme\"][value=\"system\"]').focus()");
$page->keys(':focus', 'ArrowLeft')
->assertScript($checked('dark'))
->assertScript("getComputedStyle(document.querySelector('#buttons input[value=\"dark\"]').parentElement).borderTopLeftRadius === '9999px'");
});
it('rounds a split button\'s trailing half while its menu is open', function () {
$trailing = "document.querySelector('[data-split=\"trailing\"]')";
showcase()
->click('[data-split="trailing"] >> nth=0')
->assertScript("{$trailing}.getAttribute('aria-expanded') === 'true'")
->assertScript("getComputedStyle({$trailing}).borderTopLeftRadius === '9999px'");
});
it('turns the FAB into a close button while its menu is open', function () {
$fab = "document.querySelector('button[aria-label=\"New\"]')";
$page = showcase()
->click('button[aria-label="New"]')
->assertScript("{$fab}.getAttribute('aria-expanded') === 'true'")
->assertScript(focused("textContent.trim() === 'Upload files'"));
$page->keys(':focus', 'Escape')
->assertScript("{$fab}.getAttribute('aria-expanded') === 'false'")
->assertScript(focused("getAttribute('aria-label') === 'New'"));
});
it('animates the loading indicator in the browser', function () {
$clock = "document.querySelector('#buttons [role=\"progressbar\"] svg').getCurrentTime()";
showcase()->assertScript("{$clock} > 0.1");
});
+1
View File
@@ -20,6 +20,7 @@ it('follows the operating system while the visitor has not chosen', function ()
it('keeps the visitor\'s choice over the operating system', function () { it('keeps the visitor\'s choice over the operating system', function () {
$page = visit('/material')->inDarkMode() $page = visit('/material')->inDarkMode()
->waitForEvent('networkidle')
->assertScript(theme('data-theme', 'dark')); ->assertScript(theme('data-theme', 'dark'));
$page->click('@theme-light') $page->click('@theme-light')
@@ -0,0 +1,85 @@
<?php
use Livewire\Component;
use Livewire\Livewire;
it('groups buttons with the spacing of their size', function () {
expect((string) $this->blade('<x-button-group label="Views" size="md"><x-button label="Day" /></x-button-group>'))
->toContain('role="group"')
->toContain('aria-label="Views"')
->toContain('data-button-group="standard"')
->toContain('data-size="md"')
->toContain('gap-2')
->and((string) $this->blade('<x-button-group><x-button label="Day" /></x-button-group>'))
->toContain('gap-3');
});
it('connects buttons 2px apart', function () {
expect((string) $this->blade('<x-button-group connected><x-button label="Day" /></x-button-group>'))
->toContain('data-button-group="connected"')
->toContain('gap-0.5')
->not->toContain('flex-wrap');
});
it('marks icon buttons, which keep their width when a group is pressed', function () {
expect((string) $this->blade('<x-button icon="format_bold" aria-label="Bold" />'))->toContain('data-icon-button')
->and((string) $this->blade('<x-button label="Bold" />'))->not->toContain('data-icon-button');
});
it('draws a choice as connected radios', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-group label="Theme" name="theme" x-model="theme" :options="[
['id' => 'light', 'name' => 'Light', 'icon' => 'light_mode'],
['id' => 'dark', 'name' => 'Dark', 'disabled' => true],
]" />
BLADE);
expect($html)
->toContain('<legend class="mb-2 type-label-lg text-on-surface-variant">Theme</legend>')
->toContain('data-button-group="connected"')
->toContain('type="radio"')
->toContain('name="theme"')
->toContain('x-model="theme"')
->toContain('value="light"')
->toContain('has-checked:bg-secondary has-checked:text-on-secondary')
->toContain('disabled')
->and(substr_count($html, '<svg'))->toBe(1);
});
it('draws several choices as checkboxes', function () {
expect((string) $this->blade('<x-group name="days" multiple variant="outlined" :options="[[\'id\' => \'mon\', \'name\' => \'Mon\']]" />'))
->toContain('type="checkbox"')
->toContain('name="days[]"')
->toContain('has-checked:bg-inverse-surface');
});
it('binds to a Livewire property and shows its validation message', function () {
$component = new class extends Component
{
public string $theme = 'dark';
public function save(): void
{
$this->validate(['theme' => 'in:light'], ['theme.in' => 'Pick light.']);
}
public function render(): string
{
return <<<'BLADE'
<div>
<x-group wire:model="theme" hint="How it looks" :options="[['id' => 'light', 'name' => 'Light'], ['id' => 'dark', 'name' => 'Dark']]" />
</div>
BLADE;
}
};
Livewire::test($component)
->assertSeeHtml('wire:model="theme"')
->assertSee('How it looks')
->set('theme', 'light')
->assertSet('theme', 'light')
->set('theme', 'dark')
->call('save')
->assertSee('Pick light.')
->assertDontSee('How it looks');
});
+158
View File
@@ -0,0 +1,158 @@
<?php
/**
* The classes on the rendered button element.
*/
function buttonClasses(string $blade): string
{
preg_match('/<(?:button|a)\b[^>]*\bclass="([^"]*)"/', (string) test()->blade($blade), $matches);
return $matches[1] ?? '';
}
it('is a text button in the primary colour by default', function () {
expect(buttonClasses('<x-button label="Cancel" />'))
->toContain('text-primary')
->not->toContain('bg-primary');
});
it('draws each M3 variant', function (string $variant, string $classes) {
expect(buttonClasses("<x-button label=\"Go\" variant=\"{$variant}\" />"))->toContain($classes);
})->with([
'filled' => ['filled', 'bg-primary text-on-primary'],
'tonal' => ['tonal', 'bg-secondary-container text-on-secondary-container'],
'outlined' => ['outlined', 'border-outline-variant text-on-surface-variant'],
'elevated' => ['elevated', 'bg-surface-container-low text-primary'],
'text' => ['text', 'text-primary'],
]);
it('draws a variant in another colour role', function () {
expect(buttonClasses('<x-button label="Go" variant="filled" color="tertiary" />'))->toContain('bg-tertiary text-on-tertiary')
->and(buttonClasses('<x-button label="Go" variant="tonal" color="error" />'))->toContain('bg-error-container text-on-error-container')
->and(buttonClasses('<x-button label="Go" variant="outlined" tone="success" />'))->toContain('text-success');
});
it('keeps the maryUI and ReStride shorthands', function () {
expect(buttonClasses('<x-button label="Save" primary />'))->toContain('bg-primary text-on-primary')
->and(buttonClasses('<x-button label="Delete" danger />'))->toContain('bg-error text-on-error')
->and(buttonClasses('<x-button label="Take down" caution />'))->toContain('bg-warning text-on-warning');
});
it('falls back to the defaults for values it does not know', function () {
expect(buttonClasses('<x-button label="Go" variant="glass" color="purple" size="huge" />'))
->toContain('text-primary')
->toContain('h-10 gap-2 px-4 type-label-lg');
});
it('sizes a label button on Expressive\'s scale', function (string $size, string $classes, string $icon) {
$html = (string) $this->blade("<x-button label=\"Upload\" icon=\"upload\" size=\"{$size}\" />");
expect($html)->toContain($classes)->toContain($icon);
})->with([
'xs' => ['xs', 'h-8 gap-2 px-3 type-label-lg', 'size-5'],
'sm' => ['sm', 'h-10 gap-2 px-4 type-label-lg', 'size-5'],
'md' => ['md', 'h-14 gap-2 px-6 type-title-md', 'size-6'],
'lg' => ['lg', 'h-24 gap-3 px-12 type-headline-sm', 'size-8'],
'xl' => ['xl', 'h-34 gap-4 px-16 type-headline-lg', 'size-10'],
]);
it('rounds by default, squares on request, and squares off further while pressed', function () {
expect(buttonClasses('<x-button label="Go" size="md" />'))->toContain('rounded-corner-full')->toContain('active:rounded-corner-md')
->and(buttonClasses('<x-button label="Go" size="md" shape="square" />'))->toContain('rounded-corner-lg')
->and(buttonClasses('<x-button label="Go" size="xl" shape="square" />'))->toContain('rounded-corner-xl')->toContain('active:rounded-corner-lg');
});
it('reaches a 48px touch target below the medium size', function () {
expect(buttonClasses('<x-button label="Go" size="sm" />'))->toContain('after:min-h-12')
->and(buttonClasses('<x-button label="Go" size="md" />'))->not->toContain('after:min-h-12');
});
it('is an icon button named by its tooltip when it has no label', function () {
$html = (string) $this->blade('<x-button icon="close" tooltip="Close menu" />');
expect($html)
->toContain('aria-label="Close menu"')
->toContain('size-10')
->toContain('text-on-surface-variant')
->toContain('popover="manual"')
->toContain('aria-hidden="true"');
});
it('sizes an icon button by width', function () {
expect(buttonClasses('<x-button icon="share" label="" width="wide" aria-label="Share" />'))->toContain('h-10 w-13')
->and(buttonClasses('<x-button icon="share" size="xl" width="narrow" aria-label="Share" />'))->toContain('h-34 w-26');
});
it('toggles with aria-pressed and M3\'s selected colours and shape', function () {
expect((string) $this->blade('<x-button label="Bold" variant="filled" :selected="true" />'))->toContain('aria-pressed="true"')
->and(buttonClasses('<x-button label="Bold" variant="filled" :selected="true" />'))->toContain('bg-primary')->toContain('rounded-corner-md')
->and(buttonClasses('<x-button label="Bold" variant="filled" :selected="false" />'))->toContain('bg-surface-container text-on-surface-variant')->toContain('rounded-corner-full')
->and(buttonClasses('<x-button label="Bold" variant="tonal" :selected="true" />'))->toContain('bg-secondary text-on-secondary')
->and(buttonClasses('<x-button label="Bold" variant="outlined" :selected="true" />'))->toContain('bg-inverse-surface text-inverse-on-surface');
});
it('squares a selected round icon button and rounds a selected square one', function () {
expect(buttonClasses('<x-button icon="favorite" aria-label="Like" variant="tonal" :selected="true" />'))->toContain('rounded-corner-md')
->and(buttonClasses('<x-button icon="favorite" aria-label="Like" variant="tonal" shape="square" :selected="true" />'))->toContain('rounded-corner-full')
->and((string) $this->blade('<x-button icon="favorite" aria-label="Like" :selected="true" />'))->toContain('text-primary');
});
it('navigates inside the app, and leaves it for an external link', function () {
$this->blade('<x-button label="Shares" link="/admin/shares" />')
->assertSee('<a ', false)
->assertSee('href="/admin/shares"', false)
->assertSee('wire:navigate', false)
->assertDontSee('type="button"', false);
$this->blade('<x-button label="M3" link="https://m3.material.io" external />')
->assertSee('target="_blank"', false)
->assertSee('rel="noopener"', false)
->assertDontSee('wire:navigate', false);
});
it('disables a button, and a link as far as a link can be', function () {
$this->blade('<x-button label="Save" variant="filled" disabled />')
->assertSee('disabled="disabled"', false)
->assertSee('disabled:bg-on-surface/10', false);
$this->blade('<x-button label="Shares" link="/admin/shares" disabled />')
->assertSee('aria-disabled="true"', false)
->assertSee('tabindex="-1"', false);
});
it('shows the loading indicator while its own action runs', function () {
$this->blade('<x-button label="Save" wire:click="save" spinner />')
->assertSee('wire:loading.attr="disabled"', false)
->assertSee('wire:target="save"', false);
$this->blade('<x-button label="Save" wire:click="save" spinner="upload" />')
->assertSee('wire:target="upload"', false);
});
it('hides a responsive label below lg', function () {
$this->blade('<x-button label="New share" icon="add" responsive />')
->assertSee('<span class="max-lg:hidden">New share</span>', false);
});
it('anchors its tooltip to itself', function () {
$html = (string) $this->blade('<x-button label="Save" tooltip-bottom="Saves the draft" />');
preg_match('/anchor-name: (--material-button-[a-z0-9]+)/', $html, $anchor);
expect($anchor)->not->toBeEmpty()
->and($html)->toContain("position-anchor: {$anchor[1]}")
->toContain('[position-area:bottom]')
->not->toContain('aria-label="Saves the draft"');
});
it('is a FAB below sm and a filled button above it, in one element', function () {
$html = (string) $this->blade('<x-button label="New share" icon="add" fab />');
expect(substr_count($html, '<button'))->toBe(1)
->and($html)->toContain('max-sm:fixed')->toContain('max-sm:bg-primary-container')->toContain('bg-primary text-on-primary');
});
it('submits a form when asked', function () {
$this->blade('<x-button label="Save" type="submit" />')
->assertSee('type="submit"', false);
});
+46
View File
@@ -0,0 +1,46 @@
<?php
it('draws a FAB in its container colour, named by its tooltip', function () {
expect((string) $this->blade('<x-fab icon="add" tooltip="New share" />'))
->toContain('size-14 rounded-corner-lg')
->toContain('bg-primary-container text-on-primary-container')
->toContain('shadow-elevation-3')
->toContain('aria-label="New share"')
->toContain('popover="manual"');
});
it('sizes a FAB at 56, 80 and 96px', function (string $size, string $classes, string $icon) {
expect((string) $this->blade("<x-fab icon=\"add\" size=\"{$size}\" aria-label=\"Add\" />"))->toContain($classes)->toContain($icon);
})->with([
'sm' => ['sm', 'size-14 rounded-corner-lg', 'size-6'],
'md' => ['md', 'size-20 rounded-corner-lg-increased', 'size-7'],
'lg' => ['lg', 'size-24 rounded-corner-xl', 'size-8'],
]);
it('extends with a label', function () {
expect((string) $this->blade('<x-fab icon="upload" label="Upload" size="md" color="tertiary" variant="filled" />'))
->toContain('h-20 min-w-20 gap-3 px-[26px] rounded-corner-lg-increased type-title-lg')
->toContain('bg-tertiary text-on-tertiary')
->toContain('<span>Upload</span>')
->not->toContain('aria-label');
});
it('opens a FAB menu of end-aligned actions above it', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-fab-menu label="New" color="secondary">
<x-fab-menu-item label="Upload files" icon="upload_file" color="secondary" wire:click="upload" />
</x-fab-menu>
BLADE);
expect($html)
->toContain('x-data="materialMenu"')
->toContain('aria-label="New"')
->toContain('bg-secondary-container text-on-secondary-container aria-expanded:bg-secondary aria-expanded:text-on-secondary')
->toContain('aria-expanded:rounded-corner-full')
->toContain('role="menu"')
->toContain('[position-area:top_span-left]')
->toContain('role="menuitem"')
->toContain('wire:click="upload"')
->toContain('h-14')
->toContain('Upload files');
});
+50
View File
@@ -0,0 +1,50 @@
<?php
it('is a named progressbar in the primary colour, 48px by default', function () {
$html = (string) $this->blade('<x-loading />');
expect($html)
->toContain('role="progressbar"')
->toContain('aria-label="Loading"')
->toContain('size-12')
->toContain('text-primary')
->toContain('<animateTransform')
->toContain('class="size-full motion-reduce:hidden"')
->toContain('class="hidden size-full motion-reduce:block"');
});
it('sits on a primary-container circle when contained', function () {
expect((string) $this->blade('<x-loading contained class="size-8" label="Uploading" />'))
->toContain('rounded-corner-full bg-primary-container text-on-primary-container')
->toContain('size-8')
->not->toContain('size-12')
->toContain('aria-label="Uploading"');
});
it('takes the caller\'s colour', function () {
expect((string) $this->blade('<x-loading class="text-tertiary" />'))
->toContain('text-tertiary')
->not->toContain('text-primary');
});
it('says nothing where something else already does', function () {
expect((string) $this->blade('<x-loading :label="false" />'))
->toContain('aria-hidden="true"')
->not->toContain('role="progressbar"');
expect((string) $this->blade('<x-button label="Save" wire:click="save" spinner />'))
->toContain('<animateTransform')
->not->toContain('role="progressbar"');
});
it('animates without script and rests the same shape', function () {
$animated = file_get_contents(__DIR__.'/../../../resources/svg/loading-indicator/indeterminate.svg');
$static = file_get_contents(__DIR__.'/../../../resources/svg/loading-indicator/static.svg');
expect($animated)->toStartWith('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor">')
->toContain('repeatCount="indefinite"')
->not->toContain('<script')
->not->toContain('__ID__')
->and($static)->toStartWith('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="currentColor">')
->not->toContain('<animate');
});
+71
View File
@@ -0,0 +1,71 @@
<?php
it('opens a popover menu from its trigger', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-menu label="Share actions">
<x-slot:trigger><button>More</button></x-slot:trigger>
<x-menu-item label="Copy link" icon="content_copy" />
</x-menu>
BLADE);
preg_match('/anchor-name: (--material-menu-([a-z0-9]+))/', $html, $anchor);
expect($anchor)->not->toBeEmpty()
->and($html)
->toContain('x-data="materialMenu"')
->toContain('<button>More</button>')
->toContain('popover="auto"')
->toContain('role="menu"')
->toContain('aria-label="Share actions"')
->toContain("id=\"material-menu-{$anchor[2]}\"")
->toContain("position-anchor: {$anchor[1]}")
->toContain('bg-surface-container-low')
->toContain('[position-area:bottom_span-right]');
});
it('opens at the position asked for, in the vibrant colours on request', function () {
$html = (string) $this->blade('<x-menu position="top-end" vibrant><x-slot:trigger><button>x</button></x-slot:trigger></x-menu>');
expect($html)->toContain('[position-area:top_span-left]')->toContain('bg-tertiary-container text-on-tertiary-container');
});
it('draws an item as a menuitem button', function () {
$html = (string) $this->blade('<x-menu-item label="Download" icon="download" shortcut="⌘D" description="As a ZIP" wire:click="download" />');
expect($html)
->toContain('role="menuitem"')
->toContain('tabindex="-1"')
->toContain('type="button"')
->toContain('wire:click="download"')
->toContain('Download')
->toContain('⌘D')
->toContain('As a ZIP')
->toContain('size-5 text-on-surface-variant');
});
it('makes a selectable item a menuitemcheckbox', function () {
expect((string) $this->blade('<x-menu-item label="Newest" :selected="true" keep-open />'))
->toContain('role="menuitemcheckbox"')
->toContain('aria-checked="true"')
->toContain('bg-tertiary-container')
->toContain('data-keep-open')
->and((string) $this->blade('<x-menu-item label="Largest" :selected="false" />'))
->toContain('aria-checked="false"');
});
it('links an item, and keeps a disabled one out of reach', function () {
$this->blade('<x-menu-item label="Settings" link="/settings" />')
->assertSee('href="/settings"', false)
->assertSee('wire:navigate', false);
$this->blade('<x-menu-item label="Reset" disabled />')
->assertSee('aria-disabled="true"', false)
->assertSee('pointer-events-none', false);
});
it('separates and labels groups', function () {
$this->blade('<x-menu-separator />')->assertSee('role="separator"', false);
$this->blade('<x-menu-group label="Sort by"><x-menu-item label="Newest" /></x-menu-group>')
->assertSee('role="group" aria-label="Sort by"', false);
});
@@ -0,0 +1,43 @@
<?php
it('puts the action on the leading button and the menu on the trailing one', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options" class="mt-4">
<x-menu-item label="As a ZIP" />
</x-split-button>
BLADE);
expect($html)
->toContain('data-button-group="split"')
->toContain('mt-4')
->toContain('data-split="leading"')
->toContain('wire:click="downloadZip"')
->toContain('data-split="trailing"')
->toContain('aria-label="Download options"')
->toContain('role="menu"')
->toContain('As a ZIP')
->and(substr_count($html, 'wire:click="downloadZip"'))->toBe(1);
});
it('leaves the corners to the group', function () {
$html = (string) $this->blade('<x-split-button label="Save"><x-menu-item label="Draft" /></x-split-button>');
preg_match_all('/<button\b[^>]*data-split="[^"]*"[^>]*>/', $html, $halves);
expect($halves[0])->toHaveCount(2);
foreach ($halves[0] as $half) {
expect($half)->not->toContain('rounded-corner-full')->not->toContain('active:rounded-corner');
}
});
it('is filled unless another container variant is asked for', function () {
expect((string) $this->blade('<x-split-button label="Save" variant="text"><x-menu-item label="Draft" /></x-split-button>'))->toContain('bg-primary text-on-primary')
->and((string) $this->blade('<x-split-button label="Save" variant="tonal"><x-menu-item label="Draft" /></x-split-button>'))->toContain('bg-secondary-container');
});
it('gives the two smallest sizes their narrower inner padding and 48px trailing button', function () {
expect((string) $this->blade('<x-split-button label="Save" size="xs"><x-menu-item label="Draft" /></x-split-button>'))
->toContain('pe-2.5')
->toContain('w-12');
});
+29
View File
@@ -0,0 +1,29 @@
<?php
it('wraps a trigger and anchors the bubble to it', function () {
$html = (string) $this->blade('<x-tooltip text="Copy link"><button>Copy</button></x-tooltip>');
preg_match('/anchor-name: (--material-tooltip-[a-z0-9]+)/', $html, $anchor);
expect($anchor)->not->toBeEmpty()
->and($html)
->toContain('<button>Copy</button>')
->toContain('popover="manual"')
->toContain('x-data="materialTooltip"')
->toContain("position-anchor: {$anchor[1]}")
->toContain('bg-inverse-surface')
->toContain('[position-area:top]');
});
it('places the bubble on the side asked for, flipping when there is no room', function (string $side, string $classes) {
expect((string) $this->blade("<x-tooltip text=\"Hi\" side=\"{$side}\"><span>x</span></x-tooltip>"))->toContain($classes);
})->with([
'bottom' => ['bottom', '[position-area:bottom] [position-try-fallbacks:flip-block]'],
'left' => ['left', '[position-area:left] [position-try-fallbacks:flip-inline]'],
'right' => ['right', '[position-area:right] [position-try-fallbacks:flip-inline]'],
]);
it('escapes its text', function () {
$this->blade('<x-tooltip text="<b>bold</b>"><span>x</span></x-tooltip>')
->assertSee('&lt;b&gt;bold&lt;/b&gt;', false);
});
+9
View File
@@ -1,5 +1,7 @@
<?php <?php
use NoNameWeb\LivewireMaterial\Support\SvgFile;
/** /**
* Reboot the application with the showcase switched on or off. The routes are mounted * Reboot the application with the showcase switched on or off. The routes are mounted
* while the provider boots, so the flag has to be in the environment before that. * while the provider boots, so the flag has to be in the environment before that.
@@ -42,3 +44,10 @@ it('answers 404 for a symbol or style that does not exist', function () {
$this->get('/material/symbols/outlined/not_a_symbol_at_all.svg')->assertNotFound(); $this->get('/material/symbols/outlined/not_a_symbol_at_all.svg')->assertNotFound();
$this->get('/material/symbols/sharp/favorite.svg')->assertNotFound(); $this->get('/material/symbols/sharp/favorite.svg')->assertNotFound();
}); });
it('lists the symbol names for the icon search', function () {
$this->getJson('/material/symbols.json')
->assertOk()
->assertJsonFragment(['calendar_month'])
->assertJsonCount(count(SvgFile::symbolNames()));
});