diff --git a/apps/docs/.storybook/preview-head.html b/apps/docs/.storybook/preview-head.html new file mode 100644 index 00000000..b78ed648 --- /dev/null +++ b/apps/docs/.storybook/preview-head.html @@ -0,0 +1,76 @@ + + + + + diff --git a/apps/docs/.storybook/preview.jsx b/apps/docs/.storybook/preview.jsx index 78d48c4b..cf41e3d8 100644 --- a/apps/docs/.storybook/preview.jsx +++ b/apps/docs/.storybook/preview.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import {Unstyled} from '@storybook/blocks'; +import {Unstyled, Title} from '@storybook/blocks'; import {Code} from '../stories/react/components'; diff --git a/apps/docs/stories/react/Droppable/Droppable.mdx b/apps/docs/stories/react/Droppable/Droppable.mdx deleted file mode 100644 index 9bc25f09..00000000 --- a/apps/docs/stories/react/Droppable/Droppable.mdx +++ /dev/null @@ -1,11 +0,0 @@ -import {Canvas, Meta} from '@storybook/blocks'; - -import * as DroppableStories from './Droppable.stories'; - - - -# Droppable - -A droppable component - - diff --git a/apps/docs/stories/react/Droppable/Droppable.stories.tsx b/apps/docs/stories/react/Droppable/Droppable.stories.tsx index cae97941..83d1bbbb 100644 --- a/apps/docs/stories/react/Droppable/Droppable.stories.tsx +++ b/apps/docs/stories/react/Droppable/Droppable.stories.tsx @@ -1,16 +1,30 @@ import type {Meta, StoryObj} from '@storybook/react'; import {DroppableExample} from './DroppableExample'; +import {KitchenSinkExample} from './KitchenSinkExample'; +import docs from './docs/DroppableDocs.mdx'; const meta: Meta = { component: DroppableExample, + tags: ['autodocs'], + parameters: { + docs: { + page: docs, + }, + }, }; export default meta; type Story = StoryObj; -export const BasicSetup: Story = { +export const BasicSetup: Story = {}; + +export const MultipleDroppables: Story = { args: { - label: 'Basic setup', + parents: ['A', 'B', 'C'], }, }; + +export const KitchenSink: Story = { + render: KitchenSinkExample, +}; diff --git a/apps/docs/stories/react/Droppable/DroppableExample.tsx b/apps/docs/stories/react/Droppable/DroppableExample.tsx index 702372d2..d4b70b0d 100644 --- a/apps/docs/stories/react/Droppable/DroppableExample.tsx +++ b/apps/docs/stories/react/Droppable/DroppableExample.tsx @@ -1,145 +1,57 @@ -import React, {useRef, useState} from 'react'; +import React, {useState} from 'react'; import type {PropsWithChildren} from 'react'; import type {UniqueIdentifier} from '@dnd-kit/types'; import {DragDropProvider, useDraggable, useDroppable} from '@dnd-kit/react'; -import {closestCenter, CollisionDetector} from '@dnd-kit/collision'; -import {Button, Dropzone, Handle} from '../components'; +import {Button, Dropzone} from '../components'; import {DraggableIcon} from '../icons'; -export function DroppableExample() { - const [items, setItems] = useState({ - A: { - A1: [{id: 0, type: 'A'}], - A2: [], - A3: [], - A4: [], - A5: [], - A6: [], - A7: [], - }, - B: { - B1: [], - B2: [{id: 1, type: 'B'}], - B3: [], - B4: [], - }, - C: { - C1: [], - C2: [], - C3: [], - C4: [], - }, - }); +interface Props { + parents?: UniqueIdentifier[]; +} + +export function DroppableExample({parents = ['A']}: Props) { + const [parent, setParent] = useState(); + const draggable = ; return ( { - const {source, target} = event.operation; + const {target} = event.operation; if (event.canceled) { - const {abort, resume} = event.suspend(); - const cancel = confirm('Cancel drop?'); - - resume(); - - if (cancel) { - abort(); - return; - } + return; } - if (source && target) { - const targetRowId = target.id; - const currentRowId = source.data!.parent; - const [targetColumnId] = String(target.id); - const [currentColumnId] = String(currentRowId); - - if (currentRowId !== targetRowId) { - setItems((items) => { - const newItems = {...items}; - - newItems[currentColumnId][currentRowId] = newItems[ - currentColumnId - ][currentRowId].filter((child) => child.id !== source.id); - - newItems[targetColumnId][targetRowId] = [ - ...newItems[targetColumnId][targetRowId].filter( - (child) => child.id !== source.id - ), - {id: source.id, type: source.type}, - ]; - - return newItems; - }); - } - } + setParent(target?.id); }} > -
- {Object.entries(items).map(([columnId, items]) => ( -
- {Object.entries(items).map(([rowId, children]) => ( - - {children.length - ? children.map((child) => ( - - )) - : null} - - ))} -
+
+
{parent == null ? draggable : null}
+ {parents.map((id) => ( + + {parent === id ? draggable : null} + ))} -
+
); } interface DraggableProps { id: UniqueIdentifier; - parent: UniqueIdentifier; - type?: UniqueIdentifier; } -function Draggable({id, parent, type}: DraggableProps) { +function Draggable({id}: DraggableProps) { const [element, setElement] = useState(null); - const activatorRef = useRef(null); const {isDragSource} = useDraggable({ id, - data: {parent}, element, - activator: activatorRef, - type, }); return ( - ); @@ -147,17 +59,10 @@ function Draggable({id, parent, type}: DraggableProps) { interface DroppableProps { id: UniqueIdentifier; - accept?: UniqueIdentifier[]; - collisionDetector?: CollisionDetector; } -function Droppable({ - id, - accept, - collisionDetector, - children, -}: PropsWithChildren) { - const {ref, isDropTarget} = useDroppable({id, accept, collisionDetector}); +function Droppable({id, children}: PropsWithChildren) { + const {ref, isDropTarget} = useDroppable({id}); return ( diff --git a/apps/docs/stories/react/Droppable/KitchenSinkExample.tsx b/apps/docs/stories/react/Droppable/KitchenSinkExample.tsx new file mode 100644 index 00000000..64868c82 --- /dev/null +++ b/apps/docs/stories/react/Droppable/KitchenSinkExample.tsx @@ -0,0 +1,167 @@ +import React, {useRef, useState} from 'react'; +import type {PropsWithChildren} from 'react'; +import type {UniqueIdentifier} from '@dnd-kit/types'; +import {DragDropProvider, useDraggable, useDroppable} from '@dnd-kit/react'; +import {closestCenter, CollisionDetector} from '@dnd-kit/collision'; + +import {Button, Dropzone, Handle} from '../components'; +import {DraggableIcon} from '../icons'; + +export function KitchenSinkExample() { + const [items, setItems] = useState({ + A: { + A1: [{id: 0, type: 'A'}], + A2: [], + A3: [], + A4: [], + A5: [], + A6: [], + A7: [], + }, + B: { + B1: [], + B2: [{id: 1, type: 'B'}], + B3: [], + B4: [], + }, + C: { + C1: [], + C2: [], + C3: [], + C4: [], + }, + }); + + return ( + { + const {source, target} = event.operation; + + if (event.canceled) { + const {abort, resume} = event.suspend(); + const cancel = confirm('Cancel drop?'); + + resume(); + + if (cancel) { + abort(); + return; + } + } + + if (source && target) { + const targetRowId = target.id; + const currentRowId = source.data!.parent; + const [targetColumnId] = String(target.id); + const [currentColumnId] = String(currentRowId); + + if (currentRowId !== targetRowId) { + setItems((items) => { + const newItems = {...items}; + + newItems[currentColumnId][currentRowId] = newItems[ + currentColumnId + ][currentRowId].filter((child) => child.id !== source.id); + + newItems[targetColumnId][targetRowId] = [ + ...newItems[targetColumnId][targetRowId].filter( + (child) => child.id !== source.id + ), + {id: source.id, type: source.type}, + ]; + + return newItems; + }); + } + } + }} + > +
+ {Object.entries(items).map(([columnId, items]) => ( +
+ {Object.entries(items).map(([rowId, children]) => ( + + {children.length + ? children.map((child) => ( + + )) + : null} + + ))} +
+ ))} +
+
+ ); +} + +interface DraggableProps { + id: UniqueIdentifier; + parent: UniqueIdentifier; + type?: UniqueIdentifier; +} + +function Draggable({id, parent, type}: DraggableProps) { + const [element, setElement] = useState(null); + const activatorRef = useRef(null); + + const {isDragSource} = useDraggable({ + id, + data: {parent}, + element, + activator: activatorRef, + type, + }); + + return ( + + ); +} + +interface DroppableProps { + id: UniqueIdentifier; + accept?: UniqueIdentifier[]; + collisionDetector?: CollisionDetector; +} + +function Droppable({ + id, + accept, + collisionDetector, + children, +}: PropsWithChildren) { + const {ref, isDropTarget} = useDroppable({id, accept, collisionDetector}); + + return ( + + {children} + + ); +} diff --git a/apps/docs/stories/react/Droppable/docs/DroppableDocs.mdx b/apps/docs/stories/react/Droppable/docs/DroppableDocs.mdx new file mode 100644 index 00000000..81f0d420 --- /dev/null +++ b/apps/docs/stories/react/Droppable/docs/DroppableDocs.mdx @@ -0,0 +1,53 @@ +import {Preview} from '../../components'; +import {BasicSetup} from '../Droppable.stories'; +import {Example} from './examples/QuickStart'; +import ExampleSource from './examples/QuickStart?raw'; +import DraggableSource from './examples/Draggable?raw'; +import DroppableSource from './examples/Droppable?raw'; +import {Example as MultipleDroppableExample} from './examples/MultipleDroppable'; +import MultipleDroppableExampleSource from './examples/MultipleDroppable?raw'; + +import image from './assets/useSortable.png'; + +# Droppable + +Create droppable targets for draggable elements. + + + +## Usage + +Use the `useDroppable` hook to create droppable targets that `draggable` elements can be dropped over. + +```jsx +import {useDroppable} from '@dnd-kit/core'; + +function Droppable(props) { + const {ref} = useDroppable({ + id: props.id, + }); + + return
{props.children}
; +} +``` + +On its own, the `useDroppable` hook does not provide a lot of functionality. It is meant to be used in conjunction with the `useDraggable` hook, along with a parent `` component to listen for and respond to drag and drop events. + +You can set up as many droppable targets as needed, either by calling the `useDroppable` hook multiple times within the same component, or by setting up a generic component that calls `useDroppable` and rendering that component multiple times. Just remember to make sure they all have a unique `id` so that they can be differentiated. + +## Getting started + +To get started, we will create three files, `App.js`, `Droppable.js`, and `Draggable.js`. + + + + + +To render multiple droppable targets, we can simply render the `` component multiple times: + + + + diff --git a/apps/docs/stories/react/Droppable/docs/assets/useSortable.png b/apps/docs/stories/react/Droppable/docs/assets/useSortable.png new file mode 100644 index 00000000..a5de498f Binary files /dev/null and b/apps/docs/stories/react/Droppable/docs/assets/useSortable.png differ diff --git a/apps/docs/stories/react/Droppable/docs/examples/Draggable.jsx b/apps/docs/stories/react/Droppable/docs/examples/Draggable.jsx new file mode 100644 index 00000000..91bbe955 --- /dev/null +++ b/apps/docs/stories/react/Droppable/docs/examples/Draggable.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import {useDraggable} from '@dnd-kit/react'; + +export function Draggable({id}) { + const {ref} = useDraggable({ + id, + }); + + return ( + + ); +} diff --git a/apps/docs/stories/react/Droppable/docs/examples/Droppable.jsx b/apps/docs/stories/react/Droppable/docs/examples/Droppable.jsx new file mode 100644 index 00000000..c312d3c6 --- /dev/null +++ b/apps/docs/stories/react/Droppable/docs/examples/Droppable.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import {useDroppable} from '@dnd-kit/react'; + +export function Droppable({id, children}) { + const {ref, isDropTarget} = useDroppable({id}); + + return ( +
+ {children} +
+ ); +} diff --git a/apps/docs/stories/react/Droppable/docs/examples/MultipleDroppable.jsx b/apps/docs/stories/react/Droppable/docs/examples/MultipleDroppable.jsx new file mode 100644 index 00000000..88cb04a4 --- /dev/null +++ b/apps/docs/stories/react/Droppable/docs/examples/MultipleDroppable.jsx @@ -0,0 +1,34 @@ +import React, {useState} from 'react'; +import {DragDropProvider} from '@dnd-kit/react'; + +import {Droppable} from './Droppable'; +import {Draggable} from './Draggable'; + +export function Example() { + const [parent, setParent] = useState(); + const parents = ['A', 'B', 'C']; + const draggable = ; + + return ( + { + const {target} = event.operation; + + if (event.canceled) return; + + setParent(target ? target.id : undefined); + }} + > +
+
{parent == null ? draggable : null}
+ {parents.map((id) => ( + + {parent === id ? draggable : null} + + ))} +
+
+ ); +} + + diff --git a/apps/docs/stories/react/Droppable/docs/examples/QuickStart.jsx b/apps/docs/stories/react/Droppable/docs/examples/QuickStart.jsx new file mode 100644 index 00000000..9e0dc7ed --- /dev/null +++ b/apps/docs/stories/react/Droppable/docs/examples/QuickStart.jsx @@ -0,0 +1,31 @@ +import React, {useState} from 'react'; +import {DragDropProvider} from '@dnd-kit/react'; + +import {Droppable} from './Droppable'; +import {Draggable} from './Draggable'; + +export function Example() { + const [parent, setParent] = useState(); + const draggable = ; + + return ( + { + const {target} = event.operation; + + if (event.canceled) return; + + setParent(target ? target.id : undefined); + }} + > +
+
{parent == null ? draggable : null}
+ + {parent === 'dropzone' ? draggable : null} + +
+
+ ); +} + + diff --git a/apps/docs/stories/react/Sortable/MultipleContainers/MultipleContainers.stories.tsx b/apps/docs/stories/react/Sortable/MultipleContainers/MultipleContainers.stories.tsx new file mode 100644 index 00000000..605e3ee1 --- /dev/null +++ b/apps/docs/stories/react/Sortable/MultipleContainers/MultipleContainers.stories.tsx @@ -0,0 +1,14 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import {MultipleContainers} from './MultipleContainers'; + +const meta: Meta = { + component: MultipleContainers, +}; + +export default meta; +type Story = StoryObj; + +export const BasicSetup: Story = { + name: 'Basic setup', +}; diff --git a/apps/docs/stories/react/Sortable/MultipleContainers/MultipleContainers.tsx b/apps/docs/stories/react/Sortable/MultipleContainers/MultipleContainers.tsx new file mode 100644 index 00000000..e3727aec --- /dev/null +++ b/apps/docs/stories/react/Sortable/MultipleContainers/MultipleContainers.tsx @@ -0,0 +1,112 @@ +import React, {useRef, useState} from 'react'; +import type {PropsWithChildren} from 'react'; +import type {UniqueIdentifier} from '@dnd-kit/types'; +import {DragDropProvider, useSortable} from '@dnd-kit/react'; +import {arrayMove} from '@dnd-kit/utilities'; + +import {Item, Handle} from '../../components'; +import {createRange, cloneDeep} from '../../../utilities'; + +interface Props { + itemCount?: number; +} + +export function MultipleContainers({itemCount = 5}: Props) { + const [items, setItems] = useState>({ + A: createRange(itemCount).map((id) => `A${id}`), + B: createRange(itemCount).map((id) => `B${id}`), + C: createRange(itemCount).map((id) => `C${id}`), + }); + const snapshot = useRef(cloneDeep(items)); + + return ( + { + snapshot.current = cloneDeep(items); + }} + onDragOver={(event, manager) => { + const {source, target} = event.operation; + + if (!source?.data || !target?.data) { + return; + } + + const {column} = source.data; + const {column: targetColumn} = target.data; + + const sourceIndex = items[column].indexOf(source.id); + const targetIndex = items[targetColumn].indexOf(target.id); + + if (column !== targetColumn) { + setItems((items) => ({ + ...items, + [column]: items[column].filter((item) => item !== source.id), + [targetColumn]: [ + ...items[targetColumn] + .filter((item) => item !== source.id) + .slice(0, targetIndex), + source.id, + ...items[targetColumn] + .filter((item) => item !== source.id) + .slice(targetIndex), + ], + })); + } else if (sourceIndex !== targetIndex) { + setItems((items) => ({ + ...items, + [column]: arrayMove(items[column], sourceIndex, targetIndex), + })); + } + }} + onDragEnd={(event) => { + if (event.canceled) { + setItems(snapshot.current); + } + }} + > +
+ {Object.entries(items).map(([column, rows]) => ( +
+ {rows.map((id, index) => ( + + ))} +
+ ))} +
+
+ ); +} + +interface SortableProps { + id: UniqueIdentifier; + column: string; + index: number; +} + +function Sortable({id, column, index}: PropsWithChildren) { + const {activatorRef, ref, isDragSource} = useSortable({ + id, + data: {column}, + index, + }); + + return ( + } + style={{width: 250}} + > + {id} + + ); +} diff --git a/apps/docs/stories/react/Sortable/Sortable.stories.tsx b/apps/docs/stories/react/Sortable/Sortable.stories.tsx index 70358d42..64ee1e88 100644 --- a/apps/docs/stories/react/Sortable/Sortable.stories.tsx +++ b/apps/docs/stories/react/Sortable/Sortable.stories.tsx @@ -1,7 +1,7 @@ import type {Meta, StoryObj} from '@storybook/react'; import {SortableExample} from './SortableExample'; -import docs from './docs/Docs.mdx'; +import docs from './docs/SortableDocs.mdx'; const meta: Meta = { component: SortableExample, @@ -17,5 +17,27 @@ export default meta; type Story = StoryObj; export const BasicSetup: Story = { - render: SortableExample, + name: 'Vertical', +}; + +export const VariableHeights: Story = { + name: 'Variable heights', + args: { + heights: {1: 100, 3: 150, 5: 200, 8: 100, 12: 150}, + }, +}; + +export const Horizontal: Story = { + args: { + widths: 180, + horizontal: true, + }, +}; + +export const VariableWidths: Story = { + name: 'Variable widths', + args: { + horizontal: true, + widths: {0: 140, 2: 120, 4: 140, 5: 240, 8: 100, 12: 150, default: 180}, + }, }; diff --git a/apps/docs/stories/react/Sortable/SortableExample.tsx b/apps/docs/stories/react/Sortable/SortableExample.tsx index 2c4a5801..bc00dc7d 100644 --- a/apps/docs/stories/react/Sortable/SortableExample.tsx +++ b/apps/docs/stories/react/Sortable/SortableExample.tsx @@ -8,10 +8,18 @@ import {Item, Handle} from '../components'; import {createRange, cloneDeep} from '../../utilities'; interface Props { + horizontal?: boolean; itemCount?: number; + heights?: number | Record; + widths?: number | Record; } -export function SortableExample({itemCount = 15}: Props) { +export function SortableExample({ + itemCount = 15, + horizontal, + heights, + widths, +}: Props) { const [items, setItems] = useState( createRange(itemCount) ); @@ -44,26 +52,42 @@ export function SortableExample({itemCount = 15}: Props) { >
{items.map((id, index) => ( - + ))}
); + + function getStyle(id: UniqueIdentifier) { + return { + width: + typeof widths === 'number' + ? widths + : widths?.[id] ?? widths?.['default'], + height: + typeof heights === 'number' + ? heights + : heights?.[id] ?? heights?.['default'], + }; + } } interface SortableProps { id: UniqueIdentifier; index: number; + style?: React.CSSProperties; } -function Sortable({id, index}: PropsWithChildren) { +function Sortable({id, index, style}: PropsWithChildren) { const [element, setElement] = useState(null); const activatorRef = useRef(null); @@ -79,6 +103,7 @@ function Sortable({id, index}: PropsWithChildren) { ref={setElement} shadow={isDragSource} actions={} + style={style} > {id} diff --git a/apps/docs/stories/react/Sortable/Virtualized/VirtualizedSortableExample.tsx b/apps/docs/stories/react/Sortable/Virtualized/ReactVirtualExample.tsx similarity index 98% rename from apps/docs/stories/react/Sortable/Virtualized/VirtualizedSortableExample.tsx rename to apps/docs/stories/react/Sortable/Virtualized/ReactVirtualExample.tsx index 02779f95..0daddd18 100644 --- a/apps/docs/stories/react/Sortable/Virtualized/VirtualizedSortableExample.tsx +++ b/apps/docs/stories/react/Sortable/Virtualized/ReactVirtualExample.tsx @@ -8,7 +8,7 @@ import {useWindowVirtualizer} from '@tanstack/react-virtual'; import {Item, Handle} from '../../components'; import {createRange, cloneDeep} from '../../../utilities'; -export function VirtualizedSortableExample() { +export function ReactVirtualExample() { const [items, setItems] = useState(createRange(1000)); const snapshot = useRef(cloneDeep(items)); diff --git a/apps/docs/stories/react/Sortable/Virtualized/Virtualized.stories.tsx b/apps/docs/stories/react/Sortable/Virtualized/Virtualized.stories.tsx index 862cb4a4..f4716211 100644 --- a/apps/docs/stories/react/Sortable/Virtualized/Virtualized.stories.tsx +++ b/apps/docs/stories/react/Sortable/Virtualized/Virtualized.stories.tsx @@ -1,15 +1,15 @@ import type {Meta, StoryObj} from '@storybook/react'; -import {VirtualizedSortableExample} from './VirtualizedSortableExample'; +import {ReactVirtualExample} from './ReactVirtualExample'; -const meta: Meta = { - component: VirtualizedSortableExample, +const meta: Meta = { + component: ReactVirtualExample, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const ReactVirtual: Story = { name: 'react-virtual', - render: VirtualizedSortableExample, + render: ReactVirtualExample, }; diff --git a/apps/docs/stories/react/Sortable/docs/Docs.mdx b/apps/docs/stories/react/Sortable/docs/SortableDocs.mdx similarity index 59% rename from apps/docs/stories/react/Sortable/docs/Docs.mdx rename to apps/docs/stories/react/Sortable/docs/SortableDocs.mdx index 3f0e7a85..ce7ed39c 100644 --- a/apps/docs/stories/react/Sortable/docs/Docs.mdx +++ b/apps/docs/stories/react/Sortable/docs/SortableDocs.mdx @@ -1,31 +1,31 @@ -import {Canvas} from '@storybook/blocks'; - import {Code, Preview} from '../../components'; import {BasicSetup} from '../Sortable.stories'; import BasicSetupSource from '../SortableExample?raw'; import {Example} from './examples/QuickStart'; -import QuickStartSource from './examples/QuickStart?raw'; +import ExampleSource from './examples/QuickStart?raw'; import image from './assets/useSortable.png'; # Sortable -> The sortable plugin provides the building blocks to build sortable interfaces. +Reorder elements in a list or multiple lists using the sortable plugin. - + ## Installation -To get started, install the `@dnd-kit/react` package. +Before getting started, make sure to install the `@dnd-kit/react` package: + +```bash +npm install @dnd-kit/react +``` ## Getting started - + -{QuickStartSource} - -To create a vertical list, we can simply update the CSS value for `flex-direction` to `column`, without any other configuration changes. +To create a vertical list, we can simply update the of the `flex-direction` property to `column`, without any other configuration changes. diff --git a/apps/docs/stories/react/Sortable/docs/examples/QuickStart.jsx b/apps/docs/stories/react/Sortable/docs/examples/QuickStart.jsx index 6fcc1929..d4f46734 100644 --- a/apps/docs/stories/react/Sortable/docs/examples/QuickStart.jsx +++ b/apps/docs/stories/react/Sortable/docs/examples/QuickStart.jsx @@ -30,7 +30,7 @@ function Sortable({id, index}) { const {ref} = useSortable({id, index}); return ( - ); diff --git a/apps/docs/stories/react/components/Action/Action.tsx b/apps/docs/stories/react/components/Action/Action.tsx index 50864e18..9852a932 100644 --- a/apps/docs/stories/react/components/Action/Action.tsx +++ b/apps/docs/stories/react/components/Action/Action.tsx @@ -16,7 +16,6 @@ export const Action = forwardRef( ref={ref} {...props} className={classNames(styles.Action, styles[variant], className)} - tabIndex={0} style={ { ...style, diff --git a/apps/docs/stories/react/components/Code/Code.module.css b/apps/docs/stories/react/components/Code/Code.module.css index b5145ae6..85ca774b 100644 --- a/apps/docs/stories/react/components/Code/Code.module.css +++ b/apps/docs/stories/react/components/Code/Code.module.css @@ -1,25 +1,23 @@ .Code { --background-color: #263038; - --border-radius: 10px; + --border-radius: 8px; max-width: 100%; margin: 30px 0; background-color: var(--background-color); border-radius: var(--border-radius); color: #fff; - transition: box-shadow 250ms ease, - transform 300ms cubic-bezier(0.19, 1, 0.22, 1); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.10), inset 0 0 1px hsla(203, 50%, 30%, 0.5); } -@media (prefers-color-scheme: dark) { - .Code { - --background-color: #1b1f27; - } +.InlineCode { + color: rgba(0,37,158,0.797) !important; + background-color: rgba(1,68,255,0.059) !important; } -@media (max-width: 540px) { +@media (prefers-color-scheme: dark) { .Code { - margin: 0 -0.8rem; + --background-color: #1b1f27; } } @@ -28,3 +26,28 @@ padding-right: 20px; overflow: auto; } + +.Tabs { + display: flex; + overflow-x: auto; + background-color: #1e272d; +} + +.Tab { + background-color: transparent; + appearance: none; + outline: none; + border: 0; + color: #97999e; + padding: 20px; + font-family: "Roboto Mono",Consolas,"Andale Mono WT","Andale Mono","Lucida Console","Lucida Sans Typewriter","DejaVu Sans Mono","Bitstream Vera Sans Mono","Liberation Mono","Nimbus Mono L",Monaco,"Courier New",Courier,monospace; + + cursor: pointer; + font-size: 14px; + font-weight: 400; +} + +.Tab[aria-selected="true"] { + color: #fff; + background-color: var(--background-color); +} diff --git a/apps/docs/stories/react/components/Code/Code.tsx b/apps/docs/stories/react/components/Code/Code.tsx index 17cf0ef4..60df60aa 100644 --- a/apps/docs/stories/react/components/Code/Code.tsx +++ b/apps/docs/stories/react/components/Code/Code.tsx @@ -1,25 +1,46 @@ -import React from 'react'; +import React, {useState} from 'react'; import {Unstyled} from '@storybook/blocks'; import {CodeHighlighter} from './components'; import styles from './Code.module.css'; interface Props { - children: string; + children: string | string[]; + tabs?: string[]; + className?: string; } export function Code(props: Props) { - const {children} = props; + const {children, className} = props; + const [selectedTab, setSelectedTab] = useState(0); + const contents = Array.isArray(children) ? children[selectedTab] : children; - return children.includes('\n') ? ( + return Array.isArray(children) || children.includes('\n') ? (
+ {props.tabs ? ( +
+ {props.tabs.map((tab, index) => ( + + ))} +
+ ) : null}
- {children} + + {contents} +
) : ( - {children} + {children} ); } diff --git a/apps/docs/stories/react/components/Code/components/CodeHighlighter/CodeHighlighter.module.css b/apps/docs/stories/react/components/Code/components/CodeHighlighter/CodeHighlighter.module.css index b344da42..9539f782 100644 --- a/apps/docs/stories/react/components/Code/components/CodeHighlighter/CodeHighlighter.module.css +++ b/apps/docs/stories/react/components/Code/components/CodeHighlighter/CodeHighlighter.module.css @@ -30,7 +30,7 @@ .CodeHighlighter code, .LineNumbers { - font-weight: 500; + font-weight: 400; font-family: var(--mono-font-stack); } diff --git a/apps/docs/stories/react/components/Code/components/CodeHighlighter/CodeHighlighter.tsx b/apps/docs/stories/react/components/Code/components/CodeHighlighter/CodeHighlighter.tsx index 90697cfa..c7378ec2 100644 --- a/apps/docs/stories/react/components/Code/components/CodeHighlighter/CodeHighlighter.tsx +++ b/apps/docs/stories/react/components/Code/components/CodeHighlighter/CodeHighlighter.tsx @@ -9,31 +9,43 @@ import {classNames, createRange} from '../../../../../utilities'; interface Props { children: string; + language?: string; } -export function CodeHighlighter({children = ''}: Props) { +export function CodeHighlighter({children = '', language = 'jsx'}: Props) { + const lineCount = children.split('\n').length - 1; const nodeRef = useRef(); const highlightedCode = useMemo( () => - syntaxReplacements(Prism.highlight(children.trim(), Prism.languages.jsx)), + syntaxReplacements( + Prism.highlight( + children.trim(), + Prism.languages[language] ?? Prism.languages.txt, + language + ) + ), [children] ); useEffect(() => { - const clipboard = new Clipboard(nodeRef.current as any); + const clipboard = new Clipboard(nodeRef.current as Element); return () => clipboard.destroy(); }, []); return ( -
+
         
         
       
@@ -72,5 +84,3 @@ function syntaxReplacements(value: string) { value ); } - -const lineNumbers = createRange(50); diff --git a/apps/docs/stories/react/components/Item/Item.module.css b/apps/docs/stories/react/components/Item/Item.module.css index c479fdcf..729ca3db 100644 --- a/apps/docs/stories/react/components/Item/Item.module.css +++ b/apps/docs/stories/react/components/Item/Item.module.css @@ -11,17 +11,15 @@ background-color: #FFFFFF; border-radius: 8px; font-size: 14px !important; - font-weight: 600; - color: #333333; - cursor: grab; + color: #555; outline: none; transition: background 0.4s ease, box-shadow 0.3s ease, transform 0.25s ease; min-height: 62px; box-shadow: var(--box-shadow); - font-family: sans-serif !important; - font-weight: 300; + font-family: var(--font-family); width: 100%; max-width: 300px; + white-space: nowrap; } .Item:focus-visible:not(.shadow) { @@ -40,6 +38,7 @@ } .Item:not(.hasActions) { + cursor: grab; touch-action: none; } diff --git a/apps/docs/stories/react/components/Preview/Preview.module.css b/apps/docs/stories/react/components/Preview/Preview.module.css index f788818a..6824d27c 100644 --- a/apps/docs/stories/react/components/Preview/Preview.module.css +++ b/apps/docs/stories/react/components/Preview/Preview.module.css @@ -1,10 +1,36 @@ .Preview { - max-height: 400px; overflow-y: auto; - padding: 25px; + padding: 30px 40px; margin: 25px 0 40px; - border-radius: 4px; - background: #FFFFFF; - box-shadow: rgba(0, 0, 0, 0.10) 0 1px 3px 0; - border: 1px solid hsla(203, 50%, 30%, 0.15); + border-radius: 8px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.10), inset 0 0 1px hsla(203, 50%, 30%, 0.5); + background: #FCFCFC; +} + +.Preview:has( + .Code) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + margin-bottom: 0; +} + +.Preview + .Code > div > div { + margin-top: 0px; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.hero { + max-height: 400px; + background: linear-gradient(65deg, #56fff410, #001AFF10, #5F6AF210, #F25FD010, #56FFF510, #F25FD010, #001AFF10, #56fff410); + animation: gradient 16s linear infinite; + animation-direction: alternate; + background-size: 600% 100%; +} + + + +@keyframes gradient { + 0% {background-position: 0%} + 100% {background-position: 100%} } + diff --git a/apps/docs/stories/react/components/Preview/Preview.tsx b/apps/docs/stories/react/components/Preview/Preview.tsx index c45497cf..9ba5741b 100644 --- a/apps/docs/stories/react/components/Preview/Preview.tsx +++ b/apps/docs/stories/react/components/Preview/Preview.tsx @@ -2,17 +2,29 @@ import React from 'react'; import {Story, Unstyled} from '@storybook/blocks'; import {type StoryFn} from '@storybook/react'; +import {classNames} from '../../../utilities'; +import {Code} from '../Code'; import styles from './Preview.module.css'; interface Props { of?: StoryFn; + code?: string; + hero?: boolean; + tabs?: string[]; children?: React.ReactNode; } -export function Preview({children, of}: Props) { +export function Preview({children, code, of, hero, tabs}: Props) { return ( -
{children ?? }
+
+ {children ?? } +
+ {code ? ( +
+ {code} +
+ ) : null}
); } diff --git a/packages/abstract/src/collision/index.ts b/packages/abstract/src/collision/index.ts index 03d6fda7..36622f7f 100644 --- a/packages/abstract/src/collision/index.ts +++ b/packages/abstract/src/collision/index.ts @@ -1,3 +1,4 @@ export {CollisionObserver} from './observer'; export {CollisionNotifier} from './notifier'; +export {sortCollisions} from './utilities'; export * from './types'; diff --git a/packages/abstract/src/collision/notifier.ts b/packages/abstract/src/collision/notifier.ts index 648c0f80..3901ad44 100644 --- a/packages/abstract/src/collision/notifier.ts +++ b/packages/abstract/src/collision/notifier.ts @@ -1,4 +1,4 @@ -import {effect} from '@dnd-kit/state'; +import {effect, untracked} from '@dnd-kit/state'; import {DragDropManager} from '../manager'; import {Plugin} from '../plugins'; @@ -10,10 +10,18 @@ export class CollisionNotifier extends Plugin { this.destroy = effect(() => { const {collisionObserver, monitor} = manager; const {collisions} = collisionObserver; + + if (collisionObserver.isDisabled()) { + return; + } + let defaultPrevented = false; monitor.dispatch('collision', { collisions, + get defaultPrevented() { + return defaultPrevented; + }, preventDefault() { defaultPrevented = true; }, diff --git a/packages/abstract/src/collision/observer.ts b/packages/abstract/src/collision/observer.ts index 6f7a8f71..ffe7ac1f 100644 --- a/packages/abstract/src/collision/observer.ts +++ b/packages/abstract/src/collision/observer.ts @@ -1,60 +1,63 @@ import {computed, ReadonlySignal} from '@dnd-kit/state'; import {isEqual} from '@dnd-kit/utilities'; -import type {DragOperation, DragDropRegistry} from '../manager'; +import type {DragDropManager} from '../manager'; import type {Draggable, Droppable} from '../nodes'; -import type {Collision, Collisions} from './types'; +import {Plugin} from '../plugins'; +import type {Collision, CollisionDetector, Collisions} from './types'; import {sortCollisions} from './utilities'; -export type Input< - T extends Draggable = Draggable, - U extends Droppable = Droppable, -> = { - dragOperation: DragOperation; - registry: DragDropRegistry; -}; - const DEFAULT_VALUE: Collisions = []; export class CollisionObserver< T extends Draggable = Draggable, U extends Droppable = Droppable, -> { - constructor({registry, dragOperation}: Input) { - this.__computedCollisions = computed(() => { - const {source, shape, initialized} = dragOperation; + V extends DragDropManager = DragDropManager, +> extends Plugin { + constructor(manager: V) { + super(manager); - if (!initialized || !shape) { - return DEFAULT_VALUE; - } + this.computeCollisions = this.computeCollisions.bind(this); + this.__computedCollisions = computed(this.computeCollisions, isEqual); + } - const type = source?.type; - const collisions: Collision[] = []; + public computeCollisions( + entries?: Droppable[], + collisionDetector?: CollisionDetector + ) { + const {registry, dragOperation} = this.manager; + const {source, shape, initialized} = dragOperation; - for (const entry of registry.droppable) { - if (entry.disabled) { - continue; - } + if (!initialized || !shape) { + return DEFAULT_VALUE; + } - if (type != null && !entry.accepts(type)) { - continue; - } + const type = source?.type; + const collisions: Collision[] = []; + + for (const entry of entries ?? registry.droppable) { + if (entry.disabled) { + continue; + } + + if (type != null && !entry.accepts(type)) { + continue; + } - const {collisionDetector} = entry; - const collision = collisionDetector({ - droppable: entry, - dragOperation, - }); + const detectCollision = collisionDetector ?? entry.collisionDetector; + const collision = detectCollision({ + droppable: entry, + dragOperation, + }); - if (collision) { - collisions.push(collision); - } + if (collision) { + collisions.push(collision); } + } - collisions.sort(sortCollisions); + collisions.sort(sortCollisions); - return collisions; - }, isEqual); + return collisions; } public get collisions() { diff --git a/packages/abstract/src/index.ts b/packages/abstract/src/index.ts index 97365e13..02699de7 100644 --- a/packages/abstract/src/index.ts +++ b/packages/abstract/src/index.ts @@ -6,7 +6,7 @@ export type { DragDropEvents, } from './manager'; -export {CollisionPriority} from './collision'; +export {CollisionPriority, sortCollisions} from './collision'; export type {Collision, CollisionDetector} from './collision'; export {Modifier} from './modifiers'; diff --git a/packages/abstract/src/manager/dragOperation.ts b/packages/abstract/src/manager/dragOperation.ts index 0ebb46bd..705591ed 100644 --- a/packages/abstract/src/manager/dragOperation.ts +++ b/packages/abstract/src/manager/dragOperation.ts @@ -1,7 +1,7 @@ import {Position, type Shape} from '@dnd-kit/geometry'; import type {Coordinates} from '@dnd-kit/geometry'; import type {UniqueIdentifier} from '@dnd-kit/types'; -import {batch, computed, signal} from '@dnd-kit/state'; +import {batch, effect, computed, signal} from '@dnd-kit/state'; import type {Draggable, Droppable} from '../nodes'; @@ -22,6 +22,7 @@ export interface DragOperation< T extends Draggable = Draggable, U extends Droppable = Droppable, > { + activatorEvent: Event | null; status: Status; position: Position; transform: Coordinates; @@ -51,6 +52,7 @@ export function DragOperationManager< const status = signal(Status.Idle); const shape = signal(null); const position = new Position({x: 0, y: 0}); + const activatorEvent = signal(null); const sourceIdentifier = signal(null); const targetIdentifier = signal(null); const source = computed(() => { @@ -66,7 +68,8 @@ export function DragOperationManager< const transform = computed(() => { const {x, y} = position.delta; let transform = {x, y}; - const operation = { + const operation: Omit, 'transform'> = { + activatorEvent: activatorEvent.peek(), source: source.peek() ?? null, target: target.peek() ?? null, initialized: status.peek() !== Status.Idle, @@ -83,6 +86,9 @@ export function DragOperationManager< }); const operation: DragOperation = { + get activatorEvent() { + return activatorEvent.value; + }, get source() { return source.value ?? null; }, @@ -143,8 +149,9 @@ export function DragOperationManager< operation: snapshot(operation), }); }, - start({coordinates}: {coordinates: Coordinates}) { + start({event, coordinates}: {event: Event; coordinates: Coordinates}) { status.value = Status.Initializing; + activatorEvent.value = event; batch(() => { status.value = Status.Dragging; @@ -153,14 +160,46 @@ export function DragOperationManager< monitor.dispatch('dragstart', {}); }, - move({coordinates}: {coordinates: Coordinates}) { + move({ + by, + to, + cancelable = true, + }: + | {by: Coordinates; to?: undefined; cancelable?: boolean} + | {by?: undefined; to: Coordinates; cancelable?: boolean}) { if (!dragging.peek()) { return; } - position.update(coordinates); + let defaultPrevented = false; + + monitor.dispatch('dragmove', { + operation: snapshot(operation), + by, + to, + cancelable, + get defaultPrevented() { + return defaultPrevented; + }, + preventDefault() { + if (!cancelable) { + return; + } + + defaultPrevented = true; + }, + }); - monitor.dispatch('dragmove', {}); + if (defaultPrevented) { + return; + } + + const coordinates = to ?? { + x: position.current.x + by.x, + y: position.current.y + by.y, + }; + + position.update(coordinates); }, stop({canceled = false}: {canceled?: boolean} = {}) { const end = () => { diff --git a/packages/abstract/src/manager/manager.ts b/packages/abstract/src/manager/manager.ts index 9b2aa5e8..482b11d6 100644 --- a/packages/abstract/src/manager/manager.ts +++ b/packages/abstract/src/manager/manager.ts @@ -64,14 +64,10 @@ export class DragDropManager< this.modifiers = new PluginRegistry>(this); const {actions, operation} = DragOperationManager(this); - const collisionObserver = new CollisionObserver({ - dragOperation: operation, - registry, - }); this.actions = actions; - this.collisionObserver = collisionObserver; this.dragOperation = operation; + this.collisionObserver = new CollisionObserver(this); for (const modifier of modifiers) { this.modifiers.register(modifier); diff --git a/packages/abstract/src/manager/monitor.ts b/packages/abstract/src/manager/monitor.ts index c623b22e..8e41c47d 100644 --- a/packages/abstract/src/manager/monitor.ts +++ b/packages/abstract/src/manager/monitor.ts @@ -1,4 +1,4 @@ -import type {AnyFunction} from '@dnd-kit/types'; +import type {Coordinates} from '@dnd-kit/geometry'; import type {DragDropManager} from './manager'; import type {DragOperation} from './dragOperation'; @@ -42,23 +42,31 @@ class Monitor { } } -type DragDropEvent< - T extends Draggable, - U extends Droppable, - V extends DragDropManager, -> = (event: Record, manager: V) => void; - export type DragDropEvents< T extends Draggable, U extends Droppable, V extends DragDropManager, > = { collision( - event: {collisions: Collisions; preventDefault(): void}, + event: { + collisions: Collisions; + defaultPrevented: boolean; + preventDefault(): void; + }, manager: V ): void; dragstart(event: {}, manager: V): void; - dragmove(event: {}, manager: V): void; + dragmove( + event: { + operation: DragOperation; + to?: Coordinates; + by?: Coordinates; + cancelable: boolean; + defaultPrevented: boolean; + preventDefault(): void; + }, + manager: V + ): void; dragover(event: {operation: DragOperation}, manager: V): void; dragend( event: { diff --git a/packages/abstract/src/manager/registry.ts b/packages/abstract/src/manager/registry.ts index 72564e69..655be7e7 100644 --- a/packages/abstract/src/manager/registry.ts +++ b/packages/abstract/src/manager/registry.ts @@ -13,27 +13,17 @@ class Registry { } public get(identifier: UniqueIdentifier): T | undefined { - return this.map.peek().get(identifier); + return this.map.value.get(identifier); } - public pick(...identifiers: UniqueIdentifier[]): T[] | undefined { - const map = this.map.peek(); - - return identifiers.map((identifier) => { - const entry = map.get(identifier); - - if (!entry) { - throw new Error( - `No registered entry found for identifier: ${identifier}` - ); - } + public register = (key: UniqueIdentifier, value: T) => { + const current = this.map.peek(); - return entry; - }); - } + if (current.get(key) === value) { + return; + } - public register = (key: UniqueIdentifier, value: T) => { - const updatedMap = new Map(this.map.peek()); + const updatedMap = new Map(current); updatedMap.set(key, value); this.map.value = updatedMap; @@ -44,11 +34,13 @@ class Registry { }; public unregister = (key: UniqueIdentifier, value: T) => { - if (this.get(key) !== value) { + const current = this.map.peek(); + + if (current.get(key) !== value) { return; } - const updatedMap = new Map(this.map.peek()); + const updatedMap = new Map(current); updatedMap.delete(key); this.map.value = updatedMap; diff --git a/packages/abstract/src/plugins/plugin.ts b/packages/abstract/src/plugins/plugin.ts index 2d540b05..778ee929 100644 --- a/packages/abstract/src/plugins/plugin.ts +++ b/packages/abstract/src/plugins/plugin.ts @@ -1,3 +1,4 @@ +import {reactive, untracked} from '@dnd-kit/state'; import type {DragDropManager} from '../manager'; import type {PluginOptions} from './types'; @@ -17,11 +18,14 @@ export class Plugin< /** * Whether the plugin instance is disabled. + * Triggers effects when accessed. */ + @reactive public disabled: boolean = false; /** * Enable a disabled plugin instance. + * Triggers effects. */ public enable() { this.disabled = false; @@ -29,11 +33,22 @@ export class Plugin< /** * Disable an enabled plugin instance. + * Triggers effects. */ public disable() { this.disabled = true; } + /** + * Whether the plugin instance is disabled. + * Does not trigger effects when accessed. + */ + public isDisabled() { + return untracked(() => { + return this.disabled; + }); + } + /** * Configure a plugin instance with new options. */ diff --git a/packages/abstract/src/plugins/registry.ts b/packages/abstract/src/plugins/registry.ts index adf999af..1f8fc7ff 100644 --- a/packages/abstract/src/plugins/registry.ts +++ b/packages/abstract/src/plugins/registry.ts @@ -26,6 +26,12 @@ export class PluginRegistry< plugin: X, options?: InferPluginOptions ): InstanceType { + const existingInstance = this.instances.get(plugin); + + if (existingInstance) { + return existingInstance as InstanceType; + } + const instance = new plugin(this.manager, options) as U; this.instances.set(plugin, instance); diff --git a/packages/collision/src/algorithms/closestCenter.ts b/packages/collision/src/algorithms/closestCenter.ts index d478ba61..2f9ae9c9 100644 --- a/packages/collision/src/algorithms/closestCenter.ts +++ b/packages/collision/src/algorithms/closestCenter.ts @@ -5,12 +5,9 @@ import {Point} from '@dnd-kit/geometry'; import {defaultCollisionDetection} from './default'; /** - * Returns the closest droppable shape to the pointer coordinates. - * In the absence of pointer coordinates, return the closest shape to the - * collision shape. + * Returns the distance between the droppable shape and the drag operation coordinates. */ export const closestCenter: CollisionDetector = (input) => { - // TODO: Should dragOperation expose pointer coordinates? const {dragOperation, droppable} = input; const {shape, position} = dragOperation; diff --git a/packages/collision/src/index.ts b/packages/collision/src/index.ts index 8573f7f2..687118d0 100644 --- a/packages/collision/src/index.ts +++ b/packages/collision/src/index.ts @@ -1,2 +1,7 @@ export type {CollisionDetector} from '@dnd-kit/abstract'; -export * from './algorithms'; +export { + closestCenter, + defaultCollisionDetection, + pointerIntersection, + shapeIntersection, +} from './algorithms'; diff --git a/packages/dom-utilities/src/index.ts b/packages/dom-utilities/src/index.ts index ca468d63..f269c34c 100644 --- a/packages/dom-utilities/src/index.ts +++ b/packages/dom-utilities/src/index.ts @@ -15,12 +15,13 @@ export { getScrollableAncestors, isDocumentScrollingElement, ScrollDirection, + scrollIntoViewIfNeeded, } from './scroll'; export {scheduler, Scheduler} from './scheduler'; export {InlineStyles} from './styles'; -export {supportsViewTransition} from './type-guards'; +export {supportsViewTransition, isKeyboardEvent} from './type-guards'; export {inverseTransform} from './transform'; diff --git a/packages/dom-utilities/src/scheduler/scheduler.ts b/packages/dom-utilities/src/scheduler/scheduler.ts index bbe66b58..a0ec79cf 100644 --- a/packages/dom-utilities/src/scheduler/scheduler.ts +++ b/packages/dom-utilities/src/scheduler/scheduler.ts @@ -2,8 +2,12 @@ export class Scheduler { private animationFrame: number | undefined; private tasks: (() => void)[] = []; - public schedule(task: () => void) { - this.tasks.push(task); + public schedule(task: () => void, unshift = false) { + if (unshift) { + this.tasks.unshift(task); + } else { + this.tasks.push(task); + } if (!this.animationFrame) { this.animationFrame = requestAnimationFrame(this.flush); diff --git a/packages/dom-utilities/src/scroll/index.ts b/packages/dom-utilities/src/scroll/index.ts index 919fe76b..54825ec9 100644 --- a/packages/dom-utilities/src/scroll/index.ts +++ b/packages/dom-utilities/src/scroll/index.ts @@ -15,3 +15,4 @@ export {getScrollPosition} from './getScrollPosition'; export {isDocumentScrollingElement} from './documentScrollingElement'; export {isScrollable} from './isScrollable'; export {isFixed} from './isFixed'; +export {scrollIntoViewIfNeeded} from './scrollIntoViewIfNeeded'; diff --git a/packages/dom-utilities/src/scroll/scrollIntoViewIfNeeded.ts b/packages/dom-utilities/src/scroll/scrollIntoViewIfNeeded.ts new file mode 100644 index 00000000..fbbb7894 --- /dev/null +++ b/packages/dom-utilities/src/scroll/scrollIntoViewIfNeeded.ts @@ -0,0 +1,71 @@ +import {getScrollableAncestors} from './getScrollableAncestors'; + +function supportsScrollIntoViewIfNeeded( + element: Element +): element is Element & { + scrollIntoViewIfNeeded: (centerIfNeeded?: boolean) => void; +} { + return ( + 'scrollIntoViewIfNeeded' in element && + typeof element.scrollIntoViewIfNeeded === 'function' + ); +} + +export function scrollIntoViewIfNeeded(el: Element, centerIfNeeded = false) { + if (supportsScrollIntoViewIfNeeded(el)) { + el.scrollIntoViewIfNeeded(centerIfNeeded); + return; + } + + if (!(el instanceof HTMLElement)) { + return el.scrollIntoView(); + } + + var [parent] = getScrollableAncestors(el, {limit: 1}); + + if (!(parent instanceof HTMLElement)) { + return; + } + + const parentComputedStyle = window.getComputedStyle(parent, null), + parentBorderTopWidth = parseInt( + parentComputedStyle.getPropertyValue('border-top-width') + ), + parentBorderLeftWidth = parseInt( + parentComputedStyle.getPropertyValue('border-left-width') + ), + overTop = el.offsetTop - parent.offsetTop < parent.scrollTop, + overBottom = + el.offsetTop - parent.offsetTop + el.clientHeight - parentBorderTopWidth > + parent.scrollTop + parent.clientHeight, + overLeft = el.offsetLeft - parent.offsetLeft < parent.scrollLeft, + overRight = + el.offsetLeft - + parent.offsetLeft + + el.clientWidth - + parentBorderLeftWidth > + parent.scrollLeft + parent.clientWidth, + alignWithTop = overTop && !overBottom; + + if ((overTop || overBottom) && centerIfNeeded) { + parent.scrollTop = + el.offsetTop - + parent.offsetTop - + parent.clientHeight / 2 - + parentBorderTopWidth + + el.clientHeight / 2; + } + + if ((overLeft || overRight) && centerIfNeeded) { + parent.scrollLeft = + el.offsetLeft - + parent.offsetLeft - + parent.clientWidth / 2 - + parentBorderLeftWidth + + el.clientWidth / 2; + } + + if ((overTop || overBottom || overLeft || overRight) && !centerIfNeeded) { + el.scrollIntoView(alignWithTop); + } +} diff --git a/packages/dom-utilities/src/type-guards/index.ts b/packages/dom-utilities/src/type-guards/index.ts index bff4ee4f..eb0672c0 100644 --- a/packages/dom-utilities/src/type-guards/index.ts +++ b/packages/dom-utilities/src/type-guards/index.ts @@ -1,5 +1,6 @@ export {isDocument} from './isDocument'; export {isHTMLElement} from './isHTMLElement'; +export {isKeyboardEvent} from './isKeyboardEvent'; export {isNode} from './isNode'; export {isSVGElement} from './isSVGElement'; export {isWindow} from './isWindow'; diff --git a/packages/dom-utilities/src/type-guards/isKeyboardEvent.ts b/packages/dom-utilities/src/type-guards/isKeyboardEvent.ts new file mode 100644 index 00000000..75927505 --- /dev/null +++ b/packages/dom-utilities/src/type-guards/isKeyboardEvent.ts @@ -0,0 +1,5 @@ +export function isKeyboardEvent( + event: Event | null | undefined +): event is KeyboardEvent { + return event instanceof KeyboardEvent; +} diff --git a/packages/dom/src/modifiers/DragSourceDeltaModifier.ts b/packages/dom/src/modifiers/DragSourceDeltaModifier.ts index a22c846c..c299bd12 100644 --- a/packages/dom/src/modifiers/DragSourceDeltaModifier.ts +++ b/packages/dom/src/modifiers/DragSourceDeltaModifier.ts @@ -5,6 +5,7 @@ import {derived, effect, reactive} from '@dnd-kit/state'; import {DragDropManager} from '../manager'; import {DOMRectangle} from '../shapes'; +// TODO: Need to account for scroll delta export class DragSourceDeltaModifier extends Modifier { constructor(manager: DragDropManager) { super(manager); @@ -56,6 +57,8 @@ export class DragSourceDeltaModifier extends Modifier { return transform; } + // console.log(boundingRectangleDelta); + return { x: transform.x - boundingRectangleDelta.left, y: transform.y - boundingRectangleDelta.top, diff --git a/packages/dom/src/plugins/feedback/CloneFeedback.ts b/packages/dom/src/plugins/feedback/CloneFeedback.ts index c27c515d..8b70eae1 100644 --- a/packages/dom/src/plugins/feedback/CloneFeedback.ts +++ b/packages/dom/src/plugins/feedback/CloneFeedback.ts @@ -1,6 +1,6 @@ import {Plugin} from '@dnd-kit/abstract'; import type {CleanupFunction} from '@dnd-kit/types'; -import {effect} from '@dnd-kit/state'; +import {effect, untracked} from '@dnd-kit/state'; import {cloneElement} from '@dnd-kit/dom-utilities'; import type {DragDropManager} from '../../manager'; @@ -21,16 +21,16 @@ export class CloneFeedback extends Plugin { const {status, source} = dragOperation; const isDragging = status === 'dragging'; - if ( - !isDragging || - !source || - !source.element || - source.feedback !== CloneFeedback - ) { + if (!isDragging || !source || source.feedback !== CloneFeedback) { + return; + } + + const element = untracked(() => source.element); + + if (!element) { return; } - const {element} = source; const {boundingRectangle} = new DOMRectangle(element); const overlay = createOverlay(manager, boundingRectangle); diff --git a/packages/dom/src/plugins/feedback/Overlay.ts b/packages/dom/src/plugins/feedback/Overlay.ts index f82f31a4..9c573ee0 100644 --- a/packages/dom/src/plugins/feedback/Overlay.ts +++ b/packages/dom/src/plugins/feedback/Overlay.ts @@ -1,6 +1,7 @@ import {effect} from '@dnd-kit/state'; import { InlineStyles, + isKeyboardEvent, supportsViewTransition, scheduler, } from '@dnd-kit/dom-utilities'; @@ -33,6 +34,9 @@ class Overlay { const style = document.createElement('style'); element.style.setProperty('all', 'initial'); + element.style.setProperty('display', 'flex'); + element.style.setProperty('align-items', 'stretch'); + element.style.setProperty('justify-content', 'stretch'); element.style.setProperty('pointer-events', 'none'); element.style.setProperty('position', 'fixed'); element.style.setProperty('top', `${top}px`); @@ -40,6 +44,11 @@ class Overlay { element.style.setProperty('width', `${width}px`); element.style.setProperty('height', `${height}px`); element.style.setProperty('touch-action', 'none'); + element.style.setProperty('z-index', '9999'); + + if (isKeyboardEvent(manager.dragOperation.activatorEvent)) { + element.style.setProperty('transition', 'transform 150ms ease'); + } element.setAttribute('data-dnd-kit-overlay', ''); style.innerText = `dialog[data-dnd-kit-overlay]::backdrop {display: none;}`; @@ -47,6 +56,29 @@ class Overlay { this.element = element; + effect(() => { + const {source} = manager.dragOperation; + + if (!source || !source.element) { + return; + } + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const {target} = entry; + const {width, height} = target.getBoundingClientRect(); + + element.style.setProperty('width', `${width}px`); + element.style.setProperty('height', `${height}px`); + } + }); + resizeObserver.observe(source.element); + + return () => { + resizeObserver.disconnect(); + }; + }); + const effectCleanup = effect(() => { const {dragOperation} = manager; const {initialized, transform} = dragOperation; @@ -57,12 +89,11 @@ class Overlay { scheduler.schedule(() => { const {x, y} = this.transform; + dragOperation.shape = new DOMRectangle(element, true).translate(x, y); element.style.setProperty( 'transform', `translate3d(${x}px, ${y}px, 0)` ); - - dragOperation.shape = new DOMRectangle(element); }); } }); diff --git a/packages/dom/src/plugins/feedback/PlaceholderFeedback.ts b/packages/dom/src/plugins/feedback/PlaceholderFeedback.ts index 8f2517aa..497875a1 100644 --- a/packages/dom/src/plugins/feedback/PlaceholderFeedback.ts +++ b/packages/dom/src/plugins/feedback/PlaceholderFeedback.ts @@ -113,8 +113,6 @@ export class PlaceholderFeedback extends Plugin { if (Array.from(mutation.addedNodes).includes(element)) { ignoreNextMutation = true; - console.log(mutation); - element.replaceWith(placeholder); overlay.appendChild(element); diff --git a/packages/dom/src/plugins/scrolling/Scroller.ts b/packages/dom/src/plugins/scrolling/Scroller.ts index 831c1b18..83ad31e0 100644 --- a/packages/dom/src/plugins/scrolling/Scroller.ts +++ b/packages/dom/src/plugins/scrolling/Scroller.ts @@ -6,6 +6,7 @@ import { getScrollableAncestors, ScrollDirection, scheduler, + isKeyboardEvent, } from '@dnd-kit/dom-utilities'; import {Axes, type Coordinates} from '@dnd-kit/geometry'; import {isEqual} from '@dnd-kit/utilities'; @@ -56,6 +57,22 @@ export class Scroller extends Plugin { }; this.scrollIntentTracker = new ScrollIntentTracker(manager); + + this.destroy = manager.monitor.addEventListener('dragmove', (event) => { + if ( + this.disabled || + event.defaultPrevented || + !isKeyboardEvent(manager.dragOperation.activatorEvent) || + !event.by + ) { + return; + } + + // Prevent the move event if we can scroll to the new coordinates + if (this.scroll({by: event.by})) { + event.preventDefault(); + } + }); } public scroll = (options?: {by: Coordinates}): boolean => { diff --git a/packages/dom/src/plugins/sortable/SortableKeyboardPlugin.ts b/packages/dom/src/plugins/sortable/SortableKeyboardPlugin.ts new file mode 100644 index 00000000..39137610 --- /dev/null +++ b/packages/dom/src/plugins/sortable/SortableKeyboardPlugin.ts @@ -0,0 +1,178 @@ +import {batch, effect} from '@dnd-kit/state'; +import {Plugin} from '@dnd-kit/abstract'; +import {closestCenter} from '@dnd-kit/collision'; +import { + scheduler, + isKeyboardEvent, + scrollIntoViewIfNeeded, +} from '@dnd-kit/dom-utilities'; +import type {Coordinates} from '@dnd-kit/geometry'; + +import type {Droppable} from '../../nodes'; +import {DragDropManager} from '../../manager'; +import {DOMRectangle} from '../../shapes'; + +import {isSortable} from './registry'; +import {Scroller} from '../scrolling'; + +export class SortableKeyboardPlugin extends Plugin { + constructor(manager: DragDropManager) { + super(manager); + + const effectCleanup = effect(() => { + const {dragOperation} = manager; + + if (!isKeyboardEvent(dragOperation.activatorEvent)) { + return; + } + + if (!isSortable(dragOperation.source)) { + return; + } + + if (dragOperation.initialized) { + const scroller = manager.plugins.get(Scroller); + + if (scroller) { + scroller.disable(); + + return () => scroller.enable(); + } + } + }); + + const unsubscribe = manager.monitor.addEventListener( + 'dragmove', + (event, manager) => { + if (this.disabled) { + return; + } + + const {dragOperation} = manager; + + if (!isKeyboardEvent(dragOperation.activatorEvent)) { + return; + } + + if (!isSortable(dragOperation.source)) { + return; + } + + if (!dragOperation.shape) { + return; + } + + const {actions, collisionObserver, registry} = manager; + const {by} = event; + + if (!by) { + return; + } + + const direction = getDirection(by); + const {boundingRectangle} = dragOperation.shape; + const potentialTargets: Droppable[] = []; + + for (const droppable of registry.droppable) { + const {shape, id} = droppable; + + if ( + !shape || + (id === dragOperation.source?.id && isSortable(droppable)) + ) { + continue; + } + + switch (direction) { + case 'down': + if (boundingRectangle.top < shape.boundingRectangle.top) { + potentialTargets.push(droppable); + } + break; + case 'up': + if (boundingRectangle.top > shape.boundingRectangle.top) { + potentialTargets.push(droppable); + } + break; + case 'left': + if (boundingRectangle.left > shape.boundingRectangle.left) { + potentialTargets.push(droppable); + } + break; + case 'right': + if (boundingRectangle.left < shape.boundingRectangle.left) { + potentialTargets.push(droppable); + } + break; + } + } + + event.preventDefault(); + collisionObserver.disable(); + + const collisions = collisionObserver.computeCollisions( + potentialTargets, + closestCenter + ); + const [firstCollision] = collisions; + + if (!firstCollision) { + return; + } + + const {id} = firstCollision; + + actions.setDropTarget(id); + + scheduler.schedule(() => { + const {shape, source} = dragOperation; + + if (!shape || !source?.element) { + return; + } + + scrollIntoViewIfNeeded(source.element); + + scheduler.schedule(() => { + if (!source.element) { + return; + } + + const {center} = new DOMRectangle(source.element, true); + + batch(() => { + actions.setDropTarget(source.id); + actions.move({ + to: { + x: center.x, + y: center.y, + }, + }); + }); + + collisionObserver.enable(); + }); + }, true); + } + ); + + this.destroy = () => { + unsubscribe(); + effectCleanup(); + }; + } +} + +function getDirection(delta: Coordinates) { + const {x, y} = delta; + + if (x > 0) { + return 'right'; + } else if (x < 0) { + return 'left'; + } else if (y > 0) { + return 'down'; + } else if (y < 0) { + return 'up'; + } +} diff --git a/packages/dom/src/plugins/sortable/registry.ts b/packages/dom/src/plugins/sortable/registry.ts new file mode 100644 index 00000000..ce80558c --- /dev/null +++ b/packages/dom/src/plugins/sortable/registry.ts @@ -0,0 +1,7 @@ +import type {Droppable, Draggable} from '../../nodes'; + +export const SortableRegistry = new WeakSet(); + +export function isSortable(element: Draggable | Droppable | null): boolean { + return element ? SortableRegistry.has(element) : false; +} diff --git a/packages/dom/src/plugins/sortable/sortable.ts b/packages/dom/src/plugins/sortable/sortable.ts index d5262d29..4d2e75d0 100644 --- a/packages/dom/src/plugins/sortable/sortable.ts +++ b/packages/dom/src/plugins/sortable/sortable.ts @@ -2,8 +2,9 @@ import type { Data, DragDropManager as AbstractDragDropManager, } from '@dnd-kit/abstract'; -import {effect, reactive, untracked} from '@dnd-kit/state'; +import {batch, effect, reactive, untracked} from '@dnd-kit/state'; import type {Type, UniqueIdentifier} from '@dnd-kit/types'; +import {scheduler} from '@dnd-kit/dom-utilities'; import {Draggable, Droppable} from '../../nodes'; import type { @@ -12,7 +13,9 @@ import type { DroppableInput, } from '../../nodes'; import type {Sensors} from '../../sensors'; -import {DOMRectangle} from '../../shapes'; + +import {SortableKeyboardPlugin} from './SortableKeyboardPlugin'; +import {SortableRegistry} from './registry'; export interface SortableInput extends DraggableInput, @@ -28,11 +31,19 @@ export class Sortable { index: number; constructor( - {index, ...input}: SortableInput, + {index, sensors, ...input}: SortableInput, protected manager: AbstractDragDropManager ) { - this.draggable = new Draggable(input, manager); - this.droppable = new Droppable({...input, ignoreTransform: true}, manager); + this.draggable = new Draggable({...input, sensors}, manager); + this.droppable = new Droppable( + {...input, ignoreTransform: true}, + manager + ); + + SortableRegistry.add(this.draggable); + SortableRegistry.add(this.droppable); + + manager.plugins.register(SortableKeyboardPlugin); let previousIndex = index; @@ -60,15 +71,17 @@ export class Sortable { }; if (delta.x || delta.y) { - element.animate( - { - transform: [ - `translate3d(${delta.x}px, ${delta.y}px, 0)`, - 'translate3d(0, 0, 0)', - ], - }, - {duration: 150, easing: 'ease'} - ); + scheduler.schedule(() => { + element.animate( + { + transform: [ + `translate3d(${delta.x}px, ${delta.y}px, 0)`, + 'translate3d(0, 0, 0)', + ], + }, + {duration: 150, easing: 'ease'} + ); + }); } }; @@ -100,8 +113,17 @@ export class Sortable { } public set disabled(value: boolean) { - this.draggable.disabled = value; - this.droppable.disabled = value; + batch(() => { + this.draggable.disabled = value; + this.droppable.disabled = value; + }); + } + + public set data(data: T | null) { + batch(() => { + this.draggable.data = data; + this.droppable.data = data; + }); } public set activator(activator: Element | undefined) { @@ -109,13 +131,17 @@ export class Sortable { } public set element(element: Element | undefined) { - this.draggable.element = element; - this.droppable.element = element; + batch(() => { + this.draggable.element = element; + this.droppable.element = element; + }); } public set id(id: UniqueIdentifier) { - this.draggable.id = id; - this.droppable.id = id; + batch(() => { + this.draggable.id = id; + this.droppable.id = id; + }); } public set sensors(value: Sensors | undefined) { @@ -147,5 +173,3 @@ export class Sortable { this.droppable.destroy(); } } - -function noop() {} diff --git a/packages/dom/src/sensors/drag/DragSensor.ts b/packages/dom/src/sensors/drag/DragSensor.ts index 7301f8d3..6dfa66b1 100644 --- a/packages/dom/src/sensors/drag/DragSensor.ts +++ b/packages/dom/src/sensors/drag/DragSensor.ts @@ -142,6 +142,7 @@ export class DragSensor extends Sensor { if (this.manager.dragOperation.status === 'idle') { this.manager.actions.start({ + event, coordinates: { x: event.clientX, y: event.clientY, @@ -151,7 +152,7 @@ export class DragSensor extends Sensor { } this.manager.actions.move({ - coordinates: { + to: { x: event.clientX, y: event.clientY, }, diff --git a/packages/dom/src/sensors/keyboard/KeyboardSensor.ts b/packages/dom/src/sensors/keyboard/KeyboardSensor.ts index 3b817330..ae84e9f8 100644 --- a/packages/dom/src/sensors/keyboard/KeyboardSensor.ts +++ b/packages/dom/src/sensors/keyboard/KeyboardSensor.ts @@ -7,6 +7,7 @@ import type {DragDropManager} from '../../manager'; import type {Draggable} from '../../nodes'; import {AutoScroller, Scroller} from '../../plugins'; import {DOMRectangle} from '../../shapes'; +import {Coordinates} from '@dnd-kit/geometry'; export type KeyCode = KeyboardEvent['code']; @@ -108,6 +109,7 @@ export class KeyboardSensor extends Sensor< this.manager.actions.setDragSource(source.id); this.manager.actions.start({ + event, coordinates: { x: center.x, y: center.y, @@ -131,44 +133,16 @@ export class KeyboardSensor extends Sensor< return; } - const {shape} = this.manager.dragOperation; - - if (!shape) { - return; - } - - const {center} = shape; - const factor = event.shiftKey ? 5 : 1; - const offset = { - x: 0, - y: 0, - }; - if (isKeycode(event, keyboardCodes.up)) { - offset.y = -DEFAULT_OFFSET * factor; + this.handleMove('up', event); } else if (isKeycode(event, keyboardCodes.down)) { - offset.y = DEFAULT_OFFSET * factor; + this.handleMove('down', event); } if (isKeycode(event, keyboardCodes.left)) { - offset.x = -DEFAULT_OFFSET * factor; + this.handleMove('left', event); } else if (isKeycode(event, keyboardCodes.right)) { - offset.x = DEFAULT_OFFSET * factor; - } - - if (offset.x || offset.y) { - event.preventDefault(); - - const scroller = this.manager.plugins.get(Scroller); - - if (!scroller?.scroll({by: offset})) { - this.manager.actions.move({ - coordinates: { - x: center.x + offset.x, - y: center.y + offset.y, - }, - }); - } + this.handleMove('right', event); } }; @@ -177,6 +151,45 @@ export class KeyboardSensor extends Sensor< ]); }; + protected handleMove( + direction: 'up' | 'down' | 'left' | 'right', + event: KeyboardEvent + ) { + const {shape} = this.manager.dragOperation; + const factor = event.shiftKey ? 5 : 1; + let offset = { + x: 0, + y: 0, + }; + + if (!shape) { + return; + } + + switch (direction) { + case 'up': + offset = {x: 0, y: -DEFAULT_OFFSET * factor}; + break; + case 'down': + offset = {x: 0, y: DEFAULT_OFFSET * factor}; + break; + case 'left': + offset = {x: -DEFAULT_OFFSET * factor, y: 0}; + break; + case 'right': + offset = {x: DEFAULT_OFFSET * factor, y: 0}; + break; + } + + if (offset?.x || offset?.y) { + event.preventDefault(); + + this.manager.actions.move({ + by: offset, + }); + } + } + private sideEffects(): CleanupFunction { const effectCleanupFns: CleanupFunction[] = []; diff --git a/packages/dom/src/sensors/pointer/PointerSensor.ts b/packages/dom/src/sensors/pointer/PointerSensor.ts index 109b474f..124244c3 100644 --- a/packages/dom/src/sensors/pointer/PointerSensor.ts +++ b/packages/dom/src/sensors/pointer/PointerSensor.ts @@ -115,13 +115,16 @@ export class PointerSensor extends Sensor< const {activationConstraints} = options; if (!activationConstraints?.delay && !activationConstraints?.distance) { - this.handleStart(source); + this.handleStart(source, event); event.stopImmediatePropagation(); } else { const {delay} = activationConstraints; if (delay) { - const timeout = setTimeout(() => this.handleStart(source), delay.value); + const timeout = setTimeout( + () => this.handleStart(source, event), + delay.value + ); this.clearTimeout = () => { clearTimeout(timeout); @@ -183,7 +186,7 @@ export class PointerSensor extends Sensor< event.preventDefault(); event.stopPropagation(); - this.manager.actions.move({coordinates}); + this.manager.actions.move({to: coordinates}); return; } @@ -206,7 +209,7 @@ export class PointerSensor extends Sensor< return this.handleCancel(); } if (exceedsDistance(delta, distance.value)) { - return this.handleStart(source); + return this.handleStart(source, event); } } @@ -236,7 +239,7 @@ export class PointerSensor extends Sensor< } } - protected handleStart(source: Draggable) { + protected handleStart(source: Draggable, event: PointerEvent) { this.clearTimeout?.(); if ( @@ -247,7 +250,7 @@ export class PointerSensor extends Sensor< } this.manager.actions.setDragSource(source.id); - this.manager.actions.start({coordinates: this.initialCoordinates}); + this.manager.actions.start({coordinates: this.initialCoordinates, event}); } protected handleCancel() { diff --git a/packages/react/src/draggable/useDraggable.ts b/packages/react/src/draggable/useDraggable.ts index 38bfcee2..29c25485 100644 --- a/packages/react/src/draggable/useDraggable.ts +++ b/packages/react/src/draggable/useDraggable.ts @@ -48,6 +48,12 @@ export function useDraggable( return { isDragSource, + activatorRef: useCallback( + (element: Element | null) => { + draggable.activator = element ?? undefined; + }, + [draggable] + ), ref: useCallback( (element: Element | null) => { draggable.element = element ?? undefined; diff --git a/packages/react/src/sortable/useSortable.ts b/packages/react/src/sortable/useSortable.ts index eb505854..dc0167a3 100644 --- a/packages/react/src/sortable/useSortable.ts +++ b/packages/react/src/sortable/useSortable.ts @@ -19,7 +19,7 @@ export interface UseSortableInput } export function useSortable(input: UseSortableInput) { - const {id, index, disabled, feedback = CloneFeedback, sensors} = input; + const {id, data, index, disabled, feedback = CloneFeedback, sensors} = input; const activator = getCurrentValue(input.activator); const element = getCurrentValue(input.element); const manager = useDragDropManager(); @@ -32,6 +32,7 @@ export function useSortable(input: UseSortableInput) { const isDragSource = useComputed(() => sortable.isDragSource).value; useOnValueChange(id, () => (sortable.id = id)); + useOnValueChange(data, () => (sortable.data = data ?? null)); useOnValueChange(index, () => (sortable.index = index)); useOnValueChange(activator, () => (sortable.activator = activator)); useOnValueChange(element, () => (sortable.element = element)); @@ -56,6 +57,12 @@ export function useSortable(input: UseSortableInput) { get isDropTarget() { return isDropTarget.value; }, + activatorRef: useCallback( + (element: Element | null) => { + sortable.activator = element ?? undefined; + }, + [sortable] + ), ref: useCallback( (element: Element | null) => { sortable.element = element ?? undefined;