textmachine/frontend/src/ui/Slider.tsx

72 lines
2.5 KiB
TypeScript

import { useId } from 'react';
import {
Label,
Slider as AriaSlider,
SliderOutput,
SliderThumb,
SliderTrack,
} from 'react-aria-components';
import styles from './Slider.module.css';
interface Props {
label: string;
/** The value in the caller's own unit; this primitive knows nothing about what it counts. */
value: number;
min: number;
max: number;
onChange: (value: number) => void;
/** The value spelled out — the caller formats it, because only it knows the unit. */
valueLabel: string;
/** The line under the scale: what the chosen value will do. */
hint?: string;
}
/**
* A scale for choosing a bounded quantity.
*
* The bounds come from the caller and are NOT clamped here: they arrive already trimmed by the
* platform, and a second clamp on the client would be a second copy of a policy that lives on the
* server (contract, `CeilingBounds`).
*/
export function Slider({ label, value, min, max, onChange, valueLabel, hint }: Props) {
// The line under the scale is a description OF THE SCALE, and saying so is the only way it
// reaches a screen reader: the thumb is the control, and it came out with an empty
// `aria-describedby` (measured in the browser).
const hintId = useId();
return (
<AriaSlider
className={styles.slider}
value={value}
minValue={min}
maxValue={max}
onChange={onChange}
>
<div className={styles.head}>
<Label className={styles.label}>{label}</Label>
{/* ⚠ The output is the only place the UNIT is spoken, and that is a measurement rather than
a design: the thumb announces a bare `aria-valuetext="60"`, composed by the library from
the value itself, and a value text handed to `SliderThumb` does not reach the input at
all (probed in the browser). The output, though, is a live region (`status`) bound to the
scale, so every change is read out with the unit the caller wrote into it. */}
<SliderOutput className={styles.value}>{valueLabel}</SliderOutput>
</div>
<SliderTrack className={styles.track}>
{({ state }) => (
<>
<span className={styles.fill} style={{ '--progress': state.getThumbPercent(0) }} />
<SliderThumb
className={styles.thumb}
aria-describedby={hint === undefined ? undefined : hintId}
/>
</>
)}
</SliderTrack>
{hint !== undefined && (
<p className={styles.hint} id={hintId}>
{hint}
</p>
)}
</AriaSlider>
);
}