reactgrid
reactgrid copied to clipboard
How to actively manage cell blurring?
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!!!
Please prevent the event from bubbling
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
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>
);
} `
you cannot edit a cell which is not focused
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!
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>
);
```
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?
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
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.
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.
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 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 });
}, []);
Thank you @webloopbox!! You don't know how long I've been looking for that solution