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.
- package/.eslintignore +3 -0
- package/.eslintrc.cjs +21 -0
- package/.storybook/main.ts +15 -0
- package/.storybook/preview.css +80 -0
- package/.storybook/preview.ts +15 -0
- package/LICENSE +373 -0
- package/README.md +292 -0
- package/index.ts +19 -0
- package/package.json +64 -0
- package/public/.gitkeep +0 -0
- package/src/Gravity.stories.ts +207 -0
- package/src/components/DragAndDrop/DragAndDrop.scss +4 -0
- package/src/components/DragAndDrop/DragAndDrop.stories.ts +787 -0
- package/src/components/DragAndDrop/DragAndDrop.visuals.css +1 -0
- package/src/components/DragAndDrop/DragAndDrop.vue +23 -0
- package/src/components/DragAndDrop.scss +4 -0
- package/src/components/DragAndDrop.visuals.css +1 -0
- package/src/components/DragAndDrop.vue +23 -0
- package/src/components/Draggable/DragDropProvider.scss +4 -0
- package/src/components/Draggable/DragDropProvider.visuals.css +1 -0
- package/src/components/Draggable/DragDropProvider.vue +11 -0
- package/src/components/Draggable/DragPreviewOverlay.scss +21 -0
- package/src/components/Draggable/DragPreviewOverlay.visuals.css +3 -0
- package/src/components/Draggable/DragPreviewOverlay.vue +41 -0
- package/src/components/Draggable/Draggable.scss +86 -0
- package/src/components/Draggable/Draggable.stories.ts +232 -0
- package/src/components/Draggable/Draggable.visuals.css +8 -0
- package/src/components/Draggable/Draggable.vue +292 -0
- package/src/components/Draggable/contracts.ts +82 -0
- package/src/components/Draggable/internalDropLayer.ts +126 -0
- package/src/components/Draggable/useDragDropContext.ts +310 -0
- package/src/components/Pool/Pool.scss +107 -0
- package/src/components/Pool/Pool.stories.ts +155 -0
- package/src/components/Pool/Pool.visuals.css +25 -0
- package/src/components/Pool/Pool.vue +198 -0
- package/src/components/Slot/Slot.scss +48 -0
- package/src/components/Slot/Slot.stories.ts +299 -0
- package/src/components/Slot/Slot.visuals.css +15 -0
- package/src/components/Slot/Slot.vue +126 -0
- package/src/styles.css +15 -0
- package/styles.css +1 -0
- package/styles.scss +6 -0
- package/tsconfig.json +18 -0
- 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
|
+
}
|