Dashboard motion: round dashboards page up and down, the ring eases to a notch or a reading, the slider tile to a value set elsewhere; reduced motion honoured by every tween; motion spec in the docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5b2769c141
commit
b500af84ec
@@ -277,7 +277,7 @@ look's muted text colour.
|
||||
| Any other type | its rectangular body, without a card, from y = c − 64 to c + S/(2√2), x = c ± S/(2√2) (Knob: 53–307 × 116–307) |
|
||||
| Page dots | one per face, from two faces: dots 6 across on targets 16 apart (closer when more than 128 wide would be needed), centred at y = S − 12; the current one in the text colour, the others muted at half opacity |
|
||||
| Dashboard name | a pill centred at (c, c), 36 high: the icon 16 and the title at 15, medium, at most 220 wide, on the frosted surface; for 1000 ms |
|
||||
| Paging | a new face slides in 48 from the side it was paged towards and fades in; the old one leaves the other way |
|
||||
| Paging | faces slide sideways, dashboards up and down; see [Motion](#motion) |
|
||||
| Locked | controls do nothing and read as disabled; the ring at 0.45 opacity |
|
||||
|
||||
Paint follows the look: Fluksio draws a muted track, a primary value and a
|
||||
@@ -285,6 +285,52 @@ handle of the ground colour ringed in primary; Material a tonal track and a
|
||||
primary handle; Glass a translucent track, a glowing value and a white handle.
|
||||
A Switch or Button face wears the look's own button.
|
||||
|
||||
## Motion
|
||||
|
||||
How a dashboard moves, for devices that draw it natively. Distances are canvas
|
||||
pixels. Each look moves at its own spring; a device without springs runs the
|
||||
spring's closed form for the duration given and then sets the end value, or
|
||||
uses the cubic-bezier that stands in for it.
|
||||
|
||||
| Look | Spring (stiffness, damping, mass) | ζ, ω₀ | Duration (within 1 %) | Overshoot | Cubic-bezier stand-in |
|
||||
|---|---|---|---|---|---|
|
||||
| Fluksio | 420, 34, 1 | 0.830, 20.49 s⁻¹ | 210 ms | 0.9 % | (0.26, 0.12, 0.25, 1.01) over 250 ms, at most 1.5 % off |
|
||||
| Material | 550, 38, 1 | 0.810, 23.45 s⁻¹ | 270 ms | 1.3 % | (0.20, 0.11, 0.27, 1.19) over 260 ms, at most 1.7 % off |
|
||||
| Glass | 320, 22, 0.8 | 0.688, 20.00 s⁻¹ | 330 ms | 5.1 % | (0.26, 0.20, 0.20, 1.33) over 320 ms, at most 1.7 % off |
|
||||
|
||||
From rest, a spring's progress is p(t) = 1 − e^(−ζω₀t)·(cos ω_d t +
|
||||
(ζω₀/ω_d)·sin ω_d t), with ω_d = ω₀·√(1 − ζ²). "Spring" below is the
|
||||
dashboard's look's.
|
||||
|
||||
| What | Moves | From → to | Timing | Input during it |
|
||||
|---|---|---|---|---|
|
||||
| Face paging (round) | the new face and the old | new: x +48 (next) or −48 (previous), opacity 0 → x 0, opacity 1; old: x 0, opacity 1 → x −48 (next) or +48, opacity 0. Both at once | spring | another page retargets: each face heads for its new end from where it is |
|
||||
| Dashboard paging (round panel) | the new dashboard's face and the old | the same on y: the next rises from +48, the previous comes down from −48. One opened by its address only fades | spring | as faces |
|
||||
| Page dot | the current dot and the last | muted at 0.5 opacity ↔ text colour at 1 | 200 ms, cubic-bezier(0.4, 0, 0.2, 1) | retargets |
|
||||
| Dashboard name | the pill | opacity 0 → 1, held 1000 ms, 1 → 0 | in 200 ms, out 150 ms, cubic-bezier(0.4, 0, 0.2, 1) | — |
|
||||
| Ring value set by a notch, a key or the engine | the value arc's end, the handle (slider, selector, colour) | from where it is drawn to the new fraction of the sweep | spring | each notch retargets from where it is, at the speed it has |
|
||||
| Ring value set by a drag | the same | follows the pointer exactly | none | — |
|
||||
| Hue across red | the colour handle | a change of more than half the sweep jumps to the other end | none | — |
|
||||
| Slider's reading on the ring, gauge on the ring | the reading's mark; the value arc | from where it is drawn to the new fraction | spring | retargets |
|
||||
| Numbers on the ring | centre reading, gap line | switch at once, with the notch | none | — |
|
||||
| Value face, Value and Gauge tiles | the number | counts to the new value, written to its precision | spring | retargets |
|
||||
| Slider tile set elsewhere | the fill's end, the handle, the reading's mark | from where it is drawn to the new fraction | spring | a drag or a key moves it at once |
|
||||
| Gauge and Bar tiles | the value arc, each bar's fill | from where it is drawn to the new fraction | spring | retargets |
|
||||
| Dashboard change (rectangular panel) | every tile | the old dashboard goes at once; each new tile opacity 0 → 1, y +8 → 0 | spring | — |
|
||||
| Rail | the current entry's marker | slides to the new entry | spring | retargets |
|
||||
|
||||
With reduced motion asked for, nothing travels: pages and dashboards only
|
||||
cross-fade (opacity at the spring), and every value is drawn at its new place
|
||||
at once. The dot and the name pill, which only change colour or opacity, keep
|
||||
their timing.
|
||||
|
||||
A device may simplify where its hardware is slow, keeping the direction and
|
||||
the end state: a page may slide without the fade, fade without the slide, or
|
||||
cut; a retarget may restart the curve from where it is without carrying the
|
||||
speed; a counting number may switch; and the value arc may be redrawn each
|
||||
frame as an arc to the eased angle, which is what the browser's reveal
|
||||
amounts to.
|
||||
|
||||
## Showing one
|
||||
|
||||
Open `/view/{name}` for a dashboard-only view with your normal session.
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { DashboardDef_Output, Placement, WidgetDef } from "@/client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import "./dashboard.css"
|
||||
import { RoundView } from "./RoundFace"
|
||||
import { RoundPanel } from "./RoundFace"
|
||||
import {
|
||||
LockedProvider,
|
||||
PaletteProvider,
|
||||
@@ -393,8 +393,7 @@ export function DashboardView({
|
||||
{round ? (
|
||||
// Its own face memory per dashboard, so a panel switching to another
|
||||
// starts that one where it was left.
|
||||
<RoundView
|
||||
key={dashboard.name}
|
||||
<RoundPanel
|
||||
dashboard={dashboard}
|
||||
size={canvasOf(dashboard).width}
|
||||
onDashboard={onDashboard}
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
TITLE_WIDTH,
|
||||
useRingDraft,
|
||||
} from "./ui/core/ring"
|
||||
import { useAnimatedFraction } from "./ui/core/values"
|
||||
import { useEased } from "./ui/core/values"
|
||||
import type { Series } from "./widgets"
|
||||
|
||||
/** How much wheel a notch is: what one detent of a mouse wheel reports. */
|
||||
@@ -242,7 +242,8 @@ export function CentreReading({
|
||||
* which is the one thing a knob's ring can set and a circle's edge can show.
|
||||
* Turned by a drag along it or by the mouse wheel, a notch a step, an option
|
||||
* or a hue step; the value moves at once and one publish follows once the
|
||||
* turning stops (`useRingDraft`). Its reading is written large in the middle,
|
||||
* turning stops (`useRingDraft`). What is drawn along the ring eases to a
|
||||
* notch or a reading at the look's spring, and follows a drag exactly. Its reading is written large in the middle,
|
||||
* unless a slider reads something beside it: then that is in the middle, and
|
||||
* the slider's own title and value move to the gap's line.
|
||||
*
|
||||
@@ -272,16 +273,21 @@ export function Ring({
|
||||
widget.type === "slider" ? ((cfg.reading ?? {}) as Series) : {}
|
||||
const read = useBoundValue(beside.message || undefined)
|
||||
const dial = dialOf(widget, widget.type === "gauge" ? bound?.value : value)
|
||||
const { current, latest, move, commit } = useRingDraft(dial.value, (next) =>
|
||||
send(dial.wire(next)),
|
||||
const { current, held, latest, move, commit } = useRingDraft(
|
||||
dial.value,
|
||||
(next) => send(dial.wire(next)),
|
||||
)
|
||||
const svg = useRef<SVGSVGElement>(null)
|
||||
const face = useRef<HTMLDivElement>(null)
|
||||
const spin = useRef(0)
|
||||
const turns = Boolean(dial.notch) && !locked
|
||||
const at = dial.at(current)
|
||||
const animated = useAnimatedFraction(at ?? 0)
|
||||
const shown = useTransform(animated, (fraction) => (fraction > 0 ? 1 : 0))
|
||||
const along = useEased(at ?? 0, held, Boolean(dial.hue))
|
||||
// At the minimum the value arc is a zero-length dash whose round caps would
|
||||
// still paint a dot, so it is not drawn there.
|
||||
const shown = useTransform(along, (fraction) => (fraction > 0 ? 1 : 0))
|
||||
const handleX = useTransform(along, (fraction) => ringPoint(size, fraction).x)
|
||||
const handleY = useTransform(along, (fraction) => ringPoint(size, fraction).y)
|
||||
|
||||
const turn = (by: number) => {
|
||||
if (turns && dial.notch) move(dial.notch(latest(), by), true)
|
||||
@@ -327,17 +333,14 @@ export function Ring({
|
||||
// Written to the slider's own decimals and in its unit: the two are read
|
||||
// against each other.
|
||||
const other = beside.message ? dial.reading(read?.value ?? null) : null
|
||||
const handle = dial.handle && at !== null ? ringPoint(size, at) : null
|
||||
// The same reading, placed: a bar across the band where it sits on the
|
||||
// slider's scale, held to the sweep at either end.
|
||||
const actual = typeof read?.value === "number" ? dial.at(read.value) : null
|
||||
const across =
|
||||
actual === null
|
||||
? null
|
||||
: [
|
||||
ringPoint(size, actual, -RING_READING / 2),
|
||||
ringPoint(size, actual, RING_READING / 2),
|
||||
]
|
||||
const across = useTransform(useEased(actual ?? 0), (fraction) => {
|
||||
const from = ringPoint(size, fraction, -RING_READING / 2)
|
||||
const to = ringPoint(size, fraction, RING_READING / 2)
|
||||
return `M${from.x} ${from.y}L${to.x} ${to.y}`
|
||||
})
|
||||
const whole = ringArc(size, 0, 1)
|
||||
const name = widget.title || text(cfg.target, widget.id)
|
||||
|
||||
@@ -394,26 +397,16 @@ export function Ring({
|
||||
className="dui-ring-track"
|
||||
/>
|
||||
)}
|
||||
{/* A slider's reading follows the hand, so it is drawn where it
|
||||
is. A gauge eases to its reading, revealed rather than re-pathed
|
||||
as the gauge tile's arc is, and transparent at the minimum where
|
||||
its round caps would still paint a dot. */}
|
||||
{dial.fill && dial.notch && at ? (
|
||||
<path
|
||||
d={ringArc(size, 0, at)}
|
||||
fill="none"
|
||||
strokeWidth={RING_STROKE}
|
||||
strokeLinecap="round"
|
||||
className="dui-ring-fill"
|
||||
/>
|
||||
) : dial.fill && !dial.notch ? (
|
||||
{/* Revealed rather than re-pathed, as the gauge tile's arc is: `d`
|
||||
does not ease, and `pathLength` makes the reveal the fraction. */}
|
||||
{dial.fill ? (
|
||||
<motion.path
|
||||
d={whole}
|
||||
fill="none"
|
||||
strokeWidth={RING_STROKE}
|
||||
strokeLinecap="round"
|
||||
className="dui-ring-fill"
|
||||
style={{ pathLength: animated, opacity: shown }}
|
||||
style={{ pathLength: along, opacity: shown }}
|
||||
/>
|
||||
) : null}
|
||||
{dial.marks?.map((mark, index) => {
|
||||
@@ -430,20 +423,17 @@ export function Ring({
|
||||
)
|
||||
})}
|
||||
{/* Under the handle, so where the two meet the value being set wins. */}
|
||||
{across ? (
|
||||
<line
|
||||
x1={across[0].x}
|
||||
y1={across[0].y}
|
||||
x2={across[1].x}
|
||||
y2={across[1].y}
|
||||
{actual !== null ? (
|
||||
<motion.path
|
||||
d={across}
|
||||
strokeWidth={RING_READING_STROKE}
|
||||
className="dui-ring-reading"
|
||||
/>
|
||||
) : null}
|
||||
{handle ? (
|
||||
<circle
|
||||
cx={handle.x}
|
||||
cy={handle.y}
|
||||
{dial.handle && at !== null ? (
|
||||
<motion.circle
|
||||
cx={handleX}
|
||||
cy={handleY}
|
||||
r={RING_HANDLE / 2}
|
||||
className={cn("dui-ring-handle", dial.hue && "dui-ring-swatch")}
|
||||
style={dial.hue ? { fill: cssOf(current as Triple) } : undefined}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { Power } from "lucide-react"
|
||||
import { AnimatePresence, motion, type Variants } from "motion/react"
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useIsPresent,
|
||||
type Variants,
|
||||
} from "motion/react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { ErrorBoundary } from "react-error-boundary"
|
||||
|
||||
import type { WidgetDef } from "@/client"
|
||||
import { fadeIn } from "@/lib/motion"
|
||||
import { useShortcuts } from "@/lib/shortcuts"
|
||||
import { useClock } from "./ClockWidget"
|
||||
import type { Dashboard } from "./DashboardView"
|
||||
@@ -70,11 +76,77 @@ const lastFace = new Map<string, number>()
|
||||
/** The dashboard a panel showed last, so landing on another can say which. */
|
||||
let lastShown: string | null = null
|
||||
|
||||
/** How far a face or a dashboard slides in as it is paged to, in canvas
|
||||
* pixels, fading in as it comes. */
|
||||
const PAGE_SLIDE = 48
|
||||
|
||||
/** A face arrives from the side it was paged towards. */
|
||||
const TURN: Variants = {
|
||||
enter: (way: number) => ({ opacity: 0, x: way * 48 }),
|
||||
enter: (way: number) => ({ opacity: 0, x: way * PAGE_SLIDE }),
|
||||
shown: { opacity: 1, x: 0 },
|
||||
leave: (way: number) => ({ opacity: 0, x: way * -48 }),
|
||||
leave: (way: number) => ({ opacity: 0, x: way * -PAGE_SLIDE }),
|
||||
}
|
||||
|
||||
/** And a dashboard from below or above, as the faces do from the side. */
|
||||
const RISE: Variants = {
|
||||
enter: (way: number) => ({ opacity: 0, y: way * PAGE_SLIDE }),
|
||||
shown: { opacity: 1, y: 0 },
|
||||
leave: (way: number) => ({ opacity: 0, y: way * -PAGE_SLIDE }),
|
||||
}
|
||||
|
||||
/**
|
||||
* A panel's round dashboards, one at a time, paged up and down.
|
||||
*
|
||||
* The next one rises in from below as the last leaves upwards — the way a
|
||||
* swipe up moved them — and the previous one comes down from above. One that
|
||||
* arrives without a turn, by its address, only fades in.
|
||||
*/
|
||||
export function RoundPanel({
|
||||
dashboard,
|
||||
size,
|
||||
onDashboard,
|
||||
}: {
|
||||
dashboard: Dashboard
|
||||
size: number
|
||||
onDashboard?: (by: number) => void
|
||||
}) {
|
||||
const { look } = useLook()
|
||||
// Read between renders: the turn asked for, and the side the dashboard it
|
||||
// brings arrives from once it has loaded.
|
||||
const turned = useRef(0)
|
||||
const shown = useRef(dashboard.name)
|
||||
const way = useRef(0)
|
||||
if (shown.current !== dashboard.name) {
|
||||
shown.current = dashboard.name
|
||||
way.current = turned.current
|
||||
turned.current = 0
|
||||
}
|
||||
return (
|
||||
<AnimatePresence initial={false} custom={way.current}>
|
||||
<motion.div
|
||||
key={dashboard.name}
|
||||
custom={way.current}
|
||||
variants={RISE}
|
||||
initial="enter"
|
||||
animate="shown"
|
||||
exit="leave"
|
||||
transition={LOOK[look].spring}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<RoundView
|
||||
dashboard={dashboard}
|
||||
size={size}
|
||||
onDashboard={
|
||||
onDashboard &&
|
||||
((by) => {
|
||||
turned.current = by
|
||||
onDashboard(by)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,6 +196,8 @@ export function RoundView({
|
||||
was.current = index
|
||||
}
|
||||
const swiped = useRef(false)
|
||||
// A dashboard on its way out keeps its keys to itself.
|
||||
const present = useIsPresent()
|
||||
const [announce, setAnnounce] = useState(
|
||||
() =>
|
||||
Boolean(onDashboard) &&
|
||||
@@ -148,11 +222,12 @@ export function RoundView({
|
||||
onFace?.(next)
|
||||
}
|
||||
|
||||
const keys = !editing && present
|
||||
useShortcuts({
|
||||
pagedown: () => (editing ? false : page(index + 1)),
|
||||
pageup: () => (editing ? false : page(index - 1)),
|
||||
"shift+pagedown": () => (onDashboard && !editing ? onDashboard(1) : false),
|
||||
"shift+pageup": () => (onDashboard && !editing ? onDashboard(-1) : false),
|
||||
pagedown: () => (keys ? page(index + 1) : false),
|
||||
pageup: () => (keys ? page(index - 1) : false),
|
||||
"shift+pagedown": () => (onDashboard && keys ? onDashboard(1) : false),
|
||||
"shift+pageup": () => (onDashboard && keys ? onDashboard(-1) : false),
|
||||
})
|
||||
|
||||
/** A drag that ends far enough away is a swipe, measured in canvas pixels
|
||||
@@ -270,9 +345,10 @@ export function RoundView({
|
||||
{announce ? (
|
||||
<motion.div
|
||||
key="announce"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
variants={fadeIn}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center"
|
||||
>
|
||||
<output
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react"
|
||||
|
||||
import { fractionOf } from "./config"
|
||||
import { useEased } from "./values"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Press
|
||||
@@ -173,7 +174,8 @@ export function fitMarks<T extends { label: string }>(
|
||||
* A drag would otherwise send a value per pixel and flood whatever is
|
||||
* listening, so the draft follows the finger and only the release publishes.
|
||||
* While there is no draft the handle follows the engine, which is what makes a
|
||||
* value set elsewhere show up here.
|
||||
* value set elsewhere show up here — easing there, as a reading does, where a
|
||||
* drag or a key moves it at once.
|
||||
*/
|
||||
export function useSliderDrag({
|
||||
value,
|
||||
@@ -242,13 +244,16 @@ export function useSliderDrag({
|
||||
}))
|
||||
: []
|
||||
|
||||
const fraction = useEased(fractionOf(current, min, max), draft !== null)
|
||||
const markAt = useEased(mark === null ? 0 : fractionOf(mark, min, max))
|
||||
|
||||
return {
|
||||
current,
|
||||
/** Goes on the slider, whose width is the scale's width. */
|
||||
trackRef,
|
||||
fraction: fractionOf(current, min, max),
|
||||
fraction,
|
||||
/** Where the mark sits along the track, 0..1 and held to it; null for none. */
|
||||
markAt: mark === null ? null : fractionOf(mark, min, max),
|
||||
markAt: mark === null ? null : markAt,
|
||||
inputProps: {
|
||||
type: "range" as const,
|
||||
min,
|
||||
|
||||
@@ -694,11 +694,15 @@
|
||||
stroke: var(--foreground);
|
||||
}
|
||||
|
||||
/* Page dots, one per face, in the ring's gap. */
|
||||
/* Page dots, one per face, in the ring's gap. The current one changes over
|
||||
as the face slides in: a colour, so reduced motion keeps it. */
|
||||
.dui-dot-mark {
|
||||
border-radius: 50%;
|
||||
background: var(--muted-foreground);
|
||||
opacity: 0.5;
|
||||
transition:
|
||||
background-color var(--duration-base) var(--ease-standard),
|
||||
opacity var(--duration-base) var(--ease-standard);
|
||||
}
|
||||
|
||||
.dui-dot[aria-current="page"] .dui-dot-mark {
|
||||
|
||||
@@ -132,7 +132,7 @@ export function fractionAt(dx: number, dy: number): number {
|
||||
* a draft the ring follows the engine, through the caller's hold-until-echo.
|
||||
*/
|
||||
export function useRingDraft<T>(value: T, send: (value: T) => void) {
|
||||
const [draft, setDraft] = useState<{ value: T } | null>(null)
|
||||
const [draft, setDraft] = useState<{ value: T; settle: boolean } | null>(null)
|
||||
// Read between renders: notches arrive faster than React draws, and each
|
||||
// one steps from the last rather than from what was last drawn.
|
||||
const pending = useRef<{ value: T } | null>(null)
|
||||
@@ -155,12 +155,14 @@ export function useRingDraft<T>(value: T, send: (value: T) => void) {
|
||||
|
||||
return {
|
||||
current: draft ? draft.value : value,
|
||||
/** A drag is moving it, which the drawing follows without easing. */
|
||||
held: draft !== null && !draft.settle,
|
||||
/** The value a further notch steps from. */
|
||||
latest: () => (pending.current ? pending.current.value : value),
|
||||
/** Move the draft; `settle` publishes once nothing has moved it for a while. */
|
||||
move: (next: T, settle: boolean) => {
|
||||
pending.current = { value: next }
|
||||
setDraft({ value: next })
|
||||
setDraft({ value: next, settle })
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = settle ? setTimeout(commit, RING_SETTLE_MS) : null
|
||||
},
|
||||
|
||||
@@ -3,16 +3,20 @@
|
||||
*
|
||||
* A number that jumps is hard to read and a number that crawls is a distraction
|
||||
* — both sets tween theirs, at their own look's stiffness, through the motion
|
||||
* values here. `<MotionConfig reducedMotion="user">` at the app root switches
|
||||
* every one of them off when the device asks.
|
||||
* values here. These are motion values rather than animated props, which
|
||||
* `<MotionConfig reducedMotion="user">` never reaches, so `useEased` asks the
|
||||
* device itself and moves at once when it wants less motion.
|
||||
*/
|
||||
import {
|
||||
animate,
|
||||
cancelFrame,
|
||||
frame,
|
||||
type MotionValue,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
useReducedMotion,
|
||||
useTransform,
|
||||
} from "motion/react"
|
||||
import { useEffect } from "react"
|
||||
import { useLayoutEffect, useRef } from "react"
|
||||
|
||||
import type { WidgetDef } from "@/client"
|
||||
import { CHART_SLOTS } from "@/components/Common/UplotChart"
|
||||
@@ -43,14 +47,12 @@ export function useAnimatedNumber(
|
||||
value: unknown,
|
||||
precision: number | null,
|
||||
): { label: MotionValue<string>; numeric: boolean } {
|
||||
const { look } = useLook()
|
||||
const numeric = typeof value === "number"
|
||||
const raw = useMotionValue(numeric ? value : 0)
|
||||
const settled = useSpring(raw, LOOK[look].spring)
|
||||
// A reading that goes away and comes back tweens from where it last was.
|
||||
const last = useRef(numeric ? value : 0)
|
||||
if (numeric) last.current = value
|
||||
const settled = useEased(last.current)
|
||||
const written = precision ?? (numeric ? decimalsOf(value as number) : null)
|
||||
useEffect(() => {
|
||||
if (numeric) raw.set(value as number)
|
||||
}, [numeric, value, raw])
|
||||
return {
|
||||
label: useTransform(settled, (at) => format(at, written)),
|
||||
numeric,
|
||||
@@ -59,12 +61,42 @@ export function useAnimatedNumber(
|
||||
|
||||
/** Where a reading sits on its scale, tweened, for a fill or an arc. */
|
||||
export function useAnimatedFraction(fraction: number): MotionValue<number> {
|
||||
return useEased(fraction)
|
||||
}
|
||||
|
||||
/**
|
||||
* A number on its way to `value`, at the look's spring.
|
||||
*
|
||||
* A new value retargets a move still running, from where it is and as fast as
|
||||
* it was going. It moves at once instead while a hand is on it (`held`: a drag
|
||||
* stays under the finger), when the device asks for reduced motion, and for a
|
||||
* value that `wraps`, a hue, when it crosses the seam — more than half the
|
||||
* range, which would otherwise sweep back along the whole of it.
|
||||
*/
|
||||
export function useEased(
|
||||
value: number,
|
||||
held = false,
|
||||
wraps = false,
|
||||
): MotionValue<number> {
|
||||
const { look } = useLook()
|
||||
const raw = useMotionValue(fraction)
|
||||
useEffect(() => {
|
||||
raw.set(fraction)
|
||||
}, [fraction, raw])
|
||||
return useSpring(raw, LOOK[look].spring)
|
||||
const still = useReducedMotion()
|
||||
const eased = useMotionValue(value)
|
||||
// Before paint, so a drag is drawn in the frame it moved in.
|
||||
useLayoutEffect(() => {
|
||||
if (held || still || (wraps && Math.abs(value - eased.get()) > 0.5)) {
|
||||
// In the frame about to be drawn rather than now: what the reader
|
||||
// derives from this value re-subscribes after this effect, and would
|
||||
// miss a change made in it.
|
||||
const jump = () => eased.jump(value)
|
||||
frame.preRender(jump)
|
||||
return () => cancelFrame(jump)
|
||||
}
|
||||
const move = animate(eased, value, LOOK[look].spring)
|
||||
// Stopped rather than left running when the reader goes away; a value
|
||||
// that changes again retargets instead, from inside `animate`.
|
||||
return () => move.stop()
|
||||
}, [value, held, wraps, still, look, eased])
|
||||
return eased
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { type MotionStyle, motion } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { asOriginal, text } from "../core/config"
|
||||
@@ -68,14 +68,14 @@ export function Slider(props: SliderProps) {
|
||||
const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props)
|
||||
const orientation = props.orientation ?? "horizontal"
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
className={cn(
|
||||
"dui-slider fx-slider",
|
||||
orientation === "horizontal" && "w-full",
|
||||
)}
|
||||
data-orientation={orientation}
|
||||
ref={trackRef}
|
||||
style={{ "--dui-fraction": fraction } as React.CSSProperties}
|
||||
style={{ "--dui-fraction": fraction } as MotionStyle}
|
||||
>
|
||||
<div className="dui-slider-body">
|
||||
<span className="dui-slider-rail" aria-hidden>
|
||||
@@ -88,10 +88,10 @@ export function Slider(props: SliderProps) {
|
||||
{/* Under the handle, so where the two meet the value being set wins;
|
||||
after the input only for its `:disabled`, as it takes no pointer. */}
|
||||
{markAt === null ? null : (
|
||||
<span
|
||||
<motion.span
|
||||
className="dui-slider-mark"
|
||||
aria-hidden
|
||||
style={{ "--dui-mark": markAt } as React.CSSProperties}
|
||||
style={{ "--dui-mark": markAt } as MotionStyle}
|
||||
/>
|
||||
)}
|
||||
<span className="dui-slider-thumb" aria-hidden />
|
||||
@@ -115,7 +115,7 @@ export function Slider(props: SliderProps) {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { type MotionStyle, motion } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { asOriginal, text } from "../core/config"
|
||||
@@ -79,14 +79,14 @@ export function Slider(props: SliderProps) {
|
||||
const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props)
|
||||
const orientation = props.orientation ?? "horizontal"
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
className={cn(
|
||||
"dui-slider gl-slider",
|
||||
orientation === "horizontal" && "w-full",
|
||||
)}
|
||||
data-orientation={orientation}
|
||||
ref={trackRef}
|
||||
style={{ "--dui-fraction": fraction } as React.CSSProperties}
|
||||
style={{ "--dui-fraction": fraction } as MotionStyle}
|
||||
>
|
||||
<div className="dui-slider-body">
|
||||
<span className="dui-slider-rail" aria-hidden>
|
||||
@@ -99,10 +99,10 @@ export function Slider(props: SliderProps) {
|
||||
{/* Under the handle, so where the two meet the value being set wins;
|
||||
after the input only for its `:disabled`, as it takes no pointer. */}
|
||||
{markAt === null ? null : (
|
||||
<span
|
||||
<motion.span
|
||||
className="dui-slider-mark"
|
||||
aria-hidden
|
||||
style={{ "--dui-mark": markAt } as React.CSSProperties}
|
||||
style={{ "--dui-mark": markAt } as MotionStyle}
|
||||
/>
|
||||
)}
|
||||
<span className="dui-slider-thumb" aria-hidden />
|
||||
@@ -126,7 +126,7 @@ export function Slider(props: SliderProps) {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown } from "lucide-react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { AnimatePresence, type MotionStyle, motion } from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { asOriginal, text } from "../core/config"
|
||||
@@ -108,14 +108,14 @@ export function Slider(props: SliderProps) {
|
||||
const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props)
|
||||
const orientation = props.orientation ?? "horizontal"
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
className={cn(
|
||||
"dui-slider m3-slider",
|
||||
orientation === "horizontal" && "w-full",
|
||||
)}
|
||||
data-orientation={orientation}
|
||||
ref={trackRef}
|
||||
style={{ "--dui-fraction": fraction } as React.CSSProperties}
|
||||
style={{ "--dui-fraction": fraction } as MotionStyle}
|
||||
>
|
||||
<div className="dui-slider-body">
|
||||
<span className="dui-slider-rail" aria-hidden>
|
||||
@@ -128,10 +128,10 @@ export function Slider(props: SliderProps) {
|
||||
{/* Under the handle, so where the two meet the value being set wins;
|
||||
after the input only for its `:disabled`, as it takes no pointer. */}
|
||||
{markAt === null ? null : (
|
||||
<span
|
||||
<motion.span
|
||||
className="dui-slider-mark"
|
||||
aria-hidden
|
||||
style={{ "--dui-mark": markAt } as React.CSSProperties}
|
||||
style={{ "--dui-mark": markAt } as MotionStyle}
|
||||
/>
|
||||
)}
|
||||
<span className="dui-slider-thumb" aria-hidden />
|
||||
@@ -155,7 +155,7 @@ export function Slider(props: SliderProps) {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user