├── .eslintignore ├── .node-version ├── src ├── groups │ ├── index.ts │ └── context.ts ├── shared │ ├── items │ │ ├── identifiers.ts │ │ ├── directions.ts │ │ └── index.ts │ ├── nodes │ │ ├── elements.ts │ │ ├── index.ts │ │ ├── node-meta.ts │ │ └── node-meta.spec.ts │ ├── errors.ts │ ├── index.ts │ ├── drop-lines │ │ ├── index.ts │ │ ├── direcitons.ts │ │ ├── positions.ts │ │ ├── directions.spec.ts │ │ └── positions.spec.ts │ └── errors.spec.ts ├── list │ ├── index.ts │ ├── meta │ │ ├── destinations.ts │ │ ├── stacks.ts │ │ ├── index.ts │ │ ├── drags.ts │ │ └── renderers.ts │ └── context.ts ├── item │ ├── move-events.ts │ ├── index.ts │ ├── context.ts │ ├── renderers.ts │ ├── body.ts │ ├── ghosts.ts │ ├── drop-lines.ts │ ├── move-events.spec.ts │ ├── renderers.spec.ts │ ├── ghosts.spec.ts │ └── body.spec.ts ├── index.ts ├── drag-handle.component.tsx ├── list.component.tsx └── item.component.tsx ├── stories ├── shared │ ├── index.ts │ ├── common.css.d.ts │ └── common.css ├── 1-simple-horizontal │ ├── shared │ │ ├── index.ts │ │ ├── common.css.d.ts │ │ └── common.css │ ├── index.ts │ ├── static.component.tsx │ ├── dynamic.component.tsx │ └── dynamic-partial-locked.component.tsx ├── 1-simple-vertical │ ├── shared │ │ ├── index.ts │ │ ├── common.css.d.ts │ │ └── common.css │ ├── index.ts │ ├── static.component.tsx │ ├── dynamic.component.tsx │ └── dynamic-partial-locked.component.tsx ├── 2-nested-horizontal │ ├── shared │ │ ├── index.ts │ │ ├── common.css.d.ts │ │ └── common.css │ ├── index.ts │ ├── static.component.tsx │ ├── dynamic.component.tsx │ └── dynamic-partial-locked.component.tsx ├── 2-nested-vertical │ ├── shared │ │ ├── index.ts │ │ ├── common.css.d.ts │ │ └── common.css │ ├── index.ts │ ├── static.component.tsx │ ├── dynamic.component.tsx │ └── dynamic-partial-locked.component.tsx ├── 3-advanced-examples │ ├── custom-drag-handle.css.d.ts │ ├── index.ts │ ├── kanban.css.d.ts │ ├── tree.css.d.ts │ ├── layers-panel.css.d.ts │ ├── custom-drag-handle.css │ ├── kanban.css │ ├── non-styled.component.tsx │ ├── tree.css │ ├── layers-panel.css │ ├── custom-drag-handle.component.tsx │ ├── kanban.component.tsx │ ├── layers-panel.component.tsx │ └── tree.component.tsx ├── 1-simple-vertical.stories.tsx ├── 2-nested-vertical.stories.tsx ├── 1-simple-horizontal.stories.tsx ├── 2-nested-horizontal.stories.tsx └── 3-advanced-examples.stories.tsx ├── .docs ├── main.png ├── words.png └── stackable-area-threshold.png ├── .babelrc.js ├── .editorconfig ├── .github ├── PULL_REQUEST_TEMPLATE.md └── CONTRIBUTING.md ├── CHANGELOG.md ├── tsconfig.build.json ├── .storybook ├── preview-head.html ├── preview.js └── main.js ├── tsconfig.json ├── .vscode ├── typescriptreact.code-snippets ├── typescript-common.code-snippets └── settings.json ├── .gitignore ├── LICENSE ├── .eslintrc.json ├── .circleci └── config.yml ├── .prettierrc.yml ├── CODE_OF_CONDUCT.md ├── package.json └── jest.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | *.css.d.ts 2 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 10.15.3 2 | -------------------------------------------------------------------------------- /src/groups/index.ts: -------------------------------------------------------------------------------- 1 | export { Context as GroupContext } from "./context"; 2 | -------------------------------------------------------------------------------- /src/shared/items/identifiers.ts: -------------------------------------------------------------------------------- 1 | export type ItemIdentifier = number | string; 2 | -------------------------------------------------------------------------------- /stories/shared/index.ts: -------------------------------------------------------------------------------- 1 | export { default as commonStyles } from "./common.css"; 2 | -------------------------------------------------------------------------------- /.docs/main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jagaapple/react-sortful/HEAD/.docs/main.png -------------------------------------------------------------------------------- /src/shared/items/directions.ts: -------------------------------------------------------------------------------- 1 | export type Direction = "vertical" | "horizontal"; 2 | -------------------------------------------------------------------------------- /.docs/words.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jagaapple/react-sortful/HEAD/.docs/words.png -------------------------------------------------------------------------------- /stories/1-simple-horizontal/shared/index.ts: -------------------------------------------------------------------------------- 1 | export { default as styles } from "./common.css"; 2 | -------------------------------------------------------------------------------- /stories/1-simple-vertical/shared/index.ts: -------------------------------------------------------------------------------- 1 | export { default as styles } from "./common.css"; 2 | -------------------------------------------------------------------------------- /stories/2-nested-horizontal/shared/index.ts: -------------------------------------------------------------------------------- 1 | export { default as styles } from "./common.css"; 2 | -------------------------------------------------------------------------------- /stories/2-nested-vertical/shared/index.ts: -------------------------------------------------------------------------------- 1 | export { default as styles } from "./common.css"; 2 | -------------------------------------------------------------------------------- /src/list/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./meta"; 2 | export { Context as ListContext } from "./context"; 3 | -------------------------------------------------------------------------------- /src/shared/nodes/elements.ts: -------------------------------------------------------------------------------- 1 | export type ElementPosition = { 2 | top: number; 3 | left: number; 4 | }; 5 | -------------------------------------------------------------------------------- /src/shared/items/index.ts: -------------------------------------------------------------------------------- 1 | export { ItemIdentifier } from "./identifiers"; 2 | export { Direction } from "./directions"; 3 | -------------------------------------------------------------------------------- /.docs/stackable-area-threshold.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jagaapple/react-sortful/HEAD/.docs/stackable-area-threshold.png -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@babel/preset-env", "@babel/preset-typescript", "@babel/preset-react"], 3 | }; 4 | -------------------------------------------------------------------------------- /src/shared/nodes/index.ts: -------------------------------------------------------------------------------- 1 | export { ElementPosition } from "./elements"; 2 | export { NodeMeta, getNodeMeta } from "./node-meta"; 3 | -------------------------------------------------------------------------------- /src/shared/errors.ts: -------------------------------------------------------------------------------- 1 | const prefix = "[react-sortful] "; 2 | export const createNewError = (message: string) => new Error(prefix + message); 3 | -------------------------------------------------------------------------------- /src/shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./drop-lines"; 2 | export * from "./items"; 3 | export * from "./nodes"; 4 | export { createNewError } from "./errors"; 5 | -------------------------------------------------------------------------------- /stories/shared/common.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "dropLine": string; 3 | readonly "horizontal": string; 4 | }; 5 | export = styles; 6 | 7 | -------------------------------------------------------------------------------- /src/shared/drop-lines/index.ts: -------------------------------------------------------------------------------- 1 | export { getDropLineDirectionFromXY, DropLineDirection } from "./direcitons"; 2 | export { getDropLinePosition, checkIsInStackableArea } from "./positions"; 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /src/list/meta/destinations.ts: -------------------------------------------------------------------------------- 1 | import { ItemIdentifier } from "../../shared"; 2 | 3 | export type DestinationMeta = { 4 | groupIdentifier: T | undefined; 5 | index: number | undefined; 6 | }; 7 | -------------------------------------------------------------------------------- /stories/1-simple-horizontal/index.ts: -------------------------------------------------------------------------------- 1 | export { DynamicPartialLockedComponent } from "./dynamic-partial-locked.component"; 2 | export { DynamicComponent } from "./dynamic.component"; 3 | export { StaticComponent } from "./static.component"; 4 | -------------------------------------------------------------------------------- /stories/1-simple-vertical/index.ts: -------------------------------------------------------------------------------- 1 | export { DynamicPartialLockedComponent } from "./dynamic-partial-locked.component"; 2 | export { DynamicComponent } from "./dynamic.component"; 3 | export { StaticComponent } from "./static.component"; 4 | -------------------------------------------------------------------------------- /stories/2-nested-horizontal/index.ts: -------------------------------------------------------------------------------- 1 | export { DynamicPartialLockedComponent } from "./dynamic-partial-locked.component"; 2 | export { DynamicComponent } from "./dynamic.component"; 3 | export { StaticComponent } from "./static.component"; 4 | -------------------------------------------------------------------------------- /stories/2-nested-vertical/index.ts: -------------------------------------------------------------------------------- 1 | export { DynamicPartialLockedComponent } from "./dynamic-partial-locked.component"; 2 | export { DynamicComponent } from "./dynamic.component"; 3 | export { StaticComponent } from "./static.component"; 4 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # New Features 2 | - Add ... 3 | - Implement to ... 4 | - Enable ... 5 | 6 | 7 | # Changes and Fixes 8 | - Change ... 9 | - Fix ... 10 | - Modify ... 11 | 12 | 13 | # Refactors 14 | - Clean ... 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | ## 0.1.1 (2020-03-21) 3 | - Fix priority of dragging cursor style #16 [@jagaapple](https://github.com/jagaapple) 4 | 5 | ## 0.1.0 (2020-03-21) 6 | - Initial public release - [@jagaapple](https://github.com/jagaapple) 7 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | }, 6 | "include": [ 7 | "./src/**/*" 8 | ], 9 | "exclude": [ 10 | "node_modules", 11 | "./src/**/*.spec.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/list/meta/stacks.ts: -------------------------------------------------------------------------------- 1 | import { ItemIdentifier, NodeMeta } from "../../shared"; 2 | 3 | export type StackGroupMeta = Pick< 4 | NodeMeta, 5 | "identifier" | "groupIdentifier" | "index" | "isGroup" 6 | > & { 7 | nextGroupIdentifier: T | undefined; 8 | }; 9 | -------------------------------------------------------------------------------- /stories/3-advanced-examples/custom-drag-handle.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "wrapper": string; 3 | readonly "dropLine": string; 4 | readonly "dragHandle": string; 5 | readonly "item": string; 6 | readonly "placeholder": string; 7 | readonly "ghost": string; 8 | }; 9 | export = styles; 10 | 11 | -------------------------------------------------------------------------------- /stories/3-advanced-examples/index.ts: -------------------------------------------------------------------------------- 1 | export { CustomDragHandleComponent } from "./custom-drag-handle.component"; 2 | export { TreeComponent } from "./tree.component"; 3 | export { NonStyledComponent } from "./non-styled.component"; 4 | export { KanbanComponent } from "./kanban.component"; 5 | export { LayersPanelComponent } from "./layers-panel.component"; 6 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | -------------------------------------------------------------------------------- /stories/1-simple-horizontal/shared/common.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "wrapper": string; 3 | readonly "item": string; 4 | readonly "placeholder": string; 5 | readonly "disabled": string; 6 | readonly "locked": string; 7 | readonly "ghost": string; 8 | readonly "static": string; 9 | readonly "ghostItemVisible": string; 10 | }; 11 | export = styles; 12 | 13 | -------------------------------------------------------------------------------- /stories/1-simple-vertical/shared/common.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "wrapper": string; 3 | readonly "item": string; 4 | readonly "placeholder": string; 5 | readonly "disabled": string; 6 | readonly "locked": string; 7 | readonly "ghost": string; 8 | readonly "static": string; 9 | readonly "ghostItemVisible": string; 10 | }; 11 | export = styles; 12 | 13 | -------------------------------------------------------------------------------- /src/item/move-events.ts: -------------------------------------------------------------------------------- 1 | import { ItemIdentifier } from "../shared"; 2 | 3 | export const checkIsAncestorItem = (targetItemIdentifier: ItemIdentifier, ancestorIdentifiersOfChild: ItemIdentifier[]) => { 4 | const ancestorIdentifiersWithoutTarget = [...ancestorIdentifiersOfChild]; 5 | ancestorIdentifiersWithoutTarget.pop(); 6 | 7 | return ancestorIdentifiersWithoutTarget.includes(targetItemIdentifier); 8 | }; 9 | -------------------------------------------------------------------------------- /stories/3-advanced-examples/kanban.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "wrapper": string; 3 | readonly "dropLine": string; 4 | readonly "group": string; 5 | readonly "stacked": string; 6 | readonly "heading": string; 7 | readonly "items": string; 8 | readonly "item": string; 9 | readonly "placeholder": string; 10 | readonly "ghost": string; 11 | }; 12 | export = styles; 13 | 14 | -------------------------------------------------------------------------------- /src/list/meta/index.ts: -------------------------------------------------------------------------------- 1 | export { DestinationMeta } from "./destinations"; 2 | export { DragStartMeta, DragEndMeta } from "./drags"; 3 | export { 4 | GhostRendererMeta, 5 | DropLineRendererInjectedProps, 6 | PlaceholderRendererInjectedProps, 7 | PlaceholderRendererMeta, 8 | StackedGroupRendererInjectedProps, 9 | StackedGroupRendererMeta, 10 | } from "./renderers"; 11 | export { StackGroupMeta } from "./stacks"; 12 | -------------------------------------------------------------------------------- /src/groups/context.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { ItemIdentifier } from "../shared"; 4 | 5 | export const Context = React.createContext<{ 6 | identifier: ItemIdentifier | undefined; 7 | ancestorIdentifiers: ItemIdentifier[]; 8 | childIdentifiersRef: React.MutableRefObject>; 9 | }>({ identifier: undefined, ancestorIdentifiers: [], childIdentifiersRef: { current: new Set() } }); 10 | -------------------------------------------------------------------------------- /src/item/index.ts: -------------------------------------------------------------------------------- 1 | export { setBodyStyle, clearBodyStyle } from "./body"; 2 | export { Context as ItemContext } from "./context"; 3 | export { setDropLineElementStyle, getDropLinePositionItemIndex } from "./drop-lines"; 4 | export { initializeGhostElementStyle, moveGhostElement, clearGhostElementStyle } from "./ghosts"; 5 | export { checkIsAncestorItem } from "./move-events"; 6 | export { getPlaceholderElementStyle, getStackedGroupElementStyle } from "./renderers"; 7 | -------------------------------------------------------------------------------- /src/list/meta/drags.ts: -------------------------------------------------------------------------------- 1 | import { ItemIdentifier, NodeMeta } from "../../shared"; 2 | 3 | export type DragStartMeta = Pick, "identifier" | "groupIdentifier" | "index" | "isGroup">; 4 | export type DragEndMeta = Pick< 5 | NodeMeta, 6 | "identifier" | "groupIdentifier" | "index" | "isGroup" 7 | > & { 8 | nextGroupIdentifier: T | undefined; 9 | nextIndex: number | undefined; 10 | }; 11 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | DragEndMeta, 3 | DragStartMeta, 4 | GhostRendererMeta, 5 | PlaceholderRendererInjectedProps, 6 | PlaceholderRendererMeta, 7 | StackedGroupRendererInjectedProps, 8 | StackedGroupRendererMeta, 9 | StackGroupMeta, 10 | DropLineRendererInjectedProps, 11 | } from "./list"; 12 | export { List } from "./list.component"; 13 | export { Item } from "./item.component"; 14 | export { DragHandleComponent } from "./drag-handle.component"; 15 | -------------------------------------------------------------------------------- /src/item/context.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export const Context = React.createContext<{ 4 | isLocked: boolean; 5 | dragHandlers: { 6 | onDragStart: () => void; 7 | onDrag: (isDown: boolean, absoluteXY: [number, number]) => void; 8 | onDragEnd: () => void; 9 | }; 10 | }>({ 11 | isLocked: false, 12 | dragHandlers: { 13 | onDragStart: () => false, 14 | onDrag: () => false, 15 | onDragEnd: () => false, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /stories/2-nested-horizontal/shared/common.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "wrapper": string; 3 | readonly "group": string; 4 | readonly "stacked": string; 5 | readonly "placeholder": string; 6 | readonly "ghost": string; 7 | readonly "heading": string; 8 | readonly "disabled": string; 9 | readonly "locked": string; 10 | readonly "item": string; 11 | readonly "static": string; 12 | readonly "ghostItemVisible": string; 13 | }; 14 | export = styles; 15 | 16 | -------------------------------------------------------------------------------- /stories/2-nested-vertical/shared/common.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "wrapper": string; 3 | readonly "group": string; 4 | readonly "stacked": string; 5 | readonly "placeholder": string; 6 | readonly "ghost": string; 7 | readonly "heading": string; 8 | readonly "disabled": string; 9 | readonly "locked": string; 10 | readonly "item": string; 11 | readonly "static": string; 12 | readonly "ghostItemVisible": string; 13 | }; 14 | export = styles; 15 | 16 | -------------------------------------------------------------------------------- /stories/3-advanced-examples/tree.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "wrapper": string; 3 | readonly "withIcon": string; 4 | readonly "group": string; 5 | readonly "placeholder": string; 6 | readonly "stacked": string; 7 | readonly "heading": string; 8 | readonly "opened": string; 9 | readonly "locked": string; 10 | readonly "item": string; 11 | readonly "disabled": string; 12 | readonly "ghost": string; 13 | readonly "ghostItemVisible": string; 14 | }; 15 | export = styles; 16 | 17 | -------------------------------------------------------------------------------- /stories/1-simple-vertical.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | 4 | import { DynamicComponent, DynamicPartialLockedComponent, StaticComponent } from "./1-simple-vertical"; 5 | 6 | storiesOf("1 Simple (vertical)", module) 7 | .add("Static", () => ) 8 | .add("Dynamic", () => ) 9 | .add("Dynamic (disabled)", () => ) 10 | .add("Dynamic (partial locked)", () => ); 11 | -------------------------------------------------------------------------------- /stories/2-nested-vertical.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | 4 | import { DynamicComponent, DynamicPartialLockedComponent, StaticComponent } from "./2-nested-vertical"; 5 | 6 | storiesOf("2 Nested (vertical)", module) 7 | .add("Static", () => ) 8 | .add("Dynamic", () => ) 9 | .add("Dynamic (disabled)", () => ) 10 | .add("Dynamic (partial locked)", () => ); 11 | -------------------------------------------------------------------------------- /stories/1-simple-horizontal.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | 4 | import { DynamicComponent, DynamicPartialLockedComponent, StaticComponent } from "./1-simple-horizontal"; 5 | 6 | storiesOf("1 Simple (horizontal)", module) 7 | .add("Static", () => ) 8 | .add("Dynamic", () => ) 9 | .add("Dynamic (disabled)", () => ) 10 | .add("Dynamic (partial locked)", () => ); 11 | -------------------------------------------------------------------------------- /stories/2-nested-horizontal.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | 4 | import { DynamicComponent, DynamicPartialLockedComponent, StaticComponent } from "./2-nested-horizontal"; 5 | 6 | storiesOf("2 Nested (horizontal)", module) 7 | .add("Static", () => ) 8 | .add("Dynamic", () => ) 9 | .add("Dynamic (disabled)", () => ) 10 | .add("Dynamic (partial locked)", () => ); 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "allowJs": false, 5 | "baseUrl": "./", 6 | "declaration": true, 7 | "esModuleInterop": true, 8 | "jsx": "react", 9 | "lib": [ 10 | "es2020", 11 | "dom" 12 | ], 13 | "module": "commonjs", 14 | "moduleResolution": "node", 15 | "outDir": "./lib", 16 | "rootDir": "./", 17 | "removeComments": false, 18 | "sourceMap": false, 19 | "skipLibCheck": true, 20 | "strict": true, 21 | "target": "es2015" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | const React = require("react"); 2 | const storybook = require("@storybook/react"); 3 | 4 | // Configures storybook-addon-backgrounds. 5 | storybook.addParameters({ 6 | backgrounds: [ 7 | { name: "default", value: "#f6f9fc", default: true }, 8 | { name: "white", value: "#ffffff" }, 9 | { name: "black", value: "#222222" }, 10 | ], 11 | }); 12 | 13 | // Centering 14 | storybook.addDecorator((story) => ( 15 |
16 |
{story()}
17 |
18 | )); 19 | -------------------------------------------------------------------------------- /src/shared/errors.spec.ts: -------------------------------------------------------------------------------- 1 | import { createNewError } from "./errors"; 2 | 3 | describe("createNewError", () => { 4 | it("should return an object instance of Error", () => { 5 | expect(createNewError("dummy") instanceof Error).toBe(true); 6 | }); 7 | 8 | it("should set a error message to constructor's string", () => { 9 | // eslint-disable-next-line @typescript-eslint/no-var-requires 10 | const { name } = require("../../package.json"); 11 | const errorMessage = "dummy"; 12 | 13 | expect(createNewError(errorMessage).message).toBe(`[${name}] ${errorMessage}`); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /stories/3-advanced-examples/layers-panel.css.d.ts: -------------------------------------------------------------------------------- 1 | declare const styles: { 2 | readonly "wrapper": string; 3 | readonly "navigation": string; 4 | readonly "title": string; 5 | readonly "dropLine": string; 6 | readonly "list": string; 7 | readonly "item": string; 8 | readonly "preview": string; 9 | readonly "contents": string; 10 | readonly "ghost": string; 11 | readonly "group": string; 12 | readonly "icon": string; 13 | readonly "arrow": string; 14 | readonly "folder": string; 15 | readonly "stacked": string; 16 | readonly "placeholder": string; 17 | }; 18 | export = styles; 19 | 20 | -------------------------------------------------------------------------------- /stories/3-advanced-examples.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | 4 | import { 5 | CustomDragHandleComponent, 6 | KanbanComponent, 7 | LayersPanelComponent, 8 | NonStyledComponent, 9 | TreeComponent, 10 | } from "./3-advanced-examples"; 11 | 12 | storiesOf("3 Advanced Examples", module) 13 | .add("Non styled", () => ) 14 | .add("Custom drag handle", () => ) 15 | .add("Tree", () => ) 16 | .add("Kanban", () => ) 17 | .add("Layers panel", () => ); 18 | -------------------------------------------------------------------------------- /.vscode/typescriptreact.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Component stories for Storybook": { 3 | "prefix": "component stories for storybook", 4 | "scope": "typescriptreact", 5 | "body": [ 6 | "storiesOf(\"${TM_DIRECTORY/(.*?stories\\/)\\/?(\\w+)(\\-)?/${1:? :\/}${2:/capitalize}/g}${TM_FILENAME/(^\\w+)?(\\-)?(\\w+)?(\\..+$)?/${1:/capitalize}${2:+ }${3:/capitalize}/g}\", module)", 7 | " .add(\"${1:Default}\", () => $0)" 8 | ] 9 | }, 10 | "Import styles from CSS": { 11 | "prefix": "import styles", 12 | "scope": "typescript,typescriptreact", 13 | "body": ["import styles from \"./${TM_FILENAME/(\\.stories\\.tsx)$//}.stories.css\";"] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/item/renderers.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { Direction, ItemIdentifier, NodeMeta } from "../shared"; 4 | 5 | export const getPlaceholderElementStyle = ( 6 | draggingNodeMeta: NodeMeta | undefined, 7 | itemSpacing: number, 8 | direction: Direction, 9 | ): React.CSSProperties => { 10 | if (draggingNodeMeta == undefined) return {}; 11 | 12 | const { width, height } = draggingNodeMeta; 13 | 14 | return { 15 | width: direction === "horizontal" ? width - itemSpacing : width, 16 | height: direction === "vertical" ? height - itemSpacing : height, 17 | }; 18 | }; 19 | 20 | export const getStackedGroupElementStyle = getPlaceholderElementStyle; 21 | -------------------------------------------------------------------------------- /src/list/meta/renderers.ts: -------------------------------------------------------------------------------- 1 | import { ItemIdentifier, NodeMeta } from "../../shared"; 2 | 3 | export type GhostRendererMeta = Pick< 4 | NodeMeta, 5 | "identifier" | "groupIdentifier" | "index" | "isGroup" 6 | >; 7 | export type DropLineRendererInjectedProps = { 8 | ref: React.RefObject; 9 | style: React.CSSProperties; 10 | }; 11 | export type PlaceholderRendererInjectedProps = { style: React.CSSProperties }; 12 | export type PlaceholderRendererMeta = Pick< 13 | NodeMeta, 14 | "identifier" | "groupIdentifier" | "index" | "isGroup" 15 | >; 16 | export type StackedGroupRendererInjectedProps = { style: React.CSSProperties }; 17 | export type StackedGroupRendererMeta = Pick, "identifier" | "groupIdentifier" | "index">; 18 | -------------------------------------------------------------------------------- /stories/shared/common.css: -------------------------------------------------------------------------------- 1 | .dropLine { 2 | height: 2px; 3 | background-color: #007bff; 4 | } 5 | .dropLine.horizontal { 6 | width: 2px; 7 | height: auto; 8 | } 9 | .dropLine::before, 10 | .dropLine::after { 11 | --size: 6px; 12 | 13 | position: absolute; 14 | width: var(--size); 15 | height: var(--size); 16 | border-radius: 50%; 17 | background-color: inherit; 18 | content: ""; 19 | } 20 | .dropLine::before { 21 | top: calc(var(--size) / 2 * -1 + 1px); 22 | left: calc(var(--size) / 2 * -1); 23 | } 24 | .dropLine::after { 25 | top: calc(var(--size) / 2 * -1 + 1px); 26 | right: calc(var(--size) / 2 * -1); 27 | } 28 | .dropLine.horizontal::before { 29 | top: calc(var(--size) / 2 * -1); 30 | left: calc(var(--size) / 2 * -1 + 1px); 31 | } 32 | .dropLine.horizontal::after { 33 | top: auto; 34 | bottom: calc(var(--size) / 2 * -1); 35 | left: calc(var(--size) / 2 * -1 + 1px); 36 | } 37 | -------------------------------------------------------------------------------- /stories/3-advanced-examples/custom-drag-handle.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | width: 128px; 3 | } 4 | 5 | .dropLine { 6 | height: 3px; 7 | background-color: #7a4a8d; 8 | } 9 | 10 | .dragHandle { 11 | --size: 24px; 12 | 13 | margin-right: 4px; 14 | width: var(--size); 15 | height: var(--size); 16 | border-radius: 3px; 17 | cursor: grab; 18 | } 19 | .dragHandle svg { 20 | margin: 4px; 21 | height: 16px; 22 | fill: black; 23 | } 24 | 25 | .item { 26 | display: flex; 27 | justify-content: flex-start; 28 | align-items: center; 29 | padding: 10px 8px; 30 | color: #7a4a8d; 31 | font-weight: bold; 32 | border-radius: 3px; 33 | background-color: #eee5f4; 34 | } 35 | .item.placeholder { 36 | box-shadow: 0 0 0 3px #eee5f4 inset; 37 | color: #eee5f4; 38 | background-color: transparent; 39 | } 40 | .item.placeholder .dragHandle { 41 | visibility: hidden; 42 | } 43 | 44 | .ghost { 45 | width: 100%; 46 | height: 100%; 47 | transform: scale(0.8); 48 | transform-origin: 32px center; 49 | } 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------------------------------------------------------ 2 | # Temp Files 3 | # ------------------------------------------------------------------------------------------------------------------------------ 4 | # Generated temporary files by OS or editors. 5 | *~ 6 | *# 7 | *.bak 8 | *.tmproj 9 | .buildpath 10 | .project 11 | .settings 12 | .idea 13 | .tmp 14 | .netbeans 15 | nbproject 16 | 17 | 18 | # ------------------------------------------------------------------------------------------------------------------------------ 19 | # Dynamicaly Generated Files 20 | # ------------------------------------------------------------------------------------------------------------------------------ 21 | # Hidden files created by OS. 22 | Thumbs.db 23 | desktop.ini 24 | .DS_Store 25 | .DS_STORE 26 | 27 | # Generated files created by Node.js. 28 | node_modules 29 | npm-debug.log 30 | 31 | # Generated files created by bundler. 32 | lib 33 | 34 | # Generated files created by tests. 35 | coverage 36 | .out 37 | -------------------------------------------------------------------------------- /src/shared/drop-lines/direcitons.ts: -------------------------------------------------------------------------------- 1 | import { Direction, ItemIdentifier } from "../items"; 2 | import { NodeMeta } from "../nodes"; 3 | 4 | export type DropLineDirection = "TOP" | "RIGHT" | "BOTTOM" | "LEFT"; 5 | 6 | export const getDropLineDirection = ( 7 | nodeWidth: number, 8 | nodeHeight: number, 9 | pointerXY: [number, number], 10 | direction: Direction, 11 | ): DropLineDirection | undefined => { 12 | const [pointerX, pointerY] = pointerXY; 13 | 14 | if (direction === "vertical") return nodeHeight / 2 >= pointerY ? "TOP" : "BOTTOM"; 15 | if (direction === "horizontal") return nodeWidth / 2 >= pointerX ? "LEFT" : "RIGHT"; 16 | }; 17 | 18 | export const getDropLineDirectionFromXY = ( 19 | absoluteXY: [number, number], 20 | nodeMeta: NodeMeta, 21 | direction: Direction, 22 | ) => { 23 | const x = Math.max(absoluteXY[0] - nodeMeta.absolutePosition.left, 0); 24 | const y = Math.max(absoluteXY[1] - nodeMeta.absolutePosition.top, 0); 25 | 26 | return getDropLineDirection(nodeMeta.width, nodeMeta.height, [x, y], direction); 27 | }; 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2020 Jaga Apple 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files 6 | (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, 7 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 13 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 14 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 15 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 16 | -------------------------------------------------------------------------------- /stories/1-simple-vertical/shared/common.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | width: 512px; 3 | } 4 | 5 | .item { 6 | display: flex; 7 | justify-content: space-between; 8 | align-items: center; 9 | padding: 8px 16px; 10 | min-height: 48px; 11 | border: 1px solid #cfd4db; 12 | border-radius: 4px; 13 | background-color: white; 14 | } 15 | .item.placeholder { 16 | color: #cfd4db; 17 | border-style: dashed; 18 | background-color: transparent; 19 | } 20 | .wrapper.disabled .item { 21 | color: #cfd4db; 22 | } 23 | .item.locked::after { 24 | color: #cfd4db; 25 | font-family: "FontAwesome"; 26 | font-size: 14px; 27 | text-align: center; 28 | content: "\f023"; 29 | } 30 | .item.ghost.static { 31 | background-color: #f6f9fc; 32 | } 33 | 34 | .ghost { 35 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.05); 36 | width: 100%; 37 | height: 100%; 38 | opacity: 0.8; 39 | animation-name: ghostItemVisible; 40 | animation-duration: 200ms; 41 | animation-fill-mode: forwards; 42 | } 43 | @keyframes ghostItemVisible { 44 | 0% { 45 | transform: scale(1); 46 | } 47 | 100% { 48 | transform: scale(0.8); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/item/body.ts: -------------------------------------------------------------------------------- 1 | const styleElementId = "react-sortful-global-style"; 2 | 3 | export const setBodyStyle = ( 4 | bodyElement: HTMLElement, 5 | draggingCusrsorStyle: string | undefined, 6 | // istanbul ignore next 7 | documentElement = document, 8 | ) => { 9 | // Disables to select elements in entire page. 10 | bodyElement.style.userSelect = "none"; 11 | 12 | // Applies a cursor style when dragging. 13 | if (draggingCusrsorStyle != undefined) { 14 | const styleElement = documentElement.createElement("style"); 15 | styleElement.textContent = `* { cursor: ${draggingCusrsorStyle} !important; }`; 16 | styleElement.setAttribute("id", styleElementId); 17 | 18 | documentElement.head.appendChild(styleElement); 19 | } 20 | }; 21 | 22 | export const clearBodyStyle = ( 23 | bodyElement: HTMLElement, 24 | // istanbul ignore next 25 | documentElement = document, 26 | ) => { 27 | // Enables to select elements in entire page. 28 | bodyElement.style.removeProperty("user-select"); 29 | 30 | // Resets a cursor style when dragging. 31 | documentElement.getElementById(styleElementId)?.remove(); 32 | }; 33 | -------------------------------------------------------------------------------- /stories/1-simple-horizontal/shared/common.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | } 6 | 7 | .item { 8 | display: flex; 9 | justify-content: space-between; 10 | align-items: center; 11 | padding: 8px 16px; 12 | min-width: 128px; 13 | min-height: 48px; 14 | border: 1px solid #cfd4db; 15 | border-radius: 4px; 16 | background-color: white; 17 | } 18 | .item.placeholder { 19 | color: #cfd4db; 20 | border-style: dashed; 21 | background-color: transparent; 22 | } 23 | .wrapper.disabled .item { 24 | color: #cfd4db; 25 | } 26 | .item.locked::after { 27 | color: #cfd4db; 28 | font-family: "FontAwesome"; 29 | font-size: 14px; 30 | text-align: center; 31 | content: "\f023"; 32 | } 33 | .item.ghost.static { 34 | background-color: #f6f9fc; 35 | } 36 | 37 | .ghost { 38 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.05); 39 | width: 100%; 40 | height: 100%; 41 | opacity: 0.8; 42 | animation-name: ghostItemVisible; 43 | animation-duration: 200ms; 44 | animation-fill-mode: forwards; 45 | } 46 | @keyframes ghostItemVisible { 47 | 0% { 48 | transform: scale(1); 49 | } 50 | 100% { 51 | transform: scale(0.8); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/drag-handle.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useGesture } from "react-use-gesture"; 3 | 4 | import { ItemContext } from "./item"; 5 | 6 | type Props = { 7 | className?: string; 8 | children: React.ReactNode; 9 | }; 10 | 11 | export const DragHandleComponent = (props: Props) => { 12 | const itemContext = React.useContext(ItemContext); 13 | 14 | // Checks `props.children` has one React node. 15 | React.useEffect(() => { 16 | React.Children.only(props.children); 17 | }, [props.children]); 18 | 19 | const draggableBinder = useGesture({ 20 | onDragStart: (state: any) => { 21 | if (itemContext.isLocked) return; 22 | 23 | const event: React.SyntheticEvent = state.event; 24 | event.persist(); 25 | event.stopPropagation(); 26 | 27 | itemContext.dragHandlers.onDragStart(); 28 | }, 29 | onDrag: ({ down, movement }) => { 30 | if (itemContext.isLocked) return; 31 | 32 | itemContext.dragHandlers.onDrag(down, movement); 33 | }, 34 | onDragEnd: () => { 35 | if (itemContext.isLocked) return; 36 | 37 | itemContext.dragHandlers.onDragEnd(); 38 | }, 39 | }); 40 | 41 | return ( 42 |
43 | {props.children} 44 |
45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/shared/nodes/node-meta.ts: -------------------------------------------------------------------------------- 1 | import { ItemIdentifier } from "../items"; 2 | import { ElementPosition } from "./elements"; 3 | 4 | type NodeRect = { 5 | width: number; 6 | height: number; 7 | relativePosition: ElementPosition; 8 | absolutePosition: ElementPosition; 9 | }; 10 | export type NodeMeta = { 11 | identifier: T; 12 | groupIdentifier: T | undefined; 13 | ancestorIdentifiers: T[]; 14 | index: number; 15 | isGroup: boolean; 16 | element: HTMLElement; 17 | } & NodeRect; 18 | 19 | const getNodeRect = (element: HTMLElement): NodeRect => { 20 | const elementRect = element.getBoundingClientRect(); 21 | 22 | return { 23 | width: elementRect.width, 24 | height: elementRect.height, 25 | relativePosition: { top: element.offsetTop, left: element.offsetLeft }, 26 | absolutePosition: { top: elementRect.top, left: elementRect.left }, 27 | }; 28 | }; 29 | 30 | export const getNodeMeta = ( 31 | element: HTMLElement, 32 | identifier: T, 33 | groupIdentifier: T | undefined, 34 | ancestorIdentifiers: T[], 35 | index: number, 36 | isGroup: boolean, 37 | ): NodeMeta => ({ 38 | identifier, 39 | groupIdentifier, 40 | ancestorIdentifiers, 41 | index, 42 | isGroup, 43 | element, 44 | ...getNodeRect(element), 45 | }); 46 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | stories: ["../stories/**/*.stories.tsx"], 5 | addons: ["@storybook/addon-backgrounds", "@storybook/addon-storysource"], 6 | webpackFinal: async (config) => { 7 | // --- TypeScript 8 | config.resolve.extensions.push(".ts", ".tsx"); 9 | config.module.rules.push({ test: /\.tsx?$/, exclude: /node_modules/, loader: "babel-loader" }); 10 | config.module.rules.push({ 11 | test: /\.stories\.tsx?$/, 12 | exclude: /node_modules/, 13 | loader: "@storybook/source-loader", 14 | options: { parser: "typescript" }, 15 | enforce: "pre", 16 | }); 17 | 18 | // --- CSS 19 | // Appends "exclude" option to the existing CSS rule in order to ignore default rules for application styles. 20 | config.module.rules.forEach((rule) => { 21 | if (rule.test.toString() !== "/\\.css$/") return; 22 | 23 | rule.exclude = path.resolve(process.cwd(), "stories"); 24 | }); 25 | 26 | config.module.rules.push({ 27 | test: /\.css$/, 28 | use: [ 29 | "style-loader", 30 | { 31 | loader: "css-loader", 32 | options: { modules: true, sourceMap: true, importLoaders: 1 }, 33 | }, 34 | ], 35 | include: path.resolve(process.cwd(), "stories"), 36 | }); 37 | 38 | return config; 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /stories/3-advanced-examples/kanban.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: flex; 3 | justify-content: center; 4 | align-items: flex-start; 5 | } 6 | .wrapper hr { 7 | display: block; 8 | margin: 0 16px; 9 | width: 0; 10 | height: 0; 11 | border: none; 12 | } 13 | 14 | .dropLine { 15 | height: 4px; 16 | border-radius: 10px; 17 | background-color: #748da0; 18 | } 19 | 20 | .group { 21 | width: 256px; 22 | min-height: 108px; 23 | border-radius: 8px; 24 | background-color: #e7eff4; 25 | } 26 | .group.stacked { 27 | --color: #007bff; 28 | 29 | box-shadow: 0 0 0 1px var(--color); 30 | border-style: solid; 31 | border-color: var(--color); 32 | } 33 | .group .heading { 34 | padding: 12px 16px 4px; 35 | font-size: 14px; 36 | font-weight: bold; 37 | border-radius: 5px 5px 0 0; 38 | } 39 | 40 | .items { 41 | padding: 4px 8px; 42 | } 43 | .item { 44 | display: flex; 45 | justify-content: space-between; 46 | align-items: center; 47 | padding: 8px 16px; 48 | min-height: 48px; 49 | color: #1b2b4a; 50 | font-weight: bold; 51 | border-radius: 6px; 52 | background-color: white; 53 | cursor: grab; 54 | } 55 | .item.placeholder { 56 | color: white; 57 | background-color: #cfdce5; 58 | } 59 | 60 | .ghost { 61 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.2); 62 | width: 100%; 63 | height: 100%; 64 | transform: scale(0.8) rotate(5deg); 65 | } 66 | -------------------------------------------------------------------------------- /src/item/ghosts.ts: -------------------------------------------------------------------------------- 1 | import { Direction } from "../shared"; 2 | 3 | export const initializeGhostElementStyle = ( 4 | itemElement: HTMLElement, 5 | ghostWrapperElement: HTMLElement | undefined, 6 | itemSpacing: number, 7 | direction: Direction, 8 | ) => { 9 | if (ghostWrapperElement == undefined) return; 10 | 11 | const elementRect = itemElement.getBoundingClientRect(); 12 | const top = direction === "vertical" ? elementRect.top + itemSpacing / 2 : elementRect.top; 13 | const left = direction === "horizontal" ? elementRect.left + itemSpacing / 2 : elementRect.left; 14 | const width = direction === "horizontal" ? elementRect.width - itemSpacing : elementRect.width; 15 | const height = direction === "vertical" ? elementRect.height - itemSpacing : elementRect.height; 16 | 17 | ghostWrapperElement.style.top = `${top}px`; 18 | ghostWrapperElement.style.left = `${left}px`; 19 | ghostWrapperElement.style.width = `${width}px`; 20 | ghostWrapperElement.style.height = `${height}px`; 21 | }; 22 | 23 | export const moveGhostElement = (ghostWrapperElement: HTMLElement | undefined, movementXY: [number, number]) => { 24 | if (ghostWrapperElement == undefined) return; 25 | 26 | const [x, y] = movementXY; 27 | ghostWrapperElement.style.transform = `translate3d(${x}px, ${y}px, 0)`; 28 | }; 29 | 30 | export const clearGhostElementStyle = (ghostWrapperElement: HTMLElement | undefined) => { 31 | if (ghostWrapperElement == undefined) return; 32 | 33 | ghostWrapperElement.style.removeProperty("width"); 34 | ghostWrapperElement.style.removeProperty("height"); 35 | }; 36 | -------------------------------------------------------------------------------- /src/shared/nodes/node-meta.spec.ts: -------------------------------------------------------------------------------- 1 | import { ElementPosition } from "./elements"; 2 | import { getNodeMeta } from "./node-meta"; 3 | import * as sinon from "sinon"; 4 | 5 | describe("getNodeMeta", () => { 6 | const identifier = "a8e95d60-a68e-452c-9ba0-a640e911489a"; 7 | const groupIdentifier = "f76d7452-b6fe-4dfd-a5fb-9dac9029e679"; 8 | const ancestorIdentifiers = ["741959db-f0aa-4f61-aa72-43edd372ab17", "cd1e18e1-e476-476c-b17b-d43542653cf3"]; 9 | const index = 0; 10 | const isGroup = true; 11 | 12 | it("should return a node meta", () => { 13 | const dummyBoundingClientRect = { width: 1, height: 2, top: 3, left: 4 }; 14 | const boundingRectGetterSpy = sinon.mock().returns(dummyBoundingClientRect); 15 | const element = ({ 16 | offsetTop: 5, 17 | offsetLeft: 6, 18 | getBoundingClientRect: boundingRectGetterSpy, 19 | } as any) as HTMLElement; 20 | 21 | const returnedValue = getNodeMeta(element, identifier, groupIdentifier, ancestorIdentifiers, index, isGroup); 22 | const expectedRelativePosition: ElementPosition = { top: 5, left: 6 }; 23 | const expectedAbsolutePosition: ElementPosition = { top: 3, left: 4 }; 24 | expect(returnedValue).toEqual({ 25 | identifier, 26 | groupIdentifier, 27 | ancestorIdentifiers, 28 | index, 29 | isGroup, 30 | element, 31 | width: dummyBoundingClientRect.width, 32 | height: dummyBoundingClientRect.height, 33 | relativePosition: expectedRelativePosition, 34 | absolutePosition: expectedAbsolutePosition, 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/item/drop-lines.ts: -------------------------------------------------------------------------------- 1 | import { Direction, DropLineDirection, getDropLinePosition, ItemIdentifier, NodeMeta } from "../shared"; 2 | 3 | export const setDropLineElementStyle = ( 4 | dropLineElement: HTMLElement | undefined, 5 | absoluteXY: [number, number], 6 | nodeMeta: NodeMeta, 7 | direction: Direction, 8 | ) => { 9 | if (dropLineElement == undefined) return; 10 | 11 | const dropLinePosition = getDropLinePosition(absoluteXY, nodeMeta, direction); 12 | dropLineElement.style.top = `${dropLinePosition.top}px`; 13 | dropLineElement.style.left = `${dropLinePosition.left}px`; 14 | 15 | if (direction === "vertical") dropLineElement.style.width = `${nodeMeta.width}px`; 16 | if (direction === "horizontal") dropLineElement.style.height = `${nodeMeta.height}px`; 17 | }; 18 | 19 | export const getDropLinePositionItemIndex = ( 20 | dropLineDirection: DropLineDirection | undefined, 21 | draggingItemIndex: number, 22 | draggingItemGroupIdentifier: T | undefined, 23 | hoveredItemIndex: number, 24 | hoveredItemGroupIdentifier: T | undefined, 25 | ) => { 26 | let nextIndex = draggingItemIndex; 27 | if (dropLineDirection === "TOP") nextIndex = hoveredItemIndex; 28 | if (dropLineDirection === "BOTTOM") nextIndex = hoveredItemIndex + 1; 29 | if (dropLineDirection === "LEFT") nextIndex = hoveredItemIndex; 30 | if (dropLineDirection === "RIGHT") nextIndex = hoveredItemIndex + 1; 31 | 32 | const isInSameGroup = draggingItemGroupIdentifier === hoveredItemGroupIdentifier; 33 | if (isInSameGroup && draggingItemIndex < nextIndex) nextIndex -= 1; 34 | 35 | return nextIndex; 36 | }; 37 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { "browser": true, "node": true, "es6": true }, 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:prettier/recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react/recommended" 8 | ], 9 | "plugins": [ 10 | "@typescript-eslint", 11 | "import", 12 | "react" 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "sourceType": "module", 17 | "ecmaFeatures": { "jsx": true } 18 | }, 19 | "settings": { 20 | "react": { "pragma": "React", "version": "16.9" } 21 | }, 22 | "rules": { 23 | "@typescript-eslint/no-explicit-any": "off", 24 | "@typescript-eslint/no-unused-vars": [ 25 | "error", 26 | { "argsIgnorePattern": "^_" } 27 | ], 28 | "@typescript-eslint/explicit-function-return-type": "off", 29 | "@typescript-eslint/no-non-null-assertion": "off", 30 | "@typescript-eslint/consistent-type-definitions": ["error", "type"], 31 | "import/no-self-import": "error", 32 | "import/no-cycle": "error", 33 | "import/no-useless-path-segments": "error", 34 | "import/no-relative-parent-imports": "error", 35 | "import/first": "error", 36 | "import/newline-after-import": "error", 37 | "react/button-has-type": "error", 38 | "react/react-in-jsx-scope": "off", 39 | "react/prop-types": "off", 40 | "react/display-name": "off", 41 | "react/jsx-curly-brace-presence": ["error", "never"], 42 | "arrow-body-style": ["error", "as-needed"], 43 | "sort-imports": ["error", { 44 | "ignoreCase": true, 45 | "ignoreDeclarationSort": true 46 | }], 47 | "no-undef": "off" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /stories/1-simple-vertical/static.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | 4 | import { DropLineRendererInjectedProps, Item, List, PlaceholderRendererInjectedProps } from "../../src"; 5 | 6 | import { commonStyles } from "../shared"; 7 | import { styles } from "./shared"; 8 | 9 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 10 |
11 | ); 12 | const renderGhostElement = () =>
; 13 | const renderPlaceholderElement = (injectedProps: PlaceholderRendererInjectedProps) => ( 14 |
15 | ); 16 | 17 | export const StaticComponent = () => ( 18 | false} 24 | > 25 | 26 |
Item A
27 |
28 | 29 |
Item B
30 |
31 | 32 |
Item C
33 |
34 | 35 |
Item D
36 |
37 | 38 |
Item E
39 |
40 |
41 | ); 42 | -------------------------------------------------------------------------------- /src/list/context.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { Direction, NodeMeta } from "../shared"; 4 | import { 5 | DestinationMeta, 6 | DragEndMeta, 7 | DragStartMeta, 8 | PlaceholderRendererInjectedProps, 9 | PlaceholderRendererMeta, 10 | StackedGroupRendererInjectedProps, 11 | StackedGroupRendererMeta, 12 | StackGroupMeta, 13 | } from "./meta"; 14 | 15 | export const Context = React.createContext<{ 16 | itemSpacing: number; 17 | stackableAreaThreshold: number; 18 | draggingNodeMeta: NodeMeta | undefined; 19 | setDraggingNodeMeta: (meta: NodeMeta | undefined) => void; 20 | dropLineElementRef: React.RefObject; 21 | ghostWrapperElementRef: React.RefObject; 22 | isVisibleDropLineElement: boolean; 23 | setIsVisibleDropLineElement: (isVisible: boolean) => void; 24 | renderPlaceholder: 25 | | ((injectedProps: PlaceholderRendererInjectedProps, meta: PlaceholderRendererMeta) => JSX.Element) 26 | | undefined; 27 | stackedGroupIdentifier: any; 28 | setStackedGroupIdentifier: (identifier: any) => void; 29 | renderStackedGroup: 30 | | ((injectedProps: StackedGroupRendererInjectedProps, meta: StackedGroupRendererMeta) => JSX.Element) 31 | | undefined; 32 | hoveredNodeMetaRef: React.MutableRefObject | undefined>; 33 | destinationMetaRef: React.MutableRefObject | undefined>; 34 | direction: Direction; 35 | draggingCursorStyle: React.CSSProperties["cursor"] | undefined; 36 | isDisabled: boolean; 37 | onDragStart: ((meta: DragStartMeta) => void) | undefined; 38 | onDragEnd: (meta: DragEndMeta) => void; 39 | onStackGroup: ((meta: StackGroupMeta) => void) | undefined; 40 | }>(undefined as any); 41 | -------------------------------------------------------------------------------- /stories/1-simple-horizontal/static.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | 4 | import { DropLineRendererInjectedProps, Item, List, PlaceholderRendererInjectedProps } from "../../src"; 5 | 6 | import { commonStyles } from "../shared"; 7 | import { styles } from "./shared"; 8 | 9 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 10 |
15 | ); 16 | const renderGhostElement = () =>
; 17 | const renderPlaceholderElement = (injectedProps: PlaceholderRendererInjectedProps) => ( 18 |
19 | ); 20 | 21 | export const StaticComponent = () => ( 22 | false} 29 | > 30 | 31 |
Item A
32 |
33 | 34 |
Item B
35 |
36 | 37 |
Item C
38 |
39 | 40 |
Item D
41 |
42 | 43 |
Item E
44 |
45 |
46 | ); 47 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # JavaScript CircleCI 2.0 configuration file. 2 | # Check https://circleci.com/docs/2.0/language-javascript/ for more details. 3 | version: 2.1 4 | jobs: 5 | node_v12: 6 | working_directory: "~/repo" 7 | docker: 8 | - image: "node:12.13" 9 | steps: 10 | - "checkout" 11 | - restore_cache: 12 | key: "node-v12.13-node-modules-{{ checksum \"package.json\" }}" 13 | - run: 14 | name: "Install packages" 15 | command: "npm ci" 16 | - save_cache: 17 | key: "node-v12.13-node-modules-{{ checksum \"package.json\" }}" 18 | paths: 19 | - "node_modules" 20 | - run: 21 | name: "Execute linters" 22 | command: "npm run lint" 23 | - run: 24 | name: "Execute tests" 25 | command: "npm test && npm run coverage" 26 | - run: 27 | name: "Build" 28 | command: "npm run build" 29 | - run: 30 | name: "Execute visual regression tests" 31 | command: "npm run chromatic" 32 | node_v10: 33 | working_directory: "~/repo" 34 | docker: 35 | - image: "node:10.15" 36 | steps: 37 | - "checkout" 38 | - restore_cache: 39 | key: "node-v10.15-node-modules-{{ checksum \"package.json\" }}" 40 | - run: 41 | name: "Install packages" 42 | command: "npm ci" 43 | - save_cache: 44 | key: "node-v10.15-node-modules-{{ checksum \"package.json\" }}" 45 | paths: 46 | - "node_modules" 47 | - run: 48 | name: "Execute linters" 49 | command: "npm run lint" 50 | - run: 51 | name: "Execute tests" 52 | command: "npm test" 53 | - run: 54 | name: "Build" 55 | command: "npm run build" 56 | workflows: 57 | version: 2 58 | build: 59 | jobs: 60 | - "node_v12" 61 | - "node_v10" 62 | -------------------------------------------------------------------------------- /src/item/move-events.spec.ts: -------------------------------------------------------------------------------- 1 | import { checkIsAncestorItem } from "./move-events"; 2 | 3 | describe("checkIsAncestorItem", () => { 4 | const targetItemIdentifier = "386e0e1b-b02b-4c73-b1d2-e843113f8485"; 5 | 6 | context("when `targetItemIdentifier` exists in `ancestorIdentifiersOfChild`", () => { 7 | context("the last element in `ancestorIdentifiersOfChild` is not the identifier", () => { 8 | const ancestorIdentifiersOfChild = [ 9 | "f17fc33c-3e1a-4f66-85ad-31067c16a871", 10 | "af1e83d7-2c6e-4789-950f-28dff465ede2", 11 | targetItemIdentifier, 12 | "a29fcce7-0b2c-4ff1-b519-e2e807018ab0", 13 | "e630e35f-c8ff-4ed4-bead-692fc7cb8c3c", 14 | ]; 15 | 16 | it("should return true", () => { 17 | expect(checkIsAncestorItem(targetItemIdentifier, ancestorIdentifiersOfChild)).toBe(true); 18 | }); 19 | }); 20 | 21 | context("the last element in `ancestorIdentifiersOfChild` is the identifier", () => { 22 | const ancestorIdentifiersOfChild = [ 23 | "f17fc33c-3e1a-4f66-85ad-31067c16a871", 24 | "af1e83d7-2c6e-4789-950f-28dff465ede2", 25 | "a29fcce7-0b2c-4ff1-b519-e2e807018ab0", 26 | "e630e35f-c8ff-4ed4-bead-692fc7cb8c3c", 27 | targetItemIdentifier, 28 | ]; 29 | 30 | it("should return false", () => { 31 | expect(checkIsAncestorItem(targetItemIdentifier, ancestorIdentifiersOfChild)).toBe(false); 32 | }); 33 | }); 34 | }); 35 | 36 | context("when `targetItemIdentifier` does not exist in `ancestorIdentifiersOfChild`", () => { 37 | const ancestorIdentifiersOfChild = [ 38 | "f17fc33c-3e1a-4f66-85ad-31067c16a871", 39 | "af1e83d7-2c6e-4789-950f-28dff465ede2", 40 | "a29fcce7-0b2c-4ff1-b519-e2e807018ab0", 41 | "e630e35f-c8ff-4ed4-bead-692fc7cb8c3c", 42 | ]; 43 | 44 | it("should return false", () => { 45 | expect(checkIsAncestorItem(targetItemIdentifier, ancestorIdentifiersOfChild)).toBe(false); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /stories/3-advanced-examples/non-styled.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import arrayMove from "array-move"; 3 | 4 | import { DragEndMeta, DropLineRendererInjectedProps, GhostRendererMeta, Item, List } from "../../src"; 5 | 6 | type DummyItem = { id: string; title: string }; 7 | 8 | const initialItems: DummyItem[] = [ 9 | { id: "a", title: "Item A" }, 10 | { id: "b", title: "Item B" }, 11 | { id: "c", title: "Item C" }, 12 | { id: "d", title: "Item D" }, 13 | { id: "e", title: "Item E" }, 14 | ]; 15 | 16 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 17 |
18 | ); 19 | 20 | export const NonStyledComponent = () => { 21 | const [itemsState, setItemsState] = React.useState(initialItems); 22 | const itemsById = React.useMemo( 23 | () => 24 | itemsState.reduce>((object, item) => { 25 | object[item.id] = item; 26 | 27 | return object; 28 | }, {}), 29 | [itemsState], 30 | ); 31 | 32 | const itemElements = React.useMemo( 33 | () => 34 | itemsState.map((item, index) => ( 35 | 36 | {item.title} 37 | 38 | )), 39 | [itemsState], 40 | ); 41 | const renderGhostElement = React.useCallback( 42 | ({ identifier }: GhostRendererMeta) => { 43 | const item = itemsById[identifier]; 44 | 45 | return item.title; 46 | }, 47 | [itemsById], 48 | ); 49 | 50 | const onDragEnd = React.useCallback( 51 | (meta: DragEndMeta) => { 52 | if (meta.nextIndex == undefined) return; 53 | 54 | setItemsState(arrayMove(itemsState, meta.index, meta.nextIndex)); 55 | }, 56 | [itemsState], 57 | ); 58 | 59 | return ( 60 | 61 | {itemElements} 62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /src/shared/drop-lines/positions.ts: -------------------------------------------------------------------------------- 1 | import { Direction, ItemIdentifier } from "../items"; 2 | import { ElementPosition, NodeMeta } from "../nodes"; 3 | import { createNewError } from "../errors"; 4 | import { getDropLineDirection } from "./direcitons"; 5 | 6 | export const getDropLinePosition = ( 7 | absoluteXY: [number, number], 8 | nodeMeta: NodeMeta, 9 | direction: Direction, 10 | ): ElementPosition => { 11 | const x = Math.max(absoluteXY[0] - nodeMeta.absolutePosition.left, 0); 12 | const y = Math.max(absoluteXY[1] - nodeMeta.absolutePosition.top, 0); 13 | 14 | const dropLineDirection = getDropLineDirection(nodeMeta.width, nodeMeta.height, [x, y], direction); 15 | if (dropLineDirection == undefined) throw new Error("A drop line direction is undefined"); 16 | 17 | let left = nodeMeta.relativePosition.left; 18 | let top = nodeMeta.relativePosition.top; 19 | if (dropLineDirection === "BOTTOM") top += nodeMeta.height; 20 | if (dropLineDirection === "RIGHT") left += nodeMeta.width; 21 | 22 | return { top, left }; 23 | }; 24 | 25 | export const checkIsInStackableArea = ( 26 | absoluteXY: [number, number], 27 | nodeMeta: NodeMeta, 28 | stackableAreaThreshold: number, 29 | direciton: Direction, 30 | ) => { 31 | const x = Math.max(absoluteXY[0] - nodeMeta.absolutePosition.left, 0); 32 | const y = Math.max(absoluteXY[1] - nodeMeta.absolutePosition.top, 0); 33 | 34 | if (direciton === "vertical") { 35 | const nodeTop = 0; 36 | const nodeBottom = nodeMeta.height; 37 | const isInStackableAreaY = nodeTop + stackableAreaThreshold <= y && y <= nodeBottom - stackableAreaThreshold; 38 | 39 | return isInStackableAreaY; 40 | } 41 | if (direciton === "horizontal") { 42 | const nodeLeft = 0; 43 | const nodeRight = nodeMeta.width; 44 | const isInStackableAreaX = nodeLeft + stackableAreaThreshold <= x && x <= nodeRight - stackableAreaThreshold; 45 | 46 | return isInStackableAreaX; 47 | } 48 | 49 | throw createNewError("direction is an unexpected value"); 50 | }; 51 | -------------------------------------------------------------------------------- /stories/3-advanced-examples/tree.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | --padding: 8px; 3 | --icon-width: 14px; 4 | } 5 | 6 | .withIcon::before { 7 | display: block; 8 | margin-right: 0.3em; 9 | width: var(--icon-width); 10 | font-family: "FontAwesome"; 11 | font-size: 14px; 12 | text-align: center; 13 | } 14 | 15 | .group { 16 | padding: 2px 8px 2px 16px; 17 | border: 1px dashed transparent; 18 | border-radius: 4px; 19 | } 20 | .group.placeholder { 21 | border-color: #cfd4db; 22 | background-color: #f6f9fc; 23 | } 24 | .group.stacked { 25 | --color: #007bff; 26 | 27 | box-shadow: 0 0 0 1px var(--color); 28 | border-style: solid; 29 | border-color: var(--color); 30 | background-color: rgba(0, 123, 255, 0.1); 31 | } 32 | .group .heading { 33 | display: flex; 34 | justify-content: flex-start; 35 | align-items: center; 36 | margin-left: -12px; 37 | font-size: 12px; 38 | font-weight: bold; 39 | line-height: 1.4; 40 | } 41 | .group .heading::before { 42 | content: "\f07b"; 43 | } 44 | .group .heading.opened::before { 45 | content: "\f07c"; 46 | } 47 | .group .heading.locked::before { 48 | color: #cfd4db; 49 | content: "\f114"; 50 | } 51 | .group .heading.locked.opened::before { 52 | content: "\f115"; 53 | } 54 | 55 | .item { 56 | display: flex; 57 | justify-content: flex-start; 58 | align-items: center; 59 | padding: 2px 8px 2px 4px; 60 | font-size: 12px; 61 | line-height: 1.4; 62 | border: 1px dashed transparent; 63 | border-radius: 4px; 64 | } 65 | .item::before { 66 | content: "\f016"; 67 | } 68 | .item.placeholder { 69 | border-color: #cfd4db; 70 | background-color: #f6f9fc; 71 | } 72 | .item.locked::before { 73 | color: #cfd4db; 74 | content: "\f023"; 75 | } 76 | 77 | .disabled { 78 | color: #cfd4db; 79 | } 80 | 81 | .ghost { 82 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.05); 83 | border-radius: 50px; 84 | animation-name: ghostItemVisible; 85 | animation-duration: 200ms; 86 | animation-fill-mode: forwards; 87 | } 88 | @keyframes ghostItemVisible { 89 | 0% { 90 | opacity: 0; 91 | transform: scale(1); 92 | } 93 | 100% { 94 | opacity: 0.8; 95 | transform: scale(0.8) translate(16px, 16px); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /stories/2-nested-vertical/shared/common.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | --padding: 8px; 3 | 4 | width: 512px; 5 | } 6 | 7 | .group { 8 | padding: 0 var(--padding) calc(var(--padding) / 2); 9 | border: 1px solid #bee0a9; 10 | border-radius: 4px; 11 | background-color: rgba(113, 189, 69, 0.1); 12 | } 13 | .group.stacked { 14 | --color: #71bd45; 15 | 16 | box-shadow: 0 0 0 1px var(--color); 17 | border-color: var(--color); 18 | } 19 | .group.placeholder { 20 | border-style: dashed; 21 | border-color: #579135; 22 | background-color: transparent; 23 | opacity: 0.4; 24 | } 25 | .group.ghost { 26 | border-color: #aed893; 27 | background-color: #e7f3df; 28 | } 29 | .group .heading { 30 | display: flex; 31 | justify-content: space-between; 32 | align-items: center; 33 | margin-bottom: 4px; 34 | padding: 8px 4px 0; 35 | color: #579135; 36 | font-size: 12px; 37 | font-weight: bold; 38 | line-height: 1.4; 39 | } 40 | .wrapper.disabled .group .heading { 41 | color: #bee0a9; 42 | } 43 | .group .heading.locked::after { 44 | color: #aed893; 45 | font-family: "FontAwesome"; 46 | font-size: 14px; 47 | text-align: center; 48 | content: "\f023"; 49 | } 50 | 51 | .item { 52 | display: flex; 53 | justify-content: space-between; 54 | align-items: center; 55 | padding: 8px 16px; 56 | min-height: 48px; 57 | border: 1px solid #cfd4db; 58 | border-radius: 4px; 59 | background-color: white; 60 | } 61 | .item.placeholder { 62 | color: #cfd4db; 63 | border-style: dashed; 64 | background-color: transparent; 65 | } 66 | .wrapper.disabled .item { 67 | color: #cfd4db; 68 | } 69 | .item.locked::after { 70 | color: #cfd4db; 71 | font-family: "FontAwesome"; 72 | font-size: 14px; 73 | text-align: center; 74 | content: "\f023"; 75 | } 76 | .item.ghost.static { 77 | background-color: #f6f9fc; 78 | } 79 | 80 | .ghost { 81 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.05); 82 | width: 100%; 83 | height: 100%; 84 | opacity: 0.8; 85 | animation-name: ghostItemVisible; 86 | animation-duration: 200ms; 87 | animation-fill-mode: forwards; 88 | } 89 | @keyframes ghostItemVisible { 90 | 0% { 91 | transform: scale(1); 92 | } 93 | 100% { 94 | transform: scale(0.8); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/item/renderers.spec.ts: -------------------------------------------------------------------------------- 1 | import { Direction, NodeMeta } from "../shared"; 2 | import { getPlaceholderElementStyle, getStackedGroupElementStyle } from "./renderers"; 3 | 4 | describe("getPlaceholderElementStyle", () => { 5 | const draggingNodeMeta: NodeMeta = { 6 | identifier: 0, 7 | groupIdentifier: undefined, 8 | ancestorIdentifiers: [], 9 | index: 0, 10 | isGroup: false, 11 | element: {} as any, 12 | width: 1212, 13 | height: 3434, 14 | relativePosition: { top: 100, left: 200 }, 15 | absolutePosition: { top: 300, left: 400 }, 16 | }; 17 | const itemSpacing = 123; 18 | 19 | context('`direction` is "horizontal"', () => { 20 | const direction: Direction = "horizontal"; 21 | 22 | it("should return an object which has proper width and height", () => { 23 | const returnedValue = getPlaceholderElementStyle(draggingNodeMeta, itemSpacing, direction); 24 | 25 | expect(returnedValue.width).toBe(draggingNodeMeta.width - itemSpacing); 26 | expect(returnedValue.height).toBe(draggingNodeMeta.height); 27 | }); 28 | }); 29 | 30 | context('`direction` is "vertical"', () => { 31 | const direction: Direction = "vertical"; 32 | 33 | it("should return an object which has proper width and height", () => { 34 | const returnedValue = getPlaceholderElementStyle(draggingNodeMeta, itemSpacing, direction); 35 | 36 | expect(returnedValue.width).toBe(draggingNodeMeta.width); 37 | expect(returnedValue.height).toBe(draggingNodeMeta.height - itemSpacing); 38 | }); 39 | }); 40 | 41 | context("when `draggingNodeMeta` is undefined", () => { 42 | context('`direction` is "horizontal"', () => { 43 | const direction: Direction = "horizontal"; 44 | 45 | it("should return an empty object", () => { 46 | expect(getPlaceholderElementStyle(undefined, itemSpacing, direction)).toEqual({}); 47 | }); 48 | }); 49 | 50 | context('`direction` is "vertical"', () => { 51 | const direction: Direction = "vertical"; 52 | 53 | it("should return an empty object", () => { 54 | expect(getPlaceholderElementStyle(undefined, itemSpacing, direction)).toEqual({}); 55 | }); 56 | }); 57 | }); 58 | }); 59 | 60 | describe("getStackedGroupElementStyle", () => { 61 | it("should equal to `getPlaceholderElementStyle`", () => { 62 | expect(getStackedGroupElementStyle).toBe(getPlaceholderElementStyle); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /.vscode/typescript-common.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Export": { 3 | "prefix": "export statement", 4 | "scope": "typescript,typescriptreact", 5 | "body": ["export * from \"${0:module}\";"] 6 | }, 7 | "Insert variables comment": { 8 | "prefix": "comment variables", 9 | "scope": "typescript,typescriptreact", 10 | "body": [ 11 | " // ---------------------------------------------------------------------------------------------------------------------------", 12 | " // Variables", 13 | " // ---------------------------------------------------------------------------------------------------------------------------" 14 | ] 15 | }, 16 | "Insert public variables comment": { 17 | "prefix": "comment public variables", 18 | "scope": "typescript,typescriptreact", 19 | "body": [ 20 | " // Public Variables" 21 | ] 22 | }, 23 | "Insert private variables comment": { 24 | "prefix": "comment private variables", 25 | "scope": "typescript,typescriptreact", 26 | "body": [ 27 | " // Private Variables" 28 | ] 29 | }, 30 | "Insert functions comment": { 31 | "prefix": "comment functions", 32 | "scope": "typescript,typescriptreact", 33 | "body": [ 34 | " // ---------------------------------------------------------------------------------------------------------------------------", 35 | " // Functions", 36 | " // ---------------------------------------------------------------------------------------------------------------------------" 37 | ] 38 | }, 39 | "Insert constructors comment": { 40 | "prefix": "comment constructors", 41 | "scope": "typescript,typescriptreact", 42 | "body": [ 43 | " // Constructors", 44 | " // ---------------------------------------------------------------------------------------------------------------------------" 45 | ] 46 | }, 47 | "Insert public functions comment": { 48 | "prefix": "comment public functions", 49 | "scope": "typescript,typescriptreact", 50 | "body": [ 51 | " // Public Functions", 52 | " // ---------------------------------------------------------------------------------------------------------------------------" 53 | ] 54 | }, 55 | "Insert private functions comment": { 56 | "prefix": "comment private functions", 57 | "scope": "typescript,typescriptreact", 58 | "body": [ 59 | " // Private Functions", 60 | " // ---------------------------------------------------------------------------------------------------------------------------" 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /stories/2-nested-horizontal/shared/common.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | --padding: 8px; 3 | 4 | display: flex; 5 | justify-content: center; 6 | align-items: stretch; 7 | } 8 | 9 | .group { 10 | display: flex; 11 | justify-content: flex-start; 12 | align-items: stretch; 13 | padding: var(--padding) calc(var(--padding) / 2) var(--padding) 0; 14 | height: 100%; 15 | border: 1px solid #bee0a9; 16 | border-radius: 4px; 17 | background-color: rgba(113, 189, 69, 0.1); 18 | } 19 | .group.stacked { 20 | --color: #71bd45; 21 | 22 | box-shadow: 0 0 0 1px var(--color); 23 | width: 100%; 24 | min-height: 100%; 25 | border-color: var(--color); 26 | } 27 | .group.placeholder { 28 | border-style: dashed; 29 | border-color: #579135; 30 | background-color: transparent; 31 | opacity: 0.4; 32 | } 33 | .group.ghost { 34 | border-color: #aed893; 35 | background-color: #e7f3df; 36 | } 37 | .group .heading { 38 | display: flex; 39 | justify-content: space-between; 40 | align-items: center; 41 | padding: 4px 12px; 42 | color: #579135; 43 | font-size: 12px; 44 | font-weight: bold; 45 | line-height: 1.4; 46 | } 47 | .wrapper.disabled .group .heading { 48 | color: #bee0a9; 49 | } 50 | .group .heading.locked::after { 51 | margin-left: 8px; 52 | color: #aed893; 53 | font-family: "FontAwesome"; 54 | font-size: 14px; 55 | text-align: center; 56 | content: "\f023"; 57 | } 58 | 59 | .item { 60 | display: flex; 61 | justify-content: space-between; 62 | align-items: center; 63 | padding: 8px 16px; 64 | min-width: 128px; 65 | height: 100%; 66 | min-height: 48px; 67 | border: 1px solid #cfd4db; 68 | border-radius: 4px; 69 | background-color: white; 70 | } 71 | .item.placeholder { 72 | color: #cfd4db; 73 | border-style: dashed; 74 | background-color: transparent; 75 | } 76 | .wrapper.disabled .item { 77 | color: #cfd4db; 78 | } 79 | .item.locked::after { 80 | color: #cfd4db; 81 | font-family: "FontAwesome"; 82 | font-size: 14px; 83 | text-align: center; 84 | content: "\f023"; 85 | } 86 | .item.ghost.static { 87 | background-color: #f6f9fc; 88 | } 89 | 90 | .ghost { 91 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.05); 92 | width: 100%; 93 | height: 100%; 94 | opacity: 0.8; 95 | animation-name: ghostItemVisible; 96 | animation-duration: 200ms; 97 | animation-fill-mode: forwards; 98 | } 99 | @keyframes ghostItemVisible { 100 | 0% { 101 | transform: scale(1); 102 | } 103 | 100% { 104 | transform: scale(0.8); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /stories/3-advanced-examples/layers-panel.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | --padding: 8px; 3 | 4 | box-shadow: 0 0 0 2px #424242; 5 | padding: 2px; 6 | width: 512px; 7 | border: 1px solid #383838; 8 | background-color: #424242; 9 | user-select: none; 10 | } 11 | 12 | .navigation { 13 | display: flex; 14 | justify-content: flex-start; 15 | align-items: stretch; 16 | box-shadow: 0 -1px 0 0 #383838 inset; 17 | color: white; 18 | font-weight: bold; 19 | background-color: #424242; 20 | } 21 | .navigation .title { 22 | padding: 4px 8px; 23 | font-size: 12px; 24 | border: 1px solid #383838; 25 | border-bottom-color: #454545; 26 | background-color: #535353; 27 | } 28 | 29 | .dropLine { 30 | height: 4px; 31 | background-color: #6a6a6a; 32 | } 33 | 34 | .list { 35 | border: 1px solid #383838; 36 | border-top: none; 37 | background-color: #535353; 38 | } 39 | 40 | .item { 41 | display: flex; 42 | justify-content: flex-start; 43 | align-items: center; 44 | height: 38px; 45 | color: #f0f0f0; 46 | font-size: 13px; 47 | border-bottom: 1px solid #454545; 48 | background-color: #535353; 49 | } 50 | .item::before { 51 | display: flex; 52 | justify-content: center; 53 | align-items: center; 54 | padding: 0 12px; 55 | min-height: 100%; 56 | font-family: "FontAwesome"; 57 | font-size: 12px; 58 | border-right: 1px solid #454545; 59 | content: "\f06e"; 60 | } 61 | .item .preview { 62 | margin: 0 8px; 63 | margin-left: calc(var(--nest-level, 0) * 16px + 8px); 64 | width: 30px; 65 | height: 20px; 66 | border: 1px solid #2d2d2d; 67 | background-color: white; 68 | } 69 | .item .contents { 70 | display: flex; 71 | justify-content: center; 72 | align-items: center; 73 | } 74 | .item.ghost { 75 | padding: 16px 0; 76 | border: none; 77 | } 78 | .item.ghost::before { 79 | display: none; 80 | } 81 | 82 | .item.group { 83 | height: 32px; 84 | } 85 | .item.group .contents { 86 | flex-direction: column; 87 | } 88 | .item.group .icon { 89 | color: #c7c7c7; 90 | } 91 | .item.group .icon.arrow { 92 | margin: 0 6px 0 8px; 93 | margin-left: calc(var(--nest-level, 0) * 16px + 8px); 94 | width: 18px; 95 | height: 18px; 96 | font-size: 16px; 97 | text-align: center; 98 | } 99 | .item.group .icon.folder { 100 | margin-right: 8px; 101 | font-size: 16px; 102 | } 103 | .item.group.ghost { 104 | align-items: flex-start; 105 | padding: 8px; 106 | } 107 | .item.group.stacked { 108 | background-color: #6a6a6a; 109 | } 110 | 111 | .ghost { 112 | box-shadow: 0 0 8px rgba(0, 0, 0, 0.05); 113 | width: 100%; 114 | height: 100%; 115 | opacity: 0.8; 116 | transform: scale(0.8); 117 | } 118 | 119 | .placeholder { 120 | background-color: #424242; 121 | } 122 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | // --------------------------------------------------------------------------------------------------------------------------- 4 | // Editor 5 | // --------------------------------------------------------------------------------------------------------------------------- 6 | // Inserts snippets when their prefix matches. 7 | // Works best when 'quickSuggestions' aren't enabled. 8 | "editor.tabCompletion": "on", 9 | // Columns at which to show vertical rulers. 10 | "editor.rulers": [ 11 | 128 12 | ], 13 | "editor.codeActionsOnSave": { 14 | "source.fixAll.eslint": true 15 | }, 16 | 17 | 18 | // --------------------------------------------------------------------------------------------------------------------------- 19 | // Files 20 | // --------------------------------------------------------------------------------------------------------------------------- 21 | // Configures glob patterns for excluding files and folders. 22 | "files.exclude": { 23 | "**/node_modules": true 24 | }, 25 | 26 | // Configures glob patterns of file paths to exclude from file watching. 27 | // Changing this setting requires a restart. When you experience Code consuming 28 | // lots of cpu time on startup, you can exclude large folders to reduce the initial load. 29 | "files.watcherExclude": { 30 | "**/.git/objects/**": true, 31 | "**/node_modules/**": true, 32 | "lib/**": true 33 | }, 34 | 35 | 36 | // --------------------------------------------------------------------------------------------------------------------------- 37 | // Extensions 38 | // --------------------------------------------------------------------------------------------------------------------------- 39 | // ESLint 40 | "eslint.validate": [ 41 | "javascript", 42 | "javascriptreact", 43 | "typescript", 44 | "typescriptreact" 45 | ], 46 | 47 | 48 | // --------------------------------------------------------------------------------------------------------------------------- 49 | // Language Setings 50 | // --------------------------------------------------------------------------------------------------------------------------- 51 | // Specifies the folder path containing the tsserver and lib*.d.ts files to use. 52 | "typescript.tsdk": "node_modules/typescript/lib", 53 | "typescript.suggest.autoImports": false, 54 | 55 | // Disables built-in validations. 56 | "css.validate": false, 57 | "less.validate": false, 58 | "scss.validate": false, 59 | 60 | "[javascript]": { 61 | "editor.formatOnSave": true 62 | }, 63 | "[javascriptreact]": { 64 | "editor.formatOnSave": true 65 | }, 66 | "[typescript]": { 67 | "editor.formatOnSave": true 68 | }, 69 | "[typescriptreact]": { 70 | "editor.formatOnSave": true 71 | }, 72 | "[css]": { 73 | "editor.formatOnSave": true 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | # Specify the line length that the printer will wrap on. 2 | printWidth: 128 3 | 4 | # Specify the number of spaces per indentation-level. 5 | tabWidth: 2 6 | 7 | # Indent lines with tabs instead of spaces. 8 | useTabs: false 9 | 10 | # Print semicolons at the ends of statements. 11 | semi: true 12 | 13 | # Use single quotes instead of double quotes. 14 | singleQuote: false 15 | 16 | # Change when properties in objects are quoted. 17 | quoteProps: "as-needed" 18 | 19 | # Use single quotes instead of double quotes in JSX. 20 | # jsxSingleQuote: false 21 | 22 | # Print trailing commas wherever possible when multi-line (A single-line array, for example, never gets trailing commas). 23 | trailingComma: "all" 24 | 25 | # Print spaces between brackets in object literals. 26 | bracketSpacing: true 27 | 28 | # Put the `>` of a multi-line JSX element at the end of the last line instead of being alone on the next line 29 | # (does not apply to self closing elements). 30 | # jsxBracketSameLine: false 31 | 32 | # Include parentheses around a sole arrow function parameter. 33 | arrowParens: "always" 34 | 35 | # Format only a segment of a file. 36 | # These two options can be used to format code starting and ending at a given character offset (inclusive and exclusive, 37 | # respectively). 38 | # rangeStart: 0 39 | # rangeEnd: "Infinity" 40 | # Specify which parser to use. 41 | # Both the babylon and flow parsers support the same set of JavaScript features (including Flow). 42 | # Prettier automatically infers the parser from the input file path, so you shouldn't have to change this setting. 43 | # parser: "typescript" 44 | # Specify the input filepath. This will be used to do parser inference. 45 | filepath: "none" 46 | 47 | # Prettier can restrict itself to only format files that contain a special comment, called a pragma, at the top of the file. 48 | # This is very useful when gradually transitioning large, unformatted codebases to prettier. 49 | requirePragma: false 50 | 51 | # Prettier can insert a special @format marker at the top of files specifying that the file has been formatted with prettier. 52 | # This works well when used in tandem with the --require-pragma option. If there is already a docblock at the top of the file 53 | # then this option will add a newline to it with the @format marker. 54 | insertPragma: false 55 | 56 | # By default, Prettier will wrap markdown text as-is since some services use a linebreak-sensitive renderer, 57 | # e.g. GitHub comment and BitBucket. In some cases you may want to rely on editor/viewer soft wrapping instead, so this option 58 | # allows you to opt out with "never". 59 | proseWrap: "preserve" 60 | 61 | # Specify the global whitespace sensitivity for HTML files, see whitespace-sensitive formatting for more info. 62 | # https://prettier.io/blog/2018/11/07/1.15.0.html#whitespace-sensitive-formatting 63 | htmlWhitespaceSensitivity: "strict" 64 | 65 | # For historical reasons, there exist two commonly used flavors of line endings in text files. 66 | endOfLine: "lf" 67 | -------------------------------------------------------------------------------- /stories/2-nested-horizontal/static.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | 4 | import { 5 | DropLineRendererInjectedProps, 6 | GhostRendererMeta, 7 | Item, 8 | List, 9 | PlaceholderRendererInjectedProps, 10 | PlaceholderRendererMeta, 11 | StackedGroupRendererInjectedProps, 12 | } from "../../src"; 13 | 14 | import { commonStyles } from "../shared"; 15 | import { styles } from "./shared"; 16 | 17 | type DummyItem = { id: string; title: string; children: DummyItem[] | undefined }; 18 | 19 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 20 |
25 | ); 26 | const renderGhostElement = ({ isGroup }: GhostRendererMeta) => ( 27 |
28 | ); 29 | const renderPlaceholderElement = ( 30 | injectedProps: PlaceholderRendererInjectedProps, 31 | { isGroup }: PlaceholderRendererMeta, 32 | ) => ( 33 |
37 | ); 38 | const renderStackedGroupElement = (injectedProps: StackedGroupRendererInjectedProps) => ( 39 |
40 | ); 41 | 42 | export const StaticComponent = () => ( 43 | false} 51 | > 52 | 53 |
Item A
54 |
55 | 56 |
57 |
Group B
58 | 59 |
Item B - 1
60 |
61 | 62 |
63 |
Group B - 2
64 | 65 |
Item B - 2 - 1
66 |
67 |
68 |
69 | 70 |
71 |
Group B - 3
72 |
73 |
74 | 75 |
Item B - 4
76 |
77 |
78 |
79 | 80 |
Item C
81 |
82 |
83 | ); 84 | -------------------------------------------------------------------------------- /stories/1-simple-vertical/dynamic.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | import arrayMove from "array-move"; 4 | 5 | import { 6 | DragEndMeta, 7 | DropLineRendererInjectedProps, 8 | GhostRendererMeta, 9 | Item, 10 | List, 11 | PlaceholderRendererInjectedProps, 12 | PlaceholderRendererMeta, 13 | } from "../../src"; 14 | 15 | import { commonStyles } from "../shared"; 16 | import { styles } from "./shared"; 17 | 18 | type DummyItem = { id: string; title: string }; 19 | 20 | const initialItems: DummyItem[] = [ 21 | { id: "a", title: "Item A" }, 22 | { id: "b", title: "Item B" }, 23 | { id: "c", title: "Item C" }, 24 | { id: "d", title: "Item D" }, 25 | { id: "e", title: "Item E" }, 26 | ]; 27 | 28 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 29 |
30 | ); 31 | 32 | type Props = { 33 | isDisabled?: boolean; 34 | }; 35 | 36 | export const DynamicComponent = (props: Props) => { 37 | const [itemsState, setItemsState] = React.useState(initialItems); 38 | const itemsById = React.useMemo( 39 | () => 40 | itemsState.reduce>((object, item) => { 41 | object[item.id] = item; 42 | 43 | return object; 44 | }, {}), 45 | [itemsState], 46 | ); 47 | 48 | const itemElements = React.useMemo( 49 | () => 50 | itemsState.map((item, index) => ( 51 | 52 |
{item.title}
53 |
54 | )), 55 | [itemsState], 56 | ); 57 | const renderGhostElement = React.useCallback( 58 | ({ identifier }: GhostRendererMeta) => { 59 | const item = itemsById[identifier]; 60 | 61 | return
{item.title}
; 62 | }, 63 | [itemsById], 64 | ); 65 | const renderPlaceholderElement = React.useCallback( 66 | (injectedProps: PlaceholderRendererInjectedProps, { identifier }: PlaceholderRendererMeta) => { 67 | const item = itemsById[identifier]; 68 | 69 | return ( 70 |
71 | {item.title} 72 |
73 | ); 74 | }, 75 | [itemsById], 76 | ); 77 | 78 | const onDragEnd = React.useCallback( 79 | (meta: DragEndMeta) => { 80 | if (meta.nextIndex == undefined) return; 81 | 82 | setItemsState(arrayMove(itemsState, meta.index, meta.nextIndex)); 83 | }, 84 | [itemsState], 85 | ); 86 | 87 | return ( 88 | 96 | {itemElements} 97 | 98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /stories/1-simple-horizontal/dynamic.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | import arrayMove from "array-move"; 4 | 5 | import { 6 | DragEndMeta, 7 | DropLineRendererInjectedProps, 8 | GhostRendererMeta, 9 | Item, 10 | List, 11 | PlaceholderRendererInjectedProps, 12 | PlaceholderRendererMeta, 13 | } from "../../src"; 14 | 15 | import { commonStyles } from "../shared"; 16 | import { styles } from "./shared"; 17 | 18 | type DummyItem = { id: string; title: string }; 19 | 20 | const initialItems: DummyItem[] = [ 21 | { id: "a", title: "Item A" }, 22 | { id: "b", title: "Item B" }, 23 | { id: "c", title: "Item C" }, 24 | { id: "d", title: "Item D" }, 25 | { id: "e", title: "Item E" }, 26 | ]; 27 | 28 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 29 |
34 | ); 35 | 36 | type Props = { 37 | isDisabled?: boolean; 38 | }; 39 | 40 | export const DynamicComponent = (props: Props) => { 41 | const [itemsState, setItemsState] = React.useState(initialItems); 42 | const itemsById = React.useMemo( 43 | () => 44 | itemsState.reduce>((object, item) => { 45 | object[item.id] = item; 46 | 47 | return object; 48 | }, {}), 49 | [itemsState], 50 | ); 51 | 52 | const itemElements = React.useMemo( 53 | () => 54 | itemsState.map((item, index) => ( 55 | 56 |
{item.title}
57 |
58 | )), 59 | [itemsState], 60 | ); 61 | const renderGhostElement = React.useCallback( 62 | ({ identifier }: GhostRendererMeta) => { 63 | const item = itemsById[identifier]; 64 | 65 | return
{item.title}
; 66 | }, 67 | [itemsById], 68 | ); 69 | const renderPlaceholderElement = React.useCallback( 70 | (injectedProps: PlaceholderRendererInjectedProps, { identifier }: PlaceholderRendererMeta) => { 71 | const item = itemsById[identifier]; 72 | 73 | return ( 74 |
75 | {item.title} 76 |
77 | ); 78 | }, 79 | [itemsById], 80 | ); 81 | 82 | const onDragEnd = React.useCallback( 83 | (meta: DragEndMeta) => { 84 | if (meta.nextIndex == undefined) return; 85 | 86 | setItemsState(arrayMove(itemsState, meta.index, meta.nextIndex)); 87 | }, 88 | [itemsState], 89 | ); 90 | 91 | return ( 92 | 101 | {itemElements} 102 | 103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /stories/1-simple-vertical/dynamic-partial-locked.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | import arrayMove from "array-move"; 4 | 5 | import { 6 | DragEndMeta, 7 | DropLineRendererInjectedProps, 8 | GhostRendererMeta, 9 | Item, 10 | List, 11 | PlaceholderRendererInjectedProps, 12 | PlaceholderRendererMeta, 13 | } from "../../src"; 14 | 15 | import { commonStyles } from "../shared"; 16 | import { styles } from "./shared"; 17 | 18 | type DummyItem = { id: string; title: string }; 19 | 20 | const initialItems: DummyItem[] = [ 21 | { id: "a", title: "Item A" }, 22 | { id: "b", title: "Item B" }, 23 | { id: "c", title: "Item C" }, 24 | { id: "d", title: "Item D" }, 25 | { id: "e", title: "Item E" }, 26 | ]; 27 | const lockedItemIds = initialItems.filter((_, index) => index % 2 === 0).map((item) => item.id); 28 | 29 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 30 |
31 | ); 32 | 33 | export const DynamicPartialLockedComponent = () => { 34 | const [itemsState, setItemsState] = React.useState(initialItems); 35 | const itemsById = React.useMemo( 36 | () => 37 | itemsState.reduce>((object, item) => { 38 | object[item.id] = item; 39 | 40 | return object; 41 | }, {}), 42 | [itemsState], 43 | ); 44 | 45 | const itemElements = React.useMemo( 46 | () => 47 | itemsState.map((item, index) => { 48 | const isLocked = lockedItemIds.includes(item.id); 49 | 50 | return ( 51 | 52 |
{item.title}
53 |
54 | ); 55 | }), 56 | [itemsState], 57 | ); 58 | const renderGhostElement = React.useCallback( 59 | ({ identifier }: GhostRendererMeta) => { 60 | const item = itemsById[identifier]; 61 | 62 | return
{item.title}
; 63 | }, 64 | [itemsById], 65 | ); 66 | const renderPlaceholderElement = React.useCallback( 67 | (injectedProps: PlaceholderRendererInjectedProps, { identifier }: PlaceholderRendererMeta) => { 68 | const item = itemsById[identifier]; 69 | 70 | return ( 71 |
72 | {item.title} 73 |
74 | ); 75 | }, 76 | [itemsById], 77 | ); 78 | 79 | const onDragEnd = React.useCallback( 80 | (meta: DragEndMeta) => { 81 | if (meta.nextIndex == undefined) return; 82 | 83 | setItemsState(arrayMove(itemsState, meta.index, meta.nextIndex)); 84 | }, 85 | [itemsState], 86 | ); 87 | 88 | return ( 89 | 96 | {itemElements} 97 | 98 | ); 99 | }; 100 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | ## Our Pledge 3 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making 4 | participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, 5 | disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or 6 | sexual identity and orientation. 7 | 8 | 9 | ## Our Standards 10 | Examples of behavior that contributes to creating a positive environment include: 11 | 12 | - Using welcoming and inclusive language 13 | - Being respectful of differing viewpoints and experiences 14 | - Gracefully accepting constructive criticism 15 | - Focusing on what is best for the community 16 | - Showing empathy towards other community members 17 | 18 | Examples of unacceptable behavior by participants include: 19 | 20 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 21 | - Trolling, insulting/derogatory comments, and personal or political attacks 22 | - Public or private harassment 23 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 24 | - Other conduct which could reasonably be considered inappropriate in a professional setting 25 | 26 | 27 | ## Our Responsibilities 28 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and 29 | fair corrective action in response to any instances of unacceptable behavior. 30 | 31 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, 32 | and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for 33 | other behaviors that they deem inappropriate, threatening, offensive, or harmful. 34 | 35 | 36 | ## Scope 37 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or 38 | its community. Examples of representing a project or community include using an official project e-mail address, posting via an 39 | official social media account, or acting as an appointed representative at an online or offline event. Representation of a 40 | project may be further defined and clarified by project maintainers. 41 | 42 | 43 | ## Enforcement 44 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at 45 | `jagaapple+github@uniboar.com`. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and 46 | appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an 47 | incident. Further details of specific enforcement policies may be posted separately. 48 | 49 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions 50 | as determined by other members of the project's leadership. 51 | 52 | 53 | ## Attribution 54 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 55 | available at [http://contributor-covenant.org/version/1/4][version] 56 | 57 | [homepage]: http://contributor-covenant.org 58 | [version]: http://contributor-covenant.org/version/1/4/ 59 | -------------------------------------------------------------------------------- /stories/1-simple-horizontal/dynamic-partial-locked.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | import arrayMove from "array-move"; 4 | 5 | import { 6 | DragEndMeta, 7 | DropLineRendererInjectedProps, 8 | GhostRendererMeta, 9 | Item, 10 | List, 11 | PlaceholderRendererInjectedProps, 12 | PlaceholderRendererMeta, 13 | } from "../../src"; 14 | 15 | import { commonStyles } from "../shared"; 16 | import { styles } from "./shared"; 17 | 18 | type DummyItem = { id: string; title: string }; 19 | 20 | const initialItems: DummyItem[] = [ 21 | { id: "a", title: "Item A" }, 22 | { id: "b", title: "Item B" }, 23 | { id: "c", title: "Item C" }, 24 | { id: "d", title: "Item D" }, 25 | { id: "e", title: "Item E" }, 26 | ]; 27 | const lockedItemIds = initialItems.filter((_, index) => index % 2 === 0).map((item) => item.id); 28 | 29 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 30 |
35 | ); 36 | 37 | export const DynamicPartialLockedComponent = () => { 38 | const [itemsState, setItemsState] = React.useState(initialItems); 39 | const itemsById = React.useMemo( 40 | () => 41 | itemsState.reduce>((object, item) => { 42 | object[item.id] = item; 43 | 44 | return object; 45 | }, {}), 46 | [itemsState], 47 | ); 48 | 49 | const itemElements = React.useMemo( 50 | () => 51 | itemsState.map((item, index) => { 52 | const isLocked = lockedItemIds.includes(item.id); 53 | 54 | return ( 55 | 56 |
{item.title}
57 |
58 | ); 59 | }), 60 | [itemsState], 61 | ); 62 | const renderGhostElement = React.useCallback( 63 | ({ identifier }: GhostRendererMeta) => { 64 | const item = itemsById[identifier]; 65 | 66 | return
{item.title}
; 67 | }, 68 | [itemsById], 69 | ); 70 | const renderPlaceholderElement = React.useCallback( 71 | (injectedProps: PlaceholderRendererInjectedProps, { identifier }: PlaceholderRendererMeta) => { 72 | const item = itemsById[identifier]; 73 | 74 | return ( 75 |
76 | {item.title} 77 |
78 | ); 79 | }, 80 | [itemsById], 81 | ); 82 | 83 | const onDragEnd = React.useCallback( 84 | (meta: DragEndMeta) => { 85 | if (meta.nextIndex == undefined) return; 86 | 87 | setItemsState(arrayMove(itemsState, meta.index, meta.nextIndex)); 88 | }, 89 | [itemsState], 90 | ); 91 | 92 | return ( 93 | 101 | {itemElements} 102 | 103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-sortful", 3 | "version": "0.1.1", 4 | "description": "Sortable components for horizontal and vertical, nested, and tree forms.", 5 | "keywords": [ 6 | "React", 7 | "drag", 8 | "dnd", 9 | "sort", 10 | "arrange", 11 | "nest", 12 | "tree" 13 | ], 14 | "homepage": "https://github.com/jagaapple/react-sortful", 15 | "bugs": "https://github.com/jagaapple/react-sortful/issues", 16 | "license": "MIT", 17 | "author": "Jaga Apple", 18 | "contributors": [], 19 | "files": [ 20 | "lib", 21 | "CHANGELOG.md", 22 | "CODE_OF_CONDUCT.md", 23 | "LICENSE", 24 | "package.json", 25 | "README.md" 26 | ], 27 | "main": "lib/index.js", 28 | "bin": "", 29 | "man": "", 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/jagaapple/react-sortful.git" 33 | }, 34 | "scripts": { 35 | "prebuild": "rm -rf ./lib", 36 | "build": "tsc --project \"./tsconfig.build.json\"", 37 | "lint": "tsc --noEmit && eslint \"./**/*.ts\" \"./**/*.tsx\" && stylelint \"./stories/**/*.css\"", 38 | "fix:ts": "eslint --fix \"./**/*.ts\" \"./**/*.tsx\"", 39 | "fix:css": "stylelint \"./stories/**/*.css\" --fix", 40 | "tcm": "tcm \"./stories\"", 41 | "prepublishOnly": "npm run build", 42 | "test": "jest --coverage", 43 | "coverage": "codecov", 44 | "clean": "rm -rf ./lib ./coverage", 45 | "storybook": "start-storybook --port 5000", 46 | "build-storybook": "build-storybook --config-dir ./.storybook --output-dir ./.out", 47 | "chromatic": "CHROMATIC_APP_CODE=kmmyjbiwtyq chromatic --auto-accept-changes" 48 | }, 49 | "config": {}, 50 | "dependencies": { 51 | "react-use-gesture": "^7.0.4" 52 | }, 53 | "devDependencies": { 54 | "@babel/core": "^7.8.6", 55 | "@babel/preset-env": "^7.8.6", 56 | "@babel/preset-react": "^7.8.3", 57 | "@babel/preset-typescript": "^7.8.3", 58 | "@storybook/addon-backgrounds": "^5.3.14", 59 | "@storybook/addon-storysource": "^5.3.14", 60 | "@storybook/react": "^5.3.14", 61 | "@types/classnames": "^2.2.9", 62 | "@types/jest": "^25.1.3", 63 | "@types/jest-plugin-context": "^2.9.2", 64 | "@types/react": "^16.9.23", 65 | "@types/react-dom": "^16.9.5", 66 | "@types/sinon": "^7.5.2", 67 | "@typescript-eslint/eslint-plugin": "^2.22.0", 68 | "@typescript-eslint/parser": "^2.22.0", 69 | "array-move": "^2.2.1", 70 | "babel-loader": "^8.0.6", 71 | "classnames": "^2.2.6", 72 | "codecov": "^3.6.5", 73 | "eslint": "^6.8.0", 74 | "eslint-config-prettier": "^6.10.0", 75 | "eslint-plugin-import": "^2.20.1", 76 | "eslint-plugin-prettier": "^3.1.2", 77 | "eslint-plugin-react": "^7.18.3", 78 | "jest": "^25.1.0", 79 | "jest-plugin-context": "^2.9.0", 80 | "prettier": "^1.19.1", 81 | "react": "^16.13.0", 82 | "react-dom": "^16.13.0", 83 | "sinon": "^9.0.1", 84 | "storybook-chromatic": "^3.5.2", 85 | "stylelint": "^13.2.1", 86 | "stylelint-order": "^4.0.0", 87 | "stylelint-prettier": "^1.1.2", 88 | "ts-jest": "^25.2.1", 89 | "typed-css-modules": "^0.6.3", 90 | "typescript": "^3.8.3" 91 | }, 92 | "peerDependencies": { 93 | "react": ">=16.8.0", 94 | "react-dom": ">=16.8.0" 95 | }, 96 | "engines": { 97 | "node": ">=10.0.0" 98 | }, 99 | "engineStrict": false, 100 | "preferGlobal": false, 101 | "private": false 102 | } 103 | -------------------------------------------------------------------------------- /stories/2-nested-vertical/static.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | 4 | import { 5 | DropLineRendererInjectedProps, 6 | GhostRendererMeta, 7 | Item, 8 | List, 9 | PlaceholderRendererInjectedProps, 10 | PlaceholderRendererMeta, 11 | StackedGroupRendererInjectedProps, 12 | } from "../../src"; 13 | 14 | import { commonStyles } from "../shared"; 15 | import { styles } from "./shared"; 16 | 17 | type DummyItem = { id: string; title: string; children: DummyItem[] | undefined }; 18 | 19 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 20 |
21 | ); 22 | const renderGhostElement = ({ isGroup }: GhostRendererMeta) => ( 23 |
24 | ); 25 | const renderPlaceholderElement = ( 26 | injectedProps: PlaceholderRendererInjectedProps, 27 | { isGroup }: PlaceholderRendererMeta, 28 | ) => ( 29 |
33 | ); 34 | const renderStackedGroupElement = (injectedProps: StackedGroupRendererInjectedProps) => ( 35 |
36 | ); 37 | 38 | export const StaticComponent = () => ( 39 | false} 46 | > 47 | 48 |
Item A
49 |
50 | 51 |
Item B
52 |
53 | 54 |
Item C
55 |
56 | 57 |
Item D
58 |
59 | 60 |
61 |
Group E
62 | 63 |
Item E - 1
64 |
65 | 66 |
Item E - 2
67 |
68 | 69 |
Item E - 3
70 |
71 | 72 |
Item E - 4
73 |
74 | 75 |
76 |
Group E - 5
77 | 78 |
Item E - 5 - 1
79 |
80 |
81 |
82 | 83 |
84 |
Group E - 6
85 |
86 |
87 | 88 |
Item E - 7
89 |
90 |
91 |
92 | 93 |
Item F
94 |
95 |
96 | ); 97 | -------------------------------------------------------------------------------- /stories/3-advanced-examples/custom-drag-handle.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | import arrayMove from "array-move"; 4 | 5 | import { 6 | DragEndMeta, 7 | DragHandleComponent, 8 | DropLineRendererInjectedProps, 9 | GhostRendererMeta, 10 | Item, 11 | List, 12 | PlaceholderRendererInjectedProps, 13 | PlaceholderRendererMeta, 14 | } from "../../src"; 15 | 16 | import styles from "./custom-drag-handle.css"; 17 | 18 | type DummyItem = { id: string; title: string }; 19 | 20 | const initialItems: DummyItem[] = [ 21 | { id: "a", title: "Item A" }, 22 | { id: "b", title: "Item B" }, 23 | { id: "c", title: "Item C" }, 24 | { id: "d", title: "Item D" }, 25 | { id: "e", title: "Item E" }, 26 | ]; 27 | 28 | const dotsSVG = ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 39 |
40 | ); 41 | 42 | export const CustomDragHandleComponent = () => { 43 | const [itemsState, setItemsState] = React.useState(initialItems); 44 | const itemsById = React.useMemo( 45 | () => 46 | itemsState.reduce>((object, item) => { 47 | object[item.id] = item; 48 | 49 | return object; 50 | }, {}), 51 | [itemsState], 52 | ); 53 | 54 | const itemElements = React.useMemo( 55 | () => 56 | itemsState.map((item, index) => ( 57 | 58 |
59 | {dotsSVG} 60 | 61 | {item.title} 62 |
63 |
64 | )), 65 | [itemsState], 66 | ); 67 | const renderGhostElement = React.useCallback( 68 | ({ identifier }: GhostRendererMeta) => { 69 | const item = itemsById[identifier]; 70 | 71 | return ( 72 |
73 |
{dotsSVG}
74 | 75 | {item.title} 76 |
77 | ); 78 | }, 79 | [itemsById], 80 | ); 81 | const renderPlaceholderElement = React.useCallback( 82 | (injectedProps: PlaceholderRendererInjectedProps, { identifier }: PlaceholderRendererMeta) => { 83 | const item = itemsById[identifier]; 84 | 85 | return ( 86 |
87 | {dotsSVG} 88 | 89 | {item.title} 90 |
91 | ); 92 | }, 93 | [itemsById], 94 | ); 95 | 96 | const onDragEnd = React.useCallback( 97 | (meta: DragEndMeta) => { 98 | if (meta.nextIndex == undefined) return; 99 | 100 | setItemsState(arrayMove(itemsState, meta.index, meta.nextIndex)); 101 | }, 102 | [itemsState], 103 | ); 104 | 105 | return ( 106 | 115 | {itemElements} 116 | 117 | ); 118 | }; 119 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | ## Language 3 | Git branch names and commit messages, and GitHub pull request should be written in English in order to be readable for 4 | developers around the world. 5 | 6 | 7 | ## Git branch flow 8 | We adhere GitHub Flow to develop this project. Anything in the `master` branch is deployable. To work on something new, create 9 | a descriptively named branch off of master, also add a prefix `feature/` to its name. 10 | A branch name should be started with verb and the most terse and lucid possible. 11 | 12 | ```bash 13 | # Example 14 | feature/implement-xxx 15 | feature/support-xxx-for-xxx 16 | feature/fix-xxx-bugs 17 | ``` 18 | 19 | For more details, see [GitHub Flow – Scott Chacon](http://scottchacon.com/2011/08/31/github-flow.html). 20 | 21 | 22 | ## Git commit messages convention 23 | Follow the following format for Git commit messages. 24 | 25 | ```bash 26 | # Format 27 | [] 28 | 29 | - 30 | - 31 | - 32 | ``` 33 | 34 | ### `` 35 | One commit should have only one purpose, so you should add the following commit type to beginning of line 1 of the commit 36 | message. 37 | 38 | | TYPE | USE CASE | COMMENTS | 39 | |:---------|:----------------------------------------------------------|:------------------------------------------------------------------------------| 40 | | `Add` | Implement functions/Add files/Support new platform | | 41 | | `Change` | Change current spec | Use this type when breaking changes are happened, otherwise DO NOT use. | 42 | | `Fix` | Fix bugs | Use this type when you fix bugs, otherwise DO NOT use. | 43 | | `Modify` | Modify wording | Use this type when breaking changes are not happened and fix other than bugs. | 44 | | `Clean` | Refactor some codes/Rename classes, methods, or variables | | 45 | | `Remove` | Remove unneeded files or libraries | | 46 | | `Update` | Update dependencies or this project version | | 47 | 48 | ```bash 49 | # Example 50 | [Add] Implement sign up system 51 | [Clean] Rename XXXClass to YYYClass 52 | 53 | # BAD 54 | [Add]Implement sign up system 55 | Implement sign up system 56 | [ADD] Implement sign up system 57 | [add] Implement sign up system 58 | Add Implement sign up system 59 | ``` 60 | 61 | ### `` 62 | `` is a summary of changes, do not exceed 50 characters including a commit type. Do not include period `.` because 63 | a summary should be expressed one sentence. Also start with upper case. 64 | 65 | ```bash 66 | # Example 67 | [Add] Implement sign up system 68 | [Clean] Rename XXXClass to YYYClass 69 | 70 | # BAD 71 | [Add] implement sign up system 72 | [Add] Implement sign up system. Because ... 73 | ``` 74 | 75 | ### `` (Optional) 76 | `` is a description what was changed in the commit. Start with upper case and write one description line by line, 77 | also do not include period `.` . 78 | 79 | ```bash 80 | # Example 81 | [Add] Implement sign up system 82 | 83 | - Add sign up pages 84 | - Add sign up form styles 85 | 86 | # BAD 87 | [Add] Implement sign up system 88 | - Add sign up pages 89 | - Add sign up form styles 90 | 91 | # BAD 92 | [Add] Implement sign up system 93 | 94 | - Add sign up pages. 95 | - Add sign up form styles. 96 | 97 | # BAD 98 | [Add] Implement sign up system 99 | 100 | - add sign up pages 101 | - add sign up form styles 102 | ``` 103 | -------------------------------------------------------------------------------- /src/item/ghosts.spec.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | 3 | import { Direction } from "../shared"; 4 | import { clearGhostElementStyle, initializeGhostElementStyle, moveGhostElement } from "./ghosts"; 5 | 6 | describe("initializeGhostElementStyle", () => { 7 | const itemSpacing = 10; 8 | 9 | afterEach(() => { 10 | sinon.restore(); 11 | }); 12 | 13 | context("when `ghostWrapperElement` is undefined", () => { 14 | it("should not throw any errors", () => { 15 | const dummyBoundingClientRect = { width: 10, height: 20, top: 30, left: 40 }; 16 | const boundingRectGetterSpy = sinon.mock().returns(dummyBoundingClientRect); 17 | const itemElement = ({ getBoundingClientRect: boundingRectGetterSpy } as any) as HTMLElement; 18 | 19 | expect(() => initializeGhostElementStyle(itemElement, undefined, itemSpacing, "horizontal")).not.toThrowError(); 20 | }); 21 | }); 22 | 23 | context('when `direction` is "vertical"', () => { 24 | const direction: Direction = "vertical"; 25 | 26 | it("should set `ghostWrapperElement.style` to proper values", () => { 27 | const dummyBoundingClientRect = { width: 10, height: 20, top: 30, left: 40 }; 28 | const boundingRectGetterSpy = sinon.mock().returns(dummyBoundingClientRect); 29 | const itemElement = ({ getBoundingClientRect: boundingRectGetterSpy } as any) as HTMLElement; 30 | const ghostWrapperElement = { style: {} } as HTMLElement; 31 | 32 | initializeGhostElementStyle(itemElement, ghostWrapperElement, itemSpacing, direction); 33 | expect(ghostWrapperElement.style.top).toBe(`${dummyBoundingClientRect.top + itemSpacing / 2}px`); 34 | expect(ghostWrapperElement.style.left).toBe(`${dummyBoundingClientRect.left}px`); 35 | expect(ghostWrapperElement.style.width).toBe(`${dummyBoundingClientRect.width}px`); 36 | expect(ghostWrapperElement.style.height).toBe(`${dummyBoundingClientRect.height - itemSpacing}px`); 37 | }); 38 | }); 39 | 40 | context('when `direction` is "horizontal"', () => { 41 | const direction: Direction = "horizontal"; 42 | 43 | it("should set `ghostWrapperElement.style` to proper values", () => { 44 | const dummyBoundingClientRect = { width: 10, height: 20, top: 30, left: 40 }; 45 | const boundingRectGetterSpy = sinon.mock().returns(dummyBoundingClientRect); 46 | const itemElement = ({ getBoundingClientRect: boundingRectGetterSpy } as any) as HTMLElement; 47 | const ghostWrapperElement = { style: {} } as HTMLElement; 48 | 49 | initializeGhostElementStyle(itemElement, ghostWrapperElement, itemSpacing, direction); 50 | expect(ghostWrapperElement.style.top).toBe(`${dummyBoundingClientRect.top}px`); 51 | expect(ghostWrapperElement.style.left).toBe(`${dummyBoundingClientRect.left + itemSpacing / 2}px`); 52 | expect(ghostWrapperElement.style.width).toBe(`${dummyBoundingClientRect.width - itemSpacing}px`); 53 | expect(ghostWrapperElement.style.height).toBe(`${dummyBoundingClientRect.height}px`); 54 | }); 55 | }); 56 | }); 57 | 58 | describe("moveGhostElement", () => { 59 | context("when `ghostWrapperElement` is undefined", () => { 60 | it("should not throw any errors", () => { 61 | expect(() => moveGhostElement(undefined, [0, 0])).not.toThrowError(); 62 | }); 63 | }); 64 | 65 | it("should set `ghostWrapperElement.style.transform` to `movementXY`", () => { 66 | const ghostWrapperElement = { style: {} } as HTMLElement; 67 | const movementXY: [number, number] = [123, 456]; 68 | 69 | moveGhostElement(ghostWrapperElement, movementXY); 70 | expect(ghostWrapperElement.style.transform).toBe(`translate3d(${movementXY[0]}px, ${movementXY[1]}px, 0)`); 71 | }); 72 | }); 73 | 74 | describe("clearGhostElementStyle", () => { 75 | afterEach(() => { 76 | sinon.restore(); 77 | }); 78 | 79 | context("when `ghostWrapperElement` is undefined", () => { 80 | it("should not throw any errors", () => { 81 | expect(() => clearGhostElementStyle(undefined)).not.toThrowError(); 82 | }); 83 | }); 84 | 85 | it("should set `ghostWrapperElement.style.transform` to `movementXY`", () => { 86 | const propertyRemoverSpy = sinon.spy(); 87 | const ghostWrapperElement = ({ style: { removeProperty: propertyRemoverSpy } } as any) as HTMLElement; 88 | 89 | clearGhostElementStyle(ghostWrapperElement); 90 | expect(propertyRemoverSpy.calledWith("width")).toBe(true); 91 | expect(propertyRemoverSpy.calledWith("height")).toBe(true); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /src/item/body.spec.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | 3 | import { clearBodyStyle, setBodyStyle } from "./body"; 4 | 5 | describe("setBodyStyle", () => { 6 | let dummyDocumentElement: Document; 7 | let dummyElement: HTMLElement; 8 | let elementCreatorMock: sinon.SinonStub, ReturnType>; 9 | let elementAttributeSetterSpy: sinon.SinonSpy, ReturnType>; 10 | let childAppenderSpy: sinon.SinonSpy, ReturnType>; 11 | 12 | beforeEach(() => { 13 | elementAttributeSetterSpy = sinon.spy() as any; 14 | dummyElement = { setAttribute: elementAttributeSetterSpy, textContent: "" } as any; 15 | 16 | elementCreatorMock = sinon.mock().returns(dummyElement) as any; 17 | childAppenderSpy = sinon.spy() as any; 18 | dummyDocumentElement = { createElement: elementCreatorMock, head: { appendChild: childAppenderSpy } } as any; 19 | }); 20 | afterEach(() => { 21 | sinon.restore(); 22 | }); 23 | 24 | it('should set `style.userSelect` to "none"', () => { 25 | const bodyElement = { style: {} } as HTMLElement; 26 | 27 | setBodyStyle(bodyElement, undefined, dummyDocumentElement); 28 | expect(bodyElement.style.userSelect).toBe("none"); 29 | }); 30 | 31 | context("when `draggingCusrsorStyle` is undefined", () => { 32 | it("should not call `document.createElement`", () => { 33 | const bodyElement = { style: {} } as HTMLElement; 34 | 35 | setBodyStyle(bodyElement, undefined, dummyDocumentElement); 36 | expect(elementCreatorMock.called).toBe(false); 37 | }); 38 | 39 | it("should not call `document.head.appendChild`", () => { 40 | const bodyElement = { style: {} } as HTMLElement; 41 | 42 | setBodyStyle(bodyElement, undefined, dummyDocumentElement); 43 | expect(childAppenderSpy.called).toBe(false); 44 | }); 45 | }); 46 | 47 | context("when `draggingCusrsorStyle` is not undefined", () => { 48 | const draggingCusrsorStyle = "dummy"; 49 | 50 | it('should set `style.userSelect` to "none"', () => { 51 | const bodyElement = { style: {} } as HTMLElement; 52 | 53 | setBodyStyle(bodyElement, draggingCusrsorStyle, dummyDocumentElement); 54 | expect(bodyElement.style.userSelect).toBe("none"); 55 | }); 56 | 57 | it("should create a style element, set attributes, and append to a document element", () => { 58 | const bodyElement = { style: {} } as HTMLElement; 59 | 60 | setBodyStyle(bodyElement, draggingCusrsorStyle, dummyDocumentElement); 61 | expect(elementCreatorMock.calledOnceWith("style")).toBe(true); 62 | expect(dummyElement.textContent).toBe(`* { cursor: ${draggingCusrsorStyle} !important; }`); 63 | expect(elementAttributeSetterSpy.calledOnceWith("id", "react-sortful-global-style")).toBe(true); 64 | expect(childAppenderSpy.calledOnceWith(dummyElement)).toBe(true); 65 | }); 66 | }); 67 | }); 68 | 69 | describe("clearBodyStyle", () => { 70 | let bodyElement = {} as HTMLElement; 71 | let propertyRemoverSpy: sinon.SinonSpy< 72 | Parameters, 73 | ReturnType 74 | >; 75 | let dummyDocumentElement: Document; 76 | let dummyElement: HTMLElement; 77 | let elementGetterMock: sinon.SinonStub, ReturnType>; 78 | let elementRemoverSpy: sinon.SinonSpy, ReturnType>; 79 | 80 | beforeEach(() => { 81 | propertyRemoverSpy = sinon.spy() as any; 82 | bodyElement = { style: { removeProperty: propertyRemoverSpy } } as any; 83 | 84 | elementRemoverSpy = sinon.spy() as any; 85 | dummyElement = { remove: elementRemoverSpy } as any; 86 | 87 | elementGetterMock = sinon.mock().returns(dummyElement) as any; 88 | dummyDocumentElement = { getElementById: elementGetterMock } as any; 89 | }); 90 | afterEach(() => { 91 | sinon.restore(); 92 | }); 93 | 94 | it('should call `style.removeProperty` with "user-select" of `bodyElement`', () => { 95 | clearBodyStyle(bodyElement, dummyDocumentElement); 96 | 97 | expect(propertyRemoverSpy.calledWith("user-select")).toBe(true); 98 | }); 99 | 100 | it("should remove a style element created by react-sortful", () => { 101 | clearBodyStyle(bodyElement, dummyDocumentElement); 102 | 103 | expect(elementGetterMock.calledOnceWith("react-sortful-global-style")).toBe(true); 104 | expect(elementRemoverSpy.calledOnce).toBe(true); 105 | }); 106 | 107 | context("when `documentElement.getElementById` returns null", () => { 108 | beforeEach(() => { 109 | elementGetterMock = sinon.mock().returns(null) as any; 110 | dummyDocumentElement = { getElementById: elementGetterMock } as any; 111 | }); 112 | 113 | it("should not raise any errors", () => { 114 | expect(() => clearBodyStyle(bodyElement, dummyDocumentElement)).not.toThrowError(); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /src/list.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { Direction, ItemIdentifier, NodeMeta } from "./shared"; 4 | import { 5 | DestinationMeta, 6 | DragEndMeta, 7 | DragStartMeta, 8 | DropLineRendererInjectedProps, 9 | GhostRendererMeta, 10 | ListContext, 11 | PlaceholderRendererInjectedProps, 12 | PlaceholderRendererMeta, 13 | StackedGroupRendererInjectedProps, 14 | StackedGroupRendererMeta, 15 | StackGroupMeta, 16 | } from "./list"; 17 | 18 | type Props = { 19 | /** 20 | * A function to return an element used as a drop line. 21 | * A drop line is a line to display a destination position to users. 22 | */ 23 | renderDropLine: (injectedProps: DropLineRendererInjectedProps) => React.ReactNode; 24 | /** 25 | * A function to return an element used as a ghost. 26 | * A ghost is an element following a mouse pointer when dragging. 27 | */ 28 | renderGhost: (meta: GhostRendererMeta) => React.ReactNode; 29 | /** 30 | * A function to return an element used as a placeholder. 31 | * A placeholder is an element remaining in place when dragging the element. 32 | */ 33 | renderPlaceholder?: (injectedProps: PlaceholderRendererInjectedProps, meta: PlaceholderRendererMeta) => JSX.Element; 34 | /** A function to render an item element when an empty group item is hovered by a dragged item. */ 35 | renderStackedGroup?: (injectedProps: StackedGroupRendererInjectedProps, meta: StackedGroupRendererMeta) => JSX.Element; 36 | /** 37 | * A spacing size (px) between items. 38 | * @default 8 39 | */ 40 | itemSpacing?: number; 41 | /** 42 | * A threshold size (px) of stackable area for group items. 43 | * @default 8 44 | */ 45 | stackableAreaThreshold?: number; 46 | /** 47 | * A direction to recognize a drop area. 48 | * Note that this will not change styles, so you have to apply styles such as being arranged side by side. 49 | * @default "vertical" 50 | */ 51 | direction?: Direction; 52 | /** A cursor style when dragging. */ 53 | draggingCursorStyle?: React.CSSProperties["cursor"]; 54 | /** 55 | * Whether all items are not able to move, drag, and stack. 56 | * @default false 57 | */ 58 | isDisabled?: boolean; 59 | /** A callback function after starting of dragging. */ 60 | onDragStart?: (meta: DragStartMeta) => void; 61 | /** A callback function after end of dragging. */ 62 | onDragEnd: (meta: DragEndMeta) => void; 63 | /** A callback function when an empty group item is hovered by a dragged item. */ 64 | onStackGroup?: (meta: StackGroupMeta) => void; 65 | className?: string; 66 | children?: React.ReactNode; 67 | }; 68 | 69 | export const List = (props: Props) => { 70 | const [draggingNodeMetaState, setDraggingNodeMetaState] = React.useState>(); 71 | const [isVisibleDropLineElementState, setIsVisibleDropLineElementState] = React.useState(false); 72 | const [stackedGroupIdentifierState, setStackedGroupIdentifierState] = React.useState(); 73 | 74 | const itemSpacing = props.itemSpacing ?? 8; 75 | const stackableAreaThreshold = props.stackableAreaThreshold ?? 8; 76 | const direction = props.direction ?? "vertical"; 77 | const isDisabled = props.isDisabled ?? false; 78 | 79 | const dropLineElementRef = React.useRef(null); 80 | const ghostWrapperElementRef = React.useRef(null); 81 | const hoveredNodeMetaRef = React.useRef>(); 82 | const destinationMetaRef = React.useRef>(); 83 | 84 | const dropLineElement = React.useMemo(() => { 85 | const style: React.CSSProperties = { 86 | display: isVisibleDropLineElementState ? "block" : "none", 87 | position: "absolute", 88 | top: 0, 89 | left: 0, 90 | transform: direction === "vertical" ? "translate(0, -50%)" : "translate(-50%, 0)", 91 | pointerEvents: "none", 92 | }; 93 | 94 | return props.renderDropLine({ ref: dropLineElementRef, style }); 95 | }, [props.renderDropLine, isVisibleDropLineElementState, direction]); 96 | const ghostElement = React.useMemo(() => { 97 | if (draggingNodeMetaState == undefined) return; 98 | 99 | const { identifier, groupIdentifier, index, isGroup } = draggingNodeMetaState; 100 | 101 | return props.renderGhost({ identifier, groupIdentifier, index, isGroup }); 102 | }, [props.renderGhost, draggingNodeMetaState]); 103 | 104 | const padding: [string, string] = ["0", "0"]; 105 | if (direction === "vertical") padding[0] = `${itemSpacing}px`; 106 | if (direction === "horizontal") padding[1] = `${itemSpacing}px`; 107 | 108 | return ( 109 | 133 |
134 | {props.children} 135 | 136 | {dropLineElement} 137 |
138 | {ghostElement} 139 |
140 |
141 |
142 | ); 143 | }; 144 | -------------------------------------------------------------------------------- /src/shared/drop-lines/directions.spec.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | 3 | import { Direction } from "../items"; 4 | import { NodeMeta } from "../nodes"; 5 | import * as directions from "./direcitons"; 6 | 7 | describe("getDropLineDirection", () => { 8 | const nodeWidth = 256; 9 | const nodeHeight = 128; 10 | 11 | context('when `direction` is "horizontal"', () => { 12 | const direction: Direction = "horizontal"; 13 | 14 | context("`nodeWidth / 2` is less than `pointerX`", () => { 15 | it('should return "LEFT"', () => { 16 | const pointerX = nodeWidth / 2 - 1; 17 | 18 | expect(directions.getDropLineDirection(nodeWidth, nodeHeight, [pointerX, 0], direction)).toBe("LEFT"); 19 | }); 20 | }); 21 | 22 | context("`nodeWidth / 2` is equal to `pointerX`", () => { 23 | it('should return "LEFT"', () => { 24 | const pointerX = nodeWidth / 2; 25 | 26 | expect(directions.getDropLineDirection(nodeWidth, nodeHeight, [pointerX, 0], direction)).toBe("LEFT"); 27 | }); 28 | }); 29 | 30 | context("`nodeWidth / 2` is more than `pointerX`", () => { 31 | it('should return "RIGHT"', () => { 32 | const pointerX = nodeWidth / 2 + 1; 33 | 34 | expect(directions.getDropLineDirection(nodeWidth, nodeHeight, [pointerX, 0], direction)).toBe("RIGHT"); 35 | }); 36 | }); 37 | }); 38 | 39 | context('when `direction` is "vertical"', () => { 40 | const direction: Direction = "vertical"; 41 | 42 | context("`nodeHeight / 2` is less than `pointerY`", () => { 43 | it('should return "TOP"', () => { 44 | const pointerY = nodeHeight / 2 - 1; 45 | 46 | expect(directions.getDropLineDirection(nodeWidth, nodeHeight, [0, pointerY], direction)).toBe("TOP"); 47 | }); 48 | }); 49 | 50 | context("`nodeHeight / 2` is equal to `pointerY`", () => { 51 | it('should return "TOP"', () => { 52 | const pointerY = nodeHeight / 2; 53 | 54 | expect(directions.getDropLineDirection(nodeWidth, nodeHeight, [0, pointerY], direction)).toBe("TOP"); 55 | }); 56 | }); 57 | 58 | context("`nodeHeight / 2` is more than `pointerY`", () => { 59 | it('should return "BOTTOM"', () => { 60 | const pointerY = nodeHeight / 2 + 1; 61 | 62 | expect(directions.getDropLineDirection(nodeWidth, nodeHeight, [0, pointerY], direction)).toBe("BOTTOM"); 63 | }); 64 | }); 65 | }); 66 | 67 | context("when direction is not an allowed string", () => { 68 | it("should return undefined", () => { 69 | expect(directions.getDropLineDirection(nodeWidth, nodeHeight, [0, 0], "dummy" as Direction)).toBeUndefined(); 70 | }); 71 | }); 72 | }); 73 | 74 | describe("getDropLineDirectionFromXY", () => { 75 | const nodeMeta: NodeMeta = { 76 | identifier: 0, 77 | groupIdentifier: undefined, 78 | ancestorIdentifiers: [], 79 | index: 0, 80 | isGroup: false, 81 | element: {} as any, 82 | width: 1212, 83 | height: 3434, 84 | relativePosition: { top: 100, left: 200 }, 85 | absolutePosition: { top: 100, left: 200 }, 86 | }; 87 | const direction: Direction = "horizontal"; 88 | 89 | afterEach(() => { 90 | sinon.restore(); 91 | }); 92 | 93 | context("when `absoluteXY[0]` is less than `nodeMeta.absolutePosition.left`", () => { 94 | const absoluteXY: [number, number] = [nodeMeta.absolutePosition.left - 1, 0]; 95 | 96 | it("should call `getDropLineDirection` with 0 as absolute X", () => { 97 | const stub = sinon.stub(directions, "getDropLineDirection"); 98 | 99 | directions.getDropLineDirectionFromXY(absoluteXY, nodeMeta, direction); 100 | expect(stub.calledOnceWithExactly(nodeMeta.width, nodeMeta.height, [0, 0], direction)).toBe(true); 101 | }); 102 | }); 103 | 104 | context("when `absoluteXY[0]` is equal to `nodeMeta.absolutePosition.left`", () => { 105 | const absoluteXY: [number, number] = [nodeMeta.absolutePosition.left, 0]; 106 | 107 | it("should call `getDropLineDirection` with 0 as absolute X", () => { 108 | const stub = sinon.stub(directions, "getDropLineDirection"); 109 | 110 | directions.getDropLineDirectionFromXY(absoluteXY, nodeMeta, direction); 111 | expect(stub.calledOnceWithExactly(nodeMeta.width, nodeMeta.height, [0, 0], direction)).toBe(true); 112 | }); 113 | }); 114 | 115 | context("when `absoluteXY[0]` is more than to `nodeMeta.absolutePosition.left`", () => { 116 | const absoluteXY: [number, number] = [nodeMeta.absolutePosition.left + 1, 0]; 117 | 118 | it("should call `getDropLineDirection` with a difference as absolute X", () => { 119 | const stub = sinon.stub(directions, "getDropLineDirection"); 120 | 121 | directions.getDropLineDirectionFromXY(absoluteXY, nodeMeta, direction); 122 | expect(stub.calledOnceWithExactly(nodeMeta.width, nodeMeta.height, [1, 0], direction)).toBe(true); 123 | }); 124 | }); 125 | 126 | context("when `absoluteXY[1]` is less than `nodeMeta.absolutePosition.top`", () => { 127 | const absoluteXY: [number, number] = [0, nodeMeta.absolutePosition.top - 1]; 128 | 129 | it("should call `getDropLineDirection` with 0 as absolute Y", () => { 130 | const stub = sinon.stub(directions, "getDropLineDirection"); 131 | 132 | directions.getDropLineDirectionFromXY(absoluteXY, nodeMeta, direction); 133 | expect(stub.calledOnceWithExactly(nodeMeta.width, nodeMeta.height, [0, 0], direction)).toBe(true); 134 | }); 135 | }); 136 | 137 | context("when `absoluteXY[1]` is equal to `nodeMeta.absolutePosition.top`", () => { 138 | const absoluteXY: [number, number] = [0, nodeMeta.absolutePosition.top]; 139 | 140 | it("should call `getDropLineDirection` with 0 as absolute Y", () => { 141 | const stub = sinon.stub(directions, "getDropLineDirection"); 142 | 143 | directions.getDropLineDirectionFromXY(absoluteXY, nodeMeta, direction); 144 | expect(stub.calledOnceWithExactly(nodeMeta.width, nodeMeta.height, [0, 0], direction)).toBe(true); 145 | }); 146 | }); 147 | 148 | context("when `absoluteXY[1]` is more than to `nodeMeta.absolutePosition.top`", () => { 149 | const absoluteXY: [number, number] = [0, nodeMeta.absolutePosition.top + 1]; 150 | 151 | it("should call `getDropLineDirection` with a difference as absolute Y", () => { 152 | const stub = sinon.stub(directions, "getDropLineDirection"); 153 | 154 | directions.getDropLineDirectionFromXY(absoluteXY, nodeMeta, direction); 155 | expect(stub.calledOnceWithExactly(nodeMeta.width, nodeMeta.height, [0, 1], direction)).toBe(true); 156 | }); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /stories/2-nested-horizontal/dynamic.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | import arrayMove from "array-move"; 4 | 5 | import { 6 | DragEndMeta, 7 | DropLineRendererInjectedProps, 8 | GhostRendererMeta, 9 | Item, 10 | List, 11 | PlaceholderRendererInjectedProps, 12 | PlaceholderRendererMeta, 13 | StackedGroupRendererInjectedProps, 14 | StackedGroupRendererMeta, 15 | } from "../../src"; 16 | 17 | import { commonStyles } from "../shared"; 18 | import { styles } from "./shared"; 19 | 20 | type DummyItem = { id: string; title: string; children: DummyItem["id"][] | undefined }; 21 | 22 | const rootItemId = "root"; 23 | const initialItemEntitiesMap = new Map([ 24 | [rootItemId, { id: rootItemId, title: "", children: ["a", "b", "c"] }], 25 | ["a", { id: "a", title: "Item A", children: undefined }], 26 | ["b", { id: "b", title: "Group B", children: ["b-1", "b-2", "b-3", "b-4"] }], 27 | ["b-1", { id: "b-1", title: "Item B - 1", children: undefined }], 28 | ["b-2", { id: "b-2", title: "Group B - 2", children: ["b-2-1"] }], 29 | ["b-2-1", { id: "b-2-1", title: "Item B - 2 - 1", children: undefined }], 30 | ["b-3", { id: "b-3", title: "Group B - 3", children: [] }], 31 | ["b-4", { id: "b-4", title: "Item B - 4", children: undefined }], 32 | ["c", { id: "c", title: "Item C", children: undefined }], 33 | ]); 34 | 35 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 36 |
41 | ); 42 | 43 | type Props = { 44 | isDisabled?: boolean; 45 | }; 46 | 47 | export const DynamicComponent = (props: Props) => { 48 | const [itemEntitiesMapState, setItemEntitiesMapState] = React.useState(initialItemEntitiesMap); 49 | 50 | const itemElements = React.useMemo(() => { 51 | const topLevelItems = itemEntitiesMapState.get(rootItemId)!.children!.map((itemId) => itemEntitiesMapState.get(itemId)!); 52 | const createItemElement = (item: DummyItem, index: number) => { 53 | if (item.children != undefined) { 54 | const childItems = item.children.map((itemId) => itemEntitiesMapState.get(itemId)!); 55 | const childItemElements = childItems.map(createItemElement); 56 | 57 | return ( 58 | 59 |
60 |
{item.title}
61 | {childItemElements} 62 |
63 |
64 | ); 65 | } 66 | 67 | return ( 68 | 69 |
{item.title}
70 |
71 | ); 72 | }; 73 | 74 | return topLevelItems.map(createItemElement); 75 | }, [itemEntitiesMapState]); 76 | const renderGhostElement = React.useCallback( 77 | ({ identifier, isGroup }: GhostRendererMeta) => { 78 | const item = itemEntitiesMapState.get(identifier); 79 | if (item == undefined) return; 80 | 81 | if (isGroup) { 82 | return ( 83 |
84 |
{item.title}
85 |
86 | ); 87 | } 88 | 89 | return
{item.title}
; 90 | }, 91 | [itemEntitiesMapState], 92 | ); 93 | const renderPlaceholderElement = React.useCallback( 94 | (injectedProps: PlaceholderRendererInjectedProps, { identifier, isGroup }: PlaceholderRendererMeta) => { 95 | const item = itemEntitiesMapState.get(identifier)!; 96 | const className = classnames({ [styles.group]: isGroup, [styles.item]: !isGroup }, styles.placeholder); 97 | const children = isGroup ?
{item.title}
: item.title; 98 | 99 | return ( 100 |
101 | {children} 102 |
103 | ); 104 | }, 105 | [itemEntitiesMapState], 106 | ); 107 | const renderStackedGroupElement = React.useCallback( 108 | (injectedProps: StackedGroupRendererInjectedProps, { identifier }: StackedGroupRendererMeta) => { 109 | const item = itemEntitiesMapState.get(identifier)!; 110 | 111 | return ( 112 |
113 |
{item.title}
114 |
115 | ); 116 | }, 117 | [itemEntitiesMapState], 118 | ); 119 | 120 | const onDragEnd = React.useCallback( 121 | (meta: DragEndMeta) => { 122 | if (meta.groupIdentifier === meta.nextGroupIdentifier && meta.index === meta.nextIndex) return; 123 | 124 | const newMap = new Map(itemEntitiesMapState.entries()); 125 | const item = newMap.get(meta.identifier); 126 | if (item == undefined) return; 127 | const groupItem = newMap.get(meta.groupIdentifier ?? rootItemId); 128 | if (groupItem == undefined) return; 129 | if (groupItem.children == undefined) return; 130 | 131 | if (meta.groupIdentifier === meta.nextGroupIdentifier) { 132 | const nextIndex = meta.nextIndex ?? groupItem.children?.length ?? 0; 133 | groupItem.children = arrayMove(groupItem.children, meta.index, nextIndex); 134 | } else { 135 | const nextGroupItem = newMap.get(meta.nextGroupIdentifier ?? rootItemId); 136 | if (nextGroupItem == undefined) return; 137 | if (nextGroupItem.children == undefined) return; 138 | 139 | groupItem.children.splice(meta.index, 1); 140 | if (meta.nextIndex == undefined) { 141 | // Inserts an item to a group which has no items. 142 | nextGroupItem.children.push(meta.identifier); 143 | } else { 144 | // Insets an item to a group. 145 | nextGroupItem.children.splice(meta.nextIndex, 0, item.id); 146 | } 147 | } 148 | 149 | setItemEntitiesMapState(newMap); 150 | }, 151 | [itemEntitiesMapState], 152 | ); 153 | 154 | return ( 155 | 165 | {itemElements} 166 | 167 | ); 168 | }; 169 | -------------------------------------------------------------------------------- /stories/2-nested-horizontal/dynamic-partial-locked.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | import arrayMove from "array-move"; 4 | 5 | import { 6 | DragEndMeta, 7 | DropLineRendererInjectedProps, 8 | GhostRendererMeta, 9 | Item, 10 | List, 11 | PlaceholderRendererInjectedProps, 12 | PlaceholderRendererMeta, 13 | StackedGroupRendererInjectedProps, 14 | StackedGroupRendererMeta, 15 | } from "../../src"; 16 | 17 | import { commonStyles } from "../shared"; 18 | import { styles } from "./shared"; 19 | 20 | type DummyItem = { id: string; title: string; children: DummyItem["id"][] | undefined }; 21 | 22 | const rootItemId = "root"; 23 | const initialItemEntitiesMap = new Map([ 24 | [rootItemId, { id: rootItemId, title: "", children: ["a", "b", "c"] }], 25 | ["a", { id: "a", title: "Item A", children: undefined }], 26 | ["b", { id: "b", title: "Group B", children: ["b-1", "b-2", "b-3", "b-4"] }], 27 | ["b-1", { id: "b-1", title: "Item B - 1", children: undefined }], 28 | ["b-2", { id: "b-2", title: "Group B - 2", children: ["b-2-1"] }], 29 | ["b-2-1", { id: "b-2-1", title: "Item B - 2 - 1", children: undefined }], 30 | ["b-3", { id: "b-3", title: "Group B - 3", children: [] }], 31 | ["b-4", { id: "b-4", title: "Item B - 4", children: undefined }], 32 | ["c", { id: "c", title: "Item C", children: undefined }], 33 | ]); 34 | const lockedItemIds = ["a", "b-2"]; 35 | 36 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 37 |
42 | ); 43 | 44 | export const DynamicPartialLockedComponent = () => { 45 | const [itemEntitiesMapState, setItemEntitiesMapState] = React.useState(initialItemEntitiesMap); 46 | 47 | const itemElements = React.useMemo(() => { 48 | const topLevelItems = itemEntitiesMapState.get(rootItemId)!.children!.map((itemId) => itemEntitiesMapState.get(itemId)!); 49 | const createItemElement = (item: DummyItem, index: number) => { 50 | const isLocked = lockedItemIds.includes(item.id); 51 | if (item.children != undefined) { 52 | const childItems = item.children.map((itemId) => itemEntitiesMapState.get(itemId)!); 53 | const childItemElements = childItems.map(createItemElement); 54 | 55 | return ( 56 | 57 |
58 |
{item.title}
59 | {childItemElements} 60 |
61 |
62 | ); 63 | } 64 | 65 | return ( 66 | 67 |
{item.title}
68 |
69 | ); 70 | }; 71 | 72 | return topLevelItems.map(createItemElement); 73 | }, [itemEntitiesMapState]); 74 | const renderGhostElement = React.useCallback( 75 | ({ identifier, isGroup }: GhostRendererMeta) => { 76 | const item = itemEntitiesMapState.get(identifier); 77 | if (item == undefined) return; 78 | 79 | if (isGroup) { 80 | return ( 81 |
82 |
{item.title}
83 |
84 | ); 85 | } 86 | 87 | return
{item.title}
; 88 | }, 89 | [itemEntitiesMapState], 90 | ); 91 | const renderPlaceholderElement = React.useCallback( 92 | (injectedProps: PlaceholderRendererInjectedProps, { identifier, isGroup }: PlaceholderRendererMeta) => { 93 | const item = itemEntitiesMapState.get(identifier)!; 94 | const className = classnames({ [styles.group]: isGroup, [styles.item]: !isGroup }, styles.placeholder); 95 | const children = isGroup ?
{item.title}
: item.title; 96 | 97 | return ( 98 |
99 | {children} 100 |
101 | ); 102 | }, 103 | [itemEntitiesMapState], 104 | ); 105 | const renderStackedGroupElement = React.useCallback( 106 | (injectedProps: StackedGroupRendererInjectedProps, { identifier }: StackedGroupRendererMeta) => { 107 | const item = itemEntitiesMapState.get(identifier)!; 108 | 109 | return ( 110 |
111 |
{item.title}
112 |
113 | ); 114 | }, 115 | [itemEntitiesMapState], 116 | ); 117 | 118 | const onDragEnd = React.useCallback( 119 | (meta: DragEndMeta) => { 120 | if (meta.groupIdentifier === meta.nextGroupIdentifier && meta.index === meta.nextIndex) return; 121 | 122 | const newMap = new Map(itemEntitiesMapState.entries()); 123 | const item = newMap.get(meta.identifier); 124 | if (item == undefined) return; 125 | const groupItem = newMap.get(meta.groupIdentifier ?? rootItemId); 126 | if (groupItem == undefined) return; 127 | if (groupItem.children == undefined) return; 128 | 129 | if (meta.groupIdentifier === meta.nextGroupIdentifier) { 130 | const nextIndex = meta.nextIndex ?? groupItem.children?.length ?? 0; 131 | groupItem.children = arrayMove(groupItem.children, meta.index, nextIndex); 132 | } else { 133 | const nextGroupItem = newMap.get(meta.nextGroupIdentifier ?? rootItemId); 134 | if (nextGroupItem == undefined) return; 135 | if (nextGroupItem.children == undefined) return; 136 | 137 | groupItem.children.splice(meta.index, 1); 138 | if (meta.nextIndex == undefined) { 139 | // Inserts an item to a group which has no items. 140 | nextGroupItem.children.push(meta.identifier); 141 | } else { 142 | // Insets an item to a group. 143 | nextGroupItem.children.splice(meta.nextIndex, 0, item.id); 144 | } 145 | } 146 | 147 | setItemEntitiesMapState(newMap); 148 | }, 149 | [itemEntitiesMapState], 150 | ); 151 | 152 | return ( 153 | 162 | {itemElements} 163 | 164 | ); 165 | }; 166 | -------------------------------------------------------------------------------- /stories/2-nested-vertical/dynamic.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | import arrayMove from "array-move"; 4 | 5 | import { 6 | DragEndMeta, 7 | DropLineRendererInjectedProps, 8 | GhostRendererMeta, 9 | Item, 10 | List, 11 | PlaceholderRendererInjectedProps, 12 | PlaceholderRendererMeta, 13 | StackedGroupRendererInjectedProps, 14 | StackedGroupRendererMeta, 15 | } from "../../src"; 16 | 17 | import { commonStyles } from "../shared"; 18 | import { styles } from "./shared"; 19 | 20 | type DummyItem = { id: string; title: string; children: DummyItem["id"][] | undefined }; 21 | 22 | const rootItemId = "root"; 23 | const initialItemEntitiesMap = new Map([ 24 | [rootItemId, { id: rootItemId, title: "", children: ["a", "b", "c", "d", "e", "f"] }], 25 | ["a", { id: "a", title: "Item A", children: undefined }], 26 | ["b", { id: "b", title: "Item B", children: undefined }], 27 | ["c", { id: "c", title: "Item C", children: undefined }], 28 | ["d", { id: "d", title: "Item D", children: undefined }], 29 | ["e", { id: "e", title: "Group E", children: ["e-1", "e-2", "e-3", "e-4", "e-5", "e-6", "e-7"] }], 30 | ["e-1", { id: "e-1", title: "Item E - 1", children: undefined }], 31 | ["e-2", { id: "e-2", title: "Item E - 2", children: undefined }], 32 | ["e-3", { id: "e-3", title: "Item E - 3", children: undefined }], 33 | ["e-4", { id: "e-4", title: "Item E - 4", children: undefined }], 34 | ["e-5", { id: "e-5", title: "Group E - 5", children: ["e-5-1"] }], 35 | ["e-5-1", { id: "e-5-1", title: "Item E - 5 - 1", children: undefined }], 36 | ["e-6", { id: "e-6", title: "Group E - 6", children: [] }], 37 | ["e-7", { id: "e-7", title: "Item E - 7", children: undefined }], 38 | ["f", { id: "f", title: "Item F", children: undefined }], 39 | ]); 40 | 41 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 42 |
43 | ); 44 | 45 | type Props = { 46 | isDisabled?: boolean; 47 | }; 48 | 49 | export const DynamicComponent = (props: Props) => { 50 | const [itemEntitiesMapState, setItemEntitiesMapState] = React.useState(initialItemEntitiesMap); 51 | 52 | const itemElements = React.useMemo(() => { 53 | const topLevelItems = itemEntitiesMapState.get(rootItemId)!.children!.map((itemId) => itemEntitiesMapState.get(itemId)!); 54 | const createItemElement = (item: DummyItem, index: number) => { 55 | if (item.children != undefined) { 56 | const childItems = item.children.map((itemId) => itemEntitiesMapState.get(itemId)!); 57 | const childItemElements = childItems.map(createItemElement); 58 | 59 | return ( 60 | 61 |
62 |
{item.title}
63 | {childItemElements} 64 |
65 |
66 | ); 67 | } 68 | 69 | return ( 70 | 71 |
{item.title}
72 |
73 | ); 74 | }; 75 | 76 | return topLevelItems.map(createItemElement); 77 | }, [itemEntitiesMapState]); 78 | const renderGhostElement = React.useCallback( 79 | ({ identifier, isGroup }: GhostRendererMeta) => { 80 | const item = itemEntitiesMapState.get(identifier); 81 | if (item == undefined) return; 82 | 83 | if (isGroup) { 84 | return ( 85 |
86 |
{item.title}
87 |
88 | ); 89 | } 90 | 91 | return
{item.title}
; 92 | }, 93 | [itemEntitiesMapState], 94 | ); 95 | const renderPlaceholderElement = React.useCallback( 96 | (injectedProps: PlaceholderRendererInjectedProps, { identifier, isGroup }: PlaceholderRendererMeta) => { 97 | const item = itemEntitiesMapState.get(identifier)!; 98 | const className = classnames({ [styles.group]: isGroup, [styles.item]: !isGroup }, styles.placeholder); 99 | const children = isGroup ?
{item.title}
: item.title; 100 | 101 | return ( 102 |
103 | {children} 104 |
105 | ); 106 | }, 107 | [itemEntitiesMapState], 108 | ); 109 | const renderStackedGroupElement = React.useCallback( 110 | (injectedProps: StackedGroupRendererInjectedProps, { identifier }: StackedGroupRendererMeta) => { 111 | const item = itemEntitiesMapState.get(identifier)!; 112 | 113 | return ( 114 |
115 |
{item.title}
116 |
117 | ); 118 | }, 119 | [itemEntitiesMapState], 120 | ); 121 | 122 | const onDragEnd = React.useCallback( 123 | (meta: DragEndMeta) => { 124 | if (meta.groupIdentifier === meta.nextGroupIdentifier && meta.index === meta.nextIndex) return; 125 | 126 | const newMap = new Map(itemEntitiesMapState.entries()); 127 | const item = newMap.get(meta.identifier); 128 | if (item == undefined) return; 129 | const groupItem = newMap.get(meta.groupIdentifier ?? rootItemId); 130 | if (groupItem == undefined) return; 131 | if (groupItem.children == undefined) return; 132 | 133 | if (meta.groupIdentifier === meta.nextGroupIdentifier) { 134 | const nextIndex = meta.nextIndex ?? groupItem.children?.length ?? 0; 135 | groupItem.children = arrayMove(groupItem.children, meta.index, nextIndex); 136 | } else { 137 | const nextGroupItem = newMap.get(meta.nextGroupIdentifier ?? rootItemId); 138 | if (nextGroupItem == undefined) return; 139 | if (nextGroupItem.children == undefined) return; 140 | 141 | groupItem.children.splice(meta.index, 1); 142 | if (meta.nextIndex == undefined) { 143 | // Inserts an item to a group which has no items. 144 | nextGroupItem.children.push(meta.identifier); 145 | } else { 146 | // Insets an item to a group. 147 | nextGroupItem.children.splice(meta.nextIndex, 0, item.id); 148 | } 149 | } 150 | 151 | setItemEntitiesMapState(newMap); 152 | }, 153 | [itemEntitiesMapState], 154 | ); 155 | 156 | return ( 157 | 166 | {itemElements} 167 | 168 | ); 169 | }; 170 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // Respect "browser" field in package.json when resolving modules 12 | // browser: false, 13 | 14 | // The directory where Jest should store its cached dependency information 15 | // cacheDirectory: "/private/var/folders/88/kfhvhbw51qlgmj4jxjpb34vw0000gn/T/jest_dx", 16 | 17 | // Automatically clear mock calls and instances between every test 18 | clearMocks: true, 19 | 20 | // Indicates whether the coverage information should be collected while executing the test 21 | collectCoverage: true, 22 | 23 | // An array of glob patterns indicating a set of files for which coverage information should be collected 24 | // collectCoverageFrom: null, 25 | 26 | // The directory where Jest should output its coverage files 27 | coverageDirectory: "coverage", 28 | 29 | // An array of regexp pattern strings used to skip coverage collection 30 | // coveragePathIgnorePatterns: [ 31 | // "/node_modules/" 32 | // ], 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | coverageReporters: [ 36 | "html", 37 | // "json", 38 | "text", 39 | "lcov", 40 | // "clover" 41 | ], 42 | 43 | // An object that configures minimum threshold enforcement for coverage results 44 | // coverageThreshold: null, 45 | 46 | // A path to a custom dependency extractor 47 | // dependencyExtractor: null, 48 | 49 | // Make calling deprecated APIs throw helpful error messages 50 | // errorOnDeprecated: false, 51 | 52 | // Force coverage collection from ignored files using an array of glob patterns 53 | // forceCoverageMatch: [], 54 | 55 | // A path to a module which exports an async function that is triggered once before all test suites 56 | // globalSetup: null, 57 | 58 | // A path to a module which exports an async function that is triggered once after all test suites 59 | // globalTeardown: null, 60 | 61 | // A set of global variables that need to be available in all test environments 62 | // globals: {}, 63 | 64 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 65 | // maxWorkers: "50%", 66 | 67 | // An array of directory names to be searched recursively up from the requiring module's location 68 | // moduleDirectories: [ 69 | // "node_modules" 70 | // ], 71 | 72 | // An array of file extensions your modules use 73 | // moduleFileExtensions: [ 74 | // "js", 75 | // "json", 76 | // "jsx", 77 | // "ts", 78 | // "tsx", 79 | // "node" 80 | // ], 81 | 82 | // A map from regular expressions to module names that allow to stub out resources with a single module 83 | // moduleNameMapper: {}, 84 | 85 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 86 | // modulePathIgnorePatterns: [], 87 | 88 | // Activates notifications for test results 89 | // notify: false, 90 | 91 | // An enum that specifies notification mode. Requires { notify: true } 92 | // notifyMode: "failure-change", 93 | 94 | // A preset that is used as a base for Jest's configuration 95 | preset: "ts-jest", 96 | 97 | // Run tests from one or more projects 98 | // projects: null, 99 | 100 | // Use this configuration option to add custom reporters to Jest 101 | // reporters: undefined, 102 | 103 | // Automatically reset mock state between every test 104 | // resetMocks: false, 105 | 106 | // Reset the module registry before running each individual test 107 | // resetModules: false, 108 | 109 | // A path to a custom resolver 110 | // resolver: null, 111 | 112 | // Automatically restore mock state between every test 113 | // restoreMocks: false, 114 | 115 | // The root directory that Jest should scan for tests and modules within 116 | // rootDir: null, 117 | 118 | // A list of paths to directories that Jest should use to search for files in 119 | // roots: [ 120 | // "" 121 | // ], 122 | 123 | // Allows you to use a custom runner instead of Jest's default test runner 124 | // runner: "jest-runner", 125 | 126 | // The paths to modules that run some code to configure or set up the testing environment before each test 127 | setupFiles: ["jest-plugin-context/setup"], 128 | 129 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 130 | // setupFilesAfterEnv: [], 131 | 132 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 133 | // snapshotSerializers: [], 134 | 135 | // The test environment that will be used for testing 136 | testEnvironment: "node", 137 | 138 | // Options that will be passed to the testEnvironment 139 | // testEnvironmentOptions: {}, 140 | 141 | // Adds a location field to test results 142 | // testLocationInResults: false, 143 | 144 | // The glob patterns Jest uses to detect test files 145 | testMatch: [ 146 | // "**/__tests__/**/*.[jt]s?(x)", 147 | // "**/?(*.)+(spec|test).[tj]s?(x)", 148 | "**/src/**/?(*.)+(spec|test).[tj]s?(x)", 149 | ], 150 | 151 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 152 | // testPathIgnorePatterns: [ 153 | // "/node_modules/" 154 | // ], 155 | 156 | // The regexp pattern or array of patterns that Jest uses to detect test files 157 | // testRegex: [], 158 | 159 | // This option allows the use of a custom results processor 160 | // testResultsProcessor: null, 161 | 162 | // This option allows use of a custom test runner 163 | // testRunner: "jasmine2", 164 | 165 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 166 | // testURL: "http://localhost", 167 | 168 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 169 | // timers: "real", 170 | 171 | // A map from regular expressions to paths to transformers 172 | // transform: null, 173 | 174 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 175 | // transformIgnorePatterns: [ 176 | // "/node_modules/" 177 | // ], 178 | 179 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 180 | // unmockedModulePathPatterns: undefined, 181 | 182 | // Indicates whether each individual test should be reported during the run 183 | // verbose: null, 184 | 185 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 186 | // watchPathIgnorePatterns: [], 187 | 188 | // Whether to use watchman for file crawling 189 | // watchman: true, 190 | }; 191 | -------------------------------------------------------------------------------- /stories/2-nested-vertical/dynamic-partial-locked.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | import arrayMove from "array-move"; 4 | 5 | import { 6 | DragEndMeta, 7 | DropLineRendererInjectedProps, 8 | GhostRendererMeta, 9 | Item, 10 | List, 11 | PlaceholderRendererInjectedProps, 12 | PlaceholderRendererMeta, 13 | StackedGroupRendererInjectedProps, 14 | StackedGroupRendererMeta, 15 | } from "../../src"; 16 | 17 | import { commonStyles } from "../shared"; 18 | import { styles } from "./shared"; 19 | 20 | type DummyItem = { id: string; title: string; children: DummyItem["id"][] | undefined }; 21 | 22 | const rootItemId = "root"; 23 | const initialItemEntitiesMap = new Map([ 24 | [rootItemId, { id: rootItemId, title: "", children: ["a", "b", "c", "d", "e", "f"] }], 25 | ["a", { id: "a", title: "Item A", children: undefined }], 26 | ["b", { id: "b", title: "Item B", children: undefined }], 27 | ["c", { id: "c", title: "Item C", children: undefined }], 28 | ["d", { id: "d", title: "Item D", children: undefined }], 29 | ["e", { id: "e", title: "Group E", children: ["e-1", "e-2", "e-3", "e-4", "e-5", "e-6", "e-7"] }], 30 | ["e-1", { id: "e-1", title: "Item E - 1", children: undefined }], 31 | ["e-2", { id: "e-2", title: "Item E - 2", children: undefined }], 32 | ["e-3", { id: "e-3", title: "Item E - 3", children: undefined }], 33 | ["e-4", { id: "e-4", title: "Item E - 4", children: undefined }], 34 | ["e-5", { id: "e-5", title: "Group E - 5", children: ["e-5-1"] }], 35 | ["e-5-1", { id: "e-5-1", title: "Item E - 5 - 1", children: undefined }], 36 | ["e-6", { id: "e-6", title: "Group E - 6", children: [] }], 37 | ["e-7", { id: "e-7", title: "Item E - 7", children: undefined }], 38 | ["f", { id: "f", title: "Item F", children: undefined }], 39 | ]); 40 | const lockedItemIds = ["b", "c", "e-2", "e-3", "e-5"]; 41 | 42 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 43 |
44 | ); 45 | 46 | export const DynamicPartialLockedComponent = () => { 47 | const [itemEntitiesMapState, setItemEntitiesMapState] = React.useState(initialItemEntitiesMap); 48 | 49 | const itemElements = React.useMemo(() => { 50 | const topLevelItems = itemEntitiesMapState.get(rootItemId)!.children!.map((itemId) => itemEntitiesMapState.get(itemId)!); 51 | const createItemElement = (item: DummyItem, index: number) => { 52 | const isLocked = lockedItemIds.includes(item.id); 53 | if (item.children != undefined) { 54 | const childItems = item.children.map((itemId) => itemEntitiesMapState.get(itemId)!); 55 | const childItemElements = childItems.map(createItemElement); 56 | 57 | return ( 58 | 59 |
60 |
{item.title}
61 | {childItemElements} 62 |
63 |
64 | ); 65 | } 66 | 67 | return ( 68 | 69 |
{item.title}
70 |
71 | ); 72 | }; 73 | 74 | return topLevelItems.map(createItemElement); 75 | }, [itemEntitiesMapState]); 76 | const renderGhostElement = React.useCallback( 77 | ({ identifier, isGroup }: GhostRendererMeta) => { 78 | const item = itemEntitiesMapState.get(identifier); 79 | if (item == undefined) return; 80 | 81 | if (isGroup) { 82 | return ( 83 |
84 |
{item.title}
85 |
86 | ); 87 | } 88 | 89 | return
{item.title}
; 90 | }, 91 | [itemEntitiesMapState], 92 | ); 93 | const renderPlaceholderElement = React.useCallback( 94 | (injectedProps: PlaceholderRendererInjectedProps, { identifier, isGroup }: PlaceholderRendererMeta) => { 95 | const item = itemEntitiesMapState.get(identifier)!; 96 | const className = classnames({ [styles.group]: isGroup, [styles.item]: !isGroup }, styles.placeholder); 97 | const children = isGroup ?
{item.title}
: item.title; 98 | 99 | return ( 100 |
101 | {children} 102 |
103 | ); 104 | }, 105 | [itemEntitiesMapState], 106 | ); 107 | const renderStackedGroupElement = React.useCallback( 108 | (injectedProps: StackedGroupRendererInjectedProps, { identifier }: StackedGroupRendererMeta) => { 109 | const item = itemEntitiesMapState.get(identifier)!; 110 | const isLocked = lockedItemIds.includes(item.id); 111 | 112 | return ( 113 |
114 |
{item.title}
115 |
116 | ); 117 | }, 118 | [itemEntitiesMapState], 119 | ); 120 | 121 | const onDragEnd = React.useCallback( 122 | (meta: DragEndMeta) => { 123 | if (meta.groupIdentifier === meta.nextGroupIdentifier && meta.index === meta.nextIndex) return; 124 | 125 | const newMap = new Map(itemEntitiesMapState.entries()); 126 | const item = newMap.get(meta.identifier); 127 | if (item == undefined) return; 128 | const groupItem = newMap.get(meta.groupIdentifier ?? rootItemId); 129 | if (groupItem == undefined) return; 130 | if (groupItem.children == undefined) return; 131 | 132 | if (meta.groupIdentifier === meta.nextGroupIdentifier) { 133 | const nextIndex = meta.nextIndex ?? groupItem.children?.length ?? 0; 134 | groupItem.children = arrayMove(groupItem.children, meta.index, nextIndex); 135 | } else { 136 | const nextGroupItem = newMap.get(meta.nextGroupIdentifier ?? rootItemId); 137 | if (nextGroupItem == undefined) return; 138 | if (nextGroupItem.children == undefined) return; 139 | 140 | groupItem.children.splice(meta.index, 1); 141 | if (meta.nextIndex == undefined) { 142 | // Inserts an item to a group which has no items. 143 | nextGroupItem.children.push(meta.identifier); 144 | } else { 145 | // Insets an item to a group. 146 | nextGroupItem.children.splice(meta.nextIndex, 0, item.id); 147 | } 148 | } 149 | 150 | setItemEntitiesMapState(newMap); 151 | }, 152 | [itemEntitiesMapState], 153 | ); 154 | 155 | return ( 156 | 164 | {itemElements} 165 | 166 | ); 167 | }; 168 | -------------------------------------------------------------------------------- /src/shared/drop-lines/positions.spec.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from "sinon"; 2 | 3 | import { Direction } from "../items"; 4 | import { NodeMeta } from "../nodes"; 5 | import { checkIsInStackableArea, getDropLinePosition } from "./positions"; 6 | import * as directions from "./direcitons"; 7 | 8 | describe("getDropLinePosition", () => { 9 | const absoluteXY: [number, number] = [0, 0]; 10 | const nodeMeta: NodeMeta = { 11 | identifier: 0, 12 | groupIdentifier: undefined, 13 | ancestorIdentifiers: [], 14 | index: 0, 15 | isGroup: false, 16 | element: {} as any, 17 | width: 1212, 18 | height: 3434, 19 | relativePosition: { top: 1, left: 2 }, 20 | absolutePosition: { top: 3, left: 4 }, 21 | }; 22 | const direction: Direction = "horizontal"; 23 | 24 | afterEach(() => { 25 | sinon.restore(); 26 | }); 27 | 28 | context("when `getDropLineDirection` returns undefined", () => { 29 | it("should throw an error", () => { 30 | sinon.stub(directions, "getDropLineDirection").returns(undefined); 31 | 32 | expect(() => getDropLinePosition(absoluteXY, nodeMeta, direction)).toThrowError(); 33 | }); 34 | }); 35 | 36 | context('when `getDropLineDirection` return "TOP"', () => { 37 | it("should return an object which has `nodeMeta.relativePosition.top` as `top` property and `nodeMeta.relativePosition.left` as `left` property", () => { 38 | sinon.stub(directions, "getDropLineDirection").returns("TOP"); 39 | 40 | expect(getDropLinePosition(absoluteXY, nodeMeta, direction)).toEqual({ 41 | top: nodeMeta.relativePosition.top, 42 | left: nodeMeta.relativePosition.left, 43 | }); 44 | }); 45 | }); 46 | 47 | context('when `getDropLineDirection` return "RIGHT"', () => { 48 | it("should return an object which has `nodeMeta.relativePosition.top` as `top` property and `nodeMeta.relativePosition.left + nodeMeta.width` as `left` property", () => { 49 | sinon.stub(directions, "getDropLineDirection").returns("RIGHT"); 50 | 51 | expect(getDropLinePosition(absoluteXY, nodeMeta, direction)).toEqual({ 52 | top: nodeMeta.relativePosition.top, 53 | left: nodeMeta.relativePosition.left + nodeMeta.width, 54 | }); 55 | }); 56 | }); 57 | 58 | context('when `getDropLineDirection` return "BOTTOM"', () => { 59 | it("should return an object which has `nodeMeta.relativePosition.top + nodeMeta.height` as `top` property and `nodeMeta.relativePosition.left` as `left` property", () => { 60 | sinon.stub(directions, "getDropLineDirection").returns("BOTTOM"); 61 | 62 | expect(getDropLinePosition(absoluteXY, nodeMeta, direction)).toEqual({ 63 | top: nodeMeta.relativePosition.top + nodeMeta.height, 64 | left: nodeMeta.relativePosition.left, 65 | }); 66 | }); 67 | }); 68 | 69 | context('when `getDropLineDirection` return "LEFT"', () => { 70 | it("should return an object which has `nodeMeta.relativePosition.top` as `top` property and `nodeMeta.relativePosition.left` as `left` property", () => { 71 | sinon.stub(directions, "getDropLineDirection").returns("TOP"); 72 | 73 | expect(getDropLinePosition(absoluteXY, nodeMeta, direction)).toEqual({ 74 | top: nodeMeta.relativePosition.top, 75 | left: nodeMeta.relativePosition.left, 76 | }); 77 | }); 78 | }); 79 | }); 80 | 81 | describe("checkIsInStackableArea", () => { 82 | const nodeMeta: NodeMeta = { 83 | identifier: 0, 84 | groupIdentifier: undefined, 85 | ancestorIdentifiers: [], 86 | index: 0, 87 | isGroup: false, 88 | element: {} as any, 89 | width: 100, 90 | height: 100, 91 | relativePosition: { top: 50, left: 50 }, 92 | absolutePosition: { top: 500, left: 500 }, 93 | }; 94 | const stackableAreaThreshold = 20; 95 | 96 | context('when `direction` is "vertical"', () => { 97 | const direction: Direction = "vertical"; 98 | 99 | context("`absoluteXY` is located to the top of a stackable area", () => { 100 | it("should return false", () => { 101 | expect( 102 | checkIsInStackableArea([0, nodeMeta.absolutePosition.top - 1], nodeMeta, stackableAreaThreshold, direction), 103 | ).toBe(false); 104 | }); 105 | }); 106 | 107 | context("`absoluteXY` is located in the top of a stackable area", () => { 108 | it("should return true", () => { 109 | expect( 110 | checkIsInStackableArea( 111 | [0, nodeMeta.absolutePosition.top + stackableAreaThreshold], 112 | nodeMeta, 113 | stackableAreaThreshold, 114 | direction, 115 | ), 116 | ).toBe(true); 117 | }); 118 | }); 119 | 120 | context("`absoluteXY` is located in the bottom of a stackable area", () => { 121 | it("should return true", () => { 122 | expect( 123 | checkIsInStackableArea( 124 | [0, nodeMeta.absolutePosition.top + nodeMeta.height - stackableAreaThreshold], 125 | nodeMeta, 126 | stackableAreaThreshold, 127 | direction, 128 | ), 129 | ).toBe(true); 130 | }); 131 | }); 132 | 133 | context("`absoluteXY` is located to the bottom of a stackable area", () => { 134 | it("should return false", () => { 135 | expect( 136 | checkIsInStackableArea( 137 | [0, nodeMeta.absolutePosition.top + nodeMeta.height - stackableAreaThreshold + 1], 138 | nodeMeta, 139 | stackableAreaThreshold, 140 | direction, 141 | ), 142 | ).toBe(false); 143 | }); 144 | }); 145 | }); 146 | 147 | context('when `direction` is "horizontal"', () => { 148 | const direction: Direction = "horizontal"; 149 | 150 | context("`absoluteXY` is located to the left of a stackable area", () => { 151 | it("should return false", () => { 152 | expect( 153 | checkIsInStackableArea([nodeMeta.absolutePosition.left - 1, 0], nodeMeta, stackableAreaThreshold, direction), 154 | ).toBe(false); 155 | }); 156 | }); 157 | 158 | context("`absoluteXY` is located in the left of a stackable area", () => { 159 | it("should return true", () => { 160 | expect( 161 | checkIsInStackableArea( 162 | [nodeMeta.absolutePosition.left + stackableAreaThreshold, 0], 163 | nodeMeta, 164 | stackableAreaThreshold, 165 | direction, 166 | ), 167 | ).toBe(true); 168 | }); 169 | }); 170 | 171 | context("`absoluteXY` is located in the right of a stackable area", () => { 172 | it("should return true", () => { 173 | expect( 174 | checkIsInStackableArea( 175 | [nodeMeta.absolutePosition.left + nodeMeta.width - stackableAreaThreshold, 0], 176 | nodeMeta, 177 | stackableAreaThreshold, 178 | direction, 179 | ), 180 | ).toBe(true); 181 | }); 182 | }); 183 | 184 | context("`absoluteXY` is located to the right of a stackable area", () => { 185 | it("should return false", () => { 186 | expect( 187 | checkIsInStackableArea( 188 | [nodeMeta.absolutePosition.left + nodeMeta.width - stackableAreaThreshold + 1, 0], 189 | nodeMeta, 190 | stackableAreaThreshold, 191 | direction, 192 | ), 193 | ).toBe(false); 194 | }); 195 | }); 196 | }); 197 | 198 | context("when `direction` is not an allowed value", () => { 199 | it("should throw an error", () => { 200 | expect(() => checkIsInStackableArea([0, 0], nodeMeta, stackableAreaThreshold, "dummy" as Direction)).toThrowError(); 201 | }); 202 | }); 203 | }); 204 | -------------------------------------------------------------------------------- /stories/3-advanced-examples/kanban.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | import arrayMove from "array-move"; 4 | 5 | import { 6 | DragEndMeta, 7 | DropLineRendererInjectedProps, 8 | GhostRendererMeta, 9 | Item, 10 | List, 11 | PlaceholderRendererInjectedProps, 12 | PlaceholderRendererMeta, 13 | StackedGroupRendererInjectedProps, 14 | StackedGroupRendererMeta, 15 | } from "../../src"; 16 | 17 | import styles from "./kanban.css"; 18 | 19 | type DummyItem = { id: string; title: string; children?: DummyItem["id"][] }; 20 | 21 | const leftRootItemId = "left-root"; 22 | const centerRootItemId = "center-root"; 23 | const rightRootItemId = "right-root"; 24 | const initialItemEntitiesMap = new Map([ 25 | [ 26 | leftRootItemId, 27 | { 28 | id: leftRootItemId, 29 | title: "TODO", 30 | children: ["left-a", "left-b", "left-c", "left-d", "left-e"], 31 | }, 32 | ], 33 | ["left-a", { id: "left-a", title: "Left Item A" }], 34 | ["left-b", { id: "left-b", title: "Left Item B" }], 35 | ["left-c", { id: "left-c", title: "Left Item C" }], 36 | ["left-d", { id: "left-d", title: "Left Item D" }], 37 | ["left-e", { id: "left-e", title: "Left Item E" }], 38 | [ 39 | centerRootItemId, 40 | { 41 | id: centerRootItemId, 42 | title: "Doing", 43 | children: ["center-a", "center-b", "center-c", "center-d", "center-e"], 44 | }, 45 | ], 46 | ["center-a", { id: "center-a", title: "Center Item A" }], 47 | ["center-b", { id: "center-b", title: "Center Item B" }], 48 | ["center-c", { id: "center-c", title: "Center Item C" }], 49 | ["center-d", { id: "center-d", title: "Center Item D" }], 50 | ["center-e", { id: "center-e", title: "Center Item E" }], 51 | [ 52 | rightRootItemId, 53 | { 54 | id: rightRootItemId, 55 | title: "Done", 56 | children: ["right-a", "right-b", "right-c", "right-d", "right-e"], 57 | }, 58 | ], 59 | ["right-a", { id: "right-a", title: "Right Item A" }], 60 | ["right-b", { id: "right-b", title: "Right Item B" }], 61 | ["right-c", { id: "right-c", title: "Right Item C" }], 62 | ["right-d", { id: "right-d", title: "Right Item D" }], 63 | ["right-e", { id: "right-e", title: "Right Item E" }], 64 | ]); 65 | 66 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 67 |
68 | ); 69 | 70 | export const KanbanComponent = () => { 71 | const [itemEntitiesMapState, setItemEntitiesMapState] = React.useState(initialItemEntitiesMap); 72 | 73 | const createItemElements = React.useCallback( 74 | (items: DummyItem[]) => 75 | items.map((item, index) => ( 76 | 77 |
{item.title}
78 |
79 | )), 80 | [], 81 | ); 82 | const leftItemElements = React.useMemo(() => { 83 | const leftItems = itemEntitiesMapState.get(leftRootItemId)!.children!.map((itemId) => itemEntitiesMapState.get(itemId)!); 84 | 85 | return createItemElements(leftItems); 86 | }, [itemEntitiesMapState, createItemElements]); 87 | const centerItemElements = React.useMemo(() => { 88 | const centerItems = itemEntitiesMapState 89 | .get(centerRootItemId)! 90 | .children!.map((itemId) => itemEntitiesMapState.get(itemId)!); 91 | 92 | return createItemElements(centerItems); 93 | }, [itemEntitiesMapState, createItemElements]); 94 | const rightItemElements = React.useMemo(() => { 95 | const rightItems = itemEntitiesMapState.get(rightRootItemId)!.children!.map((itemId) => itemEntitiesMapState.get(itemId)!); 96 | 97 | return createItemElements(rightItems); 98 | }, [itemEntitiesMapState, createItemElements]); 99 | const renderGhostElement = React.useCallback( 100 | ({ identifier }: GhostRendererMeta) => { 101 | const item = itemEntitiesMapState.get(identifier)!; 102 | 103 | return
{item.title}
; 104 | }, 105 | [itemEntitiesMapState], 106 | ); 107 | const renderPlaceholderElement = React.useCallback( 108 | (injectedProps: PlaceholderRendererInjectedProps, { identifier }: PlaceholderRendererMeta) => { 109 | const item = itemEntitiesMapState.get(identifier)!; 110 | 111 | return ( 112 |
113 | {item.title} 114 |
115 | ); 116 | }, 117 | [itemEntitiesMapState], 118 | ); 119 | const renderStackedGroupElement = React.useCallback( 120 | (injectedProps: StackedGroupRendererInjectedProps, { identifier }: StackedGroupRendererMeta) => { 121 | const rootItem = itemEntitiesMapState.get(identifier)!; 122 | 123 | return ( 124 |
125 |
{rootItem.title}
126 |
127 | ); 128 | }, 129 | [], 130 | ); 131 | 132 | const onDragEnd = React.useCallback((meta: DragEndMeta) => { 133 | if (meta.groupIdentifier === meta.nextGroupIdentifier && meta.index === meta.nextIndex) return; 134 | 135 | const newMap = new Map(itemEntitiesMapState.entries()); 136 | const item = newMap.get(meta.identifier); 137 | if (item == undefined) return; 138 | const groupItem = newMap.get(meta.groupIdentifier!); 139 | if (groupItem == undefined) return; 140 | if (groupItem.children == undefined) return; 141 | 142 | if (meta.groupIdentifier === meta.nextGroupIdentifier) { 143 | const nextIndex = meta.nextIndex ?? groupItem.children.length; 144 | groupItem.children = arrayMove(groupItem.children, meta.index, nextIndex); 145 | } else { 146 | const nextGroupItem = newMap.get(meta.nextGroupIdentifier!); 147 | if (nextGroupItem == undefined) return; 148 | if (nextGroupItem.children == undefined) return; 149 | 150 | groupItem.children.splice(meta.index, 1); 151 | if (meta.nextIndex == undefined) { 152 | // Inserts an item to a group which has no items. 153 | nextGroupItem.children.push(meta.identifier); 154 | } else { 155 | // Insets an item to a group. 156 | nextGroupItem.children.splice(meta.nextIndex, 0, item.id); 157 | } 158 | } 159 | 160 | setItemEntitiesMapState(newMap); 161 | }, []); 162 | 163 | const leftRootItem = itemEntitiesMapState.get(leftRootItemId)!; 164 | const centerRootItem = itemEntitiesMapState.get(centerRootItemId)!; 165 | const rightRootItem = itemEntitiesMapState.get(rightRootItemId)!; 166 | 167 | return ( 168 | 178 | 179 |
180 |
{leftRootItem.title}
181 |
{leftItemElements}
182 |
183 |
184 | 185 |
186 | 187 | 188 |
189 |
{centerRootItem.title}
190 |
{centerItemElements}
191 |
192 |
193 | 194 |
195 | 196 | 197 |
198 |
{rightRootItem.title}
199 |
{rightItemElements}
200 |
201 |
202 |
203 | ); 204 | }; 205 | -------------------------------------------------------------------------------- /stories/3-advanced-examples/layers-panel.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | import arrayMove from "array-move"; 4 | 5 | import { 6 | DragEndMeta, 7 | DropLineRendererInjectedProps, 8 | GhostRendererMeta, 9 | Item, 10 | List, 11 | PlaceholderRendererInjectedProps, 12 | StackedGroupRendererInjectedProps, 13 | StackedGroupRendererMeta, 14 | } from "../../src"; 15 | 16 | import styles from "./layers-panel.css"; 17 | 18 | type Layer = { id: string; name: string; children?: Layer["id"][] }; 19 | 20 | const rootLayerId = "root"; 21 | const initialLayerEntitiesMap = new Map([ 22 | [ 23 | rootLayerId, 24 | { 25 | id: rootLayerId, 26 | name: "", 27 | children: [ 28 | "14a005b8-76a2-4e59-abd6-7612793ee04e", 29 | "08a9ae06-ed6b-4f3e-ae51-a9d4d563dead", 30 | "47ddb750-c6af-4587-8333-d4749ddeb23f", 31 | "860c0911-e23f-40c1-8612-bca306337c15", 32 | "266797ee-bb96-46a4-9c53-d64c2e8ca4b4", 33 | "e51d66cd-0ef3-44a1-9393-9fd3f238ebee", 34 | ], 35 | }, 36 | ], 37 | [ 38 | "14a005b8-76a2-4e59-abd6-7612793ee04e", 39 | { id: "14a005b8-76a2-4e59-abd6-7612793ee04e", name: "Layer 0", children: undefined }, 40 | ], 41 | [ 42 | "08a9ae06-ed6b-4f3e-ae51-a9d4d563dead", 43 | { id: "08a9ae06-ed6b-4f3e-ae51-a9d4d563dead", name: "Layer 1", children: undefined }, 44 | ], 45 | [ 46 | "47ddb750-c6af-4587-8333-d4749ddeb23f", 47 | { 48 | id: "47ddb750-c6af-4587-8333-d4749ddeb23f", 49 | name: "Group 0", 50 | children: [ 51 | "506830c9-3460-404d-a608-5470f3ad5c2b", 52 | "a82ddb48-8bf5-469a-b84e-320ded851dee", 53 | "92474c86-df50-458e-893e-4932cb6a7f4c", 54 | ], 55 | }, 56 | ], 57 | [ 58 | "506830c9-3460-404d-a608-5470f3ad5c2b", 59 | { id: "506830c9-3460-404d-a608-5470f3ad5c2b", name: "Grouped Layer 0", children: undefined }, 60 | ], 61 | [ 62 | "a82ddb48-8bf5-469a-b84e-320ded851dee", 63 | { id: "a82ddb48-8bf5-469a-b84e-320ded851dee", name: "Grouped Layer 1", children: undefined }, 64 | ], 65 | [ 66 | "92474c86-df50-458e-893e-4932cb6a7f4c", 67 | { id: "92474c86-df50-458e-893e-4932cb6a7f4c", name: "Grouped Layer 2", children: undefined }, 68 | ], 69 | [ 70 | "860c0911-e23f-40c1-8612-bca306337c15", 71 | { id: "860c0911-e23f-40c1-8612-bca306337c15", name: "Layer 2", children: undefined }, 72 | ], 73 | [ 74 | "266797ee-bb96-46a4-9c53-d64c2e8ca4b4", 75 | { id: "266797ee-bb96-46a4-9c53-d64c2e8ca4b4", name: "Layer 3", children: undefined }, 76 | ], 77 | ["e51d66cd-0ef3-44a1-9393-9fd3f238ebee", { id: "e51d66cd-0ef3-44a1-9393-9fd3f238ebee", name: "Group 1", children: [] }], 78 | ]); 79 | 80 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 81 |
82 | ); 83 | 84 | export const LayersPanelComponent = () => { 85 | const [layerEntitiesMapState, setLayerEntitiesMapState] = React.useState(initialLayerEntitiesMap); 86 | 87 | const itemElements = React.useMemo(() => { 88 | const topLevelLayers = layerEntitiesMapState 89 | .get(rootLayerId)! 90 | .children!.map((layerId) => layerEntitiesMapState.get(layerId)!); 91 | const createItemElement = (layer: Layer, index: number, nestLevel = 0) => { 92 | const style: React.CSSProperties = { "--nest-level": nestLevel } as any; 93 | if (layer.children != undefined) { 94 | const childLayers = layer.children.map((layerId) => layerEntitiesMapState.get(layerId)!); 95 | const childLayerElements = childLayers.map((layer, index) => createItemElement(layer, index, nestLevel + 1)); 96 | const isEmpty = childLayerElements.length === 0; 97 | const iconClassName = classnames( 98 | "fa", 99 | { "fa-caret-down": !isEmpty, "fa-caret-right ": isEmpty }, 100 | styles.icon, 101 | styles.arrow, 102 | ); 103 | 104 | return ( 105 | 106 |
107 |
108 |
109 | {layer.name} 110 |
111 | 112 |
{childLayerElements}
113 | 114 | ); 115 | } 116 | 117 | return ( 118 | 119 |
120 |
121 |
122 | {layer.name} 123 |
124 |
125 | 126 | ); 127 | }; 128 | 129 | return topLevelLayers.map((layer, index) => createItemElement(layer, index)); 130 | }, [layerEntitiesMapState]); 131 | const renderGhostElement = React.useCallback( 132 | ({ identifier, isGroup }: GhostRendererMeta) => { 133 | const layer = layerEntitiesMapState.get(identifier); 134 | if (layer == undefined) return; 135 | 136 | if (isGroup) { 137 | return ( 138 |
139 |
140 | {layer.name} 141 |
142 | ); 143 | } 144 | 145 | return ( 146 |
147 |
148 |
149 | {layer.name} 150 |
151 |
152 | ); 153 | }, 154 | [layerEntitiesMapState], 155 | ); 156 | const renderPlaceholderElement = React.useCallback( 157 | (injectedProps: PlaceholderRendererInjectedProps) =>
, 158 | [layerEntitiesMapState], 159 | ); 160 | const renderStackedGroupElement = React.useCallback( 161 | (injectedProps: StackedGroupRendererInjectedProps, { identifier }: StackedGroupRendererMeta) => { 162 | const layer = layerEntitiesMapState.get(identifier)!; 163 | 164 | return ( 165 |
166 |
167 |
168 | {layer.name} 169 |
170 | ); 171 | }, 172 | [layerEntitiesMapState], 173 | ); 174 | 175 | const onDragEnd = React.useCallback( 176 | (meta: DragEndMeta) => { 177 | if (meta.groupIdentifier === meta.nextGroupIdentifier && meta.index === meta.nextIndex) return; 178 | 179 | const newMap = new Map(layerEntitiesMapState.entries()); 180 | const layer = newMap.get(meta.identifier); 181 | if (layer == undefined) return; 182 | const group = newMap.get(meta.groupIdentifier ?? rootLayerId); 183 | if (group == undefined) return; 184 | if (group.children == undefined) return; 185 | 186 | if (meta.groupIdentifier === meta.nextGroupIdentifier) { 187 | const nextIndex = meta.nextIndex ?? group.children?.length ?? 0; 188 | group.children = arrayMove(group.children, meta.index, nextIndex); 189 | } else { 190 | const nextGroup = newMap.get(meta.nextGroupIdentifier ?? rootLayerId); 191 | if (nextGroup == undefined) return; 192 | if (nextGroup.children == undefined) return; 193 | 194 | group.children.splice(meta.index, 1); 195 | if (meta.nextIndex == undefined) { 196 | // Inserts a layer to a group which has no layers. 197 | nextGroup.children.push(meta.identifier); 198 | } else { 199 | // Insets a layer to a group. 200 | nextGroup.children.splice(meta.nextIndex, 0, layer.id); 201 | } 202 | } 203 | 204 | setLayerEntitiesMapState(newMap); 205 | }, 206 | [layerEntitiesMapState], 207 | ); 208 | 209 | return ( 210 |
211 | 214 | 215 | 224 | {itemElements} 225 | 226 |
227 | ); 228 | }; 229 | -------------------------------------------------------------------------------- /stories/3-advanced-examples/tree.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import classnames from "classnames"; 3 | import arrayMove from "array-move"; 4 | 5 | import { 6 | DragEndMeta, 7 | DropLineRendererInjectedProps, 8 | GhostRendererMeta, 9 | Item, 10 | List, 11 | PlaceholderRendererInjectedProps, 12 | PlaceholderRendererMeta, 13 | StackedGroupRendererInjectedProps, 14 | StackedGroupRendererMeta, 15 | } from "../../src"; 16 | 17 | import { commonStyles } from "../shared"; 18 | import styles from "./tree.css"; 19 | 20 | type DummyItem = { id: string; title: string; children: DummyItem["id"][] | undefined }; 21 | 22 | const rootItemId = "d93cc42d-a57c-447a-a527-1655388b9dc6"; 23 | const initialItemEntitiesMap = new Map([ 24 | [ 25 | rootItemId, 26 | { 27 | id: rootItemId, 28 | title: "", 29 | children: ["d11a3412-c7cd-438b-8a93-121d724dde8b", "37e73dcc-53b1-423a-9574-c78942c60a0f"], 30 | }, 31 | ], 32 | [ 33 | "d11a3412-c7cd-438b-8a93-121d724dde8b", 34 | { 35 | id: "d11a3412-c7cd-438b-8a93-121d724dde8b", 36 | title: "Macintosh HD", 37 | children: [ 38 | "23e03875-675e-45a7-b62a-3d444269ccdf", 39 | "7eebbba8-c485-42b4-9df9-2057caddce6d", 40 | "5178aae3-0eab-459d-a921-d3d02e95760e", 41 | ], 42 | }, 43 | ], 44 | ["23e03875-675e-45a7-b62a-3d444269ccdf", { id: "23e03875-675e-45a7-b62a-3d444269ccdf", title: "Library", children: [] }], 45 | ["7eebbba8-c485-42b4-9df9-2057caddce6d", { id: "7eebbba8-c485-42b4-9df9-2057caddce6d", title: "System", children: [] }], 46 | [ 47 | "5178aae3-0eab-459d-a921-d3d02e95760e", 48 | { 49 | id: "5178aae3-0eab-459d-a921-d3d02e95760e", 50 | title: "Users", 51 | children: ["0838980f-672c-4f24-b583-2fced2a60302", "0f9f3bec-69a9-48e2-bac7-56c1607b878c"], 52 | }, 53 | ], 54 | [ 55 | "0838980f-672c-4f24-b583-2fced2a60302", 56 | { 57 | id: "0838980f-672c-4f24-b583-2fced2a60302", 58 | title: "jagaapple", 59 | children: [ 60 | "affbd250-36a4-4a2b-a7b0-cdc64e03db8b", 61 | "868eb97c-7970-45e5-bec1-8eb626d7b0b9", 62 | "258be242-8493-4cb5-bf9e-35778aa3ad69", 63 | ], 64 | }, 65 | ], 66 | [ 67 | "affbd250-36a4-4a2b-a7b0-cdc64e03db8b", 68 | { 69 | id: "affbd250-36a4-4a2b-a7b0-cdc64e03db8b", 70 | title: "Desktop", 71 | children: [ 72 | "dbede9cd-ac22-4f62-b6bc-23ed2c6f88c8", 73 | "6725369f-d929-4fa9-a955-704486824a30", 74 | "e353fca1-0ca7-4f1a-9292-bc90a3b1a5e7", 75 | ], 76 | }, 77 | ], 78 | [ 79 | "dbede9cd-ac22-4f62-b6bc-23ed2c6f88c8", 80 | { id: "dbede9cd-ac22-4f62-b6bc-23ed2c6f88c8", title: "dummy-1.txt", children: undefined }, 81 | ], 82 | [ 83 | "6725369f-d929-4fa9-a955-704486824a30", 84 | { id: "6725369f-d929-4fa9-a955-704486824a30", title: "dummy-2.txt", children: undefined }, 85 | ], 86 | [ 87 | "e353fca1-0ca7-4f1a-9292-bc90a3b1a5e7", 88 | { id: "e353fca1-0ca7-4f1a-9292-bc90a3b1a5e7", title: "dummy-3.txt", children: undefined }, 89 | ], 90 | ["868eb97c-7970-45e5-bec1-8eb626d7b0b9", { id: "868eb97c-7970-45e5-bec1-8eb626d7b0b9", title: "Documents", children: [] }], 91 | [ 92 | "258be242-8493-4cb5-bf9e-35778aa3ad69", 93 | { 94 | id: "258be242-8493-4cb5-bf9e-35778aa3ad69", 95 | title: "Pictures", 96 | children: [ 97 | "706215a1-4e8a-4f85-8ca3-adad5b17abb9", 98 | "324474cd-7430-4f21-b97f-9565e264540c", 99 | "64cfd25f-3e8b-4169-a329-d8d79352be7b", 100 | ], 101 | }, 102 | ], 103 | [ 104 | "706215a1-4e8a-4f85-8ca3-adad5b17abb9", 105 | { id: "706215a1-4e8a-4f85-8ca3-adad5b17abb9", title: "photo-1.jpg", children: undefined }, 106 | ], 107 | [ 108 | "324474cd-7430-4f21-b97f-9565e264540c", 109 | { id: "324474cd-7430-4f21-b97f-9565e264540c", title: "photo-2.jpg", children: undefined }, 110 | ], 111 | [ 112 | "64cfd25f-3e8b-4169-a329-d8d79352be7b", 113 | { id: "64cfd25f-3e8b-4169-a329-d8d79352be7b", title: "photo-3.jpg", children: undefined }, 114 | ], 115 | [ 116 | "0f9f3bec-69a9-48e2-bac7-56c1607b878c", 117 | { 118 | id: "0f9f3bec-69a9-48e2-bac7-56c1607b878c", 119 | title: "pineapple", 120 | children: ["778d414f-3d3a-42d2-8643-d557286a16b6", "dce4d345-81ab-4c0f-9ca1-11e5ec5fe44c"], 121 | }, 122 | ], 123 | [ 124 | "778d414f-3d3a-42d2-8643-d557286a16b6", 125 | { 126 | id: "778d414f-3d3a-42d2-8643-d557286a16b6", 127 | title: "Documents", 128 | children: ["7a16103d-13e5-4269-a4ac-87f630484a5f", "be702972-f4e9-43f9-ac69-5ebdeff7efc4"], 129 | }, 130 | ], 131 | ["7a16103d-13e5-4269-a4ac-87f630484a5f", { id: "7a16103d-13e5-4269-a4ac-87f630484a5f", title: "pages", children: [] }], 132 | [ 133 | "be702972-f4e9-43f9-ac69-5ebdeff7efc4", 134 | { id: "be702972-f4e9-43f9-ac69-5ebdeff7efc4", title: "sample.numbers", children: undefined }, 135 | ], 136 | [ 137 | "dce4d345-81ab-4c0f-9ca1-11e5ec5fe44c", 138 | { 139 | id: "dce4d345-81ab-4c0f-9ca1-11e5ec5fe44c", 140 | title: "Pictures", 141 | children: ["f5780287-7e72-492d-9176-ab097fe90343", "fa25d843-afa4-4621-b2a0-d9e4151bf6b8"], 142 | }, 143 | ], 144 | [ 145 | "f5780287-7e72-492d-9176-ab097fe90343", 146 | { id: "f5780287-7e72-492d-9176-ab097fe90343", title: "photo-1.png", children: undefined }, 147 | ], 148 | [ 149 | "fa25d843-afa4-4621-b2a0-d9e4151bf6b8", 150 | { id: "fa25d843-afa4-4621-b2a0-d9e4151bf6b8", title: "photo-2.png", children: undefined }, 151 | ], 152 | ["37e73dcc-53b1-423a-9574-c78942c60a0f", { id: "37e73dcc-53b1-423a-9574-c78942c60a0f", title: "Network", children: [] }], 153 | ]); 154 | const groupHeadingClassname = classnames(styles.heading, styles.withIcon); 155 | const itemClassName = classnames(styles.item, styles.withIcon); 156 | 157 | const renderDropLineElement = (injectedProps: DropLineRendererInjectedProps) => ( 158 |
159 | ); 160 | 161 | type Props = { 162 | isDisabled?: boolean; 163 | }; 164 | 165 | export const TreeComponent = (props: Props) => { 166 | const [itemEntitiesMapState, setItemEntitiesMapState] = React.useState(initialItemEntitiesMap); 167 | 168 | const itemElements = React.useMemo(() => { 169 | const topLevelItems = itemEntitiesMapState.get(rootItemId)!.children!.map((itemId) => itemEntitiesMapState.get(itemId)!); 170 | const createItemElement = (item: DummyItem, index: number) => { 171 | if (item.children != undefined) { 172 | const childItems = item.children.map((itemId) => itemEntitiesMapState.get(itemId)!); 173 | const childItemElements = childItems.map(createItemElement); 174 | const hasItems = childItemElements.length > 0; 175 | 176 | return ( 177 | 178 |
179 |
{item.title}
180 | {childItemElements} 181 |
182 |
183 | ); 184 | } 185 | 186 | return ( 187 | 188 |
{item.title}
189 |
190 | ); 191 | }; 192 | 193 | return topLevelItems.map(createItemElement); 194 | }, [itemEntitiesMapState]); 195 | const renderGhostElement = React.useCallback( 196 | ({ identifier, isGroup }: GhostRendererMeta) => { 197 | const item = itemEntitiesMapState.get(identifier); 198 | if (item == undefined) return; 199 | 200 | if (isGroup) { 201 | return ( 202 |
203 |
{item.title}
204 |
205 | ); 206 | } 207 | 208 | return
{item.title}
; 209 | }, 210 | [itemEntitiesMapState], 211 | ); 212 | const renderPlaceholderElement = React.useCallback( 213 | (injectedProps: PlaceholderRendererInjectedProps, { identifier, isGroup }: PlaceholderRendererMeta) => { 214 | const item = itemEntitiesMapState.get(identifier)!; 215 | const className = classnames({ [styles.group]: isGroup, [itemClassName]: !isGroup }, styles.placeholder); 216 | const children = isGroup ?
{item.title}
: item.title; 217 | 218 | return ( 219 |
220 | {children} 221 |
222 | ); 223 | }, 224 | [itemEntitiesMapState], 225 | ); 226 | const renderStackedGroupElement = React.useCallback( 227 | (injectedProps: StackedGroupRendererInjectedProps, { identifier }: StackedGroupRendererMeta) => { 228 | const item = itemEntitiesMapState.get(identifier)!; 229 | 230 | return ( 231 |
232 |
{item.title}
233 |
234 | ); 235 | }, 236 | [], 237 | ); 238 | 239 | const onDragEnd = React.useCallback( 240 | (meta: DragEndMeta) => { 241 | if (meta.groupIdentifier === meta.nextGroupIdentifier && meta.index === meta.nextIndex) return; 242 | 243 | const newMap = new Map(itemEntitiesMapState.entries()); 244 | const item = newMap.get(meta.identifier); 245 | if (item == undefined) return; 246 | const groupItem = newMap.get(meta.groupIdentifier ?? rootItemId); 247 | if (groupItem == undefined) return; 248 | if (groupItem.children == undefined) return; 249 | 250 | if (meta.groupIdentifier === meta.nextGroupIdentifier) { 251 | const nextIndex = meta.nextIndex ?? groupItem.children.length; 252 | groupItem.children = arrayMove(groupItem.children, meta.index, nextIndex); 253 | } else { 254 | const nextGroupItem = newMap.get(meta.nextGroupIdentifier ?? rootItemId); 255 | if (nextGroupItem == undefined) return; 256 | if (nextGroupItem.children == undefined) return; 257 | 258 | groupItem.children.splice(meta.index, 1); 259 | if (meta.nextIndex == undefined) { 260 | // Inserts an item to a group which has no items. 261 | nextGroupItem.children.push(meta.identifier); 262 | } else { 263 | // Insets an item to a group. 264 | nextGroupItem.children.splice(meta.nextIndex, 0, item.id); 265 | } 266 | } 267 | 268 | setItemEntitiesMapState(newMap); 269 | }, 270 | [itemEntitiesMapState], 271 | ); 272 | 273 | return ( 274 | 284 | {itemElements} 285 | 286 | ); 287 | }; 288 | -------------------------------------------------------------------------------- /src/item.component.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useGesture } from "react-use-gesture"; 3 | 4 | import { checkIsInStackableArea, getDropLineDirectionFromXY, getNodeMeta, ItemIdentifier, NodeMeta } from "./shared"; 5 | import { ListContext, PlaceholderRendererMeta, StackedGroupRendererMeta } from "./list"; 6 | import { GroupContext } from "./groups"; 7 | import { 8 | checkIsAncestorItem, 9 | clearBodyStyle, 10 | clearGhostElementStyle, 11 | getDropLinePositionItemIndex, 12 | getPlaceholderElementStyle, 13 | getStackedGroupElementStyle, 14 | initializeGhostElementStyle, 15 | ItemContext, 16 | moveGhostElement, 17 | setBodyStyle, 18 | setDropLineElementStyle, 19 | } from "./item"; 20 | 21 | type Props = { 22 | /** A unique identifier in all items of list. */ 23 | identifier: T; 24 | /** A unique and sequential index number in a parent group. */ 25 | index: number; 26 | /** 27 | * Whether an item is possible to have child items. 28 | * @default false 29 | */ 30 | isGroup?: boolean; 31 | /** 32 | * Whether child items are not able to move and drag. 33 | * Stacking and popping child items will be allowed. Grandchild items will not be affected. 34 | * @default false 35 | */ 36 | isLocked?: boolean; 37 | /** 38 | * Whether droppable areas on both sides of an item is disabled. 39 | * @default false 40 | */ 41 | isLonely?: boolean; 42 | /** 43 | * Whether an item contains custom drag handlers in child items (not grandchildren). 44 | * @default false 45 | */ 46 | isUsedCustomDragHandlers?: boolean; 47 | children?: React.ReactNode; 48 | }; 49 | 50 | export const Item = (props: Props) => { 51 | const listContext = React.useContext(ListContext); 52 | const groupContext = React.useContext(GroupContext); 53 | 54 | const wrapperElementRef = React.useRef(null); 55 | 56 | const ancestorIdentifiers = [...groupContext.ancestorIdentifiers, props.identifier]; 57 | const isGroup = props.isGroup ?? false; 58 | const isLocked = (listContext.isDisabled || props.isLocked) ?? false; 59 | const isLonley = props.isLonely ?? false; 60 | const isUsedCustomDragHandlers = props.isUsedCustomDragHandlers ?? false; 61 | 62 | // Registers an identifier to the group context. 63 | const childIdentifiersRef = React.useRef>(new Set()); 64 | React.useEffect(() => { 65 | groupContext.childIdentifiersRef.current.add(props.identifier); 66 | 67 | return () => { 68 | groupContext.childIdentifiersRef.current.delete(props.identifier); 69 | }; 70 | }, []); 71 | 72 | // Clears timers. 73 | const clearingDraggingNodeTimeoutIdRef = React.useRef(); 74 | React.useEffect( 75 | () => () => { 76 | window.clearTimeout(clearingDraggingNodeTimeoutIdRef.current); 77 | }, 78 | [], 79 | ); 80 | 81 | const onDragStart = React.useCallback(() => { 82 | const element = wrapperElementRef.current; 83 | if (element == undefined) return; 84 | 85 | setBodyStyle(document.body, listContext.draggingCursorStyle); 86 | initializeGhostElementStyle( 87 | element, 88 | listContext.ghostWrapperElementRef.current ?? undefined, 89 | listContext.itemSpacing, 90 | listContext.direction, 91 | ); 92 | 93 | // Sets contexts to values. 94 | const nodeMeta = getNodeMeta(element, props.identifier, groupContext.identifier, ancestorIdentifiers, props.index, isGroup); 95 | listContext.setDraggingNodeMeta(nodeMeta); 96 | 97 | // Calls callbacks. 98 | listContext.onDragStart?.({ 99 | identifier: nodeMeta.identifier, 100 | groupIdentifier: nodeMeta.groupIdentifier, 101 | index: nodeMeta.index, 102 | isGroup: nodeMeta.isGroup, 103 | }); 104 | }, [ 105 | listContext.itemSpacing, 106 | listContext.direction, 107 | listContext.onDragStart, 108 | listContext.draggingCursorStyle, 109 | groupContext.identifier, 110 | props.identifier, 111 | props.index, 112 | ancestorIdentifiers, 113 | isGroup, 114 | ]); 115 | const onDrag = React.useCallback((isDown: boolean, absoluteXY: [number, number]) => { 116 | if (!isDown) return; 117 | 118 | moveGhostElement(listContext.ghostWrapperElementRef.current ?? undefined, absoluteXY); 119 | }, []); 120 | const onDragEnd = React.useCallback(() => { 121 | clearBodyStyle(document.body); 122 | clearGhostElementStyle(listContext.ghostWrapperElementRef.current ?? undefined); 123 | 124 | // Calls callbacks. 125 | const destinationMeta = listContext.destinationMetaRef.current; 126 | listContext.onDragEnd({ 127 | identifier: props.identifier, 128 | groupIdentifier: groupContext.identifier, 129 | index: props.index, 130 | isGroup, 131 | nextGroupIdentifier: destinationMeta != undefined ? destinationMeta.groupIdentifier : groupContext.identifier, 132 | nextIndex: destinationMeta != undefined ? destinationMeta.index : props.index, 133 | }); 134 | 135 | // Resets context values. 136 | listContext.setDraggingNodeMeta(undefined); 137 | listContext.setIsVisibleDropLineElement(false); 138 | listContext.setStackedGroupIdentifier(undefined); 139 | listContext.hoveredNodeMetaRef.current = undefined; 140 | listContext.destinationMetaRef.current = undefined; 141 | }, [listContext.onDragEnd, groupContext.identifier, props.identifier, props.index, isGroup]); 142 | 143 | const onHover = React.useCallback( 144 | (element: HTMLElement) => { 145 | // Initialize if the dragging item is this item or an ancestor group of this item. 146 | const draggingNodeMeta = listContext.draggingNodeMeta; 147 | const isNeededInitialization = 148 | draggingNodeMeta == undefined || 149 | props.identifier === draggingNodeMeta.identifier || 150 | checkIsAncestorItem(draggingNodeMeta.identifier, ancestorIdentifiers); 151 | if (isNeededInitialization) { 152 | listContext.setIsVisibleDropLineElement(false); 153 | listContext.hoveredNodeMetaRef.current = undefined; 154 | listContext.destinationMetaRef.current = undefined; 155 | 156 | return; 157 | } 158 | 159 | listContext.setIsVisibleDropLineElement(true); 160 | listContext.hoveredNodeMetaRef.current = getNodeMeta( 161 | element, 162 | props.identifier, 163 | groupContext.identifier, 164 | ancestorIdentifiers, 165 | props.index, 166 | isGroup, 167 | ); 168 | }, 169 | [listContext.draggingNodeMeta, groupContext.identifier, props.identifier, props.index, ancestorIdentifiers, isGroup], 170 | ); 171 | const onMoveForStackableGroup = React.useCallback( 172 | (hoveredNodeMeta: NodeMeta) => { 173 | // Sets contexts to values. 174 | listContext.setIsVisibleDropLineElement(false); 175 | listContext.setStackedGroupIdentifier(props.identifier); 176 | listContext.destinationMetaRef.current = { 177 | groupIdentifier: props.identifier, 178 | index: undefined, 179 | }; 180 | 181 | // Calls callbacks. 182 | listContext.onStackGroup?.({ 183 | identifier: props.identifier, 184 | groupIdentifier: groupContext.identifier, 185 | index: props.index, 186 | isGroup, 187 | nextGroupIdentifier: hoveredNodeMeta.identifier, 188 | }); 189 | }, 190 | [listContext.stackableAreaThreshold, listContext.onStackGroup, groupContext.identifier, props.identifier, props.index], 191 | ); 192 | const onMoveForItems = React.useCallback( 193 | (draggingNodeMeta: NodeMeta, hoveredNodeMeta: NodeMeta, absoluteXY: [number, number]) => { 194 | if (isLonley) { 195 | listContext.setIsVisibleDropLineElement(false); 196 | listContext.destinationMetaRef.current = undefined; 197 | 198 | return; 199 | } 200 | if (draggingNodeMeta.index !== hoveredNodeMeta.index) listContext.setIsVisibleDropLineElement(true); 201 | 202 | const dropLineElement = listContext.dropLineElementRef.current ?? undefined; 203 | setDropLineElementStyle(dropLineElement, absoluteXY, hoveredNodeMeta, listContext.direction); 204 | 205 | // Calculates the next index. 206 | const dropLineDirection = getDropLineDirectionFromXY(absoluteXY, hoveredNodeMeta, listContext.direction); 207 | const nextIndex = getDropLinePositionItemIndex( 208 | dropLineDirection, 209 | draggingNodeMeta.index, 210 | draggingNodeMeta.groupIdentifier, 211 | hoveredNodeMeta.index, 212 | hoveredNodeMeta.groupIdentifier, 213 | ); 214 | 215 | // Calls callbacks if needed. 216 | const destinationMeta = listContext.destinationMetaRef.current; 217 | const isComeFromStackedGroup = 218 | destinationMeta != undefined && destinationMeta.groupIdentifier != undefined && destinationMeta.index == undefined; 219 | if (isComeFromStackedGroup) { 220 | listContext.onStackGroup?.({ 221 | identifier: props.identifier, 222 | groupIdentifier: groupContext.identifier, 223 | index: props.index, 224 | isGroup, 225 | nextGroupIdentifier: undefined, 226 | }); 227 | } 228 | 229 | // Sets contexts to values. 230 | listContext.setStackedGroupIdentifier(undefined); 231 | listContext.destinationMetaRef.current = { groupIdentifier: groupContext.identifier, index: nextIndex }; 232 | }, 233 | [ 234 | listContext.direction, 235 | listContext.onStackGroup, 236 | groupContext.identifier, 237 | props.identifier, 238 | props.index, 239 | isGroup, 240 | isLonley, 241 | ], 242 | ); 243 | const onMove = React.useCallback( 244 | (absoluteXY: [number, number]) => { 245 | const draggingNodeMeta = listContext.draggingNodeMeta; 246 | if (draggingNodeMeta == undefined) return; 247 | const hoveredNodeMeta = listContext.hoveredNodeMetaRef.current; 248 | if (hoveredNodeMeta == undefined) return; 249 | 250 | const hasNoItems = childIdentifiersRef.current.size === 0; 251 | if ( 252 | isGroup && 253 | hasNoItems && 254 | checkIsInStackableArea(absoluteXY, hoveredNodeMeta, listContext.stackableAreaThreshold, listContext.direction) 255 | ) { 256 | onMoveForStackableGroup(hoveredNodeMeta); 257 | } else { 258 | onMoveForItems(draggingNodeMeta, hoveredNodeMeta, absoluteXY); 259 | } 260 | }, 261 | [listContext.draggingNodeMeta, listContext.direction, onMoveForStackableGroup, onMoveForItems, isGroup], 262 | ); 263 | const onLeave = React.useCallback(() => { 264 | if (listContext.draggingNodeMeta == undefined) return; 265 | 266 | // Clears a dragging node after 50ms in order to prevent setting and clearing at the same time. 267 | window.clearTimeout(clearingDraggingNodeTimeoutIdRef.current); 268 | clearingDraggingNodeTimeoutIdRef.current = window.setTimeout(() => { 269 | if (listContext.hoveredNodeMetaRef.current?.identifier !== props.identifier) return; 270 | 271 | listContext.setIsVisibleDropLineElement(false); 272 | listContext.setStackedGroupIdentifier(undefined); 273 | listContext.hoveredNodeMetaRef.current = undefined; 274 | listContext.destinationMetaRef.current = undefined; 275 | }, 50); 276 | }, [listContext.draggingNodeMeta, props.identifier]); 277 | 278 | const binder = useGesture({ 279 | onHover: ({ event }) => { 280 | if (listContext.draggingNodeMeta == undefined) return; 281 | 282 | const element = event?.currentTarget; 283 | if (!(element instanceof HTMLElement)) return; 284 | 285 | event?.stopPropagation(); 286 | onHover(element); 287 | }, 288 | onMove: ({ xy }) => { 289 | if (listContext.draggingNodeMeta == undefined) return; 290 | 291 | // Skips if this item is an ancestor group of the dragging item. 292 | const hasItems = childIdentifiersRef.current.size > 0; 293 | const hoveredNodeAncestors = listContext.hoveredNodeMetaRef.current?.ancestorIdentifiers ?? []; 294 | if (hasItems && checkIsAncestorItem(props.identifier, hoveredNodeAncestors)) return; 295 | if (props.identifier === listContext.draggingNodeMeta.identifier) return; 296 | // Skips if the dragging item is an ancestor group of this item. 297 | if (checkIsAncestorItem(listContext.draggingNodeMeta.identifier, ancestorIdentifiers)) return; 298 | 299 | onMove(xy); 300 | }, 301 | onPointerLeave: onLeave, 302 | }); 303 | const dragHandlers: React.ContextType["dragHandlers"] = { onDragStart, onDrag, onDragEnd }; 304 | const draggableBinder = useGesture({ 305 | onDragStart: (state: any) => { 306 | if (isLocked) return; 307 | 308 | const event: React.SyntheticEvent = state.event; 309 | event.persist(); 310 | event.stopPropagation(); 311 | 312 | dragHandlers.onDragStart(); 313 | }, 314 | onDrag: ({ down, movement }) => { 315 | if (isLocked) return; 316 | 317 | dragHandlers.onDrag(down, movement); 318 | }, 319 | onDragEnd: () => { 320 | if (isLocked) return; 321 | 322 | dragHandlers.onDragEnd(); 323 | }, 324 | }); 325 | 326 | const contentElement = React.useMemo((): JSX.Element => { 327 | const draggingNodeMeta = listContext.draggingNodeMeta; 328 | const isDragging = draggingNodeMeta != undefined && props.identifier === draggingNodeMeta.identifier; 329 | const { renderPlaceholder, renderStackedGroup, itemSpacing, direction } = listContext; 330 | 331 | const rendererMeta: Omit, "isGroup"> | StackedGroupRendererMeta = { 332 | identifier: props.identifier, 333 | groupIdentifier: groupContext.identifier, 334 | index: props.index, 335 | }; 336 | 337 | let children = props.children; 338 | if (isDragging && renderPlaceholder != undefined) { 339 | const style = getPlaceholderElementStyle(draggingNodeMeta, itemSpacing, direction); 340 | children = renderPlaceholder({ style }, { ...rendererMeta, isGroup }); 341 | } 342 | if (listContext.stackedGroupIdentifier === props.identifier && renderStackedGroup != undefined) { 343 | const style = getStackedGroupElementStyle(listContext.hoveredNodeMetaRef.current, itemSpacing, direction); 344 | children = renderStackedGroup({ style }, rendererMeta); 345 | } 346 | 347 | const padding: [string, string] = ["0", "0"]; 348 | if (direction === "vertical") padding[0] = `${itemSpacing / 2}px`; 349 | if (direction === "horizontal") padding[1] = `${itemSpacing / 2}px`; 350 | 351 | return ( 352 |
358 | {children} 359 |
360 | ); 361 | }, [ 362 | listContext.draggingNodeMeta, 363 | listContext.renderPlaceholder, 364 | listContext.renderStackedGroup, 365 | listContext.stackedGroupIdentifier, 366 | listContext.itemSpacing, 367 | listContext.direction, 368 | groupContext.identifier, 369 | props.identifier, 370 | props.children, 371 | props.index, 372 | isGroup, 373 | isUsedCustomDragHandlers, 374 | binder, 375 | draggableBinder, 376 | ]); 377 | if (!isGroup) return {contentElement}; 378 | 379 | return ( 380 | 381 | {contentElement} 382 | 383 | ); 384 | }; 385 | --------------------------------------------------------------------------------