Files
app/frontend/src/components/Dashboard/ui/fluksio/Controls.tsx
T

228 lines
6.7 KiB
TypeScript

/**
* Fluksio: the controls.
*
* Every control is a pill, and a press is answered by the colour changing and
* nothing else — no ripple, no glow, no lift. That restraint is the house
* style rather than an omission: the one accent is spent on saying what is
* selected, not on saying that something was touched.
*
* 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 { type MotionStyle, 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 { useSegmented, useSliderDrag, useSwitch } from "../core/controls"
import { useLook } from "../core/look"
import { SliderMarks } from "../core/marks"
import { LOOK } from "../core/motion"
export function Button({
variant = "tonal",
pressed,
disabled,
label,
onClick,
children,
}: ButtonProps) {
return (
<button
type="button"
className="fx-button w-full"
data-variant={variant}
aria-pressed={pressed}
aria-label={label}
disabled={disabled}
onClick={onClick}
>
<span className="min-w-0 truncate">{children}</span>
</button>
)
}
export function Switch(props: SwitchProps) {
const { buttonProps } = useSwitch(props)
return (
<button {...buttonProps} className="fx-switch">
<motion.span
layout
transition={LOOK.fluksio.spring}
className="fx-switch-thumb"
/>
</button>
)
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks, markAt, stops, trackRef } =
useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<motion.div
className={cn(
"dui-slider fx-slider",
orientation === "horizontal" && "w-full",
)}
data-orientation={orientation}
ref={trackRef}
style={{ "--dui-fraction": fraction } as MotionStyle}
>
<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" />
<SliderMarks stops={stops} mark={markAt} />
<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}
</motion.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="fx-segmented"
style={
vertical
? undefined
: {
gridTemplateColumns: `repeat(${props.options.length}, minmax(0, 1fr))`,
}
}
>
{props.options.map(([value, label], index) => (
<button key={value} {...itemProps(index)} className="fx-segment">
{index === chosen ? (
<motion.span
aria-hidden
layoutId={thumbId}
transition={LOOK.fluksio.spring}
className="fx-segment-thumb"
/>
) : null}
<span className="fx-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="fx-field justify-between"
>
<span className="min-w-0 truncate">
<SelectPrimitive.Value placeholder="Choose" />
</span>
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-4 shrink-0 opacity-60" aria-hidden />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
position="popper"
sideOffset={4}
style={style}
className="fx-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="fx-menu-item flex cursor-pointer select-none items-center justify-between gap-2 px-3 py-2 text-sm outline-none"
>
<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="fx-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()
}}
/>
)
}