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
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:
co-authored by
Claude Opus 5
parent
b48e879254
commit
cd64f4f371
@@ -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 400–700, roundness 0–100.
|
Subset: Latin and Latin Extended, weight 400–700, roundness 0–100.
|
||||||
|
|||||||
@@ -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 0–1 in time and value, by a
|
||||||
|
* deterministic pattern search over the four control values (all kept in 0–1, as SMIL requires).
|
||||||
|
*/
|
||||||
|
function fitKeySpline(fromMs, toMs, fromValue, toValue) {
|
||||||
|
const samples = Array.from({ length: 101 }, (_, i) => {
|
||||||
|
const x = i / 100
|
||||||
|
|
||||||
|
return [x, (spring(fromMs + x * (toMs - fromMs)) - fromValue) / (toValue - fromValue)]
|
||||||
|
})
|
||||||
|
const error = (spline) => samples.reduce((sum, [x, y]) => sum + (keySpline(spline, x) - y) ** 2, 0)
|
||||||
|
let spline = [1 / 3, 1 / 3, 2 / 3, 2 / 3]
|
||||||
|
let best = error(spline)
|
||||||
|
|
||||||
|
for (let step = 0.25; step > 1e-5; step /= 2) {
|
||||||
|
let improved = true
|
||||||
|
|
||||||
|
while (improved) {
|
||||||
|
improved = false
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
for (const delta of [step, -step]) {
|
||||||
|
const candidate = spline.with(i, Math.min(1, Math.max(0, spline[i] + delta)))
|
||||||
|
const candidateError = error(candidate)
|
||||||
|
|
||||||
|
if (candidateError < best - 1e-12) {
|
||||||
|
spline = candidate
|
||||||
|
best = candidateError
|
||||||
|
improved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return spline
|
||||||
|
}
|
||||||
|
|
||||||
|
// SVG -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Precision of every coordinate: hundredths of a container unit. */
|
||||||
|
const PRECISION = 100
|
||||||
|
|
||||||
|
function decimal(value, places) {
|
||||||
|
return String(Number(value.toFixed(places))).replace(/^(-?)0\./, '$1.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Joins numbers, leaving out the separator where a sign or a second decimal point already splits them. */
|
||||||
|
function joinNumbers(numbers) {
|
||||||
|
let out = ''
|
||||||
|
|
||||||
|
for (const text of numbers) {
|
||||||
|
const previous = out.slice(out.lastIndexOf(' ') + 1)
|
||||||
|
|
||||||
|
if (out === '' || text.startsWith('-') || (text.startsWith('.') && previous.includes('.'))) {
|
||||||
|
out += text
|
||||||
|
} else {
|
||||||
|
out += ` ${text}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path data in hundredths relative to the current point, rounded as absolute positions first so the
|
||||||
|
* rounding never drifts along the outline. `format` writes one count of hundredths.
|
||||||
|
*/
|
||||||
|
function pathData(cubics, format) {
|
||||||
|
const points = cubics.map((c) => c.map((value) => Math.round(value * PRECISION)))
|
||||||
|
const numbers = []
|
||||||
|
let [x, y] = [points[0][0], points[0][1]]
|
||||||
|
|
||||||
|
for (const c of points) {
|
||||||
|
numbers.push(c[2] - x, c[3] - y, c[4] - x, c[5] - y, c[6] - x, c[7] - y)
|
||||||
|
;[x, y] = [c[6], c[7]]
|
||||||
|
}
|
||||||
|
|
||||||
|
return `M${joinNumbers([points[0][0], points[0][1]].map(format))}c${joinNumbers(numbers.map(format))}Z`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hundredths as decimals, for a path drawn in container units. */
|
||||||
|
const decimalHundredths = (n) => decimal(n / PRECISION, 2)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hundredths as integers, for a path drawn inside `scale(.01)`: the same precision as two decimals,
|
||||||
|
* and about a sixth shorter.
|
||||||
|
*/
|
||||||
|
const integerHundredths = (n) => String(n)
|
||||||
|
|
||||||
|
/** The same command structure as a path of `count` cubics, collapsed onto the container centre. */
|
||||||
|
function collapsedPathData(count) {
|
||||||
|
const centre = (CONTAINER / 2) * PRECISION
|
||||||
|
|
||||||
|
return `M${centre} ${centre}c${Array(count * 6).fill(0).join(' ')}Z`
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = (value) => decimal(value, 6)
|
||||||
|
const splineText = (spline) => spline.map((value) => decimal(value, 3)).join(' ')
|
||||||
|
|
||||||
|
const polygons = SEQUENCE.map((name) => SHAPES[name]().normalized())
|
||||||
|
const scale = CONTAINER * calculateScaleFactor(polygons) * (ACTIVE_SIZE / CONTAINER)
|
||||||
|
const rise = fitKeySpline(0, PEAK_TIME, 0, PEAK)
|
||||||
|
const settle = fitKeySpline(PEAK_TIME, MORPH_INTERVAL, PEAK, 1)
|
||||||
|
const cycle = MORPH_INTERVAL * SEQUENCE.length
|
||||||
|
const riseTime = time(PEAK_TIME / cycle)
|
||||||
|
const collapseTime = decimal(Math.floor(((MORPH_INTERVAL - HANDOVER_LEAD) / cycle) * 1e7) / 1e7, 7)
|
||||||
|
|
||||||
|
const morphPaths = SEQUENCE.map((_, index) => {
|
||||||
|
const pairs = match(polygons[index], polygons[(index + 1) % SEQUENCE.length])
|
||||||
|
const values = [0, PEAK, 1].map((progress) =>
|
||||||
|
pathData(processPath(asCubics(pairs, progress), scale), integerHundredths),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<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}`,
|
||||||
|
)
|
||||||
+19
-13
@@ -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)) {
|
||||||
if (file.endsWith('.svg')) {
|
mkdirSync(OUTPUT, { recursive: true })
|
||||||
rmSync(join(OUTPUT, file))
|
|
||||||
|
for (const file of readdirSync(OUTPUT)) {
|
||||||
|
if (file.endsWith('.svg')) {
|
||||||
|
rmSync(join(OUTPUT, file))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
|
||||||
|
writeFileSync(join(OUTPUT, `${name}.svg`), svg)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Wrote ${Object.keys(SHAPES).length} shapes to ${OUTPUT}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, build] of Object.entries(SHAPES)) {
|
|
||||||
const svg = `<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)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Wrote ${Object.keys(SHAPES).length} shapes to ${OUTPUT}`)
|
|
||||||
|
|||||||
@@ -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.1–0.18 unit jump at three hand-overs. Reduced motion shows `static.svg`.
|
||||||
|
- **Showcase examples are Blade strings rendered with `Blade::render()` beside their source**
|
||||||
|
(`<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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -9,3 +9,5 @@
|
|||||||
*/
|
*/
|
||||||
import './theme.js'
|
import './theme.js'
|
||||||
import './figure.js'
|
import './figure.js'
|
||||||
|
import './tooltip.js'
|
||||||
|
import './menu.js'
|
||||||
|
|||||||
@@ -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())
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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') }} />
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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><x-button></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><x-menu></code> with <code><x-menu-item></code>, <code><x-menu-group></code> and <code><x-menu-separator></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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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('<b>bold</b>', false);
|
||||||
|
});
|
||||||
@@ -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()));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user