Add the Material 3 Expressive foundation
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
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
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
This commit is contained in:
co-authored by
Claude Opus 5
parent
9b53891a8e
commit
b48e879254
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* `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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,9 @@
|
||||
*
|
||||
* import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
|
||||
*
|
||||
* Alpine ships with Livewire, so this only registers onto it: the theme store, the
|
||||
* snackbar listener and the list-row behaviour arrive in 0.2.0.
|
||||
* Alpine ships with Livewire, so these only register onto it (on `alpine:init`). A module
|
||||
* script runs before Livewire starts Alpine, and wire:navigate keeps it rather than running
|
||||
* it again, so nothing registers twice.
|
||||
*/
|
||||
import './theme.js'
|
||||
import './figure.js'
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* The theme, as one piece of state every toggle on the page shares.
|
||||
*
|
||||
* <x-theme-script> has already decided the theme before this runs; the store starts from
|
||||
* what it wrote on <html> (data-theme-choice, data-theme, data-theme-key). `choice` is what
|
||||
* the visitor picked — light, dark or system — and `resolved` is what is showing.
|
||||
*
|
||||
* `value` is an accessor, so binding a control with `x-model="$store.theme.value"` goes
|
||||
* through the same write as `set()` and `toggle()`: the attributes, then localStorage.
|
||||
*/
|
||||
document.addEventListener('alpine:init', () => {
|
||||
const root = document.documentElement
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const choices = ['light', 'dark', 'system']
|
||||
|
||||
const resolve = (choice) => (choice === 'system' ? (media.matches ? 'dark' : 'light') : choice)
|
||||
|
||||
window.Alpine.store('theme', {
|
||||
choice: choices.includes(root.dataset.themeChoice) ? root.dataset.themeChoice : 'system',
|
||||
resolved: root.dataset.theme === 'dark' ? 'dark' : 'light',
|
||||
|
||||
get value() {
|
||||
return this.choice
|
||||
},
|
||||
|
||||
set value(choice) {
|
||||
this.set(choice)
|
||||
},
|
||||
|
||||
set(choice) {
|
||||
if (!choices.includes(choice)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.choice = choice
|
||||
this.resolved = resolve(choice)
|
||||
|
||||
root.dataset.themeChoice = choice
|
||||
root.dataset.theme = this.resolved
|
||||
|
||||
try {
|
||||
localStorage.setItem(root.dataset.themeKey || 'material-theme', choice)
|
||||
} catch {
|
||||
// Blocked storage: the page still switches, it just will not remember.
|
||||
}
|
||||
},
|
||||
|
||||
toggle() {
|
||||
this.set(this.resolved === 'dark' ? 'light' : 'dark')
|
||||
},
|
||||
})
|
||||
|
||||
// The head script repaints on an OS change while the choice is `system`; this keeps the
|
||||
// store's `resolved` — which a toggle's icon reads — in step with it.
|
||||
media.addEventListener('change', () => {
|
||||
const theme = window.Alpine.store('theme')
|
||||
|
||||
if (theme.choice === 'system') {
|
||||
theme.resolved = resolve('system')
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user