Files
app/frontend/src/components/Dashboard/ui/material/Controls.tsx
T
stroblme 88c23af638 Hold the tile title still in edit mode, and thin crowded slider ticks
The grip's `-m-1 p-1` pair cancelled on three sides only: `.dui-frame-head`
sets `margin-top: -0.5rem` unlayered in core.css, which beats a Tailwind
utility, so the padding above went uncancelled and the title dropped 4px the
moment the editor opened. The grip's geometry moves into core.css beside the
head's own half step, where one declaration cancels all four sides.

Slider tick labels were placed in percent with nothing measured, so five
four-character labels crowded on a tile narrower than the default four
columns. A ResizeObserver on the slider reports its width and every Nth label
is kept, N a divisor of the interval count so the first and last stay.
2026-08-28 14:20:02 +02:00

274 lines
7.7 KiB
TypeScript

/**
* Material 3: the controls.
*
* A press is answered by a state layer and a ripple from where it landed;
* nothing lifts or scales. Every one of these is a `ui/core` hook in a
* different coat — the state, the keyboard and the `aria-` come from there.
*/
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown } from "lucide-react"
import { AnimatePresence, 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 {
type Press,
usePress,
useSegmented,
useSliderDrag,
useSwitch,
} from "../core/controls"
import { useLook } from "../core/look"
import { LOOK } from "../core/motion"
/** The state layer plus whatever presses are still fading. */
function Skin({
presses,
done,
}: {
presses: Press[]
done: (id: number) => void
}) {
return (
<>
<span className="m3-state" aria-hidden />
<AnimatePresence>
{presses.map((press) => (
<motion.span
key={press.id}
aria-hidden
className="m3-ripple"
style={{ left: `${press.x}%`, top: `${press.y}%` }}
initial={{ scale: 0, opacity: 0.3 }}
animate={{ scale: 1, opacity: 0 }}
transition={{ duration: 0.45, ease: [0.4, 0, 0.2, 1] }}
onAnimationComplete={() => done(press.id)}
/>
))}
</AnimatePresence>
</>
)
}
export function Button({
variant = "tonal",
pressed,
disabled,
label,
onClick,
children,
}: ButtonProps) {
const { presses, onPointerDown, done } = usePress()
return (
<button
type="button"
className="m3-button m3-pressable w-full"
data-variant={variant}
aria-pressed={pressed}
aria-label={label}
disabled={disabled}
onPointerDown={onPointerDown}
onClick={onClick}
>
<Skin presses={presses} done={done} />
<span className="m3-lift min-w-0 truncate">{children}</span>
</button>
)
}
export function Switch(props: SwitchProps) {
const { buttonProps } = useSwitch(props)
const { presses, onPointerDown, done } = usePress()
return (
<button
{...buttonProps}
className="m3-switch m3-pressable"
onPointerDown={onPointerDown}
>
<Skin presses={presses} done={done} />
<motion.span
layout
transition={LOOK.material.spring}
className="m3-switch-thumb"
/>
</button>
)
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks, trackRef } = useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<div
className={cn(
"dui-slider m3-slider",
orientation === "horizontal" && "w-full",
)}
data-orientation={orientation}
ref={trackRef}
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"
return (
<div
{...groupProps}
data-testid={props.testId}
className="m3-segmented"
style={
vertical
? undefined
: {
gridTemplateColumns: `repeat(${props.options.length}, minmax(0, 1fr))`,
}
}
>
{props.options.map(([value, label], index) => (
<button
key={value}
{...itemProps(index)}
className="m3-segment m3-pressable"
>
{index === chosen ? (
<motion.span
aria-hidden
layoutId={thumbId}
transition={LOOK.material.spring}
className="m3-segment-thumb"
/>
) : null}
<span className="m3-state" aria-hidden />
<span className="m3-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="m3-field m3-pressable justify-between"
>
<span className="m3-lift min-w-0 truncate">
<SelectPrimitive.Value placeholder="Choose" />
</span>
<SelectPrimitive.Icon asChild>
<ChevronDown
className="m3-lift size-4 shrink-0 opacity-60"
aria-hidden
/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
position="popper"
sideOffset={4}
style={style}
className="m3-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]:zoom-in-95 data-[state=open]:fade-in-0"
>
<SelectPrimitive.Viewport>
{options.map((option) => (
<SelectPrimitive.Item
key={text(option.value)}
value={text(option.value)}
className="m3-pressable flex cursor-pointer select-none items-center justify-between gap-2 rounded-lg px-3 py-2 text-sm outline-none"
>
<span className="m3-state" aria-hidden />
<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="m3-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()
}}
/>
)
}