in src/data-table/column-numerical.tsx [190:468]
function NumericalFilter(props) {
const [css, theme] = useStyletron();
const locale = React.useContext(LocaleContext);
const precision = props.options.precision;
// The state handling of this component could be refactored and cleaned up if we used useReducer.
const initialState = React.useMemo(() => {
return (
props.filterParams || {
exclude: false,
excludeKind: 'range',
comparatorIndex: 0,
lowerValue: null,
upperValue: null,
}
);
}, [props.filterParams]);
const [exclude, setExclude] = React.useState(initialState.exclude);
// the api of our ButtonGroup forces these numerical indexes...
// TODO look into allowing semantic names, similar to the radio component. Tricky part would be backwards compat
const [comparatorIndex, setComparatorIndex] = React.useState(() => {
switch (initialState.excludeKind) {
case 'value':
return 1;
case 'range':
default:
// fallthrough
return 0;
}
});
// We use the d3 function to get the extent as it's a little more robust to null, -Infinity, etc.
const [min, max] = React.useMemo(() => extent(props.data), [props.data]);
const [lv, setLower] = React.useState<number>(() =>
roundToFixed(initialState.lowerValue || min, precision)
);
const [uv, setUpper] = React.useState<number>(() =>
roundToFixed(initialState.upperValue || max, precision)
);
// We keep a separate value for the single select, to give a user the ability to toggle between
// the range and single values without losing their previous input.
const [sv, setSingle] = React.useState<number>(() =>
roundToFixed(initialState.lowerValue || median(props.data), precision)
);
// This is the only conditional which we want to use to determine
// if we are in range or single value mode.
// Don't derive it via something else, e.g. lowerValue === upperValue, etc.
const isRange = comparatorIndex === 0;
const excludeKind = isRange ? 'range' : 'value';
// while the user is inputting values, we take their input at face value,
// if we don't do this, a user can't input partial numbers, e.g. "-", or "3."
const [focused, setFocus] = React.useState(false);
const [inputValueLower, inputValueUpper] = React.useMemo(() => {
if (focused) {
return [isRange ? lv : sv, uv];
}
// once the user is done inputting.
// we validate then format to the given precision
let l = isRange ? lv : sv;
// @ts-expect-error todo(ts-migration) TS2322 Type 'string | number | undefined' is not assignable to type 'number'.
l = validateInput(l) ? l : min;
let h = validateInput(uv) ? uv : max;
// @ts-expect-error todo(ts-migration) TS2345 Argument of type 'string | number | undefined' is not assignable to parameter of type 'number'.
return [roundToFixed(l, precision), roundToFixed(h, precision)];
}, [isRange, focused, sv, lv, uv, precision]);
// We have our slider values range from 1 to the bin size, so we have a scale which
// takes in the data driven range and maps it to values the scale can always handle
const sliderScale = React.useMemo(
() =>
scaleLinear()
// @ts-expect-error todo(ts-migration) TS2345 Argument of type '(string | undefined)[]' is not assignable to parameter of type 'Iterable<NumberValue>'.
.domain([min, max])
.rangeRound([1, MAX_BIN_COUNT])
// We clamp the values within our min and max even if a user enters a huge number
.clamp(true),
[min, max]
);
let sliderValue = isRange
? [sliderScale(inputValueLower), sliderScale(inputValueUpper)]
: [sliderScale(inputValueLower)];
// keep the slider happy by sorting the two values
if (isRange && sliderValue[0] > sliderValue[1]) {
sliderValue = [sliderValue[1], sliderValue[0]];
}
return (
<FilterShell
exclude={exclude}
onExcludeChange={() => setExclude(!exclude)}
excludeKind={excludeKind}
onApply={() => {
if (isRange) {
// @ts-expect-error todo(flow->ts)
const lowerValue = parseFloat(inputValueLower);
// @ts-expect-error todo(flow->ts)
const upperValue = parseFloat(inputValueUpper);
props.setFilter({
description: `≥ ${lowerValue} and ≤ ${upperValue}`,
exclude: exclude,
lowerValue,
upperValue,
excludeKind,
});
} else {
// @ts-expect-error todo(flow->ts)
const value = parseFloat(inputValueLower);
props.setFilter({
description: `= ${value}`,
exclude: exclude,
lowerValue: inputValueLower,
upperValue: inputValueLower,
excludeKind,
});
}
props.close();
}}
>
<ButtonGroup
size={SIZE.mini}
mode={MODE.radio}
selected={comparatorIndex}
onClick={(_, index) => setComparatorIndex(index)}
overrides={{
Root: {
style: ({ $theme }) => ({ marginBottom: $theme.sizing.scale300 }),
},
}}
>
<Button
type="button"
overrides={{ BaseButton: { style: { width: '100%' } } }}
aria-label={locale.datatable.numericalFilterRange}
>
{locale.datatable.numericalFilterRange}
</Button>
<Button
type="button"
overrides={{ BaseButton: { style: { width: '100%' } } }}
aria-label={locale.datatable.numericalFilterSingleValue}
>
{locale.datatable.numericalFilterSingleValue}
</Button>
</ButtonGroup>
<Histogram
data={props.data}
lower={inputValueLower}
upper={inputValueUpper}
isRange={isRange}
exclude={exclude}
precision={props.options.precision}
/>
<div className={css({ display: 'flex', justifyContent: 'space-between' })}>
<Slider
// The slider throws errors when switching between single and two values
// when it tries to read getThumbDistance on a thumb which is not there anymore
// if we create a new instance these errors are prevented.
key={isRange.toString()}
min={1}
max={MAX_BIN_COUNT}
value={sliderValue}
onChange={({ value }) => {
if (!value) {
return;
}
// we convert back from the slider scale to the actual data's scale
if (isRange) {
const [lowerValue, upperValue] = value;
setLower(sliderScale.invert(lowerValue));
setUpper(sliderScale.invert(upperValue));
} else {
const [singleValue] = value;
setSingle(sliderScale.invert(singleValue));
}
}}
overrides={{
InnerThumb: function InnerThumb({ $value, $thumbIndex }) {
return <React.Fragment>{$value[$thumbIndex]}</React.Fragment>;
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
TickBar: ({ $min, $max }) => null, // we don't want the ticks
ThumbValue: () => null,
Root: {
style: () => ({
// Aligns the center of the slider handles with the histogram bars
width: 'calc(100% + 14px)',
margin: '0 -7px',
}),
},
InnerTrack: {
style: () => {
if (!isRange) {
return {
// For range selection we use the color as is, but when selecting the single value,
// we don't want the track standing out, so mute its color
background: theme.colors.backgroundSecondary,
};
}
},
},
Thumb: {
style: () => ({
// Slider handles are small enough to visually be centered within each histogram bar
height: '18px',
width: '18px',
fontSize: '0px',
}),
},
}}
/>
</div>
<div
className={css({
display: 'flex',
marginTop: theme.sizing.scale400,
// This % gap is visually appealing given the filter box width
gap: '30%',
justifyContent: 'space-between',
})}
>
{/* @ts-expect-error todo(ts-migration) TS2769 No overload matches this call. */}
<Input
min={min}
max={max}
size={INPUT_SIZE.mini}
overrides={{ Root: { style: { width: '100%' } } }}
value={inputValueLower}
onChange={(event) => {
if (validateInput(event.target.value)) {
isRange
? // @ts-expect-error - we know it is a number by now
setLower(event.target.value)
: // @ts-expect-error - we know it is a number by now
setSingle(event.target.value);
}
}}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
/>
{isRange && (
// @ts-expect-error todo(ts-migration) TS2769 No overload matches this call.
<Input
min={min}
max={max}
size={INPUT_SIZE.mini}
overrides={{
Input: { style: { textAlign: 'right' } },
Root: { style: { width: '100%' } },
}}
value={inputValueUpper}
onChange={(event) => {
if (validateInput(event.target.value)) {
// @ts-expect-error - we know it is a number by now
setUpper(event.target.value);
}
}}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
/>
)}
</div>
</FilterShell>
);
}