@adaptabletools/adaptable
Version:
Powerful data-agnostic HTML5 AG Grid extension which provides advanced, cutting-edge functionality to meet all DataGrid requirements
686 lines (685 loc) • 33.2 kB
JavaScript
import { isSameDay } from 'date-fns';
import * as React from 'react';
import ReactSelect, { components, } from 'react-select';
import CreatableSelect from 'react-select/creatable';
import { Icon } from '../icons';
import { Box, Flex } from 'rebass';
import { DataSource, InfiniteTable, } from '@infinite-table/infinite-react';
import { useCallback, useMemo, useState } from 'react';
import join from '../utils/join';
import { Resizable } from 're-resizable';
import Tooltip from '../Tooltip';
import { ensurePortalElement } from '../OverlayTrigger';
import { CheckBox } from '../CheckBox';
import { useAdaptableComputedCSSVars } from '../../View/AdaptableComputedCSSVarsContext';
const resizableDirections = {
right: true,
bottom: true,
bottomRight: true,
};
const defaultResizableSize = {
width: '100%',
};
const checkboxStyle = {
position: 'relative',
top: 1,
};
const INFINITE_DOM_PROPS = {
style: {
// height: '100%',
flex: 1,
// maxHeight: '50vh',
width: '100%',
},
};
const infiniteContentValueClassName = 'InfiniteCell_content_value';
const renderValue = ({ renderBag, data, value }) => {
if (data.tooltip) {
const tooltipNode = typeof data.tooltip === 'string' ? data.tooltip : value;
return (React.createElement(Tooltip, { label: tooltipNode },
React.createElement("div", { className: infiniteContentValueClassName }, renderBag.value)));
}
return React.createElement("div", { className: infiniteContentValueClassName }, renderBag.value);
};
const INFINITE_COLUMNS_WITH_CHECKBOX = {
label: {
field: 'label',
defaultFlex: 1,
style: {
lineHeight: '30px',
},
resizable: false,
defaultSortable: false,
renderSelectionCheckBox: (params) => {
// disable reacting to onChange
// as we handle selection change in the onCellClick
return React.createElement(CheckBox, { mx: 1, checked: params.rowInfo?.rowSelected ?? false });
},
renderHeaderSelectionCheckBox: true,
className: 'ab-Select-CheckboxColumn',
renderValue,
renderHeader: (headerParams) => {
const selected = headerParams.allRowsSelected
? true
: headerParams.someRowsSelected
? null
: false;
const { api } = headerParams;
return (React.createElement(React.Fragment, null,
React.createElement(CheckBox, { mx: 1, checked: selected, onChange: (selected) => {
if (selected) {
api.rowSelectionApi.selectAll();
}
else {
api.rowSelectionApi.deselectAll();
}
} }, headerParams.allRowsSelected ? '(Deselect All)' : '(Select All)')));
},
renderMenuIcon: false,
},
};
const INFINITE_COLUMNS_WITH_RADIO = {
label: {
field: 'label',
defaultFlex: 1,
renderValue,
},
};
const isRowDisabled = ({ data, }) => {
return data ? !!data.isDisabled : false;
};
const rowClassName = ({ data }) => {
const flags = data.isDisabled ? 'ab-Select-Row--disabled' : '';
return `ab-Select-Row ${flags}`;
};
const fontCommonStyles = {
fontSize: 'var(--ab-cmp-select__font-size)',
fontFamily: 'var(--ab-cmp-select__font-family)',
};
const commonStyles = ({ isDisabled, }) => {
return {
color: 'var(--ab-cmp-input__color)',
background: isDisabled
? 'var(--ab-cmp-input--disabled__background)'
: 'var(--ab-cmp-input__background)',
...fontCommonStyles,
};
};
const doesOptionMatchValue = function (value) {
return (option) => {
if (typeof option.value === 'object' && option.value instanceof Date) {
return isSameDay(option.value, value);
}
return option.value === value;
};
};
export const Select = function (props) {
let maxLabelLength = 0;
const computedCSSVars = useAdaptableComputedCSSVars();
const CSS_VARS_VALUES = {
'--ab-cmp-select-menu__max-width': computedCSSVars['--ab-cmp-select-menu__max-width'] || '60vw',
'--ab-cmp-select-menu__min-width': computedCSSVars['--ab-cmp-select-menu__min-width'] || 150,
'--ab-cmp-select-menu__max-height': computedCSSVars['--ab-cmp-select-menu__max-height'] || '60vh',
};
const searchableInMenulist = props.searchable === 'menulist';
const searchableInline = props.searchable === 'inline';
// relevant for menulist search only
const menulistInputRef = React.useRef(null);
const [isSelectMenuOpen, setIsSelectMenuOpen] = useState(false);
const openSelectMenu = () => {
setIsSelectMenuOpen(true);
// it's a react-select bug, onMenuOpen is not called with controlled menuIsOpen
props.onMenuOpen?.();
};
const closeSelectMenu = () => {
setIsSelectMenuOpen(false);
};
const ref = React.useRef(null);
const valueToOptionMap = new Map((props.options || []).map((opt) => {
let label = opt.label;
if (typeof label === 'string' || typeof label === 'number' || typeof label === 'boolean') {
maxLabelLength = Math.max(maxLabelLength, `${label}`.length);
}
return [opt.value, opt];
}));
const findOptionByValue = (value) => {
const option = valueToOptionMap.get(value);
if (option) {
return option;
}
// try date comparison if the value is a date
// or if it's a number but the options are dates
if ((typeof value === 'object' && value instanceof Date) ||
(Array.isArray(props.options) &&
props.options.length &&
typeof props.options[0] === 'object' &&
props.options[0].value instanceof Date)) {
return (props.options || []).find(doesOptionMatchValue(value)) ?? null;
}
return null;
};
const renderMultipleValues = props.renderMultipleValues;
const isMulti = props.isMulti ?? Array.isArray(props.value);
const showHeaderSelectionCheckbox = isMulti && (props.showHeaderSelectionCheckbox ?? false);
const skipDefaultFiltering = props.skipDefaultFiltering ?? false;
let selectedOption = null;
if (isMulti) {
selectedOption =
(props.value ?? []).map((value) => {
const option = findOptionByValue(value);
if (!option) {
return {
value,
label: value,
};
}
return option;
}) ?? null;
}
else {
selectedOption = findOptionByValue(props.value);
if (!selectedOption &&
props.value !== undefined &&
props.value !== null &&
props.value !== '') {
selectedOption = {
value: props.value,
label: props.value,
};
}
}
let disabled = props.disabled ?? false;
const accessLevel = props.accessLevel ?? 'Full';
if (accessLevel === 'Hidden') {
return null;
}
if (accessLevel === 'ReadOnly') {
disabled = true;
}
/**
* If on each render a new reference is passed, the menu will not open using the keyboard.
*/
const SelectContainer = React.useMemo(() => {
return (selectContainerProps) => {
return (React.createElement(components.SelectContainer, { ...selectContainerProps, innerProps: {
// @ts-ignore
'data-name': props['data-name'],
'data-id': props['data-id'],
'data-test': props.searchable || false,
...selectContainerProps.innerProps,
onMouseDown: (e) => {
if (!searchableInMenulist) {
return;
}
if (!isSelectMenuOpen) {
openSelectMenu();
e.stopPropagation();
e.preventDefault();
}
},
} }));
};
}, [isSelectMenuOpen]);
const resizable = props.resizable ?? false;
const ValueContainer = React.useMemo(() => {
return (props) => {
let { children, ...inputProps } = props;
const initialChildren = children;
const currentValues = inputProps.getValue();
const customRenderValue = renderMultipleValues?.(currentValues, {
focused: focusedRef.current,
placeholder: props.placeholder,
});
const title = typeof customRenderValue === 'string' ? customRenderValue : '';
children = customRenderValue ? (React.createElement(Box, { display: 'flex', flexDirection: 'row', flex: 1, flexWrap: 'nowrap', alignItems: 'center', overflow: 'hidden', height: '100%' },
React.createElement("span", { title: title, "data-name": "multi-value-text", style: {
flex: '0 1 auto',
appearance: 'none',
border: 'none',
background: 'transparent',
outline: 'none',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
} }, customRenderValue),
initialChildren[1])) : (initialChildren);
return (React.createElement(components.ValueContainer, { ...inputProps, innerProps: {
'data-name': 'value-container',
...inputProps.innerProps,
} }, children));
};
}, [renderMultipleValues, props.placeholder]);
const sizeRef = React.useRef({ ...defaultResizableSize });
const MenuComponent = React.useMemo(() => {
return (menuProps) => {
const { isLoading } = menuProps;
const theChildren = (React.createElement(React.Fragment, null,
menuProps.children,
React.createElement("div", { style: {
display: isLoading ? 'block' : 'none',
position: 'absolute',
inset: 0,
background: `var(--ab-cmp-select-loading__background)`,
zIndex: 1,
} })));
const onResizeStop = useCallback((_e, _direction, ref) => {
const newSize = {
width: ref.style.width,
height: ref.style.height,
};
sizeRef.current = newSize;
}, []);
return (React.createElement(React.Fragment, null,
React.createElement(components.Menu, { ...menuProps, innerProps: {
// @ts-ignore
'data-name': 'menu-container',
'data-resizable': resizable,
...menuProps.innerProps,
onBlur: (e) => {
if (!searchableInMenulist) {
return;
}
const { relatedTarget } = e;
const menuDOMNode = menuProps.innerRef && 'current' in menuProps.innerRef
? menuProps.innerRef.current
: null;
if ((menuDOMNode && relatedTarget == menuDOMNode) ||
menuDOMNode?.contains(relatedTarget)) {
// ignore the event if the focus is still inside the menu
return;
}
requestAnimationFrame(() => {
// wee need to wait for the single value selection to complete before closing
closeSelectMenu();
});
},
onMouseDown: (event) => {
if (!props.isMulti) {
menuProps.innerProps?.onMouseDown?.(event);
}
if (!searchableInMenulist) {
return;
}
if (props.isMulti) {
// to avoid the menu closing when clicking inside it
event.stopPropagation();
}
},
} }, resizable ? (React.createElement(Resizable, { className: "ab-Select-MenuContainer-Resizable", enable: resizableDirections,
// ideally we wouldn't need those to be computed values
// and instead pass the CSS var name, like `var(--ab-cmp-select-menu__min-width)`
// to the Resizable component, but it won't respect them
// so we needed to read the CSS variables from the DOM, using CSSNumericVariableWatch
// and then pass the actual values to Resizable
minWidth: CSS_VARS_VALUES['--ab-cmp-select-menu__min-width'], maxHeight: CSS_VARS_VALUES['--ab-cmp-select-menu__max-height'], maxWidth: CSS_VARS_VALUES['--ab-cmp-select-menu__max-width'], defaultSize: sizeRef.current, onResizeStop: onResizeStop, onResizeStart: (e) => {
e.preventDefault();
} }, theChildren)) : (theChildren))));
};
}, [resizable, JSON.stringify(CSS_VARS_VALUES)]);
const SelectComponent = props.isCreatable ? CreatableSelect : ReactSelect;
const ClearIndicator = React.useMemo(() => {
return (clearIndicatorProps) => {
return (React.createElement(components.ClearIndicator, { ...clearIndicatorProps },
React.createElement(Icon, { name: "close" })));
};
}, []);
const MenuList = React.useMemo(() => {
return (props) => {
const { setValue, getValue, focusedOption, options, selectProps } = props;
const { filterOption, inputValue, isLoading } = selectProps;
// Focus the input when MenuList mounts
React.useEffect(() => {
if (menulistInputRef.current) {
menulistInputRef.current.focus();
}
}, []);
const filterFunction = React.useCallback(({ data }) => filterOption({ data, label: `${data.label}`, value: data.value }, inputValue), [filterOption, inputValue]);
const hasFilter = inputValue !== '' && !skipDefaultFiltering;
const filteredOptionsValues = new Set();
const filteredOptions = !hasFilter
? options
: options.filter((data) => {
const included = filterOption({ data, label: `${data.label}`, value: data.value }, inputValue);
if (included) {
filteredOptionsValues.add(data.value);
}
return included;
});
const focusedOptionIndex = options
.filter((option) => filterFunction({ data: option }))
.indexOf(focusedOption);
const valueStrKey = getValue()
// if we simply map, then if we have [] or [''], they will map to the same value
// but we need it to be different
// so we can trigger a change when the value changes
.map((option, index) => `${index}-${option.value}`)
.join(',');
const selectedRows = useMemo(() => {
const selectedRows = [];
// we build the Infinite Table selection
getValue().forEach((option) => {
// if there are no filters, include everything that is in the value
// but if there is a filter (which will be smaller than the current value)
// only include the current options that match the filter
if (!hasFilter || filteredOptionsValues.has(option.value)) {
selectedRows.push(option.value);
}
});
return selectedRows;
}, [valueStrKey, inputValue, hasFilter]);
const rowSelection = React.useMemo(() => {
return isMulti
? {
selectedRows,
deselectedRows: [],
defaultSelection: false,
}
: selectedRows[0];
}, [selectedRows, isMulti]);
const onRowSelectionChange = React.useCallback((selectionParams) => {
let selection = [];
const { selectedRows, defaultSelection, deselectedRows } = selectionParams;
if (hasFilter) {
if (defaultSelection === true && deselectedRows.length === 0) {
// all selected
const currentValue = getValue();
const includedKeys = new Set();
// add all the options that are in the current value
selection = currentValue.map((option) => {
includedKeys.add(option.value);
return option;
});
// also add all the options that match the filter
filteredOptions.forEach((option) => {
// make sure you don't add the same option twice
if (!includedKeys.has(option.value)) {
selection.push({ value: option.value });
}
});
}
else if (defaultSelection === false && selectedRows.length === 0) {
// we need to clear the selection
// but we have a filter, so we need to clear only those options that are included in the filter
// and leave the rest
const currentValue = getValue();
selection = currentValue.filter((row) => !filteredOptionsValues.has(row.value));
}
else {
// some selected
const currentValue = getValue();
const includedKeys = new Set();
// start with an empty selection
selection = [];
// add those in the value that are not in the filter
selection = currentValue.filter((row) => {
const res = !filteredOptionsValues.has(row.value);
if (res) {
includedKeys.add(row.value);
}
return res;
});
// and now also add those in the filter that are actually selected
selectedRows.forEach((row) => {
if (!includedKeys.has(row)) {
selection.push({ value: row });
}
});
}
}
else {
if (defaultSelection === true && deselectedRows.length === 0) {
// all selected
selection = options.map((option) => {
return { value: option.value };
});
}
else if (defaultSelection === false && selectedRows.length === 0) {
// none selected
selection = [];
}
else {
// some selected
selection = selectedRows.map((row) => {
return { value: row };
});
}
}
//@ts-ignore
setValue(selection, 'select-option');
}, [setValue, filterOption, inputValue, hasFilter, filteredOptions, rowSelection, valueStrKey]);
const onSingleRowSelectionChange = React.useCallback((value) => {
if (value == undefined && rowSelection != undefined) {
return;
}
//@ts-ignore
setValue({ value }, 'select-option');
}, [setValue]);
const onCellClick = React.useCallback(({ rowIndex, api, dataSourceApi }) => {
if (isMulti) {
const pk = dataSourceApi.getPrimaryKeyByIndex(rowIndex);
api.rowSelectionApi.toggleRowSelection(pk);
}
if (searchableInMenulist) {
requestAnimationFrame(() => {
menulistInputRef.current?.focus();
});
}
}, []);
const prevInputValueRef = React.useRef('');
return (React.createElement(DataSource, {
// @ts-ignore
// data={props.options}
// @ts-ignore
data: filteredOptions, primaryKey: "value", selectionMode: isMulti ? 'multi-row' : 'single-row',
// @ts-ignore
onRowSelectionChange: isLoading ? null : isMulti ? onRowSelectionChange : onSingleRowSelectionChange, rowSelection: rowSelection, isRowDisabled: isRowDisabled },
searchableInMenulist && (React.createElement(Flex, { p: 1, className: "ab-Select-MenulistSearchContainer" },
React.createElement("input", { ref: menulistInputRef, "data-name": "menulist-search-input", style: {
width: '100%',
}, className: 'ab-Select-MenulistSearch ab-Input ab-Input--type-text', autoCorrect: "off", autoComplete: "off", spellCheck: "false", type: "text", value: inputValue, onChange: (e) => {
const currentValue = e.currentTarget.value;
onInputChange(currentValue, {
action: 'input-change',
prevInputValue: prevInputValueRef.current,
});
prevInputValueRef.current = currentValue;
}, onMouseDown: (e) => {
e.stopPropagation();
const inputElement = e.target;
inputElement?.focus?.();
}, onTouchEnd: (e) => {
e.stopPropagation();
const inputElement = e.target;
inputElement?.focus?.();
}, placeholder: "Search..." }))),
React.createElement(InfiniteTable, { header: isMulti && showHeaderSelectionCheckbox ? true : false, rowClassName: rowClassName, showZebraRows: false, rowHeight: '--ab-grid-row-height', onCellClick: isLoading ? null : onCellClick, keyboardNavigation: isLoading ? false : 'row', activeRowIndex: focusedOptionIndex, keyboardSelection: true, columns: isMulti ? INFINITE_COLUMNS_WITH_CHECKBOX : INFINITE_COLUMNS_WITH_RADIO, domProps: INFINITE_DOM_PROPS })));
};
}, [isMulti, skipDefaultFiltering, showHeaderSelectionCheckbox]);
const DropdownIndicator = React.useMemo(() => {
return (dropdownIndicatorProps) => {
return (React.createElement(components.DropdownIndicator, { ...dropdownIndicatorProps, innerProps: {
...dropdownIndicatorProps.innerProps,
} },
React.createElement(Icon, { name: "triangle-down", style: { height: 20, width: 20 } })));
};
}, []);
const focusedRef = React.useRef(false);
const onFocus = React.useCallback(() => {
focusedRef.current = true;
props.onFocus?.();
}, [props.onFocus]);
const onBlur = React.useCallback(() => {
focusedRef.current = false;
props.onBlur?.();
}, [props.onBlur]);
const [inputValue, setInputValue] = React.useState('');
const onInputChange = React.useCallback((value, actionMeta) => {
if (isMulti && actionMeta.action === 'set-value') {
// when selecting an option, don't clear the input filter
return;
}
setInputValue(value);
props.onInputChange?.(value);
}, [props.onInputChange, isMulti]);
return (React.createElement(React.Fragment, null,
React.createElement(SelectComponent, { ref: ref, openMenuOnClick: searchableInMenulist ? false : undefined, openMenuOnFocus: searchableInMenulist ? false : undefined, menuIsOpen: searchableInMenulist ? isSelectMenuOpen : undefined, isSearchable: searchableInline, "aria-label": props['aria-label'], onKeyDown: props.onKeyDown, inputValue: inputValue, onInputChange: onInputChange, onFocus: onFocus, onBlur: onBlur, onMenuOpen: props.onMenuOpen, isLoading: props.isLoading, options: props.options, className: join(props.className, 'ab-Select'), isDisabled: disabled, menuPlacement: props.menuPlacement ?? 'auto', hideSelectedOptions: false, isMulti: isMulti, value: selectedOption, blurInputOnSelect: false, menuPosition: props.menuPosition ?? 'absolute',
// This needed so the menu is not clipped by overflow: hidden
menuPortalTarget: ensurePortalElement(), isClearable: props.isClearable, closeMenuOnSelect: props.closeMenuOnSelect, onChange: (option) => {
if (isMulti) {
const value = option.map((x) => x?.value);
props.onChange(value);
}
else {
props.onChange(option?.value);
}
if (searchableInMenulist) {
// ensure element keeps focus
requestAnimationFrame(() => {
menulistInputRef.current?.focus();
});
}
}, placeholder: props.placeholder, createOptionPosition: 'first',
// formatCreateLabel={(inputValue) => inputValue} // can we make this auto??
// we use this: https://react-select.com/creatable
components: {
SelectContainer,
ValueContainer,
Menu: MenuComponent,
SingleValue: React.useCallback((singleValueProps) => {
return (React.createElement(components.SingleValue, { ...singleValueProps }, props.renderSingleValue
? props.renderSingleValue(selectedOption)
: singleValueProps.children));
}, [selectedOption]),
ClearIndicator,
DropdownIndicator,
MenuList,
},
/**
* Using styles is the preferred way to style react-select.
* https://react-select.com/styles#the-styles-prop
*/
styles: {
// Typescript issue with csstype@3.1.3
// https://github.com/JedWatson/react-select/issues/5825#issuecomment-1850472549
// Remove
container: (baseStyle) => {
return {
...baseStyle,
...props.style,
...props.styles?.container,
};
},
// @ts-ignorets-ignore when fixed
menuPortal: (baseStyle) => {
return {
...baseStyle,
// needed to avoid tooltips from being displayed under this menu
// as otherwise react-select uses zIndex 1
zIndex: 'auto',
textAlign: 'left',
};
},
// @ts-ignore
menu: (baseStyle, state) => {
return {
...baseStyle,
// needed to avoid tooltips from being displayed under this menu
// as otherwise react-select uses zIndex 1
zIndex: 'auto',
width: resizable ? '100%' : `${Math.max(maxLabelLength, 10)}ch`,
'--ab-cmp-select-menu__min-height': `min(${(props.options || []).length + (showHeaderSelectionCheckbox ? 1 : 0)} * var(--ab-grid-row-height) + ${searchableInMenulist ? 40 : 0}px, ${searchableInMenulist ? 22 : 20}rem)`,
maxHeight: 'var(--ab-cmp-select-menu__max-height)',
minWidth: 'var(--ab-cmp-select-menu__min-width)',
maxWidth: 'var(--ab-cmp-select-menu__max-width)',
...commonStyles(state),
...props.menuStyle,
};
},
// @ts-ignore
option: (baseStyle, state) => {
const style = {
...baseStyle,
...commonStyles(state),
'&:active': {
background: 'var(--ab-cmp-select-option-active__background)',
},
};
if (state.isSelected) {
style.background = 'var(--ab-cmp-select-option-active__background)';
style.color = 'var(--ab-cmp-select-option-active__color)';
}
if (state.isFocused) {
style.background = 'var(--ab-cmp-select-option-focused__background)';
}
return style;
},
// @ts-ignore
input: (baseStyle, state) => {
return {
...baseStyle,
padding: props.size === 'small' ? 0 : baseStyle.padding,
color: 'var(--ab-cmp-select__color)',
};
},
valueContainer: (baseStyle) => {
return {
...baseStyle,
padding: props.size === 'small' ? `0 var(--ab-space-1)` : baseStyle.padding,
...props.styles?.valueContainer,
};
},
// @ts-ignore
singleValue: (baseStyle, state) => {
return {
...baseStyle,
...commonStyles(state),
};
},
// @ts-ignore
control: (baseStyle, state) => {
return {
...baseStyle,
...commonStyles(state), // height: 30,
minHeight: props.size === 'small' ? 0 : '100%',
boxShadow: state.isFocused ? 'var(--ab-cmp-select-focused__box-shadow)' : 'none',
outline: state.isFocused ? 'var(--ab-cmp-select-focused__outline)' : 'none',
border: 'var(--ab-cmp-select__border)',
borderRadius: 'var(--ab-cmp-select__border-radius)',
'&:hover': {
border: 'var(--ab-cmp-select__border)',
},
...props.styles?.control,
};
},
// @ts-ignore
dropdownIndicator: (baseStyle) => {
return {
...baseStyle,
padding: 0,
...props.styles?.dropdownIndicator,
};
},
// @ts-ignore
clearIndicator: (baseStyle) => {
return {
...baseStyle,
padding: '2px 3px',
};
},
multiValue: (baseStyle) => {
return {
...baseStyle,
color: 'var(--ab-cmp-select__color)',
background: 'var(--ab-cmp-select-multi-value__background)',
border: 'var(--ab-cmp-select__border)',
};
},
multiValueLabel: (baseStyle) => {
return {
...baseStyle,
...fontCommonStyles,
color: 'var(--ab-cmp-select__color)',
};
},
} })));
};