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,198 @@
1
+ <template lang="pug">
2
+ .st-gravity-pool(
3
+ ref="rootEl"
4
+ :class="rootClasses"
5
+ )
6
+ TransitionGroup.st-gravity-pool__list(
7
+ name="gravity-pool-reorder"
8
+ move-class="gravity-pool-reorder-move"
9
+ tag="div"
10
+ )
11
+ template(v-for="position in positions")
12
+ .st-gravity-pool__drop-indicator(
13
+ v-if="showDropIndicator && indicatorIndex === position"
14
+ :key="`drop-indicator-${position}`"
15
+ aria-hidden="true"
16
+ )
17
+ .st-gravity-pool__entry(
18
+ v-if="position < items.length"
19
+ :key="resolveItemKey(items[position], position)"
20
+ :data-pool-index="position"
21
+ )
22
+ slot(name="item" :item="items[position]" :index="position")
23
+ slot(name="append")
24
+ </template>
25
+
26
+ <script setup lang="ts">
27
+ import { computed, onBeforeUnmount, ref, watch } from 'vue';
28
+ import { TransitionGroup } from 'vue';
29
+ import { useDragDropContext } from '../Draggable/useDragDropContext';
30
+ import type { PoolReceiveEvent, PoolReorderEvent } from '../Draggable/contracts';
31
+ import './Pool.scss';
32
+
33
+ const props = withDefaults(
34
+ defineProps<{
35
+ poolId: string;
36
+ items: unknown[];
37
+ itemKey?: string | ((item: unknown, index: number) => string);
38
+ disabled?: boolean;
39
+ accepts?: (item: unknown, payload: { sourceContainerId: string; sourceIndex: number }) => boolean;
40
+ }>(),
41
+ {
42
+ itemKey: 'id',
43
+ disabled: false,
44
+ accepts: undefined,
45
+ }
46
+ );
47
+
48
+ const emit = defineEmits<{
49
+ reorder: [payload: PoolReorderEvent<unknown>];
50
+ receive: [payload: PoolReceiveEvent<unknown>];
51
+ hoverChange: [payload: { poolId: string; hovering: boolean; accepts: boolean }];
52
+ }>();
53
+
54
+ const rootEl = ref<HTMLElement | null>(null);
55
+ const dragDrop = useDragDropContext();
56
+ let unregister: (() => void) | null = null;
57
+
58
+ const hovering = computed(() => {
59
+ if (!dragDrop) return false;
60
+ return dragDrop.isHovering(props.poolId);
61
+ });
62
+
63
+ const accepting = computed(() => {
64
+ if (!dragDrop?.dragState.hoverTarget) return true;
65
+ return dragDrop.dragState.hoverTarget.accepts;
66
+ });
67
+ const rootClasses = computed(() => ({
68
+ 'st-gravity-pool--hovering': hovering.value,
69
+ 'st-gravity-pool--accepting': hovering.value && accepting.value,
70
+ 'st-gravity-pool--rejecting': hovering.value && !accepting.value,
71
+ }));
72
+ const positions = computed(() => Array.from({ length: props.items.length + 1 }, (_, idx) => idx));
73
+ const activeHoverTarget = computed(() => {
74
+ const hoverTarget = dragDrop?.dragState.hoverTarget;
75
+ if (!hoverTarget || hoverTarget.kind !== 'pool') return null;
76
+ if (hoverTarget.containerId !== props.poolId) return null;
77
+ return hoverTarget;
78
+ });
79
+ const showDropIndicator = computed(() => !!activeHoverTarget.value?.accepts);
80
+ const indicatorIndex = computed(() => {
81
+ const index = activeHoverTarget.value?.index;
82
+ if (typeof index !== 'number') return -1;
83
+ return Math.max(0, Math.min(index, props.items.length));
84
+ });
85
+
86
+ function resolveItemKey(item: unknown, index: number) {
87
+ if (typeof props.itemKey === 'function') return props.itemKey(item, index);
88
+ const key = (item as Record<string, unknown>)?.[props.itemKey];
89
+ if (typeof key === 'string' || typeof key === 'number') return String(key);
90
+ return `${props.poolId}-${index}`;
91
+ }
92
+
93
+ function resolvePoolIndex(clientX: number, clientY: number) {
94
+ const root = rootEl.value;
95
+ if (!root) return props.items.length;
96
+
97
+
98
+ if (_rectCacheDirty || _rectCache.length !== props.items.length) {
99
+ _rebuildRectCache(root);
100
+ }
101
+
102
+ for (let i = 0; i < _rectCache.length; i++) {
103
+ const r = _rectCache[i];
104
+ if (clientY < r.top) return i;
105
+ if (clientY > r.bottom) continue;
106
+ if (clientX <= r.left + r.hw) return i;
107
+ }
108
+ return props.items.length;
109
+ }
110
+
111
+ interface _CachedRect { top: number; bottom: number; left: number; hw: number; }
112
+ let _rectCache: _CachedRect[] = [];
113
+ let _rectCacheDirty = true;
114
+
115
+ function _rebuildRectCache(root: HTMLElement) {
116
+ const nodes = root.querySelectorAll<HTMLElement>('[data-pool-index]');
117
+ _rectCache = [];
118
+ for (let i = 0; i < nodes.length; i++) {
119
+ const rect = nodes[i].getBoundingClientRect();
120
+ _rectCache.push({ top: rect.top, bottom: rect.bottom, left: rect.left, hw: rect.width / 2 });
121
+ }
122
+ _rectCacheDirty = false;
123
+ }
124
+
125
+ function _invalidateRectCache() {
126
+ _rectCacheDirty = true;
127
+ }
128
+
129
+ function canAccept(item: unknown, sourceContainerId: string, sourceIndex: number) {
130
+ if (props.disabled) return false;
131
+ if (!props.accepts) return true;
132
+ return props.accepts(item, { sourceContainerId, sourceIndex });
133
+ }
134
+
135
+ function registerTarget() {
136
+ if (!dragDrop) return;
137
+ if (unregister) unregister();
138
+ unregister = dragDrop.registerTarget({
139
+ id: `pool:${props.poolId}`,
140
+ kind: 'pool',
141
+ containerId: props.poolId,
142
+ resolveHover(clientX, clientY) {
143
+ const root = rootEl.value;
144
+ if (!root || !dragDrop.dragState.item || !dragDrop.dragState.source) return null;
145
+ const rect = root.getBoundingClientRect();
146
+ const inside = clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
147
+ if (!inside) return null;
148
+ return {
149
+ index: resolvePoolIndex(clientX, clientY),
150
+ accepts: canAccept(dragDrop.dragState.item, dragDrop.dragState.source.containerId, dragDrop.dragState.source.index),
151
+ };
152
+ },
153
+ onDrop(payload) {
154
+ if (payload.target.kind !== 'pool') return;
155
+ if (payload.source.containerId === props.poolId) {
156
+ emit('reorder', {
157
+ ...(payload as PoolReorderEvent<unknown>),
158
+ poolId: props.poolId,
159
+ fromIndex: payload.source.index,
160
+ toIndex: payload.target.index,
161
+ });
162
+ return;
163
+ }
164
+ emit('receive', {
165
+ ...(payload as PoolReceiveEvent<unknown>),
166
+ poolId: props.poolId,
167
+ insertIndex: payload.target.index,
168
+ });
169
+ },
170
+ });
171
+ }
172
+
173
+ watch(
174
+ () => [props.poolId, props.disabled, props.items.length],
175
+ () => {
176
+ _invalidateRectCache();
177
+ registerTarget();
178
+ },
179
+ { immediate: true }
180
+ );
181
+
182
+ watch(
183
+ () => hovering.value,
184
+ (isHovering) => {
185
+ emit('hoverChange', {
186
+ poolId: props.poolId,
187
+ hovering: isHovering,
188
+ accepts: accepting.value,
189
+ });
190
+ },
191
+ { immediate: true }
192
+ );
193
+
194
+ onBeforeUnmount(() => {
195
+ if (unregister) unregister();
196
+ _rectCache = [];
197
+ });
198
+ </script>
@@ -0,0 +1,48 @@
1
+ .st-gravity-slot {
2
+ border-radius: 8px;
3
+ transform-origin: center;
4
+ transition:
5
+ background-color 120ms ease,
6
+ border-color 120ms ease,
7
+ box-shadow 120ms ease,
8
+ transform 120ms ease;
9
+ }
10
+
11
+ .st-gravity-slot--hovering {
12
+ transform: translateY(-1px);
13
+ }
14
+
15
+ .st-gravity-slot--accepting {
16
+ animation: gravity-slot-accepting 170ms ease-out;
17
+ }
18
+
19
+ .st-gravity-slot--rejecting {
20
+ animation: gravity-slot-rejecting 190ms ease-out;
21
+ }
22
+
23
+ @keyframes gravity-slot-accepting {
24
+ 0% {
25
+ transform: translateY(0) scale(1);
26
+ }
27
+ 55% {
28
+ transform: translateY(-1px) scale(1.01);
29
+ }
30
+ 100% {
31
+ transform: translateY(-1px) scale(1);
32
+ }
33
+ }
34
+
35
+ @keyframes gravity-slot-rejecting {
36
+ 0% {
37
+ transform: translateX(0);
38
+ }
39
+ 30% {
40
+ transform: translateX(-2px);
41
+ }
42
+ 60% {
43
+ transform: translateX(2px);
44
+ }
45
+ 100% {
46
+ transform: translateX(0);
47
+ }
48
+ }
@@ -0,0 +1,299 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3-vite';
2
+ import { ref } from 'vue';
3
+ import GravityProvider from '../Draggable/DragDropProvider.vue';
4
+ import Draggable from '../Draggable/Draggable.vue';
5
+ import Slot from './Slot.vue';
6
+ import type { GravitySlotDropEvent } from '../Draggable/contracts';
7
+
8
+ interface DemoItem {
9
+ id: string;
10
+ label: string;
11
+ tier: 'bronze' | 'gold';
12
+ }
13
+
14
+ const meta: Meta<typeof Slot> = {
15
+ title: 'Gravity/Slot',
16
+ component: Slot,
17
+ tags: ['autodocs'],
18
+ parameters: { layout: 'centered' },
19
+ };
20
+
21
+ export default meta;
22
+ type Story = StoryObj<typeof Slot>;
23
+
24
+ export const AcceptAll: Story = {
25
+ render: () => ({
26
+ setup() {
27
+ const sourceItem = ref<DemoItem | null>({
28
+ id: 'source-accept-all',
29
+ label: 'Any Item',
30
+ tier: 'bronze',
31
+ });
32
+ const slottedItem = ref<DemoItem | null>(null);
33
+
34
+ function onDrop(event: GravitySlotDropEvent<unknown>) {
35
+ slottedItem.value = event.item as DemoItem;
36
+ if (event.source.containerId === 'slot-story-source') {
37
+ sourceItem.value = null;
38
+ }
39
+ }
40
+
41
+ return { sourceItem, slottedItem, onDrop };
42
+ },
43
+ components: { GravityProvider, Draggable, Slot },
44
+ template: `
45
+ <div style="width: 460px; max-width: 96vw; font-family: system-ui, sans-serif;">
46
+ <GravityProvider>
47
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
48
+ <div style="min-height: 64px;">
49
+ <Draggable
50
+ v-if="sourceItem"
51
+ draggable-id="slot-story-accept-all"
52
+ :item="sourceItem"
53
+ source-id="slot-story-source"
54
+ source-kind="custom"
55
+ :source-index="0"
56
+ >
57
+ <template #default="{ dragging }">
58
+ <div
59
+ :style="{
60
+ padding: '10px 12px',
61
+ borderRadius: '8px',
62
+ border: '1px solid #d1d5db',
63
+ background: '#f9fafb',
64
+ opacity: dragging ? .9 : 1
65
+ }"
66
+ >
67
+ {{ sourceItem.label }}
68
+ </div>
69
+ </template>
70
+ </Draggable>
71
+ <div
72
+ v-else
73
+ aria-hidden="true"
74
+ style="padding: 10px 12px; border-radius: 8px; border: 1px solid transparent; visibility: hidden;"
75
+ >
76
+ hidden
77
+ </div>
78
+ </div>
79
+
80
+ <Slot slot-id="slot-accept-all" @drop="onDrop">
81
+ <template #default="{ hovering, accepting }">
82
+ <div
83
+ :style="{
84
+ minHeight: '64px',
85
+ borderRadius: '8px',
86
+ border: '1px solid #d1d5db',
87
+ display: 'flex',
88
+ alignItems: 'center',
89
+ justifyContent: 'center',
90
+ background: hovering ? (accepting ? '#ecfeff' : '#fff1f2') : '#fff'
91
+ }"
92
+ >
93
+ {{ slottedItem ? slottedItem.label : 'Accepts all draggables' }}
94
+ </div>
95
+ </template>
96
+ </Slot>
97
+ </div>
98
+
99
+ </GravityProvider>
100
+ </div>
101
+ `,
102
+ }),
103
+ };
104
+
105
+ export const ExplicitDeny: Story = {
106
+ render: () => ({
107
+ setup() {
108
+ const sourceItem = ref<DemoItem | null>({
109
+ id: 'source-deny-all',
110
+ label: 'Denied Item',
111
+ tier: 'gold',
112
+ });
113
+ const slottedItem = ref<DemoItem | null>(null);
114
+
115
+ function onDrop(event: GravitySlotDropEvent<unknown>) {
116
+ slottedItem.value = event.item as DemoItem;
117
+ if (event.source.containerId === 'slot-story-source-deny') {
118
+ sourceItem.value = null;
119
+ }
120
+ }
121
+
122
+ return { sourceItem, slottedItem, onDrop };
123
+ },
124
+ components: { GravityProvider, Draggable, Slot },
125
+ template: `
126
+ <div style="width: 460px; max-width: 96vw; font-family: system-ui, sans-serif;">
127
+ <GravityProvider>
128
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
129
+ <div style="min-height: 64px;">
130
+ <Draggable
131
+ v-if="sourceItem"
132
+ draggable-id="slot-story-deny-all"
133
+ :item="sourceItem"
134
+ source-id="slot-story-source-deny"
135
+ source-kind="custom"
136
+ :source-index="0"
137
+ >
138
+ <template #default="{ dragging }">
139
+ <div
140
+ :style="{
141
+ padding: '10px 12px',
142
+ borderRadius: '8px',
143
+ border: '1px solid #d1d5db',
144
+ background: '#f9fafb',
145
+ opacity: dragging ? .9 : 1
146
+ }"
147
+ >
148
+ {{ sourceItem.label }}
149
+ </div>
150
+ </template>
151
+ </Draggable>
152
+ <div
153
+ v-else
154
+ aria-hidden="true"
155
+ style="padding: 10px 12px; border-radius: 8px; border: 1px solid transparent; visibility: hidden;"
156
+ >
157
+ hidden
158
+ </div>
159
+ </div>
160
+
161
+ <Slot slot-id="slot-deny-all" :accepts="() => false" @drop="onDrop">
162
+ <template #default="{ hovering, accepting }">
163
+ <div
164
+ :style="{
165
+ minHeight: '64px',
166
+ borderRadius: '8px',
167
+ border: '1px solid #d1d5db',
168
+ display: 'flex',
169
+ alignItems: 'center',
170
+ justifyContent: 'center',
171
+ background: hovering ? (accepting ? '#ecfeff' : '#fff1f2') : '#fff'
172
+ }"
173
+ >
174
+ {{ slottedItem ? slottedItem.label : 'Always rejects drops' }}
175
+ </div>
176
+ </template>
177
+ </Slot>
178
+ </div>
179
+
180
+ </GravityProvider>
181
+ </div>
182
+ `,
183
+ }),
184
+ };
185
+
186
+ export const FilteredAccept: Story = {
187
+ render: () => ({
188
+ setup() {
189
+ const bronze = ref<DemoItem | null>({ id: 'bronze', label: 'Bronze Item', tier: 'bronze' });
190
+ const gold = ref<DemoItem | null>({ id: 'gold', label: 'Gold Item', tier: 'gold' });
191
+ const slottedItem = ref<DemoItem | null>(null);
192
+
193
+ function acceptsGold(item: unknown) {
194
+ return (item as DemoItem)?.tier === 'gold';
195
+ }
196
+ function onDrop(event: GravitySlotDropEvent<unknown>) {
197
+ slottedItem.value = event.item as DemoItem;
198
+ if (event.source.containerId === 'slot-story-filter-source') {
199
+ if (event.source.index === 0) bronze.value = null;
200
+ if (event.source.index === 1) gold.value = null;
201
+ }
202
+ }
203
+
204
+ return { bronze, gold, slottedItem, acceptsGold, onDrop };
205
+ },
206
+ components: { GravityProvider, Draggable, Slot },
207
+ template: `
208
+ <div style="width: 560px; max-width: 96vw; font-family: system-ui, sans-serif;">
209
+ <GravityProvider>
210
+ <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px;">
211
+ <div style="min-height: 64px;">
212
+ <Draggable
213
+ v-if="bronze"
214
+ draggable-id="slot-story-bronze"
215
+ :item="bronze"
216
+ source-id="slot-story-filter-source"
217
+ source-kind="custom"
218
+ :source-index="0"
219
+ >
220
+ <template #default="{ dragging }">
221
+ <div
222
+ :style="{
223
+ padding: '10px 12px',
224
+ borderRadius: '8px',
225
+ border: '1px solid #d1d5db',
226
+ background: '#f9fafb',
227
+ opacity: dragging ? .9 : 1
228
+ }"
229
+ >
230
+ {{ bronze.label }}
231
+ </div>
232
+ </template>
233
+ </Draggable>
234
+ <div
235
+ v-else
236
+ aria-hidden="true"
237
+ style="padding: 10px 12px; border-radius: 8px; border: 1px solid transparent; visibility: hidden;"
238
+ >
239
+ hidden
240
+ </div>
241
+ </div>
242
+
243
+ <div style="min-height: 64px;">
244
+ <Draggable
245
+ v-if="gold"
246
+ draggable-id="slot-story-gold"
247
+ :item="gold"
248
+ source-id="slot-story-filter-source"
249
+ source-kind="custom"
250
+ :source-index="1"
251
+ >
252
+ <template #default="{ dragging }">
253
+ <div
254
+ :style="{
255
+ padding: '10px 12px',
256
+ borderRadius: '8px',
257
+ border: '1px solid #d1d5db',
258
+ background: '#f9fafb',
259
+ opacity: dragging ? .9 : 1
260
+ }"
261
+ >
262
+ {{ gold.label }}
263
+ </div>
264
+ </template>
265
+ </Draggable>
266
+ <div
267
+ v-else
268
+ aria-hidden="true"
269
+ style="padding: 10px 12px; border-radius: 8px; border: 1px solid transparent; visibility: hidden;"
270
+ >
271
+ hidden
272
+ </div>
273
+ </div>
274
+
275
+ <Slot slot-id="slot-filtered" :accepts="acceptsGold" @drop="onDrop">
276
+ <template #default="{ hovering, accepting }">
277
+ <div
278
+ :style="{
279
+ minHeight: '64px',
280
+ borderRadius: '8px',
281
+ border: '1px solid #d1d5db',
282
+ display: 'flex',
283
+ alignItems: 'center',
284
+ justifyContent: 'center',
285
+ textAlign: 'center',
286
+ background: hovering ? (accepting ? '#ecfeff' : '#fff1f2') : '#fff'
287
+ }"
288
+ >
289
+ {{ slottedItem ? slottedItem.label : 'Only accepts gold tier' }}
290
+ </div>
291
+ </template>
292
+ </Slot>
293
+ </div>
294
+
295
+ </GravityProvider>
296
+ </div>
297
+ `,
298
+ }),
299
+ };
@@ -0,0 +1,15 @@
1
+ .st-gravity-slot {
2
+ border: 1px dashed #9ca3af;
3
+ }
4
+
5
+ .st-gravity-slot--accepting {
6
+ background: #ecfeff;
7
+ border-color: #22d3ee;
8
+ box-shadow: 0 0 0 2px rgb(34 211 238 / 20%);
9
+ }
10
+
11
+ .st-gravity-slot--rejecting {
12
+ background: #fef2f2;
13
+ border-color: #f87171;
14
+ box-shadow: 0 0 0 2px rgb(248 113 113 / 20%);
15
+ }
@@ -0,0 +1,126 @@
1
+ <template lang="pug">
2
+ .st-gravity-slot(
3
+ ref="rootEl"
4
+ :class="rootClasses"
5
+ )
6
+ slot(:hovering="hovering" :accepting="accepting")
7
+ </template>
8
+
9
+ <script setup lang="ts">
10
+ import { computed, onBeforeUnmount, ref, watch } from 'vue';
11
+ import { useDragDropContext } from '../Draggable/useDragDropContext';
12
+ import type { SlotDropEvent } from '../Draggable/contracts';
13
+ import './Slot.scss';
14
+
15
+ const props = withDefaults(
16
+ defineProps<{
17
+ slotId: string;
18
+ index?: number;
19
+ item?: unknown;
20
+ swap?: boolean;
21
+ onDropCollision?: 'replace' | 'swap' | 'reject';
22
+ disabled?: boolean;
23
+ accepts?: (item: unknown, payload: { sourceContainerId: string; sourceIndex: number }) => boolean;
24
+ }>(),
25
+ {
26
+ index: 0,
27
+ swap: true,
28
+ onDropCollision: undefined,
29
+ disabled: false,
30
+ accepts: undefined,
31
+ }
32
+ );
33
+
34
+ const emit = defineEmits<{
35
+ drop: [payload: SlotDropEvent<unknown>];
36
+ hoverChange: [payload: { slotId: string; hovering: boolean; accepts: boolean }];
37
+ }>();
38
+
39
+ const rootEl = ref<HTMLElement | null>(null);
40
+ const dragDrop = useDragDropContext();
41
+ let unregister: (() => void) | null = null;
42
+
43
+ const hovering = computed(() => {
44
+ if (!dragDrop) return false;
45
+ return dragDrop.isHovering(props.slotId, props.index);
46
+ });
47
+
48
+ const accepting = computed(() => {
49
+ if (!dragDrop?.dragState.hoverTarget) return true;
50
+ return dragDrop.dragState.hoverTarget.accepts;
51
+ });
52
+ const rootClasses = computed(() => ({
53
+ 'st-gravity-slot--hovering': hovering.value,
54
+ 'st-gravity-slot--accepting': hovering.value && accepting.value,
55
+ 'st-gravity-slot--rejecting': hovering.value && !accepting.value,
56
+ }));
57
+
58
+ function canAccept(item: unknown, sourceContainerId: string, sourceIndex: number) {
59
+ if (props.disabled) return false;
60
+
61
+ const collisionMode =
62
+ props.onDropCollision ?? (props.swap === false ? 'replace' : 'swap');
63
+
64
+ if (collisionMode === 'reject' && props.item != null) return false;
65
+
66
+ if (!props.accepts) return true;
67
+ return props.accepts(item, { sourceContainerId, sourceIndex });
68
+ }
69
+
70
+ function registerTarget() {
71
+ if (!dragDrop) return;
72
+ if (unregister) unregister();
73
+
74
+ unregister = dragDrop.registerTarget({
75
+ id: `slot:${props.slotId}`,
76
+ kind: 'slot',
77
+ containerId: props.slotId,
78
+ resolveHover(clientX, clientY) {
79
+ const el = rootEl.value;
80
+ if (!el || !dragDrop.dragState.item || !dragDrop.dragState.source) return null;
81
+ const rect = el.getBoundingClientRect();
82
+ const inside = clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom;
83
+ if (!inside) return null;
84
+ return {
85
+ index: props.index,
86
+ accepts: canAccept(dragDrop.dragState.item, dragDrop.dragState.source.containerId, dragDrop.dragState.source.index),
87
+ };
88
+ },
89
+ onDrop(payload) {
90
+ const collisionMode =
91
+ props.onDropCollision ?? (props.swap === false ? 'replace' : 'swap');
92
+ const replacedItem = props.item as unknown;
93
+
94
+ emit('drop', {
95
+ ...(payload as SlotDropEvent<unknown>),
96
+ slotId: props.slotId,
97
+ swap: collisionMode === 'swap',
98
+ collision: collisionMode,
99
+ replacedItem: replacedItem ?? undefined,
100
+ });
101
+ },
102
+ });
103
+ }
104
+
105
+ watch(
106
+ () => [props.slotId, props.index, props.disabled, props.onDropCollision, props.accepts],
107
+ () => registerTarget(),
108
+ { immediate: true }
109
+ );
110
+
111
+ watch(
112
+ () => hovering.value,
113
+ (isHovering) => {
114
+ emit('hoverChange', {
115
+ slotId: props.slotId,
116
+ hovering: isHovering,
117
+ accepts: accepting.value,
118
+ });
119
+ },
120
+ { immediate: true }
121
+ );
122
+
123
+ onBeforeUnmount(() => {
124
+ if (unregister) unregister();
125
+ });
126
+ </script>
package/src/styles.css ADDED
@@ -0,0 +1,15 @@
1
+ @import './components/DragAndDrop.visuals.css';
2
+ @import './components/DragAndDrop/DragAndDrop.visuals.css';
3
+ @import './components/Draggable/DragDropProvider.visuals.css';
4
+ @import './components/Draggable/Draggable.visuals.css';
5
+ @import './components/Draggable/DragPreviewOverlay.visuals.css';
6
+ @import './components/Pool/Pool.visuals.css';
7
+ @import './components/Slot/Slot.visuals.css';
8
+
9
+ :root {
10
+ --bg-border: #444;
11
+ }
12
+
13
+ body {
14
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
15
+ }