baseui
Version:
A React Component library implementing the Base design language
484 lines (467 loc) • 16.8 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = void 0;
var React = _interopRequireWildcard(require("react"));
var _button = require("../button");
var _buttonGroup = require("../button-group");
var _input = require("../input");
var _styles = require("../styles");
var _column = _interopRequireDefault(require("./column"));
var _constants = require("./constants");
var _filterShell = _interopRequireDefault(require("./filter-shell"));
var _locale = require("../locale");
var _d = require("d3");
var _slider = require("../slider");
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); }
function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && Object.prototype.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; }
function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } /*
Copyright (c) Uber Technologies, Inc.
This source code is licensed under the MIT license found in the
LICENSE file in the root directory of this source tree.
*/ // @ts-ignore
function roundToFixed(value, precision) {
const k = Math.pow(10, precision);
return Math.round(value * k) / k;
}
// @ts-ignore
function format(value, options) {
if (typeof options.format === 'function') {
return options.format(value);
}
let formatted = value.toString();
switch (options.format) {
case _constants.NUMERICAL_FORMATS.ACCOUNTING:
{
const abs = Math.abs(value);
if (value < 0) {
formatted = `($${roundToFixed(abs, options.precision)})`;
break;
}
formatted = `$${roundToFixed(abs, options.precision)}`;
break;
}
case _constants.NUMERICAL_FORMATS.PERCENTAGE:
{
formatted = `${roundToFixed(value, options.precision)}%`;
break;
}
case _constants.NUMERICAL_FORMATS.DEFAULT:
default:
formatted = roundToFixed(value, options.precision);
break;
}
return formatted;
}
// @ts-ignore
function validateInput(input) {
return Boolean(parseFloat(input)) || input === '' || input === '-';
}
// @ts-ignore
const bisect = (0, _d.bisector)(d => d.x0);
const Histogram = /*#__PURE__*/React.memo(function Histogram({
data,
lower,
upper,
isRange,
exclude,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
precision
}) {
const [css, theme] = (0, _styles.useStyletron)();
const {
bins,
xScale,
yScale
} = React.useMemo(() => {
const bins = (0, _d.bin)().thresholds(Math.min(data.length, _constants.MAX_BIN_COUNT))(data);
const xScale = (0, _d.scaleLinear)().domain([bins[0].x0, bins[bins.length - 1].x1]).range([0, _constants.HISTOGRAM_SIZE.width]).clamp(true);
const yScale = (0, _d.scaleLinear)()
// @ts-ignore
.domain([0, (0, _d.max)(bins, d => d.length)]).nice().range([_constants.HISTOGRAM_SIZE.height, 0]);
return {
bins,
xScale,
yScale
};
}, [data]);
// We need to find the index of bar which is nearest to the given single value
const singleIndexNearest = React.useMemo(() => {
if (isRange) {
return null;
}
return bisect.center(bins, lower);
}, [isRange, data, lower, upper]);
return /*#__PURE__*/React.createElement("div", {
className: css({
display: 'flex',
marginTop: theme.sizing.scale600,
marginLeft: theme.sizing.scale200,
marginRight: 0,
marginBottom: theme.sizing.scale400,
justifyContent: 'space-between',
overflow: 'visible'
})
}, /*#__PURE__*/React.createElement("svg", _constants.HISTOGRAM_SIZE, bins.map((d, index) => {
const x = xScale(d.x0) + 1;
const y = yScale(d.length);
const width = Math.max(0, xScale(d.x1) - xScale(d.x0) - 1);
const height = yScale(0) - yScale(d.length);
let included;
if (singleIndexNearest != null) {
included = index === singleIndexNearest;
} else {
const withinLower = d.x1 > lower;
const withinUpper = d.x0 <= upper;
included = withinLower && withinUpper;
}
if (exclude) {
included = !included;
}
return /*#__PURE__*/React.createElement("rect", {
key: `bar-${index}`,
fill: included ? theme.colors.backgroundInversePrimary : theme.colors.backgroundSecondary,
x: x,
y: y,
width: width,
height: height
});
})));
});
// @ts-ignore
function NumericalFilter(props) {
const [css, theme] = (0, _styles.useStyletron)();
const locale = React.useContext(_locale.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(() => (0, _d.extent)(props.data), [props.data]);
const [lv, setLower] = React.useState(() => roundToFixed(initialState.lowerValue || min, precision));
const [uv, setUpper] = React.useState(() => 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(() => roundToFixed(initialState.lowerValue || (0, _d.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;
l = validateInput(l) ? l : min;
let h = validateInput(uv) ? uv : max;
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(() => (0, _d.scaleLinear)().domain([min, max]).rangeRound([1, _constants.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 /*#__PURE__*/React.createElement(_filterShell.default, {
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();
}
}, /*#__PURE__*/React.createElement(_buttonGroup.ButtonGroup, {
size: _button.SIZE.mini,
mode: _buttonGroup.MODE.radio,
selected: comparatorIndex,
onClick: (_, index) => setComparatorIndex(index),
overrides: {
Root: {
style: ({
$theme
}) => ({
marginBottom: $theme.sizing.scale300
})
}
}
}, /*#__PURE__*/React.createElement(_button.Button, {
type: "button",
overrides: {
BaseButton: {
style: {
width: '100%'
}
}
},
"aria-label": locale.datatable.numericalFilterRange
}, locale.datatable.numericalFilterRange), /*#__PURE__*/React.createElement(_button.Button, {
type: "button",
overrides: {
BaseButton: {
style: {
width: '100%'
}
}
},
"aria-label": locale.datatable.numericalFilterSingleValue
}, locale.datatable.numericalFilterSingleValue)), /*#__PURE__*/React.createElement(Histogram, {
data: props.data,
lower: inputValueLower,
upper: inputValueUpper,
isRange: isRange,
exclude: exclude,
precision: props.options.precision
}), /*#__PURE__*/React.createElement("div", {
className: css({
display: 'flex',
justifyContent: 'space-between'
})
}, /*#__PURE__*/React.createElement(_slider.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: _constants.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 /*#__PURE__*/React.createElement(React.Fragment, null, $value[$thumbIndex]);
},
// 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'
})
}
}
})), /*#__PURE__*/React.createElement("div", {
className: css({
display: 'flex',
marginTop: theme.sizing.scale400,
// This % gap is visually appealing given the filter box width
gap: '30%',
justifyContent: 'space-between'
})
}, /*#__PURE__*/React.createElement(_input.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 && /*#__PURE__*/React.createElement(_input.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)
})));
}
// @ts-ignore
function NumericalCell(props) {
const [css, theme] = (0, _styles.useStyletron)();
return /*#__PURE__*/React.createElement("div", {
className: css({
...theme.typography.MonoParagraphXSmall,
display: 'flex',
justifyContent: theme.direction !== 'rtl' ? 'flex-end' : 'flex-start',
// @ts-ignore
color: props.highlight(props.value) ? theme.colors.contentNegative : null,
width: '100%'
})
}, format(props.value, {
format: props.format,
precision: props.precision
}));
}
const defaultOptions = {
title: '',
sortable: true,
filterable: true,
format: _constants.NUMERICAL_FORMATS.DEFAULT,
highlight: () => false,
precision: 0
};
function NumericalColumn(options) {
const normalizedOptions = {
...defaultOptions,
...options
};
if (normalizedOptions.format !== _constants.NUMERICAL_FORMATS.DEFAULT && (options.precision === null || options.precision === undefined)) {
normalizedOptions.precision = 2;
}
if (normalizedOptions.format === _constants.NUMERICAL_FORMATS.ACCOUNTING && (options.highlight === null || options.highlight === undefined)) {
normalizedOptions.highlight = n => n < 0;
}
return (0, _column.default)({
kind: _constants.COLUMNS.NUMERICAL,
buildFilter: function (params) {
return function (data) {
const value = roundToFixed(data, normalizedOptions.precision);
const included = value >= params.lowerValue && value <= params.upperValue;
return params.exclude ? !included : included;
};
},
cellBlockAlign: options.cellBlockAlign,
fillWidth: options.fillWidth,
filterable: normalizedOptions.filterable,
mapDataToValue: options.mapDataToValue,
maxWidth: options.maxWidth,
minWidth: options.minWidth,
renderCell: function RenderNumericalCell(props) {
return /*#__PURE__*/React.createElement(NumericalCell, _extends({}, props, {
format: normalizedOptions.format,
highlight: normalizedOptions.highlight,
precision: normalizedOptions.precision
}));
},
renderFilter: function RenderNumericalFilter(props) {
return /*#__PURE__*/React.createElement(NumericalFilter, _extends({}, props, {
options: normalizedOptions
}));
},
sortable: normalizedOptions.sortable,
sortFn: function (a, b) {
return a - b;
},
title: normalizedOptions.title
});
}
var _default = exports.default = NumericalColumn;