72 lines
2.5 KiB
TypeScript
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>
|
|
);
|
|
}
|