Float the dashboard rail along the bottom on a phone
Below md a dashboard stacks to one column and dropped its rail. The rail is
now a horizontal row in the dashboard's own look, floated over the bottom
of the screen on /panel/{id} and above the dock in the app's view, with
44px items, sideways scrolling when it overflows, and padding so the last
tile clears it; a panel's read-only notice moves up out of its way. The
three looks share the geometry from ui/core.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
This commit is contained in:
@@ -240,7 +240,9 @@ A wall tablet has no keyboard, so it pairs.
|
||||
|
||||
1. **Dashboards → Panels**, add a panel named after where it hangs, and tick
|
||||
the dashboards it shows. More than one and the screen draws a rail to switch
|
||||
between them, with each dashboard's own icon on it.
|
||||
between them, with each dashboard's own icon on it. On a phone the rail is a
|
||||
row floating along the bottom; the app shows it there too, above its own
|
||||
buttons, for a dashboard that hangs on such a panel.
|
||||
2. Tick **Touch friendly** if the screen is touched rather than pointed at.
|
||||
Controls and the rail grow to a finger's size — the rail gets taller without
|
||||
taking a wider column, so the arrangement does not move. It sits here rather
|
||||
|
||||
@@ -361,6 +361,20 @@ export function DashboardEditor({
|
||||
(panel.dashboards ?? []).includes(draft.name),
|
||||
)?.dashboards ?? null
|
||||
const hasRail = Boolean(railDashboards) && !stacked
|
||||
const rail = railDashboards ? (
|
||||
<PanelRail
|
||||
dashboards={railDashboards}
|
||||
current={draft.name}
|
||||
horizontal={stacked}
|
||||
// Editing one dashboard of a panel and editing its neighbour are the
|
||||
// same job, so the rail keeps whichever mode this one is in.
|
||||
linkFor={(name) => ({
|
||||
to: "/dashboards/$name",
|
||||
params: { name },
|
||||
search: edit ? { edit: true } : {},
|
||||
})}
|
||||
/>
|
||||
) : null
|
||||
// What the arrangement is laid out in: the canvas less its margin, and less
|
||||
// the rail's column when the panel carries one.
|
||||
const area = areaOf(draft, hasRail)
|
||||
@@ -453,38 +467,29 @@ export function DashboardEditor({
|
||||
data-touch={root["data-touch"]}
|
||||
data-palette={root["data-palette"]}
|
||||
className={cn(
|
||||
"h-full overflow-y-auto bg-background text-foreground",
|
||||
"relative h-full bg-background text-foreground",
|
||||
root.className,
|
||||
)}
|
||||
style={root.style}
|
||||
>
|
||||
<DashboardView
|
||||
dashboard={draft}
|
||||
stacked
|
||||
editing={edit}
|
||||
renderWidget={edit ? pickable : undefined}
|
||||
/>
|
||||
<div className={cn("h-full overflow-y-auto", rail && "pb-20")}>
|
||||
<DashboardView
|
||||
dashboard={draft}
|
||||
stacked
|
||||
editing={edit}
|
||||
renderWidget={edit ? pickable : undefined}
|
||||
/>
|
||||
</div>
|
||||
{rail ? (
|
||||
// No column to spare on a phone, so the rail is a row floating over
|
||||
// the bottom of the stack, just above the dock.
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex justify-center">
|
||||
{rail}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<CanvasSurface
|
||||
dashboard={draft}
|
||||
dots={edit}
|
||||
rail={
|
||||
railDashboards ? (
|
||||
<PanelRail
|
||||
dashboards={railDashboards}
|
||||
current={draft.name}
|
||||
// Editing one dashboard of a panel and editing its neighbour are
|
||||
// the same job, so the rail keeps whichever mode this one is in.
|
||||
linkFor={(name) => ({
|
||||
to: "/dashboards/$name",
|
||||
params: { name },
|
||||
search: edit ? { edit: true } : {},
|
||||
})}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<CanvasSurface dashboard={draft} dots={edit} rail={rail}>
|
||||
{(scale) =>
|
||||
!edit ? (
|
||||
<DashboardView dashboard={draft} rail={hasRail} />
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useUi } from "./ui"
|
||||
*
|
||||
* Drawn in the dashboard's own look, because it hangs on the same screen: a
|
||||
* rail wearing the app's chrome beside a glass panel is two designs at once.
|
||||
* On a phone it is a row the caller floats along the bottom.
|
||||
*
|
||||
* Reading the dashboards it links to is also what fills the labels, and it
|
||||
* warms the cache for the neighbours so a switch draws immediately.
|
||||
@@ -23,10 +24,12 @@ export function PanelRail({
|
||||
dashboards,
|
||||
current,
|
||||
linkFor,
|
||||
horizontal,
|
||||
}: {
|
||||
dashboards: string[]
|
||||
current: string
|
||||
linkFor: (name: string) => LinkProps
|
||||
horizontal?: boolean
|
||||
}) {
|
||||
const { Rail } = useUi()
|
||||
const entries = useQueries({
|
||||
@@ -41,5 +44,5 @@ export function PanelRail({
|
||||
})),
|
||||
})
|
||||
|
||||
return <Rail entries={entries} />
|
||||
return <Rail entries={entries} horizontal={horizontal} />
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { useDashboardLocked } from "@/components/Dashboard/settings"
|
||||
import { useUi } from "@/components/Dashboard/ui"
|
||||
import { LookProvider } from "@/components/Dashboard/ui/core/look"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* One dashboard filling whatever screen it landed on.
|
||||
@@ -30,7 +31,8 @@ export function PanelSurface({
|
||||
/** A landscape arrangement scaled onto a phone comes out at about a fifth of
|
||||
* its size, which reads as nothing at all. Stack it instead. */
|
||||
stacked?: boolean
|
||||
/** The way between this panel's dashboards, drawn on the canvas itself. */
|
||||
/** The way between this panel's dashboards, drawn on the canvas itself, or
|
||||
* along the bottom of the screen when stacked. */
|
||||
rail?: React.ReactNode
|
||||
/** Whether the screen this hangs on is touched. Stated by the panel route. */
|
||||
touch?: boolean
|
||||
@@ -49,7 +51,18 @@ export function PanelSurface({
|
||||
// the look on: this branch mounts what `CanvasSurface` mounts for the
|
||||
// other one, touch included.
|
||||
<LookProvider dashboard={dashboard} touch={touch}>
|
||||
<DashboardView dashboard={dashboard} stacked />
|
||||
{/* Padded here rather than by the route: the stack overflows this
|
||||
box, so only padding inside the overflow clears the rail. */}
|
||||
<div className={cn(rail && "pb-24")}>
|
||||
<DashboardView dashboard={dashboard} stacked />
|
||||
</div>
|
||||
{rail ? (
|
||||
// No column to spare on a phone, so the rail is a row floating
|
||||
// over the bottom edge.
|
||||
<div className="pointer-events-none fixed inset-x-4 bottom-[calc(1rem+env(safe-area-inset-bottom))] z-10 flex justify-center">
|
||||
{rail}
|
||||
</div>
|
||||
) : null}
|
||||
</LookProvider>
|
||||
) : (
|
||||
// The panel's own surface, scaled to fit. No dots: nothing is being
|
||||
@@ -60,7 +73,14 @@ export function PanelSurface({
|
||||
)}
|
||||
{/* Outside the canvas, so it needs the look stated for it. */}
|
||||
<LookProvider dashboard={dashboard} touch={touch}>
|
||||
<LockNotice dashboard={dashboard} />
|
||||
{stacked && rail ? (
|
||||
// Lifted clear of the rail floating along the bottom.
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 bottom-20">
|
||||
<LockNotice dashboard={dashboard} />
|
||||
</div>
|
||||
) : (
|
||||
<LockNotice dashboard={dashboard} />
|
||||
)}
|
||||
</LookProvider>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -147,8 +147,26 @@ export type RailProps = {
|
||||
active: boolean
|
||||
link: LinkProps
|
||||
}[]
|
||||
/** A row rather than a column, for a phone; whoever draws it places it. */
|
||||
horizontal?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a rail runs, shared by the looks, which only paint it.
|
||||
*
|
||||
* A column centred down the canvas's left edge, as tall as what it carries: a
|
||||
* rail of two stretched to the height of the panel is mostly empty pill. Below
|
||||
* `md` there is no column to spare, so it is a row instead, floated along the
|
||||
* bottom by its caller. More dashboards than fit still scroll, but no bar is
|
||||
* ever drawn — a wall panel is swiped, and there is no room.
|
||||
*/
|
||||
export const railGeometry = (horizontal = false) =>
|
||||
`pointer-events-auto z-10 flex items-center gap-1 p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden ${
|
||||
horizontal
|
||||
? "max-w-full flex-row overflow-x-auto"
|
||||
: "absolute left-3 top-1/2 max-h-[calc(100%-1.5rem)] w-12 -translate-y-1/2 flex-col overflow-y-auto"
|
||||
}`
|
||||
|
||||
export type NoticeProps = { children: React.ReactNode }
|
||||
|
||||
/** The ambient ground under the widgets, or the image that replaces it. */
|
||||
|
||||
@@ -83,6 +83,12 @@
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
/* The row a phone gets has no column to fit, and a phone is touched. */
|
||||
.dui-rail[data-orientation="horizontal"] .dui-rail-item {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* Pulled up half a step, so the title reads from the centre of the corner
|
||||
* radius rather than from below it — and the body, which is what anyone is
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
NoticeProps,
|
||||
RailProps,
|
||||
} from "../core/contract"
|
||||
import { TESTID } from "../core/contract"
|
||||
import { railGeometry, TESTID } from "../core/contract"
|
||||
import { LOOK } from "../core/motion"
|
||||
|
||||
/** The page colour is the ground; only an image is drawn over it. */
|
||||
@@ -110,19 +110,13 @@ export function Frame({
|
||||
)
|
||||
}
|
||||
|
||||
export function Rail({ entries }: RailProps) {
|
||||
export function Rail({ entries, horizontal }: RailProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Dashboards on this panel"
|
||||
data-testid={TESTID.rail}
|
||||
className={cn(
|
||||
"dui-rail fx-rail pointer-events-auto absolute left-3 top-1/2 z-10 flex max-h-[calc(100%-1.5rem)] w-12 -translate-y-1/2 flex-col items-center gap-1 p-1",
|
||||
// As tall as what it carries, centred in the column it reserves: a
|
||||
// rail of two stretched to the height of the panel is mostly empty
|
||||
// pill. More dashboards than the panel is tall still scroll, but no
|
||||
// bar is ever drawn — a wall panel is swiped, and there is no room.
|
||||
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
data-orientation={horizontal ? "horizontal" : undefined}
|
||||
className={cn("dui-rail fx-rail", railGeometry(horizontal))}
|
||||
>
|
||||
{entries.map((entry) => {
|
||||
const Glyph = ICONS[entry.icon ?? ""]
|
||||
@@ -149,7 +143,9 @@ export function Rail({ entries }: RailProps) {
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{entry.label}</TooltipContent>
|
||||
<TooltipContent side={horizontal ? "top" : "right"}>
|
||||
{entry.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
NoticeProps,
|
||||
RailProps,
|
||||
} from "../core/contract"
|
||||
import { TESTID } from "../core/contract"
|
||||
import { railGeometry, TESTID } from "../core/contract"
|
||||
import { LOOK } from "../core/motion"
|
||||
|
||||
/**
|
||||
@@ -143,19 +143,13 @@ export function Frame({
|
||||
)
|
||||
}
|
||||
|
||||
export function Rail({ entries }: RailProps) {
|
||||
export function Rail({ entries, horizontal }: RailProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Dashboards on this panel"
|
||||
data-testid={TESTID.rail}
|
||||
className={cn(
|
||||
"dui-rail gl-surface gl-rail pointer-events-auto absolute left-3 top-1/2 z-10 flex max-h-[calc(100%-1.5rem)] w-12 -translate-y-1/2 flex-col items-center gap-1 p-1",
|
||||
// As tall as what it carries, centred in the column it reserves: a
|
||||
// rail of two stretched to the height of the panel is mostly empty
|
||||
// pill. More dashboards than the panel is tall still scroll, but no
|
||||
// bar is ever drawn — a wall panel is swiped, and there is no room.
|
||||
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
data-orientation={horizontal ? "horizontal" : undefined}
|
||||
className={cn("dui-rail gl-surface gl-rail", railGeometry(horizontal))}
|
||||
>
|
||||
{entries.map((entry) => {
|
||||
const Glyph = ICONS[entry.icon ?? ""]
|
||||
@@ -186,7 +180,9 @@ export function Rail({ entries }: RailProps) {
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{entry.label}</TooltipContent>
|
||||
<TooltipContent side={horizontal ? "top" : "right"}>
|
||||
{entry.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
NoticeProps,
|
||||
RailProps,
|
||||
} from "../core/contract"
|
||||
import { TESTID } from "../core/contract"
|
||||
import { railGeometry, TESTID } from "../core/contract"
|
||||
import { LOOK } from "../core/motion"
|
||||
|
||||
/** Material's ground is the surface colour itself; only an image is drawn. */
|
||||
@@ -109,19 +109,13 @@ export function Frame({
|
||||
)
|
||||
}
|
||||
|
||||
export function Rail({ entries }: RailProps) {
|
||||
export function Rail({ entries, horizontal }: RailProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Dashboards on this panel"
|
||||
data-testid={TESTID.rail}
|
||||
className={cn(
|
||||
"dui-rail m3-rail pointer-events-auto absolute left-3 top-1/2 z-10 flex max-h-[calc(100%-1.5rem)] w-12 -translate-y-1/2 flex-col items-center gap-1 p-1",
|
||||
// As tall as what it carries, centred in the column it reserves: a
|
||||
// rail of two stretched to the height of the panel is mostly empty
|
||||
// pill. More dashboards than the panel is tall still scroll, but no
|
||||
// bar is ever drawn — a wall panel is swiped, and there is no room.
|
||||
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}
|
||||
data-orientation={horizontal ? "horizontal" : undefined}
|
||||
className={cn("dui-rail m3-rail", railGeometry(horizontal))}
|
||||
>
|
||||
{entries.map((entry) => {
|
||||
const Glyph = ICONS[entry.icon ?? ""]
|
||||
@@ -155,7 +149,9 @@ export function Rail({ entries }: RailProps) {
|
||||
</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{entry.label}</TooltipContent>
|
||||
<TooltipContent side={horizontal ? "top" : "right"}>
|
||||
{entry.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { cn } from "@/lib/utils"
|
||||
* What a paired device shows: the dashboards this panel was assigned.
|
||||
*
|
||||
* The same full-bleed surface as `/view/{name}`, with a rail down the left
|
||||
* when there is more than one to switch between — so a hallway tablet and a
|
||||
* (along the bottom on a phone) when there is more than one to switch between — so a hallway tablet and a
|
||||
* workshop tablet can carry different sets without either dashboard knowing
|
||||
* anything about the other.
|
||||
*
|
||||
@@ -112,6 +112,7 @@ function PanelRoute() {
|
||||
<PanelRail
|
||||
dashboards={dashboards}
|
||||
current={current}
|
||||
horizontal={stacked}
|
||||
linkFor={(name) => ({
|
||||
to: "/panel/$id",
|
||||
params: { id },
|
||||
|
||||
Reference in New Issue
Block a user