material-ui icon indicating copy to clipboard operation
material-ui copied to clipboard

[Autocomplete] How to drag & drop Chips?

Open marc-polizzi opened this issue 3 years ago • 19 comments

Duplicates

  • [X] I have searched the existing issues

Latest version

  • [X] I have tested the latest version

Summary 💡

I'd like to be able to sort the autocomplete chips.

I've tried using react-dnd in the renderTags method but there seems to be an issue.

Do you have any plan supporting this feature? Any advise otherwise?

Examples 🌈

A working example found on the net using Angular: https://stackblitz.com/edit/autocomplete-chips-drag-drop

Motivation 🔦

The autocomplete is used to select a list of ordered values (e.g., a list of sorting operations to perform in a given order ).

marc-polizzi avatar Dec 13 '21 11:12 marc-polizzi

Hi Marc, This sounds like a feature request, but it isn't clear what the use case is. Could you provide more information, and perhaps examples of other Autocomplete components that support the feature you're requesting? (You can edit the issue to keep everything in one place.)

mbrookes avatar Dec 14 '21 23:12 mbrookes

@mbrookes done

marc-polizzi avatar Dec 15 '21 05:12 marc-polizzi

Thanks. The Autocomplete doesn't intuitively suggest that it can be used to create an ordered list, as opposed to a set. Let's see if others would find this useful.

mbrookes avatar Dec 15 '21 20:12 mbrookes

I'd find this really useful!

danjenkins avatar Apr 07 '22 16:04 danjenkins

@danjenkins What's your use-case? I still don't picture the autocomplete as being an obvious choice of component for this.

mbrookes avatar Apr 16 '22 21:04 mbrookes

@danjenkins What's your use-case? I still don't picture the autocomplete as being an obvious choice of component for this.

@mbrookes in my case I want to select a list of media codecs and order them. I can do that today using a multi select, but you have to unselect codecs and then reselect them in preference order. In my use case it doesn't matter so much that it's an autocomplete... that's just a nice way of getting to a select option value quicker but the codec list is only 5 or so long... so autocomplete isn't necessary... it's just nice to have it as it behaves like a select when opening...

Anyway! In my use case it's easier for a user to select their codecs and then intuitively drag and drop the chips, press on a x to remove them etc...

Could I maybe write this another way? Yes. Would it be good to have it as part of the lib? I think so :) make sense?

danjenkins avatar Apr 16 '22 21:04 danjenkins

@danjenkins It sounds like the transfer list but with added drag-and-drop might be a good fit – albeit only suited to desktop. (You mentioned codecs, so that might not be a problem in your case).

mbrookes avatar Apr 18 '22 15:04 mbrookes

@mbrookes I didnt like the idea of the transfer list.... its just a lot of UI space. If I could get a multiselect with the renderValue rendering chips and having those chips draggable that would be perfect I think!

danjenkins avatar Apr 21 '22 09:04 danjenkins

@mbrookes I would also find this feature very useful. In my case, the order actually matters... for most use-cases, the autocomplete acts like an unordered set, but sometimes it should act like a unique list or ordered set with a user-defined ordering (for my particular use-case, the order of entries in the autocomplete also affects the order of the placement of geometries on a map, and is persisted to the db with that ordering which is important to maintain). A transfer list is far outside the scope of what I need.

HansBrende avatar May 07 '22 22:05 HansBrende

@marc-polizzi Can you please attach the working example if you have achieved this?

Naveenbc avatar Jul 04 '22 13:07 Naveenbc

@marc-polizzi you just have to add it onMouseDown={(event) => event.stopPropagation()} to Chip https://codesandbox.io/s/elated-murdock-ngggbp?file=/demo.tsx

timursayfetdinov avatar Nov 09 '22 08:11 timursayfetdinov

You can use react-sortable-hoc to implement exactly the same functionality. Too lazy to make codesandbox, but here some chunks from my implementation:

import { SortableContainer, SortableElement, SortEndHandler } from 'react-sortable-hoc';

const SortableItem = SortableElement<SortableItemProps>(
  ({ value, onDelete }: SortableItemProps) => {
    const onMouseDown: MouseEventHandler<HTMLDivElement> = (e) => {
      e.preventDefault();
      e.stopPropagation();
    };
    return (
      <Chip
        key={value.tag}
        label={value.tag}
        onMouseDown={onMouseDown}
        onDelete={() => onDelete(value.tag)}
      />
    );
  }
);

const SortableList = SortableContainer<SortableListProps>(
  ({ items, onDelete }: SortableListProps) => (
    <ChipsWrapper>
      {items.map((value, index) => (
        <SortableItem key={`item-${value.tag}`} index={index} value={value} onDelete={onDelete} />
      ))}
    </ChipsWrapper>
  )
);

<Autocomplete
    freeSolo
    multiple
    disableCloseOnSelect
    options={options}
    getOptionLabel={(option) => (option as TagObject).tag}
    groupBy={(option) => option.group}
    value={promptsTags}
    onChange={(event, value) => {
      const tags = value as TagObject[];
      setPromptsTags(tags);
    }}
    renderTags={(value) => (
      <SortableList
        items={value}
        axis="xy"
        onSortEnd={onSortEnd}
        distance={4}
        getHelperDimensions={({ node }) => node.getBoundingClientRect()}
        onDelete={onDelete}
      />
    )}
    renderInput={(params) => <TextField {...params} variant="outlined" label="Tags" rows={2} />}
  />

kostia-official avatar Feb 08 '23 10:02 kostia-official

dndkit worked well for me. Here is the complete code

import * as React from "react";
import Chip from "@mui/material/Chip";
import Autocomplete from "@mui/material/Autocomplete";
import TextField from "@mui/material/TextField";
import Stack from "@mui/material/Stack";
import {
  DndContext,
  closestCenter,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors
} from "@dnd-kit/core";
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
  useSortable
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

export default function Tags() {
  const [values, setValues] = React.useState([top100Films[13]])
  const SortableItem = (props) => {
    const {
      attributes,
      listeners,
      setNodeRef,
      transform,
      transition
    } = useSortable({ id: props.value });

    const style = {
      transform: CSS.Transform.toString(transform),
      transition
    };

    const onMouseDown = (e) => {
      // e.preventDefault();
      // e.stopPropagation();
    };
    return (
      <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
        <Chip
          key={props.value?.title}
          label={props.value?.title}
          onMouseDown={onMouseDown}
          onDelete={() => {
            console.log("OnDelete");
          }}
        />
      </div>
    );
  };

  const SortableChips = (props) => {
    const sensors = useSensors(
      useSensor(PointerSensor),
      useSensor(KeyboardSensor, {
        coordinateGetter: sortableKeyboardCoordinates
      })
    );
    const handleDragEnd = (event) => {
      const { active, over } = event;

      if (active && over && active.id.title !== over.id.title) {
        setValues((items) => {
          const oldIndex = items.indexOf(active.id);
          const newIndex = items.indexOf(over.id);
          return arrayMove(items, oldIndex, newIndex);
        });
      }
    };
    return (
      <DndContext
        sensors={sensors}
        collisionDetection={closestCenter}
        onDragEnd={handleDragEnd}
      >
        <SortableContext items={props.values} strategy={verticalListSortingStrategy}>
          {props.values.map((id) => (
            <SortableItem key={id.title} value={id} />
          ))}
        </SortableContext>
      </DndContext>
    );
  };
  console.log(values)
  return (
    <Stack spacing={3} sx={{ width: 500 }}>
      <Autocomplete
        multiple
        id="tags-standard"
        onChange={(changedValues, value) => {
          console.log(value);
          setValues(value)
        }}
        options={top100Films}
        value={values}
        getOptionLabel={(option) => option.title}
        renderInput={(params) => (
          <TextField
            {...params}
            variant="standard"
            label="Multiple values"
            placeholder="Favorites"
          />
        )}
        renderTags={(value) => <SortableChips values={value} />}
      />
    </Stack>
  );
}

// Top 100 films as rated by IMDb users. http://www.imdb.com/chart/top
const top100Films = [
  { title: "The Shawshank Redemption", year: 1994 },
  { title: "The Godfather", year: 1972 },
  { title: "The Godfather: Part II", year: 1974 },
  { title: "The Dark Knight", year: 2008 },
  { title: "12 Angry Men", year: 1957 },
  { title: "Schindler's List", year: 1993 },
  { title: "Pulp Fiction", year: 1994 },
  {
    title: "The Lord of the Rings: The Return of the King",
    year: 2003
  },
  { title: "The Good, the Bad and the Ugly", year: 1966 },
  { title: "Fight Club", year: 1999 },
  {
    title: "The Lord of the Rings: The Fellowship of the Ring",
    year: 2001
  },
  {
    title: "Star Wars: Episode V - The Empire Strikes Back",
    year: 1980
  },
  { title: "Forrest Gump", year: 1994 },
  { title: "Inception", year: 2010 },
  {
    title: "The Lord of the Rings: The Two Towers",
    year: 2002
  },
  { title: "One Flew Over the Cuckoo's Nest", year: 1975 },
  { title: "Goodfellas", year: 1990 },
  { title: "The Matrix", year: 1999 },
  { title: "Seven Samurai", year: 1954 },
  {
    title: "Star Wars: Episode IV - A New Hope",
    year: 1977
  },
  { title: "City of God", year: 2002 },
  { title: "Se7en", year: 1995 },
  { title: "The Silence of the Lambs", year: 1991 },
  { title: "It's a Wonderful Life", year: 1946 },
  { title: "Life Is Beautiful", year: 1997 },
  { title: "The Usual Suspects", year: 1995 },
  { title: "Léon: The Professional", year: 1994 },
  { title: "Spirited Away", year: 2001 },
  { title: "Saving Private Ryan", year: 1998 },
  { title: "Once Upon a Time in the West", year: 1968 },
  { title: "American History X", year: 1998 },
  { title: "Interstellar", year: 2014 },
  { title: "Casablanca", year: 1942 },
  { title: "City Lights", year: 1931 },
  { title: "Psycho", year: 1960 },
  { title: "The Green Mile", year: 1999 },
  { title: "The Intouchables", year: 2011 },
  { title: "Modern Times", year: 1936 },
  { title: "Raiders of the Lost Ark", year: 1981 },
  { title: "Rear Window", year: 1954 },
  { title: "The Pianist", year: 2002 },
  { title: "The Departed", year: 2006 },
  { title: "Terminator 2: Judgment Day", year: 1991 },
  { title: "Back to the Future", year: 1985 },
  { title: "Whiplash", year: 2014 },
  { title: "Gladiator", year: 2000 },
  { title: "Memento", year: 2000 },
  { title: "The Prestige", year: 2006 },
  { title: "The Lion King", year: 1994 },
  { title: "Apocalypse Now", year: 1979 },
  { title: "Alien", year: 1979 },
  { title: "Sunset Boulevard", year: 1950 },
  {
    title:
      "Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb",
    year: 1964
  },
  { title: "The Great Dictator", year: 1940 },
  { title: "Cinema Paradiso", year: 1988 },
  { title: "The Lives of Others", year: 2006 },
  { title: "Grave of the Fireflies", year: 1988 },
  { title: "Paths of Glory", year: 1957 },
  { title: "Django Unchained", year: 2012 },
  { title: "The Shining", year: 1980 },
  { title: "WALL·E", year: 2008 },
  { title: "American Beauty", year: 1999 },
  { title: "The Dark Knight Rises", year: 2012 },
  { title: "Princess Mononoke", year: 1997 },
  { title: "Aliens", year: 1986 },
  { title: "Oldboy", year: 2003 },
  { title: "Once Upon a Time in America", year: 1984 },
  { title: "Witness for the Prosecution", year: 1957 },
  { title: "Das Boot", year: 1981 },
  { title: "Citizen Kane", year: 1941 },
  { title: "North by Northwest", year: 1959 },
  { title: "Vertigo", year: 1958 },
  {
    title: "Star Wars: Episode VI - Return of the Jedi",
    year: 1983
  },
  { title: "Reservoir Dogs", year: 1992 },
  { title: "Braveheart", year: 1995 },
  { title: "M", year: 1931 },
  { title: "Requiem for a Dream", year: 2000 },
  { title: "Amélie", year: 2001 },
  { title: "A Clockwork Orange", year: 1971 },
  { title: "Like Stars on Earth", year: 2007 },
  { title: "Taxi Driver", year: 1976 },
  { title: "Lawrence of Arabia", year: 1962 },
  { title: "Double Indemnity", year: 1944 },
  {
    title: "Eternal Sunshine of the Spotless Mind",
    year: 2004
  },
  { title: "Amadeus", year: 1984 },
  { title: "To Kill a Mockingbird", year: 1962 },
  { title: "Toy Story 3", year: 2010 },
  { title: "Logan", year: 2017 },
  { title: "Full Metal Jacket", year: 1987 },
  { title: "Dangal", year: 2016 },
  { title: "The Sting", year: 1973 },
  { title: "2001: A Space Odyssey", year: 1968 },
  { title: "Singin' in the Rain", year: 1952 },
  { title: "Toy Story", year: 1995 },
  { title: "Bicycle Thieves", year: 1948 },
  { title: "The Kid", year: 1921 },
  { title: "Inglourious Basterds", year: 2009 },
  { title: "Snatch", year: 2000 },
  { title: "3 Idiots", year: 2009 },
  { title: "Monty Python and the Holy Grail", year: 1975 }
];

cmark1302 avatar Aug 25 '23 04:08 cmark1302

For ease of review, I copied @cmark1302 code onto a codesandbox to test how it worked

obedparla avatar Oct 23 '23 11:10 obedparla

indeed, while autocomplete in most cases used only to select number of values, it can be useful to order them in case order matters. Same can be achieved by removing all chips and adding them again in relevant order, but it's pretty bad UX.

danikp avatar Oct 28 '23 10:10 danikp

We're having the same challenge.

Here is our use case:

  • The item detail page allows users to add labels to an item. Users can add as many labels as they wish. For this we use the Autocomplete component.
  • The item overview page shows for each item a card with the top 3 labels (that's all the space there is in a card).
  • Users ask to reorder the labels, so they can control which ones are shown in the cards on the overview page.

ronnyroeller avatar Dec 07 '23 07:12 ronnyroeller

I did it using dndkit/core sortable and this worked perfectly fine without impacting the existing functionality of autocomplete. Using the below code you just need to the draggable flag to the AutocompleteComponent. The onfocus function is also managed to show the selected number or all selected tags

import React, { useState } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import Chip from "@mui/material/Chip";
import {
    DndContext,
    closestCenter,
    KeyboardSensor,
    PointerSensor,
    MouseSensor,
    TouchSensor,
    useSensor,
    useSensors
  } from "@dnd-kit/core";
  import {
    arrayMove,
    SortableContext,
    sortableKeyboardCoordinates,
    rectSortingStrategy,
    useSortable
  } from "@dnd-kit/sortable";
  import { CSS } from "@dnd-kit/utilities";

export default function AutoCompleteComponent(props) {

    const { classes, name, selectedValue draggable, ...rest } = props;
    // Define State
    const [values, setValues] = useState(selectedValue);
    const [inputFocused, setInputFocused] = useState(false);
    
    // Handle Draggable Autocomplete Focus
    const handleDraggableFocus = () => {
        setInputFocused(true);
    };

    const handleDraggableBlur = () => {
        setInputFocused(false);
    };
    
    /**
     * RenderTag option if draggable
     * @returns
     */
    const renderDraggableTags = (value) => {
        // Handle onDeleteItem Draggable Chip
        const onDeleteItem = (value) => {
            const nitems = Array.from(values);
            const updatedItems = nitems.filter((item) => item !== value);
            setValues(updatedItems);
            console.log(updatedItems);
            // you can call your onchange method for any other purpose
        };

        // Sortable Items
        const SortableItem = (val) => {
            const {
                attributes,
                listeners,
                setNodeRef,
                transform,
                transition
            } = useSortable({ id: val.value });

            const style = {
                transform: CSS.Transform.toString(transform),
                transition
            };

            const onMouseDown = (event) => {
              // event.preventDefault();
              // event.stopPropagation();
            };

            return (
                <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
                    <Chip
                        key={val.value}
                        label={val.value}
                        onMouseDown={onMouseDown}
                        onDelete={() => {onDeleteItem(val.value);}}
                    />
                </div>
            );
        };

        const SortableChips = (vals) => {

            const sensors = useSensors(
                useSensor(PointerSensor, {
                    activationConstraint: {
                        delay: 200,
                        tolerance: 5
                    }
                }),
                useSensor(MouseSensor),
                useSensor(TouchSensor, {
                    activationConstraint: {
                        delay: 200,
                        tolerance: 5
                    }
                }),
                useSensor(KeyboardSensor, {
                    coordinateGetter: sortableKeyboardCoordinates
                })
            );

            // handle handleDragEnd
            const handleDragEnd = (event) => {
                const { active, over } = event;
                if (active && over && active.id !== over.id) {
                    const items = Array.from(values);
                    const oldIndex = items.indexOf(active.id);
                    const newIndex = items.indexOf(over.id);
                    const nitems = arrayMove(items, oldIndex, newIndex);
                    setValues(nitems);
                    console.log(nitems);
                    // you can call your onchange method for any other purpose
                }
            };

            return (
                <DndContext
                    sensors={sensors}
                    collisionDetection={closestCenter}
                    onDragEnd={handleDragEnd}
                >
                    <SortableContext items={vals.values} strategy={rectSortingStrategy}>
                    {
                        vals.values.map((val) => (
                            <SortableItem
                                key={val}
                                value={val}
                            />
                        ))
                    }
                    </SortableContext>
                </DndContext>
            );
        };

        if (values.length !== selectedValue.length) {
            setValues(value);
        } else if (values.length === 1) {
            setValues(selectedValue);
        }

        const displayedValues = inputFocused ? value : value.slice(0, 1);
        const remainingCount = value.length - displayedValues.length;

        return (
            <div className={classes.draggableContainer}>
                <SortableChips values={displayedValues} />
                {
                    remainingCount > 0 && !inputFocused && (
                        <div>
                            {`+${remainingCount}`}
                        </div>
                    )
                }
            </div>
        );
    };

    /**
     * Updating the renderTags property
     * if draggable is true
     */
    let drest = { ...rest };
    if (draggable) {
        drest = {
            ...rest,
            renderTags: renderDraggableTags,
            onFocus: handleDraggableFocus,
            onBlur: handleDraggableBlur
        };
    }

    return (
        <Autocomplete
            id="attribute-search"
            ... rest of your property
            value={selectedValue || null}
            onChange={
                (event, newValue) => {
                    let value = newValue;
                    if (newValue && newValue.inputValue) {
                        value = newValue.inputValue;
                        if (value.includes('Add "')) {
                            value = value.split('Add "')[1].split('"')[0];
                        }
                    }
                    onChange(event, value);
                }
            }
            ... rest of your property
            onClose={() => onClose()}
            renderInput={
                () => (
                    <TextValidator
                        {...params}
                        name={name}
                        label={label}                        
                        InputProps={}
                    />
                )
            }
            ... rest of your property
            PaperComponent={() => {}}
            {
            ...drest
            }
        />
    );
};

dqjitendrak avatar Jan 22 '24 16:01 dqjitendrak

This would be a helpful feature, and I think this implementation is simple enough that it would be good example for the documentation (like how react-draggable is used for a draggable Dialog example).

My use case is that users can search an API and create a short, ordered list. I looked at Transfer List as an option--a transfer list with async autocomplete on one side is approximately what I want, but the fact that Transfer List doesn't work on mobile makes adding DnD to Autocomplete the much better solution.

justinh00k avatar May 07 '24 21:05 justinh00k

You are perfect.

SuhatAkbulak avatar May 08 '24 13:05 SuhatAkbulak

This is currently a feature of react-select and the docs for Autocomplete state "it's meant to be an improved version of the 'react-select' and 'downshift' packages". I think this claim is misleading without implementing this feature.

chriskuech avatar Jun 11 '24 05:06 chriskuech

The chip delete functionality does not work on @cmark1302 solution, here is an updated solution that works codesandbox.

Abassion avatar Sep 01 '24 21:09 Abassion