UNPKG

@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
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)', }; }, } }))); };