@dxos/react-ui-list 0.6.12-main.ac23639
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +5 -0
- package/dist/lib/browser/index.mjs +303 -0
- package/dist/lib/browser/index.mjs.map +7 -0
- package/dist/lib/browser/meta.json +1 -0
- package/dist/lib/node/index.cjs +328 -0
- package/dist/lib/node/index.cjs.map +7 -0
- package/dist/lib/node/meta.json +1 -0
- package/dist/lib/node-esm/index.mjs +305 -0
- package/dist/lib/node-esm/index.mjs.map +7 -0
- package/dist/lib/node-esm/meta.json +1 -0
- package/dist/types/src/components/List/DropIndicator.d.ts +10 -0
- package/dist/types/src/components/List/DropIndicator.d.ts.map +1 -0
- package/dist/types/src/components/List/List.d.ts +26 -0
- package/dist/types/src/components/List/List.d.ts.map +1 -0
- package/dist/types/src/components/List/List.stories.d.ts +13 -0
- package/dist/types/src/components/List/List.stories.d.ts.map +1 -0
- package/dist/types/src/components/List/ListItem.d.ts +52 -0
- package/dist/types/src/components/List/ListItem.d.ts.map +1 -0
- package/dist/types/src/components/List/ListRoot.d.ts +30 -0
- package/dist/types/src/components/List/ListRoot.d.ts.map +1 -0
- package/dist/types/src/components/List/index.d.ts +2 -0
- package/dist/types/src/components/List/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +2 -0
- package/dist/types/src/components/index.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +2 -0
- package/dist/types/src/index.d.ts.map +1 -0
- package/dist/types/src/testing.d.ts +15 -0
- package/dist/types/src/testing.d.ts.map +1 -0
- package/package.json +63 -0
- package/src/components/List/DropIndicator.tsx +64 -0
- package/src/components/List/List.stories.tsx +108 -0
- package/src/components/List/List.tsx +44 -0
- package/src/components/List/ListItem.tsx +234 -0
- package/src/components/List/ListRoot.tsx +85 -0
- package/src/components/List/index.ts +5 -0
- package/src/components/index.ts +5 -0
- package/src/index.ts +5 -0
- package/src/testing.ts +29 -0
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dxos/react-ui-list",
|
|
3
|
+
"version": "0.6.12-main.ac23639",
|
|
4
|
+
"description": "A list component.",
|
|
5
|
+
"homepage": "https://dxos.org",
|
|
6
|
+
"bugs": "https://github.com/dxos/dxos/issues",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "DXOS.org",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"browser": "./dist/lib/browser/index.mjs",
|
|
12
|
+
"node": {
|
|
13
|
+
"require": "./dist/lib/node/index.cjs",
|
|
14
|
+
"default": "./dist/lib/node-esm/index.mjs"
|
|
15
|
+
},
|
|
16
|
+
"types": "./dist/types/src/index.d.ts"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"types": "dist/types/src/index.d.ts",
|
|
20
|
+
"typesVersions": {
|
|
21
|
+
"*": {}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@atlaskit/pragmatic-drag-and-drop": "^1.4.0",
|
|
29
|
+
"@atlaskit/pragmatic-drag-and-drop-flourish": "^1.1.2",
|
|
30
|
+
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3",
|
|
31
|
+
"@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "^1.1.3",
|
|
32
|
+
"@radix-ui/react-context": "^1.0.0",
|
|
33
|
+
"effect": "^3.9.1",
|
|
34
|
+
"@dxos/debug": "0.6.12-main.ac23639",
|
|
35
|
+
"@dxos/echo-schema": "0.6.12-main.ac23639",
|
|
36
|
+
"@dxos/invariant": "0.6.12-main.ac23639",
|
|
37
|
+
"@dxos/log": "0.6.12-main.ac23639",
|
|
38
|
+
"@dxos/react-ui": "0.6.12-main.ac23639",
|
|
39
|
+
"@dxos/react-ui-theme": "0.6.12-main.ac23639",
|
|
40
|
+
"@dxos/react-ui-types": "0.6.12-main.ac23639",
|
|
41
|
+
"@dxos/util": "0.6.12-main.ac23639"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@phosphor-icons/react": "^2.1.5",
|
|
45
|
+
"@types/react": "~18.2.0",
|
|
46
|
+
"@types/react-dom": "~18.2.0",
|
|
47
|
+
"react": "~18.2.0",
|
|
48
|
+
"react-dom": "~18.2.0",
|
|
49
|
+
"vite": "5.4.7",
|
|
50
|
+
"@dxos/random": "0.6.12-main.ac23639",
|
|
51
|
+
"@dxos/react-ui-theme": "0.6.12-main.ac23639",
|
|
52
|
+
"@dxos/storybook-utils": "0.6.12-main.ac23639"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"@phosphor-icons/react": "^2.1.5",
|
|
56
|
+
"effect": "^3.9.1",
|
|
57
|
+
"react": "~18.2.0",
|
|
58
|
+
"react-dom": "~18.2.0"
|
|
59
|
+
},
|
|
60
|
+
"publishConfig": {
|
|
61
|
+
"access": "public"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/types';
|
|
6
|
+
import React, { type CSSProperties, type HTMLAttributes } from 'react';
|
|
7
|
+
|
|
8
|
+
import { mx } from '@dxos/react-ui-theme';
|
|
9
|
+
|
|
10
|
+
type Orientation = 'horizontal' | 'vertical';
|
|
11
|
+
|
|
12
|
+
const edgeToOrientationMap: Record<Edge, Orientation> = {
|
|
13
|
+
top: 'horizontal',
|
|
14
|
+
bottom: 'horizontal',
|
|
15
|
+
left: 'vertical',
|
|
16
|
+
right: 'vertical',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const orientationStyles: Record<Orientation, HTMLAttributes<HTMLElement>['className']> = {
|
|
20
|
+
horizontal: 'h-[--line-thickness] left-[--terminal-radius] right-0 before:left-[--negative-terminal-size]',
|
|
21
|
+
vertical: 'w-[--line-thickness] top-[--terminal-radius] bottom-0 before:top-[--negative-terminal-size]',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const edgeStyles: Record<Edge, HTMLAttributes<HTMLElement>['className']> = {
|
|
25
|
+
top: 'top-[--line-offset] before:top-[--offset-terminal]',
|
|
26
|
+
right: 'right-[--line-offset] before:right-[--offset-terminal]',
|
|
27
|
+
bottom: 'bottom-[--line-offset] before:bottom-[--offset-terminal]',
|
|
28
|
+
left: 'left-[--line-offset] before:left-[--offset-terminal]',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const strokeSize = 2;
|
|
32
|
+
const terminalSize = 8;
|
|
33
|
+
const offsetToAlignTerminalWithLine = (strokeSize - terminalSize) / 2;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* This is a tailwind port of `@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box`
|
|
37
|
+
*/
|
|
38
|
+
export const DropIndicator = ({ edge, gap = '0px' }: { edge: Edge; gap?: string }) => {
|
|
39
|
+
const lineOffset = `calc(-0.5 * (${gap} + ${strokeSize}px))`;
|
|
40
|
+
|
|
41
|
+
const orientation = edgeToOrientationMap[edge];
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
style={
|
|
46
|
+
{
|
|
47
|
+
'--line-thickness': `${strokeSize}px`,
|
|
48
|
+
'--line-offset': `${lineOffset}`,
|
|
49
|
+
'--terminal-size': `${terminalSize}px`,
|
|
50
|
+
'--terminal-radius': `${terminalSize / 2}px`,
|
|
51
|
+
'--negative-terminal-size': `-${terminalSize}px`,
|
|
52
|
+
'--offset-terminal': `${offsetToAlignTerminalWithLine}px`,
|
|
53
|
+
} as CSSProperties
|
|
54
|
+
}
|
|
55
|
+
className={mx(
|
|
56
|
+
'absolute z-10 pointer-events-none bg-blue-700',
|
|
57
|
+
"before:content-[''] before:w-[--terminal-size] before:h-[--terminal-size] box-border before:absolute",
|
|
58
|
+
'before:border-[length:--line-thickness] before:border-solid before:border-blue-700 before:rounded-full',
|
|
59
|
+
orientationStyles[orientation],
|
|
60
|
+
edgeStyles[edge],
|
|
61
|
+
)}
|
|
62
|
+
></div>
|
|
63
|
+
);
|
|
64
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import '@dxos-theme';
|
|
6
|
+
|
|
7
|
+
import React from 'react';
|
|
8
|
+
|
|
9
|
+
import { create, S } from '@dxos/echo-schema';
|
|
10
|
+
import { ghostHover, mx } from '@dxos/react-ui-theme';
|
|
11
|
+
import { withTheme, withLayout, withSignals } from '@dxos/storybook-utils';
|
|
12
|
+
|
|
13
|
+
import { List, type ListRootProps } from './List';
|
|
14
|
+
import { createList, TestItemSchema, type TestItemType } from '../../testing';
|
|
15
|
+
|
|
16
|
+
// TODO(burdon): var-icon-size.
|
|
17
|
+
const grid = 'grid grid-cols-[32px_1fr_32px] min-bs-[2rem] rounded';
|
|
18
|
+
|
|
19
|
+
const Story = ({ items = [], ...props }: ListRootProps<TestItemType>) => {
|
|
20
|
+
const handleSelect = (item: TestItemType) => {
|
|
21
|
+
console.log('select', item);
|
|
22
|
+
};
|
|
23
|
+
const handleDelete = (item: TestItemType) => {
|
|
24
|
+
const idx = items.findIndex((i) => i.id === item.id);
|
|
25
|
+
items.splice(idx, 1);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<List.Root<TestItemType> dragPreview items={items} {...props}>
|
|
30
|
+
{({ items }) => (
|
|
31
|
+
<>
|
|
32
|
+
<div className='flex flex-col w-full'>
|
|
33
|
+
<div role='none' className={grid}>
|
|
34
|
+
<div />
|
|
35
|
+
<div className='flex items-center text-sm'>Items</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div role='list' className='w-full h-full overflow-auto'>
|
|
39
|
+
{items.map((item) => (
|
|
40
|
+
<List.Item<TestItemType> key={item.id} item={item} classNames={mx(grid, ghostHover)}>
|
|
41
|
+
<List.ItemDragHandle />
|
|
42
|
+
<List.ItemTitle onClick={() => handleSelect(item)}>{item.name}</List.ItemTitle>
|
|
43
|
+
<List.ItemDeleteButton onClick={() => handleDelete(item)} />
|
|
44
|
+
</List.Item>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div role='none' className={grid}>
|
|
49
|
+
<div />
|
|
50
|
+
<div className='flex items-center text-sm'>{items.length} Items</div>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<List.ItemDragPreview<TestItemType>>
|
|
55
|
+
{({ item }) => (
|
|
56
|
+
<List.ItemWrapper classNames={mx(grid, 'bg-modalSurface border border-separator')}>
|
|
57
|
+
<List.ItemDragHandle />
|
|
58
|
+
<div className='flex items-center'>{item.name}</div>
|
|
59
|
+
</List.ItemWrapper>
|
|
60
|
+
)}
|
|
61
|
+
</List.ItemDragPreview>
|
|
62
|
+
</>
|
|
63
|
+
)}
|
|
64
|
+
</List.Root>
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const SimpleStory = ({ items = [], ...props }: ListRootProps<TestItemType>) => {
|
|
69
|
+
return (
|
|
70
|
+
<List.Root<TestItemType> dragPreview items={items} {...props}>
|
|
71
|
+
{({ items }) => (
|
|
72
|
+
<div role='list' className='w-full h-full overflow-auto'>
|
|
73
|
+
{items.map((item) => (
|
|
74
|
+
<List.Item<TestItemType> key={item.id} item={item} classNames={mx(grid, ghostHover)}>
|
|
75
|
+
<List.ItemDragHandle />
|
|
76
|
+
<List.ItemTitle>{item.name}</List.ItemTitle>
|
|
77
|
+
<List.ItemDeleteButton />
|
|
78
|
+
</List.Item>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
</List.Root>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export default {
|
|
87
|
+
// TODO(burdon): Storybook collides with react-ui/List.
|
|
88
|
+
title: 'react-ui-list/List',
|
|
89
|
+
decorators: [withTheme, withSignals, withLayout({ fullscreen: true })],
|
|
90
|
+
render: Story,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const list = create(createList(100));
|
|
94
|
+
|
|
95
|
+
export const Default = {
|
|
96
|
+
args: {
|
|
97
|
+
items: list.items,
|
|
98
|
+
isItem: S.is(TestItemSchema),
|
|
99
|
+
} satisfies ListRootProps<TestItemType>,
|
|
100
|
+
} as any; // TODO(burdon): TS2742: The inferred type of Default cannot be named without a reference to... (AST)
|
|
101
|
+
|
|
102
|
+
export const Simple = {
|
|
103
|
+
render: SimpleStory,
|
|
104
|
+
args: {
|
|
105
|
+
items: list.items,
|
|
106
|
+
isItem: S.is(TestItemSchema),
|
|
107
|
+
} satisfies ListRootProps<TestItemType>,
|
|
108
|
+
} as any; // TODO(burdon): TS2742: The inferred type of Default cannot be named without a reference to... (AST)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
IconButton,
|
|
7
|
+
type IconButtonProps,
|
|
8
|
+
ListItem,
|
|
9
|
+
ListItemDeleteButton,
|
|
10
|
+
ListItemDragHandle,
|
|
11
|
+
ListItemDragPreview,
|
|
12
|
+
type ListItemProps,
|
|
13
|
+
type ListItemRecord,
|
|
14
|
+
ListItemTitle,
|
|
15
|
+
ListItemWrapper,
|
|
16
|
+
} from './ListItem';
|
|
17
|
+
import { ListRoot, type ListRootProps } from './ListRoot';
|
|
18
|
+
|
|
19
|
+
// TODO(burdon): Multi-select model.
|
|
20
|
+
// TODO(burdon): Key nav.
|
|
21
|
+
// TODO(burdon): Animation.
|
|
22
|
+
// TODO(burdon): Constrain axis.
|
|
23
|
+
// TODO(burdon): Tree view.
|
|
24
|
+
// TODO(burdon): Fix autoscroll while dragging.
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Draggable list.
|
|
28
|
+
* Ref: https://github.com/atlassian/pragmatic-drag-and-drop
|
|
29
|
+
* Ref: https://github.com/alexreardon/pdnd-react-tailwind/blob/main/src/task.tsx
|
|
30
|
+
*/
|
|
31
|
+
export const List = {
|
|
32
|
+
Root: ListRoot,
|
|
33
|
+
Item: ListItem,
|
|
34
|
+
ItemDragPreview: ListItemDragPreview,
|
|
35
|
+
ItemWrapper: ListItemWrapper,
|
|
36
|
+
ItemDragHandle: ListItemDragHandle,
|
|
37
|
+
ItemDeleteButton: ListItemDeleteButton,
|
|
38
|
+
ItemTitle: ListItemTitle,
|
|
39
|
+
IconButton,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type ListItem = ListItemRecord;
|
|
43
|
+
|
|
44
|
+
export type { ListRootProps, ListItemProps, IconButtonProps, ListItem };
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
|
|
6
|
+
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
|
7
|
+
import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview';
|
|
8
|
+
import { type Edge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
|
9
|
+
import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
|
10
|
+
import { createContext } from '@radix-ui/react-context';
|
|
11
|
+
import React, {
|
|
12
|
+
type ComponentProps,
|
|
13
|
+
type HTMLAttributes,
|
|
14
|
+
type MutableRefObject,
|
|
15
|
+
type PropsWithChildren,
|
|
16
|
+
type ReactNode,
|
|
17
|
+
forwardRef,
|
|
18
|
+
useEffect,
|
|
19
|
+
useRef,
|
|
20
|
+
useState,
|
|
21
|
+
} from 'react';
|
|
22
|
+
import { createPortal } from 'react-dom';
|
|
23
|
+
|
|
24
|
+
import { invariant } from '@dxos/invariant';
|
|
25
|
+
import { type ThemedClassName } from '@dxos/react-ui';
|
|
26
|
+
import { Icon } from '@dxos/react-ui';
|
|
27
|
+
import { mx } from '@dxos/react-ui-theme';
|
|
28
|
+
|
|
29
|
+
import { DropIndicator } from './DropIndicator';
|
|
30
|
+
import { useListContext } from './ListRoot';
|
|
31
|
+
|
|
32
|
+
export type ListItemRecord = { id: string };
|
|
33
|
+
|
|
34
|
+
export type ItemState =
|
|
35
|
+
| {
|
|
36
|
+
type: 'idle';
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
type: 'preview';
|
|
40
|
+
container: HTMLElement;
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
type: 'is-dragging';
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
type: 'is-dragging-over';
|
|
47
|
+
closestEdge: Edge | null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const idle: ItemState = { type: 'idle' };
|
|
51
|
+
|
|
52
|
+
const stateStyles: { [Key in ItemState['type']]?: HTMLAttributes<HTMLDivElement>['className'] } = {
|
|
53
|
+
'is-dragging': 'opacity-50',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type ListItemContext<T extends ListItemRecord> = {
|
|
57
|
+
item: T;
|
|
58
|
+
dragHandleRef: MutableRefObject<HTMLElement | null>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Default context defined for ListItemDragPreview, which is defined outside of ListItem.
|
|
63
|
+
*/
|
|
64
|
+
const defaultContext: ListItemContext<any> = {} as any;
|
|
65
|
+
|
|
66
|
+
const LIST_ITEM_NAME = 'ListItem';
|
|
67
|
+
|
|
68
|
+
export const [ListItemProvider, useListItemContext] = createContext<ListItemContext<any>>(
|
|
69
|
+
LIST_ITEM_NAME,
|
|
70
|
+
defaultContext,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
export type ListItemProps<T extends ListItemRecord> = ThemedClassName<
|
|
74
|
+
PropsWithChildren<{
|
|
75
|
+
item: T;
|
|
76
|
+
}>
|
|
77
|
+
>;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Draggable list item.
|
|
81
|
+
*/
|
|
82
|
+
export const ListItem = <T extends ListItemRecord>({ children, classNames, item }: ListItemProps<T>) => {
|
|
83
|
+
const { isItem, dragPreview, setState: setRootState } = useListContext(LIST_ITEM_NAME);
|
|
84
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
85
|
+
const dragHandleRef = useRef<HTMLElement | null>(null);
|
|
86
|
+
const [state, setState] = useState<ItemState>(idle);
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const element = ref.current;
|
|
89
|
+
invariant(element);
|
|
90
|
+
return combine(
|
|
91
|
+
//
|
|
92
|
+
// https://atlassian.design/components/pragmatic-drag-and-drop/core-package/adapters/element/about#draggable
|
|
93
|
+
//
|
|
94
|
+
draggable({
|
|
95
|
+
element,
|
|
96
|
+
dragHandle: dragHandleRef.current!,
|
|
97
|
+
getInitialData: () => item,
|
|
98
|
+
onGenerateDragPreview: dragPreview
|
|
99
|
+
? ({ nativeSetDragImage, source }) => {
|
|
100
|
+
const rect = source.element.getBoundingClientRect();
|
|
101
|
+
setCustomNativeDragPreview({
|
|
102
|
+
nativeSetDragImage,
|
|
103
|
+
getOffset: ({ container }) => {
|
|
104
|
+
const { height } = container.getBoundingClientRect();
|
|
105
|
+
return {
|
|
106
|
+
x: 20,
|
|
107
|
+
y: height / 2,
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
render: ({ container }) => {
|
|
111
|
+
container.style.width = rect.width + 'px';
|
|
112
|
+
setState({ type: 'preview', container });
|
|
113
|
+
setRootState({ type: 'preview', container, item });
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
: undefined,
|
|
118
|
+
onDragStart: () => {
|
|
119
|
+
setState({ type: 'is-dragging' });
|
|
120
|
+
setRootState({ type: 'is-dragging', item });
|
|
121
|
+
},
|
|
122
|
+
onDrop: () => {
|
|
123
|
+
setState(idle);
|
|
124
|
+
setRootState(idle);
|
|
125
|
+
},
|
|
126
|
+
}),
|
|
127
|
+
|
|
128
|
+
//
|
|
129
|
+
// https://atlassian.design/components/pragmatic-drag-and-drop/core-package/adapters/element/about#drop-target-for-elements
|
|
130
|
+
//
|
|
131
|
+
dropTargetForElements({
|
|
132
|
+
element,
|
|
133
|
+
canDrop: ({ source }) => {
|
|
134
|
+
return source.element !== element && isItem(source.data);
|
|
135
|
+
},
|
|
136
|
+
getData: ({ input }) => {
|
|
137
|
+
return attachClosestEdge(item, { element, input, allowedEdges: ['top', 'bottom'] });
|
|
138
|
+
},
|
|
139
|
+
getIsSticky: () => true,
|
|
140
|
+
onDragEnter: ({ self }) => {
|
|
141
|
+
const closestEdge = extractClosestEdge(self.data);
|
|
142
|
+
setState({ type: 'is-dragging-over', closestEdge });
|
|
143
|
+
},
|
|
144
|
+
onDrag: ({ self }) => {
|
|
145
|
+
const closestEdge = extractClosestEdge(self.data);
|
|
146
|
+
setState((current) => {
|
|
147
|
+
if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) {
|
|
148
|
+
return current;
|
|
149
|
+
}
|
|
150
|
+
return { type: 'is-dragging-over', closestEdge };
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
onDragLeave: () => {
|
|
154
|
+
setState(idle);
|
|
155
|
+
},
|
|
156
|
+
onDrop: () => {
|
|
157
|
+
setState(idle);
|
|
158
|
+
},
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
}, [item]);
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<ListItemProvider item={item} dragHandleRef={dragHandleRef}>
|
|
165
|
+
<div className='relative'>
|
|
166
|
+
<div ref={ref} role='listitem' className={mx('flex', classNames, stateStyles[state.type])}>
|
|
167
|
+
{children}
|
|
168
|
+
</div>
|
|
169
|
+
{state.type === 'is-dragging-over' && state.closestEdge && <DropIndicator edge={state.closestEdge} />}
|
|
170
|
+
</div>
|
|
171
|
+
</ListItemProvider>
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
//
|
|
176
|
+
// List item components
|
|
177
|
+
//
|
|
178
|
+
|
|
179
|
+
export type IconButtonProps = ThemedClassName<ComponentProps<'button'>> & { icon: string };
|
|
180
|
+
|
|
181
|
+
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|
182
|
+
({ classNames, icon, ...props }, forwardedRef) => {
|
|
183
|
+
return (
|
|
184
|
+
<button ref={forwardedRef} className={mx('flex items-center justify-center', classNames)} {...props}>
|
|
185
|
+
<Icon icon={icon} classNames='cursor-pointer' size={4} />
|
|
186
|
+
</button>
|
|
187
|
+
);
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
export const ListItemDeleteButton = ({
|
|
192
|
+
autoHide = true,
|
|
193
|
+
classNames,
|
|
194
|
+
...props
|
|
195
|
+
}: Omit<IconButtonProps, 'icon'> & { autoHide?: boolean }) => {
|
|
196
|
+
const { state } = useListContext('DELETE_BUTTON');
|
|
197
|
+
const disabled = state.type !== 'idle';
|
|
198
|
+
return (
|
|
199
|
+
<IconButton
|
|
200
|
+
icon='ph--x--regular'
|
|
201
|
+
disabled={disabled}
|
|
202
|
+
classNames={[classNames, autoHide && disabled && 'hidden']}
|
|
203
|
+
{...props}
|
|
204
|
+
/>
|
|
205
|
+
);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export const ListItemDragHandle = () => {
|
|
209
|
+
const { dragHandleRef } = useListItemContext('DRAG_HANDLE');
|
|
210
|
+
return <IconButton ref={dragHandleRef as any} icon='ph--dots-six--regular' />;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export const ListItemDragPreview = <T extends ListItemRecord>({
|
|
214
|
+
children,
|
|
215
|
+
}: {
|
|
216
|
+
children: ({ item }: { item: T }) => ReactNode;
|
|
217
|
+
}) => {
|
|
218
|
+
const { state } = useListContext('DRAG_PREVIEW');
|
|
219
|
+
return state?.type === 'preview' ? createPortal(children({ item: state.item }), state.container) : null;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export const ListItemWrapper = ({ classNames, children }: ThemedClassName<PropsWithChildren>) => (
|
|
223
|
+
<div className={mx('flex w-full', classNames)}>{children}</div>
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
export const ListItemTitle = ({
|
|
227
|
+
classNames,
|
|
228
|
+
children,
|
|
229
|
+
...props
|
|
230
|
+
}: ThemedClassName<PropsWithChildren<ComponentProps<'div'>>>) => (
|
|
231
|
+
<div className={mx('flex w-full items-center', classNames)} {...props}>
|
|
232
|
+
{children}
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
|
6
|
+
import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge';
|
|
7
|
+
import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge';
|
|
8
|
+
import { createContext } from '@radix-ui/react-context';
|
|
9
|
+
import React, { type ReactNode, useEffect, useState } from 'react';
|
|
10
|
+
import { flushSync } from 'react-dom';
|
|
11
|
+
|
|
12
|
+
import { type ThemedClassName, useControlledValue } from '@dxos/react-ui';
|
|
13
|
+
|
|
14
|
+
import { type ListItemRecord, idle, type ItemState } from './ListItem';
|
|
15
|
+
|
|
16
|
+
type ListContext<T extends ListItemRecord> = {
|
|
17
|
+
isItem: (item: any) => boolean;
|
|
18
|
+
dragPreview?: boolean;
|
|
19
|
+
state: ItemState & { item?: T };
|
|
20
|
+
setState: (state: ItemState & { item?: T }) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const LIST_NAME = 'List';
|
|
24
|
+
|
|
25
|
+
export const [ListProvider, useListContext] = createContext<ListContext<any>>(LIST_NAME);
|
|
26
|
+
|
|
27
|
+
export type ListRendererProps<T extends ListItemRecord> = {
|
|
28
|
+
state: ListContext<T>['state'];
|
|
29
|
+
items: T[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ListRootProps<T extends ListItemRecord> = ThemedClassName<{
|
|
33
|
+
children?: (props: ListRendererProps<T>) => ReactNode;
|
|
34
|
+
items?: T[];
|
|
35
|
+
}> &
|
|
36
|
+
Pick<ListContext<T>, 'isItem' | 'dragPreview'>;
|
|
37
|
+
|
|
38
|
+
export const ListRoot = <T extends ListItemRecord>({
|
|
39
|
+
classNames,
|
|
40
|
+
children,
|
|
41
|
+
items: _items = [],
|
|
42
|
+
isItem,
|
|
43
|
+
...props
|
|
44
|
+
}: ListRootProps<T>) => {
|
|
45
|
+
const [items, setItems] = useControlledValue<T[]>(_items);
|
|
46
|
+
const [state, setState] = useState<ListContext<T>['state']>(idle);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
return monitorForElements({
|
|
49
|
+
canMonitor: ({ source }) => isItem(source.data),
|
|
50
|
+
onDrop: ({ location, source }) => {
|
|
51
|
+
const target = location.current.dropTargets[0];
|
|
52
|
+
if (!target) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const sourceData = source.data;
|
|
57
|
+
const targetData = target.data;
|
|
58
|
+
if (!isItem(sourceData) || !isItem(targetData)) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const sourceIdx = items.findIndex((item) => item.id === sourceData.id);
|
|
63
|
+
const targetIdx = items.findIndex((item) => item.id === targetData.id);
|
|
64
|
+
if (targetIdx < 0 || sourceIdx < 0) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const closestEdgeOfTarget = extractClosestEdge(targetData);
|
|
69
|
+
flushSync(() => {
|
|
70
|
+
setItems(
|
|
71
|
+
reorderWithEdge({
|
|
72
|
+
list: items,
|
|
73
|
+
startIndex: sourceIdx,
|
|
74
|
+
indexOfTarget: targetIdx,
|
|
75
|
+
axis: 'vertical',
|
|
76
|
+
closestEdgeOfTarget,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}, [items]);
|
|
83
|
+
|
|
84
|
+
return <ListProvider {...{ isItem, state, setState, ...props }}>{children?.({ state, items })}</ListProvider>;
|
|
85
|
+
};
|
package/src/index.ts
ADDED
package/src/testing.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { S } from '@dxos/echo-schema';
|
|
6
|
+
import { faker } from '@dxos/random';
|
|
7
|
+
|
|
8
|
+
export const TestItemSchema = S.Struct({
|
|
9
|
+
id: S.String,
|
|
10
|
+
name: S.String,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export type TestItemType = S.Schema.Type<typeof TestItemSchema>;
|
|
14
|
+
|
|
15
|
+
export const TestList = S.Struct({
|
|
16
|
+
items: S.mutable(S.Array(TestItemSchema)),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type TestList = S.Schema.Type<typeof TestList>;
|
|
20
|
+
|
|
21
|
+
export const createList = (n = 10): TestList => ({
|
|
22
|
+
items: faker.helpers.multiple(
|
|
23
|
+
() => ({
|
|
24
|
+
id: faker.string.uuid(),
|
|
25
|
+
name: faker.commerce.productName(),
|
|
26
|
+
}),
|
|
27
|
+
{ count: n },
|
|
28
|
+
),
|
|
29
|
+
});
|