283 lines
8.0 KiB
TypeScript
283 lines
8.0 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, markAt, 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" />
|
|
{/* Under the handle, so where the two meet the value being set wins;
|
|
after the input only for its `:disabled`, as it takes no pointer. */}
|
|
{markAt === null ? null : (
|
|
<span
|
|
className="dui-slider-mark"
|
|
aria-hidden
|
|
style={{ "--dui-mark": markAt } as React.CSSProperties}
|
|
/>
|
|
)}
|
|
<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()
|
|
}}
|
|
/>
|
|
)
|
|
}
|