@leavepulse/ui 0.96.2 → 0.97.0

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.
@@ -4,7 +4,7 @@ import { useAppleKeyboard, appleModifierLabel } from "../composables/useHotkeys.
4
4
  import { useLabels } from "../composables/useLpConfig.js";
5
5
  import { _ as _sfc_main$3 } from "./LpActionBar-DuwBawji.js";
6
6
  import { _ as _sfc_main$2 } from "./LpHintLayer-rD3BTMfK.js";
7
- import { _ as _sfc_main$1 } from "./LpTour-iZNl_vdy.js";
7
+ import { _ as _sfc_main$1 } from "./LpTour-DUzpkD8W.js";
8
8
  const _sfc_main = /* @__PURE__ */ defineComponent({
9
9
  __name: "LpHints",
10
10
  props: {
@@ -149,6 +149,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
149
149
  clearTimeout(glide);
150
150
  glide = window.setTimeout(() => gliding.value = false, durationToken("duration-medium", void 0, doc()) + 40);
151
151
  }
152
+ let shownRaw = void 0;
152
153
  function measure() {
153
154
  viewport.value = { width: win().innerWidth, height: win().innerHeight };
154
155
  const el = target.value;
@@ -156,7 +157,11 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
156
157
  box.value = null;
157
158
  return;
158
159
  }
159
- if (!el.isConnected) return;
160
+ if (!el.isConnected) {
161
+ const again = resolve(shownRaw);
162
+ glideTo(again && again.getClientRects().length ? again : null);
163
+ return;
164
+ }
160
165
  const r = el.getBoundingClientRect();
161
166
  const prev = box.value;
162
167
  if (!prev || prev.x !== r.x || prev.y !== r.y || prev.width !== r.width || prev.height !== r.height) {
@@ -193,6 +198,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
193
198
  }
194
199
  const el = current.enter || sampled.value ? await waitFor(raw, mine) : resolve(raw);
195
200
  if (mine !== token) return;
201
+ shownRaw = raw;
196
202
  if (!el || !el.getClientRects().length) return skip();
197
203
  await settled(el);
198
204
  if (mine !== token) return;
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpHints-wsawHZCP.js";
1
+ import { _ as _sfc_main } from "../chunks/LpHints-lGLsGxOx.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpTour-iZNl_vdy.js";
1
+ import { _ as _sfc_main } from "../chunks/LpTour-DUzpkD8W.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -12,7 +12,12 @@ export declare function scrollParent(el: Element | null | undefined, { axis, sel
12
12
  */
13
13
  export declare function atScrollEnd(el: Element | null | undefined, slack?: number): boolean;
14
14
  export interface ScrollToElementOptions {
15
- /** `nearest` scrolls only when the element is out of view. */
15
+ /**
16
+ * `nearest` scrolls only when the element is out of view. An element taller
17
+ * than the scroller is brought in by its start whatever the block: centring
18
+ * or meeting its far edge would show its middle or end, and a list that
19
+ * loads more at its end would start loading.
20
+ */
16
21
  block?: "nearest" | "center" | "start";
17
22
  behavior?: ScrollBehavior;
18
23
  /** Room kept between the element and the scroller's edge. */
@@ -35,8 +35,10 @@ function scrollToElement(el, { block = "nearest", behavior = "auto", margin = 0,
35
35
  const lo = x ? inner.left - outer.left : inner.top - outer.top;
36
36
  const hi = x ? inner.right - outer.right : inner.bottom - outer.bottom;
37
37
  const at = lo + from;
38
+ const fits = size + 2 * margin <= view;
38
39
  let target = from;
39
40
  if (block === "start") target = at - margin;
41
+ else if (!fits) target = lo < margin || lo > view - margin || block === "center" ? at - margin : from;
40
42
  else if (block === "center") target = at - (view - size) / 2;
41
43
  else if (lo < margin) target = at - margin;
42
44
  else if (hi > -margin) target = at - view + size + margin;
@@ -0,0 +1,19 @@
1
+ import { type MaybeRefOrGetter, type Ref } from "vue";
2
+ export interface UseDragDropOptions<I, Z> {
3
+ /** The element that holds the items and the zones. */
4
+ root: Ref<HTMLElement | null>;
5
+ /** Turn dragging off without unmounting (e.g. read-only). */
6
+ enabled?: MaybeRefOrGetter<boolean>;
7
+ /** Map an item's `data-drag-key` to the item. Defaults to the string itself. */
8
+ item?: (raw: string) => I | undefined;
9
+ /** Map a zone's `data-drop-zone` to the zone. Defaults to the string itself. */
10
+ zone?: (raw: string) => Z | undefined;
11
+ /** Whether this item may land in this zone; a refused zone is not highlighted. */
12
+ canDrop?: (item: I, zone: Z) => boolean;
13
+ /** The item landed in a zone other than the one it came from. */
14
+ onDrop: (item: I, zone: Z, from: Z | undefined) => void;
15
+ }
16
+ export declare function useDragDrop<I = string, Z = string>(options: UseDragDropOptions<I, Z>): {
17
+ dragging: Readonly<Ref<import("vue").DeepReadonly<I> | null, import("vue").DeepReadonly<I> | null>>;
18
+ over: Readonly<Ref<import("vue").DeepReadonly<Z> | null, import("vue").DeepReadonly<Z> | null>>;
19
+ };
@@ -0,0 +1,241 @@
1
+ import { HOLD_TO_DRAG_MS, TOUCH_SLOP, MOUSE_SLOP } from "./gesture.js";
2
+ import { ref, onMounted, onBeforeUnmount, readonly, toValue } from "vue";
3
+ import { prefersReducedMotion, durationToken } from "./easing.js";
4
+ import { scrollParent } from "./scroll.js";
5
+ import { nodeWindow } from "./realm.js";
6
+ const EDGE_PX = 48;
7
+ const SCROLL_STEP = 14;
8
+ const NO_DRAG = "button, a, input, textarea, select, [contenteditable=''], [contenteditable='true'], [data-no-drag]";
9
+ function useDragDrop(options) {
10
+ const toItem = options.item ?? ((raw) => raw);
11
+ const toZone = options.zone ?? ((raw) => raw);
12
+ const enabled = () => toValue(options.enabled) ?? true;
13
+ const hostWindow = () => options.root.value ? nodeWindow(options.root.value) : window;
14
+ let drag = null;
15
+ let pending = null;
16
+ let swallowClick = false;
17
+ let frame = 0;
18
+ const dragging = ref(null);
19
+ const over = ref(null);
20
+ function zoneOf(el) {
21
+ const zone = el?.closest("[data-drop-zone]") ?? null;
22
+ return zone && options.root.value?.contains(zone) ? zone : null;
23
+ }
24
+ function accepts(zoneEl) {
25
+ if (!zoneEl || !drag) return void 0;
26
+ const zone = toZone(zoneEl.dataset.dropZone ?? "");
27
+ if (zone === void 0 || zone === drag.from) return void 0;
28
+ return !options.canDrop || options.canDrop(drag.item, zone) ? zone : void 0;
29
+ }
30
+ function cancelPending() {
31
+ if (pending?.timer) clearTimeout(pending.timer);
32
+ pending = null;
33
+ }
34
+ function begin(el, x, y) {
35
+ const item = toItem(el.dataset.dragKey ?? "");
36
+ if (item === void 0) return;
37
+ const box = el.getBoundingClientRect();
38
+ const ghost = el.cloneNode(true);
39
+ ghost.removeAttribute("data-drag-key");
40
+ ghost.removeAttribute("id");
41
+ ghost.setAttribute("aria-hidden", "true");
42
+ Object.assign(ghost.style, {
43
+ position: "fixed",
44
+ left: `${box.left}px`,
45
+ top: `${box.top}px`,
46
+ width: `${box.width}px`,
47
+ height: `${box.height}px`,
48
+ margin: "0",
49
+ pointerEvents: "none",
50
+ zIndex: "var(--z-tooltip)",
51
+ boxShadow: "var(--shadow-panel)",
52
+ opacity: "0.95",
53
+ transform: "translate3d(0, 0, 0) scale(1.02)",
54
+ transition: "none"
55
+ });
56
+ el.ownerDocument.body.append(ghost);
57
+ const fromZone = zoneOf(el);
58
+ drag = {
59
+ item,
60
+ from: fromZone ? toZone(fromZone.dataset.dropZone ?? "") : void 0,
61
+ source: el,
62
+ ghost,
63
+ dx: x - box.left,
64
+ dy: y - box.top,
65
+ x,
66
+ y,
67
+ over: null,
68
+ scroller: scrollParent(options.root.value)
69
+ };
70
+ el.setAttribute("data-drag-source", "");
71
+ dragging.value = item;
72
+ hostWindow().getSelection()?.removeAllRanges();
73
+ if (options.root.value) options.root.value.style.userSelect = "none";
74
+ follow();
75
+ if (!frame) frame = requestAnimationFrame(tick);
76
+ }
77
+ function follow() {
78
+ if (!drag) return;
79
+ const box = drag.source.getBoundingClientRect();
80
+ drag.ghost.style.left = `${drag.x - drag.dx}px`;
81
+ drag.ghost.style.top = `${drag.y - drag.dy}px`;
82
+ drag.ghost.style.width = `${box.width}px`;
83
+ const zoneEl = zoneOf(drag.source.ownerDocument.elementFromPoint(drag.x, drag.y));
84
+ const zone = accepts(zoneEl);
85
+ const target = zone === void 0 ? null : zoneEl;
86
+ if (target !== drag.over) {
87
+ drag.over?.removeAttribute("data-drop-over");
88
+ target?.setAttribute("data-drop-over", "");
89
+ drag.over = target;
90
+ over.value = zone ?? null;
91
+ }
92
+ }
93
+ function tick() {
94
+ frame = 0;
95
+ if (!drag) return;
96
+ const box = drag.scroller?.getBoundingClientRect() ?? { top: 0, bottom: hostWindow().innerHeight };
97
+ const delta = drag.y > box.bottom - EDGE_PX ? SCROLL_STEP : drag.y < box.top + EDGE_PX ? -SCROLL_STEP : 0;
98
+ if (delta) {
99
+ if (drag.scroller) drag.scroller.scrollTop += delta;
100
+ else hostWindow().scrollBy(0, delta);
101
+ follow();
102
+ }
103
+ frame = requestAnimationFrame(tick);
104
+ }
105
+ function finish(drop) {
106
+ cancelPending();
107
+ const done = drag;
108
+ if (!done) return;
109
+ drag = null;
110
+ if (frame) cancelAnimationFrame(frame);
111
+ frame = 0;
112
+ if (options.root.value) options.root.value.style.userSelect = "";
113
+ done.over?.removeAttribute("data-drop-over");
114
+ done.source.removeAttribute("data-drag-source");
115
+ dragging.value = null;
116
+ over.value = null;
117
+ swallowClick = true;
118
+ setTimeout(() => swallowClick = false, 0);
119
+ const zone = drop && done.over ? toZone(done.over.dataset.dropZone ?? "") : void 0;
120
+ if (zone !== void 0) {
121
+ done.ghost.remove();
122
+ options.onDrop(done.item, zone, done.from);
123
+ return;
124
+ }
125
+ settle(done);
126
+ }
127
+ function settle(done) {
128
+ if (prefersReducedMotion(hostWindow()) || !done.source.isConnected) {
129
+ done.ghost.remove();
130
+ return;
131
+ }
132
+ const home = done.source.getBoundingClientRect();
133
+ const ghost = done.ghost;
134
+ const ms = durationToken("duration-medium", void 0, done.source.ownerDocument);
135
+ ghost.style.transition = `left ${ms}ms var(--ease-emphasized), top ${ms}ms var(--ease-emphasized), opacity ${ms}ms ease-out`;
136
+ ghost.style.left = `${home.left}px`;
137
+ ghost.style.top = `${home.top}px`;
138
+ ghost.style.opacity = "0";
139
+ setTimeout(() => ghost.remove(), ms);
140
+ }
141
+ function onPointerDown(event) {
142
+ if (!enabled() || event.button !== 0 || drag) return;
143
+ const target = event.target;
144
+ const el = target?.closest("[data-drag-key]");
145
+ if (!el || !options.root.value?.contains(el)) return;
146
+ const control = target?.closest(NO_DRAG);
147
+ if (control && el.contains(control)) return;
148
+ cancelPending();
149
+ const touch = event.pointerType === "touch";
150
+ pending = { el, pointerId: event.pointerId, x: event.clientX, y: event.clientY, touch, timer: null };
151
+ if (!touch) return;
152
+ const { clientX: x, clientY: y } = event;
153
+ pending.timer = setTimeout(() => {
154
+ if (!pending) return;
155
+ const held = pending.el;
156
+ pending = null;
157
+ begin(held, x, y);
158
+ navigator.vibrate?.(8);
159
+ }, HOLD_TO_DRAG_MS);
160
+ }
161
+ function onMove(event) {
162
+ if (pending && event.pointerId === pending.pointerId) {
163
+ const moved = Math.hypot(event.clientX - pending.x, event.clientY - pending.y);
164
+ if (pending.touch) {
165
+ if (moved > TOUCH_SLOP) cancelPending();
166
+ return;
167
+ }
168
+ if (moved <= MOUSE_SLOP) return;
169
+ const { el, x, y } = pending;
170
+ pending = null;
171
+ begin(el, x, y);
172
+ }
173
+ if (!drag) return;
174
+ drag.x = event.clientX;
175
+ drag.y = event.clientY;
176
+ follow();
177
+ }
178
+ function onUp() {
179
+ cancelPending();
180
+ finish(true);
181
+ }
182
+ function onCancel() {
183
+ cancelPending();
184
+ finish(false);
185
+ }
186
+ function onKey(event) {
187
+ if (event.key !== "Escape" || !drag) return;
188
+ event.preventDefault();
189
+ event.stopPropagation();
190
+ finish(false);
191
+ }
192
+ function onClickCapture(event) {
193
+ if (!swallowClick) return;
194
+ swallowClick = false;
195
+ event.stopPropagation();
196
+ event.preventDefault();
197
+ }
198
+ function onContextMenu(event) {
199
+ if (!drag && !swallowClick) return;
200
+ event.preventDefault();
201
+ event.stopPropagation();
202
+ }
203
+ function onTouchMove(event) {
204
+ if (drag) event.preventDefault();
205
+ }
206
+ function onNativeDrag(event) {
207
+ if (pending || drag) event.preventDefault();
208
+ }
209
+ let listening = null;
210
+ let rootEl = null;
211
+ onMounted(() => {
212
+ listening = hostWindow();
213
+ rootEl = options.root.value;
214
+ rootEl?.addEventListener("pointerdown", onPointerDown);
215
+ rootEl?.addEventListener("touchmove", onTouchMove, { passive: false });
216
+ rootEl?.addEventListener("dragstart", onNativeDrag);
217
+ listening.addEventListener("pointermove", onMove);
218
+ listening.addEventListener("pointerup", onUp);
219
+ listening.addEventListener("pointercancel", onCancel);
220
+ listening.addEventListener("keydown", onKey, true);
221
+ listening.addEventListener("click", onClickCapture, true);
222
+ listening.addEventListener("contextmenu", onContextMenu, true);
223
+ });
224
+ onBeforeUnmount(() => {
225
+ finish(false);
226
+ swallowClick = false;
227
+ rootEl?.removeEventListener("pointerdown", onPointerDown);
228
+ rootEl?.removeEventListener("touchmove", onTouchMove);
229
+ rootEl?.removeEventListener("dragstart", onNativeDrag);
230
+ listening?.removeEventListener("pointermove", onMove);
231
+ listening?.removeEventListener("pointerup", onUp);
232
+ listening?.removeEventListener("pointercancel", onCancel);
233
+ listening?.removeEventListener("keydown", onKey, true);
234
+ listening?.removeEventListener("click", onClickCapture, true);
235
+ listening?.removeEventListener("contextmenu", onContextMenu, true);
236
+ });
237
+ return { dragging: readonly(dragging), over: readonly(over) };
238
+ }
239
+ export {
240
+ useDragDrop
241
+ };
package/dist/index.d.ts CHANGED
@@ -297,6 +297,8 @@ export type { PersistedOptions } from "./composables/usePersisted";
297
297
  export { useMergedClass, useMergedAttrs, mergeClass } from "./composables/useMergedClass";
298
298
  export { anchorPattern, stripDisallowed, useInputFilter } from "./composables/useInputFilter";
299
299
  export { usePaintSelect } from "./composables/usePaintSelect";
300
+ export { useDragDrop } from "./composables/useDragDrop";
301
+ export type { UseDragDropOptions } from "./composables/useDragDrop";
300
302
  export type { UsePaintSelectOptions } from "./composables/usePaintSelect";
301
303
  export { LP_FIELD, fieldMessageId, provideField, useField, useFieldControl } from "./composables/useField";
302
304
  export { FIELD_NOTICE_MS, useFieldNotice } from "./composables/useFieldNotice";
package/dist/index.js CHANGED
@@ -127,10 +127,10 @@ import { _ as _83 } from "./chunks/LpThemeSwitcher-CnkIH76z.js";
127
127
  import { _ as _84 } from "./chunks/LpTilt-B7KHX1zH.js";
128
128
  import { _ as _85 } from "./chunks/LpToaster-CGHXxCie.js";
129
129
  import { _ as _86 } from "./chunks/LpTooltip-CzIfznts.js";
130
- import { _ as _87 } from "./chunks/LpTour-iZNl_vdy.js";
130
+ import { _ as _87 } from "./chunks/LpTour-DUzpkD8W.js";
131
131
  import { holeOf, openContextMenu, panelsAround } from "./components/tour.js";
132
132
  import { _ as _88 } from "./chunks/LpHintLayer-rD3BTMfK.js";
133
- import { _ as _89 } from "./chunks/LpHints-wsawHZCP.js";
133
+ import { _ as _89 } from "./chunks/LpHints-lGLsGxOx.js";
134
134
  import { HINT_SIGNALS, contextMenuTip, createHintSystem, dropzoneTip, hintKey, hintSummary, hotkeyTip, localHintStorage, noteDropzone, noteHotkeyTarget, paletteTip, provideHintSystem, runningHintSystem, useHintSystem } from "./composables/useHintSystem.js";
135
135
  import { _ as _90 } from "./chunks/LpTooltipProvider-CftjV7Xc.js";
136
136
  import { _ as _91 } from "./chunks/LpTopologyCanvas-DKn0Xcz4.js";
@@ -166,6 +166,7 @@ import { useOptimisticToggle } from "./composables/useOptimisticToggle.js";
166
166
  import { mergeClass, useMergedAttrs, useMergedClass } from "./composables/useMergedClass.js";
167
167
  import { anchorPattern, stripDisallowed, useInputFilter } from "./composables/useInputFilter.js";
168
168
  import { usePaintSelect } from "./composables/usePaintSelect.js";
169
+ import { useDragDrop } from "./composables/useDragDrop.js";
169
170
  import { LP_FIELD, fieldMessageId, provideField, useField, useFieldControl } from "./composables/useField.js";
170
171
  import { FIELD_NOTICE_MS, useFieldNotice } from "./composables/useFieldNotice.js";
171
172
  import { easeOut, easeTravel, easingToken, prefersReducedMotion } from "./composables/easing.js";
@@ -1316,6 +1317,7 @@ export {
1316
1317
  useCloseGuard,
1317
1318
  useCommands,
1318
1319
  useConfirm,
1320
+ useDragDrop,
1319
1321
  useField,
1320
1322
  useFieldControl,
1321
1323
  useFieldNotice,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.96.2",
3
+ "version": "0.97.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -256,6 +256,11 @@ function glideTo(el: Element | null) {
256
256
  glide = window.setTimeout(() => (gliding.value = false), durationToken("duration-medium", undefined, doc()) + 40)
257
257
  }
258
258
 
259
+ // What the shown step points at, kept to find it again: a list that re-renders
260
+ // replaces the element, and a card anchored to the old one would hang over
261
+ // nothing (or nowhere) with the page dimmed under it.
262
+ let shownRaw: TourStep["target"] = undefined
263
+
259
264
  function measure() {
260
265
  viewport.value = { width: win().innerWidth, height: win().innerHeight }
261
266
  const el = target.value
@@ -263,7 +268,11 @@ function measure() {
263
268
  box.value = null
264
269
  return
265
270
  }
266
- if (!el.isConnected) return
271
+ if (!el.isConnected) {
272
+ const again = resolve(shownRaw)
273
+ glideTo(again && again.getClientRects().length ? again : null)
274
+ return
275
+ }
267
276
  const r = el.getBoundingClientRect()
268
277
  const prev = box.value
269
278
  if (!prev || prev.x !== r.x || prev.y !== r.y || prev.width !== r.width || prev.height !== r.height) {
@@ -302,6 +311,7 @@ async function show(): Promise<void> {
302
311
  }
303
312
  const el = current.enter || sampled.value ? await waitFor(raw, mine) : resolve(raw)
304
313
  if (mine !== token) return
314
+ shownRaw = raw
305
315
  if (!el || !el.getClientRects().length) return skip()
306
316
  await settled(el)
307
317
  if (mine !== token) return
@@ -40,7 +40,12 @@ export function atScrollEnd(el: Element | null | undefined, slack = 2): boolean
40
40
  }
41
41
 
42
42
  export interface ScrollToElementOptions {
43
- /** `nearest` scrolls only when the element is out of view. */
43
+ /**
44
+ * `nearest` scrolls only when the element is out of view. An element taller
45
+ * than the scroller is brought in by its start whatever the block: centring
46
+ * or meeting its far edge would show its middle or end, and a list that
47
+ * loads more at its end would start loading.
48
+ */
44
49
  block?: "nearest" | "center" | "start"
45
50
  behavior?: ScrollBehavior
46
51
  /** Room kept between the element and the scroller's edge. */
@@ -76,8 +81,10 @@ export function scrollToElement(
76
81
  const lo = x ? inner.left - outer.left : inner.top - outer.top
77
82
  const hi = x ? inner.right - outer.right : inner.bottom - outer.bottom
78
83
  const at = lo + from
84
+ const fits = size + 2 * margin <= view
79
85
  let target = from
80
86
  if (block === "start") target = at - margin
87
+ else if (!fits) target = lo < margin || lo > view - margin || block === "center" ? at - margin : from
81
88
  else if (block === "center") target = at - (view - size) / 2
82
89
  else if (lo < margin) target = at - margin
83
90
  else if (hi > -margin) target = at - view + size + margin
@@ -0,0 +1,331 @@
1
+ import { HOLD_TO_DRAG_MS, MOUSE_SLOP, TOUCH_SLOP } from "./gesture"
2
+ import { onBeforeUnmount, onMounted, readonly, ref, toValue, type MaybeRefOrGetter, type Ref } from "vue"
3
+ import { durationToken, prefersReducedMotion } from "./easing"
4
+ import { scrollParent } from "./scroll"
5
+ import { nodeWindow } from "./useView"
6
+
7
+ /*
8
+ * Moving things between places by dragging them: a person from one team to
9
+ * another, a card to another column, a row back to the pool. The page marks
10
+ * what can be picked up with `data-drag-key` and where it can land with
11
+ * `data-drop-zone`; this composable does the rest and reports a drop as
12
+ * (item, zone, from).
13
+ *
14
+ * const dnd = useDragDrop({ root, onDrop: (item, zone) => move(item, zone) })
15
+ * <div ref="root">
16
+ * <section data-drop-zone="team-a"><div data-drag-key="u1">Ada</div></section>
17
+ * <section data-drop-zone="pool" />
18
+ * </div>
19
+ *
20
+ * A mouse or pen starts dragging once it moves a few pixels, so a click on the
21
+ * item stays a click. A finger holds still for HOLD_TO_DRAG_MS first, so a
22
+ * swipe through the list still scrolls it, and the long press does not open
23
+ * the context menu once it has turned into a drag. Controls inside an item
24
+ * (buttons, links, fields, `data-no-drag`) never start one.
25
+ *
26
+ * While dragging, a copy of the item follows the pointer, the item itself
27
+ * carries `data-drag-source` and the zone under the pointer `data-drop-over`,
28
+ * for styling (`data-[drop-over]:border-brand`). Near the edge of the
29
+ * scrolling box the page scrolls on its own. A drop on the zone the item came
30
+ * from, on a zone `canDrop` refuses or outside every zone is no drop: the copy
31
+ * glides back. Escape cancels.
32
+ *
33
+ * Dragging is a shortcut, never the only way: whatever a drop does should also
34
+ * be reachable from a menu or a button, for the keyboard and screen readers.
35
+ */
36
+ export interface UseDragDropOptions<I, Z> {
37
+ /** The element that holds the items and the zones. */
38
+ root: Ref<HTMLElement | null>
39
+ /** Turn dragging off without unmounting (e.g. read-only). */
40
+ enabled?: MaybeRefOrGetter<boolean>
41
+ /** Map an item's `data-drag-key` to the item. Defaults to the string itself. */
42
+ item?: (raw: string) => I | undefined
43
+ /** Map a zone's `data-drop-zone` to the zone. Defaults to the string itself. */
44
+ zone?: (raw: string) => Z | undefined
45
+ /** Whether this item may land in this zone; a refused zone is not highlighted. */
46
+ canDrop?: (item: I, zone: Z) => boolean
47
+ /** The item landed in a zone other than the one it came from. */
48
+ onDrop: (item: I, zone: Z, from: Z | undefined) => void
49
+ }
50
+
51
+ const EDGE_PX = 48
52
+ const SCROLL_STEP = 14
53
+ const NO_DRAG = "button, a, input, textarea, select, [contenteditable=''], [contenteditable='true'], [data-no-drag]"
54
+
55
+ interface Drag<I, Z> {
56
+ item: I
57
+ from: Z | undefined
58
+ source: HTMLElement
59
+ ghost: HTMLElement
60
+ dx: number
61
+ dy: number
62
+ x: number
63
+ y: number
64
+ over: HTMLElement | null
65
+ scroller: HTMLElement | null
66
+ }
67
+
68
+ interface Pending {
69
+ el: HTMLElement
70
+ pointerId: number
71
+ x: number
72
+ y: number
73
+ touch: boolean
74
+ timer: ReturnType<typeof setTimeout> | null
75
+ }
76
+
77
+ export function useDragDrop<I = string, Z = string>(options: UseDragDropOptions<I, Z>) {
78
+ const toItem = options.item ?? ((raw: string) => raw as unknown as I)
79
+ const toZone = options.zone ?? ((raw: string) => raw as unknown as Z)
80
+ const enabled = () => toValue(options.enabled) ?? true
81
+ const hostWindow = (): Window => (options.root.value ? nodeWindow(options.root.value) : window)
82
+
83
+ let drag: Drag<I, Z> | null = null
84
+ let pending: Pending | null = null
85
+ let swallowClick = false
86
+ let frame = 0
87
+ const dragging = ref<I | null>(null) as Ref<I | null>
88
+ const over = ref<Z | null>(null) as Ref<Z | null>
89
+
90
+ function zoneOf(el: Element | null): HTMLElement | null {
91
+ const zone = el?.closest<HTMLElement>("[data-drop-zone]") ?? null
92
+ return zone && options.root.value?.contains(zone) ? zone : null
93
+ }
94
+
95
+ function accepts(zoneEl: HTMLElement | null): Z | undefined {
96
+ if (!zoneEl || !drag) return undefined
97
+ const zone = toZone(zoneEl.dataset.dropZone ?? "")
98
+ if (zone === undefined || zone === drag.from) return undefined
99
+ return !options.canDrop || options.canDrop(drag.item, zone) ? zone : undefined
100
+ }
101
+
102
+ function cancelPending() {
103
+ if (pending?.timer) clearTimeout(pending.timer)
104
+ pending = null
105
+ }
106
+
107
+ function begin(el: HTMLElement, x: number, y: number) {
108
+ const item = toItem(el.dataset.dragKey ?? "")
109
+ if (item === undefined) return
110
+ const box = el.getBoundingClientRect()
111
+ const ghost = el.cloneNode(true) as HTMLElement
112
+ ghost.removeAttribute("data-drag-key")
113
+ ghost.removeAttribute("id")
114
+ ghost.setAttribute("aria-hidden", "true")
115
+ Object.assign(ghost.style, {
116
+ position: "fixed",
117
+ left: `${box.left}px`,
118
+ top: `${box.top}px`,
119
+ width: `${box.width}px`,
120
+ height: `${box.height}px`,
121
+ margin: "0",
122
+ pointerEvents: "none",
123
+ zIndex: "var(--z-tooltip)",
124
+ boxShadow: "var(--shadow-panel)",
125
+ opacity: "0.95",
126
+ transform: "translate3d(0, 0, 0) scale(1.02)",
127
+ transition: "none",
128
+ })
129
+ el.ownerDocument.body.append(ghost)
130
+ const fromZone = zoneOf(el)
131
+ drag = {
132
+ item,
133
+ from: fromZone ? toZone(fromZone.dataset.dropZone ?? "") : undefined,
134
+ source: el,
135
+ ghost,
136
+ dx: x - box.left,
137
+ dy: y - box.top,
138
+ x,
139
+ y,
140
+ over: null,
141
+ scroller: scrollParent(options.root.value),
142
+ }
143
+ el.setAttribute("data-drag-source", "")
144
+ dragging.value = item
145
+ hostWindow().getSelection()?.removeAllRanges()
146
+ if (options.root.value) options.root.value.style.userSelect = "none"
147
+ follow()
148
+ if (!frame) frame = requestAnimationFrame(tick)
149
+ }
150
+
151
+ function follow() {
152
+ if (!drag) return
153
+ const box = drag.source.getBoundingClientRect()
154
+ drag.ghost.style.left = `${drag.x - drag.dx}px`
155
+ drag.ghost.style.top = `${drag.y - drag.dy}px`
156
+ drag.ghost.style.width = `${box.width}px`
157
+ const zoneEl = zoneOf(drag.source.ownerDocument.elementFromPoint(drag.x, drag.y))
158
+ const zone = accepts(zoneEl)
159
+ const target = zone === undefined ? null : zoneEl
160
+ if (target !== drag.over) {
161
+ drag.over?.removeAttribute("data-drop-over")
162
+ target?.setAttribute("data-drop-over", "")
163
+ drag.over = target
164
+ over.value = zone ?? null
165
+ }
166
+ }
167
+
168
+ function tick() {
169
+ frame = 0
170
+ if (!drag) return
171
+ const box = drag.scroller?.getBoundingClientRect() ?? { top: 0, bottom: hostWindow().innerHeight }
172
+ const delta = drag.y > box.bottom - EDGE_PX ? SCROLL_STEP : drag.y < box.top + EDGE_PX ? -SCROLL_STEP : 0
173
+ if (delta) {
174
+ if (drag.scroller) drag.scroller.scrollTop += delta
175
+ else hostWindow().scrollBy(0, delta)
176
+ follow()
177
+ }
178
+ frame = requestAnimationFrame(tick)
179
+ }
180
+
181
+ function finish(drop: boolean) {
182
+ cancelPending()
183
+ const done = drag
184
+ if (!done) return
185
+ drag = null
186
+ if (frame) cancelAnimationFrame(frame)
187
+ frame = 0
188
+ if (options.root.value) options.root.value.style.userSelect = ""
189
+ done.over?.removeAttribute("data-drop-over")
190
+ done.source.removeAttribute("data-drag-source")
191
+ dragging.value = null
192
+ over.value = null
193
+ swallowClick = true
194
+ setTimeout(() => (swallowClick = false), 0)
195
+ const zone = drop && done.over ? toZone(done.over.dataset.dropZone ?? "") : undefined
196
+ if (zone !== undefined) {
197
+ done.ghost.remove()
198
+ options.onDrop(done.item, zone, done.from)
199
+ return
200
+ }
201
+ settle(done)
202
+ }
203
+
204
+ // A drag that landed nowhere glides back to where the item still is.
205
+ function settle(done: Drag<I, Z>) {
206
+ if (prefersReducedMotion(hostWindow()) || !done.source.isConnected) {
207
+ done.ghost.remove()
208
+ return
209
+ }
210
+ const home = done.source.getBoundingClientRect()
211
+ const ghost = done.ghost
212
+ const ms = durationToken("duration-medium", undefined, done.source.ownerDocument)
213
+ ghost.style.transition = `left ${ms}ms var(--ease-emphasized), top ${ms}ms var(--ease-emphasized), opacity ${ms}ms ease-out`
214
+ ghost.style.left = `${home.left}px`
215
+ ghost.style.top = `${home.top}px`
216
+ ghost.style.opacity = "0"
217
+ setTimeout(() => ghost.remove(), ms)
218
+ }
219
+
220
+ function onPointerDown(event: PointerEvent) {
221
+ if (!enabled() || event.button !== 0 || drag) return
222
+ const target = event.target as Element | null
223
+ const el = target?.closest<HTMLElement>("[data-drag-key]")
224
+ if (!el || !options.root.value?.contains(el)) return
225
+ const control = target?.closest(NO_DRAG)
226
+ if (control && el.contains(control)) return
227
+ cancelPending()
228
+ const touch = event.pointerType === "touch"
229
+ pending = { el, pointerId: event.pointerId, x: event.clientX, y: event.clientY, touch, timer: null }
230
+ if (!touch) return
231
+ const { clientX: x, clientY: y } = event
232
+ pending.timer = setTimeout(() => {
233
+ if (!pending) return
234
+ const held = pending.el
235
+ pending = null
236
+ begin(held, x, y)
237
+ navigator.vibrate?.(8)
238
+ }, HOLD_TO_DRAG_MS)
239
+ }
240
+
241
+ function onMove(event: PointerEvent) {
242
+ if (pending && event.pointerId === pending.pointerId) {
243
+ const moved = Math.hypot(event.clientX - pending.x, event.clientY - pending.y)
244
+ if (pending.touch) {
245
+ if (moved > TOUCH_SLOP) cancelPending()
246
+ return
247
+ }
248
+ if (moved <= MOUSE_SLOP) return
249
+ const { el, x, y } = pending
250
+ pending = null
251
+ begin(el, x, y)
252
+ }
253
+ if (!drag) return
254
+ drag.x = event.clientX
255
+ drag.y = event.clientY
256
+ follow()
257
+ }
258
+
259
+ function onUp() {
260
+ cancelPending()
261
+ finish(true)
262
+ }
263
+
264
+ function onCancel() {
265
+ cancelPending()
266
+ finish(false)
267
+ }
268
+
269
+ function onKey(event: KeyboardEvent) {
270
+ if (event.key !== "Escape" || !drag) return
271
+ event.preventDefault()
272
+ event.stopPropagation()
273
+ finish(false)
274
+ }
275
+
276
+ function onClickCapture(event: MouseEvent) {
277
+ if (!swallowClick) return
278
+ swallowClick = false
279
+ event.stopPropagation()
280
+ event.preventDefault()
281
+ }
282
+
283
+ // A long press is also how a finger asks for the context menu; once it has
284
+ // turned into a drag, that menu would open over it.
285
+ function onContextMenu(event: Event) {
286
+ if (!drag && !swallowClick) return
287
+ event.preventDefault()
288
+ event.stopPropagation()
289
+ }
290
+
291
+ // A finger that has started dragging must not scroll the page under it.
292
+ function onTouchMove(event: TouchEvent) {
293
+ if (drag) event.preventDefault()
294
+ }
295
+
296
+ // The browser's own drag of a link or an image would take over the pointer.
297
+ function onNativeDrag(event: DragEvent) {
298
+ if (pending || drag) event.preventDefault()
299
+ }
300
+
301
+ let listening: Window | null = null
302
+ let rootEl: HTMLElement | null = null
303
+ onMounted(() => {
304
+ listening = hostWindow()
305
+ rootEl = options.root.value
306
+ rootEl?.addEventListener("pointerdown", onPointerDown)
307
+ rootEl?.addEventListener("touchmove", onTouchMove, { passive: false })
308
+ rootEl?.addEventListener("dragstart", onNativeDrag)
309
+ listening.addEventListener("pointermove", onMove)
310
+ listening.addEventListener("pointerup", onUp)
311
+ listening.addEventListener("pointercancel", onCancel)
312
+ listening.addEventListener("keydown", onKey, true)
313
+ listening.addEventListener("click", onClickCapture, true)
314
+ listening.addEventListener("contextmenu", onContextMenu, true)
315
+ })
316
+ onBeforeUnmount(() => {
317
+ finish(false)
318
+ swallowClick = false
319
+ rootEl?.removeEventListener("pointerdown", onPointerDown)
320
+ rootEl?.removeEventListener("touchmove", onTouchMove)
321
+ rootEl?.removeEventListener("dragstart", onNativeDrag)
322
+ listening?.removeEventListener("pointermove", onMove)
323
+ listening?.removeEventListener("pointerup", onUp)
324
+ listening?.removeEventListener("pointercancel", onCancel)
325
+ listening?.removeEventListener("keydown", onKey, true)
326
+ listening?.removeEventListener("click", onClickCapture, true)
327
+ listening?.removeEventListener("contextmenu", onContextMenu, true)
328
+ })
329
+
330
+ return { dragging: readonly(dragging), over: readonly(over) }
331
+ }
package/src/index.ts CHANGED
@@ -459,6 +459,8 @@ export type { PersistedOptions } from "./composables/usePersisted"
459
459
  export { useMergedClass, useMergedAttrs, mergeClass } from "./composables/useMergedClass"
460
460
  export { anchorPattern, stripDisallowed, useInputFilter } from "./composables/useInputFilter"
461
461
  export { usePaintSelect } from "./composables/usePaintSelect"
462
+ export { useDragDrop } from "./composables/useDragDrop"
463
+ export type { UseDragDropOptions } from "./composables/useDragDrop"
462
464
  export type { UsePaintSelectOptions } from "./composables/usePaintSelect"
463
465
  export { LP_FIELD, fieldMessageId, provideField, useField, useFieldControl } from "./composables/useField"
464
466
  export { FIELD_NOTICE_MS, useFieldNotice } from "./composables/useFieldNotice"