wix-style-react
Version:
391 lines (365 loc) • 10.9 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import { classes as theme } from './NestableListTheme.st.css';
import { classes, st } from './StyledNestableList.st.css';
import NestableList from '../NestableList';
import TableListItem from '../TableListItem';
import Box from '../Box';
import { Arrow } from './Arrow';
import TextButton from '../TextButton';
import Add from 'wix-ui-icons-common/Add';
import {
isFistItem,
isLastItem,
isRootItem,
moveItemOutsideOfTheParent,
moveItemToTheChildOfPrevSibling,
moveItemVertically,
setCollapse,
VerticalMovementDirection,
} from '../NestableList/utils';
import { DEPTH_THRESHOLD } from './constants';
/** A styled list with drag and drop and nesting capabilities. */
class StyledNestableList extends React.PureComponent {
state = {
movedItem: null,
backup: null,
isDragging: false,
isInternalStateUpdate: false,
items: this.props.items,
};
// according to make possible use setState along with getDerivedStateFromProps
// https://stackoverflow.com/questions/51019936/why-getderivedstatefromprops-is-called-after-setstate
_setStateDecorator = state => {
this.setState({
...state,
isInternalStateUpdate: true,
});
};
static getDerivedStateFromProps(props, state) {
if (state.isInternalStateUpdate) {
return {
...state,
isInternalStateUpdate: false,
};
}
return {
items: props.items,
};
}
_renderArrow = ({
isLastChild = false,
isPreview = false,
isPlaceholder,
} = {}) => {
return (
<Box
className={classes.arrowContainer}
backgroundColor={isPlaceholder ? 'D60' : undefined}
direction={'vertical'}
>
<div
className={st(classes.arrow, {
lastChild: isLastChild,
preview: isPreview,
placeholder: isPlaceholder,
})}
>
<Arrow className={classes.horizontalArrow} />
</div>
</Box>
);
};
_renderAction = data => {
const label = data.isRootAction
? this.props.addItemLabel
: data.item.addItemLabel;
if (!label || this.props.maxDepth <= data.depth) {
return;
}
return (
<Box
direction={'vertical'}
className={st(
classes.item,
{
rootAction: data.isRootAction,
dragging: this.state.isDragging,
withoutBottomBorder:
!this.props.withBottomBorder &&
((data.veryLastItem && !data.addItemLabel) || data.isRootAction),
},
classes.actionItem,
)}
>
<TableListItem
onClick={() => this.props.onAddItem(data.item)}
options={[
{
value: (
<Box>
<TextButton size={'small'} prefixIcon={<Add />}>
{label}
</TextButton>
</Box>
),
},
]}
/>
</Box>
);
};
_renderPrefix = data => {
if (
data.isPreview ||
isLastItem(data.siblings, data.item) ||
isRootItem(data.depth)
) {
return null;
}
return <div className={classes.childArrow} />;
};
_renderItem = options => {
const { isPlaceholder, depth, isPreview, isVeryLastItem, siblings, item } =
options;
const { id, children, isCollapsed, draggable = true, ...rest } = item;
const isLastChild = isLastItem(siblings, item);
const focused = this.state.movedItem && id === this.state.movedItem.id;
return (
<div
className={st(classes.itemWrapper, {
dragEnabled: draggable && !this.props.readOnly,
})}
data-hook={`styled-nestable-list-item-${item.id}`}
>
{!isRootItem(depth) &&
this._renderArrow({
isLastChild,
isPreview,
isPlaceholder,
})}
<div
className={st(classes.item, {
root: isRootItem(depth),
firstSibling: isFistItem(siblings, item),
placeholder: isPlaceholder,
preview: isPreview,
focused,
dragging: this.state.isDragging || focused,
withoutBottomBorder:
isVeryLastItem &&
!this.props.withBottomBorder &&
!this.props.addItemLabel,
})}
>
<TableListItem
{...rest}
focused={focused}
onBlur={this._onBlur}
onKeyUp={e => this._moveItemViaKeyboard(e, options)}
showDivider={false}
dragging={isPreview || focused}
dragDisabled={!draggable && !this.props.readOnly}
draggable={!this.props.readOnly}
options={rest.options ? rest.options : []}
/>
</div>
</div>
);
};
_onBlur = () => {
this._releaseItems();
};
_moveItemViaKeyboard = (e, itemOptions) => {
const left = 'ArrowLeft';
const top = 'ArrowUp';
const right = 'ArrowRight';
const bottom = 'ArrowDown';
const enter = 'Enter';
const esc = 'Escape';
// start dragend-drop
if (e.key === enter && !this.state.movedItem) {
this._setStateDecorator({
backup: this.state.items,
movedItem: itemOptions.item,
items: setCollapse(this.state.items, itemOptions.item.id, true),
});
}
if (!this.state.movedItem) {
return;
}
switch (e.key) {
case esc:
this._setStateDecorator({
items: this.state.backup,
backup: null,
movedItem: null,
});
break;
case left:
if (!this.props.preventChangeDepth) {
this._setStateDecorator({
items: moveItemOutsideOfTheParent(
this.state.items,
itemOptions.item,
),
});
}
break;
case right:
if (!this.props.preventChangeDepth) {
this._setStateDecorator({
items: moveItemToTheChildOfPrevSibling(
this.state.items,
itemOptions.item,
),
});
}
break;
case top:
this._setStateDecorator({
items: moveItemVertically(
this.state.items,
itemOptions.item,
VerticalMovementDirection.top,
),
});
break;
case bottom:
this._setStateDecorator({
items: moveItemVertically(
this.state.items,
itemOptions.item,
VerticalMovementDirection.bottom,
),
});
break;
default:
case enter:
this._releaseItems();
break;
}
};
_releaseItems() {
if (this.state.movedItem) {
const releaseItems = setCollapse(
this.state.items,
this.state.movedItem.id,
this.state.movedItem.isCollapsed,
);
this.props.onChange({
items: releaseItems,
item: this.state.movedItem,
});
this._setStateDecorator({
movedItem: null,
backup: null,
items: releaseItems,
});
}
}
_onDragEnd = () => {
this._setStateDecorator({
isDragging: false,
});
};
_onDragStart = () => {
this._setStateDecorator({
isDragging: true,
});
};
render() {
const {
dataHook,
className,
maxDepth,
onChange,
preventChangeDepth,
readOnly,
} = this.props;
return (
<div
data-hook={dataHook}
className={st(classes.root, { dragEnabled: !readOnly }, className)}
>
<NestableList
items={this.state.items}
renderItem={this._renderItem}
renderPrefix={this._renderPrefix}
renderAction={this._renderAction}
onDragEnd={this._onDragEnd}
onDragStart={this._onDragStart}
onUpdate={({ items, item }) => {
this._setStateDecorator({
items: items,
});
onChange({ items, item: item.data });
}}
maxDepth={maxDepth}
threshold={DEPTH_THRESHOLD}
theme={theme}
readOnly={readOnly}
preventChangeDepth={preventChangeDepth}
childrenStyle={{
position: 'relative',
marginLeft: `${DEPTH_THRESHOLD}px`,
}}
childrenProperty={'children'}
isRenderDraggingChildren={false}
/>
{this.props.addItemLabel &&
this._renderAction({
depth: 0,
isRootAction: true,
})}
</div>
);
}
}
StyledNestableList.displayName = 'StyledNestableList';
const Node = {
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
isCollapsed: PropTypes.bool,
addItemLabel: PropTypes.string,
...TableListItem.propTypes,
};
const NodeShape = PropTypes.shape(Node);
Node.children = PropTypes.arrayOf(NodeShape);
StyledNestableList.propTypes = {
/** Adds a bottom border (divider) to the last item. */
withBottomBorder: PropTypes.bool,
/** Prevents the list from being reordered by removing the dragging grip icons. */
readOnly: PropTypes.bool,
/** Adds a button to create a new item (to the Root or a child to the existing item). */
addItemLabel: PropTypes.string,
/** Triggers function when the "Add new ..." button is clicked. */
onAddItem: PropTypes.func,
/** Triggers function when the item’s order or nesting position is changed. */
onChange: PropTypes.func,
/** Defines a maximum depth (number of levels) for the list. */
maxDepth: PropTypes.number,
/** Allows dragging and dropping an item only on its own depth (inside the same level). */
preventChangeDepth: PropTypes.bool,
/**
* Defines each Nestable List item individually, using the following props:
* * __id__ - specifies an item’s ID.
* * __addItemLabel__ - creates the “Add new ...” button with a given label at the bottom of the item.
* * __isCollapsed__ - bool prop, defines whether to render the item’s children.
* * __children__ - defines an item’s children.
* * __draggable__ - bool prop, turns on / off dragging ability for an item.
* * All <a href="https://www.wix-style-react.com/?path=/story/components-lists-table--tablelistitem" target="_blank">`<TableListItem/>`</a> props can be used to format item’s style and content.
*/
items: PropTypes.arrayOf(NodeShape),
/** Can be applied in the tests as a data-hook HTML attribute. */
dataHook: PropTypes.string,
/** Defines a CSS class to be applied to the component's Root element. */
className: PropTypes.string,
};
StyledNestableList.defaultProps = {
maxDepth: 10,
withBottomBorder: false,
preventChangeDepth: false,
items: [],
onAddItem: () => {},
};
export default StyledNestableList;