@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.
@@ -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 { sidebarOrderAtom, useChromeActions, useChromeState } from "../state";
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 } = useChromeActions();
20
- const [sidebarOrder, setSidebarOrder] = useAtom(sidebarOrderAtom);
21
- const snippetsEnabled = useAtomValue(snippetsEnabledAtom);
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 all available sidebar panels
28
- // Panels with defaultHidden are only available if explicitly enabled (e.g., snippets)
29
- const availableSidebarPanels = useMemo(
30
- () =>
31
- PANELS.filter((p) => {
32
- if (p.hidden || p.position !== "sidebar") {
33
- return false;
34
- }
35
- // Show defaultHidden panels only if enabled via config
36
- if (p.defaultHidden && p.id === "snippets" && !snippetsEnabled) {
37
- return false;
38
- }
39
- return true;
40
- }),
41
- [snippetsEnabled],
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
- const currentItems = sidebarOrder
45
- .map((id) => PANEL_MAP.get(id))
46
- .filter(Boolean);
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
- const handleSetValue = (panels: PanelDescriptor[]) => {
49
- setSidebarOrder(panels.map((p) => p.id));
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={currentItems}
56
- setValue={handleSetValue}
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.tooltip}
95
+ {panel.label}
62
96
  </span>
63
97
  )}
64
- ariaLabel="Reorderable sidebar panels"
98
+ ariaLabel="Sidebar panels"
65
99
  className="flex flex-col gap-0"
66
- onAction={(panel) => toggleApplication(panel.id)}
67
- renderItem={(panel) => {
68
- return (
69
- <SidebarItem
70
- tooltip={panel.tooltip}
71
- selected={selectedPanel === panel.id}
72
- >
73
- {renderIcon(panel)}
74
- </SidebarItem>
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 { ListBox, ListBoxItem, useDragAndDrop } from "react-aria-components";
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
- export interface ReorderableListProps<T extends { id: string | number }> {
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 and react-stately.
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
- * interface MyItem {
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
- * ariaLabel="My reorderable list"
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 { id: string | number }>({
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 = (item) => String(item.id),
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) => [...keys].map((key) => ({ "text/plain": String(key) })),
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.id));
92
- const remaining = value.filter((item) => !keySet.has(item.id));
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.id === e.target.key,
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 currentItemIds = useMemo(
110
- () => new Set(value.map((item) => item.id)),
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.id !== item.id));
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.id === key);
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
- availableIds: value.map((v) => v.id),
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 className="active:cursor-grabbing data-[dragging]:opacity-60">
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 isChecked = currentItemIds.has(item.id);
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={item.id}
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
  })}