UNPKG

baseui

Version:

A React Component library implementing the Base design language

484 lines (467 loc) 16.8 kB
"use strict"; 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;