import { useCallback, useState, type FocusEvent, type KeyboardEvent, } from 'react' import { Box, Slider, TextField, Typography } from '@mui/material' import type { RangeDataItem, RangeItemValue } from './types' import { styles } from './style' export interface RangeUIProps { items: readonly RangeDataItem[] /** * Fires on every pointer tick while a thumb is being dragged (mirrors * MUI ``'s `onChange`). Use this to update local UI state — * not to persist expensive operations like remote queries. */ onChange?: (index: number, value: RangeItemValue) => void /** * Fires once when the user *releases* a slider thumb after dragging, * after an arrow-key adjustment commits, or when the text inputs * blur / Enter. Mirrors MUI ``'s `onChangeCommitted`. Use * this for side-effects you want to throttle to "drag end" — e.g. * writing the value to a source filter that triggers refetches. */ onChangeCommitted?: (index: number, value: RangeItemValue) => void /** Number formatter for the slider tooltip and the text input display. */ formatter?: (value: number) => string } type Bound = 'min' | 'max' /** * Pure presentational component for the Range widget. Renders one MUI Slider * per item with editable min/max text inputs below — matching the Range v1 * UX. Each item is always a two-thumb range; supply `value` to seed the * starting selection, otherwise it defaults to `[min, max]`. */ export function RangeUI({ items, onChange, onChangeCommitted, formatter, }: RangeUIProps) { const fmt = formatter ?? ((n: number) => String(n)) return ( {items.map((item, i) => ( // Composite of the row's track bounds — stable across reorders for // any realistic widget configuration. Falls back to a literal + // index when bounds collide (degenerate same-min-same-max rows). ))} ) } interface RangeRowProps { index: number item: RangeDataItem fmt: (n: number) => string onChange?: (index: number, value: RangeItemValue) => void onChangeCommitted?: (index: number, value: RangeItemValue) => void } function RangeRow({ index, item, fmt, onChange, onChangeCommitted, }: RangeRowProps) { const current: readonly [number, number] = item.value ?? [item.min, item.max] const [editing, setEditing] = useState<'' | Bound>('') // Clamp inside [min, max] and keep `low <= high`. Pulled out so both the // live `onChange` path and the commit path share the same normalization. const normalize = useCallback( (next: readonly [number, number]): readonly [number, number] => { const [lowRaw, highRaw] = next const low = Math.min(Math.max(lowRaw, item.min), item.max) const high = Math.min(Math.max(highRaw, item.min), item.max) return low <= high ? [low, high] : [high, low] }, [item.min, item.max], ) const commit = useCallback( (next: readonly [number, number]) => { onChange?.(index, normalize(next)) }, [index, normalize, onChange], ) const commitFinal = useCallback( (next: readonly [number, number]) => { onChangeCommitted?.(index, normalize(next)) }, [index, normalize, onChangeCommitted], ) // A text-input commit (blur / Enter) is both a value change AND a final // commit, so it fires `onChange` *and* `onChangeCommitted`. Firing both // keeps the widget responsive for consumers that only wired `onChange` // (the pre-`onChangeCommitted` API) — without it, typing a value and // pressing Enter would silently no-op for them. const commitText = useCallback( (next: readonly [number, number]) => { const value = normalize(next) onChange?.(index, value) onChangeCommitted?.(index, value) }, [index, normalize, onChange, onChangeCommitted], ) const handleSlider = (_: Event, raw: number | number[]) => { if (!Array.isArray(raw)) return // Hoist defaults out of the destructure: react-compiler can't safely // reorder MemberExpression defaults inside an array pattern. const low = raw[0] ?? item.min const high = raw[1] ?? item.max commit([low, high]) } const handleSliderCommitted = ( _: Event | React.SyntheticEvent, raw: number | number[], ) => { if (!Array.isArray(raw)) return const low = raw[0] ?? item.min const high = raw[1] ?? item.max commitFinal([low, high]) } return ( {/* Text-input commits (blur / Enter) are final, but also notify `onChange` — see `commitText` — so consumers that only wired `onChange` still update on a typed value. */} {item.note ? ( {item.note} ) : null} ) } interface BoundInputProps { name: Bound value: number item: RangeDataItem fmt: (n: number) => string editing: '' | Bound setEditing: (next: '' | Bound) => void /** * Called when the user commits a new value (blur / Enter). Text input * edits never produce intermediate "live" values, so the consumer * only needs one callback — the row wires this to `commitFinal`. */ commit: (next: readonly [number, number]) => void current: readonly [number, number] ariaLabel: string } function BoundInput({ name, value, item, fmt, editing, setEditing, commit, current, ariaLabel, }: BoundInputProps) { const [raw, setRaw] = useState(String(value)) const beginEditing = () => { setEditing(name) } const finishEditingAndCommit = (e: FocusEvent) => { setEditing('') commitFromText(e.target.value) } const commitOnEnter = (e: KeyboardEvent) => { if (e.key === 'Enter') { commitFromText((e.target as HTMLInputElement).value) ;(e.target as HTMLInputElement).blur() } } function commitFromText(input: string) { const parsed = parseFloat(input) const safe = Number.isFinite(parsed) ? parsed : value const next: readonly [number, number] = name === 'min' ? [safe, current[1]] : [current[0], safe] commit(next) } const display = editing === name ? raw : fmt(Number(raw)) return ( setRaw(e.target.value)} onFocus={beginEditing} onBlur={finishEditingAndCommit} onKeyDown={commitOnEnter} disabled={item.disabled} size='small' sx={styles.input} inputProps={{ 'aria-label': ariaLabel }} /> ) } function resolveMarks( marks: RangeDataItem['marks'], ): boolean | { value: number; label?: string }[] | undefined { if (marks == null) return undefined if (typeof marks === 'boolean') return marks return [...marks] }