@jsenv/navi 0.29.20 → 0.29.21

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.
@@ -30198,6 +30198,109 @@ const getParamScope = (params) => {
30198
30198
  return newParamScope;
30199
30199
  };
30200
30200
 
30201
+ /*
30202
+ * GET_PAGE: reading a resource one slice at a time, for a list that draws its
30203
+ * rows as it goes (`<List.Items itemsAction>`).
30204
+ *
30205
+ * It is on purpose not an action. An action keeps the one response it got and
30206
+ * replays it, and takes a place in the rerun graph — two things a slice must
30207
+ * not do: the list already holds the slices it received and glues them back
30208
+ * together, and a mutation would otherwise send every slice ever loaded back to
30209
+ * the network at once. So the reader keeps nothing. It runs the callback with
30210
+ * the range the list asks for, writes what comes back into the store, and hands
30211
+ * the rows back as store items — never a copy of the JSON, so the relations a
30212
+ * row reads are the shared ones and a request sent from a row is read back on
30213
+ * it. A row following its own fields through a write reads them from the store
30214
+ * (`RESOURCE.useById(id)`): an update replaces the item object, and the one the
30215
+ * list is holding is the one it was given.
30216
+ *
30217
+ * The reader is a function, so a list feeds on it the way it feeds on any other
30218
+ * source: `itemsAction={GAME.GET_PAGE.bindParams({ radar })}`.
30219
+ */
30220
+
30221
+
30222
+ const createPageReader = (
30223
+ actionName,
30224
+ callback,
30225
+ { store, params: boundParams },
30226
+ ) => {
30227
+ const readPage = async (range = {}) => {
30228
+ const { signal, ...rangeParams } = range;
30229
+ const paramsResolved = { ...resolveParams(boundParams), ...rangeParams };
30230
+ const result = await callback(paramsResolved, { signal });
30231
+ if (!result || !Array.isArray(result.items)) {
30232
+ throw new TypeError(
30233
+ `${actionName} must return { items, start, count }, received ${describeResult(result)}.`,
30234
+ );
30235
+ }
30236
+ const items = store.upsert(result.items);
30237
+ let { start, count } = result;
30238
+ if (start === undefined) {
30239
+ const startAsked = rangeParams.start;
30240
+ if (startAsked === undefined || startAsked < 0) {
30241
+ throw new TypeError(
30242
+ `${actionName} must say where the page lands (start), it was asked for ${describeRangeAsked(rangeParams)}.`,
30243
+ );
30244
+ }
30245
+ start = startAsked;
30246
+ }
30247
+ if (count === undefined) {
30248
+ count = start + items.length;
30249
+ }
30250
+ return { items, start, count };
30251
+ };
30252
+ Object.defineProperty(readPage, "name", { value: actionName });
30253
+ readPage.isPageReader = true;
30254
+ readPage.bindParams = (paramsToBind) => {
30255
+ return createPageReader(actionName, callback, {
30256
+ store,
30257
+ params: boundParams ? { ...boundParams, ...paramsToBind } : paramsToBind,
30258
+ });
30259
+ };
30260
+ return readPage;
30261
+ };
30262
+
30263
+ // Params bound to a reader may be signals (the radar currently on screen); the
30264
+ // value they hold when the page is asked for is the one the page is about.
30265
+ const resolveParams = (params) => {
30266
+ if (!params) {
30267
+ return {};
30268
+ }
30269
+ const paramsResolved = {};
30270
+ for (const key of Object.keys(params)) {
30271
+ const value = params[key];
30272
+ paramsResolved[key] = isSignal(value) ? value.value : value;
30273
+ }
30274
+ return paramsResolved;
30275
+ };
30276
+
30277
+ const describeResult = (result) => {
30278
+ if (Array.isArray(result)) {
30279
+ return `an array of ${result.length} item${result.length === 1 ? "" : "s"}`;
30280
+ }
30281
+ if (result && typeof result === "object") {
30282
+ return `an object holding ${Object.keys(result).join(", ") || "nothing"}`;
30283
+ }
30284
+ return `${result}`;
30285
+ };
30286
+
30287
+ const describeRangeAsked = (rangeParams) => {
30288
+ const { start, limit, around, before, after } = rangeParams;
30289
+ if (around !== undefined) {
30290
+ return `the rows around "${around}"`;
30291
+ }
30292
+ if (before !== undefined) {
30293
+ return `the ${limit} rows before "${before}"`;
30294
+ }
30295
+ if (after !== undefined) {
30296
+ return `the ${limit} rows after "${after}"`;
30297
+ }
30298
+ if (start < 0) {
30299
+ return `the last ${limit} rows`;
30300
+ }
30301
+ return `${limit} rows from ${start}`;
30302
+ };
30303
+
30201
30304
  const resourceLifecycleManager = createResourceLifecycleManager();
30202
30305
  const debug$2 = (args) => {
30203
30306
  {
@@ -30216,13 +30319,18 @@ const debug$2 = (args) => {
30216
30319
  * - GET / POST / PUT / PATCH → the full item object, e.g. `{ id, name }`
30217
30320
  * - DELETE → the id or `{ id }` of the removed item
30218
30321
  * - GET_MANY / POST_MANY / … → an array of item objects
30322
+ * - GET_PAGE → `{ items, start, count }`, one slice of the collection
30323
+ *
30324
+ * `GET_PAGE` is a reader rather than an action: it keeps no value and takes no place in
30325
+ * the rerun graph, so a `<List.Items>` can feed on it slice by slice
30326
+ * (`itemsAction={USER.GET_PAGE.bindParams({ team })}`).
30219
30327
  *
30220
30328
  * A sub-resource of the backend (`/games/:id/candidates`) must be modelled with a
30221
30329
  * relationship method, never as an `op`/`type` discriminator dispatched inside one
30222
30330
  * verb's callback.
30223
30331
  *
30224
30332
  * @param {string} name - resource name, used in action names and error messages
30225
- * @param {Object} restCallbacks - `{ idKey, uniqueKeys, rerunOn, dependencies, GET, GET_MANY, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
30333
+ * @param {Object} restCallbacks - `{ idKey, uniqueKeys, rerunOn, dependencies, GET, GET_MANY, GET_PAGE, POST, POST_MANY, PUT, PUT_MANY, PATCH, PATCH_MANY, DELETE, DELETE_MANY }`
30226
30334
  * @param {string} [restCallbacks.idKey] - primary key property, defaults to `"id"` (or the first `uniqueKeys` entry)
30227
30335
  * @param {string[]} [restCallbacks.uniqueKeys] - alternate keys the store can find an item by (e.g. `"username"`); a callback may return a different `id` to rename the item's primary key
30228
30336
  * @see docs/resource.md — relationships, callback return contracts, decision table
@@ -30246,6 +30354,7 @@ const resource = (
30246
30354
 
30247
30355
  GET,
30248
30356
  GET_MANY,
30357
+ GET_PAGE,
30249
30358
  POST,
30250
30359
  POST_MANY,
30251
30360
  PUT,
@@ -30311,6 +30420,7 @@ const resource = (
30311
30420
  restCallbacks: {
30312
30421
  GET,
30313
30422
  GET_MANY,
30423
+ GET_PAGE,
30314
30424
  POST,
30315
30425
  POST_MANY,
30316
30426
  PUT,
@@ -31392,6 +31502,16 @@ ${originalActionName} source location: ${locationInfo}`,
31392
31502
  if (restCallback === undefined) {
31393
31503
  continue;
31394
31504
  }
31505
+ if (restCallbackKey === "GET_PAGE") {
31506
+ // A page is read, never kept: no action, no place in the rerun graph
31507
+ // (see resource_page_reader.js).
31508
+ stateFacade.GET_PAGE = createPageReader(
31509
+ `${name}.GET_PAGE`,
31510
+ restCallback,
31511
+ { store, params },
31512
+ );
31513
+ continue;
31514
+ }
31395
31515
  const isMany = restCallbackKey.endsWith("_MANY");
31396
31516
  const verb = isMany
31397
31517
  ? restCallbackKey.replace("_MANY", "")
@@ -43562,6 +43682,13 @@ installImportMetaCssBuild(import.meta);/**
43562
43682
  * the slides in between fly past — least of all in a tab bar, where they are
43563
43683
  * not a road one travels but places one goes straight to.
43564
43684
  *
43685
+ * A finger (or a mouse) drags the slides too: the track follows the pointer, the
43686
+ * neighbours are brought alongside for the occasion, and letting go either
43687
+ * carries on to the one being pulled in or puts the current one back — the
43688
+ * gesture decides, not the distance alone. It walks ONE AXIS, chosen from the
43689
+ * first few pixels: a diagonal would ask for two travels at once and only one
43690
+ * slide can arrive.
43691
+ *
43565
43692
  * The slides live INSIDE the box, which is what makes this work for a popup: a
43566
43693
  * dialog and a popover are both promoted to the browser's top layer, so no
43567
43694
  * container of ours could ever hold two of them side by side and translate the
@@ -43609,6 +43736,28 @@ const css$A = /* css */`
43609
43736
  outline: none;
43610
43737
  }
43611
43738
 
43739
+ /* What a touch may do here: the axis the slides travel on is taken (it is
43740
+ what the gesture drags), the other one is left to the page — so a
43741
+ carousel in an article is swiped sideways and the article still scrolls
43742
+ under the same finger. A map travelling both ways takes both.
43743
+ A scroller INSIDE a slide is not concerned: touch-action is read up to
43744
+ the scroll container the gesture would move, so a row that scrolls
43745
+ sideways within a slide still scrolls sideways. */
43746
+ &[data-travel-by-drag="x"] {
43747
+ touch-action: pan-y;
43748
+ }
43749
+ &[data-travel-by-drag="y"] {
43750
+ touch-action: pan-x;
43751
+ }
43752
+ &[data-travel-by-drag="xy"] {
43753
+ touch-action: none;
43754
+ }
43755
+ /* A drag is not a selection: without this a mouse pulling a slide paints
43756
+ the text it passes over blue. */
43757
+ &[data-slide-dragging] {
43758
+ user-select: none;
43759
+ }
43760
+
43612
43761
  /* Outside the box, which is where an outline is drawn by default: nothing
43613
43762
  inside can paint over it (the slides are all within), and this box's own
43614
43763
  overflow does not clip it either — an element's outline is not its own
@@ -43691,31 +43840,35 @@ const css$A = /* css */`
43691
43840
  // own box, because that is what its percentages resolve to.
43692
43841
  const ratioOfOneTravel = (track, from, to, targetBefore) => {
43693
43842
  const box = track.getBoundingClientRect();
43694
- const readOffset = offset => {
43695
- if (!offset || offset === "none") {
43696
- return {
43697
- x: 0,
43698
- y: 0
43699
- };
43700
- }
43701
- const [x = "0", y = "0"] = String(offset).trim().split(/\s+/);
43702
- const toPx = (value, size) => value.endsWith("%") ? parseFloat(value) / 100 * size : parseFloat(value) || 0;
43703
- return {
43704
- x: toPx(x, box.width),
43705
- y: toPx(y, box.height)
43706
- };
43707
- };
43708
43843
  const distance = (a, b) => Math.hypot(b.x - a.x, b.y - a.y);
43709
- const target = readOffset(to);
43710
- const asked = distance(readOffset(targetBefore), target);
43844
+ const target = offsetToPx(to, box);
43845
+ const asked = distance(offsetToPx(targetBefore, box), target);
43711
43846
  if (!asked) {
43712
43847
  return 1;
43713
43848
  }
43714
- const left = distance(readOffset(from), target);
43849
+ const left = distance(offsetToPx(from, box), target);
43715
43850
  const ratio = left / asked;
43716
43851
  return ratio > 1 ? 1 : ratio;
43717
43852
  };
43718
43853
 
43854
+ // A translate ("-100% 0%", "-260px 0px", "none") as two numbers of pixels.
43855
+ // Percentages are the size of the box, which is what a translate resolves them
43856
+ // against — so an offset written either way can be measured against another.
43857
+ const offsetToPx = (offset, box) => {
43858
+ if (!offset || offset === "none") {
43859
+ return {
43860
+ x: 0,
43861
+ y: 0
43862
+ };
43863
+ }
43864
+ const [x = "0", y = "0"] = String(offset).trim().split(/\s+/);
43865
+ const toPx = (value, size) => value.endsWith("%") ? parseFloat(value) / 100 * size : parseFloat(value) || 0;
43866
+ return {
43867
+ x: toPx(x, box.width),
43868
+ y: toPx(y, box.height)
43869
+ };
43870
+ };
43871
+
43719
43872
  // A press landing while the track is already travelling: what is playing is
43720
43873
  // sent home in a fifth of the time it has left, and the press it could not take
43721
43874
  // yet is taken as soon as it lands. A press has to be FELT — nudging the pace
@@ -43735,6 +43888,87 @@ const hurryTravel = animation => {
43735
43888
  animation.playbackRate = rate > HURRY_RATE_MAX ? HURRY_RATE_MAX : rate;
43736
43889
  };
43737
43890
 
43891
+ // How far a pointer goes before it is a travel rather than a click: below this
43892
+ // a press that wandered a pixel is still a press, and the slides do not budge.
43893
+ const DRAG_START_THRESHOLD = 10;
43894
+ // How much of a box has to be pulled for letting go to carry on rather than put
43895
+ // the slide back. Under half, because a gesture that has clearly begun is an
43896
+ // intention: asking for the slide to be dragged all the way across turns a
43897
+ // travel into work.
43898
+ const DRAG_COMMIT_RATIO = 0.3;
43899
+ // A flick travels whatever the distance: the hand said "away" quickly, which is
43900
+ // the whole gesture — px/ms of pointer, and a few pixels to tell it from a tap
43901
+ // that shook.
43902
+ const DRAG_FLICK_VELOCITY = 0.4;
43903
+ const DRAG_FLICK_DISTANCE = 8;
43904
+ // Pulling towards nothing: the track follows at a fraction of the finger, so
43905
+ // the gesture is answered (something moves) while saying there is nothing that
43906
+ // way. Let go and it comes back — a wall one can lean on, never walk through.
43907
+ const DRAG_RESISTANCE = 0.3;
43908
+ // What a drag must not start on: something that reads the pointer itself. A
43909
+ // button or a link is not in the list — dragging from one travels, and the
43910
+ // click it would have made is swallowed on the way out (see onDragEnd).
43911
+ const DRAG_EXCLUDED_SELECTOR = ["input", "textarea", "select", '[contenteditable=""]', '[contenteditable="true"]', "[data-no-slide-drag]"].join(",");
43912
+
43913
+ // A scroller between the pointer and the slide it is in, with room left the way
43914
+ // the gesture goes: it gets the gesture, and the slides stay where they are —
43915
+ // dragging a row that scrolls sideways scrolls that row, and only a row with
43916
+ // nowhere left to go hands the travel over.
43917
+ const scrollRoomTowards = (fromElement, stopElement, axis, sign) => {
43918
+ let element = fromElement;
43919
+ while (element && element !== stopElement && element.nodeType === 1) {
43920
+ const size = axis === "x" ? element.clientWidth : element.clientHeight;
43921
+ const scrollSize = axis === "x" ? element.scrollWidth : element.scrollHeight;
43922
+ if (scrollSize > size + 1) {
43923
+ const {
43924
+ overflowX,
43925
+ overflowY
43926
+ } = getComputedStyle(element);
43927
+ const overflow = axis === "x" ? overflowX : overflowY;
43928
+ if (overflow === "auto" || overflow === "scroll") {
43929
+ const position = axis === "x" ? element.scrollLeft : element.scrollTop;
43930
+ // Dragging the content one way reveals what is on the other side of
43931
+ // it: to the right means going back up the scroll.
43932
+ const room = sign > 0 ? position : scrollSize - size - position;
43933
+ if (room > 1) {
43934
+ return true;
43935
+ }
43936
+ }
43937
+ }
43938
+ element = element.parentElement;
43939
+ }
43940
+ return false;
43941
+ };
43942
+
43943
+ // Which axes the map has anything on, read from the layout alone: it is what
43944
+ // says which way a touch may travel, and a touch is answered before any of the
43945
+ // DOM below has been looked at.
43946
+ const dragAxesOf = layout => {
43947
+ if (typeof layout === "string") {
43948
+ return layout === "column" ? "y" : "x";
43949
+ }
43950
+ const {
43951
+ placeOf
43952
+ } = parseAreas(layout);
43953
+ let hasX = false;
43954
+ let hasY = false;
43955
+ for (const {
43956
+ x,
43957
+ y
43958
+ } of placeOf.values()) {
43959
+ if (x > 0) {
43960
+ hasX = true;
43961
+ }
43962
+ if (y > 0) {
43963
+ hasY = true;
43964
+ }
43965
+ }
43966
+ if (hasX && hasY) {
43967
+ return "xy";
43968
+ }
43969
+ return hasY ? "y" : "x";
43970
+ };
43971
+
43738
43972
  // The ways out of a slide: whatever carries a travel command, the built-in
43739
43973
  // chevrons (SlideNavButton) and anything a caller wired by hand alike. They are
43740
43974
  // the container's chrome, not its content — see rememberFocus.
@@ -43858,12 +44092,17 @@ const readArea = slideElement => slideElement.getAttribute("data-slide-area") ||
43858
44092
  * one that just travelled there. Called once the travel is over, and in the
43859
44093
  * same render as the return to rest — anything later shows the old content
43860
44094
  * for a frame.
43861
- * @param {boolean} [props.keyboardTravel=true] - whether the arrows (and
44095
+ * @param {boolean} [props.travelByKeyboard=true] - whether the arrows (and
43862
44096
  * Home/End) walk the map. On by default: a map one can see is a map one
43863
44097
  * expects to walk. Off when the arrows mean something else where these slides
43864
44098
  * are — a list of choices one moves through, a picker whose screens are
43865
44099
  * steps rather than places — so the keys keep the meaning the content gives
43866
44100
  * them, and travelling stays something one asks for (a button, a command).
44101
+ * @param {boolean} [props.travelByDrag=true] - whether a pointer dragging the
44102
+ * slides travels. On by default: slides side by side are something one
44103
+ * expects to push around with a thumb. Off where the gesture belongs to the
44104
+ * content (a canvas one draws on, a map one pans), or where the slides are
44105
+ * steps of a form rather than a row one browses.
43867
44106
  * @param {string} [props.duration="300ms"] - how long a slide change takes.
43868
44107
  */
43869
44108
  const SlideContainer = ({
@@ -43873,7 +44112,8 @@ const SlideContainer = ({
43873
44112
  onCurrentChange,
43874
44113
  loop,
43875
44114
  onLoop,
43876
- keyboardTravel = true,
44115
+ travelByKeyboard = true,
44116
+ travelByDrag = true,
43877
44117
  duration = "300ms",
43878
44118
  children,
43879
44119
  ...rest
@@ -43933,8 +44173,16 @@ const SlideContainer = ({
43933
44173
  // A focus transfer read off the interaction that asked for the travel, kept
43934
44174
  // until the slide it is meant for holds its final DOM (see handOverFocus).
43935
44175
  const focusHandOverRef = useRef(null);
44176
+ // The gesture in hand, from the pointer that started it to the slides it
44177
+ // brought alongside. Null when no finger is on the box.
44178
+ const dragRef = useRef(null);
44179
+ // Where the travel about to be drawn departs from, when that is not where the
44180
+ // track rests: a slide let go of halfway carries on from under the finger.
44181
+ // Read and dropped by the layout effect, which is the one drawing it.
44182
+ const travelFromRef = useRef(null);
43936
44183
  const current = rollingArea ?? currentProp ?? currentAreaState;
43937
44184
  const vertical = layout === "column";
44185
+ const dragAxes = useMemo(() => travelByDrag ? dragAxesOf(layout) : null, [travelByDrag, layout]);
43938
44186
  // Which required slides have been answered (see Slide's own `required`). Held
43939
44187
  // here rather than in each slide because answering one says something about
43940
44188
  // the others: the steps after it were answered about a state that has just
@@ -44191,7 +44439,15 @@ const SlideContainer = ({
44191
44439
  // fetch, and having it in hand is also what allows the pace below.
44192
44440
  const travelInFlight = trackAnimationRef.current?.playState === "running";
44193
44441
  const offsetOnScreen = travelInFlight ? getComputedStyle(track).translate : undefined;
44194
- const offsetBefore = offsetOnScreen ?? offsetRef.current;
44442
+ // …and where a slide let go of halfway was left, which is the same fact
44443
+ // said by the gesture that put it there: the track is at rest as far as any
44444
+ // animation is concerned, so nothing else could tell.
44445
+ const offsetDragged = travelFromRef.current;
44446
+ travelFromRef.current = null;
44447
+ const offsetBefore = offsetDragged ?? offsetOnScreen ?? offsetRef.current;
44448
+ // The travel that was ASKED for is still one box, whatever is left of it to
44449
+ // cover: a slide dragged most of the way there finishes in what is left of
44450
+ // the duration rather than taking a full one over a few pixels.
44195
44451
  const offsetTargetBefore = offsetRef.current;
44196
44452
  offsetRef.current = offset;
44197
44453
  // Where the track ends up, always — the animation below only covers the way
@@ -44206,9 +44462,10 @@ const SlideContainer = ({
44206
44462
  // this only ever shortens it (a longer travel is not made slower, which
44207
44463
  // would make a two-box move drag).
44208
44464
  const travelRatio = ratioOfOneTravel(track, offsetBefore, offset, offsetTargetBefore);
44209
- // Already moving, so no ease-in to play: it would stall the track for an
44210
- // instant right where the eye is following it.
44211
- const easing = travelInFlight ? "ease-out" : "ease";
44465
+ // Already moving under an animation or under a finger that has just
44466
+ // let go so there is no ease-in to play: it would stall the track for
44467
+ // an instant right where the eye is following it.
44468
+ const easing = travelInFlight || offsetDragged ? "ease-out" : "ease";
44212
44469
  // Cancelled rather than layered: two animations on the same property
44213
44470
  // would blend, and what one sees then is neither of the two moves.
44214
44471
  trackAnimationRef.current?.cancel();
@@ -44233,11 +44490,12 @@ const SlideContainer = ({
44233
44490
  trackAnimationRef.current.finished.then(settleTravel, () => {
44234
44491
  // cancelled by the next travel — that one carries the stage on
44235
44492
  });
44236
- } else if (stage && trackAnimationRef.current?.playState !== "running") {
44493
+ } else if (stage && !dragRef.current?.axis && trackAnimationRef.current?.playState !== "running") {
44237
44494
  // Staged with nothing left to play: a travel that was drawn and then had
44238
44495
  // its animation taken away (a duration set to 0, a re-render landing
44239
44496
  // between the two). Struck at once rather than left standing, since the
44240
- // thing it was standing for is over.
44497
+ // thing it was standing for is over. A gesture in hand is a stage that is
44498
+ // standing for something — the finger still holding it.
44241
44499
  settleTravel();
44242
44500
  }
44243
44501
  // A window waiting for its travel to be over (see goToArea's own loop
@@ -44282,6 +44540,9 @@ const SlideContainer = ({
44282
44540
  if (!stageRef.current) {
44283
44541
  drawnAreaRef.current = currentArea;
44284
44542
  }
44543
+ // The finger has the last word: everything above drew the map at rest, and
44544
+ // where the track actually is right now is where the gesture put it.
44545
+ paintDrag();
44285
44546
  });
44286
44547
 
44287
44548
  /**
@@ -44567,6 +44828,363 @@ const SlideContainer = ({
44567
44828
  const moveNext = event => vertical ? move(0, 1, event) || move(1, 0, event) : move(1, 0, event) || move(0, 1, event);
44568
44829
  const movePrevious = event => vertical ? move(0, -1, event) || move(-1, 0, event) : move(-1, 0, event) || move(0, -1, event);
44569
44830
 
44831
+ // Where the track is right now, as the gesture left it: the resting place of
44832
+ // the slide being dragged, plus what the pointer has pulled since.
44833
+ const paintDrag = () => {
44834
+ const drag = dragRef.current;
44835
+ const track = trackRef.current;
44836
+ // Nothing to paint for a pointer that is only resting on the box: until it
44837
+ // has an axis a gesture has moved nothing and knows no geometry.
44838
+ if (!drag || !drag.axis || !track) {
44839
+ return;
44840
+ }
44841
+ const x = drag.baseOffset.x + drag.pull.x;
44842
+ const y = drag.baseOffset.y + drag.pull.y;
44843
+ drag.offset = `${x}px ${y}px`;
44844
+ track.style.setProperty("--slide-container-offset", drag.offset);
44845
+ };
44846
+
44847
+ // The two slides the gesture can bring in, placed one box either side of the
44848
+ // one being dragged — the same stage a travel builds, except that both ends
44849
+ // are set up at once because the finger has not said yet which way it goes.
44850
+ const stageDrag = drag => {
44851
+ const {
44852
+ slideElements,
44853
+ placeOf
44854
+ } = readMap();
44855
+ const step = drag.axis === "x" ? {
44856
+ x: 1,
44857
+ y: 0
44858
+ } : {
44859
+ x: 0,
44860
+ y: 1
44861
+ };
44862
+ const placeByArea = new Map();
44863
+ placeByArea.set(drag.area, drag.basePlace);
44864
+ if (drag.areaBack) {
44865
+ placeByArea.set(drag.areaBack, {
44866
+ x: drag.basePlace.x - step.x,
44867
+ y: drag.basePlace.y - step.y
44868
+ });
44869
+ }
44870
+ if (drag.areaOn) {
44871
+ placeByArea.set(drag.areaOn, {
44872
+ x: drag.basePlace.x + step.x,
44873
+ y: drag.basePlace.y + step.y
44874
+ });
44875
+ }
44876
+ stageRef.current = {
44877
+ placeByArea,
44878
+ area: drag.area
44879
+ };
44880
+ for (const slideElement of slideElements) {
44881
+ const area = readArea(slideElement);
44882
+ const {
44883
+ x,
44884
+ y
44885
+ } = placeByArea.get(area) || placeOf.get(area) || {
44886
+ x: 0,
44887
+ y: 0
44888
+ };
44889
+ slideElement.style.setProperty("--slide-offset", `${x * 100}% ${y * 100}%`);
44890
+ slideElement.toggleAttribute("data-slide-offstage", !placeByArea.has(area));
44891
+ }
44892
+ };
44893
+
44894
+ // Let go of without enough of a gesture to travel: the slide comes back to
44895
+ // where it was, over the distance it was pulled — so a slide barely moved
44896
+ // snaps back and one dragged most of the way there takes its time.
44897
+ const returnToRest = drag => {
44898
+ const track = trackRef.current;
44899
+ if (!track) {
44900
+ return;
44901
+ }
44902
+ const restOffset = `${drag.baseOffset.x}px ${drag.baseOffset.y}px`;
44903
+ const durationMs = durationToMs(duration);
44904
+ const pulled = Math.abs(drag.pull[drag.axis]);
44905
+ const size = drag.axis === "x" ? drag.box.width : drag.box.height;
44906
+ trackAnimationRef.current?.cancel();
44907
+ trackAnimationRef.current = null;
44908
+ track.style.setProperty("--slide-container-offset", restOffset);
44909
+ if (!durationMs || !pulled) {
44910
+ settleTravel();
44911
+ return;
44912
+ }
44913
+ const animation = track.animate([{
44914
+ translate: drag.offset
44915
+ }, {
44916
+ translate: restOffset
44917
+ }], {
44918
+ duration: durationMs * (pulled / size),
44919
+ easing: "ease-out"
44920
+ });
44921
+ trackAnimationRef.current = animation;
44922
+ animation.finished.then(settleTravel, () => {
44923
+ // cancelled by a travel asked for since — that one carries the stage on
44924
+ });
44925
+ };
44926
+ const onDragMove = pointerMoveEvent => {
44927
+ const drag = dragRef.current;
44928
+ if (!drag || pointerMoveEvent.pointerId !== drag.pointerId) {
44929
+ return;
44930
+ }
44931
+ if (!drag.axis) {
44932
+ const reachX = Math.abs(pointerMoveEvent.clientX - drag.startX);
44933
+ const reachY = Math.abs(pointerMoveEvent.clientY - drag.startY);
44934
+ if (reachX < DRAG_START_THRESHOLD && reachY < DRAG_START_THRESHOLD) {
44935
+ return;
44936
+ }
44937
+ // ONE axis, decided by the first few pixels and never revisited: a
44938
+ // diagonal gesture would ask for two travels at once and only one slide
44939
+ // can arrive — so the finger picks the axis it leans on, and the slides
44940
+ // walk that one alone.
44941
+ const axis = reachX >= reachY ? "x" : "y";
44942
+ const sign = axis === "x" ? Math.sign(pointerMoveEvent.clientX - drag.startX) : Math.sign(pointerMoveEvent.clientY - drag.startY);
44943
+ const areaBack = axis === "x" ? areaTowards(-1, 0) : areaTowards(0, -1);
44944
+ const areaOn = axis === "x" ? areaTowards(1, 0) : areaTowards(0, 1);
44945
+ // Everything positional is read HERE rather than when the pointer landed:
44946
+ // the travel that was playing then may have arrived since, and it is what
44947
+ // the slides are doing at the moment the gesture takes them over that the
44948
+ // gesture must carry on from.
44949
+ const track = trackRef.current;
44950
+ const {
44951
+ slideElements,
44952
+ placeOf
44953
+ } = readMap();
44954
+ const currentElement = slideElements.find(slideElement => slideElement.hasAttribute("data-current")) || slideElements[0];
44955
+ const box = track.getBoundingClientRect();
44956
+ if (!areaBack && !areaOn || !currentElement || !box.width || !box.height || scrollRoomTowards(drag.target, currentElement, axis, sign)) {
44957
+ // Nothing that way, or something else with a better claim on the
44958
+ // gesture: given up rather than half-taken, so whatever else wants it
44959
+ // (a scroller, the page) gets it whole.
44960
+ drag.stop();
44961
+ dragRef.current = null;
44962
+ return;
44963
+ }
44964
+ const area = readArea(currentElement);
44965
+ // Where the slide being dragged stands: where the stage put it while a
44966
+ // travel is playing, its place on the map otherwise.
44967
+ const stage = stageRef.current;
44968
+ const basePlace = stage?.placeByArea.get(area) || placeOf.get(area) || {
44969
+ x: 0,
44970
+ y: 0
44971
+ };
44972
+ const baseOffset = {
44973
+ x: -basePlace.x * box.width,
44974
+ y: -basePlace.y * box.height
44975
+ };
44976
+ // Where the track IS, taken over from whatever was playing: a travel
44977
+ // grabbed mid-flight carries on from under the finger, so the animation
44978
+ // is dropped and its position kept.
44979
+ const onScreen = trackAnimationRef.current?.playState === "running" ? offsetToPx(getComputedStyle(track).translate, box) : baseOffset;
44980
+ trackAnimationRef.current?.cancel();
44981
+ trackAnimationRef.current = null;
44982
+ drag.axis = axis;
44983
+ drag.areaBack = areaBack;
44984
+ drag.areaOn = areaOn;
44985
+ drag.area = area;
44986
+ drag.box = box;
44987
+ drag.basePlace = basePlace;
44988
+ drag.baseOffset = baseOffset;
44989
+ // Where the track was when the gesture took it over — nowhere, unless it
44990
+ // was travelling. Every pull is measured from it.
44991
+ drag.slack = {
44992
+ x: onScreen.x - baseOffset.x,
44993
+ y: onScreen.y - baseOffset.y
44994
+ };
44995
+ drag.pull = {
44996
+ ...drag.slack
44997
+ };
44998
+ // The pixels spent deciding are not pulled back: the slide starts moving
44999
+ // from where the finger is now, so it follows it exactly rather than
45000
+ // jumping the threshold it just crossed.
45001
+ drag.startX = pointerMoveEvent.clientX;
45002
+ drag.startY = pointerMoveEvent.clientY;
45003
+ stageDrag(drag);
45004
+ drag.lastPosition = axis === "x" ? pointerMoveEvent.clientX : pointerMoveEvent.clientY;
45005
+ drag.lastTime = pointerMoveEvent.timeStamp;
45006
+ containerRef.current.toggleAttribute("data-slide-dragging", true);
45007
+ // Every move from here on, wherever the finger wanders — off the box, off
45008
+ // the window — and the release with it.
45009
+ containerRef.current.setPointerCapture(drag.pointerId);
45010
+ }
45011
+ const {
45012
+ axis
45013
+ } = drag;
45014
+ const size = axis === "x" ? drag.box.width : drag.box.height;
45015
+ const moved = axis === "x" ? pointerMoveEvent.clientX - drag.startX : pointerMoveEvent.clientY - drag.startY;
45016
+ // Measured from where the gesture took the track over, never from what the
45017
+ // move before it painted: the resistance below would otherwise be applied
45018
+ // again to a value it has already shrunk, and a finger going nowhere would
45019
+ // see the slide creep back on its own.
45020
+ let pulled = drag.slack[axis] + moved;
45021
+ // Which slide is being pulled in: dragging the track to the right brings in
45022
+ // the one on the left, which is the one BEFORE it.
45023
+ const areaPulled = pulled > 0 ? drag.areaBack : drag.areaOn;
45024
+ if (!areaPulled) {
45025
+ pulled *= DRAG_RESISTANCE;
45026
+ }
45027
+ if (pulled > size) {
45028
+ pulled = size;
45029
+ } else if (pulled < -size) {
45030
+ pulled = -size;
45031
+ }
45032
+ drag.pull = {
45033
+ ...drag.pull,
45034
+ [axis]: pulled
45035
+ };
45036
+ paintDrag();
45037
+ // How fast the hand is going, so that letting go says something a distance
45038
+ // cannot: a short flick travels, a long slow drag put back does not.
45039
+ const position = axis === "x" ? pointerMoveEvent.clientX : pointerMoveEvent.clientY;
45040
+ const elapsed = pointerMoveEvent.timeStamp - drag.lastTime;
45041
+ if (elapsed > 0) {
45042
+ const instant = (position - drag.lastPosition) / elapsed;
45043
+ drag.velocity = drag.velocity * 0.4 + instant * 0.6;
45044
+ }
45045
+ drag.lastPosition = position;
45046
+ drag.lastTime = pointerMoveEvent.timeStamp;
45047
+ };
45048
+ const onDragEnd = pointerEvent => {
45049
+ const drag = dragRef.current;
45050
+ if (!drag || pointerEvent.pointerId !== drag.pointerId) {
45051
+ return;
45052
+ }
45053
+ drag.stop();
45054
+ dragRef.current = null;
45055
+ containerRef.current?.removeAttribute("data-slide-dragging");
45056
+ if (!drag.axis) {
45057
+ // A press that never became a gesture: nothing was staged, nothing moved.
45058
+ return;
45059
+ }
45060
+ // The click the browser makes of a press that travelled: swallowed, or
45061
+ // letting go over a button would press it. Capture, so it never reaches
45062
+ // what it landed on, and dropped right after in case none comes.
45063
+ const swallowClick = clickEvent => {
45064
+ clickEvent.stopPropagation();
45065
+ clickEvent.preventDefault();
45066
+ };
45067
+ document.addEventListener("click", swallowClick, {
45068
+ capture: true
45069
+ });
45070
+ setTimeout(() => {
45071
+ document.removeEventListener("click", swallowClick, {
45072
+ capture: true
45073
+ });
45074
+ });
45075
+ const {
45076
+ axis
45077
+ } = drag;
45078
+ const size = axis === "x" ? drag.box.width : drag.box.height;
45079
+ const pulled = drag.pull[axis];
45080
+ const sign = pulled > 0 ? 1 : -1;
45081
+ const areaPulled = pulled > 0 ? drag.areaBack : drag.areaOn;
45082
+ // A hand that stopped before letting go has said "here", whatever it was
45083
+ // doing a moment earlier — so the speed only counts while it is still going.
45084
+ const velocity = pointerEvent.timeStamp - drag.lastTime > 100 ? 0 : drag.velocity;
45085
+ const flicked = Math.abs(velocity) > DRAG_FLICK_VELOCITY && Math.sign(velocity) === sign && Math.abs(pulled) > DRAG_FLICK_DISTANCE;
45086
+ const travels =
45087
+ // A gesture taken away rather than let go of (the browser scrolling
45088
+ // something else, a call coming in) said nothing: the slide goes back.
45089
+ pointerEvent.type !== "pointercancel" && areaPulled && (flicked || Math.abs(pulled) > size * DRAG_COMMIT_RATIO);
45090
+ if (!travels) {
45091
+ returnToRest(drag);
45092
+ return;
45093
+ }
45094
+ // Where the slide is being left, for the travel to depart from instead of
45095
+ // from the map.
45096
+ travelFromRef.current = drag.offset;
45097
+ const moved = axis === "x" ? move(-sign, 0, pointerEvent) : move(0, -sign, pointerEvent);
45098
+ if (!moved) {
45099
+ // Nowhere to go after all — a slide holding on to the user (preventNav).
45100
+ travelFromRef.current = null;
45101
+ returnToRest(drag);
45102
+ return;
45103
+ }
45104
+ // A container whose `current` is held outside and was not moved: nothing
45105
+ // rendered, so nothing drew the travel and the track is still under where
45106
+ // the finger left it. One frame is all it takes to know.
45107
+ requestAnimationFrame(() => {
45108
+ if (travelFromRef.current) {
45109
+ travelFromRef.current = null;
45110
+ returnToRest(drag);
45111
+ }
45112
+ });
45113
+ };
45114
+ const startDrag = pointerDownEvent => {
45115
+ if (!travelByDrag || dragRef.current || pointerDownEvent.button !== 0) {
45116
+ return;
45117
+ }
45118
+ // A window mid-roll has nothing to drag yet: it is on its way somewhere and
45119
+ // the content that goes with it has not moved (see goToArea).
45120
+ if (rollingRef.current) {
45121
+ return;
45122
+ }
45123
+ const target = pointerDownEvent.target;
45124
+ if (!target.closest || target.closest(DRAG_EXCLUDED_SELECTOR)) {
45125
+ return;
45126
+ }
45127
+ const containerEl = containerRef.current;
45128
+ const onMove = pointerMoveEvent => {
45129
+ onDragMove(pointerMoveEvent);
45130
+ };
45131
+ const onEnd = pointerEvent => {
45132
+ onDragEnd(pointerEvent);
45133
+ };
45134
+ // The browser's own drag, which a mouse starts on a link or an image after
45135
+ // a few pixels: it would take the pointer away mid-gesture and leave the
45136
+ // slides hanging.
45137
+ const preventNativeDrag = dragStartEvent => {
45138
+ dragStartEvent.preventDefault();
45139
+ };
45140
+ const stop = () => {
45141
+ containerEl.removeEventListener("pointermove", onMove);
45142
+ containerEl.removeEventListener("dragstart", preventNativeDrag);
45143
+ window.removeEventListener("pointerup", onEnd);
45144
+ window.removeEventListener("pointercancel", onEnd);
45145
+ };
45146
+ dragRef.current = {
45147
+ pointerId: pointerDownEvent.pointerId,
45148
+ target,
45149
+ startX: pointerDownEvent.clientX,
45150
+ startY: pointerDownEvent.clientY,
45151
+ // Nothing but a press so far: the axis, the slides either side of the one
45152
+ // being dragged and where they all stand are read the moment the finger
45153
+ // says which way it is going (see onDragMove).
45154
+ axis: null,
45155
+ areaBack: null,
45156
+ areaOn: null,
45157
+ slack: {
45158
+ x: 0,
45159
+ y: 0
45160
+ },
45161
+ pull: {
45162
+ x: 0,
45163
+ y: 0
45164
+ },
45165
+ offset: null,
45166
+ velocity: 0,
45167
+ lastPosition: 0,
45168
+ lastTime: pointerDownEvent.timeStamp,
45169
+ stop
45170
+ };
45171
+ containerEl.addEventListener("pointermove", onMove);
45172
+ containerEl.addEventListener("dragstart", preventNativeDrag);
45173
+ // On the window, not on the box: a pointer released outside it (or taken
45174
+ // away by the browser) must still end the gesture, or the slides would stay
45175
+ // where the finger left them.
45176
+ window.addEventListener("pointerup", onEnd);
45177
+ window.addEventListener("pointercancel", onEnd);
45178
+ };
45179
+
45180
+ // A gesture is listening on things that outlive this component.
45181
+ useLayoutEffect(() => {
45182
+ return () => {
45183
+ dragRef.current?.stop();
45184
+ dragRef.current = null;
45185
+ };
45186
+ }, []);
45187
+
44570
45188
  // Arrows walk the map, Home/End jump to its ends — but only where those keys
44571
45189
  // mean nothing else: applyKeyboardShortcuts refuses to intercept a key the
44572
45190
  // focused element has a native use for, so an arrow inside a text field still
@@ -44593,27 +45211,27 @@ const SlideContainer = ({
44593
45211
  // `enabled` leaves the key to whatever else wants it (see
44594
45212
  // keyboard_shortcuts.js), rather than swallowing it here.
44595
45213
  arrowright: {
44596
- enabled: keyboardTravel,
45214
+ enabled: travelByKeyboard,
44597
45215
  handler: e => travelled(move(1, 0, e))
44598
45216
  },
44599
45217
  arrowleft: {
44600
- enabled: keyboardTravel,
45218
+ enabled: travelByKeyboard,
44601
45219
  handler: e => travelled(move(-1, 0, e))
44602
45220
  },
44603
45221
  arrowdown: {
44604
- enabled: keyboardTravel,
45222
+ enabled: travelByKeyboard,
44605
45223
  handler: e => travelled(move(0, 1, e))
44606
45224
  },
44607
45225
  arrowup: {
44608
- enabled: keyboardTravel,
45226
+ enabled: travelByKeyboard,
44609
45227
  handler: e => travelled(move(0, -1, e))
44610
45228
  },
44611
45229
  home: {
44612
- enabled: keyboardTravel,
45230
+ enabled: travelByKeyboard,
44613
45231
  handler: e => travelled(goToEnd(false, e))
44614
45232
  },
44615
45233
  end: {
44616
- enabled: keyboardTravel,
45234
+ enabled: travelByKeyboard,
44617
45235
  handler: e => travelled(goToEnd(true, e))
44618
45236
  }
44619
45237
  });
@@ -44626,6 +45244,16 @@ const SlideContainer = ({
44626
45244
  ref: containerRef,
44627
45245
  baseClassName: "navi_slide_container",
44628
45246
  "data-slide-container": ""
45247
+ // Which axes a touch may travel on, said in the DOM: what the browser
45248
+ // does with a finger is decided by CSS (touch-action) before any of this
45249
+ // has seen the gesture.
45250
+ ,
45251
+
45252
+ "data-travel-by-drag": dragAxes ?? undefined,
45253
+ onPointerDown: e => {
45254
+ startDrag(e);
45255
+ rest.onPointerDown?.(e);
45256
+ }
44629
45257
  // The focusable one, and a Tab stop: a slide is not (see Slide), so the
44630
45258
  // keyboard lands on what the current slide holds, and on this box when it
44631
45259
  // holds nothing. It is also what makes the arrows and Home/End reachable
@@ -51063,6 +51691,12 @@ const VISIBILITY_HIDDEN_STYLE = {
51063
51691
  * carries a `signal`, aborted when the list stops wanting those rows (the
51064
51692
  * window has moved on) — pass it to fetch to call the request off.
51065
51693
  *
51694
+ * A resource answers through its page reader:
51695
+ * `itemsAction={GAME.GET_PAGE.bindParams({ radar })}` — the rows are upserted
51696
+ * into the store on their way in, so the list draws store items rather than
51697
+ * copies of the JSON. The list holds the pages, the store holds the objects
51698
+ * (see docs/resource.md).
51699
+ *
51066
51700
  * A collection held in memory answers synchronously: `itemsAction={() => rows}`.
51067
51701
  * What it gives back is kept, so a collection that changes as a whole (a search
51068
51702
  * reordering it) is a different collection: give the run a `key` that changes
@@ -51594,6 +52228,9 @@ const useItemStore = ({
51594
52228
  };
51595
52229
  let result;
51596
52230
  try {
52231
+ if (typeof itemsAction !== "function") {
52232
+ throw new TypeError(`itemsAction must be a function, received ${itemsAction}. A resource feeds a list through its page reader: itemsAction={RESOURCE.GET_PAGE.bindParams(...)} — its other actions keep one response and cannot answer a range.`);
52233
+ }
51597
52234
  result = itemsAction(range);
51598
52235
  } catch (e) {
51599
52236
  failed(e);