diff --git a/packages/react-core/src/components/DataList/examples/DataList.md b/packages/react-core/src/components/DataList/examples/DataList.md index 88e96dd7b4f..6c4a2f26c5b 100644 --- a/packages/react-core/src/components/DataList/examples/DataList.md +++ b/packages/react-core/src/components/DataList/examples/DataList.md @@ -13,8 +13,7 @@ propComponents: 'DataListItemRow', 'DataListToggle', 'DataListContent', - 'DataListDragButton', - 'DataListControl' + 'DataListControl', ] --- @@ -84,16 +83,6 @@ import global_BorderWidth_sm from '@patternfly/react-tokens/dist/esm/global_Bord ``` -### Draggable - -Draggable data lists used to have their own HTML5-based API for drag and drop, which wasn't able to fulfill requirements such as custom styling on items being dragged. So we wrote generic `DragDrop`, `Draggable`, and `Droppable` components for this purpose. Use those new components instead of the deprecated (and buggy!) HTML5-based API. - -Note: Keyboard accessibility and screen reader accessibility for the `DragDrop` component are still in development. - -```ts isBeta file="./DataListDraggable.tsx" - -``` - ### Small grid breakpoint ```ts file="./DataListSmGridBreakpoint.tsx" diff --git a/packages/react-core/src/components/DataList/examples/DataListDraggable.tsx b/packages/react-core/src/components/DataList/examples/DataListDraggable.tsx deleted file mode 100644 index c55244af0b1..00000000000 --- a/packages/react-core/src/components/DataList/examples/DataListDraggable.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from 'react'; -import { - DataList, - DataListItem, - DataListCell, - DataListItemRow, - DataListCheck, - DataListControl, - DataListDragButton, - DataListItemCells, - DragDrop, - Draggable, - Droppable, - getUniqueId -} from '@patternfly/react-core'; - -interface ItemType { - id: string; - content: string; -} - -const getItems = (count: number) => - Array.from({ length: count }, (_, idx) => idx).map((idx) => ({ - id: `draggable-item-${idx}`, - content: `item ${idx} `.repeat(idx === 4 ? 20 : 1) - })); - -const reorder = (list: ItemType[], startIndex: number, endIndex: number) => { - const result = list; - const [removed] = result.splice(startIndex, 1); - result.splice(endIndex, 0, removed); - return result; -}; - -export const DataListDraggable: React.FunctionComponent = () => { - const [items, setItems] = React.useState(getItems(10)); - const [liveText, setLiveText] = React.useState(''); - - function onDrag(source) { - setLiveText(`Started dragging ${items[source.index].content}`); - // Return true to allow drag - return true; - } - - function onDragMove(source, dest) { - const newText = dest ? `Move ${items[source.index].content} to ${items[dest.index].content}` : 'Invalid drop zone'; - if (newText !== liveText) { - setLiveText(newText); - } - } - - function onDrop(source, dest) { - if (dest) { - const newItems = reorder(items, source.index, dest.index); - setItems(newItems); - - setLiveText('Dragging finished.'); - return true; // Signal that this is a valid drop and not to animate the item returning home. - } else { - setLiveText('Dragging cancelled. List unchanged.'); - } - } - - const uniqueId = getUniqueId(); - - return ( - - - - {items.map(({ id, content }) => ( - - - - - - - - - {content} - - ]} - /> - - - - ))} - - -
- {liveText} -
-
- Press space or enter to begin dragging, and use the arrow keys to navigate up or down. Press enter to confirm - the drag, or any other key to cancel the drag operation. -
-
- ); -}; diff --git a/packages/react-core/src/components/DragDrop/examples/DragDrop.md b/packages/react-core/src/components/DragDrop/examples/DragDrop.md index b3a379223ee..7e700e1c8e8 100644 --- a/packages/react-core/src/components/DragDrop/examples/DragDrop.md +++ b/packages/react-core/src/components/DragDrop/examples/DragDrop.md @@ -2,7 +2,7 @@ id: Drag and drop section: components propComponents: [DragDrop, Draggable, Droppable, DraggableItemPosition] -beta: true +title: React - next --- You can use the `DragDrop` component to move items in or between lists. The `DragDrop` component should contain `Droppable` components which contain `Draggable` components. diff --git a/packages/react-core/src/components/DragDrop/examples/DragDropBasic.tsx b/packages/react-core/src/components/DragDrop/examples/DragDropBasic.tsx index 684c315bcf5..d80a6a7f213 100644 --- a/packages/react-core/src/components/DragDrop/examples/DragDropBasic.tsx +++ b/packages/react-core/src/components/DragDrop/examples/DragDropBasic.tsx @@ -20,14 +20,14 @@ const getItems = (count: number) => })); const reorder = (list: ItemType[], startIndex: number, endIndex: number) => { - const result = list; + const result = [...list]; const [removed] = result.splice(startIndex, 1); result.splice(endIndex, 0, removed); return result; }; export const DragDropBasic: React.FunctionComponent = () => { - const [items, setItems] = React.useState(getItems(10)); + const [items, setItems] = React.useState(getItems(10)); function onDrop(source: SourceType, dest: DestinationType) { if (dest) { @@ -42,8 +42,8 @@ export const DragDropBasic: React.FunctionComponent = () => { return ( - {items.map(({ content }, i) => ( - + {items.map(({ id, content }) => ( + {content} ))} diff --git a/packages/react-core/src/components/DragDrop/examples/DragDropMultipleLists.tsx b/packages/react-core/src/components/DragDrop/examples/DragDropMultipleLists.tsx index 50cc54658c8..0400633987f 100644 --- a/packages/react-core/src/components/DragDrop/examples/DragDropMultipleLists.tsx +++ b/packages/react-core/src/components/DragDrop/examples/DragDropMultipleLists.tsx @@ -11,6 +11,11 @@ interface SourceType { index: number; } +interface MultipleListState { + items1: ItemType[]; + items2: ItemType[]; +} + interface DestinationType extends SourceType {} const getItems = (count: number, startIndex: number) => @@ -35,7 +40,7 @@ const move = (source: ItemType[], destination: ItemType[], sourceIndex: number, }; export const DragDropMultipleLists: React.FunctionComponent = () => { - const [items, setItems] = React.useState({ + const [items, setItems] = React.useState({ items1: getItems(10, 0), items2: getItems(5, 10) }); @@ -84,7 +89,7 @@ export const DragDropMultipleLists: React.FunctionComponent = () => { {Object.entries(items).map(([key, subitems]) => ( - {subitems.map(({ id, content }) => ( + {(subitems as ItemType[]).map(({ id, content }) => ( {content} diff --git a/packages/react-core/src/components/DragDrop/index.ts b/packages/react-core/src/components/DragDrop/index.ts index 032ae9dfe38..7a4a6bd342e 100644 --- a/packages/react-core/src/components/DragDrop/index.ts +++ b/packages/react-core/src/components/DragDrop/index.ts @@ -1,3 +1,4 @@ export * from './DragDrop'; export * from './Draggable'; export * from './Droppable'; +export * from './DroppableContext'; diff --git a/packages/react-core/src/components/DualListSelector/examples/DualListSelector.md b/packages/react-core/src/components/DualListSelector/examples/DualListSelector.md index 60288c158ff..0eee867b386 100644 --- a/packages/react-core/src/components/DualListSelector/examples/DualListSelector.md +++ b/packages/react-core/src/components/DualListSelector/examples/DualListSelector.md @@ -85,26 +85,6 @@ The dual list selector can also be built in a composable manner to make customiz ``` -### Composable with drag and drop - -This example only allows reordering the contents of the "chosen" pane with drag and drop. To make a pane able to be reordered: - -- wrap the `DualListSelectorPane` in a `DragDrop` component -- wrap the `DualListSelectorList` in a `Droppable` component -- wrap the `DualListSelectorListItem` components in a `Draggable` component -- define an `onDrop` callback which reorders the sortable options. - - The `onDrop` function provides the starting location and destination location for a dragged item. It should return - true to enable the 'drop' animation in the new location and false to enable the 'drop' animation back to the item's - old position. - - define an `onDrag` callback which ensures that the drag event will not cross hairs with the `onOptionSelect` click - event set on the option. Note: the `ignoreNextOptionSelect` state value is used to prevent selection while dragging. - -Note: Keyboard accessibility and screen reader accessibility for the `DragDrop` component are still in development. - -```ts file="DualListSelectorComposableDragDrop.tsx" - -``` - ### Composable with tree ```ts file="DualListSelectorComposableTree.tsx" diff --git a/packages/react-core/src/components/DualListSelector/index.ts b/packages/react-core/src/components/DualListSelector/index.ts index d2b6deb96ff..7f66aa3a037 100644 --- a/packages/react-core/src/components/DualListSelector/index.ts +++ b/packages/react-core/src/components/DualListSelector/index.ts @@ -1,7 +1,9 @@ export * from './DualListSelector'; +export * from './DualListSelectorContext'; export * from './DualListSelectorControl'; export * from './DualListSelectorControlsWrapper'; export * from './DualListSelectorPane'; export * from './DualListSelectorList'; export * from './DualListSelectorListItem'; export * from './DualListSelectorTree'; +export * from './DualListSelectorContext'; diff --git a/packages/react-core/src/components/index.ts b/packages/react-core/src/components/index.ts index a322279493d..a4338497ba5 100644 --- a/packages/react-core/src/components/index.ts +++ b/packages/react-core/src/components/index.ts @@ -22,6 +22,7 @@ export * from './DataList'; export * from './DatePicker'; export * from './DescriptionList'; export * from './Divider'; +export * from './DragDrop'; export * from './Drawer'; export * from './Dropdown'; export * from './DualListSelector'; @@ -76,7 +77,6 @@ export * from './Tooltip'; export * from './NumberInput'; export * from './TreeView'; export * from './Wizard'; -export * from './DragDrop'; export * from './TextInputGroup'; export * from './Panel'; export * from './Truncate'; diff --git a/packages/react-docs/package.json b/packages/react-docs/package.json index b1e5b008ed9..94b6691ee43 100644 --- a/packages/react-docs/package.json +++ b/packages/react-docs/package.json @@ -26,6 +26,7 @@ "@patternfly/react-charts": "^7.2.0-prerelease.4", "@patternfly/react-code-editor": "^5.2.0-prerelease.10", "@patternfly/react-core": "^5.2.0-prerelease.10", + "@patternfly/react-drag-drop": "^5.2.0-prelease.0", "@patternfly/react-icons": "^5.2.0-prerelease.3", "@patternfly/react-styles": "^5.2.0-prerelease.2", "@patternfly/react-table": "^5.2.0-prerelease.10", diff --git a/packages/react-docs/patternfly-docs/patternfly-docs.source.js b/packages/react-docs/patternfly-docs/patternfly-docs.source.js index b90f892c8d3..0d6f20116d6 100644 --- a/packages/react-docs/patternfly-docs/patternfly-docs.source.js +++ b/packages/react-docs/patternfly-docs/patternfly-docs.source.js @@ -16,12 +16,14 @@ module.exports = (baseSourceMD, sourceProps) => { const reactCodeEditorPath = require .resolve('@patternfly/react-code-editor/package.json') .replace('package.json', 'src'); + const reactDragDropPath = require.resolve('@patternfly/react-drag-drop/package.json').replace('package.json', 'src'); const reactPropsIgnore = '**/*.test.tsx'; sourceProps(path.join(reactCorePath, '/**/*.tsx'), reactPropsIgnore); sourceProps(path.join(reactTablePath, '/**/*.tsx'), reactPropsIgnore); sourceProps(path.join(reactChartsPath, '/**/*.tsx'), reactPropsIgnore); sourceProps(path.join(reactCodeEditorPath, '/**/*.tsx'), reactPropsIgnore); + sourceProps(path.join(reactDragDropPath, '/**/*.tsx'), reactPropsIgnore); // React MD sourceMD(path.join(reactCorePath, '/components/**/examples/*.md'), 'react'); @@ -41,6 +43,9 @@ module.exports = (baseSourceMD, sourceProps) => { // Code Editor MD sourceMD(path.join(reactCodeEditorPath, '/**/examples/*.md'), 'react'); + // Drag drop MD + sourceMD(path.join(reactDragDropPath, '/**/examples/*.md'), 'react-next'); + // OUIA MD sourceMD(path.join(reactCorePath, 'helpers/OUIA/OUIA.md'), 'react'); }; diff --git a/packages/react-drag-drop/package.json b/packages/react-drag-drop/package.json new file mode 100644 index 00000000000..168e722a531 --- /dev/null +++ b/packages/react-drag-drop/package.json @@ -0,0 +1,49 @@ +{ + "name": "@patternfly/react-drag-drop", + "version": "5.2.0-prelease.0", + "description": "PatternFly drag and drop solution", + "main": "dist/js/index.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "sideEffects": false, + "publishConfig": { + "access": "public" + }, + "patternfly:src": "src/", + "repository": { + "type": "git", + "url": "https://github.com/patternfly/patternfly-react.git" + }, + "keywords": [ + "react", + "patternfly", + "drag-drop" + ], + "author": "Red Hat", + "license": "MIT", + "bugs": { + "url": "https://github.com/patternfly/patternfly-react/issues" + }, + "homepage": "https://github.com/patternfly/patternfly-react/tree/main/packages/react-drag-drop#readme", + "scripts": { + "clean": "rimraf dist" + }, + "dependencies": { + "@dnd-kit/core": "^6.0.8", + "@dnd-kit/modifiers": "^6.0.1", + "@dnd-kit/sortable": "^7.0.2", + "@patternfly/react-core": "^5.2.0-prerelease.2", + "@patternfly/react-icons": "^5.2.0-prerelease.1", + "@patternfly/react-styles": "^5.2.0-prerelease.1", + "memoize-one": "^5.1.0", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": "^17 || ^18", + "react-dom": "^17 || ^18" + }, + "devDependencies": { + "rimraf": "^2.6.2", + "typescript": "^4.7.4" + } +} diff --git a/packages/react-drag-drop/src/components/DragDrop/DragButton.tsx b/packages/react-drag-drop/src/components/DragDrop/DragButton.tsx new file mode 100644 index 00000000000..a559839b257 --- /dev/null +++ b/packages/react-drag-drop/src/components/DragDrop/DragButton.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { css } from '@patternfly/react-styles'; +import dragButtonStyles from '@patternfly/react-styles/css/components/DataList/data-list'; +import buttonStyles from '@patternfly/react-styles/css/components/Button/button'; +import GripVerticalIcon from '@patternfly/react-icons/dist/esm/icons/grip-vertical-icon'; + +export interface DragButtonProps extends React.HTMLProps { + /** Additional classes added to the drag button */ + className?: string; + /** Sets button type */ + type?: 'button' | 'submit' | 'reset'; + /** Flag indicating if drag is disabled for the item */ + isDisabled?: boolean; +} + +export const DragButton: React.FunctionComponent = ({ className, ...props }: DragButtonProps) => ( + +); +DragButton.displayName = 'DragButton'; diff --git a/packages/react-drag-drop/src/components/DragDrop/DragDropSort.tsx b/packages/react-drag-drop/src/components/DragDrop/DragDropSort.tsx new file mode 100644 index 00000000000..6ba0773cd46 --- /dev/null +++ b/packages/react-drag-drop/src/components/DragDrop/DragDropSort.tsx @@ -0,0 +1,150 @@ +import * as React from 'react'; +import { css } from '@patternfly/react-styles'; +import { + DndContext, + closestCenter, + DragOverlay, + DndContextProps, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, + DragStartEvent +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy +} from '@dnd-kit/sortable'; +import { Draggable } from './Draggable'; +import { DragButton } from './DragButton'; +import { DraggableDataListItem } from './DraggableDataListItem'; +import { DraggableDualListSelectorListItem } from './DraggableDualListSelectorListItem'; +import styles from '@patternfly/react-styles/css/components/DragDrop/drag-drop'; +import flexStyles from '@patternfly/react-styles/css/layouts/Flex/flex'; + +export type DragDropSortDragEndEvent = DragEndEvent; +export type DragDropSortDragStartEvent = DragStartEvent; + +export interface DraggableObject { + /** Unique id of the draggable object */ + id: string; + /** Content rendered in the draggable object */ + content: React.ReactNode; + /** Props spread to the rendered wrapper of the draggable object */ + props?: any; +} + +export interface DragDropSortProps extends DndContextProps { + /** Custom defined content wrapper for draggable items. By default, draggable items are wrapped in a styled div. + * Intended to be a 'DataList' or 'DualListSelectorList' without children. */ + children?: React.ReactElement; + /** Sorted array of draggable objects */ + items: DraggableObject[]; + /** Callback when user drops a draggable object */ + onDrop: (event: DragDropSortDragEndEvent, items: DraggableObject[], oldIndex?: number, newIndex?: number) => void; + /** Callback when use begins dragging a draggable object */ + onDrag?: (event: DragDropSortDragStartEvent, oldIndex: number) => void; + /** The variant determines which component wraps the draggable object. + * Default and defaultWithHandle varaints wrap the draggable object in a div. + * DataList vairant wraps the draggable object in a DataListItem + * DualListSelectorList variant wraps the draggable objects in a DualListSelectorListItem and a div.pf-c-dual-list-selector__item-text element + * TableComposable variant wraps the draggable objects in TODO + * */ + variant?: 'default' | 'defaultWithHandle' | 'DataList' | 'DualListSelectorList' | 'TableComposable'; +} + +export const DragDropSort: React.FunctionComponent = ({ + items, + onDrop = () => {}, + onDrag = () => {}, + variant = 'default', + children, + ...props +}: DragDropSortProps) => { + const [activeId, setActiveId] = React.useState(null); + const [dragging, setDragging] = React.useState(false); + + const itemIds = React.useMemo(() => (items ? Array.from(items, item => item.id as string) : []), [items]); + + const getItemById = (id: string): DraggableObject => items.find(item => item.id === id); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + const oldIndex = itemIds.indexOf(active.id as string); + const newIndex = itemIds.indexOf(over.id as string); + const newItems = arrayMove(items, oldIndex, newIndex); + setDragging(false); + onDrop(event, newItems, oldIndex, newIndex); + return newItems; + }; + + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as string); + setDragging(true); + onDrag(event, itemIds.indexOf(event.active.id as string)); + }; + + const renderedChildren = ( + + {items.map((item: DraggableObject) => { + switch (variant) { + case 'DualListSelectorList': + return ( + + {item.content} + + ); + case 'DataList': + return ( + + {item.content} + + ); + default: + return ( + + {item.content} + + ); + } + })} + +
+ {variant !== 'default' && } + {activeId ? getItemById(activeId).content : null} +
+
+
+ ); + + return ( + + {children && React.cloneElement(children, { + children: renderedChildren, + className: `${styles.droppable} ${dragging ? styles.modifiers.dragging : ''}` + })} + {!children && ( +
+ {renderedChildren} +
+ )} +
+ ); +}; +DragDropSort.displayName = 'DragDropSort'; diff --git a/packages/react-drag-drop/src/components/DragDrop/Draggable.tsx b/packages/react-drag-drop/src/components/DragDrop/Draggable.tsx new file mode 100644 index 00000000000..a1d7e80876d --- /dev/null +++ b/packages/react-drag-drop/src/components/DragDrop/Draggable.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/DragDrop/drag-drop'; +import { DragButton } from './DragButton'; + +export interface DraggableProps extends React.HTMLProps { + /** Content rendered inside DragDrop */ + children?: React.ReactNode; + /** Don't wrap the component in a div. Requires passing a single child. */ + hasNoWrapper?: boolean; + /** Class to add to outer div */ + className?: string; + /** */ + id?: string; + /** */ + useDragButton?: boolean; +} + +export const Draggable: React.FunctionComponent = ({ + children, + id, + className, + useDragButton = false, + ...props +}: DraggableProps) => { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ + id + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition + }; + + return useDragButton ? ( +
+ + {children} +
+ ) : ( +
+ {children} +
+ ); +}; +Draggable.displayName = 'Draggable'; diff --git a/packages/react-drag-drop/src/components/DragDrop/DraggableDataListItem.tsx b/packages/react-drag-drop/src/components/DragDrop/DraggableDataListItem.tsx new file mode 100644 index 00000000000..ca9dde501b7 --- /dev/null +++ b/packages/react-drag-drop/src/components/DragDrop/DraggableDataListItem.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/DataList/data-list'; +import { DragButton } from './DragButton'; +import { DataListItemRow, DataListControl } from '@patternfly/react-core'; + +export interface DraggableDataListItemObject { + id?: string; + content?: React.ReactNode; +} + +export interface DraggableDataListItemProps extends React.HTMLProps { + /** Content rendered inside DragDrop */ + children?: React.ReactNode; + /** Don't wrap the component in a div. Requires passing a single child. */ + hasNoWrapper?: boolean; + /** Class to add to outer div */ + className?: string; + /** */ + id?: string; +} + +export const DraggableDataListItem: React.FunctionComponent = ({ + children, + id, + className, + ...props +}: DraggableDataListItemProps) => { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ + id + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition + }; + + return ( +
  • + + + + + {children} + +
  • + ); +}; +DraggableDataListItem.displayName = 'DraggableDataListItem'; diff --git a/packages/react-drag-drop/src/components/DragDrop/DraggableDualListSelectorListItem.tsx b/packages/react-drag-drop/src/components/DragDrop/DraggableDualListSelectorListItem.tsx new file mode 100644 index 00000000000..9d6f9721d66 --- /dev/null +++ b/packages/react-drag-drop/src/components/DragDrop/DraggableDualListSelectorListItem.tsx @@ -0,0 +1,91 @@ +import * as React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { css } from '@patternfly/react-styles'; +import styles from '@patternfly/react-styles/css/components/DualListSelector/dual-list-selector'; +import { DragButton } from './DragButton'; +import { DualListSelectorListContext } from '@patternfly/react-core/dist/esm/components/DualListSelector'; + +export interface DraggableObject { + id?: string; + content?: React.ReactNode; +} + +export interface DraggableDualListSelectorListItemProps extends React.HTMLProps { + /** Content rendered inside DragDrop */ + children?: React.ReactNode; + /** Don't wrap the component in a div. Requires passing a single child. */ + hasNoWrapper?: boolean; + /** Class to add to outer div */ + className?: string; + /** */ + id?: string; + /** Flag indicating the list item is currently selected. */ + isSelected?: boolean; + /** Callback fired when an option is selected. */ + onOptionSelect?: (e: React.MouseEvent | React.ChangeEvent | React.KeyboardEvent, id?: string) => void; + /** @hide Internal field used to keep track of order of unfiltered options. */ + orderIndex?: number; + /** @hide Forwarded ref */ + innerRef?: React.RefObject; + /** Flag indicating if the dual list selector is in a disabled state */ + isDisabled?: boolean; +} + +export const DraggableDualListSelectorListItem: React.FunctionComponent = ({ + children, + id, + className, + orderIndex, + isSelected, + onOptionSelect, + ...props +}: DraggableDualListSelectorListItemProps) => { + const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ + id + }); + + const { setFocusedOption } = React.useContext(DualListSelectorListContext); + + const style = { + transform: CSS.Transform.toString(transform), + transition + }; + + return ( + + ); +}; +DraggableDualListSelectorListItem.displayName = 'DraggableDualListSelectorListItem'; diff --git a/packages/react-drag-drop/src/components/DragDrop/Droppable.tsx b/packages/react-drag-drop/src/components/DragDrop/Droppable.tsx new file mode 100644 index 00000000000..ccb05fe9e26 --- /dev/null +++ b/packages/react-drag-drop/src/components/DragDrop/Droppable.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { useDroppable } from '@dnd-kit/core'; + +interface DroppableProps extends React.HTMLProps { + /** Content rendered inside DragDrop */ + children?: React.ReactNode; + /** Class to add to outer div */ + className?: string; + /** Name of zone that items can be dragged between. Should specify if there is more than one Droppable on the page. */ + zone?: string; + /** Id to be passed back on drop events */ + droppableId?: string; + /** Don't wrap the component in a div. Requires passing a single child. */ + hasNoWrapper?: boolean; +} + +export const Droppable: React.FunctionComponent = ({ children, ...props }: DroppableProps) => { + const { isOver, setNodeRef } = useDroppable({ id: 'droppable' }); + const style = { color: isOver ? 'green' : undefined }; + + return ( +
    + {children} +
    + ); +}; +Droppable.displayName = 'Droppable'; diff --git a/packages/react-drag-drop/src/components/DragDrop/DroppableContext.ts b/packages/react-drag-drop/src/components/DragDrop/DroppableContext.ts new file mode 100644 index 00000000000..888cb4fc66f --- /dev/null +++ b/packages/react-drag-drop/src/components/DragDrop/DroppableContext.ts @@ -0,0 +1,6 @@ +import * as React from 'react'; + +export const DroppableContext = React.createContext({ + zone: 'defaultDroppableZone', + droppableId: 'defaultDroppableId' +}); diff --git a/packages/react-drag-drop/src/components/DragDrop/__tests__/DragDrop.test.tsx b/packages/react-drag-drop/src/components/DragDrop/__tests__/DragDrop.test.tsx new file mode 100644 index 00000000000..42b33a0f6e3 --- /dev/null +++ b/packages/react-drag-drop/src/components/DragDrop/__tests__/DragDrop.test.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { DragDropSort } from '../'; + +test('renders some divs', () => { + const { asFragment } = render( +
    + {}} + /> +
    + ); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/react-drag-drop/src/components/DragDrop/__tests__/__snapshots__/DragDrop.test.tsx.snap b/packages/react-drag-drop/src/components/DragDrop/__tests__/__snapshots__/DragDrop.test.tsx.snap new file mode 100644 index 00000000000..50bd3164230 --- /dev/null +++ b/packages/react-drag-drop/src/components/DragDrop/__tests__/__snapshots__/DragDrop.test.tsx.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders some divs 1`] = ` + +
    +
    +
    + one +
    +
    + two +
    +
    + three +
    +
    + +
    +
    + +`; diff --git a/packages/react-drag-drop/src/components/DragDrop/examples/BasicSorting.tsx b/packages/react-drag-drop/src/components/DragDrop/examples/BasicSorting.tsx new file mode 100644 index 00000000000..40021ef9f60 --- /dev/null +++ b/packages/react-drag-drop/src/components/DragDrop/examples/BasicSorting.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { DragDropSort, DraggableObject } from '@patternfly/react-drag-drop'; + +export const BasicSorting: React.FunctionComponent = () => { + const [items, setItems] = React.useState([ + { id: 'basic-1', content: 'one' }, + { id: 'basic-2', content: 'two' }, + { id: 'basic-3', content: 'three' } + ]); + + return ( + { + setItems(newItems); + }} + /> + ); +}; diff --git a/packages/react-drag-drop/src/components/DragDrop/examples/BasicSortingWithDragButton.tsx b/packages/react-drag-drop/src/components/DragDrop/examples/BasicSortingWithDragButton.tsx new file mode 100644 index 00000000000..0cea4a69962 --- /dev/null +++ b/packages/react-drag-drop/src/components/DragDrop/examples/BasicSortingWithDragButton.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { DragDropSort, DraggableObject } from '@patternfly/react-drag-drop'; + +export const BasicSortingWithDragButton: React.FunctionComponent = () => { + const [items, setItems] = React.useState([ + { id: 'with-button-1', content: 'one' }, + { id: 'with-button-2', content: 'two' }, + { id: 'with-button-3', content: 'three' } + ]); + + return ( + { + setItems(newItems); + }} + /> + ); +}; diff --git a/packages/react-drag-drop/src/components/DragDrop/examples/DataListDraggable.tsx b/packages/react-drag-drop/src/components/DragDrop/examples/DataListDraggable.tsx new file mode 100644 index 00000000000..48f3ffe8626 --- /dev/null +++ b/packages/react-drag-drop/src/components/DragDrop/examples/DataListDraggable.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { DataList, DataListCell, DataListCheck, DataListControl, DataListItemCells } from '@patternfly/react-core'; +import { DragDropSort, DraggableObject } from '@patternfly/react-drag-drop'; + +const getItems = (count: number): DraggableObject[] => + Array.from({ length: count }, (_, idx) => idx).map(idx => ({ + id: `data-list-item-${idx}`, + content: ( + <> + + + + + {`item-${idx}`} + + ]} + /> + + ) + })); + +export const DataListDraggable: React.FunctionComponent = props => { + const [items, setItems] = React.useState(getItems(10)); + + return ( + { + setItems(newItems); + }} + variant="DataList" + > + + + ); +}; diff --git a/packages/react-drag-drop/src/components/DragDrop/examples/DragDrop.md b/packages/react-drag-drop/src/components/DragDrop/examples/DragDrop.md new file mode 100644 index 00000000000..e2e1b120a65 --- /dev/null +++ b/packages/react-drag-drop/src/components/DragDrop/examples/DragDrop.md @@ -0,0 +1,58 @@ +--- +id: Drag and drop +section: components +cssPrefix: pf-c-drag-drop +propComponents: [ + DragDropSort, + DraggableObject +] +hideNavItem: true +beta: true +--- +Note: DragDrop lives in its own package at @patternfly/react-drag-drop!' + +import AngleDoubleLeftIcon from '@patternfly/react-icons/dist/esm/icons/angle-double-left-icon'; +import AngleLeftIcon from '@patternfly/react-icons/dist/esm/icons/angle-left-icon'; +import AngleDoubleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-double-right-icon'; +import AngleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-right-icon'; +import PficonSortCommonAscIcon from '@patternfly/react-icons/dist/esm/icons/pficon-sort-common-asc-icon'; + +import { DragDropSort, DraggableObject } from '@patternfly/react-drag-drop'; + +## Sorting examples +### Basic drag and drop sorting +```ts isBeta file="./BasicSorting.tsx" +``` + +### Basic drag and drop sorting with drag button + +```ts isBeta file="./BasicSortingWithDragButton.tsx" +``` + +### Drag and drop sortable data list + +Draggable data lists used to have their own HTML5-based API for drag and drop, which wasn't able to fulfill requirements such as custom styling on items being dragged. So we wrote generic `DragDrop`, `Draggable`, and `Droppable` components for this purpose. Use those new components instead of the deprecated (and buggy!) HTML5-based API. + +Note: Keyboard accessibility and screen reader accessibility for the `DragDrop` component are still in development. + +```ts isBeta file="./DataListDraggable.tsx" +``` + +### Drag and drop sortable dual list selector + +This example only allows reordering the contents of the "chosen" pane with drag and drop. To make a pane able to be reordered: + +- wrap the `DualListSelectorPane` in a `DragDropSort` component +- wrap the `DualListSelectorList` in a `Droppable` component +- wrap the `DualListSelectorListItem` components in a `Draggable` component +- define an `onDrop` callback which reorders the sortable options. + - The `onDrop` function provides the starting location and destination location for a dragged item. It should return + true to enable the 'drop' animation in the new location and false to enable the 'drop' animation back to the item's + old position. + - define an `onDrag` callback which ensures that the drag event will not cross hairs with the `onOptionSelect` click + event set on the option. Note: the `ignoreNextOptionSelect` state value is used to prevent selection while dragging. + +Note: Keyboard accessibility and screen reader accessibility for the `DragDropSort` component are still in development. + +```ts file="./DualListSelectorDraggable.tsx" +``` diff --git a/packages/react-drag-drop/src/components/DragDrop/examples/DualListSelectorDraggable.tsx b/packages/react-drag-drop/src/components/DragDrop/examples/DualListSelectorDraggable.tsx new file mode 100644 index 00000000000..52337adaedf --- /dev/null +++ b/packages/react-drag-drop/src/components/DragDrop/examples/DualListSelectorDraggable.tsx @@ -0,0 +1,169 @@ +import React from 'react'; +import { + DualListSelector, + DualListSelectorPane, + DualListSelectorList, + DualListSelectorListItem, + DualListSelectorControlsWrapper, + DualListSelectorControl +} from '@patternfly/react-core'; +import { DragDropSort, DragDropSortDragEndEvent, DraggableObject } from '@patternfly/react-drag-drop'; + +import AngleDoubleLeftIcon from '@patternfly/react-icons/dist/esm/icons/angle-double-left-icon'; +import AngleLeftIcon from '@patternfly/react-icons/dist/esm/icons/angle-left-icon'; +import AngleDoubleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-double-right-icon'; +import AngleRightIcon from '@patternfly/react-icons/dist/esm/icons/angle-right-icon'; + +export const ComposableDualListSelector: React.FunctionComponent = () => { + const [ignoreNextOptionSelect, setIgnoreNextOptionSelect] = React.useState(false); + const [availableOptions, setAvailableOptions] = React.useState([ + { text: 'Apple', selected: false, isVisible: true }, + { text: 'Banana', selected: false, isVisible: true }, + { text: 'Pineapple', selected: false, isVisible: true } + ]); + const [chosenOptions, setChosenOptions] = React.useState([ + { text: 'Orange', selected: false, isVisible: true }, + { text: 'Grape', selected: false, isVisible: true }, + { text: 'Peach', selected: false, isVisible: true }, + { text: 'Strawberry', selected: false, isVisible: true } + ]); + + const moveSelected = fromAvailable => { + const sourceOptions = fromAvailable ? availableOptions : chosenOptions; + const destinationOptions = fromAvailable ? chosenOptions : availableOptions; + for (let i = 0; i < sourceOptions.length; i++) { + const option = sourceOptions[i]; + if (option.selected && option.isVisible) { + sourceOptions.splice(i, 1); + destinationOptions.push(option); + option.selected = false; + i--; + } + } + if (fromAvailable) { + setAvailableOptions([...sourceOptions]); + setChosenOptions([...destinationOptions]); + } else { + setChosenOptions([...sourceOptions]); + setAvailableOptions([...destinationOptions]); + } + }; + + const moveAll = fromAvailable => { + if (fromAvailable) { + setChosenOptions([...availableOptions.filter(x => x.isVisible), ...chosenOptions]); + setAvailableOptions([...availableOptions.filter(x => !x.isVisible)]); + } else { + setAvailableOptions([...chosenOptions.filter(x => x.isVisible), ...availableOptions]); + setChosenOptions([...chosenOptions.filter(x => !x.isVisible)]); + } + }; + + const onOptionSelect = (event, index, isChosen) => { + if (ignoreNextOptionSelect) { + setIgnoreNextOptionSelect(false); + return; + } + if (isChosen) { + const newChosen = [...chosenOptions]; + newChosen[index].selected = !chosenOptions[index].selected; + setChosenOptions(newChosen); + } else { + const newAvailable = [...availableOptions]; + newAvailable[index].selected = !availableOptions[index].selected; + setAvailableOptions(newAvailable); + } + }; + + const onDrop = (event: DragDropSortDragEndEvent, newItems: DraggableObject[], oldIndex: number, newIndex: number) => { + const newList = [...chosenOptions]; + const [removed] = newList.splice(oldIndex, 1); + newList.splice(newIndex, 0, removed); + setChosenOptions(newList); + }; + + const sortableChosenOptions = chosenOptions.map((option, index) => + option.isVisible + ? { + id: `composable-available-option-${option.text}`, + content: option.text, + props: { + key: index, + isSelected: option.selected, + onOptionSelect: e => onOptionSelect(e, index, true) + } + } + : null + ); + + return ( + + x.selected && x.isVisible).length} of ${ + availableOptions.filter(x => x.isVisible).length + } options selected`} + > + + {availableOptions.map((option, index) => + option.isVisible ? ( + onOptionSelect(e, index, false)} + > + {option.text} + + ) : null + )} + + + + option.selected)} + onClick={() => moveSelected(true)} + aria-label="Add selected" + > + + + moveAll(true)} + aria-label="Add all" + > + + + moveAll(false)} + aria-label="Remove all" + > + + + moveSelected(false)} + isDisabled={!chosenOptions.some(option => option.selected)} + aria-label="Remove selected" + > + + + + x.selected && x.isVisible).length} of ${ + chosenOptions.filter(x => x.isVisible).length + } options selected`} + isChosen + > + + + + + + ); +}; diff --git a/packages/react-drag-drop/src/components/DragDrop/index.ts b/packages/react-drag-drop/src/components/DragDrop/index.ts new file mode 100644 index 00000000000..ddc081716b1 --- /dev/null +++ b/packages/react-drag-drop/src/components/DragDrop/index.ts @@ -0,0 +1 @@ +export * from './DragDropSort'; diff --git a/packages/react-drag-drop/src/components/index.ts b/packages/react-drag-drop/src/components/index.ts new file mode 100644 index 00000000000..99bbd1480e6 --- /dev/null +++ b/packages/react-drag-drop/src/components/index.ts @@ -0,0 +1 @@ +export * from './DragDrop'; diff --git a/packages/react-drag-drop/src/index.ts b/packages/react-drag-drop/src/index.ts new file mode 100644 index 00000000000..07635cbbc8e --- /dev/null +++ b/packages/react-drag-drop/src/index.ts @@ -0,0 +1 @@ +export * from './components'; diff --git a/packages/react-drag-drop/tsconfig.cjs.json b/packages/react-drag-drop/tsconfig.cjs.json new file mode 100644 index 00000000000..578d46af9d3 --- /dev/null +++ b/packages/react-drag-drop/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist/js", + "module": "commonjs", + "tsBuildInfoFile": "dist/cjs.tsbuildinfo" + } +} diff --git a/packages/react-drag-drop/tsconfig.json b/packages/react-drag-drop/tsconfig.json new file mode 100644 index 00000000000..f08aeb83272 --- /dev/null +++ b/packages/react-drag-drop/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist/esm", + "tsBuildInfoFile": "dist/esm.tsbuildinfo" + }, + "include": [ + "./src/*", + "./src/**/*" + ], + "references": [ + { + "path": "../react-core" + }, + { + "path": "../react-icons" + }, + { + "path": "../react-styles" + } + ] +} diff --git a/packages/tsconfig.cjs.json b/packages/tsconfig.cjs.json index 6a79555e886..607146bbcac 100644 --- a/packages/tsconfig.cjs.json +++ b/packages/tsconfig.cjs.json @@ -10,6 +10,9 @@ { "path": "./react-core/tsconfig.cjs.json" }, + { + "path": "./react-drag-drop/tsconfig.cjs.json" + }, { "path": "./react-icons/tsconfig.cjs.json" }, diff --git a/packages/tsconfig.json b/packages/tsconfig.json index 8e6c82bdb5f..b7ea8c38508 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -10,6 +10,9 @@ { "path": "./react-core" }, + { + "path": "./react-drag-drop" + }, { "path": "./react-icons" }, diff --git a/scripts/promote.sh b/scripts/promote.sh index 0464729f77b..26118a1e43d 100755 --- a/scripts/promote.sh +++ b/scripts/promote.sh @@ -4,6 +4,7 @@ packages=( @patternfly/react-charts @patternfly/react-code-editor @patternfly/react-core + @patternfly/react-drag-drop @patternfly/react-icons @patternfly/react-styles @patternfly/react-table diff --git a/yarn.lock b/yarn.lock index 5aba41d1155..8cf8ba9d931 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2913,6 +2913,45 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== +"@dnd-kit/accessibility@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz#3ccbefdfca595b0a23a5dc57d3de96bc6935641c" + integrity sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg== + dependencies: + tslib "^2.0.0" + +"@dnd-kit/core@^6.0.8": + version "6.0.8" + resolved "https://registry.yarnpkg.com/@dnd-kit/core/-/core-6.0.8.tgz#040ae13fea9787ee078e5f0361f3b49b07f3f005" + integrity sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA== + dependencies: + "@dnd-kit/accessibility" "^3.0.0" + "@dnd-kit/utilities" "^3.2.1" + tslib "^2.0.0" + +"@dnd-kit/modifiers@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz#9e39b25fd6e323659604cc74488fe044d33188c8" + integrity sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A== + dependencies: + "@dnd-kit/utilities" "^3.2.1" + tslib "^2.0.0" + +"@dnd-kit/sortable@^7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@dnd-kit/sortable/-/sortable-7.0.2.tgz#791d550872457f3f3c843e00d159b640f982011c" + integrity sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA== + dependencies: + "@dnd-kit/utilities" "^3.2.0" + tslib "^2.0.0" + +"@dnd-kit/utilities@^3.2.0", "@dnd-kit/utilities@^3.2.1": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@dnd-kit/utilities/-/utilities-3.2.1.tgz#53f9e2016fd2506ec49e404c289392cfff30332a" + integrity sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA== + dependencies: + tslib "^2.0.0" + "@eslint-community/eslint-utils@^4.2.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" @@ -13764,6 +13803,11 @@ memfs@^3.1.2, memfs@^3.4.3: dependencies: fs-monkey "^1.0.3" +memoize-one@^5.1.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + memory-fs@^0.4.1: version "0.4.1" resolved "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz" @@ -16778,6 +16822,11 @@ requires-port@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz" +resize-observer-polyfill@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz" @@ -18719,6 +18768,11 @@ tslib@^1.9.0: version "1.13.0" resolved "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz" +tslib@^2.0.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + tslib@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz"