diff --git a/docs/src/pages/docs/sortable-list.tsx b/docs/src/pages/docs/sortable-list.tsx new file mode 100644 index 000000000..a365d0309 --- /dev/null +++ b/docs/src/pages/docs/sortable-list.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import Docs from "@common/Docs"; +import { SortableList } from "@sebgroup/react-components/SortableList"; +import { useDynamicForm } from "@sebgroup/react-components/hooks/useDynamicForm"; +import { SortableItem } from "@sebgroup/react-components/SortableList/SortableItem"; +import { Checkbox } from "@sebgroup/react-components/Checkbox"; + +const importString: string = require("!raw-loader!@sebgroup/react-components/SortableList/SortableList"); +const code: string = ` + item 1 + item 2 + item 3 +`; + +type Example = { + label: string; + value: string; + checked: boolean; + disabled?: boolean; +}; + +const SortableListPage: React.FC = (): React.ReactElement => { + const [value, setValue] = React.useState(null); + const [array, setArray] = React.useState([ + { + label: "Name", + value: "1", + checked: false, + }, + { + label: "Age", + value: "2", + checked: false, + }, + { + label: "Company", + value: "3", + checked: false, + }, + { + label: "Address", + value: "4", + checked: false, + }, + ]); + + const [renderControls, { controls }] = useDynamicForm([ + { + key: "controls", + items: [ + { key: "disabled", label: "disabled", controlType: "Checkbox" }, + { key: "disabledItem", label: "disable one random item", controlType: "Checkbox" }, + { key: "simple", label: "simple usage", controlType: "Checkbox" }, + ], + }, + ]); + + React.useEffect(() => { + setValue(controls.disabledItem ? Math.floor(Math.random() * (array.length - 1 - 0 + 1)) + 0 : null); + }, [controls.disabledItem]); + + return ( + + setArray((oldArray: Example[]) => oldArray.sort((a: Example, b: Example) => list.indexOf(a.value) - list.indexOf(b.value)))} + > + {array.map((item: Example, index: number) => ( + + {controls.simple ? ( + item.label + ) : ( + ) => { + setArray((oldArray: Example[]) => + oldArray.map((checkbox: Example) => ({ + ...checkbox, + checked: item.value === checkbox.value ? event.target.checked : checkbox.checked, + })) + ); + }} + > + {item.label} + + )} + + ))} + + + } + code={code} + controls={<>{renderControls()}} + /> + ); +}; + +export default SortableListPage; diff --git a/docs/static/components-list.json b/docs/static/components-list.json index ea3a059a3..7bc08c19c 100644 --- a/docs/static/components-list.json +++ b/docs/static/components-list.json @@ -119,6 +119,11 @@ "path": "/docs/stepper/", "filePath": "./src/Stepper/index.ts" }, + { + "name": "SortableList", + "path": "/docs/sortable-list/", + "filePath": "./src/SortableList/index.ts" + }, { "name": "Table", "path": "/docs/table/", diff --git a/lib/jest.config.js b/lib/jest.config.js index a0966aa14..799c84d15 100644 --- a/lib/jest.config.js +++ b/lib/jest.config.js @@ -11,7 +11,7 @@ module.exports = { testPathIgnorePatterns: ["node_modules", "\\.cache", ".*/public"], transformIgnorePatterns: ["node_modules/(?!(@sebgroup|react|raf)/)"], collectCoverage: true, - coveragePathIgnorePatterns: ["node_modules", "index.ts", "^.+\\.mock"], + coveragePathIgnorePatterns: ["node_modules", "index.ts", "^.+\\.mock", "^.+\\.polyfills"], testEnvironmentOptions: { resources: "usable" }, globals: { __PATH_PREFIX__: "", diff --git a/lib/src/SortableList/SortableItem.test.tsx b/lib/src/SortableList/SortableItem.test.tsx new file mode 100644 index 000000000..e80a383b5 --- /dev/null +++ b/lib/src/SortableList/SortableItem.test.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { SortableItem, SortableItemProps } from "."; +import { unmountComponentAtNode, render } from "react-dom"; +import { act } from "react-dom/test-utils"; + +describe("Component: SortableItem", () => { + let container: HTMLDivElement = null; + const props: SortableItemProps = { uniqueKey: "1" }; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + unmountComponentAtNode(container); + container.remove(); + container = null; + }); + + it("Should render", () => { + act(() => { + render(, container); + }); + expect(container.querySelector(".sortable-item")).not.toBeNull(); + }); + + it("Should pass a custom class and id", () => { + const className: string = "mySortableItemClass"; + const id: string = "mySortableItemId"; + act(() => { + render(, container); + }); + expect(container.querySelector(`.${className}`)).not.toBeNull(); + expect(container.querySelector(`#${id}`)).not.toBeNull(); + }); + + it("Should set children to disabled if disabled prop is passed", () => { + act(() => { + render( + + + test + , + container + ); + }); + expect(container.querySelector(`input`).hasAttribute("disabled")).toBeTruthy(); + }); +}); diff --git a/lib/src/SortableList/SortableItem.tsx b/lib/src/SortableList/SortableItem.tsx new file mode 100644 index 000000000..a22d2c847 --- /dev/null +++ b/lib/src/SortableList/SortableItem.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import classnames from "classnames"; + +export type SortableItemProps = Omit & { + uniqueKey: string; + disabled?: boolean; +}; + +const SortableItem: React.FC = React.forwardRef( + ({ className, disabled, children, uniqueKey, ...props }: React.PropsWithChildren, ref: React.ForwardedRef) => { + return ( +
+ {React.Children.map(children, (Child: React.ReactElement) => { + return React.isValidElement>(Child) + ? React.cloneElement(Child, { + disabled, + "aria-disabled": disabled, + } as any) + : Child; + })} +
+ ); + } +); + +SortableItem.displayName = "SortableItem"; + +export { SortableItem }; diff --git a/lib/src/SortableList/SortableList.test.tsx b/lib/src/SortableList/SortableList.test.tsx new file mode 100644 index 000000000..0897cac17 --- /dev/null +++ b/lib/src/SortableList/SortableList.test.tsx @@ -0,0 +1,116 @@ +import React from "react"; +import { SortableList, SortableItem, SortableItemProps, SortableListProps } from "."; +import { unmountComponentAtNode, render } from "react-dom"; +import { act, Simulate } from "react-dom/test-utils"; + +describe("Component: SortableList", () => { + let container: HTMLDivElement = null; + const props: SortableListProps = { onSort: jest.fn() }; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + unmountComponentAtNode(container); + container.remove(); + container = null; + }); + + it("Should render", () => { + act(() => { + render( + + + , + container + ); + }); + expect(container.querySelector(".sortable-list")).not.toBeNull(); + }); + + it("Should pass a custom class and id", () => { + const className: string = "mySortableListClass"; + const id: string = "mySortableListId"; + act(() => { + render( + + + , + container + ); + }); + expect(container.querySelector(`.${className}`)).not.toBeNull(); + expect(container.querySelector(`#${id}`)).not.toBeNull(); + }); + + it("Should set children to disabled if disabled prop is passed", () => { + act(() => { + render( + + + + test + + , + container + ); + }); + expect(container.querySelector(`.disabled`)).not.toBeNull(); + expect(container.querySelector(`input`).hasAttribute("disabled")).toBeTruthy(); + }); + + it("Should allow to sort children by drag and drop", () => { + const children: { key: string; label: string }[] = [ + { key: "1", label: "1" }, + { key: "2", label: "2" }, + { key: "3", label: "3" }, + ]; + act(() => { + render( + + {children.map((item) => ( + + {item.label} + + ))} + , + container + ); + }); + const node: HTMLElement = container.querySelector(".sortable-item-wrapper"); + (node.getBoundingClientRect as any) = jest.fn(() => { + return { top: 50, bottom: 500, right: 400, left: 200, height: 100, width: 50 }; + }); + act(() => { + Simulate.mouseDown(container.querySelector(".drag-icon"), { target: node, pageX: 50, pageY: 50 }); + }); + act(() => { + Simulate.dragStart(container.querySelector(".drag-icon"), { dataTransfer: { setDragImage: jest.fn() } } as any); + }); + act(() => { + Simulate.dragOver(container.querySelectorAll(".sortable-item-wrapper")[1], { dataTransfer: {}, clientY: 50, target: node } as any); + }); + act(() => { + Simulate.transitionEnd(container.querySelectorAll(".sortable-item-wrapper")[1]); + }); + act(() => { + Simulate.dragEnd(container.querySelector(".drag-icon")); + }); + expect(props.onSort).toBeCalledWith(["2", "1", "3"]); + }); + + it("Should throw error if no sortable item passed", () => { + const spyConsole: jest.SpyInstance = jest.spyOn(console, "warn"); + act(() => { + render( + + test + , + container + ); + }); + expect(spyConsole).toBeCalled(); + }); +}); diff --git a/lib/src/SortableList/SortableList.tsx b/lib/src/SortableList/SortableList.tsx new file mode 100644 index 000000000..ee26ca37d --- /dev/null +++ b/lib/src/SortableList/SortableList.tsx @@ -0,0 +1,265 @@ +import React from "react"; +import classnames from "classnames"; +import { SortableItemProps } from "./SortableItem"; +import "./dragtouch.polyfills"; +import "./sortable-list.scss"; + +const dragAndDropIcon: JSX.Element = ( + + + + + + + + + + + + +); + +export type SortableListProps = JSX.IntrinsicElements["div"] & { + /** on sort callback */ + onSort: (list: Array) => void; + /** disable sorting */ + disabled?: boolean; +}; + +type PositionDelta = { + x: number; + y: number; +}; + +type OrderItem = Pick; + +/** The component allows for sorting list by drag and drop. */ +export const SortableList: React.FC = ({ onSort, className, disabled, ...props }: React.PropsWithChildren) => { + const dragContainerRef = React.useRef(); + const [defaultOrders, setDefaultOrders] = React.useState([]); + const [draggingOrders, setDraggingOrders] = React.useState(defaultOrders); + const [currentItemNode, setCurrentItemNode] = React.useState(null); + const [currentItemIndex, setCurrentItemIndex] = React.useState(null); + const [delta, setDelta] = React.useState({ x: 0, y: 0 }); + const [dragNode, setDragNode] = React.useState(null); + const [isDragging, setIsDragging] = React.useState(false); + const [isTranslating, setIsTranslating] = React.useState(false); + const [affectedIndex, setAffectedIndex] = React.useState(null); + const [affectedNode, setAffectedNode] = React.useState(null); + + /** + * when user clicks on sortable item, set initial delta and selected item + * @param event mouse event + * @param index selected index + */ + const onMouseDown = (event: React.MouseEvent | React.TouchEvent, index: number) => { + const itemParentNode: HTMLDivElement = (event.target as HTMLDivElement).closest(".sortable-item-wrapper"); + const position: React.MouseEvent = ((event as React.TouchEvent).touches ? (event as React.TouchEvent).touches[0] : event) as React.MouseEvent; + setDelta({ + x: position.pageX - itemParentNode.getBoundingClientRect().left, + y: position.pageY - itemParentNode.getBoundingClientRect().top, + }); + setCurrentItemIndex(index); + setCurrentItemNode(itemParentNode); + }; + + /** + * on drag initiated, set the ghost item + * @param event drag event + */ + const onDragStart = React.useCallback( + (event: React.DragEvent) => { + setIsDragging(() => { + const clonedNode: HTMLElement = currentItemNode.cloneNode(true) as HTMLElement; + clonedNode.classList.remove("is-active"); + clonedNode.classList.add("on-drag"); + clonedNode.style.width = `${currentItemNode.getBoundingClientRect().width}px`; + clonedNode.style.height = `${currentItemNode.getBoundingClientRect().height}px`; + document.body.appendChild(clonedNode); + event.dataTransfer.setDragImage(clonedNode, delta.x, delta.y); + event.dataTransfer.effectAllowed = "move"; + setDragNode(clonedNode); + return true; + }); + }, + [currentItemNode] + ); + + /** + * when the dragged item is dragged over another sortable item, check if the overlapped element fulfills criteria to be swapped + * @param event drag event + * @param index overlapped index + */ + const onDragOver = React.useCallback( + (event: React.DragEvent, index: number) => { + event.preventDefault(); // to prevent ghost image for reverting to original position + event.dataTransfer.dropEffect = "move"; + const activeNodeRect: DOMRect = currentItemNode.getBoundingClientRect(); + const itemParentNode: HTMLDivElement = (event.target as HTMLDivElement).closest(".sortable-item-wrapper"); + const nodeRect: DOMRect = itemParentNode.getBoundingClientRect(); + const ghostImagePositionTop: number = event.clientY - delta.y; + const ghostImagePositionBottom: number = ghostImagePositionTop + activeNodeRect.height; // get ghost image's actual position + const isAboveGhostImage: boolean = nodeRect.top <= ghostImagePositionTop; + let positionDifference: number = nodeRect.top - ghostImagePositionBottom; + if (isAboveGhostImage) { + positionDifference = ghostImagePositionTop - nodeRect.bottom; + } + const isHalfCoverage: boolean = Math.abs(positionDifference) / nodeRect.height >= 0.5; // only trigger swapping when overlapped coverage is more than 50% + const isOverlapped: boolean = isHalfCoverage && !isTranslating && itemParentNode.className.indexOf("on-drag") === -1; + itemParentNode.style.transform = null; + if (isOverlapped) { + // if the overlapped item fulfills criteria, initiate animation + setIsTranslating(true); + itemParentNode.style.transform = `translate3d(0, ${isAboveGhostImage ? "" : "-"}${itemParentNode.offsetHeight}px, 0)`; + setAffectedNode(itemParentNode); + setAffectedIndex(index); + } + }, + [currentItemNode, delta, setIsTranslating, setAffectedNode, setAffectedIndex] + ); + + /** sort dragging order on transition end */ + const onTransitionEnd = React.useCallback(() => { + if (affectedNode) { + setDraggingOrders((oldOrders: OrderItem[]) => { + const newList: OrderItem[] = oldOrders.slice(0); + affectedNode.style.transform = null; + let itemIndex: number = currentItemIndex; + const originalItem: OrderItem = newList.find(({ uniqueKey }: OrderItem, index: number) => { + itemIndex = index; + return uniqueKey === defaultOrders[currentItemIndex].uniqueKey; + }); + newList[itemIndex] = newList[affectedIndex]; // swap overlapped + newList[affectedIndex] = originalItem; + setAffectedIndex(null); + setAffectedNode(null); + setIsTranslating(false); + return newList; + }); + } + }, [affectedNode, affectedIndex, setDraggingOrders, setAffectedIndex, setAffectedNode, setIsTranslating]); + + /** + * on drag end, remove appended ghost image and style, fire on sort callback + * @param event drag event + */ + const onDragEnd = React.useCallback( + (event: React.DragEvent | React.TouchEvent | React.MouseEvent) => { + setIsDragging(() => { + event.preventDefault(); // to prevent ghost image for reverting to original position + event.stopPropagation(); + Array.from(dragContainerRef.current.children).forEach((element: HTMLElement) => { + element.style.transform = null; + }); + setCurrentItemIndex(null); + setCurrentItemNode(null); + onSort(draggingOrders.map(({ uniqueKey }: OrderItem) => uniqueKey)); + setDefaultOrders(draggingOrders); + dragNode?.remove(); + return false; + }); + }, + [dragContainerRef, draggingOrders, dragNode, setIsDragging, setCurrentItemIndex, setCurrentItemNode, onSort, setDefaultOrders] + ); + + React.useEffect(() => { + setDefaultOrders(() => { + const newOrderList: OrderItem[] = React.Children.toArray(props.children) + .filter((item: React.ReactChild) => React.isValidElement>(item) && (item?.type as any)?.displayName === "SortableItem") + .map(({ props }: React.ReactElement) => ({ uniqueKey: props.uniqueKey, disabled: props.disabled })); + if (newOrderList.length === 0) { + console.warn("Please pass at least one SortableItem element to make SortableList works"); + } + return newOrderList; + }); + }, [props.children]); + + React.useEffect(() => { + setDraggingOrders(defaultOrders); + }, [defaultOrders]); + + return ( +
+
+ {(currentItemIndex === null ? defaultOrders : draggingOrders).map((item: OrderItem, index) => ( + ) => onMouseDown(event, index)} + onTouchStart={(event: React.TouchEvent) => onMouseDown(event, index)} + onMouseUp={onDragEnd} + onTouchEnd={onDragEnd} + onDragStart={onDragStart} + onDragOver={isTranslating ? null : (event: React.DragEvent) => onDragOver(event, index)} + onDragEnd={onDragEnd} + onTransitionEnd={onTransitionEnd} + > + {React.Children.map(props.children, (Child: React.ReactElement) => { + const { uniqueKey }: SortableItemProps = Child.props; + const isDisabled: boolean = disabled || item.disabled; + return item.uniqueKey === uniqueKey && React.isValidElement>(Child) + ? React.cloneElement(Child, { + disabled: isDisabled, + "aria-disabled": isDisabled, + }) + : null; + })} + + ))} +
+
+ ); +}; + +type SortableItemWrapperProps = SortableItemProps & + JSX.IntrinsicElements["div"] & { + isActive?: boolean; + disabled?: boolean; + isDragging?: boolean; + }; + +const SortableItemWrapper: React.FC = ({ + isActive, + isDragging, + className, + disabled, + children, + uniqueKey, + onDragStart, + onDragOver, + onDragEnd, + onTransitionEnd, + onMouseDown, + onMouseUp, + onTouchEnd, + onTouchStart, + ...props +}: React.PropsWithChildren) => { + return ( +
event.preventDefault() : onDragOver} + > +
{children}
+
+ {dragAndDropIcon} +
+
+ ); +}; diff --git a/lib/src/SortableList/dragtouch.polyfills.ts b/lib/src/SortableList/dragtouch.polyfills.ts new file mode 100644 index 000000000..5e9dfa430 --- /dev/null +++ b/lib/src/SortableList/dragtouch.polyfills.ts @@ -0,0 +1,460 @@ +// tslint:disable +/** + * drag drop touch polyfill from https://github.com/Bernardo-Castilho/dragdroptouch/blob/master/DragDropTouch.js + * credits to: https://github.com/Bernardo-Castilho + */ +// This solution is meant to fix Gatsby build which complains that document doesn't exist in server-side rendering +const safeDocument: Document | null = typeof document !== "undefined" ? document : null; +const safeNavigator: Navigator | null = typeof navigator !== "undefined" ? navigator : null; +var DragDropTouch; +(function (DragDropTouch_1) { + "use strict"; + /** + * Object used to hold the data that is being dragged during drag and drop operations. + * + * It may hold one or more data items of different types. For more information about + * drag and drop operations and data transfer objects, see + * HTML Drag and Drop API. + * + * This object is created automatically by the @see:DragDropTouch singleton and is + * accessible through the @see:dataTransfer property of all drag events. + */ + var DataTransfer = (function () { + function DataTransfer() { + this._dropEffect = "move"; + this._effectAllowed = "all"; + this._data = {}; + } + Object.defineProperty(DataTransfer.prototype, "dropEffect", { + /** + * Gets or sets the type of drag-and-drop operation currently selected. + * The value must be 'none', 'copy', 'link', or 'move'. + */ + get: function () { + return this._dropEffect; + }, + set: function (value) { + this._dropEffect = value; + }, + enumerable: true, + configurable: true, + }); + Object.defineProperty(DataTransfer.prototype, "effectAllowed", { + /** + * Gets or sets the types of operations that are possible. + * Must be one of 'none', 'copy', 'copyLink', 'copyMove', 'link', + * 'linkMove', 'move', 'all' or 'uninitialized'. + */ + get: function () { + return this._effectAllowed; + }, + set: function (value) { + this._effectAllowed = value; + }, + enumerable: true, + configurable: true, + }); + Object.defineProperty(DataTransfer.prototype, "types", { + /** + * Gets an array of strings giving the formats that were set in the @see:dragstart event. + */ + get: function () { + return Object.keys(this._data); + }, + enumerable: true, + configurable: true, + }); + /** + * Removes the data associated with a given type. + * + * The type argument is optional. If the type is empty or not specified, the data + * associated with all types is removed. If data for the specified type does not exist, + * or the data transfer contains no data, this method will have no effect. + * + * @param type Type of data to remove. + */ + DataTransfer.prototype.clearData = function (type) { + if (type != null) { + delete this._data[type.toLowerCase()]; + } else { + this._data = {}; + } + }; + /** + * Retrieves the data for a given type, or an empty string if data for that type does + * not exist or the data transfer contains no data. + * + * @param type Type of data to retrieve. + */ + DataTransfer.prototype.getData = function (type) { + return this._data[type.toLowerCase()] || ""; + }; + /** + * Set the data for a given type. + * + * For a list of recommended drag types, please see + * https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Recommended_Drag_Types. + * + * @param type Type of data to add. + * @param value Data to add. + */ + DataTransfer.prototype.setData = function (type, value) { + this._data[type.toLowerCase()] = value; + }; + /** + * Set the image to be used for dragging if a custom one is desired. + * + * @param img An image element to use as the drag feedback image. + * @param offsetX The horizontal offset within the image. + * @param offsetY The vertical offset within the image. + */ + DataTransfer.prototype.setDragImage = function (img, offsetX, offsetY) { + var ddt = (DragDropTouch as any)._instance; + ddt._imgCustom = img; + ddt._imgOffset = { x: offsetX, y: offsetY }; + }; + return DataTransfer; + })(); + DragDropTouch_1.DataTransfer = DataTransfer; + /** + * Defines a class that adds support for touch-based HTML5 drag/drop operations. + * + * The @see:DragDropTouch class listens to touch events and raises the + * appropriate HTML5 drag/drop events as if the events had been caused + * by mouse actions. + * + * The purpose of this class is to enable using existing, standard HTML5 + * drag/drop code on mobile devices running IOS or Android. + * + * To use, include the DragDropTouch.js file on the page. The class will + * automatically start monitoring touch events and will raise the HTML5 + * drag drop events (dragstart, dragenter, dragleave, drop, dragend) which + * should be handled by the application. + * + * For details and examples on HTML drag and drop, see + * https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Drag_operations. + */ + const DragDropTouch = (function () { + /** + * Initializes the single instance of the @see:DragDropTouch class. + */ + function DragDropTouch() { + this._lastClick = 0; + // enforce singleton pattern + if ((DragDropTouch as any)._instance) { + throw "DragDropTouch instance already created."; + } + // detect passive event support + // https://github.com/Modernizr/Modernizr/issues/1894 + var supportsPassive = false; + safeDocument?.addEventListener("test", function () {}, { + get passive() { + supportsPassive = true; + return true; + }, + }); + // listen to touch events + if (safeNavigator?.maxTouchPoints) { + var d = safeDocument, + ts = this._touchstart.bind(this), + tm = this._touchmove.bind(this), + te = this._touchend.bind(this), + opt = supportsPassive ? { passive: false, capture: false } : false; + d.addEventListener("touchstart", ts, opt); + d.addEventListener("touchmove", tm, opt); + d.addEventListener("touchend", te); + d.addEventListener("touchcancel", te); + } + } + /** + * Gets a reference to the @see:DragDropTouch singleton. + */ + DragDropTouch.getInstance = function () { + return (DragDropTouch as any)._instance; + }; + // ** event handlers + DragDropTouch.prototype._touchstart = function (e) { + var _this = this; + if (this._shouldHandle(e)) { + // raise double-click and prevent zooming + if (Date.now() - this._lastClick < (DragDropTouch as any)._DBLCLICK) { + if (this._dispatchEvent(e, "dblclick", e.target)) { + e.preventDefault(); + this._reset(); + return; + } + } + // clear all variables + this._reset(); + // get nearest draggable element + var src = this._closestDraggable(e.target); + if (src) { + // give caller a chance to handle the hover/move events + if (!this._dispatchEvent(e, "mousemove", e.target) && !this._dispatchEvent(e, "mousedown", e.target)) { + // get ready to start dragging + this._dragSource = src; + this._ptDown = this._getPoint(e); + this._lastTouch = e; + e.preventDefault(); + // show context menu if the user hasn't started dragging after a while + setTimeout(function () { + if (_this._dragSource == src && _this._img == null) { + if (_this._dispatchEvent(e, "contextmenu", src)) { + _this._reset(); + } + } + }, (DragDropTouch as any)._CTXMENU); + if ((DragDropTouch as any)._ISPRESSHOLDMODE) { + this._pressHoldInterval = setTimeout(function () { + _this._isDragEnabled = true; + _this._touchmove(e); + }, (DragDropTouch as any)._PRESSHOLDAWAIT); + } + } + } + } + }; + DragDropTouch.prototype._touchmove = function (e) { + if (this._shouldCancelPressHoldMove(e)) { + this._reset(); + return; + } + if (this._shouldHandleMove(e) || this._shouldHandlePressHoldMove(e)) { + // see if target wants to handle move + var target = this._getTarget(e); + if (this._dispatchEvent(e, "mousemove", target)) { + this._lastTouch = e; + e.preventDefault(); + return; + } + // start dragging + if (this._dragSource && !this._img && this._shouldStartDragging(e)) { + this._dispatchEvent(e, "dragstart", this._dragSource); + this._createImage(e); + this._dispatchEvent(e, "dragenter", target); + } + // continue dragging + if (this._img) { + this._lastTouch = e; + e.preventDefault(); // prevent scrolling + this._dispatchEvent(e, "drag", this._dragSource); + if (target != this._lastTarget) { + this._dispatchEvent(this._lastTouch, "dragleave", this._lastTarget); + this._dispatchEvent(e, "dragenter", target); + this._lastTarget = target; + } + this._moveImage(e); + this._isDropZone = this._dispatchEvent(e, "dragover", target); + } + } + }; + DragDropTouch.prototype._touchend = function (e) { + if (this._shouldHandle(e)) { + // see if target wants to handle up + if (this._dispatchEvent(this._lastTouch, "mouseup", e.target)) { + e.preventDefault(); + return; + } + // user clicked the element but didn't drag, so clear the source and simulate a click + if (!this._img) { + this._dragSource = null; + this._dispatchEvent(this._lastTouch, "click", e.target); + this._lastClick = Date.now(); + } + // finish dragging + this._destroyImage(); + if (this._dragSource) { + if (e.type.indexOf("cancel") < 0 && this._isDropZone) { + this._dispatchEvent(this._lastTouch, "drop", this._lastTarget); + } + this._dispatchEvent(this._lastTouch, "dragend", this._dragSource); + this._reset(); + } + } + }; + // ** utilities + // ignore events that have been handled or that involve more than one touch + DragDropTouch.prototype._shouldHandle = function (e) { + return e && !e.defaultPrevented && e.touches && e.touches.length < 2; + }; + + // use regular condition outside of press & hold mode + DragDropTouch.prototype._shouldHandleMove = function (e) { + return !(DragDropTouch as any)._ISPRESSHOLDMODE && this._shouldHandle(e); + }; + + // allow to handle moves that involve many touches for press & hold + DragDropTouch.prototype._shouldHandlePressHoldMove = function (e) { + return (DragDropTouch as any)._ISPRESSHOLDMODE && this._isDragEnabled && e && e.touches && e.touches.length; + }; + + // reset data if user drags without pressing & holding + DragDropTouch.prototype._shouldCancelPressHoldMove = function (e) { + return (DragDropTouch as any)._ISPRESSHOLDMODE && !this._isDragEnabled && this._getDelta(e) > (DragDropTouch as any)._PRESSHOLDMARGIN; + }; + + // start dragging when specified delta is detected + DragDropTouch.prototype._shouldStartDragging = function (e) { + var delta = this._getDelta(e); + return delta > (DragDropTouch as any)._THRESHOLD || ((DragDropTouch as any)._ISPRESSHOLDMODE && delta >= (DragDropTouch as any)._PRESSHOLDTHRESHOLD); + }; + + // clear all members + DragDropTouch.prototype._reset = function () { + this._destroyImage(); + this._dragSource = null; + this._lastTouch = null; + this._lastTarget = null; + this._ptDown = null; + this._isDragEnabled = false; + this._isDropZone = false; + this._dataTransfer = new DataTransfer(); + clearInterval(this._pressHoldInterval); + }; + // get point for a touch event + DragDropTouch.prototype._getPoint = function (e, page) { + if (e && e.touches) { + e = e.touches[0]; + } + return { x: page ? e.pageX : e.clientX, y: page ? e.pageY : e.clientY }; + }; + // get distance between the current touch event and the first one + DragDropTouch.prototype._getDelta = function (e) { + if ((DragDropTouch as any)._ISPRESSHOLDMODE && !this._ptDown) { + return 0; + } + var p = this._getPoint(e); + return Math.abs(p.x - this._ptDown.x) + Math.abs(p.y - this._ptDown.y); + }; + // get the element at a given touch event + DragDropTouch.prototype._getTarget = function (e) { + var pt = this._getPoint(e), + el = safeDocument?.elementFromPoint(pt.x, pt.y); + while (el && getComputedStyle(el).pointerEvents == "none") { + el = el.parentElement; + } + return el; + }; + // create drag image from source element + DragDropTouch.prototype._createImage = function (e) { + // just in case... + if (this._img) { + this._destroyImage(); + } + // create drag image from custom element or drag source + var src = this._imgCustom || this._dragSource; + this._img = src.cloneNode(true); + this._copyStyle(src, this._img); + this._img.style.top = this._img.style.left = "-9999px"; + // if creating from drag source, apply offset and opacity + if (!this._imgCustom) { + var rc = src.getBoundingClientRect(), + pt = this._getPoint(e); + this._imgOffset = { x: pt.x - rc.left, y: pt.y - rc.top }; + this._img.style.opacity = (DragDropTouch as any)._OPACITY.toString(); + } + // add image to document + this._moveImage(e); + safeDocument?.body.appendChild(this._img); + }; + // dispose of drag image element + DragDropTouch.prototype._destroyImage = function () { + if (this._img && this._img.parentElement) { + this._img.parentElement.removeChild(this._img); + } + this._img = null; + this._imgCustom = null; + }; + // move the drag image element + DragDropTouch.prototype._moveImage = function (e) { + var _this = this; + requestAnimationFrame(function () { + if (_this._img) { + var pt = _this._getPoint(e, true), + s = _this._img.style; + s.position = "absolute"; + s.pointerEvents = "none"; + s.zIndex = "999999"; + s.left = Math.round(pt.x - _this._imgOffset.x) + "px"; + s.top = Math.round(pt.y - _this._imgOffset.y) + "px"; + } + }); + }; + // copy properties from an object to another + DragDropTouch.prototype._copyProps = function (dst, src, props) { + for (var i = 0; i < props.length; i++) { + var p = props[i]; + dst[p] = src[p]; + } + }; + DragDropTouch.prototype._copyStyle = function (src, dst) { + // remove potentially troublesome attributes + (DragDropTouch as any)._rmvAtts.forEach(function (att) { + dst.removeAttribute(att); + }); + // copy canvas content + if (src instanceof HTMLCanvasElement) { + var cSrc = src, + cDst = dst; + cDst.width = cSrc.width; + cDst.height = cSrc.height; + cDst.getContext("2d").drawImage(cSrc, 0, 0); + } + // copy style (without transitions) + var cs = getComputedStyle(src); + for (var i = 0; i < cs.length; i++) { + var key = cs[i]; + if (key.indexOf("transition") < 0) { + dst.style[key] = cs[key]; + } + } + dst.style.pointerEvents = "none"; + // and repeat for all children + for (var i = 0; i < src.children.length; i++) { + this._copyStyle(src.children[i], dst.children[i]); + } + }; + DragDropTouch.prototype._dispatchEvent = function (e, type, target) { + if (e && target) { + var evt = safeDocument?.createEvent("Event"), + t = e.touches ? e.touches[0] : e; + evt.initEvent(type, true, true); + (evt as any).button = 0; + (evt as any).which = (evt as any).buttons = 1; + this._copyProps(evt, e, (DragDropTouch as any)._kbdProps); + this._copyProps(evt, t, (DragDropTouch as any)._ptProps); + (evt as any).dataTransfer = this._dataTransfer; + target.dispatchEvent(evt); + return evt.defaultPrevented; + } + return false; + }; + // gets an element's closest draggable ancestor + DragDropTouch.prototype._closestDraggable = function (e) { + for (; e; e = e.parentElement) { + if (e.hasAttribute("draggable") && e.draggable) { + return e; + } + } + return null; + }; + return DragDropTouch; + })(); + /*private*/ (DragDropTouch as any)._instance = new DragDropTouch(); // singleton + // constants + (DragDropTouch as any)._THRESHOLD = 5; // pixels to move before drag starts + (DragDropTouch as any)._OPACITY = 0.5; // drag image opacity + (DragDropTouch as any)._DBLCLICK = 500; // max ms between clicks in a double click + (DragDropTouch as any)._CTXMENU = 900; // ms to hold before raising 'contextmenu' event + (DragDropTouch as any)._ISPRESSHOLDMODE = false; // decides of press & hold mode presence + (DragDropTouch as any)._PRESSHOLDAWAIT = 400; // ms to wait before press & hold is detected + (DragDropTouch as any)._PRESSHOLDMARGIN = 25; // pixels that finger might shiver while pressing + (DragDropTouch as any)._PRESSHOLDTHRESHOLD = 0; // pixels to move before drag starts + // copy styles/attributes from drag source to drag image element + (DragDropTouch as any)._rmvAtts = "id,class,style,draggable".split(","); + // synthesize and dispatch an event + // returns true if the event has been handled (e.preventDefault == true) + (DragDropTouch as any)._kbdProps = "altKey,ctrlKey,metaKey,shiftKey".split(","); + (DragDropTouch as any)._ptProps = "pageX,pageY,clientX,clientY,screenX,screenY,offsetX,offsetY".split(","); + DragDropTouch_1.DragDropTouch = DragDropTouch; +})(DragDropTouch || (DragDropTouch = {})); diff --git a/lib/src/SortableList/index.ts b/lib/src/SortableList/index.ts new file mode 100644 index 000000000..e0dabe0b3 --- /dev/null +++ b/lib/src/SortableList/index.ts @@ -0,0 +1,2 @@ +export * from "./SortableList"; +export * from "./SortableItem"; diff --git a/lib/src/SortableList/sortable-list.scss b/lib/src/SortableList/sortable-list.scss new file mode 100644 index 000000000..3f2608d0b --- /dev/null +++ b/lib/src/SortableList/sortable-list.scss @@ -0,0 +1,74 @@ +@import "~@sebgroup/bootstrap/scss/core"; + +.rc.sortable-list { + width: 100%; + .drop-container { + position: relative; + width: 100%; + > .sortable-item-wrapper { + width: 100%; + transition: transform 200ms; + transform: translate3d(0, 0, 0); + margin-bottom: 0.5rem; + &.is-active { + z-index: 0; + border: 1px dashed $gray-300; + background: transparent; + > .sort-item-content { + > * { + visibility: hidden; + } + } + > .drag-icon { + color: transparent; + } + } + &:hover { + border-color: $blue; + } + &:focus { + border-color: $blue-darker; + } + &.disabled { + background-color: $gray-200; + &:hover { + border-color: $gray-300; + } + .drag-icon { + &:hover { + cursor: initial; + } + } + } + } + } +} + +.rc.sortable-item-wrapper { + display: flex; + flex-direction: row; + align-items: center; + background-color: $white; + border-radius: 0.25rem; + border: 1px solid $gray-300; + padding: 0.25rem 0.75rem; + &.on-drag, + &:active { + cursor: grabbing; + border-color: $blue-darker; + } + .sort-item-content { + flex: 1; + } + .drag-icon { + flex: 0 0 2rem; + color: $gray-500; + text-align: center; + &:hover { + cursor: grab; + } + &:active { + cursor: grabbing; + } + } +} diff --git a/lib/src/index.json b/lib/src/index.json index 256567e48..cae700e98 100644 --- a/lib/src/index.json +++ b/lib/src/index.json @@ -22,6 +22,7 @@ "RadioButton": "./src/RadioButton/index.ts", "Rating": "./src/Rating/index.ts", "Slider": "./src/Slider/index.ts", + "SortableList": "./src/SortableList/index.ts", "StepTracker": "./src/StepTracker/index.ts", "Stepper": "./src/Stepper/index.ts", "Table": "./src/Table/index.ts", @@ -60,6 +61,7 @@ "./src/RadioButton/index.ts", "./src/Rating/index.ts", "./src/Slider/index.ts", + "./src/SortableList/index.ts", "./src/StepTracker/index.ts", "./src/Stepper/index.ts", "./src/Table/index.ts", diff --git a/lib/src/index.ts b/lib/src/index.ts index 5ebd342da..65e86d76a 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -22,6 +22,7 @@ export * from "./Rating/index"; export * from "./Slider/index"; export * from "./StepTracker/index"; export * from "./Stepper/index"; +export * from "./SortableList/index"; export * from "./Table/index"; export * from "./Tabs/index"; export * from "./TextLabel/index";