@marimo-team/islands 0.18.5-dev168 → 0.18.5-dev169
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/dist/main.js +91 -39
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/components/editor/actions/useNotebookActions.tsx +1 -1
- package/src/components/editor/chrome/state.ts +30 -15
- package/src/components/editor/chrome/types.ts +67 -77
- package/src/components/editor/chrome/wrapper/app-chrome.tsx +148 -84
- package/src/components/editor/chrome/wrapper/sidebar.tsx +76 -43
- package/src/components/ui/reorderable-list.tsx +190 -31
|
@@ -8,72 +8,105 @@ import { useMemo } from "react";
|
|
|
8
8
|
import { ReorderableList } from "@/components/ui/reorderable-list";
|
|
9
9
|
import { Tooltip } from "@/components/ui/tooltip";
|
|
10
10
|
import { notebookQueuedOrRunningCountAtom } from "@/core/cells/cells";
|
|
11
|
-
import { snippetsEnabledAtom } from "@/core/config/config";
|
|
12
11
|
import { cn } from "@/utils/cn";
|
|
13
12
|
import { FeedbackButton } from "../components/feedback-button";
|
|
14
|
-
import {
|
|
13
|
+
import { panelLayoutAtom, useChromeActions, useChromeState } from "../state";
|
|
15
14
|
import { PANEL_MAP, PANELS, type PanelDescriptor } from "../types";
|
|
16
15
|
|
|
17
16
|
export const Sidebar: React.FC = () => {
|
|
18
|
-
const { selectedPanel } = useChromeState();
|
|
19
|
-
const { toggleApplication } =
|
|
20
|
-
|
|
21
|
-
const
|
|
17
|
+
const { selectedPanel, selectedDeveloperPanelTab } = useChromeState();
|
|
18
|
+
const { toggleApplication, setSelectedDeveloperPanelTab } =
|
|
19
|
+
useChromeActions();
|
|
20
|
+
const [panelLayout, setPanelLayout] = useAtom(panelLayoutAtom);
|
|
22
21
|
|
|
23
22
|
const renderIcon = ({ Icon }: PanelDescriptor, className?: string) => {
|
|
24
23
|
return <Icon className={cn("h-5 w-5", className)} />;
|
|
25
24
|
};
|
|
26
25
|
|
|
27
|
-
// Get
|
|
28
|
-
//
|
|
29
|
-
const availableSidebarPanels = useMemo(
|
|
30
|
-
()
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
26
|
+
// Get panels available for sidebar context menu
|
|
27
|
+
// Only show panels that are NOT in the developer panel
|
|
28
|
+
const availableSidebarPanels = useMemo(() => {
|
|
29
|
+
const devPanelIds = new Set(panelLayout.developerPanel);
|
|
30
|
+
return PANELS.filter((p) => {
|
|
31
|
+
if (p.hidden) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
// Exclude panels that are in the developer panel
|
|
35
|
+
if (devPanelIds.has(p.type)) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
});
|
|
40
|
+
}, [panelLayout.developerPanel]);
|
|
41
|
+
|
|
42
|
+
// Convert current sidebar items to PanelDescriptors
|
|
43
|
+
const sidebarItems = useMemo(() => {
|
|
44
|
+
return panelLayout.sidebar.flatMap((id) => {
|
|
45
|
+
const panel = PANEL_MAP.get(id);
|
|
46
|
+
return panel ? [panel] : [];
|
|
47
|
+
});
|
|
48
|
+
}, [panelLayout.sidebar]);
|
|
49
|
+
|
|
50
|
+
const handleSetSidebarItems = (items: PanelDescriptor[]) => {
|
|
51
|
+
setPanelLayout((prev) => ({
|
|
52
|
+
...prev,
|
|
53
|
+
sidebar: items.map((item) => item.type),
|
|
54
|
+
}));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handleReceive = (item: PanelDescriptor, fromListId: string) => {
|
|
58
|
+
// Remove from the source list
|
|
59
|
+
if (fromListId === "developer-panel") {
|
|
60
|
+
setPanelLayout((prev) => ({
|
|
61
|
+
...prev,
|
|
62
|
+
developerPanel: prev.developerPanel.filter((id) => id !== item.type),
|
|
63
|
+
}));
|
|
43
64
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
65
|
+
// If the moved item was selected in dev panel, select the first remaining item
|
|
66
|
+
if (selectedDeveloperPanelTab === item.type) {
|
|
67
|
+
const remainingDevPanels = panelLayout.developerPanel.filter(
|
|
68
|
+
(id) => id !== item.type,
|
|
69
|
+
);
|
|
70
|
+
if (remainingDevPanels.length > 0) {
|
|
71
|
+
setSelectedDeveloperPanelTab(remainingDevPanels[0]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
47
75
|
|
|
48
|
-
|
|
49
|
-
|
|
76
|
+
// Select the dropped item in sidebar
|
|
77
|
+
toggleApplication(item.type);
|
|
50
78
|
};
|
|
51
79
|
|
|
52
80
|
return (
|
|
53
81
|
<div className="h-full pt-4 pb-1 px-1 flex flex-col items-start text-muted-foreground text-md select-none no-print text-sm z-50 dark:bg-background print:hidden hide-on-fullscreen">
|
|
54
82
|
<ReorderableList<PanelDescriptor>
|
|
55
|
-
value={
|
|
56
|
-
setValue={
|
|
83
|
+
value={sidebarItems}
|
|
84
|
+
setValue={handleSetSidebarItems}
|
|
85
|
+
getKey={(p) => p.type}
|
|
57
86
|
availableItems={availableSidebarPanels}
|
|
87
|
+
crossListDrag={{
|
|
88
|
+
dragType: "panels",
|
|
89
|
+
listId: "sidebar",
|
|
90
|
+
onReceive: handleReceive,
|
|
91
|
+
}}
|
|
58
92
|
getItemLabel={(panel) => (
|
|
59
|
-
<span className="flex items-center gap-2
|
|
93
|
+
<span className="flex items-center gap-2">
|
|
60
94
|
{renderIcon(panel, "h-4 w-4 text-muted-foreground")}
|
|
61
|
-
{panel.
|
|
95
|
+
{panel.label}
|
|
62
96
|
</span>
|
|
63
97
|
)}
|
|
64
|
-
ariaLabel="
|
|
98
|
+
ariaLabel="Sidebar panels"
|
|
65
99
|
className="flex flex-col gap-0"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}}
|
|
100
|
+
minItems={0}
|
|
101
|
+
onAction={(panel) => toggleApplication(panel.type)}
|
|
102
|
+
renderItem={(panel) => (
|
|
103
|
+
<SidebarItem
|
|
104
|
+
tooltip={panel.tooltip}
|
|
105
|
+
selected={selectedPanel === panel.type}
|
|
106
|
+
>
|
|
107
|
+
{renderIcon(panel)}
|
|
108
|
+
</SidebarItem>
|
|
109
|
+
)}
|
|
77
110
|
/>
|
|
78
111
|
<FeedbackButton>
|
|
79
112
|
<SidebarItem tooltip="Send feedback!" selected={false}>
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import type React from "react";
|
|
4
4
|
import { useMemo } from "react";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
type DropItem,
|
|
7
|
+
ListBox,
|
|
8
|
+
ListBoxItem,
|
|
9
|
+
useDragAndDrop,
|
|
10
|
+
} from "react-aria-components";
|
|
6
11
|
import { Logger } from "@/utils/Logger";
|
|
7
12
|
import {
|
|
8
13
|
ContextMenu,
|
|
@@ -12,15 +17,37 @@ import {
|
|
|
12
17
|
} from "./context-menu";
|
|
13
18
|
import "./reorderable-list.css";
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
interface DragData<T> {
|
|
21
|
+
itemId: string;
|
|
22
|
+
sourceListId: string;
|
|
23
|
+
item: T;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getDragMimeType(dragType: string): string {
|
|
27
|
+
return `application/x-reorderable-${dragType}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseDragData<T>(text: string): DragData<T> | null {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(text) as DragData<T>;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ReorderableListProps<T> {
|
|
16
39
|
/**
|
|
17
|
-
* The current list of items
|
|
40
|
+
* The current list of items.
|
|
18
41
|
*/
|
|
19
42
|
value: T[];
|
|
20
43
|
/**
|
|
21
44
|
* Callback when items are reordered
|
|
22
45
|
*/
|
|
23
46
|
setValue: (items: T[]) => void;
|
|
47
|
+
/**
|
|
48
|
+
* Function to get a unique key for each item. Used for drag-drop and rendering.
|
|
49
|
+
*/
|
|
50
|
+
getKey: (item: T) => string;
|
|
24
51
|
/**
|
|
25
52
|
* Render function for each item.
|
|
26
53
|
* Note: Avoid interactive elements (buttons) inside - they break drag behavior.
|
|
@@ -50,49 +77,148 @@ export interface ReorderableListProps<T extends { id: string | number }> {
|
|
|
50
77
|
* Additional class name for the list container
|
|
51
78
|
*/
|
|
52
79
|
className?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Configuration for cross-list drag-drop. When set, items can be dragged
|
|
82
|
+
* between lists that share the same `dragType`.
|
|
83
|
+
*/
|
|
84
|
+
crossListDrag?: {
|
|
85
|
+
/** Identifier that links lists together - same dragType = can share items */
|
|
86
|
+
dragType: string;
|
|
87
|
+
/** Unique identifier for this list */
|
|
88
|
+
listId: string;
|
|
89
|
+
/**
|
|
90
|
+
* Callback when an item is received from another list.
|
|
91
|
+
* At this point, setValue has been called with the new item included,
|
|
92
|
+
* but the parent component may not have re-rendered yet.
|
|
93
|
+
* Use this to remove the item from the source list and handle any side effects.
|
|
94
|
+
*/
|
|
95
|
+
onReceive: (item: T, fromListId: string, insertIndex: number) => void;
|
|
96
|
+
};
|
|
53
97
|
}
|
|
54
98
|
|
|
55
99
|
/**
|
|
56
|
-
* A generic reorderable list component using react-aria-components
|
|
57
|
-
* Items can be reordered via drag and drop.
|
|
100
|
+
* A generic reorderable list component using react-aria-components.
|
|
101
|
+
* Items can be reordered via drag and drop within the list.
|
|
102
|
+
*
|
|
103
|
+
* For cross-list drag-drop, set the same `dragType` on multiple lists
|
|
104
|
+
* and provide an `onReceive` callback to handle items dropped from other lists.
|
|
58
105
|
*
|
|
59
106
|
* @example
|
|
60
107
|
* ```tsx
|
|
61
|
-
*
|
|
62
|
-
* id: string;
|
|
63
|
-
* name: string;
|
|
64
|
-
* }
|
|
65
|
-
*
|
|
66
|
-
* const [items, setItems] = useState<MyItem[]>([...]);
|
|
67
|
-
*
|
|
108
|
+
* // Single list reordering
|
|
68
109
|
* <ReorderableList
|
|
69
110
|
* value={items}
|
|
70
111
|
* setValue={setItems}
|
|
112
|
+
* getKey={(item) => item.id}
|
|
71
113
|
* renderItem={(item) => <div>{item.name}</div>}
|
|
72
|
-
*
|
|
114
|
+
* />
|
|
115
|
+
*
|
|
116
|
+
* // Cross-list drag-drop
|
|
117
|
+
* <ReorderableList
|
|
118
|
+
* value={sidebarItems}
|
|
119
|
+
* setValue={setSidebarItems}
|
|
120
|
+
* getKey={(item) => item.type}
|
|
121
|
+
* renderItem={(item) => <div>{item.name}</div>}
|
|
122
|
+
* crossListDrag={{
|
|
123
|
+
* dragType: "panels",
|
|
124
|
+
* listId: "sidebar",
|
|
125
|
+
* onReceive: (item, fromListId) => {
|
|
126
|
+
* // Remove from source list
|
|
127
|
+
* setOtherItems(prev => prev.filter(i => i.type !== item.type));
|
|
128
|
+
* },
|
|
129
|
+
* }}
|
|
73
130
|
* />
|
|
74
131
|
* ```
|
|
75
132
|
*/
|
|
76
|
-
export const ReorderableList = <T extends
|
|
133
|
+
export const ReorderableList = <T extends object>({
|
|
77
134
|
value,
|
|
78
135
|
setValue,
|
|
136
|
+
getKey,
|
|
79
137
|
renderItem,
|
|
80
138
|
onAction,
|
|
81
139
|
availableItems,
|
|
82
|
-
getItemLabel
|
|
140
|
+
getItemLabel,
|
|
83
141
|
minItems = 1,
|
|
84
142
|
ariaLabel = "Reorderable list",
|
|
85
143
|
className,
|
|
144
|
+
crossListDrag,
|
|
86
145
|
}: ReorderableListProps<T>) => {
|
|
146
|
+
const mimeType = crossListDrag
|
|
147
|
+
? getDragMimeType(crossListDrag.dragType)
|
|
148
|
+
: null;
|
|
149
|
+
const onReceive = crossListDrag?.onReceive;
|
|
150
|
+
|
|
151
|
+
// Shared handler for cross-list drops
|
|
152
|
+
const handleCrossListDrop = async (
|
|
153
|
+
items: DropItem[],
|
|
154
|
+
insertIndex: number,
|
|
155
|
+
) => {
|
|
156
|
+
if (!mimeType || !crossListDrag?.listId || !onReceive) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const dragItem of items) {
|
|
161
|
+
if (dragItem.kind !== "text" || !dragItem.types.has(mimeType)) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const text = await dragItem.getText(mimeType);
|
|
166
|
+
const data = parseDragData<T>(text);
|
|
167
|
+
if (!data) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Only accept drops from different lists
|
|
172
|
+
if (data.sourceListId === crossListDrag.listId) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Skip if item already exists in this list
|
|
177
|
+
if (value.some((item) => getKey(item) === getKey(data.item))) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Add to this list and notify parent
|
|
182
|
+
setValue([
|
|
183
|
+
...value.slice(0, insertIndex),
|
|
184
|
+
data.item,
|
|
185
|
+
...value.slice(insertIndex),
|
|
186
|
+
]);
|
|
187
|
+
onReceive(data.item, data.sourceListId, insertIndex);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
87
191
|
const { dragAndDropHooks } = useDragAndDrop<T>({
|
|
88
|
-
getItems: (keys) =>
|
|
192
|
+
getItems: (keys) =>
|
|
193
|
+
[...keys].map((key) => {
|
|
194
|
+
const item = value.find((i) => getKey(i) === key);
|
|
195
|
+
const baseData: Record<string, string> = {
|
|
196
|
+
"text/plain": String(key),
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Add cross-list drag data if dragType is set
|
|
200
|
+
if (mimeType && crossListDrag?.listId && item) {
|
|
201
|
+
const dragData: DragData<T> = {
|
|
202
|
+
itemId: String(key),
|
|
203
|
+
sourceListId: crossListDrag.listId,
|
|
204
|
+
item,
|
|
205
|
+
};
|
|
206
|
+
baseData[mimeType] = JSON.stringify(dragData);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return baseData;
|
|
210
|
+
}),
|
|
211
|
+
|
|
212
|
+
// Accept drops from lists with the same dragType
|
|
213
|
+
acceptedDragTypes: mimeType ? [mimeType, "text/plain"] : ["text/plain"],
|
|
214
|
+
|
|
89
215
|
onReorder(e) {
|
|
90
216
|
const keySet = new Set(e.keys);
|
|
91
|
-
const draggedItems = value.filter((item) => keySet.has(item
|
|
92
|
-
const remaining = value.filter((item) => !keySet.has(item
|
|
217
|
+
const draggedItems = value.filter((item) => keySet.has(getKey(item)));
|
|
218
|
+
const remaining = value.filter((item) => !keySet.has(getKey(item)));
|
|
93
219
|
|
|
94
220
|
const targetIndex = remaining.findIndex(
|
|
95
|
-
(item) => item
|
|
221
|
+
(item) => getKey(item) === e.target.key,
|
|
96
222
|
);
|
|
97
223
|
const insertIndex =
|
|
98
224
|
e.target.dropPosition === "before" ? targetIndex : targetIndex + 1;
|
|
@@ -103,19 +229,34 @@ export const ReorderableList = <T extends { id: string | number }>({
|
|
|
103
229
|
...remaining.slice(insertIndex),
|
|
104
230
|
]);
|
|
105
231
|
},
|
|
232
|
+
|
|
233
|
+
// Handle drops from other lists (on a specific item)
|
|
234
|
+
async onInsert(e) {
|
|
235
|
+
const targetIndex = value.findIndex(
|
|
236
|
+
(item) => getKey(item) === e.target.key,
|
|
237
|
+
);
|
|
238
|
+
const insertIndex =
|
|
239
|
+
e.target.dropPosition === "before" ? targetIndex : targetIndex + 1;
|
|
240
|
+
await handleCrossListDrop(e.items, insertIndex);
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
// Handle drops on empty list or root
|
|
244
|
+
async onRootDrop(e) {
|
|
245
|
+
await handleCrossListDrop(e.items, value.length);
|
|
246
|
+
},
|
|
106
247
|
});
|
|
107
248
|
|
|
108
249
|
// Track which items are currently in the list
|
|
109
|
-
const
|
|
110
|
-
() => new Set(value.map((item) => item
|
|
111
|
-
[value],
|
|
250
|
+
const currentItemKeys = useMemo(
|
|
251
|
+
() => new Set(value.map((item) => getKey(item))),
|
|
252
|
+
[value, getKey],
|
|
112
253
|
);
|
|
113
254
|
|
|
114
255
|
const handleToggleItem = (item: T, isChecked: boolean) => {
|
|
115
256
|
if (isChecked) {
|
|
116
257
|
setValue([...value, item]);
|
|
117
258
|
} else if (value.length > minItems) {
|
|
118
|
-
setValue(value.filter((v) => v
|
|
259
|
+
setValue(value.filter((v) => getKey(v) !== getKey(item)));
|
|
119
260
|
}
|
|
120
261
|
};
|
|
121
262
|
|
|
@@ -124,12 +265,12 @@ export const ReorderableList = <T extends { id: string | number }>({
|
|
|
124
265
|
return;
|
|
125
266
|
}
|
|
126
267
|
|
|
127
|
-
const item = value.find((i) => i
|
|
268
|
+
const item = value.find((i) => getKey(i) === key);
|
|
128
269
|
|
|
129
270
|
if (!item) {
|
|
130
271
|
Logger.warn("handleAction: item not found for key", {
|
|
131
272
|
key,
|
|
132
|
-
|
|
273
|
+
availableKeys: value.map((v) => getKey(v)),
|
|
133
274
|
});
|
|
134
275
|
return;
|
|
135
276
|
}
|
|
@@ -137,19 +278,36 @@ export const ReorderableList = <T extends { id: string | number }>({
|
|
|
137
278
|
onAction(item);
|
|
138
279
|
};
|
|
139
280
|
|
|
281
|
+
// When list is empty, show a drop zone placeholder
|
|
282
|
+
const isEmpty = value.length === 0;
|
|
283
|
+
|
|
140
284
|
const listBox = (
|
|
141
285
|
<ListBox
|
|
142
286
|
aria-label={ariaLabel}
|
|
143
287
|
selectionMode="none"
|
|
144
|
-
items={value}
|
|
145
288
|
dragAndDropHooks={dragAndDropHooks}
|
|
146
289
|
className={className}
|
|
147
290
|
onAction={handleAction}
|
|
148
291
|
>
|
|
149
|
-
{(item) => (
|
|
150
|
-
<ListBoxItem
|
|
292
|
+
{value.map((item) => (
|
|
293
|
+
<ListBoxItem
|
|
294
|
+
key={getKey(item)}
|
|
295
|
+
id={getKey(item)}
|
|
296
|
+
className="active:cursor-grabbing data-[dragging]:opacity-60 outline-none"
|
|
297
|
+
>
|
|
151
298
|
{renderItem(item)}
|
|
152
299
|
</ListBoxItem>
|
|
300
|
+
))}
|
|
301
|
+
{/*
|
|
302
|
+
* When the list is empty, render an invisible placeholder item.
|
|
303
|
+
* This ensures the ListBox maintains minimum dimensions so users can:
|
|
304
|
+
* 1. Right-click to access the context menu and add items back
|
|
305
|
+
* 2. Drag items from another list into this empty list
|
|
306
|
+
*/}
|
|
307
|
+
{isEmpty && (
|
|
308
|
+
<ListBoxItem id="__empty__" className="min-h-[40px] min-w-[40px]">
|
|
309
|
+
<span />
|
|
310
|
+
</ListBoxItem>
|
|
153
311
|
)}
|
|
154
312
|
</ListBox>
|
|
155
313
|
);
|
|
@@ -164,19 +322,20 @@ export const ReorderableList = <T extends { id: string | number }>({
|
|
|
164
322
|
<ContextMenuTrigger asChild={true}>{listBox}</ContextMenuTrigger>
|
|
165
323
|
<ContextMenuContent>
|
|
166
324
|
{availableItems.map((item) => {
|
|
167
|
-
const
|
|
325
|
+
const key = getKey(item);
|
|
326
|
+
const isChecked = currentItemKeys.has(key);
|
|
168
327
|
const isDisabled = isChecked && value.length <= minItems;
|
|
169
328
|
|
|
170
329
|
return (
|
|
171
330
|
<ContextMenuCheckboxItem
|
|
172
|
-
key={
|
|
331
|
+
key={key}
|
|
173
332
|
checked={isChecked}
|
|
174
333
|
disabled={isDisabled}
|
|
175
334
|
onCheckedChange={(checked) => {
|
|
176
335
|
handleToggleItem(item, checked);
|
|
177
336
|
}}
|
|
178
337
|
>
|
|
179
|
-
{getItemLabel(item)}
|
|
338
|
+
{getItemLabel ? getItemLabel(item) : key}
|
|
180
339
|
</ContextMenuCheckboxItem>
|
|
181
340
|
);
|
|
182
341
|
})}
|