Files
app/frontend/src/components/Dashboard/ui/glass/Controls.tsx
T
stroblme 6238728dce Draw the panel as a panel
Five things a wall panel showed that a wall panel should not.

**A tile's body no longer clips.** It scrolled, and a box that scrolls
also cuts whatever crosses its edge — which took the glow off a lit
button at exactly the width where the button filled its tile, and off a
gauge's arc at exactly the height where the dial filled its own. Only
what is written or listed asks for a scroller now; everything else is a
picture drawn to fit, and what overflows is left to the frame, which
clips at the tile's edge where a shadow has already faded out. The
slider's phantom scrollbar goes with it.

**The selector is a selector.** Named for what it does rather than what
it is, and the choice it is holding is held in the dashboard's own
primary — a pill that slides between the options rather than a grey one
that had to be looked for. The stored type is untouched, so no document
changes meaning.

**The arrangement is held off the panel's edges**, by the same distance
it holds between two widgets. The ground is not held off with it: a
background covers the whole panel, and only what is arranged on it has
a margin. No stored panel loses a row to it.

**The rail is drawn on the panel.** It was chrome bolted to the edge of
the screen beside the canvas — in the app's own design rather than the
dashboard's, and on a scaled canvas not even lined up with it. It now
takes a column out of the canvas the way the margin does, scaled with
it and wearing its look. Which cell each widget sits in is unchanged;
only how big a cell is.

Two of these were the same mistake twice: an unlayered rule stating
`position` for everything wearing a class — `.gl-surface` on a rail
placed by a utility, and a blanket lift over every child of a pressable
on a pill placed by `layoutId`. Both now say it one element at a time.
2026-08-24 10:44:53 +02:00

246 lines
7.0 KiB
TypeScript

/**
* Liquid glass: the controls.
*
* A press is answered by the light moving — a soft glow that follows the
* pointer across the surface — and by the control giving very slightly. Every
* one of these is a `ui/core` hook in a different coat.
*/
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown } from "lucide-react"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
import { asOriginal, text } from "../core/config"
import type {
ButtonProps,
InputProps,
SegmentedProps,
SelectProps,
SliderProps,
SwitchProps,
} from "../core/contract"
import {
usePress,
useSegmented,
useSliderDrag,
useSwitch,
} from "../core/controls"
import { useLook } from "../core/look"
import { LOOK } from "../core/motion"
export function Button({
variant = "tonal",
pressed,
disabled,
label,
onClick,
children,
}: ButtonProps) {
const { onPointerDown } = usePress()
return (
<motion.button
type="button"
className="gl-button gl-pressable w-full"
data-variant={variant}
aria-pressed={pressed}
aria-label={label}
disabled={disabled}
whileTap={{ scale: 0.97 }}
transition={LOOK.glass.spring}
onPointerDown={onPointerDown}
onClick={onClick}
>
<span className="gl-glow" aria-hidden />
<span className="gl-lift min-w-0 truncate">{children}</span>
</motion.button>
)
}
export function Switch(props: SwitchProps) {
const { buttonProps } = useSwitch(props)
const { onPointerDown } = usePress()
return (
<button
{...buttonProps}
className="gl-switch gl-pressable"
onPointerDown={onPointerDown}
>
<span className="gl-glow" aria-hidden />
<motion.span
layout
transition={LOOK.glass.spring}
className="gl-switch-thumb"
/>
</button>
)
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks } = useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<div
className={cn(
"dui-slider gl-slider",
orientation === "horizontal" && "w-full",
)}
data-orientation={orientation}
style={{ "--dui-fraction": fraction } as React.CSSProperties}
>
<div className="dui-slider-body">
<span className="dui-slider-rail" aria-hidden>
<span className="dui-slider-fill" />
</span>
{/* The control itself, laid transparent over what is drawn: it keeps
the keyboard, the pointer and every `aria-`, and owes the look
nothing. */}
<input {...inputProps} className="dui-slider-input" />
<span className="dui-slider-thumb" aria-hidden />
</div>
{marks.length > 0 ? (
// Decoration: the input itself announces min, max and where it stands.
<div aria-hidden className="dui-ticks">
{marks.map((mark) => (
<span
key={mark.percent}
className="absolute top-0"
// Shifted by its own share of itself: the first label sits flush
// left and the last flush right, so neither hangs off the tile.
style={{
left: `${mark.percent}%`,
transform: `translateX(-${mark.percent}%)`,
}}
>
{mark.label}
</span>
))}
</div>
) : null}
</div>
)
}
export function Segmented(props: SegmentedProps) {
const { chosen, thumbId, groupProps, itemProps } = useSegmented(props)
const vertical = props.orientation === "vertical"
const { onPointerDown } = usePress()
return (
<div
{...groupProps}
data-testid={props.testId}
className="gl-segmented"
style={
vertical
? undefined
: {
gridTemplateColumns: `repeat(${props.options.length}, minmax(0, 1fr))`,
}
}
>
{props.options.map(([value, label], index) => (
<button
key={value}
{...itemProps(index)}
className="gl-segment gl-pressable"
onPointerDown={onPointerDown}
>
{index === chosen ? (
<motion.span
aria-hidden
layoutId={thumbId}
transition={LOOK.glass.spring}
className="gl-segment-thumb"
/>
) : null}
<span className="gl-glow" aria-hidden />
<span className="gl-segment-label">{label}</span>
</button>
))}
</div>
)
}
export function Select({
value,
options,
label,
disabled,
onChange,
}: SelectProps) {
// The menu is portalled to `body`, which is outside the canvas — so it
// re-states the dashboard's own colours rather than borrowing the app's.
const { style } = useLook()
return (
<SelectPrimitive.Root
value={value}
onValueChange={(selected) => onChange(asOriginal(selected, options))}
>
<SelectPrimitive.Trigger
aria-label={label}
disabled={disabled}
className="gl-field gl-pressable justify-between"
>
<span className="gl-glow" aria-hidden />
<span className="gl-lift min-w-0 truncate">
<SelectPrimitive.Value placeholder="Choose" />
</span>
<SelectPrimitive.Icon asChild>
<ChevronDown
className="gl-lift size-4 shrink-0 opacity-60"
aria-hidden
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
position="popper"
sideOffset={4}
style={style}
className="gl-menu z-50 max-h-64 min-w-[var(--radix-select-trigger-width)] overflow-y-auto p-1 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
<SelectPrimitive.Viewport>
{options.map((option) => (
<SelectPrimitive.Item
key={text(option.value)}
value={text(option.value)}
className="flex cursor-pointer select-none items-center justify-between gap-2 rounded-xl px-3 py-2 text-sm outline-none data-[highlighted]:bg-white/15"
>
<SelectPrimitive.ItemText>
{option.label ?? text(option.value)}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator>
<Check className="size-4" aria-hidden />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
)
}
export function Input({
value,
type,
label,
disabled,
onChange,
onCommit,
}: InputProps) {
return (
<input
className="gl-field"
value={value}
type={type}
aria-label={label}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
onBlur={onCommit}
onKeyDown={(event) => {
if (event.key === "Enter") onCommit()
}}
/>
)
}