reactgrid icon indicating copy to clipboard operation
reactgrid copied to clipboard

How to actively manage cell blurring?

Open rgnevin opened this issue 10 months ago • 12 comments

Situation: I have a custom dropdown component that only renders a dropdown after the cell is in edit mode. This is done because loading the stock dropdown on all cells is too slow. This dropdown has many options, requiring the use of the scroll bar. Clicking this scroll bar however causes the cell to exit edit mode and close.

How can handling of clicks relating to the grid be actively managed?

Thank You!!!

rgnevin avatar Apr 18 '24 20:04 rgnevin

Please prevent the event from bubbling

qiufeihong2018 avatar Apr 23 '24 02:04 qiufeihong2018

Hi I am also struggeling with the dropdown implementation. Would you kindly share your custom component when your happy with it? I need to have multiple dropdowns in a row. so i have been trying to implement an isOpen useState for each dropdown. with limited luck. But if your generating the dropdown on click that would probebly fix my situation also. Secondly i require one of the dropdowns to be a "react-select/creatable" one. but the styling all goes weird when i try and convert it. So if anyone has any direation on how to implement that i would be very greatful.

Thanks in advance

benhornshaw avatar Apr 23 '24 09:04 benhornshaw

I think the issue is in CellRenderer.tsx with where isInEditMode is defined. Essentially, I'm seeking to leave a cell in edit mode even when it is not being focused on. I'm not sure this is possible with the current implementation.

Here is my custom dropdown @benhornshaw. You will have to modify the classes that the scroll listener is watching. There are docs on the reactgrid site about implementing a custom cell template and this is no different. You just pass in the custom template to the ReactGrid component and define the cell with your desired template

`import React from "react"; import { isAlphaNumericKey, keyCodes } from "@silevis/reactgrid"; import { Box, Autocomplete, TextField, ListItem, ListItemText, ClickAwayListener, } from "@mui/material";

export class AmDropdownTemplate { getCompatibleCell = (uncertainCell) => { return { ...uncertainCell }; };

handleKeyDown = (cell, keyCode, ctrl, shift, alt) => {
    if (!ctrl && !alt && isAlphaNumericKey(keyCode))
        return { cell, enableEditMode: true };
    return {
        cell,
        enableEditMode: keyCode === keyCodes.ENTER || keyCode === keyCodes.POINTER,
    };
};

update(previousCell, changedCell) {
    return this.getCompatibleCell({ ...previousCell, ...changedCell });
}

getStyle(cell) {
    //Sets invalid value background color as red and yellow if row is approved
    const exemptColumns = ["PARENTS", "QUANTITY", "notes"];
    const getBackgroundColor = (cell) => {
        if (cell.text === null || exemptColumns.includes(cell.column.columnId)) {
            if (cell.row?.status === "N") {
                return "#BBC0FD"; //new = blue
            } else {
                return cell.row.modified ? "#D9D9D6" : "white"; //empty cell, this is not used apparently
            }
        }

        if (
            cell.text &&
            !cell?.column?.value_set?.some((value) => value == cell.text)
        ) {
            return cell.row.approved === "Y" ? "yellow" : "#FF7276"; //invalid = red, yellow if approved
        }
        if (cell.text && !cell?.column?.value_set?.length) return "#FF7276"; //no value set = invalid/red
        if (cell.row?.status === "N") {
            return "#BBC0FD"; //new = blue
        } else if (cell.row.modified) {
            return "#D9D9D6"; //modified = grey
        } else {
            return "white"; //default
        }
    };

    return { ...(cell.style ?? {}), backgroundColor: getBackgroundColor(cell) };
}

render(cell, isInEditMode, onCellChanged) {
    return isInEditMode && !cell.readOnly ? (
        <InputCell
            cell={cell}
            openDropdown={cell?.isOpen ?? true}
            isInEditMode={isInEditMode}
            onCellChanged={onCellChanged}
            getCompatibleCell={this.getCompatibleCell}
        />
    ) : (
        <Box
            sx={{
                p: 0,
                m: 0,
            }}
        >
            {cell.text}
        </Box>
    );
}

}

function InputCell(props) { const { cell, isInEditMode, onCellChanged, getCompatibleCell, openDropdown } = props; const [value, setValue] = React.useState(cell?.text?.toString() || ""); const [inputValue, setInputValue] = React.useState(cell?.text?.toString() || ""); const handleClickAway = (event) => { event.preventDefault(); event.stopPropagation(); if (cell.text != inputValue) { //Use loose comparison to prevent updating after simply opening the dropdown onCellChanged( getCompatibleCell({ ...cell, text: inputValue, }), true, ); } else { onCellChanged({ ...cell, openDropdown: false }, true); } };

function handleValueChange(value = "") {
    setValue(value || "");
    setInputValue(value?.toString() || "");
    onCellChanged(
        getCompatibleCell({
            ...cell,
            text: value,
        }),
        false,
    );
}

const dropdownOptions = (valueSetValues) => {
    if (!valueSetValues?.length) return [];
    return valueSetValues.map((value) => {
        return {
            label: value?.toString() || "",
            value: value,
        };
    });
};

React.useEffect(() => {
    const handleScroll = () => {
        onCellChanged(
            {
                ...cell,
                openDropdown: false,
            },
            true,
        );
    };

    // Identify the scroll container of the grid. This might require modification based on your grid structure.
    const scrollContainer = document.querySelector(".grid-body");
    if (scrollContainer) {
        scrollContainer.addEventListener("scroll", handleScroll);
    }
    return () => {
        if (scrollContainer) {
            scrollContainer.removeEventListener("scroll", handleScroll);
        }
    };
}, [cell, onCellChanged, getCompatibleCell]);

React.useEffect(() => {
    const handleDropdownClick = () => {
        onCellChanged(
            {
                ...cell,
                openDropdown: false,
            },
            true,
        );
    };

    // Identify the scroll container of the grid. This might require modification based on your grid structure.
    const dropdownContainer = document.querySelector(".grid-drop-down");
    if (dropdownContainer) {
        dropdownContainer.addEventListener("click", handleDropdownClick);
    }
    return () => {
        if (dropdownContainer) {
            dropdownContainer.removeEventListener("click", handleDropdownClick);
        }
    };
}, [cell, onCellChanged, getCompatibleCell]);

React.useEffect(() => {
    const handleDropdownClick = () => {
        onCellChanged(
            {
                ...cell,
                openDropdown: false,
            },
            true,
        );
    };

    // Identify the scroll container of the grid. This might require modification based on your grid structure.
    const dropdownContainer = document.querySelector(".gridbody");
    if (dropdownContainer) {
        dropdownContainer.addEventListener("click", handleDropdownClick);
    }
    return () => {
        if (dropdownContainer) {
            dropdownContainer.removeEventListener("click", handleDropdownClick);
        }
    };
}, [cell, onCellChanged, getCompatibleCell]);

return (
    <ClickAwayListener onClickAway={handleClickAway}>
        <Autocomplete
            id="react-select-autocomplete"
            options={dropdownOptions(cell?.column?.value_set)}
            open={!openDropdown}
            value={value || ""}
            inputValue={inputValue}
            freeSolo
            sx={{
                width: "100%",
                p: 0,
            }}
            //className="grid-drop-down"
            ListboxProps={{
                className: "grid-drop-down",
                //style: { maxHeight: "none" },
            }}
            onInputChange={(e, value) => {
                setInputValue(value?.toString() || "");
                onCellChanged(
                    getCompatibleCell({
                        ...cell,
                        text: value?.toString() || "",
                    }),
                    false,
                );
            }}
            onChange={(e, value) => {
                e.stopPropagation();
                handleValueChange(value?.value || "");
            }}
            onBlur={(e) => {
                e.stopPropagation();
            }}
            onKeyDown={(e) => {
                e.stopPropagation();
            }}
            onMouseDown={(e) => {
                e.stopPropagation();
                e.preventDefault();
            }}
            renderInput={(params) => (
                <TextField
                    {...params}
                    inputRef={(input) => {
                        if (isInEditMode) {
                            input && input.focus();
                        }
                    }}
                    sx={{
                        width: "100%",
                        p: 0,
                    }}
                    size="small"
                    variant="standard"
                    fullWidth
                />
            )}
            renderOption={(props, option) => (
                <ListItem
                    {...props}
                    onPointerDown={() => {
                        handleValueChange(option.value);
                    }}
                    sx={{
                        "&.MuiListItem-root": {
                            padding: 0,
                        },
                    }}
                    dense
                    disablePadding
                    divider
                >
                    <ListItemText primary={option.label} sx={{ p: 0 }} />
                </ListItem>
            )}
        />
        {/*<Combobox
            data={cell?.column?.value_set}
            //open={!openDropdown}
            open={true}
            value={value || ""}
            onChange={(value) => {
                handleValueChange(value?.value || "");
            }}
            textField="label"
            valueField="value"
            filter={false}

            // Add any other props you need for the Combobox component
        />*/}
    </ClickAwayListener>
);

} `

rgnevin avatar Apr 23 '24 16:04 rgnevin

you cannot edit a cell which is not focused

MichaelMatejko avatar Apr 24 '24 12:04 MichaelMatejko

Thanks @MichaelMatejko. Perhaps I am not understanding the nature of my issue. With this dropdown, attempts to scroll instead are interpreted as making a range selection. It was my guess that clicking onto the scroll bar, causes the cell to lose focus, which changes the cell out of edit mode and closes the dropdown before you've had a chance to select anything. I see the reactgrid stock dropdown does not have this problem, so maybe it's not this. I would also note that I've tried to prevent basically every possible event from bubbling in the Autocomplete and that did not change anything. Thanks again for the help!

rgnevin avatar Apr 24 '24 14:04 rgnevin

Hi @rgnevin Thank you so much for this. Your code has completely fixed my issue. There are quite a few gotchas with these templates. especially where some of the css is in the main code. But i got there in the end so thank you.

One quick comment on your initial question, is to do with the <ClickAwayListener /> you are using. Is that actually whats causing the dropdown to close? In my code relying completely on the variable isInEditMode seemed to do the trick for opening and closing. I have also implemented using a useState to handle the isOpen condition. Thanks again.

For anyone else struggeling and if anyone has any comments or insight into making it even better. Using react-select

import React from "react";
import { getCellProperty, getCharFromKey, isAlphaNumericKey, Cell, CellTemplate, Compatible, Uncertain, UncertainCompatible, keyCodes } from '@silevis/reactgrid';

import Select, { components, OptionProps, MenuProps, IndicatorSeparatorProps } from 'react-select';
import "@silevis/reactgrid/styles.css";
import { DownOutlined } from "@ant-design/icons";

export type OptionType = {
    label: string;
    value: string;
    isDisabled?: boolean;
}

export class DropdownTemplate {

    getCompatibleCell(uncertainCell) {
        let selectedValue
    
        try {
          selectedValue = getCellProperty(uncertainCell, "selectedValue", "string")
        } catch {
          selectedValue = undefined
        }
    
        const values = getCellProperty(uncertainCell, "values", "object")
        const value = selectedValue ? parseFloat(selectedValue) : NaN
    
        let isDisabled = true
        try {
          isDisabled = getCellProperty(uncertainCell, "isDisabled", "boolean")
        } catch {
          isDisabled = false
        }
    
        let inputValue
        try {
          inputValue = getCellProperty(uncertainCell, "inputValue", "string")
        } catch {
          inputValue = undefined
        }
        
        const text = selectedValue || ""
    
        return {
          ...uncertainCell,
          selectedValue,
          text,
          value,
          values,
          isDisabled,
          inputValue
        }
      }
    
      update(cell, cellToMerge) {
        // I use the text property as a selectedValue property because behaviors don't know about the selectedValue property
        // and instead throw an error when we try to access it.
        // Before merging, we also need to check if the incoming value is in the target values array, otherwise we set it to undefined.
        const selectedValueFromText = cell.values.some(
          val => val.value === cellToMerge.text
        )
          ? cellToMerge.text
          : undefined
    
        return this.getCompatibleCell({
          ...cell,
          selectedValue: selectedValueFromText,
          inputValue: cellToMerge.inputValue
        })
      }
    
    
      handleKeyDown(cell, keyCode, ctrl, shift, alt, key) {
        
        if (keyCode === keyCodes.POINTER) {
            return {
                cell: this.getCompatibleCell({ ...cell }),
                enableEditMode: true
              }
        }

        if ((keyCode === keyCodes.SPACE || keyCode === keyCodes.ENTER) && !shift) {
          return {
            cell: this.getCompatibleCell({ ...cell }),
            enableEditMode: true
          }
        }
    
        const char = getCharFromKey(key, shift)
    
        if (
          !ctrl &&
          !alt &&
          isAlphaNumericKey(keyCode) &&
          !(shift && keyCode === keyCodes.SPACE)
        )
          return {
            cell: this.getCompatibleCell({
              ...cell,
              inputValue: char,
              isOpen: true
            }),
            enableEditMode: false
          }
    
        return { cell, enableEditMode: false }
      }
    
      handleCompositionEnd(cell, eventData) {
        return {
          cell: { ...cell, inputValue: eventData, isOpen: false },
          enableEditMode: false
        }
      }

    render(cell, isInEditMode, onCellChanged) {
        return (
            <InputCell
                cell={cell}
                isInEditMode={isInEditMode}
                onCellChanged={onCellChanged}
                getCompatibleCell={this.getCompatibleCell}
            />
        )
    }
}

function InputCell(props) {
    const { cell, isInEditMode, onCellChanged, getCompatibleCell } = props;
    const [inputValue, setInputValue] = React.useState(cell?.inputValue?.toString() || "");
    const [openDropdown, setOpenDropdown] = React.useState(isInEditMode);

    const selectRef = React.useRef<any>(null);

    function handleValueChange(value = "") {
        setInputValue(value?.toString() || "");
        onCellChanged(
            getCompatibleCell({
                ...cell,
                selectedValue: value,
                inputValue: undefined
            }),
            true,
        );
        setOpenDropdown(false);
    }

    React.useEffect(() => {
        const handleScroll = () => {
            console.log('handleScroll')
            setOpenDropdown(false)
        };

        // Identify the scroll container of the grid. This might require modification based on your grid structure.
        const scrollContainer = document.querySelector(".reactgrid");
        if (scrollContainer) {
            scrollContainer.addEventListener("scroll", handleScroll);
        }
        return () => {
            if (scrollContainer && isInEditMode) {
                scrollContainer.removeEventListener("scroll", handleScroll);
            }
        };
    }, [cell, onCellChanged, getCompatibleCell]);


    React.useEffect(() => {
        if (openDropdown && selectRef.current) {
            selectRef.current.focus();
            selectRef.current.inputRef.select();
            setInputValue(cell.inputValue);
        }
    }, [openDropdown, cell.inputValue]);


    const DropdownIndicator = (
        props: DropdownIndicatorProps
    ) => {
        return;
    };

    const IndicatorSeparator = ({
        innerProps,
    }: IndicatorSeparatorProps) => {
        return;
    };



    return isInEditMode && !cell.readOnly ? (
        <div
            style={{ width: '100%' }}
        >
            <div className="rg-dropdown-downArrow" onClick={() => setOpenDropdown(false)}><DownOutlined /></div>
            <Select
                classNamePrefix="rg-dropdown"
                placeholder={cell.placeholder}
                {...(cell.inputValue && {
                    inputValue,
                    defaultInputValue: inputValue,
                    onInputChange: e => setInputValue(e),
                })}
                isSearchable={true}
                ref={selectRef}
                menuIsOpen={openDropdown}
                // onMenuClose={handleMenuClose}
                // onMenuOpen={handleMenuOpen}
                onChange={(value) => {
                    handleValueChange(value?.value || "");
                }}
                blurInputOnSelect={true}
                isDisabled={cell.isDisabled}
                options={cell.values}
                onKeyDown={e => {
                    e.stopPropagation();

                    if (e.key === "Escape") {
                        //handleMenuClose();
                    }
                }}
                components={{
                    Option: CustomOption,
                    Menu: CustomMenu,
                    DropdownIndicator,
                    IndicatorSeparator
                }}
                styles={{
                    container: (provided) => ({
                        ...provided,
                        width: '100%',
                        height: '100%',
                    }),
                    control: (provided) => ({
                        ...provided,
                        border: 'none',
                        borderColor: 'transparent',
                        minHeight: '25px',
                        background: 'transparent',
                        boxShadow: 'none',
                        marginTop: -2
                    }),
                    indicatorsContainer: (provided) => ({
                        ...provided,
                        paddingTop: '0px',
                    }),
                    dropdownIndicator: (provided) => ({
                        ...provided,
                        padding: '0px 4px',
                    }),
                    singleValue: (provided) => ({
                        ...provided,
                        color: 'inherit',
                        marginLeft: 0,
                        marginTop: 1
                    }),
                    indicatorSeparator: (provided) => ({
                        ...provided,
                        marginTop: '4px',
                        marginBottom: '4px',
                    }),
                    input: (provided) => ({
                        ...provided,
                        padding: 0,
                        margin: 0
                    }),
                    valueContainer: (provided) => ({
                        ...provided,
                        padding: '0 8px',
                        color: "inherit",
                        marginLeft: 1,
                    }),
                }}
            />
        </div>
    ) : (
            <>
                <div className="rg-dropdown-downArrow" onClick={() => setOpenDropdown(true)}><DownOutlined /></div>
                <div
                    className="rg-cell rg-text-cell"
                >
                    {cell.inputValue}
                </div>
            </>
        );
}

const CustomOption: React.FC<OptionProps<OptionType, false>> = ({ innerProps, label, isSelected, isFocused, isDisabled }) => (
    <div
        {...innerProps}
        onPointerDown={e => e.stopPropagation()}
        className={`rg-dropdown-option${isSelected ? ' selected' : ''}${isFocused ? ' focused' : ''}${isDisabled ? ' disabled' : ''}`}
    >
        {label}
    </div>
);

const CustomMenu: React.FC<MenuProps<OptionType, false>> = ({ innerProps, children }) => (
    <div {...innerProps} className='rg-dropdown-menu'>{children}</div>
);
```

benhornshaw avatar Apr 26 '24 13:04 benhornshaw

Glad my code was helpful @benhornshaw. I can't even remember why I have openDropdown in addition to isInEditMode but remove it doesn't solve the problem. Does your implementation allow you to click and scroll the dropdown?

rgnevin avatar Apr 26 '24 15:04 rgnevin

Hi @rgnevin. Ive used openDropdown as the State of the dropdown. which works well. I wasnt meaning openDropdown was your problem. Im sorry github seems to have remove that bit of my message. What i actually wrote was. Could it be your use of ClickAwayListener which is triggering whenever you click on the dropdown? I also removed you event hander handleDropdownClick as this wasnt needed as clicking anywhere else on the react-grid triggered the isInEditMode to be false and close the dropdown anyway for me.

I may however implement this again but it would be on the underlying page, not the react-grid. Thanks

benhornshaw avatar Apr 26 '24 16:04 benhornshaw

Removing the ClickAwayListener does not appear to have any effect with the scrolling. If I don't have the ClickAwayListener you must click within the grid to have changes take effect and close open dropdowns. It doesn't seem like this is a problem in the reactgrid dropdown example but I haven't figured out a better way to deal with this. The ClickAwayListener is also wrapping the dropdown so, presumably, clicking away would be something other than the dropdown.

Someone else suggested prevent the event from bubbling but you can see that I've done that for every event I can think of with no success.

rgnevin avatar Apr 26 '24 17:04 rgnevin

I delved into the problem and it turned out that after clicking on the scrollbar, the onPointerDown event inside CustomMenu component was propagating up to a parent element, which was subsequently handling the event by closing the menu. I've created a PR.

webloopbox avatar Apr 29 '24 05:04 webloopbox

Thanks so much for looking into this @webloopbox! I took a quick look at your PR and it seems like you've changed DropdownCellTemplate.tsx. I'm not using your dropdown template at all though so I'm not sure that this would address the issue.

rgnevin avatar Apr 29 '24 14:04 rgnevin

@rgnevin From what I can see, the problem is that when you click on the scrollbar, the onPointerDown event is propagated. This causes a change in the focus location of ReactGrid, which in turn triggers a re-render of the dropdown list with the menu list hidden by default. To solve this, you could try passing a PaperComponent prop to your Autocomplete MUI component and stop the propagation there.

<Autocomplete
   PaperComponent={CustomPaper}
   // other props
/>

const CustomPaper = (props) => {
  return <Paper elevation={8} {...props} onPointerDown={(e) => e.stopPropagation()} />;
};

another solution:

useEffect(() => {
    const observer = new MutationObserver((mutationsList, observer) => {
      for (let mutation of mutationsList) {
        if (mutation.type === "childList") {
          const popper = document.querySelector(".MuiAutocomplete-paper");
          if (popper) {
            const handlePointerDown = (event) => {
              event.stopPropagation();
            };
            popper.addEventListener("pointerdown", handlePointerDown);

            observer.disconnect(); // stop observing when the popper element is found

            return () => {
              popper.removeEventListener("pointerdown", handlePointerDown);
            };
          }
        }
      }
    });

    observer.observe(document, { childList: true, subtree: true });
  }, []);

webloopbox avatar Apr 30 '24 10:04 webloopbox

Thank you @webloopbox!! You don't know how long I've been looking for that solution

rgnevin avatar May 08 '24 19:05 rgnevin