Using Dash Mantine Components dmc.Select as an AG Grid cell editor

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");
};
1 Like