Stagger a rectangular panel's new dashboard by rail order

Changing dashboard kept the grid mounted, so every new tile rose at once.
The grid is drawn afresh per dashboard and its tiles come in row by row:
from below, top row first, for one further down the rail, and from above,
bottom row first, for one further up, as a round panel's dashboards slide.
This commit is contained in:
2026-10-15 16:12:38 +02:00
parent 64d18428b5
commit 70677dcc20
5 changed files with 57 additions and 19 deletions
+1 -1
View File
@@ -347,7 +347,7 @@ dashboard's look's.
| 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 dot | 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 | — |
| Dashboard change (rectangular panel) | every tile | the old dashboard goes at once. The new tiles come in row by row, 30 ms apart, each opacity 0 → 1 and y +8 → 0 (Glass: +12, and scale 0.96 → 1): from below, top row first, for a dashboard further down the rail, and from above (y −8 → 0), bottom row first, for one further up | spring | — |
| Rail | the current entry's marker | slides to the new entry | spring | retargets |
With reduced motion asked for, nothing travels on its own: a page under the
@@ -393,6 +393,7 @@ export function DashboardView({
editing,
onDashboard,
around,
turned,
}: {
dashboard: Dashboard
renderWidget?: (widget: WidgetDef) => React.ReactNode
@@ -407,6 +408,9 @@ export function DashboardView({
* previous (-1), and their names. */
onDashboard?: (by: number) => void
around?: [string | undefined, string | undefined]
/** Which way along a panel's rail it was reached: down (1) or up (-1).
* Its tiles come in from that side. */
turned?: number
}) {
const all = widgetsOf(dashboard)
const round = isRound(dashboard)
@@ -424,15 +428,15 @@ export function DashboardView({
)
}
// Stacked, the arrangement becomes a reading order, so it follows the rows
// the panel shows rather than the order widgets happened to be added in.
const widgets = stacked
? [...all].sort((a, b) => {
const left = placement(a)
const right = placement(b)
return (left.y ?? 0) - (right.y ?? 0) || (left.x ?? 0) - (right.x ?? 0)
})
: all
// In the rows the panel shows rather than the order widgets happened to be
// added in: the reading order a stack follows, and the order tiles arrive
// in. A grid places each one itself, and one nobody arranged has them all
// at 0,0, which the sort leaves as they were.
const widgets = [...all].sort((a, b) => {
const left = placement(a)
const right = placement(b)
return (left.y ?? 0) - (right.y ?? 0) || (left.x ?? 0) - (right.x ?? 0)
})
const columns = columnsOf(dashboard)
const grid = (
@@ -449,11 +453,15 @@ export function DashboardView({
/>
) : (
<WidgetGrid
// Drawn afresh for another dashboard, so its tiles arrive as a page
// rather than all at once under a grid already shown.
key={dashboard.name}
dashboard={dashboard}
widgets={widgets}
columns={columns}
stacked={stacked}
rail={rail}
from={turned !== undefined && turned < 0 ? -1 : 1}
renderWidget={renderWidget}
/>
)}
@@ -480,6 +488,7 @@ function WidgetGrid({
columns,
stacked,
rail,
from,
renderWidget,
}: {
dashboard: Dashboard
@@ -487,6 +496,8 @@ function WidgetGrid({
columns: number
stacked?: boolean
rail?: boolean
/** The side the tiles come in from: below (1) or above (-1). */
from: number
renderWidget?: (widget: WidgetDef) => React.ReactNode
}) {
const { Frame } = useUi()
@@ -496,6 +507,7 @@ function WidgetGrid({
className={cn("widget-grid", stacked && "widget-stacked")}
data-placed={isPlaced(widgets) || undefined}
variants={gridStagger}
custom={from}
initial="hidden"
animate="visible"
style={
@@ -513,6 +525,7 @@ function WidgetGrid({
style={widgetStyle(widget, columns)}
className="widget-cell"
variants={LOOK[look].enter}
custom={from}
transition={LOOK[look].spring}
>
{renderWidget ? (
@@ -30,6 +30,7 @@ export function PanelSurface({
touch,
onDashboard,
around,
turned,
}: {
dashboard: Dashboard
/** A landscape arrangement scaled onto a phone comes out at about a fifth of
@@ -45,6 +46,9 @@ export function PanelSurface({
* others instead, whose names are `around` it. */
onDashboard?: (by: number) => void
around?: [string | undefined, string | undefined]
/** Which way along the rail this dashboard was reached: down (1) or up
* (-1). */
turned?: number
}) {
const hasRail = Boolean(rail)
const stack = stacked && !isRound(dashboard)
@@ -57,9 +61,10 @@ export function PanelSurface({
rail={hasRail}
onDashboard={onDashboard}
around={around}
turned={turned}
/>
),
[dashboard, hasRail, onDashboard, around],
[dashboard, hasRail, onDashboard, around, turned],
)
return (
<div className="relative size-full">
@@ -76,7 +81,7 @@ export function PanelSurface({
{/* Padded here rather than by the route: the stack overflows this
box, so only padding inside the overflow clears the rail. */}
<div className={cn("relative", rail && "pb-24")}>
<DashboardView dashboard={dashboard} stacked />
<DashboardView dashboard={dashboard} stacked turned={turned} />
</div>
{rail ? (
// No column to spare on a phone, so the rail is a row floating
@@ -21,7 +21,8 @@ import type { Look } from "../../settings"
type LookMotion = {
spring: Transition
/** How a widget arrives on the canvas. */
/** How a widget arrives on the canvas. `custom` is the side it comes from,
* as `gridStagger` takes it. */
enter: Variants
/** Seconds for one round of the ambient ground; 0 draws none. */
drift: number
@@ -34,7 +35,7 @@ export const LOOK: Record<Look, LookMotion> = {
// the way the rest of the product does.
spring: { type: "spring", stiffness: 420, damping: 34, mass: 1 },
enter: {
hidden: { opacity: 0, y: 8 },
hidden: (from = 1) => ({ opacity: 0, y: 8 * from }),
visible: { opacity: 1, y: 0 },
},
drift: 0,
@@ -42,7 +43,7 @@ export const LOOK: Record<Look, LookMotion> = {
material: {
spring: { type: "spring", stiffness: 550, damping: 38, mass: 1 },
enter: {
hidden: { opacity: 0, y: 8 },
hidden: (from = 1) => ({ opacity: 0, y: 8 * from }),
visible: { opacity: 1, y: 0 },
},
drift: 0,
@@ -50,15 +51,23 @@ export const LOOK: Record<Look, LookMotion> = {
glass: {
spring: { type: "spring", stiffness: 320, damping: 22, mass: 0.8 },
enter: {
hidden: { opacity: 0, y: 12, scale: 0.96 },
hidden: (from = 1) => ({ opacity: 0, y: 12 * from, scale: 0.96 }),
visible: { opacity: 1, y: 0, scale: 1 },
},
drift: 46,
},
}
/** Widgets arrive as a page rather than all at once. */
/**
* Widgets arrive as a page rather than all at once, row by row.
*
* `custom` is the side they come from: below (1), as a round panel's next
* dashboard rises, or above (-1) for one further up a panel's rail, which
* brings the last row in first.
*/
export const gridStagger: Variants = {
hidden: {},
visible: { transition: { staggerChildren: 0.03 } },
visible: (from = 1) => ({
transition: { staggerChildren: 0.03, staggerDirection: from < 0 ? -1 : 1 },
}),
}
+12 -1
View File
@@ -1,6 +1,6 @@
import { keepPreviousData, useQuery } from "@tanstack/react-query"
import { createFileRoute, redirect } from "@tanstack/react-router"
import { useCallback, useMemo } from "react"
import { useCallback, useMemo, useState } from "react"
import type { Dashboard } from "@/components/Dashboard/DashboardView"
import { PanelRail } from "@/components/Dashboard/PanelRail"
@@ -84,6 +84,16 @@ function PanelRoute() {
},
[panel, current, navigate],
)
// Which way along the rail the dashboard shown was reached, so its tiles
// come in from that side: further down it (1) from below, as a round
// panel's next dashboard rises, and further up (-1) from above.
const shown = dashboard?.name
const [turned, setTurned] = useState({ name: shown, by: 1 })
if (shown !== turned.name) {
const by =
dashboards.indexOf(shown ?? "") - dashboards.indexOf(turned.name ?? "")
setTurned({ name: shown, by: by < 0 ? -1 : 1 })
}
// Said by the panel rather than by the dashboard: this is the screen, and
// whether it is touched or pointed at is a fact about the screen. A document
// hanging on a hallway tablet and on a desk browser is one document either
@@ -131,6 +141,7 @@ function PanelRoute() {
dashboard={dashboard as Dashboard}
stacked={stacked}
touch={touch}
turned={turned.by}
onDashboard={rail && round ? step : undefined}
around={rail && round ? around : undefined}
// Drawn on the panel rather than beside it: a screen showing four