@jsenv/dom 0.10.5 → 0.11.1

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 (2) hide show
  1. package/dist/jsenv_dom.js +778 -182
  2. package/package.json +3 -2
package/dist/jsenv_dom.js CHANGED
@@ -141,6 +141,81 @@ const looksLikeGeneratedId = (id) => {
141
141
  return /^[A-Z][0-9]+-[0-9]+$|^:[a-z][0-9]*:$/.test(id);
142
142
  };
143
143
 
144
+ /**
145
+ * Navi uses three categories of custom events:
146
+ *
147
+ * 1. **Internal events** (`dispatchInternalCustomEvent`) — a component communicates
148
+ * with other navi components internally. Not meant to be observed from outside.
149
+ * They do not bubble so they stay contained within the subtree that handles them.
150
+ * Names often reflect their internal nature (e.g. `navi_pseudo_state_request_check`).
151
+ *
152
+ * 2. **Public events** (`dispatchPublicCustomEvent`) — a component exposes information
153
+ * about something that happened (e.g. `navi_list_select`). They bubble so any
154
+ * ancestor can observe them. These are part of the public API and should be documented.
155
+ *
156
+ * 3. **Request events** (`dispatchCustomEvent`) — code *outside* a component asks it
157
+ * to perform an action (e.g. `navi_list_request_open`). They are cancelable so the
158
+ * component can signal whether it handled the request. Names are prefixed
159
+ * with `request_` by convention.
160
+ */
161
+
162
+ /**
163
+ * Dispatches an internal event on `el`.
164
+ * Does not bubble — stays within the local subtree.
165
+ */
166
+ const dispatchInternalCustomEvent = (
167
+ el,
168
+ customEventName,
169
+ customEventDetail,
170
+ ) => {
171
+ const customEvent = new CustomEvent(customEventName, {
172
+ detail: customEventDetail,
173
+ });
174
+ return el.dispatchEvent(customEvent);
175
+ };
176
+
177
+ /**
178
+ * Dispatches a public event from `el`, announcing something that happened.
179
+ * Bubbles so any ancestor can observe it.
180
+ */
181
+ const dispatchPublicCustomEvent = (
182
+ el,
183
+ customEventName,
184
+ customEventDetail,
185
+ ) => {
186
+ const customEvent = new CustomEvent(customEventName, {
187
+ detail: resolveEventDetail(customEventDetail),
188
+ bubbles: true,
189
+ });
190
+ return el.dispatchEvent(customEvent);
191
+ };
192
+
193
+ /**
194
+ * Dispatches a request event *at* `el`, asking it to perform an action.
195
+ * Cancelable — returns `false` if the component called `preventDefault()`,
196
+ * indicating it did not (or could not) handle the request.
197
+ * Names are conventionally prefixed with `request_` (e.g. `navi_list_request_open`).
198
+ */
199
+ const dispatchCustomEvent = (el, customEventName, customEventDetail) => {
200
+ const customEvent = new CustomEvent(customEventName, {
201
+ detail: resolveEventDetail(customEventDetail),
202
+ cancelable: true,
203
+ });
204
+ const result = el.dispatchEvent(customEvent);
205
+ return result;
206
+ };
207
+
208
+ const resolveEventDetail = (customEventDetail) => {
209
+ const { event, ...rest } = customEventDetail ?? {};
210
+ let resolvedEvent;
211
+ if (event?.detail?.event !== undefined) {
212
+ resolvedEvent = event.detail.event;
213
+ } else if (event !== undefined) {
214
+ resolvedEvent = event;
215
+ }
216
+ return { ...rest, event: resolvedEvent };
217
+ };
218
+
144
219
  const createIterableWeakSet = () => {
145
220
  const objectWeakRefSet = new Set();
146
221
 
@@ -1721,13 +1796,16 @@ const stringifyCSSTransform = (transformObj, normalize) => {
1721
1796
  );
1722
1797
  transforms.push(`${key}(${normalizedTransformPartValue})`);
1723
1798
  }
1799
+ if (transforms.length === 0) {
1800
+ return "none";
1801
+ }
1724
1802
  return transforms.join(" ");
1725
1803
  };
1726
1804
 
1727
1805
  // Parse transform CSS string into object
1728
1806
  const parseCSSTransform = (transformString, normalize) => {
1729
1807
  if (!transformString || transformString === "none") {
1730
- return undefined;
1808
+ return {};
1731
1809
  }
1732
1810
 
1733
1811
  const transformObj = {};
@@ -2086,9 +2164,6 @@ const normalizeStyle = (
2086
2164
  if (propertyName === "transform") {
2087
2165
  if (context === "js") {
2088
2166
  if (typeof value === "string") {
2089
- if (isCSSKeyword(value)) {
2090
- return value;
2091
- }
2092
2167
  // For js context, prefer objects
2093
2168
  return parseCSSTransform(value, normalizeStyle);
2094
2169
  }
@@ -6358,42 +6433,75 @@ const findSelfOrAncestorFixedPosition = (element) => {
6358
6433
  /**
6359
6434
  * Creates a coordinate system positioner for drag operations.
6360
6435
  *
6361
- * ARCHITECTURE:
6362
- * This function uses a modular offset-based approach to handle coordinate system conversions
6363
- * between different positioning contexts (scroll containers and positioned parents).
6436
+ * PURPOSE:
6437
+ * During a drag gesture, the system tracks mouse movement as "scrollable coordinates"
6438
+ * relative to the scroll container. This function converts those coordinates into
6439
+ * the actual CSS transform values needed to visually move an element (or a separate
6440
+ * elementToMove) to follow the mouse.
6441
+ *
6442
+ * PARAMETERS:
6443
+ * - element: The element being grabbed / tracked for drag detection and auto-scroll.
6444
+ * - referenceElement: Optional. The element whose coordinate system defines the input space.
6445
+ * When provided, scrollable coords are relative to its scroll container.
6446
+ * Defaults to element itself.
6447
+ * - elementToMove: Optional. A different element to apply the transform to (e.g. a clone
6448
+ * or a table that moves as a whole when a column is dragged).
6449
+ * When provided, its offsetParent is used as the positioning context.
6450
+ *
6451
+ * THE COORDINATE PIPELINE:
6452
+ *
6453
+ * Mouse position
6454
+ * → scrollable coords (relative to referenceScrollContainer, scroll-independent)
6455
+ * → positioned coords (relative to elementToMove's offsetParent, for CSS transform)
6364
6456
  *
6365
- * The system decomposes coordinate conversion into two types of offsets:
6366
- * 1. Position offsets - compensate for different positioned parents
6367
- * 2. Scroll offsets - handle scroll position and container differences
6457
+ * Two types of offsets bridge these spaces:
6368
6458
  *
6369
- * COORDINATE SYSTEM:
6370
- * - Input coordinates are relative to the reference element's scroll container
6371
- * - Output coordinates are relative to the element's positioned parent for DOM positioning
6372
- * - Handles cross-coordinate system scenarios (different scroll containers and positioned parents)
6459
+ * 1. POSITION OFFSETS (getPositionOffsets):
6460
+ * Compensate for the fact that positionedParent and referencePositionedParent
6461
+ * may differ. For example, if `element` lives inside a <table> and `elementToMove`
6462
+ * is a full table clone, their offsetParents are different elements.
6463
+ * This offset is the spatial difference between those two positioned ancestors.
6464
+ * Called dynamically because parents can move (e.g. overlay elements).
6465
+ *
6466
+ * 2. SCROLL OFFSETS (getScrollOffsets):
6467
+ * Account for the scroll position of the relevant scroll container(s).
6468
+ * The math ensures that at grab time, the transform delta is zero (element
6469
+ * stays at its visual position), and subsequent mouse movement maps 1:1
6470
+ * to transform change.
6471
+ *
6472
+ * CRITICAL CASE — positionedParent outside referenceScrollContainer:
6473
+ * When elementToMove's offsetParent is NOT inside the referenceScrollContainer
6474
+ * (e.g. a clone appended to document.body while tracking an element inside
6475
+ * an overflow:auto div), the scroll offset must be FROZEN at grab time.
6476
+ * Using a live scroll value would double-move the clone during auto-scroll:
6477
+ * the scrollable coordinate decreases (element appears to move up) AND the
6478
+ * live scroll value increases — both applied to the same transform.
6479
+ * Freezing the scroll at grab time cancels this out while still correctly
6480
+ * placing the clone at the right initial position.
6373
6481
  *
6374
6482
  * KEY SCENARIOS SUPPORTED:
6375
- * 1. Same positioned parent, same scroll container - Simple case, minimal offsets
6376
- * 2. Different positioned parents, same scroll container - Position offset compensation
6377
- * 3. Same positioned parent, different scroll containers - Scroll offset handling
6378
- * 4. Different positioned parents, different scroll containers - Full offset compensation
6379
- * 5. Overlay elements - Special handling for elements with data-overlay-for attribute
6380
- * 6. Fixed positioning - Special scroll offset handling for fixed positioned elements
6483
+ * 1. Same positioned parent, same scroll container minimal offsets
6484
+ * 2. Different positioned parents, same scroll container position offset compensation
6485
+ * 3. Same positioned parent, different scroll containers scroll offset bridging
6486
+ * 4. Different positioned parents, different containers full offset compensation
6487
+ * 5. Overlay elements (data-overlay-for) — specialized offset path
6488
+ * 6. Fixed positioned elements — special scroll handling
6489
+ * 7. elementToMove outside referenceScrollContainer — frozen scroll offset at grab
6381
6490
  *
6382
6491
  * API CONTRACT:
6383
6492
  * Returns [scrollableLeft, scrollableTop, convertScrollablePosition] where:
6384
6493
  *
6385
6494
  * - scrollableLeft/scrollableTop:
6386
- * Current element coordinates in the reference coordinate system (adjusted for position offsets)
6387
- *
6388
- * - convertScrollablePosition:
6389
- * Converts reference coordinate system positions to DOM positioning coordinates
6390
- * Applies both position and scroll offsets for accurate element placement
6495
+ * The element's current position in the reference coordinate system at grab time.
6496
+ * Used as the layout starting point (layoutScrollableLeft/Top) by the gesture system.
6391
6497
  *
6392
- * IMPLEMENTATION STRATEGY:
6393
- * Uses factory functions to create specialized offset calculators based on the specific
6394
- * combination of positioning contexts, optimizing for performance and code clarity.
6498
+ * - convertScrollablePosition(scrollableLeft, scrollableTop):
6499
+ * Converts a scrollable coordinate (from the gesture layout) into a positioned
6500
+ * coordinate suitable for CSS transform. The gesture system computes:
6501
+ * topDelta = convertScrollablePosition(layout.scrollableTop) - topAtGrab
6502
+ * and applies that as translateY. At grab time, delta = 0. As the mouse moves,
6503
+ * delta tracks the movement exactly, regardless of scroll context differences.
6395
6504
  */
6396
-
6397
6505
  const createDragElementPositioner = (
6398
6506
  element,
6399
6507
  referenceElement,
@@ -6411,11 +6519,11 @@ const createDragElementPositioner = (
6411
6519
  positionedParent,
6412
6520
  referencePositionedParent: referenceElement
6413
6521
  ? referenceElement.offsetParent
6414
- : undefined,
6522
+ : positionedParent,
6415
6523
  scrollContainer,
6416
6524
  referenceScrollContainer: referenceElement
6417
6525
  ? getScrollContainer(referenceElement)
6418
- : undefined,
6526
+ : scrollContainer,
6419
6527
  });
6420
6528
 
6421
6529
  {
@@ -6465,9 +6573,9 @@ const getScrollablePosition = (element, scrollContainer) => {
6465
6573
 
6466
6574
  const createGetOffsets = ({
6467
6575
  positionedParent,
6468
- referencePositionedParent = positionedParent,
6576
+ referencePositionedParent,
6469
6577
  scrollContainer,
6470
- referenceScrollContainer = scrollContainer,
6578
+ referenceScrollContainer,
6471
6579
  }) => {
6472
6580
  const samePositionedParent = positionedParent === referencePositionedParent;
6473
6581
  const getScrollOffsets = createGetScrollOffsets(
@@ -6700,6 +6808,19 @@ const createGetScrollOffsets = (
6700
6808
  return getScrollOffsetsFixed;
6701
6809
  }
6702
6810
  }
6811
+ const positionedParentIsInsideScrollContainer =
6812
+ referenceScrollContainer === documentElement ||
6813
+ referenceScrollContainer.contains(positionedParent);
6814
+ if (!positionedParentIsInsideScrollContainer) {
6815
+ // positionedParent is outside the scroll container (e.g. clone in document.body
6816
+ // while tracking an element inside a custom scroll container).
6817
+ // We must add the scroll at grab time as a frozen offset so that:
6818
+ // - initial topDelta = 0 (clone starts at correct position)
6819
+ // - auto-scroll doesn't double-move the clone (scroll changes cancel out in layout)
6820
+ const scrollLeftAtGrab = referenceScrollContainer.scrollLeft;
6821
+ const scrollTopAtGrab = referenceScrollContainer.scrollTop;
6822
+ return () => [scrollLeft + scrollLeftAtGrab, scrollTop + scrollTopAtGrab];
6823
+ }
6703
6824
  const getScrollOffsets = () => {
6704
6825
  const leftScrollToAdd = scrollLeft + referenceScrollContainer.scrollLeft;
6705
6826
  const topScrollToAdd = scrollTop + referenceScrollContainer.scrollTop;
@@ -6956,7 +7077,7 @@ installImportMetaCssBuild(import.meta);/**
6956
7077
  * donc juste x/y ca seras surement mieux
6957
7078
  *
6958
7079
  */
6959
- const css$3 = /* css */`
7080
+ const css$4 = /* css */`
6960
7081
  .navi_drag_gesture_backdrop {
6961
7082
  position: fixed;
6962
7083
  inset: 0;
@@ -7065,6 +7186,10 @@ const createDragGestureController = (options = {}) => {
7065
7186
  isGoingDown: undefined,
7066
7187
  isGoingLeft: undefined,
7067
7188
  isGoingRight: undefined,
7189
+ intentGoingUp: false,
7190
+ intentGoingDown: false,
7191
+ intentGoingLeft: false,
7192
+ intentGoingRight: false,
7068
7193
  // metadata about interaction sources
7069
7194
  grabEvent: event,
7070
7195
  dragEvent: null,
@@ -7109,7 +7234,7 @@ const createDragGestureController = (options = {}) => {
7109
7234
 
7110
7235
  // 2. VISUAL CONTROL: Backdrop for consistent cursor and pointer event blocking
7111
7236
  if (backdrop) {
7112
- import.meta.css = [css$3, "@jsenv/dom/src/interaction/drag/drag_gesture.js"];
7237
+ import.meta.css = [css$4, "@jsenv/dom/src/interaction/drag/drag_gesture.js"];
7113
7238
  const backdropElement = document.createElement("div");
7114
7239
  backdropElement.className = "navi_drag_gesture_backdrop";
7115
7240
  backdropElement.ariaHidden = "true";
@@ -7312,10 +7437,48 @@ const createDragGestureController = (options = {}) => {
7312
7437
  const layoutPrevious = gestureInfo.layout;
7313
7438
  // previousGestureInfo = { ...gestureInfo };
7314
7439
  Object.assign(gestureInfo, dragData);
7440
+ if (gestureInfo.isGoingDown) {
7441
+ gestureInfo.intentGoingDown = true;
7442
+ gestureInfo.intentGoingUp = false;
7443
+ } else if (gestureInfo.isGoingUp) {
7444
+ gestureInfo.intentGoingUp = true;
7445
+ gestureInfo.intentGoingDown = false;
7446
+ }
7447
+ if (gestureInfo.isGoingRight) {
7448
+ gestureInfo.intentGoingRight = true;
7449
+ gestureInfo.intentGoingLeft = false;
7450
+ } else if (gestureInfo.isGoingLeft) {
7451
+ gestureInfo.intentGoingLeft = true;
7452
+ gestureInfo.intentGoingRight = false;
7453
+ }
7315
7454
  if (!startedPrevious && gestureInfo.started) {
7455
+ dispatchPublicCustomEvent(element, "navi_drag_start", {
7456
+ gestureInfo
7457
+ });
7316
7458
  onDragStart?.(gestureInfo);
7459
+ // Suppress the click that the browser fires after pointerup following a real drag.
7460
+ // The capture phase runs before any element onClick handler.
7461
+ const suppressClick = clickEvent => {
7462
+ clickEvent.stopPropagation();
7463
+ clickEvent.preventDefault();
7464
+ document.removeEventListener("click", suppressClick, {
7465
+ capture: true
7466
+ });
7467
+ };
7468
+ document.addEventListener("click", suppressClick, {
7469
+ capture: true
7470
+ });
7471
+ addReleaseCallback(() => {
7472
+ document.removeEventListener("click", suppressClick, {
7473
+ capture: true
7474
+ });
7475
+ });
7317
7476
  }
7318
7477
  const someLayoutChange = gestureInfo.layout !== layoutPrevious;
7478
+ dispatchPublicCustomEvent(element, "navi_drag", {
7479
+ gestureInfo,
7480
+ someLayoutChange
7481
+ });
7319
7482
  publishDrag(gestureInfo,
7320
7483
  // we still publish drag event even when unchanged
7321
7484
  // because UI might need to adjust when document scrolls
@@ -7332,8 +7495,14 @@ const createDragGestureController = (options = {}) => {
7332
7495
  event,
7333
7496
  isRelease: true
7334
7497
  });
7498
+ dispatchPublicCustomEvent(element, "navi_drag_release", {
7499
+ gestureInfo
7500
+ });
7335
7501
  publishRelease(gestureInfo);
7336
7502
  };
7503
+ dispatchPublicCustomEvent(element, "navi_drag_grab", {
7504
+ gestureInfo
7505
+ });
7337
7506
  onGrab?.(gestureInfo);
7338
7507
  const dragGesture = {
7339
7508
  gestureInfo,
@@ -7444,6 +7613,14 @@ const createDragGestureController = (options = {}) => {
7444
7613
  return dragGestureController;
7445
7614
  };
7446
7615
  const dragAfterThreshold = (grabEvent, dragGestureInitializer, threshold) => {
7616
+ const target = grabEvent.target;
7617
+ const isDedicatedHandle = target.closest && target.closest("[data-drag-handle]");
7618
+ if (isDedicatedHandle) {
7619
+ // Element is dedicated to drag — skip the threshold and start immediately.
7620
+ const dragGesture = dragGestureInitializer();
7621
+ dragGesture.dragViaPointer(grabEvent);
7622
+ return;
7623
+ }
7447
7624
  const significantDragGestureController = createDragGestureController({
7448
7625
  threshold,
7449
7626
  // allow interaction for this intermediate gesture:
@@ -7467,7 +7644,7 @@ const definePropertyAsReadOnly = (object, propertyName) => {
7467
7644
  });
7468
7645
  };
7469
7646
 
7470
- installImportMetaCssBuild(import.meta);const css$2 = /* css */`
7647
+ installImportMetaCssBuild(import.meta);const css$3 = /* css */`
7471
7648
  .navi_constraint_feedback_line {
7472
7649
  position: fixed;
7473
7650
  z-index: 9998;
@@ -7483,7 +7660,7 @@ installImportMetaCssBuild(import.meta);const css$2 = /* css */`
7483
7660
  }
7484
7661
  `;
7485
7662
  const setupConstraintFeedbackLine = () => {
7486
- import.meta.css = [css$2, "@jsenv/dom/src/interaction/drag/constraint_feedback_line.js"];
7663
+ import.meta.css = [css$3, "@jsenv/dom/src/interaction/drag/constraint_feedback_line.js"];
7487
7664
  const constraintFeedbackLine = createConstraintFeedbackLine();
7488
7665
 
7489
7666
  // Track last known mouse position for constraint feedback line during scroll
@@ -7563,7 +7740,7 @@ let currentDebugMarkers = [];
7563
7740
  let currentConstraintMarkers = [];
7564
7741
  let currentReferenceElementMarker = null;
7565
7742
  let currentElementMarker = null;
7566
- const css$1 = /* css */`
7743
+ const css$2 = /* css */`
7567
7744
  .navi_debug_markers_container {
7568
7745
  position: fixed;
7569
7746
  top: 0;
@@ -7752,7 +7929,7 @@ const css$1 = /* css */`
7752
7929
  const setupDragDebugMarkers = (dragGesture, {
7753
7930
  referenceElement
7754
7931
  }) => {
7755
- import.meta.css = [css$1, "@jsenv/dom/src/interaction/drag/drag_debug_markers.js"];
7932
+ import.meta.css = [css$2, "@jsenv/dom/src/interaction/drag/drag_debug_markers.js"];
7756
7933
 
7757
7934
  // Clean up any existing persistent markers from previous drag gestures
7758
7935
  {
@@ -8770,60 +8947,99 @@ const createStickyFrontierOnAxis = (
8770
8947
 
8771
8948
  const dragStyleController = createStyleController("drag_to_move");
8772
8949
 
8950
+ /**
8951
+ * Creates a gesture controller that moves elements via drag.
8952
+ *
8953
+ * Wraps `createDragGestureController` and adds:
8954
+ * - Element translation via CSS transform (translate only; other existing transforms are preserved)
8955
+ * - Auto-scroll while dragging near scroll-container edges
8956
+ * - Constraints (area boundaries, obstacle elements)
8957
+ *
8958
+ * The returned controller exposes a `grab(options)` / `grabViaPointer(event, options)` method.
8959
+ * Key grab options:
8960
+ * - `element`: the element whose position drives layout calculations (scroll-container detection,
8961
+ * constraints, auto-scroll). Sets `data-grabbed` during the drag.
8962
+ * - `referenceElement`: optional sticky-frontier / obstacle reference, defaults to `element`.
8963
+ * - `elementToMove`: optional different element to actually translate (e.g. a drag clone).
8964
+ * If omitted, `element` is translated. The translate is read from `dragStyleController`
8965
+ * at grab time so any pre-existing translate is accumulated rather than reset.
8966
+ *
8967
+ * @param {object} [options]
8968
+ * @param {boolean} [options.stickyFrontiers=true]
8969
+ * Shrinks the auto-scroll area at sticky boundaries (elements with `data-sticky-left` /
8970
+ * `data-sticky-top`).
8971
+ * @param {number} [options.autoScrollAreaPadding=0]
8972
+ * Extra padding (px) subtracted from each edge of the auto-scroll trigger area.
8973
+ * @param {string|object|function} [options.areaConstraint="scroll"]
8974
+ * Constrains where the element can be dragged.
8975
+ * `"scroll"` — bounded by the full scroll area.
8976
+ * `"scrollport"` — bounded by the visible viewport of the scroll container.
8977
+ * `"none"` — no area constraint.
8978
+ * `{left, top, right, bottom}` — fixed bounds (values may be functions receiving context).
8979
+ * `function` — called each drag frame, must return a `{left,top,right,bottom}` object.
8980
+ * @param {Element} [options.obstaclesContainer]
8981
+ * Container to look for obstacle elements in. Defaults to the scroll container.
8982
+ * @param {string} [options.obstacleAttributeName="data-drag-obstacle"]
8983
+ * Attribute that marks obstacle elements.
8984
+ * @param {boolean} [options.showConstraintFeedbackLine=false]
8985
+ * Renders a visual line when the pointer deviates from the element due to constraints.
8986
+ * @param {boolean} [options.showDebugMarkers=false]
8987
+ * Renders debug markers for constraint regions.
8988
+ * @param {"commit"|"cancel"|"manual"} [options.releasePositionEffect="commit"]
8989
+ * Controls what happens to the translated position on release.
8990
+ * - `"commit"`: bakes the translate into inline styles so the element stays put (default).
8991
+ * - `"cancel"`: discards the translate so the element snaps back to its original position.
8992
+ * - `"manual"`: does nothing — the caller is responsible for clearing or committing
8993
+ * the transform via `dragStyleController`.
8994
+ * @returns {object} Drag gesture controller with augmented `grab()` / `grabViaPointer()` methods.
8995
+ */
8773
8996
  const createDragToMoveGestureController = ({
8774
- cloneOnDrag = false,
8775
8997
  stickyFrontiers = true,
8776
- // Padding to reduce the area used to autoscroll by this amount (applied after sticky frontiers)
8777
- // This creates an invisible space around the area where elements cannot be dragged
8778
8998
  autoScrollAreaPadding = 0,
8779
- // constraints,
8780
- areaConstraint = "scroll", // "scroll" | "scrollport" | "none" | {left,top,right,bottom} | function
8999
+ areaConstraint = "scroll",
8781
9000
  obstaclesContainer,
8782
9001
  obstacleAttributeName = "data-drag-obstacle",
8783
- // Visual feedback line connecting mouse cursor to the moving grab point when constraints prevent following
8784
- // This provides intuitive feedback during drag operations when the element cannot reach the mouse
8785
- // position due to obstacles, boundaries, or other constraints. The line originates from where the mouse
8786
- // initially grabbed the element, but moves with the element to show the current anchor position.
8787
- // It becomes visible when there's a significant distance between mouse and grab point.
8788
9002
  showConstraintFeedbackLine = false,
8789
9003
  showDebugMarkers = false,
8790
- resetPositionAfterRelease = false,
9004
+ releasePositionEffect = "commit",
8791
9005
  ...options
8792
9006
  } = {}) => {
8793
9007
  const initGrabToMoveElement = (
8794
9008
  dragGesture,
8795
9009
  { element, referenceElement, elementToMove, convertScrollablePosition },
8796
9010
  ) => {
8797
- if (cloneOnDrag) {
8798
- const { grabEvent } = dragGesture.gestureInfo;
8799
- const ghostData = createDragGhost(element, {
8800
- clientX: grabEvent.clientX,
8801
- clientY: grabEvent.clientY,
8802
- });
8803
- elementToMove = ghostData.ghostWrapper;
8804
- dragGesture.gestureInfo.elementImpacted = ghostData.ghostWrapper;
8805
- dragGesture.addReleaseCallback(() => {
8806
- ghostData.remove();
8807
- });
8808
- }
8809
- const direction = dragGesture.gestureInfo.direction;
8810
- // const dragGestureName = dragGesture.gestureInfo.name;
8811
9011
  const scrollContainer = dragGesture.gestureInfo.scrollContainer;
9012
+
9013
+ const direction = dragGesture.gestureInfo.direction;
9014
+ // elementImpacted is either an externally provided elementToMove (e.g. a drag clone)
8812
9015
  const elementImpacted = elementToMove || element;
8813
- const translateXAtGrab = dragStyleController.getUnderlyingValue(
9016
+ // elementImpacted is either an externally provided elementToMove
9017
+ // (e.g. a drag clone passed by the caller) or the element itself.
9018
+ // Capture any pre-existing translate so we can accumulate on top of it
9019
+ // rather than resetting it to zero on the first drag event.
9020
+ const transformAtGrab = dragStyleController.getUnderlyingValue(
8814
9021
  elementImpacted,
8815
- "transform.translateX",
8816
- );
8817
- const translateYAtGrab = dragStyleController.getUnderlyingValue(
8818
- elementImpacted,
8819
- "transform.translateY",
9022
+ "transform",
8820
9023
  );
9024
+ const translateXAtGrab = transformAtGrab.translateX;
9025
+ const translateYAtGrab = transformAtGrab.translateY;
9026
+
9027
+ const cancelPosition = () => {
9028
+ dragStyleController.clear(elementImpacted);
9029
+ };
9030
+ const commitPosition = () => {
9031
+ dragStyleController.commit(elementImpacted);
9032
+ };
9033
+ dragGesture.gestureInfo.cancelPosition = cancelPosition;
9034
+ dragGesture.gestureInfo.commitPosition = commitPosition;
9035
+
8821
9036
  dragGesture.addReleaseCallback(() => {
8822
- if (resetPositionAfterRelease) {
8823
- dragStyleController.clear(elementImpacted);
8824
- } else {
8825
- dragStyleController.commit(elementImpacted);
9037
+ if (releasePositionEffect === "cancel") {
9038
+ cancelPosition();
9039
+ } else if (releasePositionEffect === "commit") {
9040
+ commitPosition();
8826
9041
  }
9042
+ // "manual": caller handles cleanup, do nothing.
8827
9043
  });
8828
9044
 
8829
9045
  let elementWidth;
@@ -8840,8 +9056,8 @@ const createDragToMoveGestureController = ({
8840
9056
 
8841
9057
  let scrollArea;
8842
9058
  {
8843
- // computed at start so that scrollWidth/scrollHeight are fixed
8844
- // even if the dragging side effects increases them afterwards
9059
+ // Snapshot at grab time so that DOM mutations during dragging
9060
+ // (e.g. items shifting) don't change the scrollable boundary mid-drag.
8845
9061
  scrollArea = {
8846
9062
  left: 0,
8847
9063
  top: 0,
@@ -8853,8 +9069,8 @@ const createDragToMoveGestureController = ({
8853
9069
  let scrollport;
8854
9070
  let autoScrollArea;
8855
9071
  {
8856
- // for visible are we also want to snapshot the widht/height
8857
- // and we'll add scrollContainer container scrolls during drag (getScrollport does that)
9072
+ // scrollBox is the fixed bounding rect of the scroll container viewport.
9073
+ // scrollport is recomputed before each drag event to account for scrolling.
8858
9074
  const scrollBox = getScrollBox(scrollContainer);
8859
9075
  const updateScrollportAndAutoScrollArea = () => {
8860
9076
  scrollport = getScrollport(scrollBox, scrollContainer);
@@ -9009,7 +9225,10 @@ const createDragToMoveGestureController = ({
9009
9225
  scrollableLeft,
9010
9226
  scrollableTop,
9011
9227
  );
9012
- const transform = {};
9228
+ // Build the transform to apply, preserving any transforms that were
9229
+ // already on the element before the grab (e.g. rotate from another
9230
+ // controller), and accumulating from the pre-grab translate baseline.
9231
+ const transform = { ...transformAtGrab };
9013
9232
  if (direction.x) {
9014
9233
  const leftTarget = positionedLeft;
9015
9234
  const leftAtGrab = dragGesture.gestureInfo.leftAtGrab;
@@ -9018,12 +9237,6 @@ const createDragToMoveGestureController = ({
9018
9237
  ? translateXAtGrab + leftDelta
9019
9238
  : leftDelta;
9020
9239
  transform.translateX = translateX;
9021
- // console.log({
9022
- // leftAtGrab,
9023
- // scrollableLeft,
9024
- // left,
9025
- // leftTarget,
9026
- // });
9027
9240
  }
9028
9241
  if (direction.y) {
9029
9242
  const topTarget = positionedTop;
@@ -9048,6 +9261,7 @@ const createDragToMoveGestureController = ({
9048
9261
  element,
9049
9262
  referenceElement,
9050
9263
  elementToMove,
9264
+ event,
9051
9265
  ...rest
9052
9266
  } = {}) => {
9053
9267
  const scrollContainer = getScrollContainer(referenceElement || element);
@@ -9061,6 +9275,7 @@ const createDragToMoveGestureController = ({
9061
9275
  scrollContainer,
9062
9276
  layoutScrollableLeft: elementScrollableLeft,
9063
9277
  layoutScrollableTop: elementScrollableTop,
9278
+ event,
9064
9279
  ...rest,
9065
9280
  });
9066
9281
  initGrabToMoveElement(dragGesture, {
@@ -9075,106 +9290,25 @@ const createDragToMoveGestureController = ({
9075
9290
  return dragGestureController;
9076
9291
  };
9077
9292
 
9078
- const createDragGhost = (element, pointerEvent) => {
9079
- const rect = element.getBoundingClientRect();
9080
-
9081
- const ghost = element.cloneNode(true);
9082
- ghost.dataset.dragging = "";
9083
- ghost.style.pointerEvents = "none";
9084
- // transform-origin set to pointer position within the element for natural scale expansion
9085
- const originX = pointerEvent.clientX - rect.left;
9086
- const originY = pointerEvent.clientY - rect.top;
9087
- ghost.style.transformOrigin = `${originX}px ${originY}px`;
9088
-
9089
- const ghostWrapper = document.createElement("div");
9090
- ghostWrapper.style.cssText = `position: absolute; pointer-events: none; z-index: 9999; top: ${rect.top + window.scrollY}px; left: ${rect.left + window.scrollX}px; width: ${rect.width}px;`;
9091
- ghostWrapper.appendChild(ghost);
9092
- document.body.appendChild(ghostWrapper);
9093
-
9094
- return {
9095
- ghost,
9096
- ghostWrapper,
9097
- remove: () => {
9098
- ghostWrapper.remove();
9099
- },
9100
- };
9101
- };
9102
-
9103
- const startDragToResizeGesture = (
9104
- pointerdownEvent,
9105
- { onDragStart, onDrag, onRelease, ...options },
9106
- ) => {
9107
- const target = pointerdownEvent.target;
9108
- if (!target.closest) {
9109
- return null;
9110
- }
9111
- const elementWithDataResizeHandle = target.closest("[data-resize-handle]");
9112
- if (!elementWithDataResizeHandle) {
9113
- return null;
9114
- }
9115
- let elementToResize;
9116
- const dataResizeHandle =
9117
- elementWithDataResizeHandle.getAttribute("data-resize-handle");
9118
- if (!dataResizeHandle || dataResizeHandle === "true") {
9119
- elementToResize = elementWithDataResizeHandle.closest("[data-resize]");
9120
- } else {
9121
- elementToResize = document.querySelector(`#${dataResizeHandle}`);
9122
- }
9123
- if (!elementToResize) {
9124
- console.warn("No element to resize found");
9125
- return null;
9126
- }
9127
- // inspired by https://developer.mozilla.org/en-US/docs/Web/CSS/resize
9128
- // "horizontal", "vertical", "both"
9129
- const resizeDirection = getResizeDirection(elementToResize);
9130
- if (!resizeDirection.x && !resizeDirection.y) {
9131
- return null;
9132
- }
9133
-
9134
- const dragToResizeGestureController = createDragGestureController({
9135
- onDragStart: (...args) => {
9136
- onDragStart?.(...args);
9137
- },
9138
- onDrag,
9139
- onRelease: (...args) => {
9140
- elementWithDataResizeHandle.removeAttribute("data-active");
9141
- onRelease?.(...args);
9142
- },
9143
- });
9144
- elementWithDataResizeHandle.setAttribute("data-active", "");
9145
- const dragToResizeGesture = dragToResizeGestureController.grabViaPointer(
9146
- pointerdownEvent,
9147
- {
9148
- element: elementToResize,
9149
- direction: resizeDirection,
9150
- cursor:
9151
- resizeDirection.x && resizeDirection.y
9152
- ? "nwse-resize"
9153
- : resizeDirection.x
9154
- ? "ew-resize"
9155
- : "ns-resize",
9156
- ...options,
9157
- },
9158
- );
9159
- return dragToResizeGesture;
9160
- };
9161
-
9162
- const getResizeDirection = (element) => {
9163
- const direction = element.getAttribute("data-resize");
9164
- const x = direction === "horizontal" || direction === "both";
9165
- const y = direction === "vertical" || direction === "both";
9166
- return { x, y };
9167
- };
9168
-
9169
9293
  /**
9170
9294
  * Detects the drop target based on what element is actually under the mouse cursor.
9171
9295
  * Uses document.elementsFromPoint() to respect visual stacking order naturally.
9172
9296
  *
9173
9297
  * @param {Object} gestureInfo - Gesture information
9174
9298
  * @param {Element[]} targetElements - Array of potential drop target elements
9299
+ * @param {object} [options]
9300
+ * @param {Element} [options.dragElement] - The element being dragged. When provided and
9301
+ * `fallbackToEdge` is true, used to compute the fallback rect.
9302
+ * @param {boolean} [options.fallbackToEdge=false] - When true and the drag element does
9303
+ * not intersect any target, falls back to the first item (if above all items) or the
9304
+ * last item (if below all items) so there is always a valid drop target at list edges.
9175
9305
  * @returns {Object|null} Drop target info with elementSide or null if no valid target found
9176
9306
  */
9177
- const getDropTargetInfo = (gestureInfo, targetElements) => {
9307
+ const getDropTargetInfo = (
9308
+ gestureInfo,
9309
+ targetElements,
9310
+ { fallbackToEdge = false } = {},
9311
+ ) => {
9178
9312
  const dragElement = gestureInfo.elementImpacted || gestureInfo.element;
9179
9313
  const dragElementRect = dragElement.getBoundingClientRect();
9180
9314
  const intersectingTargets = [];
@@ -9195,6 +9329,38 @@ const getDropTargetInfo = (gestureInfo, targetElements) => {
9195
9329
  }
9196
9330
 
9197
9331
  if (intersectingTargets.length === 0) {
9332
+ if (fallbackToEdge) {
9333
+ const dragElement = gestureInfo.elementImpacted || gestureInfo.element;
9334
+ const dragElementRect = dragElement.getBoundingClientRect();
9335
+ const firstItem = targetElements[0];
9336
+ const lastItem = targetElements[targetElements.length - 1];
9337
+ if (
9338
+ firstItem &&
9339
+ dragElementRect.bottom < firstItem.getBoundingClientRect().top
9340
+ ) {
9341
+ // Drag element is above all items → treat as hovering the first item from the top.
9342
+ return {
9343
+ element: firstItem,
9344
+ elementSide: { x: "start", y: "start" },
9345
+ index: 0,
9346
+ intersectingIndex: 0,
9347
+ intersecting: [firstItem],
9348
+ };
9349
+ }
9350
+ if (
9351
+ lastItem &&
9352
+ dragElementRect.top > lastItem.getBoundingClientRect().bottom
9353
+ ) {
9354
+ // Drag element is below all items → treat as hovering the last item from the bottom.
9355
+ return {
9356
+ element: lastItem,
9357
+ elementSide: { x: "start", y: "end" },
9358
+ index: targetElements.length - 1,
9359
+ intersectingIndex: 0,
9360
+ intersecting: [lastItem],
9361
+ };
9362
+ }
9363
+ }
9198
9364
  return null;
9199
9365
  }
9200
9366
 
@@ -9270,17 +9436,46 @@ const getDropTargetInfo = (gestureInfo, targetElements) => {
9270
9436
  }
9271
9437
  targetIndex = targetElements.indexOf(targetElement);
9272
9438
 
9273
- // Determine position within the target for both axes
9439
+ // Determine position within the target for both axes.
9440
+ //
9441
+ // Use the leading edge of the dragged element (in the direction of movement)
9442
+ // compared against the target's center:
9443
+ // - Dragging down: "after" as soon as the bottom crosses the target center.
9444
+ // - Dragging up: "before" as soon as the top crosses the target center.
9445
+ // - Not moving: center-vs-center fallback.
9446
+ //
9447
+ // This gives consistent, predictable thresholds regardless of element size.
9274
9448
  const targetRect = targetElement.getBoundingClientRect();
9275
9449
  const targetCenterX = targetRect.left + targetRect.width / 2;
9276
9450
  const targetCenterY = targetRect.top + targetRect.height / 2;
9451
+ const { intentGoingDown, intentGoingUp, intentGoingRight, intentGoingLeft } =
9452
+ gestureInfo;
9453
+ let sideY;
9454
+ if (intentGoingDown) {
9455
+ sideY = dragElementRect.bottom > targetCenterY ? "end" : "start";
9456
+ } else if (intentGoingUp) {
9457
+ sideY = dragElementRect.top < targetCenterY ? "start" : "end";
9458
+ } else {
9459
+ sideY = dragElementCenterY < targetCenterY ? "start" : "end";
9460
+ }
9461
+ let sideX;
9462
+ if (intentGoingRight) {
9463
+ sideX = dragElementRect.right > targetCenterX ? "end" : "start";
9464
+ } else if (intentGoingLeft) {
9465
+ sideX = dragElementRect.left < targetCenterX ? "start" : "end";
9466
+ } else {
9467
+ sideX = dragElementCenterX < targetCenterX ? "start" : "end";
9468
+ }
9277
9469
  const result = {
9278
- // Index of the target element within the original targetElements array
9470
+ // NOTE: avoid relying on `index` in application code. The targetElements
9471
+ // array may be dynamically filtered (e.g. excluding the grabbed element),
9472
+ // making this index inconsistent with the full list. Use `element` instead
9473
+ // and look up its position yourself from your own data source.
9279
9474
  index: targetIndex,
9280
9475
  element: targetElement,
9281
9476
  elementSide: {
9282
- x: dragElementRect.left < targetCenterX ? "start" : "end",
9283
- y: dragElementRect.top < targetCenterY ? "start" : "end",
9477
+ x: sideX,
9478
+ y: sideY,
9284
9479
  },
9285
9480
  // Index within the intersecting subset — could be useful to know how many
9286
9481
  // elements were overlapping, but rarely needed in practice
@@ -9321,6 +9516,407 @@ const findTableCellCol = (cellElement) => {
9321
9516
  return correspondingCol;
9322
9517
  };
9323
9518
 
9519
+ // Temporarily attach to the element so inherited CSS vars resolve correctly,
9520
+ // then snapshot all drop-hint custom properties onto the scroll container
9521
+ // so they survive once the element moves to the scroll container.
9522
+ const moveCSSVars = (vars, fromEl, toEl) => {
9523
+ const fromComputedStyle = getComputedStyle(fromEl);
9524
+ const savedVars = {};
9525
+ for (const varName of vars) {
9526
+ const value = fromComputedStyle.getPropertyValue(varName).trim();
9527
+ if (value) {
9528
+ savedVars[varName] = toEl.style.getPropertyValue(varName);
9529
+ toEl.style.setProperty(varName, value);
9530
+ }
9531
+ }
9532
+
9533
+ return () => {
9534
+ for (const varName of vars) {
9535
+ if (varName in savedVars) {
9536
+ if (savedVars[varName]) {
9537
+ toEl.style.setProperty(varName, savedVars[varName]);
9538
+ } else {
9539
+ toEl.style.removeProperty(varName);
9540
+ }
9541
+ }
9542
+ }
9543
+ };
9544
+ };
9545
+
9546
+ installImportMetaCssBuild(import.meta);const css$1 = /* css */`
9547
+ .navi_drop_hint {
9548
+ position: absolute;
9549
+ top: var(--drop-hint-y);
9550
+ left: calc(var(--drop-target-left) + var(--drop-hint-margin-x, 0px));
9551
+ z-index: 10;
9552
+ display: none;
9553
+ width: calc(var(--drop-target-width) - 2 * var(--drop-hint-margin-x, 0px));
9554
+ height: var(--drop-hint-size, 3px);
9555
+ background: var(--drop-hint-background-color, #4476ff);
9556
+ border-radius: var(--drop-hint-border-radius, 2px);
9557
+ transform: translateY(-50%);
9558
+ pointer-events: none;
9559
+ }
9560
+ [data-drop-edge="top"] > .navi_drop_hint {
9561
+ display: block;
9562
+ --drop-hint-y: calc(
9563
+ var(--drop-target-top) - var(--drop-hint-margin-y, 0px)
9564
+ );
9565
+ }
9566
+ [data-drop-edge="bottom"] > .navi_drop_hint {
9567
+ display: block;
9568
+ --drop-hint-y: calc(
9569
+ var(--drop-target-bottom) + var(--drop-hint-margin-y, 0px)
9570
+ );
9571
+ }
9572
+
9573
+ [navi-drag-clone-source] {
9574
+ visibility: hidden;
9575
+ }
9576
+
9577
+ [navi-drag-clone-wrapper] {
9578
+ position: absolute;
9579
+ top: var(--clone-top);
9580
+ left: var(--clone-left);
9581
+ z-index: 9999;
9582
+ width: var(--clone-width);
9583
+ height: var(--clone-height);
9584
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
9585
+ opacity: 0.95;
9586
+ transition: box-shadow 0.15s ease;
9587
+ pointer-events: none;
9588
+ }
9589
+
9590
+ [navi-drag-clone] {
9591
+ transform: scale(var(--drag-clone-scale, 1.03));
9592
+ transform-origin: var(--drag-origin);
9593
+ transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1);
9594
+ }
9595
+
9596
+ @starting-style {
9597
+ [navi-drag-clone-wrapper] {
9598
+ box-shadow: none;
9599
+ }
9600
+
9601
+ [navi-drag-clone] {
9602
+ transform: scale(1);
9603
+ }
9604
+ }
9605
+ `;
9606
+ const dragCSSVars = ["--drop-hint-size", "--drop-hint-background-color", "--drop-hint-border-radius", "--drop-hint-margin-x", "--drop-hint-margin-y", "--drag-clone-scale"];
9607
+
9608
+ /**
9609
+ * Starts a drag-to-reorder interaction on a list item.
9610
+ *
9611
+ * Handles the full reorder UX:
9612
+ * - Activates only after a short movement threshold (avoids accidental reorders on clicks).
9613
+ * - Clones the grabbed element and moves the clone while the original stays hidden in place
9614
+ * (keeps the layout intact so other items don't shift during the drag).
9615
+ * - CSS vars (`--drop-hint-size`, `--drop-hint-background-color`, etc.) are read from the
9616
+ * dragged element and moved to `document.documentElement` for the duration of the drag so
9617
+ * the drop-hint and clone — both in `document.body` — can inherit them.
9618
+ * - Shows a drop-hint line indicating where the item will land.
9619
+ * - Drop-target detection is intersection-based: the clone's bounding rect is compared
9620
+ * against every item that matches `itemSelector` in the scroll container.
9621
+ * - No-ops are filtered: releasing on the grabbed element itself, or in a position that
9622
+ * would leave it at exactly the same index, never triggers `onReorder`.
9623
+ * - On a valid drop, the clone animates to the drop position via the View Transitions API,
9624
+ * `onReorder` is called inside the transition callback so the DOM update and the animation
9625
+ * are captured together, then the clone is removed.
9626
+ * - On a cancelled drop (pointer released with no valid target), the clone is removed
9627
+ * immediately without calling `onReorder`.
9628
+ *
9629
+ * IDs are used as the bridge between DOM elements and JS state because:
9630
+ * - Not all DOM elements matching `itemSelector` may be valid drop targets
9631
+ * (holes in the structure), so DOM indices don't reliably map to state indices.
9632
+ * - Virtual lists render fewer DOM nodes than the total item count, so
9633
+ * DOM-index-based counting would be wrong.
9634
+ *
9635
+ * @param {PointerEvent} event
9636
+ * The `pointerdown` event from the drag handle.
9637
+ * @param {Element} draggedElement
9638
+ * The list item element to drag. Typically `event.currentTarget`.
9639
+ * @param {object} options
9640
+ * @param {string} options.itemSelector
9641
+ * CSS selector that matches all list items inside the scroll container.
9642
+ * Used for drop-target detection and no-op filtering.
9643
+ * @param {function} options.getItemId
9644
+ * Returns the stable ID for a given DOM element.
9645
+ * Signature: `getItemId(element) → id`.
9646
+ * @param {function} options.onReorder
9647
+ * Called when the user drops the item in a new position.
9648
+ * Signature: `onReorder(fromId, toId)`.
9649
+ * - `fromId`: stable ID of the dragged item.
9650
+ * - `toId`: stable ID of the item to insert before, or `null` to append at the end.
9651
+ * Called inside `document.startViewTransition` so the resulting DOM mutation is
9652
+ * animated by the View Transitions API.
9653
+ * @param {object} [options.direction={ x: false, y: true }]
9654
+ * Axes along which dragging is allowed. Passed to `createDragToMoveGestureController`.
9655
+ * @param {...*} [options]
9656
+ * Any remaining options are forwarded to `createDragToMoveGestureController`
9657
+ * (e.g. `areaConstraint`, `autoScrollAreaPadding`, `stickyFrontiers`).
9658
+ * `releasePositionEffect` is always set to `"manual"` internally and cannot be overridden.
9659
+ */
9660
+ const startDragToReorder = (event, {
9661
+ draggedElement = event.currentTarget,
9662
+ containerElement = draggedElement.parentElement,
9663
+ itemSelector,
9664
+ getItemId,
9665
+ onReorder,
9666
+ direction = {
9667
+ x: false,
9668
+ y: true
9669
+ },
9670
+ ...options
9671
+ }) => {
9672
+ import.meta.css = [css$1, "@jsenv/dom/src/interaction/drag/drag_to_reorder.js"];
9673
+ event.preventDefault();
9674
+ return dragAfterThreshold(event, () => {
9675
+ const cloneWrapper = createDragClone(draggedElement, event);
9676
+ draggedElement.setAttribute("navi-drag-clone-source", "");
9677
+ // Move drag related CSS vars from the element to the document
9678
+ // so they're accessible to .navi_drop_hint and the clone (which are both in document.body)
9679
+ const restoreCSSVars = moveCSSVars(dragCSSVars, draggedElement, document.documentElement);
9680
+ const gestureController = createDragToMoveGestureController({
9681
+ direction,
9682
+ releasePositionEffect: "manual",
9683
+ ...options
9684
+ });
9685
+ const dragGesture = gestureController.grabViaPointer(event, {
9686
+ element: draggedElement,
9687
+ elementToMove: cloneWrapper
9688
+ });
9689
+ // getDropTargetInfo uses gestureInfo.elementImpacted to compute the dragged rect.
9690
+ // Point it at the clone so drop detection tracks the clone's current position.
9691
+ dragGesture.gestureInfo.elementImpacted = cloneWrapper;
9692
+ const scrollContainer = dragGesture.gestureInfo.scrollContainer;
9693
+ const dropHintEl = document.createElement("div");
9694
+ dropHintEl.className = "navi_drop_hint";
9695
+ scrollContainer.appendChild(dropHintEl);
9696
+
9697
+ // currentBeforeElement: element before which the grabbed item will be inserted (null = end)
9698
+ // currentReleaseElement: the actual hovered drop target — used to snap the clone on release
9699
+ let currentBeforeElement;
9700
+ let currentReleaseElement;
9701
+ const clearDropHintDOM = () => {
9702
+ scrollContainer.removeAttribute("data-drop-edge");
9703
+ scrollContainer.style.removeProperty("--drop-target-top");
9704
+ scrollContainer.style.removeProperty("--drop-target-bottom");
9705
+ scrollContainer.style.removeProperty("--drop-target-left");
9706
+ scrollContainer.style.removeProperty("--drop-target-width");
9707
+ };
9708
+ const clearDropHint = () => {
9709
+ currentBeforeElement = undefined;
9710
+ currentReleaseElement = undefined;
9711
+ clearDropHintDOM();
9712
+ };
9713
+ dragGesture.addDragCallback(gestureInfo => {
9714
+ const allItems = [];
9715
+ const items = [];
9716
+ for (const el of containerElement.querySelectorAll(itemSelector)) {
9717
+ allItems.push(el);
9718
+ if (el !== draggedElement) {
9719
+ items.push(el);
9720
+ }
9721
+ }
9722
+ const dropTargetInfo = getDropTargetInfo(gestureInfo, items, {
9723
+ fallbackToEdge: true
9724
+ });
9725
+ gestureInfo.dropTargetInfo = dropTargetInfo || null;
9726
+ if (!dropTargetInfo) {
9727
+ clearDropHint();
9728
+ return;
9729
+ }
9730
+ // Convert {element, edge} to a beforeElement using the items array
9731
+ // (not nextElementSibling, which breaks if non-item elements exist between items).
9732
+ // edge "start" → insert before the hovered element
9733
+ // edge "end" → insert before the next item (null = append at end)
9734
+ const edge = dropTargetInfo.elementSide.y;
9735
+ const hoveredIndex = items.indexOf(dropTargetInfo.element);
9736
+ const beforeElement = edge === "start" ? dropTargetInfo.element : items[hoveredIndex + 1] ?? null;
9737
+ // Detect no-op: result would leave the grabbed element in the same position.
9738
+ const elementIndex = allItems.indexOf(draggedElement);
9739
+ const elementNextItem = allItems[elementIndex + 1] ?? null;
9740
+ const isNoop = beforeElement === elementNextItem;
9741
+ if (isNoop) {
9742
+ clearDropHint();
9743
+ return;
9744
+ }
9745
+ // Early return if nothing changed.
9746
+ const releaseElement = dropTargetInfo.element;
9747
+ if (beforeElement === currentBeforeElement && releaseElement === currentReleaseElement) {
9748
+ return;
9749
+ }
9750
+ currentBeforeElement = beforeElement;
9751
+ currentReleaseElement = releaseElement;
9752
+ // Update drop hint CSS vars.
9753
+ // beforeElement = null → insert at end (hint after last item)
9754
+ // beforeElement = X → insert before X (hint at top edge of X)
9755
+ const anchorEl = beforeElement || items[items.length - 1];
9756
+ const anchorEdge = beforeElement !== null ? "top" : "bottom";
9757
+ const containerRect = scrollContainer.getBoundingClientRect();
9758
+ const anchorRect = anchorEl.getBoundingClientRect();
9759
+ const isPositioned = getComputedStyle(scrollContainer).position !== "static";
9760
+ const scrollOffsetLeft = isPositioned ? scrollContainer.scrollLeft : 0;
9761
+ const scrollOffsetTop = isPositioned ? scrollContainer.scrollTop : 0;
9762
+ scrollContainer.setAttribute("data-drop-edge", anchorEdge);
9763
+ scrollContainer.style.setProperty("--drop-target-top", `${anchorRect.top - containerRect.top + scrollOffsetTop}px`);
9764
+ scrollContainer.style.setProperty("--drop-target-bottom", `${anchorRect.bottom - containerRect.top + scrollOffsetTop}px`);
9765
+ scrollContainer.style.setProperty("--drop-target-left", `${anchorRect.left - containerRect.left + scrollOffsetLeft}px`);
9766
+ scrollContainer.style.setProperty("--drop-target-width", `${anchorRect.width}px`);
9767
+ });
9768
+ dragGesture.addReleaseCallback(async gestureInfo => {
9769
+ clearDropHintDOM();
9770
+ dropHintEl.remove();
9771
+ restoreCSSVars();
9772
+ if (currentBeforeElement !== undefined) {
9773
+ const clone = cloneWrapper.firstElementChild;
9774
+ // Bake the current visual position (transform included) into the CSS vars
9775
+ // so the clone stays where the user released it when we clear the transform.
9776
+ setCloneDocumentRect(cloneWrapper, cloneWrapper);
9777
+ gestureInfo.cancelPosition();
9778
+ const fromId = getItemId(draggedElement);
9779
+ const toId = currentBeforeElement ? getItemId(currentBeforeElement) : null;
9780
+ // provide onReorder a way to synchronously move the clone to the drop target
9781
+ // (meant to be used inside a startViewTransition callback)
9782
+ const syncCloneWithDropTarget = () => {
9783
+ // Snap the CSS-var position to the drop target rect so the browser
9784
+ // captures the "new" state at the landing position.
9785
+ setCloneDocumentRect(cloneWrapper, currentReleaseElement);
9786
+ // Removing this attr drops the CSS scale(1.15), so the browser
9787
+ // captures the clone at scale 1 as the "new" state.
9788
+ clone.removeAttribute("navi-drag-clone");
9789
+ };
9790
+ await onReorder(fromId, toId, syncCloneWithDropTarget);
9791
+ }
9792
+ draggedElement.removeAttribute("navi-drag-clone-source");
9793
+ cloneWrapper.remove();
9794
+ });
9795
+ return dragGesture;
9796
+ });
9797
+ };
9798
+
9799
+ // getBoundingClientRect() returns viewport-relative coords.
9800
+ // The clone wrapper is position:absolute inside document.body, so we need
9801
+ // document-relative coords (viewport coords + current page scroll).
9802
+ const setCloneDocumentRect = (cloneWrapper, el) => {
9803
+ const rect = el.getBoundingClientRect();
9804
+ const scrollLeft = document.documentElement.scrollLeft;
9805
+ const scrollTop = document.documentElement.scrollTop;
9806
+ cloneWrapper.style.setProperty("--clone-top", `${rect.top + scrollTop}px`);
9807
+ cloneWrapper.style.setProperty("--clone-left", `${rect.left + scrollLeft}px`);
9808
+ cloneWrapper.style.setProperty("--clone-width", `${rect.width}px`);
9809
+ cloneWrapper.style.setProperty("--clone-height", `${rect.height}px`);
9810
+ };
9811
+
9812
+ // Creates the two-layer clone structure used for drag-to-reorder.
9813
+ //
9814
+ // Layer 1 — wrapper (navi-drag-clone-wrapper):
9815
+ // Positioned absolutely via --clone-top/--clone-left CSS vars.
9816
+ // Carries the box-shadow and size. Moved every drag frame via dragStyleController.
9817
+ // Has a view-transition-name so the View Transitions API can animate it on release.
9818
+ //
9819
+ // Layer 2 — inner clone (navi-drag-clone):
9820
+ // A deep clone of the grabbed element.
9821
+ // Applies transform: scale(1.15) via the CSS rule for [navi-drag-clone],
9822
+ // giving the "lifted" feel. The transform-origin is set to the grab point
9823
+ // so the element expands naturally from where the user clicked.
9824
+ // On release, the `navi-drag-clone` attribute is removed inside
9825
+ // startViewTransition to drop the scale back to 1 as the "new" state.
9826
+ const createDragClone = (element, pointerEvent) => {
9827
+ const rect = element.getBoundingClientRect();
9828
+ const wrapper = document.createElement("div");
9829
+ wrapper.setAttribute("navi-drag-clone-wrapper", "");
9830
+ wrapper.viewTransitionName = "navi-drag-clone-wrapper";
9831
+ setCloneDocumentRect(wrapper, element);
9832
+ // Grab point within the element — used as transform-origin so the
9833
+ // scale(1.15) expands from where the user clicked, not the element center.
9834
+ // These offsets are element-relative so viewport coords are correct here.
9835
+ wrapper.style.setProperty("--drag-origin", `${pointerEvent.clientX - rect.left}px ${pointerEvent.clientY - rect.top}px`);
9836
+ // The clone is appended to document.body, so it loses inherited styles
9837
+ // from the original parent. Copy the computed inherited properties that
9838
+ // are most likely to affect visual appearance.
9839
+ const computedStyle = getComputedStyle(element.parentElement);
9840
+ for (const property of INHERITED_PROPERTIES_TO_COPY_SET) {
9841
+ wrapper.style.setProperty(property, computedStyle.getPropertyValue(property));
9842
+ }
9843
+ const elementClone = element.cloneNode(true);
9844
+ elementClone.setAttribute("navi-drag-clone", "");
9845
+ elementClone.style.viewTransitionName = "navi-drag-clone";
9846
+ wrapper.appendChild(elementClone);
9847
+ document.body.appendChild(wrapper);
9848
+ return wrapper;
9849
+ };
9850
+ const INHERITED_PROPERTIES_TO_COPY_SET = new Set(["color", "font-family", "font-size", "font-weight", "font-style", "line-height", "letter-spacing",
9851
+ // in case the item has border-radius: inherit. The clone can inherit too
9852
+ "border-radius"]);
9853
+
9854
+ const startDragToResizeGesture = (
9855
+ pointerdownEvent,
9856
+ { onDragStart, onDrag, onRelease, ...options },
9857
+ ) => {
9858
+ const target = pointerdownEvent.target;
9859
+ if (!target.closest) {
9860
+ return null;
9861
+ }
9862
+ const elementWithDataResizeHandle = target.closest("[data-resize-handle]");
9863
+ if (!elementWithDataResizeHandle) {
9864
+ return null;
9865
+ }
9866
+ let elementToResize;
9867
+ const dataResizeHandle =
9868
+ elementWithDataResizeHandle.getAttribute("data-resize-handle");
9869
+ if (!dataResizeHandle || dataResizeHandle === "true") {
9870
+ elementToResize = elementWithDataResizeHandle.closest("[data-resize]");
9871
+ } else {
9872
+ elementToResize = document.querySelector(`#${dataResizeHandle}`);
9873
+ }
9874
+ if (!elementToResize) {
9875
+ console.warn("No element to resize found");
9876
+ return null;
9877
+ }
9878
+ // inspired by https://developer.mozilla.org/en-US/docs/Web/CSS/resize
9879
+ // "horizontal", "vertical", "both"
9880
+ const resizeDirection = getResizeDirection(elementToResize);
9881
+ if (!resizeDirection.x && !resizeDirection.y) {
9882
+ return null;
9883
+ }
9884
+
9885
+ const dragToResizeGestureController = createDragGestureController({
9886
+ onDragStart: (...args) => {
9887
+ onDragStart?.(...args);
9888
+ },
9889
+ onDrag,
9890
+ onRelease: (...args) => {
9891
+ elementWithDataResizeHandle.removeAttribute("data-active");
9892
+ onRelease?.(...args);
9893
+ },
9894
+ });
9895
+ elementWithDataResizeHandle.setAttribute("data-active", "");
9896
+ const dragToResizeGesture = dragToResizeGestureController.grabViaPointer(
9897
+ pointerdownEvent,
9898
+ {
9899
+ element: elementToResize,
9900
+ direction: resizeDirection,
9901
+ cursor:
9902
+ resizeDirection.x && resizeDirection.y
9903
+ ? "nwse-resize"
9904
+ : resizeDirection.x
9905
+ ? "ew-resize"
9906
+ : "ns-resize",
9907
+ ...options,
9908
+ },
9909
+ );
9910
+ return dragToResizeGesture;
9911
+ };
9912
+
9913
+ const getResizeDirection = (element) => {
9914
+ const direction = element.getAttribute("data-resize");
9915
+ const x = direction === "horizontal" || direction === "both";
9916
+ const y = direction === "vertical" || direction === "both";
9917
+ return { x, y };
9918
+ };
9919
+
9324
9920
  const getPositionedParent = (element) => {
9325
9921
  let parent = element.parentElement;
9326
9922
  while (parent && parent !== document.body) {
@@ -13420,4 +14016,4 @@ const useResizeStatus = (elementRef, { as = "number" } = {}) => {
13420
14016
  };
13421
14017
  };
13422
14018
 
13423
- export { EASING, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles, canInterceptKeys, captureScrollState, contrastColor, createBackgroundColorTransition, createBackgroundTransition, createBorderRadiusTransition, createBorderTransition, createDragGestureController, createDragToMoveGestureController, createGroupTransitionController, createHeightTransition, createIterableWeakSet, createOpacityTransition, createPubSub, createStyleController, createTimelineTransition, createTransition, createTranslateXTransition, createValueEffect, createWidthTransition, cubicBezier, dragAfterThreshold, elementIsFocusable, elementIsVisibleForFocus, elementIsVisuallyVisible, findAfter, findAncestor, findBefore, findDescendant, findFocusable, getAvailableHeight, getAvailableWidth, getBackground, getBackgroundColor, getBorder, getBorderRadius, getBorderSizes, getContrastRatio, getDefaultStyles, getDragCoordinates, getDropTargetInfo, getElementSignature, getFirstVisuallyVisibleAncestor, getFocusVisibilityInfo, getHeight, getHeightWithoutTransition, getInnerHeight, getInnerWidth, getLuminance, getMarginSizes, getMaxHeight, getMaxWidth, getMinHeight, getMinWidth, getOpacity, getOpacityWithoutTransition, getPaddingSizes, getPositionedParent, getPreferedColorScheme, getScrollBox, getScrollContainer, getScrollContainerSet, getScrollRelativeRect, getSelfAndAncestorScrolls, getStyle, getTranslateX, getTranslateXWithoutTransition, getTranslateY, getVisuallyVisibleInfo, getWidth, getWidthWithoutTransition, hasCSSSizeUnit, initFlexDetailsSet, initFocusGroup, initPositionSticky, isSameColor, isScrollable, measureScrollbar, mergeOneStyle, mergeTwoStyles, normalizeStyle, normalizeStyles, parseStyle, pickPositionRelativeTo, prefersDarkColors, prefersLightColors, preventFocusNav, preventFocusNavViaKeyboard, preventIntermediateScrollbar, resolveCSSColor, resolveCSSSize, resolveColorLuminance, resolveOklchLightness, scrollIntoViewScoped, scrollIntoViewWithStickyAwareness, setAttribute, setAttributes, setStyles, snapToPixel, startDragToResizeGesture, stickyAsRelativeCoords, stringifyStyle, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect };
14019
+ export { EASING, activeElementSignal, addActiveElementEffect, addAttributeEffect, allowWheelThrough, appendStyles, canInterceptKeys, captureScrollState, contrastColor, createBackgroundColorTransition, createBackgroundTransition, createBorderRadiusTransition, createBorderTransition, createDragGestureController, createDragToMoveGestureController, createGroupTransitionController, createHeightTransition, createIterableWeakSet, createOpacityTransition, createPubSub, createStyleController, createTimelineTransition, createTransition, createTranslateXTransition, createValueEffect, createWidthTransition, cubicBezier, dispatchCustomEvent, dispatchInternalCustomEvent, dispatchPublicCustomEvent, dragAfterThreshold, elementIsFocusable, elementIsVisibleForFocus, elementIsVisuallyVisible, findAfter, findAncestor, findBefore, findDescendant, findFocusable, getAvailableHeight, getAvailableWidth, getBackground, getBackgroundColor, getBorder, getBorderRadius, getBorderSizes, getContrastRatio, getDefaultStyles, getDragCoordinates, getDropTargetInfo, getElementSignature, getFirstVisuallyVisibleAncestor, getFocusVisibilityInfo, getHeight, getHeightWithoutTransition, getInnerHeight, getInnerWidth, getLuminance, getMarginSizes, getMaxHeight, getMaxWidth, getMinHeight, getMinWidth, getOpacity, getOpacityWithoutTransition, getPaddingSizes, getPositionedParent, getPreferedColorScheme, getScrollBox, getScrollContainer, getScrollContainerSet, getScrollRelativeRect, getSelfAndAncestorScrolls, getStyle, getTranslateX, getTranslateXWithoutTransition, getTranslateY, getVisuallyVisibleInfo, getWidth, getWidthWithoutTransition, hasCSSSizeUnit, initFlexDetailsSet, initFocusGroup, initPositionSticky, isSameColor, isScrollable, measureScrollbar, mergeOneStyle, mergeTwoStyles, normalizeStyle, normalizeStyles, parseStyle, pickPositionRelativeTo, prefersDarkColors, prefersLightColors, preventFocusNav, preventFocusNavViaKeyboard, preventIntermediateScrollbar, resolveCSSColor, resolveCSSSize, resolveColorLuminance, resolveOklchLightness, scrollIntoViewScoped, scrollIntoViewWithStickyAwareness, setAttribute, setAttributes, setStyles, snapToPixel, startDragToReorder, startDragToResizeGesture, stickyAsRelativeCoords, stringifyStyle, trapFocusInside, trapScrollInside, useActiveElement, useAvailableHeight, useAvailableWidth, useMaxHeight, useMaxWidth, useResizeStatus, visibleRectEffect };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/dom",
3
- "version": "0.10.5",
3
+ "version": "0.11.1",
4
4
  "type": "module",
5
5
  "description": "DOM utilities for writing frontend code",
6
6
  "repository": {
@@ -25,7 +25,8 @@
25
25
  },
26
26
  "./details_content_full_height": "./src/size/details_content_full_height.js",
27
27
  "./details_toggle_animation": "./src/details_toggle_animation.js",
28
- "./resize": "./src/size/resize.js"
28
+ "./resize": "./src/size/resize.js",
29
+ "./*": "./*"
29
30
  },
30
31
  "files": [
31
32
  "/dist/"