Need help creating Drag and Drop Material UI Tabs - reactjs

I've got a simple setup for some MUI tabs that I'm working to implement some drag and drop functionality to. The issue I'm running into is that when the SortableContext is nested inside of the TabList component, drag and drop works but the values no longer work for the respective tabs. When I move the SortableContext outside of the TabList component the values work again, but the drag and drop doesn't. If anybody has any guidance here that would be greatly appreciated!
Here is a link to a CodeSandbox using the code below: https://codesandbox.io/s/material-ui-tabs-with-drag-n-drop-functionality-05ktf3
Below is my code snippet:
import { Box } from "#mui/material";
import { useState } from "react";
import { DndContext, closestCenter } from "#dnd-kit/core";
import { arrayMove, SortableContext, rectSortingStrategy } from "#dnd-kit/sortable";
import SortableTab from "./SortableTab";
import { TabContext, TabList } from "#mui/lab";
const App = () => {
const [items, setItems] = useState(["Item One", "Item Two", "Item Three", "Item Four", "Item Five"]);
const [activeTab, setActiveTab] = useState("0");
const handleDragEnd = (event) => {
const { active, over } = event;
console.log("Drag End Called");
if (active.id !== over.id) {
setItems((items) => {
const oldIndex = items.indexOf(active.id);
const newIndex = items.indexOf(over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
};
const handleChange = (event, newValue) => {
setActiveTab(newValue);
};
return (
<div>
<Box sx={{ width: "100%" }}>
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<TabContext value={activeTab}>
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items} strategy={rectSortingStrategy}>
<TabList onChange={handleChange} aria-label="basic tabs example">
{items.map((item, index) => (
<SortableTab value={index.toString()} key={item} id={item} index={index} label={item} />
))}
</TabList>
</SortableContext>
</DndContext>
</TabContext>
</Box>
</Box>
</div>
);
};
export default App;
import { useSortable } from "#dnd-kit/sortable";
import { CSS } from "#dnd-kit/utilities";
import { Tab, IconButton } from "#mui/material";
import DragIndicatorIcon from "#mui/icons-material/DragIndicator";
const SortableTab = (props) => {
const { attributes, listeners, setNodeRef, transform, transition, setActivatorNodeRef } = useSortable({
id: props.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} {...attributes} style={style}>
<Tab {...props} />
<IconButton ref={setActivatorNodeRef} {...listeners} size="small">
<DragIndicatorIcon fontSize="5px" />
</IconButton>
</div>
);
};
export default SortableTab;

You can make drag and drop tabs work by moving <SortableContext> inside the <TabList> component something like the below. Then change sorting started to horizontalListSortingStrategy.
In this case, your Dnd will work, but you will lose all MUI transitions/animations. Because now DndContext is overriding MuiContext. The best solution to create something like this is to create custom Tabs components where your context does not depend on TabContext.
I hope it helps.
<TabContext value={activeTab}>
<DndContext
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<TabList onChange={handleChange} aria-label="basic tabs example">
<SortableContext
items={items}
strategy={horizontalListSortingStrategy}
>
{items.map((item, index) => (
<SortableTab
value={index.toString()}
key={item}
id={item}
index={index}
label={item}
/>
))}
</SortableContext>
</TabList>
</DndContext>
</TabContext>

So in order for this to work I ended up figuring out that without a draghandle on the Tab the click event would only either fire for the drag event or setting the value depending on where the SortableContext was placed. This was my solution:
SortableTab
import { useSortable } from "#dnd-kit/sortable";
import { CSS } from "#dnd-kit/utilities";
import { Tab, IconButton } from "#mui/material";
import MoreVertRoundedIcon from "#mui/icons-material/MoreVertRounded";
const SortableTab = (props) => {
const { attributes, listeners, setNodeRef, transform, transition, setActivatorNodeRef } = useSortable({
id: props.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} {...attributes} style={style}>
<Tab {...props} />
<IconButton ref={setActivatorNodeRef} {...listeners} size="small">
<MoreVertRoundedIcon fontSize="5px" />
</IconButton>
</div>
);
};
export default SortableTab;
DndContext code chunk:
const renderedTab = selectedScenario ? (
scenarioModels.map((item, index) => <SortableTab key={item} label={models.data[item].model} id={item} index={index} value={index + 1} onClick={() => handleModelClick(item)} />)
) : (
<Tab label="Pick a Scenario" value={0} />
);
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={scenarioModels} strategy={horizontalListSortingStrategy}>
<Tabs value={selectedTab} onChange={handleChange} centered>
{selectedScenario ? <Tab label="Source Data" /> : ""}
{renderedTab}
</Tabs>
</SortableContext>
</DndContext>

Related

Mui TabList overflowing when wrapped inside a grid

In the code below the tabs are overflowing past the visible width after wrapping in the TabsWrapper. I have a use case where the tabs will always be rendered in a similar wrapper. Is it possible to override some styles in ScrollableTabs to achieve the default behaviour?
I'm getting the feeling that there should be a fix as this seems like a common use case w.r.t to CSS Grid and Flex.
Just in case you aren't familiar with the default behaviour, try rendering ScrollableTabs without it being wrapped in the TabsWrapper.
import React from "react";
import { TabContext, TabPanel, TabList } from "#mui/lab";
import { Box, Tab, Stack } from "#mui/material";
/** Cannot be changed */
function TabsWrapper(props) {
return (
<div style={{ display: "grid", gridAutoFlow: "row" }}>{props.children}</div>
);
}
/** Cannot be changed */
function MyTabList(props) {
return (
<Stack direction="row">
<TabList
orientation="horizontal"
variant="scrollable"
onChange={props.onChange}
>
{props.children}
</TabList>
</Stack>
);
}
/** Can be changed */
export default function ScrollableTabs() {
const tabValues = [...Array(30)].map((_, index) => String(index + 1));
const [tab, setTab] = React.useState("1");
const handleChange = (_, newValue) => {
setTab(newValue);
};
return (
<TabsWrapper>
<TabContext value={tab}>
<MyTabList onChange={handleChange}>
{tabValues.map((value) => (
<Tab label={`Tab ${value}`} value={value} key={value} />
))}
</MyTabList>
<Box>
{tabValues.map((value) => (
<TabPanel value={value} key={value}>
Tab content {value}
</TabPanel>
))}
</Box>
</TabContext>
</TabsWrapper>
);
}
Working codesandbox - https://codesandbox.io/s/mui-custom-scrollable-tabs-2mfcjr
Thanks!
Wrapping TabContext in <div style={{ overflow: "hidden" }}> fixes the issue.
The ScrollableTabs jsx should look like -
export default function ScrollableTabs() {
const tabValues = [...Array(30)].map((_, index) => String(index + 1));
const [tab, setTab] = React.useState("1");
const handleChange = (_, newValue) => {
setTab(newValue);
};
return (
<TabsWrapper>
<div style={{ overflow: "hidden" }}>
<TabContext value={tab}>
<MyTabList onChange={handleChange}>
{tabValues.map((value) => (
<Tab label={`Tab ${value}`} value={value} key={value} />
))}
</MyTabList>
<Box>
{tabValues.map((value) => (
<TabPanel value={value} key={value}>
Tab content {value}
</TabPanel>
))}
</Box>
</TabContext>
</div>
</TabsWrapper>
);
}
Working codesandbox - https://codesandbox.io/s/mui-custom-scrollable-tabs-forked-6ycqfv

Mui TabList with react-beautiful-dnd not auto scrolling on drag

I have added drag & drop feature to the MUI Tabs List using react-beautiful-dnd.
Code -
import * as React from 'react';
import TabContext from '#mui/lab/TabContext';
import TabList from '#mui/lab/TabList';
import TabPanel from '#mui/lab/TabPanel';
import Tab from '#mui/material/Tab';
import { DragDropContext, Droppable } from 'react-beautiful-dnd';
import { Draggable } from 'react-beautiful-dnd';
import { styled } from '#mui/material/styles';
import { Stack } from '#mui/material';
function DraggableTab(props) {
return (
<Draggable
draggableId={`${props.index}`}
index={props.index}
disableInteractiveElementBlocking
>
{(draggableProvided) => (
<div
ref={draggableProvided.innerRef}
{...draggableProvided.draggableProps}
>
{React.cloneElement(props.child, {
...props,
...draggableProvided.dragHandleProps,
})}
</div>
)}
</Draggable>
);
}
const StyledTabList = styled(TabList)();
const StyledTab = styled(Tab)();
export default function DraggableTabsList() {
const [value, setValue] = React.useState('1');
const handleChange = (event, newValue) => {
setValue(newValue);
};
const [tabs, setTabs] = React.useState(
[...Array(55)].map((_, index) => ({
id: `tab${index + 1}`,
label: `Tab ${index + 1}`,
value: `${index + 1}`,
content: `Content ${index + 1}`,
}))
);
const onDragEnd = (result) => {
const newTabs = Array.from(tabs);
const draggedTab = newTabs.splice(result.source.index, 1)[0];
newTabs.splice(result.destination?.index, 0, draggedTab);
setTabs(newTabs);
};
const _renderTabList = (droppableProvided) => (
<StyledTabList onChange={handleChange} variant="scrollable">
{tabs.map((tab, index) => {
const child = (
<StyledTab
label={tab.label}
value={tab.value}
key={index}
/>
);
return (
<DraggableTab
label={tab.label}
value={tab.value}
index={index}
key={index}
child={child}
/>
);
})}
{droppableProvided ? droppableProvided.placeholder : null}
</StyledTabList>
);
const _renderTabListWrappedInDroppable = () => (
<DragDropContext onDragEnd={onDragEnd}>
<div>
<Droppable droppableId="1" direction="horizontal">
{(droppableProvided) => (
<div
ref={droppableProvided.innerRef}
{...droppableProvided.droppableProps}
>
{_renderTabList(droppableProvided)}
</div>
)}
</Droppable>
</div>
</DragDropContext>
);
return (
<TabContext value={value}>
<Stack>{_renderTabListWrappedInDroppable()}</Stack>
{tabs.map((tab, index) => (
<TabPanel value={tab.value} key={index}>
{tab.content}
</TabPanel>
))}
</TabContext>
);
}
Working codesandbox example - https://codesandbox.io/s/mui-tab-list-drag-and-drop-jceqnz
I am facing trouble making the tab list auto scroll while a tab is being dragged to the end of the visible list.
Also, I tried some hacks to make it work - see this, but I'm losing out on the scroll buttons which is a feature loss and not wanted. As auto scrolling is a standard d&d feature I'm hoping there must be some solution. Could you please help?
Thanks!
First of all thank you for such good quality code. I was searching for implementation of MUI tabs with react-beautiful-dnd and found your implementation really helpful.
To make it scrollable i along with dragable I removed unnecessary div and it worked perfectly for me
Here's a screen shot of my code.
And Heres the picture of dragable tab with scroll
Also I'm importing tabs from mui directly like this

Hide popper of one field when opening popper of another field

I am implementing two text fields next to each other and they have end adornments as a buttons. Those buttons toggle popper visibility. Also each popper has clickawaylistener so the popper is closed when mouse clicks outside popper. If first popper is opened it should be closed when I click button of second text field adornment. Issue is that end adornments have event propagation stopped. I do that to prevent clickaway event when clicking adornment, so to prevent instant closing of popper when it is opened by toggle handler.
I was thinking about wrapping TextField into ClickAwayListener but it didn't work.
P.S. Both TextField will be rendered from separate components and I don't want to share any props between them as they should be independent.
https://codesandbox.io/s/basictextfields-material-demo-forked-rykrx
const [firstPopperVisible, setFirstPopperVisible] = React.useState(false);
const [secondPopperVisible, setSecondPopperVisible] = React.useState(false);
const firstTextFieldRef = React.useRef();
const secondTextFieldRef = React.useRef();
const toggleFirstPopperVisible = (e) => {
e.stopPropagation();
setFirstPopperVisible((prev) => !prev);
};
const handleFirstPopperClickAway = (e) => {
setFirstPopperVisible(false);
};
const toggleSecondPopperVisible = (e) => {
e.stopPropagation();
setSecondPopperVisible((prev) => !prev);
};
const handleSecondPoppertClickAway = (e) => {
setSecondPopperVisible(false);
};
return (
<div style={{ display: "flex", flexDirection: "row" }}>
<div>
<TextField
label="Outlined"
variant="outlined"
inputRef={firstTextFieldRef}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
edge="end"
onClick={toggleFirstPopperVisible}
>
<Visibility />
</IconButton>
</InputAdornment>
)
}}
/>
<ClickAwayListener onClickAway={handleFirstPopperClickAway}>
<Popper
open={firstPopperVisible}
anchorEl={firstTextFieldRef.current}
placement="bottom-start"
>
Content
</Popper>
</ClickAwayListener>
</div>
<div>
<TextField
label="Outlined"
variant="outlined"
inputRef={secondTextFieldRef}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
edge="end"
onClick={toggleSecondPopperVisible}
>
<Visibility />
</IconButton>
</InputAdornment>
)
}}
/>
<ClickAwayListener onClickAway={handleSecondPoppertClickAway}>
<Popper
open={secondPopperVisible}
anchorEl={secondTextFieldRef.current}
placement="bottom-start"
>
Content
</Popper>
</ClickAwayListener>
</div>
</div>
);
}
EDIT: Found a temporary solution by wrapping TextField into div and then wrapping tat ClickawayListener. Also prevented propagation on popper itself where needed. This is not ideal, but for my case it worked.
<ClickAwayListener onClickAway={handleFirstPopperClickAway}>
<div style={{display: inline-box}>
<TextField>
.....
</TextField>
</div>
</ClickawayListener>
<Popper>
....
</Popper>
UPDATED
Wrap the ClickAwayListener in a conditional statement:
{firstPopperVisible && (
<ClickAwayListener onClickAway={handleFirstPopperClickAway}>
<Popper open={firstPopperVisible} anchorEl={firstTextFieldRef.current} placement="bottom-start">
Content
</Popper>
</ClickAwayListener>
)}
...
{secondPopperVisible && (
<ClickAwayListener onClickAway={handleSecondPoppertClickAway}>
<Popper open={secondPopperVisible} anchorEl={secondTextFieldRef.current} placement="bottom-start">
Content
</Popper>
</ClickAwayListener>
)}
Codesandbox Demo
PREVIOUS
I recommend you look at Portals for this. Instead of having multiple elements in the dom, you have one that gets added where needed, as needed.
Portals provide a first-class way to render children into a DOM node
that exists outside the DOM hierarchy of the parent component.
Your single Portal component:
import ReactDOM from 'react-dom';
import React, { useEffect, useState } from 'react';
const Component = ({ content, handleCloseClick }) => {
return <div onClick={handleCloseClick}>{content}</div>;
};
interface PortalProps {
isShowing: boolean;
content: any;
location: any;
handleCloseClick: () => void;
}
const Portal = ({ isShowing, content, handleCloseClick, location }: PortalProps) => (isShowing ? ReactDOM.createPortal(<Component handleCloseClick={handleCloseClick} content={content} />, location.current) : null);
export default Portal;
Which is then used once in your main component:
import React, { useState, useRef } from 'react';
import Widget from './Widget';
import './styles.css';
export default function App() {
const [isShowing, setIsShowing] = useState<boolean>(false);
const [content, setContent] = useState<string>();
const buttonRef = useRef(null);
const handleClick = (e) => {
const { target } = e;
buttonRef.current = e.target.parentNode;
setContent(target.dataset.content);
setIsShowing(true);
};
const handleCloseClick = () => {
buttonRef.current = null;
setContent('');
setIsShowing(false);
};
return (
<div className="App">
<div>
<button onClick={handleClick} data-content={`content for one`}>
One
</button>
</div>
<div>
<button onClick={handleClick} data-content={`content for two`}>
Two
</button>
</div>
<Widget isShowing={isShowing} location={buttonRef} content={content} handleCloseClick={handleCloseClick} />
</div>
);
}
Codesandbox Demo

Unable to drag React bootstrap tabs - React Beautiful dnd

I would like to drag drop tabs from react-bootstrap and I am using react-beautiful-dnd to achieve it. But for some reason, I am not able to get it. No tabs are appearing.
Here is my code:
import React from "react";
import { Tabs, Tab } from "react-bootstrap";
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd";
export default function App() {
const getItems = count =>
Array.from({ length: count }, (v, k) => k).map(k => ({
id: `item-${k}`,
content: `item ${k}`
}));
const [selectedTab, setSelectedTab] = React.useState("item-0");
const [items, setItems] = React.useState(getItems(6));
const reorder = (list, startIndex, endIndex) => {
const result = Array.from(list);
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
const onDragEnd = result => {
// dropped outside the list
if (!result.destination) {
return;
}
const items = reorder(
this.state.items,
result.source.index,
result.destination.index
);
setItems(items);
};
return (
<div className="App">
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="tab-drop" direction="horizontal">
{droppable => (
<React.Fragment>
<Tabs
ref={droppable.innerRef}
activeKey={selectedTab}
onSelect={chartId => {
if (chartId === this.state.selectedTab) {
return;
}
setSelectedTab(chartId);
}}
{...droppable.droppableProps}
>
{items.map((item, index) => (
<Draggable
key={`tab-${item.id}-order-${index}`}
draggableId={`${item.id}-order-${index}`}
index={index}
>
{(draggable, snapshot) => (
<Tab
ref={draggable.innerRef}
eventKey={item.id}
title={<div>{item.content}</div>}
{...draggable.draggableProps}
{...draggable.dragHandleProps}
style={getItemStyle(
draggable.draggableProps.style,
snapshot.isDragging
)}
/>
)}
</Draggable>
))}
<Tab title={<button>+</button>} />
<Tab
tabClassName="ml-auto active tabBottomBorder"
title={<button>Format</button>}
/>
<Tab
tabClassName="active tabBottomBorder"
title={<button>Edit</button>}
/>
</Tabs>
{droppable.placeholder}
</React.Fragment>
)}
</Droppable>
</DragDropContext>
</div>
);
}
This is the example which works without DND:
import React from "react";
import "./styles.css";
import { Tabs, Tab } from "react-bootstrap";
export default function App() {
const getItems = (count) =>
Array.from({ length: count }, (v, k) => k).map((k) => ({
id: `item-${k}`,
content: `item ${k}`
}));
const items = getItems(6);
const [selectedTab, setSelectedTab] = React.useState("item-0");
return (
<div className="App">
<Tabs
activeKey={selectedTab}
onSelect={(chartId) => {
if (chartId === selectedTab) {
return;
}
setSelectedTab(chartId);
}}
>
{items.map((item, index) => (
<Tab eventKey={item.id} title={<div>{item.content}</div>} />
))}
<Tab title={<button>+</button>} />
<Tab
tabClassName="ml-auto active tabBottomBorder"
title={<button>Format</button>}
/>
<Tab
tabClassName="active tabBottomBorder"
title={<button>Edit</button>}
/>
</Tabs>
</div>
);
}
Link with DND: https://stackblitz.com/edit/react-u1cts7?file=src%2FApp.js
Link without DND: https://codesandbox.io/s/pedantic-minsky-7zelb?file=/src/App.js:0-1045
I want to achieve a similar functionality to : https://codesandbox.io/s/mmrp44okvj?file=/index.js:0-2939
Please note only the tabs before the "+" button should be draggable. The + button tab and last two tabs shouldnt be.
Please advice on a way to fix this. Any help is appreciated.

How to manage react state for a list of JSX.Elements correctly

I am using react-hooks to manage a list of JSX.Elements. However, once the element changed, trying to delete it will cause unexpected behavior.
I had tried using useReducer, remove by index etc, still unexpected updated result occurred.
FolderPage.tsx
import React, { useState, useEffect } from 'react';
import { Button, Box, Grid } from 'grommet';
import { Add, Close } from 'grommet-icons';
import { Files } from '../Component/Files/Files';
import { ePub } from '../extension/ePub/ePub';
interface Props {}
export const FolderPage: React.FC<Props> = () => {
const [state, setState] = useState([<Files openFileHandlers={[ePub]} />]);
const newFolderPanel = () => setState(prev => prev.concat(<Files openFileHandlers={[ePub]} />));
const removePanel = (panel: JSX.Element) => setState(prevState => prevState.filter(s => s !== panel));
return (
<div>
<Box align="start" pad="xsmall">
<Button icon={<Add />} label="New Folder Panel" onClick={newFolderPanel} primary />
</Box>
{state.map((s, index) => (
<Grid key={index}>
<Box align="end">
<Button
icon={<Close color="white" style={{ backgroundColor: 'red', borderRadius: '50%', padding: '.25rem' }} />}
type="button"
onClick={() => removePanel(s)}
/>
</Box>
{s}
</Grid>
))}
</div>
);
};
For example, in usage:
What should I change my code so my delete click will delete the matched element?
There is a way to work around it. For each item inside the array. Instead of storing item directly, stored it as {id: yourAssignedNumber, content: item}.
In this way, you could have control of the id, and remove by comparing the id only. This way, it will work correctly.
import React, { useState, useRef } from 'react';
import { Button, Row, Col } from 'antd';
import { Files } from '../Components/Files/Files';
import { fileHandler } from '../model/fileHandler';
interface Props {
fileHandlers?: fileHandler[];
}
export const FolderPage: React.FC<Props> = ({ fileHandlers }) => {
const [state, setState] = useState([{ key: -1, content: <Files fileHandlers={fileHandlers} /> }]);
const key = useRef(0);
const newFolderPanel = () =>
setState(prev =>
prev.concat({
key: key.current++,
content: <Files fileHandlers={fileHandlers} />
})
);
const removePanel = (key: number) => setState(prevState => prevState.filter(s => s.key !== key));
return (
<Row>
<Button type="primary" icon="plus" onClick={newFolderPanel} style={{ margin: '.75rem' }}>
New Foldr Panel
</Button>
{state.map(({ key, content }) => (
<Col key={key}>
<div
style={{
background: '#75ff8133',
display: 'grid',
justifyItems: 'end',
padding: '.5rem 1rem'
}}>
<Button onClick={() => removePanel(key)} icon="close" type="danger" shape="circle" />
</div>
{content}
</Col>
))}
</Row>
);
};

Resources