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.
274 lines
7.7 KiB
TypeScript
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()
|
|
}}
|
|
/>
|
|
)
|
|
}
|