- IconPicker replaces the three icon selects (rail icon, icon-widget rule,
"Otherwise"): the glyphs in a grid, and a button that clears back to none —
which a Radix SelectItem could never offer.
- ModePicker/StylePicker drop out in favour of a shared ui/Segmented, the same
sliding-thumb shape RangePicker and the widget-side control already wear.
- PanelRail draws no scrollbars at all: hiding them also takes back the gutter
a vertical bar claimed from a column exactly as wide as its buttons, which is
what pushed a horizontal bar under them.
- The panels dialog can re-pair one screen (POST /panels/{id}/unpair) without
deleting the panel it hangs on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
107 lines
3.9 KiB
TypeScript
107 lines
3.9 KiB
TypeScript
import { useQueries } from "@tanstack/react-query"
|
|
import { Link, type LinkProps } from "@tanstack/react-router"
|
|
|
|
import { ICONS } from "@/components/Dashboard/icons"
|
|
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
/** How much room the rail takes, canvas insets included. Mirrors the side
|
|
* panel's `27rem`: the button plus the gutters either side of it. */
|
|
export const RAIL_INSET = "4.5rem"
|
|
|
|
/** Two letters off the title, so a rail of four reads as four different things. */
|
|
function initials(label: string): string {
|
|
const words = label.split(/[\s_-]+/).filter(Boolean)
|
|
if (words.length === 0) return "?"
|
|
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
|
|
return (words[0][0] + words[1][0]).toUpperCase()
|
|
}
|
|
|
|
/**
|
|
* Switching between the dashboards one panel was assigned.
|
|
*
|
|
* Permanent rather than a menu behind a button: a wall panel is glanced at,
|
|
* and something you have to open first is something nobody opens. It costs
|
|
* about a fortieth of the canvas, which `CanvasSurface` absorbs by scaling —
|
|
* the grid itself never changes, so an arrangement made without the rail still
|
|
* fits with it.
|
|
*
|
|
* 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.
|
|
*/
|
|
export function PanelRail({
|
|
dashboards,
|
|
current,
|
|
linkFor,
|
|
className,
|
|
}: {
|
|
dashboards: string[]
|
|
current: string
|
|
linkFor: (name: string) => LinkProps
|
|
className?: string
|
|
}) {
|
|
const entries = useQueries({
|
|
queries: dashboards.map((name) => dashboardQueryOptions(name)),
|
|
combine: (results) =>
|
|
results.map((result, index) => ({
|
|
label: result.data?.title || dashboards[index],
|
|
icon: result.data?.icon ?? "",
|
|
})),
|
|
})
|
|
|
|
return (
|
|
<nav
|
|
aria-label="Dashboards on this panel"
|
|
data-testid="panel-rail"
|
|
className={cn(
|
|
"pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-12 flex-col items-center gap-1 rounded-lg border border-border bg-card/80 p-1 shadow-e2 backdrop-blur-md",
|
|
// More dashboards than the column is tall still scroll, but no bar is
|
|
// ever drawn: a wall panel is swiped, and there is no room for one
|
|
// anyway. `w-12` less `p-1` either side is exactly the 40px button, so
|
|
// a classic vertical bar claiming its gutter is what pushed the buttons
|
|
// out sideways and put a horizontal bar under them — `overflow-y` alone
|
|
// computes `overflow-x` to `auto` rather than leaving it visible.
|
|
"overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
|
className,
|
|
)}
|
|
>
|
|
{dashboards.map((name, index) => {
|
|
const { label, icon } = entries[index]
|
|
const Glyph = ICONS[icon]
|
|
const active = name === current
|
|
return (
|
|
<Tooltip key={name}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
asChild
|
|
variant="ghost"
|
|
// `icon-lg` at every width on purpose: a rail is a touch
|
|
// surface whatever the screen's size, and the panel it hangs
|
|
// on is 1024 wide as often as it is 400.
|
|
size="icon-lg"
|
|
className={cn(
|
|
"shrink-0 text-xs font-medium text-muted-foreground",
|
|
active && "bg-accent text-foreground",
|
|
)}
|
|
aria-current={active ? "page" : undefined}
|
|
data-testid={`panel-rail-${name}`}
|
|
>
|
|
<Link {...linkFor(name)} aria-label={label}>
|
|
{Glyph ? <Glyph className="size-5" /> : initials(label)}
|
|
</Link>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right">{label}</TooltipContent>
|
|
</Tooltip>
|
|
)
|
|
})}
|
|
</nav>
|
|
)
|
|
}
|