Skip to content

Commit fa7b60f

Browse files
committed
work
1 parent 999a003 commit fa7b60f

6 files changed

Lines changed: 370 additions & 135 deletions

File tree

packages/frontend/dom/src/interaction/drag/drag_to_reorder.js

Lines changed: 115 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import { createDragToMoveGestureController } from "./drag_to_move.js";
44
import { getDropTargetInfo } from "./drop_target_detection.js";
55
import { moveCSSVars } from "./move_css_vars.js";
66

7+
// How long the copy takes to leave the screen, and to come back. Written into the
8+
// CSS below from here: the flight has to be waited for, and a duration living only
9+
// in a stylesheet is a timing JS cannot read reliably.
10+
const TOSS_DURATION_MS = 320;
11+
// Far enough to be off any screen, in the direction the hand was going.
12+
const TOSS_DISTANCE = 900;
13+
714
const css = /* css */ `
815
/* IN THE PAGE, NOT IN THE LIST: the hint lands on the edge of a row, which
916
for the last one is the very bottom of the scroll area — drawn inside it,
@@ -105,13 +112,6 @@ const css = /* css */ `
105112
}
106113
107114
[navi-drag-clone-wrapper] {
108-
/* Nothing in a copy being carried by a pointer is text to select: the
109-
selection belongs to the original, which is still in the page. This is the
110-
one place the rule is unconditional — an element that can be dragged is
111-
usually selectable too (a link is both), and forcing it there would take
112-
away a selection made from outside the element. */
113-
user-select: none;
114-
115115
/* Also a popover (see .navi_drop_hint): in the top layer it is over the
116116
page whatever the page's own stacking is, and the coordinates it is
117117
given are viewport ones — which is what the pointer carrying it works
@@ -132,9 +132,26 @@ const css = /* css */ `
132132
opacity: 0.95;
133133
transition: box-shadow 0.15s ease;
134134
pointer-events: none;
135+
/* Nothing in a copy being carried by a pointer is text to select: the
136+
selection belongs to the original, which is still in the page. This is the
137+
one place the rule is unconditional — an element that can be dragged is
138+
usually selectable too (a link is both), and forcing it there would take
139+
away a selection made from outside the element. */
140+
user-select: none;
135141
overflow: visible;
136142
}
137143
144+
/* Ce qui a été lancé: il continue dans la direction du geste jusqu'à sortir de
145+
l'écran, et revient par le même chemin si la réponse refuse. */
146+
[navi-drag-clone-wrapper][data-tossed] {
147+
transition:
148+
translate ${TOSS_DURATION_MS}ms ease-out,
149+
opacity ${TOSS_DURATION_MS}ms ease-out;
150+
}
151+
[navi-drag-clone-wrapper][data-tossed="away"] {
152+
opacity: 0;
153+
}
154+
138155
[navi-drag-clone] {
139156
transform: scale(var(--drag-clone-scale, 1.03));
140157
transform-origin: var(--drag-origin);
@@ -206,9 +223,11 @@ const dragCSSVars = [
206223
* The list item to drag.
207224
* @param {Element} [options.containerElement=draggedElement.parentElement]
208225
* Element searched with `itemSelector` to find the items to drop between.
209-
* @param {string} options.itemSelector
226+
* @param {string} [options.itemSelector]
210227
* CSS selector that matches all list items inside `containerElement`.
211-
* Used for drop-target detection and no-op filtering.
228+
* Used for drop-target detection and no-op filtering. Left out, nothing is a
229+
* drop target: no hint is drawn and no reorder can be answered — which is what
230+
* a drag that only ever throws the thing away asks for.
212231
* @param {function} options.getItemId
213232
* Returns the stable ID for a given DOM element.
214233
* Signature: `getItemId(element) → id`.
@@ -220,6 +239,15 @@ const dragCSSVars = [
220239
* - `syncCloneWithDropTarget`: call it synchronously inside a
221240
* `document.startViewTransition` callback, next to the DOM mutation, so the
222241
* clone is captured at its landing position.
242+
* @param {(detail: {gestureInfo: object, dropTarget: Element|null}) => "reorder"|"toss"|"cancel"} [options.resolveDrop]
243+
* What THIS release means, when the answer is not simply "a target was found or
244+
* not": the same grab can be meant to reorder or to get rid of the thing, and
245+
* only the caller knows which — far and fast is a throw, over a row is a move.
246+
* Left out, a drop target reorders and anything else is cancelled.
247+
* @param {(detail: {gestureInfo: object}) => Promise|void} [options.onToss]
248+
* The release was a throw. The clone leaves the screen the way it was thrown
249+
* while this runs; it comes back if the promise rejects, because the thing still
250+
* exists and the screen has to say so.
223251
* @param {object} [options.direction={ x: false, y: true }]
224252
* Axes along which dragging is allowed. Passed to `createDragToMoveGestureController`.
225253
* @param {number} [options.threshold=5]
@@ -245,6 +273,8 @@ export const startDragToReorder = (
245273
itemSelector,
246274
getItemId,
247275
onReorder,
276+
resolveDrop,
277+
onToss,
248278
direction = { x: false, y: true },
249279
threshold,
250280
longPress,
@@ -319,6 +349,11 @@ export const startDragToReorder = (
319349
};
320350

321351
dragGesture.addDragCallback((gestureInfo) => {
352+
if (!itemSelector) {
353+
// Nothing is a drop target: there is no hint to draw and no landing to
354+
// look for (see itemSelector).
355+
return;
356+
}
322357
const allItems = [];
323358
const items = [];
324359
for (const el of containerElement.querySelectorAll(itemSelector)) {
@@ -397,7 +432,29 @@ export const startDragToReorder = (
397432
dropHintEl.remove();
398433
restoreCSSVars();
399434

400-
if (currentBeforeElement !== undefined) {
435+
const hasDropTarget = currentBeforeElement !== undefined;
436+
const dropMeans = resolveDrop
437+
? resolveDrop({
438+
gestureInfo,
439+
dropTarget: hasDropTarget ? currentReleaseElement : null,
440+
})
441+
: hasDropTarget
442+
? "reorder"
443+
: "cancel";
444+
445+
if (dropMeans === "toss") {
446+
// Bake the position the hand left it at, so the flight starts from
447+
// there rather than from where the clone was declared.
448+
setCloneViewportRect(cloneWrapper, cloneWrapper);
449+
gestureInfo.cancelPosition();
450+
const gone = await tossCloneAway(cloneWrapper, gestureInfo, onToss);
451+
if (!gone) {
452+
// It still exists, so the screen has to say so: the copy comes back
453+
// over the original, and taking it away then reveals the row in
454+
// place.
455+
await settleCloneBack(cloneWrapper, draggedElement);
456+
}
457+
} else if (dropMeans === "reorder" && hasDropTarget) {
401458
const clone = cloneWrapper.firstElementChild;
402459
// Bake the current visual position (transform included) into the CSS vars
403460
// so the clone stays where the user released it when we clear the transform.
@@ -491,6 +548,54 @@ const createDropHint = () => {
491548
return div.firstElementChild;
492549
};
493550

551+
/**
552+
* The copy leaves the screen the way it was thrown, and the caller says what that
553+
* meant. Resolves true when it is really gone.
554+
*
555+
* The answer is asked for WHILE it flies rather than after: the thing is already
556+
* far away by the time the request lands, which is the whole point of a gesture
557+
* that means "get rid of this" — nobody waits to watch it go.
558+
*/
559+
const tossCloneAway = async (cloneWrapper, gestureInfo, onToss) => {
560+
const { xDelta, yDelta } = gestureInfo.layout;
561+
const distance = Math.hypot(xDelta, yDelta) || 1;
562+
cloneWrapper.dataset.tossed = "away";
563+
cloneWrapper.style.translate = `${(xDelta / distance) * TOSS_DISTANCE}px ${
564+
(yDelta / distance) * TOSS_DISTANCE
565+
}px`;
566+
try {
567+
await onToss?.({ gestureInfo });
568+
return true;
569+
} catch {
570+
return false;
571+
}
572+
};
573+
574+
/**
575+
* It comes back where it came from, and only then is taken away — which is what
576+
* makes the original reappear in place rather than blink back into it.
577+
*
578+
* Flown home on `translate` rather than by rewriting the position vars: the vars
579+
* hold where the hand let go, the transition is on translate, and moving the vars
580+
* would put the copy there instantly instead of taking it there.
581+
*/
582+
const settleCloneBack = (cloneWrapper, sourceElement) => {
583+
const sourceRect = sourceElement.getBoundingClientRect();
584+
const releaseLeft = parseFloat(
585+
cloneWrapper.style.getPropertyValue("--clone-left"),
586+
);
587+
const releaseTop = parseFloat(
588+
cloneWrapper.style.getPropertyValue("--clone-top"),
589+
);
590+
cloneWrapper.dataset.tossed = "back";
591+
cloneWrapper.style.translate = `${sourceRect.left - releaseLeft}px ${
592+
sourceRect.top - releaseTop
593+
}px`;
594+
return new Promise((resolve) => {
595+
setTimeout(resolve, TOSS_DURATION_MS);
596+
});
597+
};
598+
494599
const createDragClone = (element, pointerEvent) => {
495600
const rect = element.getBoundingClientRect();
496601

packages/frontend/navi/docs/interactions.md

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,13 @@ condition: `{ swipe_right: canArchive && archive }`.
5757

5858
### The interactions navi detects
5959

60-
| Key | Read from |
61-
| ------------------------------------------------------ | ------------------------------------------ |
62-
| `mousedown` `mouseup` `click` `dblclick` `contextmenu` | the browser's own events |
63-
| `swipe_left` `swipe_right` `swipe_up` `swipe_down` | a press that travels |
64-
| `longpress` | a press held still |
65-
| `drag_to_reorder` | a row carried to another place in its list |
66-
| `"keyboard:<shortcut>"` | keys, e.g. `"keyboard:ctrl+backspace"` |
60+
| Key | Read from |
61+
| ------------------------------------------------------ | ---------------------------------------------------- |
62+
| `mousedown` `mouseup` `click` `dblclick` `contextmenu` | the browser's own events |
63+
| `swipe_left` `swipe_right` `swipe_up` `swipe_down` | a press that travels |
64+
| `longpress` | a press held still |
65+
| `reorder` `toss` | a row carried, then dropped somewhere or thrown away |
66+
| `"keyboard:<shortcut>"` | keys, e.g. `"keyboard:ctrl+backspace"` |
6767

6868
A name nothing knows how to detect produces a dev warning naming the detectors
6969
that exist.
@@ -140,41 +140,86 @@ comes back once it settles — a failure leaves the row in place so it can be tr
140140
again. What a success does to the element is yours (a list that redemands its
141141
rows, a row that leaves): navi does not make it disappear.
142142

143-
## Reordering a list
143+
## Reordering, and throwing away
144144

145-
`drag_to_reorder` is `startDragToReorder`'s gesture, whole — a clone carried above
146-
the page while the original keeps its place, a drop hint, drop targets found by
147-
intersection, no-op drops filtered out. Every element declaring it marks itself, so
148-
the set of items IS the set of elements that declared it: no selector to pass, and
149-
an item that must not move simply does not declare it. Items are identified by
150-
their `id`.
145+
`reorder` and `toss` are the same gesture — the element is picked up and carried —
146+
and they combine. What differs is the release: dropped on another item it changes
147+
places, thrown far and fast it is gotten rid of. One detector reads both, because
148+
it is one press.
151149

152150
```jsx
153151
<List.Item
154152
id={task.id}
155153
data-view-transition-name={`task_${task.id}`}
156154
interactions={{
157-
drag_to_reorder: (event) => {
155+
reorder: (event) => {
158156
const { fromId, toId, syncCloneWithDropTarget } = event.detail;
159157
return document.startViewTransition(() => {
160158
syncCloneWithDropTarget();
161159
setOrder(moveBefore(order, fromId, toId));
162160
}).finished;
163161
},
162+
toss: (event) => remove(event.detail.id),
164163
}}
165164
/>
166165
```
167166

167+
The gesture is `startDragToReorder`'s, whole: a copy carried above the page while
168+
the original keeps its place, a drop hint, drop targets found by intersection,
169+
no-op drops filtered out, the flight of a thrown copy and its return when the
170+
answer refuses.
171+
172+
Every element declaring `reorder` marks itself, so the set of items IS the set of
173+
elements that declared it — no selector to pass, and an item that must not move
174+
simply does not declare it. An element declaring only `toss` marks nothing: it is
175+
not a place anything lands. Items are named by their `id`.
176+
168177
`toId` is null for a drop at the end. `syncCloneWithDropTarget` must be called
169-
synchronously inside the transition callback, next to the state change, so the
170-
clone is captured where it lands rather than where it was let go of — and
171-
returning the transition is what makes the landing continuous, since the gesture
172-
keeps its clone until the answer settles.
178+
synchronously inside the transition callback, next to the state change, so the copy
179+
is captured where it lands rather than where it was let go of.
180+
181+
**The promise matters in both cases**: the gesture holds its copy until the answer
182+
settles. Returning the transition is what makes a landing continuous; a `toss` that
183+
rejects brings the copy back, because the thing still exists and the screen has to
184+
say so.
185+
186+
A throw is asked about before a landing: a hand that sent something across the
187+
screen has not asked for it to swap places with whatever it flew over.
173188

174-
Starting the document transition is the application's call, not navi's: a
189+
Starting a document transition is the application's call, not navi's: a
175190
`view-transition-name` must be unique per document, so only the application can
176191
name what moves.
177192

193+
| Attribute | Meaning |
194+
| -------------------------------------------------------- | -------------------------------------- |
195+
| `data-reorder-axis="x"` | the list runs sideways |
196+
| `data-drag-delay` `data-drag-slop` `data-drag-threshold` | when the press becomes a grab |
197+
| `data-toss-distance` `data-toss-speed` | how far and how fast counts as a throw |
198+
199+
### Dressing the clone
200+
201+
What the pointer carries is a copy, and a copy of a transparent element is
202+
invisible — an element has no background unless something gave it one, and a row
203+
usually gets its own from the list around it, which the copy has left. So the
204+
clone's look is the page's to declare, through the attributes the gesture puts on
205+
it:
206+
207+
| Attribute | On |
208+
| ------------------------- | ------------------------------------------------------ |
209+
| `navi-drag-clone` | the copy being carried |
210+
| `navi-drag-clone-wrapper` | what positions it (already shadowed, in the top layer) |
211+
| `navi-drag-clone-source` | the original, still in place (already hidden) |
212+
213+
```css
214+
.task[navi-drag-clone] {
215+
background: white;
216+
border-radius: 6px;
217+
}
218+
```
219+
220+
Reusing the item's own class is the point: the copy is that item, so it is styled
221+
as that item plus whatever being carried changes.
222+
178223
`data-reorder-axis="x"` for a list that runs sideways. `data-reorder-delay`,
179224
`data-reorder-slop`, `data-reorder-threshold` tune when the press becomes a grab.
180225

packages/frontend/navi/src/control/demos/38_interactions_demo.html

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,15 @@
221221
font-size: 14px;
222222
background: white;
223223
}
224+
/* Ce qui est porté par le pointeur. navi pose `navi-drag-clone` sur la
225+
copie et `navi-drag-clone-source` sur l'original resté en place — c'est
226+
par là qu'on habille l'un et l'autre. Un élément est transparent par
227+
défaut, et une copie transparente ne se voit pas : le fond est un choix
228+
de la page, pas du geste. */
229+
.task[navi-drag-clone] {
230+
background: white;
231+
border-radius: 6px;
232+
}
224233
.task_handle {
225234
color: #9aa6b6;
226235
font-size: 16px;
@@ -921,6 +930,11 @@ <h2 style="margin: 0 0 4px">Sommaire</h2>
921930

922931
const ReorderDemo = () => {
923932
const [order, setOrder] = useState(TASKS.map((task) => task.id));
933+
const toss = (event) => {
934+
setOrder((previous) =>
935+
previous.filter((id) => id !== event.detail.id),
936+
);
937+
};
924938
const reorder = (event) => {
925939
const { fromId, toId, syncCloneWithDropTarget } = event.detail;
926940
// Le nom de transition est unique par document, donc c'est l'appli qui
@@ -944,10 +958,11 @@ <h2 style="margin: 0 0 4px">Sommaire</h2>
944958
};
945959
return (
946960
<div class="demo-section">
947-
<Heading id="reorder">Réordonner</Heading>
961+
<Heading id="reorder">Réordonner, ou jeter</Heading>
948962
<p class="legend">
949-
Attraper une tâche par sa poignée et la déposer ailleurs. À la
950-
souris quelques pixels suffisent, au doigt il faut la tenir.
963+
Attraper une tâche par sa poignée : la déposer sur une autre les
964+
échange, l'envoyer loin et vite la jette. Un même geste, deux
965+
réponses.
951966
</p>
952967
<Box className="rows" width="320px">
953968
<List>
@@ -959,7 +974,7 @@ <h2 style="margin: 0 0 4px">Sommaire</h2>
959974
id={id}
960975
className="task"
961976
data-view-transition-name={`task_${id}`}
962-
interactions={{ drag_to_reorder: reorder }}
977+
interactions={{ reorder, toss }}
963978
>
964979
<Box flex="x" spacing="s" alignY="center">
965980
<span className="task_handle" data-drag-handle>
@@ -972,6 +987,13 @@ <h2 style="margin: 0 0 4px">Sommaire</h2>
972987
})}
973988
</List>
974989
</Box>
990+
{order.length < TASKS.length ? (
991+
<Box paddingY="s">
992+
<Button action={() => setOrder(TASKS.map((task) => task.id))}>
993+
Rétablir {TASKS.length - order.length}
994+
</Button>
995+
</Box>
996+
) : null}
975997
</div>
976998
);
977999
};

0 commit comments

Comments
 (0)