@adaptabletools/adaptable
Version:
Powerful data-agnostic HTML5 AG Grid extension which provides advanced, cutting-edge functionality to meet all DataGrid requirements
512 lines (511 loc) • 23 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 } from 'rebass';
import { DataSource, InfiniteTable, } from '@infinite-table/infinite-react';
import { useMemo } from 'react';
import join from '../utils/join';
const checkboxStyle = {
position: 'relative',
top: 1,
};
const INFINITE_DOM_PROPS = {
style: {
height: '100%',
minHeight: `var(--ab-cmp-select-menu__min-height, 25rem)`,
maxHeight: '50vh',
width: '100%',
},
};
const INFINITE_COLUMNS_WITH_CHECKBOX = {
label: {
field: 'label',
defaultFlex: 1,
style: {
lineHeight: '30px',
},
resizable: false,
defaultSortable: false,
renderSelectionCheckBox: ({ renderBag }) => {
return renderBag.selectionCheckBox;
},
renderHeader: (headerParams) => {
return (React.createElement(React.Fragment, null,
headerParams.renderBag.selectionCheckBox,
headerParams.allRowsSelected ? '(Deselect All)' : '(Select All)'));
},
renderMenuIcon: false,
},
};
const INFINITE_COLUMNS_WITH_RADIO = {
label: {
field: 'label',
defaultFlex: 1,
},
};
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) {
const ref = React.useRef(null);
const valueToOptionMap = new Map((props.options || []).map((opt) => [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);
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) {
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 (inputProps) => {
return (React.createElement(components.SelectContainer, { ...inputProps, innerProps: {
// @ts-ignore
'data-name': props['data-name'],
'data-id': props['data-id'],
...inputProps.innerProps,
} }));
};
}, []);
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 MenuComponent = React.useMemo(() => {
return (inputProps) => {
const { isLoading } = inputProps;
return (React.createElement(React.Fragment, null,
React.createElement(components.Menu, { ...inputProps, innerProps: {
'data-name': 'menu-container',
...inputProps.innerProps,
} },
inputProps.children,
React.createElement("div", { style: {
display: isLoading ? 'block' : 'none',
position: 'absolute',
inset: 0,
background: `var(--ab-cmp-select-loading__background)`,
zIndex: 1,
} }))));
};
}, []);
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, options: selectOptions, isLoading } = selectProps;
const filterFunction = React.useCallback(({ data }) => filterOption({ data, label: `${data.label}`, value: data.value }, inputValue), [filterOption, inputValue]);
const hasFilter = inputValue !== '';
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 valueStr = getValue()
.map((option) => 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;
}, [valueStr, 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, valueStr]);
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 }) => {
const pk = dataSourceApi.getPrimaryKeyByIndex(rowIndex);
api.rowSelectionApi.toggleRowSelection(pk);
// see #ensure-select-closes-after-clicking-outside
requestAnimationFrame(() => {
ref.current?.focus();
});
}, []);
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 },
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, showHeaderSelectionCheckbox]);
const DropdownIndicator = React.useMemo(() => {
return (props) => {
return (React.createElement(components.DropdownIndicator, { ...props },
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(SelectComponent, { ref: ref, 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', isSearchable: props.searchable, hideSelectedOptions: false, isMulti: isMulti, value: selectedOption, blurInputOnSelect: false, menuPosition: props.menuPosition ?? 'absolute',
// This needed so the menu is not clipped by overflow: hidden
menuPortalTarget: props.menuPortalTarget === undefined ? document.body : null, isClearable: props.isClearable, closeMenuOnSelect: props.closeMenuOnSelect, onChange: (option) => {
if (isMulti) {
const value = option.map((x) => x?.value);
props.onChange(value);
// ensure element keeps focus
requestAnimationFrame(() => {
ref.current?.focus();
});
}
else {
props.onChange(option?.value);
}
}, 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,
zIndex: 999999,
textAlign: 'left',
};
},
// @ts-ignore
menu: (baseStyle, state) => {
return {
...baseStyle,
zIndex: 999999,
boxShadow: 'var(--ab-cmp-select-menu__box-shadow)',
minWidth: `var(--ab-cmp-select-menu__min-width)`,
'--ab-cmp-select-menu__min-height': `min(${(props.options || []).length + (showHeaderSelectionCheckbox ? 1 : 0)} * var(--ab-grid-row-height), 20rem)`,
...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)',
};
},
} }));
};