Skip to content

Commit 5536395

Browse files
committed
fix(web): draggable-list-update-issue
1 parent 957f17c commit 5536395

File tree

5 files changed

+191
-60
lines changed

5 files changed

+191
-60
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
"@storybook/test": "^8.6.4",
5353
"@tailwindcss/postcss": "^4.0.11",
5454
"@tailwindcss/vite": "^4.1.4",
55+
"@types/lodash": "^4",
5556
"@types/node": "^22.13.10",
5657
"@types/react": "^18.0.9",
5758
"@types/react-dom": "^18.0.3",
@@ -93,6 +94,7 @@
9394
"@internationalized/date": "^3.7.0",
9495
"bignumber.js": "^9.1.2",
9596
"clsx": "^2.1.1",
97+
"lodash": "^4.17.21",
9698
"react": "^18.0.0",
9799
"react-aria-components": "^1.7.1",
98100
"react-dom": "^18.0.0",

src/lib/draggable-list/index.tsx

Lines changed: 62 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import React, { useEffect } from "react";
2-
import { useListData } from "react-stately";
1+
import React from "react";
32
import {
43
Button,
54
ListBox,
@@ -13,6 +12,7 @@ import { cn } from "../../utils";
1312
import DragAndDropIcon from "../../assets/svgs/drag-and-drop.svg";
1413
import Trash from "../../assets/svgs/trash.svg";
1514
import clsx from "clsx";
15+
import { useList } from "./useList";
1616

1717
type ListItem = {
1818
id: string | number;
@@ -53,24 +53,26 @@ function DraggableList({
5353
deletionDisabled = false,
5454
...props
5555
}: Readonly<IDraggableList>) {
56-
const list = useListData({
56+
const {
57+
items: list,
58+
moveAfter,
59+
moveBefore,
60+
remove,
61+
getItem,
62+
} = useList({
5763
initialItems: items,
64+
onChange: updateCallback,
5865
});
5966

60-
useEffect(() => {
61-
if (!updateCallback) return;
62-
updateCallback(list.items);
63-
}, [list, updateCallback, items]);
64-
6567
const { dragAndDropHooks } = useDragAndDrop({
6668
getItems: (keys) =>
67-
[...keys].map((key) => ({ "text/plain": list.getItem(key)!.name })),
69+
[...keys].map((key) => ({ "text/plain": getItem(key)!.name })),
6870
getAllowedDropOperations: () => ["move"],
6971
onReorder(e) {
7072
if (e.target.dropPosition === "before") {
71-
list.moveBefore(e.target.key, e.keys);
73+
moveBefore(e.target.key, e.keys);
7274
} else if (e.target.dropPosition === "after") {
73-
list.moveAfter(e.target.key, e.keys);
75+
moveAfter(e.target.key, e.keys);
7476
}
7577
},
7678
renderDragPreview,
@@ -81,11 +83,11 @@ function DraggableList({
8183
{...props}
8284
aria-label={props["aria-label"] ?? "Reorderable list"}
8385
selectionMode="single"
84-
items={list.items}
86+
items={list}
8587
dragAndDropHooks={dragDisabled ? undefined : dragAndDropHooks}
8688
onSelectionChange={(keys) => {
8789
const keyArr = Array.from(keys);
88-
const selectedItem = list.getItem(keyArr[0]);
90+
const selectedItem = getItem(keyArr[0]);
8991

9092
if (selectionCallback && selectedItem) selectionCallback(selectedItem);
9193
}}
@@ -96,50 +98,53 @@ function DraggableList({
9698
className,
9799
)}
98100
>
99-
{(item) => (
100-
<ListBoxItem
101-
textValue={item.name}
102-
className={({ isHovered, isDragging, isSelected }) =>
103-
cn(
104-
"h-11.25 w-full cursor-pointer border-l-3 border-l-transparent",
105-
"flex items-center gap-4 px-4",
106-
"focus-visible:outline-klerosUIComponentsPrimaryBlue focus-visible:outline",
107-
(isHovered || isSelected) && "bg-klerosUIComponentsMediumBlue",
108-
isSelected && "border-l-klerosUIComponentsPrimaryBlue",
109-
isDragging && "cursor-grabbing opacity-60",
110-
)
111-
}
112-
>
113-
{({ isHovered }) => (
114-
<>
115-
{dragDisabled ? null : (
116-
<DragAndDropIcon className="size-4 cursor-grab" />
117-
)}
118-
<span className="text-klerosUIComponentsPrimaryText flex-1 text-base">
119-
{item.name}
120-
</span>
121-
{isHovered && !deletionDisabled ? (
122-
<Button
123-
className={"cursor-pointer hover:scale-105"}
124-
onPress={() => {
125-
list.remove(item.id);
126-
}}
127-
>
128-
{({ isHovered: isButtonHovered }) => (
129-
<Trash
130-
className={clsx(
131-
"ease-ease size-4 transition",
132-
isButtonHovered &&
133-
"[&_path]:fill-klerosUIComponentsPrimaryBlue",
134-
)}
135-
/>
136-
)}
137-
</Button>
138-
) : null}
139-
</>
140-
)}
141-
</ListBoxItem>
142-
)}
101+
{items.map((item) => {
102+
return (
103+
<ListBoxItem
104+
key={item.id}
105+
textValue={item.name}
106+
className={({ isHovered, isDragging, isSelected }) =>
107+
cn(
108+
"h-11.25 w-full cursor-pointer border-l-3 border-l-transparent",
109+
"flex items-center gap-4 px-4",
110+
"focus-visible:outline-klerosUIComponentsPrimaryBlue focus-visible:outline",
111+
(isHovered || isSelected) && "bg-klerosUIComponentsMediumBlue",
112+
isSelected && "border-l-klerosUIComponentsPrimaryBlue",
113+
isDragging && "cursor-grabbing opacity-60",
114+
)
115+
}
116+
>
117+
{({ isHovered, isSelected }) => (
118+
<>
119+
{dragDisabled ? null : (
120+
<DragAndDropIcon className="size-4 cursor-grab" />
121+
)}
122+
<span className="text-klerosUIComponentsPrimaryText flex-1 text-base">
123+
{item.name}
124+
</span>
125+
{isHovered && !deletionDisabled ? (
126+
<Button
127+
className={"cursor-pointer hover:scale-105"}
128+
onPress={() => {
129+
remove(item.id);
130+
}}
131+
>
132+
{({ isHovered: isButtonHovered }) => (
133+
<Trash
134+
className={clsx(
135+
"ease-ease size-4 transition",
136+
(isButtonHovered || isSelected) &&
137+
"[&_path]:fill-klerosUIComponentsPrimaryBlue",
138+
)}
139+
/>
140+
)}
141+
</Button>
142+
) : null}
143+
</>
144+
)}
145+
</ListBoxItem>
146+
);
147+
})}
143148
</ListBox>
144149
);
145150
}

src/lib/draggable-list/useList.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { useCallback, useEffect, useMemo, useState } from "react";
2+
import _ from "lodash";
3+
4+
type Key = string | number;
5+
6+
export interface ListItem {
7+
id: Key;
8+
name: string;
9+
value: any;
10+
}
11+
12+
interface UseListOptions {
13+
initialItems: ListItem[];
14+
onChange?: (updatedItems: ListItem[]) => void;
15+
}
16+
17+
export function useList({ initialItems, onChange }: UseListOptions) {
18+
const [items, setItems] = useState<ListItem[]>(initialItems);
19+
20+
useEffect(() => {
21+
// preventing callback loop, we cannot rely on useEffect dependency since that does not utilize deep comparison
22+
if (_.isEqual(initialItems, items)) return;
23+
24+
setItems(initialItems);
25+
// eslint-disable-next-line react-hooks/exhaustive-deps
26+
}, [initialItems]);
27+
28+
const updateItems = useCallback(
29+
(newItems: ListItem[]) => {
30+
setItems(newItems);
31+
onChange?.(newItems);
32+
},
33+
[onChange],
34+
);
35+
36+
const itemsMap = useMemo(() => {
37+
const map = new Map<Key, ListItem>();
38+
for (const item of items) {
39+
map.set(item.id, item);
40+
}
41+
return map;
42+
}, [items]);
43+
44+
const getItem = useCallback((key: Key) => itemsMap.get(key), [itemsMap]);
45+
46+
const remove = useCallback(
47+
(key: Key) => {
48+
updateItems(items.filter((item) => key !== item.id));
49+
},
50+
[items, updateItems],
51+
);
52+
53+
const moveBefore = useCallback(
54+
(targetKey: Key, keys: Iterable<Key>) => {
55+
const key = Array.from(keys)[0];
56+
if (key === targetKey) return;
57+
58+
const indexFrom = items.findIndex((item) => item.id === key);
59+
const indexTo = items.findIndex((item) => item.id === targetKey);
60+
if (indexFrom === -1 || indexTo === -1) return;
61+
62+
const reordered = [...items];
63+
const [movedItem] = reordered.splice(indexFrom, 1);
64+
reordered.splice(indexTo, 0, movedItem);
65+
updateItems(reordered);
66+
},
67+
[items, updateItems],
68+
);
69+
70+
const moveAfter = useCallback(
71+
(targetKey: Key, keys: Iterable<Key>) => {
72+
const key = Array.from(keys)[0];
73+
if (key === targetKey) return;
74+
75+
const indexFrom = items.findIndex((item) => item.id === key);
76+
const indexTo = items.findIndex((item) => item.id === targetKey);
77+
if (indexFrom === -1 || indexTo === -1) return;
78+
79+
const reordered = [...items];
80+
const [movedItem] = reordered.splice(indexFrom, 1);
81+
82+
// Adjust if removing item before target index
83+
const insertIndex = indexFrom < indexTo ? indexTo : indexTo + 1;
84+
reordered.splice(insertIndex, 0, movedItem);
85+
updateItems(reordered);
86+
},
87+
[items, updateItems],
88+
);
89+
90+
return {
91+
items,
92+
getItem,
93+
remove,
94+
moveBefore,
95+
moveAfter,
96+
};
97+
}

src/stories/draggable-list.stories.tsx

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import React from "react";
1+
import React, { useState } from "react";
22
import type { Meta, StoryObj } from "@storybook/react";
33

44
import { IPreviewArgs } from "./utils";
55

66
import DraggableList from "../lib/draggable-list";
7+
import { Button } from "../lib";
78

89
const meta = {
910
component: DraggableList,
@@ -33,8 +34,25 @@ export const Default: Story = {
3334
{ id: 3, name: "Acrobat", value: "" },
3435
],
3536
},
36-
render: (args) => {
37-
return <DraggableList {...args} />;
37+
render: function Render(args) {
38+
const [items, setItems] = useState([
39+
{ id: 1, name: "Illustrator", value: "" },
40+
{ id: 2, name: "Premiere", value: "" },
41+
{ id: 3, name: "Acrobat", value: "" },
42+
]);
43+
44+
const addItem = () => {
45+
setItems([
46+
...items,
47+
{ id: items.length + 1, name: "New Item", value: "" },
48+
]);
49+
};
50+
return (
51+
<div>
52+
<DraggableList {...args} items={items} />
53+
<Button onPress={addItem} text="Add Item" className="mt-4" />
54+
</div>
55+
);
3856
},
3957
};
4058

yarn.lock

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,6 +1132,7 @@ __metadata:
11321132
"@storybook/test": "npm:^8.6.4"
11331133
"@tailwindcss/postcss": "npm:^4.0.11"
11341134
"@tailwindcss/vite": "npm:^4.1.4"
1135+
"@types/lodash": "npm:^4"
11351136
"@types/node": "npm:^22.13.10"
11361137
"@types/react": "npm:^18.0.9"
11371138
"@types/react-dom": "npm:^18.0.3"
@@ -1153,6 +1154,7 @@ __metadata:
11531154
globals: "npm:^16.0.0"
11541155
husky: "npm:^7.0.0"
11551156
lint-staged: "npm:^12.1.2"
1157+
lodash: "npm:^4.17.21"
11561158
prettier: "npm:^3.5.3"
11571159
prettier-plugin-tailwindcss: "npm:^0.6.11"
11581160
process: "npm:^0.11.10"
@@ -4172,6 +4174,13 @@ __metadata:
41724174
languageName: node
41734175
linkType: hard
41744176

4177+
"@types/lodash@npm:^4":
4178+
version: 4.17.17
4179+
resolution: "@types/lodash@npm:4.17.17"
4180+
checksum: 10c0/8e75df02a15f04d4322c5a503e4efd0e7a92470570ce80f17e9f11ce2b1f1a7c994009c9bcff39f07e0f9ffd8ccaff09b3598997c404b801abd5a7eee5a639dc
4181+
languageName: node
4182+
linkType: hard
4183+
41754184
"@types/mdx@npm:^2.0.0":
41764185
version: 2.0.13
41774186
resolution: "@types/mdx@npm:2.0.13"

0 commit comments

Comments
 (0)