How to create code editor like Autocomplete dropdown with Material UI?

I have a rather specific use case that I’m thinking about how to implement in the app I’m working on.
The component is editor-like textarea that should be filled with Material UI Chip components (something like tags in the autocomplete textbox) which generate some kind of expression. When the user starts to type inside this text area, an autocomplete dropdown should pop up showing possible options to the user.

I would like to have this dropdown to be positioned inside this textarea (similar to intellisense) in IDEs.

I’m trying to implement this component by using combination of Autocomplete and some kind of custom Popper component.
The code looks something like this (it is still in some kind of draft phase):

import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
import TextField from "@material-ui/core/TextField";
import Autocomplete from "@material-ui/lab/Autocomplete";
import Chip from '@material-ui/core/Chip';
import { Popper } from "@material-ui/core";

const targetingOptions = [
  { label: "(", type: "operator" },
  { label: ")", type: "operator" },
  { label: "OR", type: "operator" },
  { label: "AND", type: "operator" },
  { label: "Test Option 1", type: "option" },
  { label: "Test Option 2", type: "option" },
];




const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      '& .MuiAutocomplete-inputRoot': {
        alignItems: 'start'
      }
    },
  }),
);


export default () => {
  const classes = useStyles();
  const [value, setValue] = React.useState<string[] | null>([]);

  const CustomPopper = function (props) {
    return <Popper {...props} style={{ width: 250, position: 'relative' }} />;
  };
  

  return (
    <div>
        <Autocomplete
        className={classes.root}
        multiple
        id="tags-filled"
        options={targetingOptions.map((option) => option.label)}
        freeSolo
        disableClearable
        PopperComponent={CustomPopper}
        renderTags={(value: string[], getTagProps) =>
          value.map((option: string, index: number) => (
            <Chip variant="outlined" label={option} {...getTagProps({ index })} />
          ))
        }
        renderInput={(params) => (
          <TextField {...params} variant="outlined" multiline={true} rows={20} />
        )}
      />
    </div>
  );
};

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    root: {
      '& .MuiAutocomplete-inputRoot': {
        alignItems: 'start'
      }
    },
  }),
);

export default () => {
  const classes = useStyles();

  const CustomPopper = function (props) {
    return <Popper {...props} style={{ width: 250, position: 'relative' }} />;
  };
  

  return (
    <div>
        <Autocomplete
        className={classes.root}
        multiple
        id="tags-filled"
        options={targetingOptions.map((option) => option.label)}
        freeSolo
        disableClearable
        PopperComponent={CustomPopper}
        renderTags={(value: string[], getTagProps) =>
          value.map((option: string, index: number) => (
            <Chip variant="outlined" label={option} {...getTagProps({ index })} />
          ))
        }
        renderInput={(params) => (
          <TextField {...params} variant="outlined" multiline={true} rows={20} />
        )}
      />
    </div>
  );
};

  1. How can I position this dropdown (Popper) bellow text cursor inside textarea?
  2. This component should also have ability to format created expression (Again similar to code editor formatter). Do you think this is the right approach for this use case, or should I use some other library and/or UI Components?

Thanks.

36 thoughts on “How to create code editor like Autocomplete dropdown with Material UI?”

  1. Caveat: This is going to be deep in the realm of opinion… I ended up going with Downshift for my customization npm install downshift

    This code is bit dirty (out of my dev branch), but it does a customized dropdown that you can edit

        import React from 'react'
    import {render} from 'react-dom'
    import Downshift from 'downshift'
    
    import {
      MenuItem,
      Paper,
      TextField,
    } from '@material-ui/core'
    
    import {
      withStyles
    } from '@material-ui/core/styles'
    
    const items = [
      'apple',
      'pear',
      'orange',
      'grape',
      'banana',
    ]
    
    class DownshiftWrapper extends React.Component {
      constructor(props) {
        super(props)
        this.state = {
          value: props.value || '',
          backup: props.value || '',
          onChange: v => {console.log('changed', v)}
        }
      }
    
      _renderMenuItem(args) {
        const { key, index, itemProps, current, highlightedIndex, selectedItem, ...rest } = args
        const isSelected = key == current
        return (
          <MenuItem
            {...rest}
            key = { key }
            selected = { isSelected }
            component='div'
            style={{
              fontWeight: isSelected ? 500 : 400,
              padding: '2px 16px 2px 16px',
              borderBottom: '1px solid rgba(128,128,128,0.5)',
            }}
          >
            { key }
          </MenuItem>
        )
      }
      render() {
        const { classes, style } = this.props
    
        const _override = (incoming) => {
          console.log('override:', incoming)
          this.setState({
            ...this.state,
            value: incoming
          })
    
          if(this.props.onChange) {
            this.props.onChange(incoming)
          } else {
            console.log(`Downshift::onChange the onchange handler is missing. New value:${incoming}`)
          }      
        }
    
        return (
          <Downshift
            ref = { x => this.downshift = x}
            onSelect = { (selected) => {
              if(selected) {
                console.log('::onSelect', selected) 
                _override(selected)
              }
            } }
            onInputValueChange= { (inputValue, stateAndHelpers) => {
              console.log('::onInputValueChange', {
                ...stateAndHelpers,
                _val: inputValue,
              })
            } }
            // onStateChange={( state ) => {
            //   //return input.onChange(inputValue);
            //   let value = state.inputValue
    
            //   this.state.onChange(state.inputValue)
            //   console.log('old:state', state)
            //   console.log('value:', value)
    
            //   _override( state.inputValue )
            // }}
            onChange={ selection => { console.log(selection) }}
            itemToString={ item => {
              return item || ''
            } }
            //selectedItem={this.props.input.value}
          >
          {({
            getInputProps,
            getItemProps,
            getLabelProps,
            getMenuProps,
            isOpen,
            inputValue,
            highlightedIndex,
            selectedItem,
          }) => {
            const inputProps = getInputProps()
            let value = inputProps.value
    
            //FIXME add filtering options
            let filtered = this.props.items || items//.filter(item => !inputValue || item.includes(inputValue))
    
            return (
              <div className={classes.container}>
                <TextField 
                  { ...inputProps } 
                  style={
                    style
                  }
                  label={this.props.label}
                  placeholder={this.props.placeholder}
                  
                  value = { 
                    this.state.value 
                  }
                  onFocus = { e => {
                    this.downshift.openMenu()
                    e.target.select()
                  }}
                  onBlur={ e => { 
                    console.log(inputValue) 
                    e.preventDefault()
                    this.downshift.closeMenu()
                  } }  
                  onChange={ e => {
                    inputProps.onChange(e)//pass to the logic
                    _override(e.target.value)
                  }}
                  onKeyDown= { (e) => {
                    const key = e.which || e.keyCode
                    if(key == 27){
                      e.preventDefault()
                      e.target.blur()
                       //reset to default
                      _override(this.state.backup || '')
                    } else if (key == 13){
                      e.preventDefault()
                      e.target.blur()
                      _override(e.target.value)
                    }
                  }}
                />
                {isOpen
                  ? (
                    <Paper 
                      className={classes.paper}
                      // style={{
                      //   backgroundColor: 'white',
                      // }}
                    square>
                      { filtered
                          .map( (item, index) => {
                            const _props = {
                              ...getItemProps({ item: item }),
                              index: index,
                              key: item,
                              item: item,
                              current: this.state.value,
                            } 
                            return this._renderMenuItem(_props)  
                          } )
                      }
                    </Paper>
                  )
                  : null}
    
                {/* <div style={{color: 'red'}}>{this.state.value || 'null'}</div> */}
              </div>
            )
            
          } }
          </Downshift>
        )
      }
    }
    
    class Integrated extends React.Component {
    
    }
    
    //Material UI Examples -> https://material-ui.com/demos/autocomplete/
    const styles = theme => ({
      root: {
        flexGrow: 1,
        height: 250,
      },
      container: {
        flexGrow: 1,
        position: 'relative',
      },
      paper: {
        position: 'absolute',
        zIndex: 1,
        marginTop: theme.spacing.unit,
        left: 0,
        right: 0,
      },
      chip: {
        margin: `${theme.spacing.unit / 2}px ${theme.spacing.unit / 4}px`,
      },
      inputRoot: {
        flexWrap: 'wrap',
      },
    })
    
    export default withStyles(styles)(DownshiftWrapper)
    

    in use (EditableSelect is the export name in my project):

    return (
            <EditableSelect 
              //onFocus={e => this.onFocus(e) }
              //multiLine={true}
              //onKeyDown={ e=> this.keyHandler(e) }
              items={ options }
              value={ cue.spots[index][field] }
              hintText={T.get('spot' + field + 'Hint')}
              placeholder={ T.get('spot' + field + 'Hint') }
              ref={x => this[id] = x }
              style={{width: '90%' }}
              onChange={ val => this.updateSpotExplicit(val, index, field) } 
            />
          )
    

    enter image description here

    I’m not sure what you’re after, but I found the Autocomplete problematic when I tried to customize it. There’s a bunch of cleanup that needs to happen in this code, but I can verify it is working in our production environment. This was the best solution I found ~18 months ago and we’re still using it.

    "@material-ui/core": "^4.11.2",
    "@material-ui/icons": "^4.11.2",
    "@material-ui/lab": "^4.0.0-alpha.57",
    "@material-ui/styles": "^4.11.2",
    "downshift": "^2.0.10",
    
    Reply

Leave a Comment