UNPKG

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