gravity-dnd 1.1.6

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.
Files changed (44) hide show
  1. package/.eslintignore +3 -0
  2. package/.eslintrc.cjs +21 -0
  3. package/.storybook/main.ts +15 -0
  4. package/.storybook/preview.css +80 -0
  5. package/.storybook/preview.ts +15 -0
  6. package/LICENSE +373 -0
  7. package/README.md +292 -0
  8. package/index.ts +19 -0
  9. package/package.json +64 -0
  10. package/public/.gitkeep +0 -0
  11. package/src/Gravity.stories.ts +207 -0
  12. package/src/components/DragAndDrop/DragAndDrop.scss +4 -0
  13. package/src/components/DragAndDrop/DragAndDrop.stories.ts +787 -0
  14. package/src/components/DragAndDrop/DragAndDrop.visuals.css +1 -0
  15. package/src/components/DragAndDrop/DragAndDrop.vue +23 -0
  16. package/src/components/DragAndDrop.scss +4 -0
  17. package/src/components/DragAndDrop.visuals.css +1 -0
  18. package/src/components/DragAndDrop.vue +23 -0
  19. package/src/components/Draggable/DragDropProvider.scss +4 -0
  20. package/src/components/Draggable/DragDropProvider.visuals.css +1 -0
  21. package/src/components/Draggable/DragDropProvider.vue +11 -0
  22. package/src/components/Draggable/DragPreviewOverlay.scss +21 -0
  23. package/src/components/Draggable/DragPreviewOverlay.visuals.css +3 -0
  24. package/src/components/Draggable/DragPreviewOverlay.vue +41 -0
  25. package/src/components/Draggable/Draggable.scss +86 -0
  26. package/src/components/Draggable/Draggable.stories.ts +232 -0
  27. package/src/components/Draggable/Draggable.visuals.css +8 -0
  28. package/src/components/Draggable/Draggable.vue +292 -0
  29. package/src/components/Draggable/contracts.ts +82 -0
  30. package/src/components/Draggable/internalDropLayer.ts +126 -0
  31. package/src/components/Draggable/useDragDropContext.ts +310 -0
  32. package/src/components/Pool/Pool.scss +107 -0
  33. package/src/components/Pool/Pool.stories.ts +155 -0
  34. package/src/components/Pool/Pool.visuals.css +25 -0
  35. package/src/components/Pool/Pool.vue +198 -0
  36. package/src/components/Slot/Slot.scss +48 -0
  37. package/src/components/Slot/Slot.stories.ts +299 -0
  38. package/src/components/Slot/Slot.visuals.css +15 -0
  39. package/src/components/Slot/Slot.vue +126 -0
  40. package/src/styles.css +15 -0
  41. package/styles.css +1 -0
  42. package/styles.scss +6 -0
  43. package/tsconfig.json +18 -0
  44. package/vite.config.ts +21 -0
@@ -0,0 +1,787 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite';
2
+ import { ref } from 'vue';
3
+ import DragAndDrop from './DragAndDrop.vue';
4
+ import Draggable from '../Draggable/Draggable.vue';
5
+ import Slot from '../Slot/Slot.vue';
6
+ import Pool from '../Pool/Pool.vue';
7
+ import type { PoolReceiveEvent, PoolReorderEvent, SlotDropEvent } from '../Draggable/contracts';
8
+
9
+ interface DemoItem {
10
+ id: string;
11
+ label: string;
12
+ }
13
+
14
+ const SLOT_COUNT = 4;
15
+
16
+ const meta: Meta<typeof DragAndDrop> = {
17
+ title: 'Gravity/DragAndDrop',
18
+ component: DragAndDrop,
19
+ tags: ['autodocs'],
20
+ parameters: { layout: 'centered' },
21
+ };
22
+
23
+ export default meta;
24
+ type Story = StoryObj<typeof DragAndDrop>;
25
+
26
+ export const Combined: Story = {
27
+ render: () => ({
28
+ setup() {
29
+ const slots = ref<(DemoItem | null)[]>([{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }, null, null]);
30
+ const pool = ref<DemoItem[]>([
31
+ { id: 'c', label: 'C' },
32
+ { id: 'd', label: 'D' },
33
+ { id: 'e', label: 'E' },
34
+ ]);
35
+
36
+ function takeFromSource(sourceContainerId: string, sourceIndex: number): DemoItem | null {
37
+ if (sourceContainerId.startsWith('slot-')) {
38
+ const slotIndex = Number(sourceContainerId.replace('slot-', ''));
39
+ const moved = slots.value[slotIndex] || null;
40
+ slots.value[slotIndex] = null;
41
+ return moved;
42
+ }
43
+ if (sourceContainerId === 'pool-main') {
44
+ const [moved] = pool.value.splice(sourceIndex, 1);
45
+ return moved || null;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function onSlotDrop(event: SlotDropEvent<unknown>) {
51
+ const slotIndex = Number(event.slotId.replace('slot-', ''));
52
+ if (!Number.isFinite(slotIndex) || slotIndex < 0 || slotIndex >= SLOT_COUNT) return;
53
+ if (event.source.containerId === event.slotId) return;
54
+ if (event.swap && event.source.kind === 'slot') {
55
+ const sourceSlotIndex = Number(event.source.containerId.replace('slot-', ''));
56
+ if (!Number.isFinite(sourceSlotIndex) || sourceSlotIndex < 0 || sourceSlotIndex >= SLOT_COUNT) return;
57
+ const moved = slots.value[sourceSlotIndex];
58
+ if (!moved) return;
59
+ const displaced = slots.value[slotIndex];
60
+ slots.value[slotIndex] = moved;
61
+ slots.value[sourceSlotIndex] = displaced ?? null;
62
+ return;
63
+ }
64
+
65
+ const moved = takeFromSource(event.source.containerId, event.source.index);
66
+ if (!moved) return;
67
+ const displaced = slots.value[slotIndex];
68
+ slots.value[slotIndex] = moved;
69
+ if (displaced) pool.value.push(displaced);
70
+ }
71
+
72
+ function onPoolReorder(event: PoolReorderEvent<unknown>) {
73
+ if (event.fromIndex === event.toIndex) return;
74
+ const [moved] = pool.value.splice(event.fromIndex, 1);
75
+ if (!moved) return;
76
+ let insertIndex = event.toIndex;
77
+ if (event.fromIndex < event.toIndex) insertIndex -= 1;
78
+ pool.value.splice(Math.max(0, Math.min(insertIndex, pool.value.length)), 0, moved as DemoItem);
79
+ }
80
+
81
+ function onPoolReceive(event: PoolReceiveEvent<unknown>) {
82
+ const moved = takeFromSource(event.source.containerId, event.source.index);
83
+ if (!moved) return;
84
+ const insertIndex = Math.max(0, Math.min(event.insertIndex, pool.value.length));
85
+ pool.value.splice(insertIndex, 0, moved);
86
+ }
87
+
88
+ return {
89
+ slots,
90
+ pool,
91
+ SLOT_COUNT,
92
+ onSlotDrop,
93
+ onPoolReorder,
94
+ onPoolReceive,
95
+ };
96
+ },
97
+ components: { DragAndDrop, Draggable, Slot, Pool },
98
+ template: `
99
+ <div style="width: 560px; max-width: 96vw; font-family: system-ui, sans-serif;">
100
+ <DragAndDrop>
101
+ <div style="display: grid; gap: 16px;">
102
+ <div>
103
+ <div style="margin-bottom: 8px; font-size: 14px; font-weight: 600;">Slots</div>
104
+ <div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px;">
105
+ <Slot
106
+ v-for="idx in SLOT_COUNT"
107
+ :key="'slot-' + idx"
108
+ :slot-id="'slot-' + (idx - 1)"
109
+ @drop="onSlotDrop"
110
+ >
111
+ <template #default="{ hovering }">
112
+ <div
113
+ style="min-height: 56px; display: flex; align-items: center; justify-content: center; border-radius: 8px;"
114
+ :style="{ background: hovering ? '#ecfeff' : '#ffffff' }"
115
+ >
116
+ <Draggable
117
+ v-if="slots[idx - 1]"
118
+ :draggable-id="'slot-item-' + (idx - 1)"
119
+ :item="slots[idx - 1]"
120
+ :source-id="'slot-' + (idx - 1)"
121
+ source-kind="slot"
122
+ :source-index="0"
123
+ >
124
+ <template #default="{ dragging }">
125
+ <div
126
+ :style="{
127
+ padding: '6px 10px',
128
+ borderRadius: '6px',
129
+ border: '1px solid #d1d5db',
130
+ background: '#f9fafb',
131
+ opacity: dragging ? .9 : 1
132
+ }"
133
+ >
134
+ {{ slots[idx - 1]?.label }}
135
+ </div>
136
+ </template>
137
+ </Draggable>
138
+ <span v-else style="font-size: 12px; color: #64748b;">Drop item</span>
139
+ </div>
140
+ </template>
141
+ </Slot>
142
+ </div>
143
+ </div>
144
+
145
+ <div>
146
+ <div style="margin-bottom: 8px; font-size: 14px; font-weight: 600;">Pool</div>
147
+ <Pool
148
+ pool-id="pool-main"
149
+ :items="pool"
150
+ @reorder="onPoolReorder"
151
+ @receive="onPoolReceive"
152
+ >
153
+ <template #item="{ item, index }">
154
+ <Draggable
155
+ :draggable-id="'pool-item-' + item.id"
156
+ :item="item"
157
+ source-id="pool-main"
158
+ source-kind="pool"
159
+ :source-index="index"
160
+ >
161
+ <template #default="{ dragging }">
162
+ <div
163
+ :style="{
164
+ padding: '6px 10px',
165
+ minWidth: '56px',
166
+ textAlign: 'center',
167
+ borderRadius: '6px',
168
+ border: '1px solid #d1d5db',
169
+ background: '#f9fafb',
170
+ opacity: dragging ? .9 : 1
171
+ }"
172
+ >
173
+ {{ item.label }}
174
+ </div>
175
+ </template>
176
+ </Draggable>
177
+ </template>
178
+ </Pool>
179
+ </div>
180
+ </div>
181
+
182
+ <template #preview="{ item }">
183
+ <div
184
+ v-if="item"
185
+ style="padding: 6px 10px; border-radius: 6px; border: 1px solid #d1d5db; background: #ffffff; box-shadow: 0 4px 12px rgba(0,0,0,0.12);"
186
+ >
187
+ {{ item.label }}
188
+ </div>
189
+ </template>
190
+ </DragAndDrop>
191
+ </div>
192
+ `,
193
+ }),
194
+ };
195
+
196
+ /*
197
+ import type { Meta, StoryObj } from '@storybook/vue3-vite';
198
+ import { ref } from 'vue';
199
+ import DragAndDrop from './DragAndDrop.vue';
200
+ import Draggable from './Draggable.vue';
201
+ import Slot from '../Slot/Slot.vue';
202
+ import Pool from '../Pool/Pool.vue';
203
+ import type { PoolReceiveEvent, PoolReorderEvent, SlotDropEvent } from './contracts';
204
+
205
+ interface DemoItem {
206
+ id: string;
207
+ label: string;
208
+ }
209
+
210
+ const SLOT_COUNT = 4;
211
+
212
+ const meta: Meta<typeof DragAndDrop> = {
213
+ title: 'Gravity/DragAndDrop',
214
+ component: DragAndDrop,
215
+ tags: ['autodocs'],
216
+ parameters: { layout: 'centered' },
217
+ };
218
+
219
+ export default meta;
220
+ type Story = StoryObj<typeof DragAndDrop>;
221
+
222
+ export const Combined: Story = {
223
+ render: () => ({
224
+ setup() {
225
+ const slots = ref<(DemoItem | null)[]>([{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }, null, null]);
226
+ const pool = ref<DemoItem[]>([
227
+ { id: 'c', label: 'C' },
228
+ { id: 'd', label: 'D' },
229
+ { id: 'e', label: 'E' },
230
+ ]);
231
+
232
+ function takeFromSource(sourceContainerId: string, sourceIndex: number): DemoItem | null {
233
+ if (sourceContainerId.startsWith('slot-')) {
234
+ const slotIndex = Number(sourceContainerId.replace('slot-', ''));
235
+ const moved = slots.value[slotIndex] || null;
236
+ slots.value[slotIndex] = null;
237
+ return moved;
238
+ }
239
+ if (sourceContainerId === 'pool-main') {
240
+ const [moved] = pool.value.splice(sourceIndex, 1);
241
+ return moved || null;
242
+ }
243
+ return null;
244
+ }
245
+
246
+ function onSlotDrop(event: SlotDropEvent<unknown>) {
247
+ const slotIndex = Number(event.slotId.replace('slot-', ''));
248
+ if (!Number.isFinite(slotIndex) || slotIndex < 0 || slotIndex >= SLOT_COUNT) return;
249
+ if (event.source.containerId === event.slotId) return;
250
+ if (event.swap && event.source.kind === 'slot') {
251
+ const sourceSlotIndex = Number(event.source.containerId.replace('slot-', ''));
252
+ if (!Number.isFinite(sourceSlotIndex) || sourceSlotIndex < 0 || sourceSlotIndex >= SLOT_COUNT) return;
253
+ const moved = slots.value[sourceSlotIndex];
254
+ if (!moved) return;
255
+ const displaced = slots.value[slotIndex];
256
+ slots.value[slotIndex] = moved;
257
+ slots.value[sourceSlotIndex] = displaced ?? null;
258
+ return;
259
+ }
260
+
261
+ const moved = takeFromSource(event.source.containerId, event.source.index);
262
+ if (!moved) return;
263
+ const displaced = slots.value[slotIndex];
264
+ slots.value[slotIndex] = moved;
265
+ if (displaced) pool.value.push(displaced);
266
+ }
267
+
268
+ function onPoolReorder(event: PoolReorderEvent<unknown>) {
269
+ if (event.fromIndex === event.toIndex) return;
270
+ const [moved] = pool.value.splice(event.fromIndex, 1);
271
+ if (!moved) return;
272
+ let insertIndex = event.toIndex;
273
+ if (event.fromIndex < event.toIndex) insertIndex -= 1;
274
+ pool.value.splice(Math.max(0, Math.min(insertIndex, pool.value.length)), 0, moved as DemoItem);
275
+ }
276
+
277
+ function onPoolReceive(event: PoolReceiveEvent<unknown>) {
278
+ const moved = takeFromSource(event.source.containerId, event.source.index);
279
+ if (!moved) return;
280
+ const insertIndex = Math.max(0, Math.min(event.insertIndex, pool.value.length));
281
+ pool.value.splice(insertIndex, 0, moved);
282
+ }
283
+
284
+ return {
285
+ slots,
286
+ pool,
287
+ SLOT_COUNT,
288
+ onSlotDrop,
289
+ onPoolReorder,
290
+ onPoolReceive,
291
+ };
292
+ },
293
+ components: { DragAndDrop, Draggable, Slot, Pool },
294
+ template: `
295
+ <div style="width: 560px; max-width: 96vw; font-family: system-ui, sans-serif;">
296
+ <DragAndDrop>
297
+ <div style="display: grid; gap: 16px;">
298
+ <div>
299
+ <div style="margin-bottom: 8px; font-size: 14px; font-weight: 600;">Slots</div>
300
+ <div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px;">
301
+ <Slot
302
+ v-for="idx in SLOT_COUNT"
303
+ :key="'slot-' + idx"
304
+ :slot-id="'slot-' + (idx - 1)"
305
+ @drop="onSlotDrop"
306
+ >
307
+ <template #default="{ hovering }">
308
+ <div
309
+ style="min-height: 56px; display: flex; align-items: center; justify-content: center; border-radius: 8px;"
310
+ :style="{ background: hovering ? '#ecfeff' : '#ffffff' }"
311
+ >
312
+ <Draggable
313
+ v-if="slots[idx - 1]"
314
+ :draggable-id="'slot-item-' + (idx - 1)"
315
+ :item="slots[idx - 1]"
316
+ :source-id="'slot-' + (idx - 1)"
317
+ source-kind="slot"
318
+ :source-index="0"
319
+ >
320
+ <template #default="{ dragging }">
321
+ <div
322
+ :style="{
323
+ padding: '6px 10px',
324
+ borderRadius: '6px',
325
+ border: '1px solid #d1d5db',
326
+ background: '#f9fafb',
327
+ opacity: dragging ? 0.3 : 1
328
+ }"
329
+ >
330
+ {{ slots[idx - 1]?.label }}
331
+ </div>
332
+ </template>
333
+ </Draggable>
334
+ <div v-else style="font-size: 12px; color: #9ca3af;">empty</div>
335
+ </div>
336
+ </template>
337
+ </Slot>
338
+ </div>
339
+ </div>
340
+
341
+ <div>
342
+ <div style="margin-bottom: 8px; font-size: 14px; font-weight: 600;">Pool</div>
343
+ <Pool
344
+ pool-id="pool-main"
345
+ :items="pool"
346
+ @reorder="onPoolReorder"
347
+ @receive="onPoolReceive"
348
+ >
349
+ <template #item="{ item, index }">
350
+ <Draggable
351
+ :draggable-id="'pool-item-' + item.id"
352
+ :item="item"
353
+ source-id="pool-main"
354
+ source-kind="pool"
355
+ :source-index="index"
356
+ >
357
+ <template #default="{ dragging }">
358
+ <div
359
+ :style="{
360
+ padding: '6px 10px',
361
+ minWidth: '56px',
362
+ textAlign: 'center',
363
+ borderRadius: '6px',
364
+ border: '1px solid #d1d5db',
365
+ background: '#f9fafb',
366
+ opacity: dragging ? 0.3 : 1
367
+ }"
368
+ >
369
+ {{ item.label }}
370
+ </div>
371
+ </template>
372
+ </Draggable>
373
+ </template>
374
+ </Pool>
375
+ </div>
376
+ </div>
377
+
378
+ <template #preview="{ item }">
379
+ <div
380
+ v-if="item"
381
+ style="padding: 6px 10px; border-radius: 6px; border: 1px solid #d1d5db; background: #ffffff; box-shadow: 0 4px 12px rgba(0,0,0,0.12);"
382
+ >
383
+ {{ item.label }}
384
+ </div>
385
+ </template>
386
+ </DragAndDrop>
387
+ </div>
388
+ `,
389
+ }),
390
+ };
391
+
392
+ import type { Meta, StoryObj } from '@storybook/vue3-vite';
393
+ import { ref } from 'vue';
394
+ import DragAndDrop from './DragAndDrop.vue';
395
+ import Draggable from './Draggable.vue';
396
+ import Slot from '../Slot/Slot.vue';
397
+ import Pool from '../Pool/Pool.vue';
398
+ import type { PoolReceiveEvent, PoolReorderEvent, SlotDropEvent } from './contracts';
399
+
400
+ interface DemoItem {
401
+ id: string;
402
+ label: string;
403
+ }
404
+
405
+ const SLOT_COUNT = 4;
406
+
407
+ const meta: Meta<typeof DragAndDrop> = {
408
+ title: 'Gravity/DragAndDrop',
409
+ component: DragAndDrop,
410
+ tags: ['autodocs'],
411
+ parameters: { layout: 'centered' },
412
+ };
413
+
414
+ export default meta;
415
+ type Story = StoryObj<typeof DragAndDrop>;
416
+
417
+ export const Combined: Story = {
418
+ render: () => ({
419
+ setup() {
420
+ const slots = ref<(DemoItem | null)[]>([{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }, null, null]);
421
+ const pool = ref<DemoItem[]>([
422
+ { id: 'c', label: 'C' },
423
+ { id: 'd', label: 'D' },
424
+ { id: 'e', label: 'E' },
425
+ ]);
426
+
427
+ function takeFromSource(sourceContainerId: string, sourceIndex: number): DemoItem | null {
428
+ if (sourceContainerId.startsWith('slot-')) {
429
+ const slotIndex = Number(sourceContainerId.replace('slot-', ''));
430
+ const moved = slots.value[slotIndex] || null;
431
+ slots.value[slotIndex] = null;
432
+ return moved;
433
+ }
434
+ if (sourceContainerId === 'pool-main') {
435
+ const [moved] = pool.value.splice(sourceIndex, 1);
436
+ return moved || null;
437
+ }
438
+ return null;
439
+ }
440
+
441
+ function onSlotDrop(event: SlotDropEvent<unknown>) {
442
+ const slotIndex = Number(event.slotId.replace('slot-', ''));
443
+ if (!Number.isFinite(slotIndex) || slotIndex < 0 || slotIndex >= SLOT_COUNT) return;
444
+ if (event.source.containerId === event.slotId) return;
445
+ if (event.swap && event.source.kind === 'slot') {
446
+ const sourceSlotIndex = Number(event.source.containerId.replace('slot-', ''));
447
+ if (!Number.isFinite(sourceSlotIndex) || sourceSlotIndex < 0 || sourceSlotIndex >= SLOT_COUNT) return;
448
+ const moved = slots.value[sourceSlotIndex];
449
+ if (!moved) return;
450
+ const displaced = slots.value[slotIndex];
451
+ slots.value[slotIndex] = moved;
452
+ slots.value[sourceSlotIndex] = displaced ?? null;
453
+ return;
454
+ }
455
+
456
+ const moved = takeFromSource(event.source.containerId, event.source.index);
457
+ if (!moved) return;
458
+ const displaced = slots.value[slotIndex];
459
+ slots.value[slotIndex] = moved;
460
+ if (displaced) pool.value.push(displaced);
461
+ }
462
+
463
+ function onPoolReorder(event: PoolReorderEvent<unknown>) {
464
+ if (event.fromIndex === event.toIndex) return;
465
+ const [moved] = pool.value.splice(event.fromIndex, 1);
466
+ if (!moved) return;
467
+ let insertIndex = event.toIndex;
468
+ if (event.fromIndex < event.toIndex) insertIndex -= 1;
469
+ pool.value.splice(Math.max(0, Math.min(insertIndex, pool.value.length)), 0, moved as DemoItem);
470
+ }
471
+
472
+ function onPoolReceive(event: PoolReceiveEvent<unknown>) {
473
+ const moved = takeFromSource(event.source.containerId, event.source.index);
474
+ if (!moved) return;
475
+ const insertIndex = Math.max(0, Math.min(event.insertIndex, pool.value.length));
476
+ pool.value.splice(insertIndex, 0, moved);
477
+ }
478
+
479
+ return {
480
+ slots,
481
+ pool,
482
+ SLOT_COUNT,
483
+ onSlotDrop,
484
+ onPoolReorder,
485
+ onPoolReceive,
486
+ };
487
+ },
488
+ components: { DragAndDrop, Draggable, Slot, Pool },
489
+ template: `
490
+ <div style="width: 560px; max-width: 96vw; font-family: system-ui, sans-serif;">
491
+ <DragAndDrop>
492
+ <div style="display: grid; gap: 16px;">
493
+ <div>
494
+ <div style="margin-bottom: 8px; font-size: 14px; font-weight: 600;">Slots</div>
495
+ <div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px;">
496
+ <Slot
497
+ v-for="idx in SLOT_COUNT"
498
+ :key="'slot-' + idx"
499
+ :slot-id="'slot-' + (idx - 1)"
500
+ @drop="onSlotDrop"
501
+ >
502
+ <template #default="{ hovering }">
503
+ <div
504
+ style="min-height: 56px; display: flex; align-items: center; justify-content: center; border-radius: 8px;"
505
+ :style="{ background: hovering ? '#ecfeff' : '#ffffff' }"
506
+ >
507
+ <Draggable
508
+ v-if="slots[idx - 1]"
509
+ :draggable-id="'slot-item-' + (idx - 1)"
510
+ :item="slots[idx - 1]"
511
+ :source-id="'slot-' + (idx - 1)"
512
+ source-kind="slot"
513
+ :source-index="0"
514
+ >
515
+ <template #default="{ dragging }">
516
+ <div
517
+ :style="{
518
+ padding: '6px 10px',
519
+ borderRadius: '6px',
520
+ border: '1px solid #d1d5db',
521
+ background: '#f9fafb',
522
+ opacity: dragging ? 0.3 : 1
523
+ }"
524
+ >
525
+ {{ slots[idx - 1]?.label }}
526
+ </div>
527
+ </template>
528
+ </Draggable>
529
+ <div v-else style="font-size: 12px; color: #9ca3af;">empty</div>
530
+ </div>
531
+ </template>
532
+ </Slot>
533
+ </div>
534
+ </div>
535
+
536
+ <div>
537
+ <div style="margin-bottom: 8px; font-size: 14px; font-weight: 600;">Pool</div>
538
+ <Pool
539
+ pool-id="pool-main"
540
+ :items="pool"
541
+ @reorder="onPoolReorder"
542
+ @receive="onPoolReceive"
543
+ >
544
+ <template #item="{ item, index }">
545
+ <Draggable
546
+ :draggable-id="'pool-item-' + item.id"
547
+ :item="item"
548
+ source-id="pool-main"
549
+ source-kind="pool"
550
+ :source-index="index"
551
+ >
552
+ <template #default="{ dragging }">
553
+ <div
554
+ :style="{
555
+ padding: '6px 10px',
556
+ minWidth: '56px',
557
+ textAlign: 'center',
558
+ borderRadius: '6px',
559
+ border: '1px solid #d1d5db',
560
+ background: '#f9fafb',
561
+ opacity: dragging ? 0.3 : 1
562
+ }"
563
+ >
564
+ {{ item.label }}
565
+ </div>
566
+ </template>
567
+ </Draggable>
568
+ </template>
569
+ </Pool>
570
+ </div>
571
+ </div>
572
+
573
+ <template #preview="{ item }">
574
+ <div
575
+ v-if="item"
576
+ style="padding: 6px 10px; border-radius: 6px; border: 1px solid #d1d5db; background: #ffffff; box-shadow: 0 4px 12px rgba(0,0,0,0.12);"
577
+ >
578
+ {{ item.label }}
579
+ </div>
580
+ </template>
581
+ </DragAndDrop>
582
+ </div>
583
+ `,
584
+ }),
585
+ };
586
+
587
+ import type { Meta, StoryObj } from '@storybook/vue3-vite';
588
+ import { ref } from 'vue';
589
+ import DragAndDrop from './DragAndDrop.vue';
590
+ import Draggable from './Draggable.vue';
591
+ import Slot from '../Slot/Slot.vue';
592
+ import Pool from '../Pool/Pool.vue';
593
+ import type { PoolReceiveEvent, PoolReorderEvent, SlotDropEvent } from './contracts';
594
+
595
+ interface DemoItem {
596
+ id: string;
597
+ label: string;
598
+ }
599
+
600
+ const SLOT_COUNT = 4;
601
+
602
+ const meta: Meta<typeof DragAndDrop> = {
603
+ title: 'Gravity/DragAndDrop',
604
+ component: DragAndDrop,
605
+ tags: ['autodocs'],
606
+ parameters: { layout: 'centered' },
607
+ };
608
+
609
+ export default meta;
610
+ type Story = StoryObj<typeof DragAndDrop>;
611
+
612
+ export const Combined: Story = {
613
+ render: () => ({
614
+ setup() {
615
+ const slots = ref<(DemoItem | null)[]>([{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }, null, null]);
616
+ const pool = ref<DemoItem[]>([
617
+ { id: 'c', label: 'C' },
618
+ { id: 'd', label: 'D' },
619
+ { id: 'e', label: 'E' },
620
+ ]);
621
+
622
+ function takeFromSource(sourceContainerId: string, sourceIndex: number): DemoItem | null {
623
+ if (sourceContainerId.startsWith('slot-')) {
624
+ const slotIndex = Number(sourceContainerId.replace('slot-', ''));
625
+ const moved = slots.value[slotIndex] || null;
626
+ slots.value[slotIndex] = null;
627
+ return moved;
628
+ }
629
+ if (sourceContainerId === 'pool-main') {
630
+ const [moved] = pool.value.splice(sourceIndex, 1);
631
+ return moved || null;
632
+ }
633
+ return null;
634
+ }
635
+
636
+ function onSlotDrop(event: SlotDropEvent<unknown>) {
637
+ const slotIndex = Number(event.slotId.replace('slot-', ''));
638
+ if (!Number.isFinite(slotIndex) || slotIndex < 0 || slotIndex >= SLOT_COUNT) return;
639
+ if (event.source.containerId === event.slotId) return;
640
+ if (event.swap && event.source.kind === 'slot') {
641
+ const sourceSlotIndex = Number(event.source.containerId.replace('slot-', ''));
642
+ if (!Number.isFinite(sourceSlotIndex) || sourceSlotIndex < 0 || sourceSlotIndex >= SLOT_COUNT) return;
643
+ const moved = slots.value[sourceSlotIndex];
644
+ if (!moved) return;
645
+ const displaced = slots.value[slotIndex];
646
+ slots.value[slotIndex] = moved;
647
+ slots.value[sourceSlotIndex] = displaced ?? null;
648
+ return;
649
+ }
650
+
651
+ const moved = takeFromSource(event.source.containerId, event.source.index);
652
+ if (!moved) return;
653
+ const displaced = slots.value[slotIndex];
654
+ slots.value[slotIndex] = moved;
655
+ if (displaced) pool.value.push(displaced);
656
+ }
657
+
658
+ function onPoolReorder(event: PoolReorderEvent<unknown>) {
659
+ if (event.fromIndex === event.toIndex) return;
660
+ const [moved] = pool.value.splice(event.fromIndex, 1);
661
+ if (!moved) return;
662
+ let insertIndex = event.toIndex;
663
+ if (event.fromIndex < event.toIndex) insertIndex -= 1;
664
+ pool.value.splice(Math.max(0, Math.min(insertIndex, pool.value.length)), 0, moved as DemoItem);
665
+ }
666
+
667
+ function onPoolReceive(event: PoolReceiveEvent<unknown>) {
668
+ const moved = takeFromSource(event.source.containerId, event.source.index);
669
+ if (!moved) return;
670
+ const insertIndex = Math.max(0, Math.min(event.insertIndex, pool.value.length));
671
+ pool.value.splice(insertIndex, 0, moved);
672
+ }
673
+
674
+ return {
675
+ slots,
676
+ pool,
677
+ SLOT_COUNT,
678
+ onSlotDrop,
679
+ onPoolReorder,
680
+ onPoolReceive,
681
+ };
682
+ },
683
+ components: { DragAndDrop, Draggable, Slot, Pool },
684
+ template: `
685
+ <div style="width: 560px; max-width: 96vw; font-family: system-ui, sans-serif;">
686
+ <DragAndDrop>
687
+ <div style="display: grid; gap: 16px;">
688
+ <div>
689
+ <div style="margin-bottom: 8px; font-size: 14px; font-weight: 600;">Slots</div>
690
+ <div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px;">
691
+ <Slot
692
+ v-for="idx in SLOT_COUNT"
693
+ :key="'slot-' + idx"
694
+ :slot-id="'slot-' + (idx - 1)"
695
+ @drop="onSlotDrop"
696
+ >
697
+ <template #default="{ hovering }">
698
+ <div
699
+ style="min-height: 56px; display: flex; align-items: center; justify-content: center; border-radius: 8px;"
700
+ :style="{ background: hovering ? '#ecfeff' : '#ffffff' }"
701
+ >
702
+ <Draggable
703
+ v-if="slots[idx - 1]"
704
+ :draggable-id="'slot-item-' + (idx - 1)"
705
+ :item="slots[idx - 1]"
706
+ :source-id="'slot-' + (idx - 1)"
707
+ source-kind="slot"
708
+ :source-index="0"
709
+ >
710
+ <template #default="{ dragging }">
711
+ <div
712
+ :style="{
713
+ padding: '6px 10px',
714
+ borderRadius: '6px',
715
+ border: '1px solid #d1d5db',
716
+ background: '#f9fafb',
717
+ opacity: dragging ? 0.3 : 1
718
+ }"
719
+ >
720
+ {{ slots[idx - 1].label }}
721
+ </div>
722
+ </template>
723
+ </Draggable>
724
+ <div v-else style="font-size: 12px; color: #9ca3af;">empty</div>
725
+ </div>
726
+ </template>
727
+ </Slot>
728
+ </div>
729
+ </div>
730
+
731
+ <div>
732
+ <div style="margin-bottom: 8px; font-size: 14px; font-weight: 600;">Pool</div>
733
+ <Pool
734
+ pool-id="pool-main"
735
+ :items="pool"
736
+ @reorder="onPoolReorder"
737
+ @receive="onPoolReceive"
738
+ >
739
+ <template #item="{ item, index }">
740
+ <Draggable
741
+ :draggable-id="'pool-item-' + item.id"
742
+ :item="item"
743
+ source-id="pool-main"
744
+ source-kind="pool"
745
+ :source-index="index"
746
+ >
747
+ <div
748
+ v-if="showPoolGhostAt(i, dragState.active, dragState.hoverTargetName, dragState.hoverTargetIndex, dragState.source, dragState.index, pool.length)"
749
+ style="height: 34px; min-width: 56px; border-radius: 6px; border: 1px dashed #22c55e; background: #f0fdf4;"
750
+ />
751
+
752
+ <div
753
+ v-else
754
+ :style="{
755
+ padding: '6px 10px',
756
+ minWidth: '56px',
757
+ textAlign: 'center',
758
+ borderRadius: '6px',
759
+ border: '1px solid #d1d5db',
760
+ background: '#f9fafb',
761
+ cursor: 'grab',
762
+ opacity: isDraggingSource('pool', i) ? 0.3 : 1
763
+ }"
764
+ @pointerdown.prevent="onPointerDown('pool', i, $event)"
765
+ >
766
+ {{ item.label }}
767
+ </div>
768
+ </div>
769
+ </TransitionGroup>
770
+ </div>
771
+ </div>
772
+ </template>
773
+
774
+ <template #preview="{ item }">
775
+ <div
776
+ v-if="item"
777
+ style="padding: 6px 10px; border-radius: 6px; border: 1px solid #d1d5db; background: #ffffff; box-shadow: 0 4px 12px rgba(0,0,0,0.12);"
778
+ >
779
+ {{ item.label }}
780
+ </div>
781
+ </template>
782
+ </DragAndDrop>
783
+ </div>
784
+ `,
785
+ }),
786
+ };
787
+ */