Okay here’s my final solution, which is working really great now:
dagfuncs.SelectDMC = class {
// Called when the editor is first created and before it's used
init(params) {
// Store the grid parameters for later use
this.params = params;
// Flag to prevent multiple activations of the dropdown
// This prevents race conditions when rapid events occur
this.isActivating = false;
// Define how to handle data coming back from the Mantine component
// This function is called when the user selects an option from the dropdown
const setProps = (props) => {
// Only proceed if a value property exists in the props
if (typeof props.value !== typeof undefined) {
// Update our internal value with the selected option
this.value = props.value;
// Re-enable keyboard events in the grid that were disabled during editing
delete params.colDef.suppressKeyboardEvent;
// Tell the grid to exit edit mode for this cell
params.api.stopEditing();
// Return focus to the previously active element (usually the grid cell)
if (this.prevFocus) this.prevFocus.focus();
}
};
// Create a DOM element to host our React component
this.eInput = document.createElement("div");
// Create a React root for rendering into our DOM element
this.root = ReactDOM.createRoot(this.eInput);
// Get Mantine components from the global window object
// These are provided by the dash_mantine_components package
const MantineProvider = window.dash_mantine_components.MantineProvider;
const Select = window.dash_mantine_components.Select;
// Get global Mantine theme configuration if it exists
// This ensures our dropdown matches the rest of the application's styling
const globalMantineConfig = {
theme: window.dash_mantine_components.mantineTheme || {},
styles: window.dash_mantine_components.mantineStyles || {},
colorScheme: window.dash_mantine_components.mantineColorScheme || "light",
emotionCache: window.dash_mantine_components.mantineEmotionCache || null,
withGlobalStyles: true,
withNormalizeCSS: true,
};
// Log initialization value for debugging
console.log(`Initializing with value: ${params.value}`);
// Render the Mantine Select component inside a MantineProvider
this.root.render(
React.createElement(
MantineProvider,
globalMantineConfig,
React.createElement(Select, {
// Data options for the dropdown
data: params.options,
value: params.value,
setProps,
// Basic styling - using the grid column's width
style: {
width: params.column.actualWidth - 2, // Slight adjustment to fit cell
zIndex: 1000, // Ensure dropdown appears above other elements
},
// *** CRITICAL DROPDOWN POSITIONING SETTINGS ***
// Keep the dropdown in the natural DOM flow instead of using a portal
// This is key to fixing the positioning issue
withinPortal: false,
// Position dropdown below the input and aligned with left edge
dropdownPosition: "bottom-start",
// Visual and functional settings
shadow: "md", // Medium shadow for visual distinction
clearable: params.clearable !== false, // Allow clearing selection
searchable: true, // Enable searching within options
placeholder: params.placeholder || "Select...", // Default placeholder
maxDropdownHeight: params.maxDropdownHeight || 280, // Control dropdown height
size: params.size, // Use size parameter if provided
// Advanced positioning settings for the combobox
comboboxProps: {
position: "bottom-start", // Consistent positioning
middleware: {
shift: true, // Allow horizontal shifting to keep in viewport
flip: true, // Allow flipping to above input if no room below
},
withinPortal: false, // Consistent with parent setting
},
})
)
);
// Make our container element focusable
this.eInput.tabIndex = "0";
// Initialize internal value to match the cell's value
this.value = params.value;
}
// Return the DOM element that will be inserted into the grid
getGui() {
return this.eInput;
}
// Handle focusing and activating the dropdown
focusChild() {
// Prevent multiple simultaneous activation attempts
if (this.isActivating) return;
this.isActivating = true;
// Delay to ensure the component has fully rendered
setTimeout(() => {
// Safety check - ensure our element still exists
if (!this.eInput) {
this.isActivating = false;
return;
}
// Find the input element using multiple possible selectors for compatibility
const input =
this.eInput.querySelector(".mantine-Select-input") ||
this.eInput.querySelector("input");
// If we can't find the input, abort the operation
if (!input) {
this.isActivating = false;
return;
}
// Make the input focusable with tab navigation
input.tabIndex = "1";
// Disable grid keyboard events while editing to prevent conflicts
this.params.colDef.suppressKeyboardEvent = (params) => {
return params.editing;
};
// Focus the input element to prepare for dropdown activation
input.focus();
// Delay slightly to ensure focus is complete before clicking
setTimeout(() => {
// First try to find and click the dropdown indicator button
// This is more reliable than clicking the input itself
const dropdownButton = this.eInput.querySelector(
".mantine-Select-rightSection"
);
if (dropdownButton) {
// If found, click the dropdown button to open options
dropdownButton.click();
} else {
// Fallback: click the input element itself
input.click();
}
// Reset activation flag after all attempts are complete
setTimeout(() => {
this.isActivating = false;
}, 100);
}, 150);
}, 100);
}
// Called by AG Grid after the editor is attached to the DOM
afterGuiAttached() {
// Store reference to currently focused element for later restoration
this.prevFocus = document.activeElement;
// Create a properly bound version of focusChild that preserves 'this' context
// This is critical to ensure the method has access to class properties
const boundFocusChild = this.focusChild.bind(this);
// Attach the event listener to our element
this.eInput.addEventListener("focus", boundFocusChild);
// Store reference to bound function for proper cleanup later
this._boundFocusChild = boundFocusChild;
// Trigger focus on our container element
this.eInput.focus();
// Explicitly call focusChild to ensure dropdown activates
// This ensures dropdown opens even if focus event doesn't trigger properly
this.focusChild();
}
// Return the current value to AG Grid when editing is complete
getValue() {
return this.value;
}
// Clean up resources when the editor is removed
destroy() {
// Remove event listeners to prevent memory leaks
// Using the stored bound function reference ensures proper removal
if (this._boundFocusChild) {
this.eInput.removeEventListener("focus", this._boundFocusChild);
}
// Use setTimeout to ensure clean unmounting after current execution
setTimeout(() => {
// Properly unmount the React component
if (this.root) {
this.root.unmount();
this.root = null; // Clear reference to prevent memory leaks
}
// Restore focus to previously active element
if (this.prevFocus) {
this.prevFocus.focus();
}
}, 0); // Zero timeout ensures it happens after current execution cycle
}
};
And a delete button for the row:
// Delete button for AG Grid
// https://community.plotly.com/t/deleting-rows-in-dash-ag-grid/78700
dagcomponentfuncs.DeleteButton = function (props) {
function onClick() {
props.api.applyTransaction({ remove: [props.node.data] });
}
// Red color? "#ff0000"
colorWanted = props.colorWanted || "";
return React.createElement(
"span", // Using span instead of button for less default styling
{
onClick,
style: {
cursor: "pointer", // Show pointer cursor on hover
color: colorWanted,
fontSize: "16px", // Larger font size
fontWeight: "bold", // Make it bold
display: "flex", // Center content
justifyContent: "center", // Center horizontally
alignItems: "center", // Center vertically
width: "100%", // Take full width of the cell
height: "100%", // Take full height of the cell
transition: "color 0.2s", // Smooth color transition on hover
},
onMouseOver: (e) => (e.currentTarget.style.color = "#cc0000"), // Darker red on hover
onMouseOut: (e) => (e.currentTarget.style.color = colorWanted), // Restore original color
title: "Delete row", // Tooltip on hover
},
"×" // Using the multiplication symbol which looks nicer than "X"
);
};
and the function to allow the dropdown to extend beyond the grid, in case the grid table area is small:
dagfuncs.setBody = () => {
return document.querySelector("body");
};