Files
livewire-material/resources/js/figure.js
T
Andreas Reinhold / reiniandClaude Opus 5 b48e879254
tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m0s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 1m59s
tests / browser (chrome, chromium) (push) Successful in 1m49s
tests / browser (firefox, firefox) (push) Successful in 1m54s
Add the Material 3 Expressive foundation
Colour, shape, type, elevation and motion as tokens and Tailwind
utilities; `php artisan material:scheme`, which generates an app's colour
roles with Google's material-color-utilities (spec 2025); the theme head
script with light, dark and system and its Alpine store; Google Sans Flex;
every Material Symbol (4,135, outlined and filled) drawn by <x-icon>
without blade-icons; all 35 M3 Expressive shapes, ported from androidx, as
<x-shape>; the x-figure directive; the Toasts concern; DesignGuard for
applications' tests; and a showcase with every token, both themes side by
side and an icon search.

Colour utilities are `@theme inline`, so a section with its own
data-theme repaints; without it they resolve once on :root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 05:24:11 +02:00

122 lines
4.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `x-figure`: a figure counts to its value — from zero when it first appears, and from what
* it said before when the server changes it.
*
* The server renders the real value, so without JavaScript, in a test or in a screenshot the
* figure is simply right. Only a figure with exactly one number in it moves — "39.6",
* "1,204 km", "CHF 12.50" — and a time, a date or a range is left alone rather than counted
* as if it were one quantity. The format is the target's: its decimals and its thousands
* separator, so a column of counting figures keeps its width under `tabular-nums`.
*
* The curve decelerates and never overshoots, unlike the spatial springs, because a figure
* that passes its value is briefly a lie. The duration is the spatial-slow token's, which
* reduced motion sets to zero — and then the figure is written once, as it is.
*
* A re-render that leaves the text the same never reaches the observer, so a Livewire round
* trip does not replay it; one that changes it counts from the figure on screen, even
* mid-count. The observer ignores the directive's own writes by comparing against the last
* thing it wrote.
*/
const FIGURE = /[-]?\d{1,3}(?:[,']\d{3})+(?:\.\d+)?|[-]?\d+(?:\.\d+)?/g
const readFigure = (text) => {
const matches = [...text.matchAll(FIGURE)]
if (matches.length !== 1) {
return null
}
const [raw] = matches[0]
return {
value: Number(raw.replace(/[,']/g, '').replace('', '-')),
decimals: raw.includes('.') ? raw.split('.')[1].length : 0,
separator: raw.match(/\d([,'])\d{3}/)?.[1] ?? '',
minus: raw.startsWith('') ? '' : '-',
before: text.slice(0, matches[0].index),
after: text.slice(matches[0].index + raw.length),
}
}
const writeFigure = (figure, value) => {
let [whole, fraction] = Math.abs(value).toFixed(figure.decimals).split('.')
if (figure.separator) {
whole = whole.replace(/\B(?=(\d{3})+(?!\d))/g, figure.separator)
}
const sign = value < 0 && Number(Math.abs(value).toFixed(figure.decimals)) !== 0 ? figure.minus : ''
return figure.before + sign + whole + (fraction ? `.${fraction}` : '') + figure.after
}
document.addEventListener('alpine:init', () => {
window.Alpine.directive('figure', (el, _, { cleanup }) => {
const text = () => (el.childNodes.length === 1 && el.firstChild.nodeType === Node.TEXT_NODE ? el.firstChild : null)
// In milliseconds, whichever unit arrives: a minifier turns `600ms` into `.6s`, and a
// bare parseFloat would read that as 0.6ms — an instant count.
const duration = () => {
const token = getComputedStyle(document.documentElement).getPropertyValue('--md-sys-motion-spatial-slow-duration').trim()
const value = parseFloat(token) || 0
return token.endsWith('ms') ? value : value * 1000
}
let written = text()?.data ?? null
let frame = null
const count = (from, target) => {
cancelAnimationFrame(frame)
const node = text()
const length = duration()
if (!node || !target || length === 0 || from === target.value) {
return
}
const started = performance.now()
const step = (now) => {
const progress = Math.min(1, (now - started) / length)
const eased = 1 - Math.pow(1 - progress, 4)
written = progress === 1 ? writeFigure(target, target.value) : writeFigure(target, from + (target.value - from) * eased)
node.data = written
if (progress < 1) {
frame = requestAnimationFrame(step)
}
}
written = writeFigure(target, from)
node.data = written
frame = requestAnimationFrame(step)
}
const observer = new MutationObserver(() => {
const node = text()
if (!node || node.data === written) {
return
}
const target = readFigure(node.data)
const shown = written === null ? null : readFigure(written)
written = node.data
count(shown?.value ?? 0, target)
})
observer.observe(el, { characterData: true, childList: true, subtree: true })
count(0, written === null ? null : readFigure(written))
cleanup(() => {
cancelAnimationFrame(frame)
observer.disconnect()
})
})
})