@jsenv/navi 0.29.19 → 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.
@@ -12545,10 +12545,17 @@ const createAction = (callback, rootOptions = {}) => {
12545
12545
  action.debug(`${action}.prerun(${stringifyForDisplay(options)})`);
12546
12546
  return dispatchSingleAction(action, "prerun", options);
12547
12547
  };
12548
+ /**
12549
+ * Requests the action's data. An action that is already RUNNING or
12550
+ * COMPLETED already has it, so the request is a no-op there: use `rerun()`
12551
+ * to force a fresh run ("refresh", "check now", any explicit user intent to
12552
+ * go back to the network).
12553
+ */
12548
12554
  const run = (options) => {
12549
12555
  action.debug(`${action}.run(${stringifyForDisplay(options)})`);
12550
12556
  return dispatchSingleAction(action, "run", options);
12551
12557
  };
12558
+ /** Resets the action and runs it again, whatever state it is in. */
12552
12559
  const rerun = (options) => {
12553
12560
  action.debug(`${action}.rerun(${stringifyForDisplay(options)})`);
12554
12561
  return dispatchSingleAction(action, "rerun", options);
@@ -17945,6 +17952,10 @@ const useExecuteAction = (
17945
17952
  */
17946
17953
 
17947
17954
  const CLICK_TO_EXPAND_SELECTOR = "summary, [aria-expanded]";
17955
+ // A popup is written inside whatever opened it, but it is not part of it on
17956
+ // screen: a control inside a popup must not be read as a click on the region
17957
+ // the popup happens to be nested in.
17958
+ const POPUP_SELECTOR = "[navi-control='popover'], [navi-control='dialog']";
17948
17959
 
17949
17960
  /**
17950
17961
  * Cancels `event` when the control consumed a click that a surrounding
@@ -17970,13 +17981,29 @@ const preventClickToExpand = (element, event) => {
17970
17981
  }
17971
17982
  // From the parent: a control that opens something carries its own
17972
17983
  // `aria-expanded` and would find itself.
17973
- const clickToExpandRegion = parentElement.closest(CLICK_TO_EXPAND_SELECTOR);
17984
+ const clickToExpandRegion = findClickToExpandRegion(parentElement);
17974
17985
  if (!clickToExpandRegion) {
17975
17986
  return;
17976
17987
  }
17977
17988
  event.preventDefault();
17978
17989
  };
17979
17990
 
17991
+ const findClickToExpandRegion = (element) => {
17992
+ let ancestor = element;
17993
+ while (ancestor) {
17994
+ // Tested first: a popup carries `aria-expanded` of its own, so it would
17995
+ // otherwise pass for the region containing its own content.
17996
+ if (ancestor.matches(POPUP_SELECTOR)) {
17997
+ return null;
17998
+ }
17999
+ if (ancestor.matches(CLICK_TO_EXPAND_SELECTOR)) {
18000
+ return ancestor;
18001
+ }
18002
+ ancestor = ancestor.parentElement;
18003
+ }
18004
+ return null;
18005
+ };
18006
+
17980
18007
  const clickDefaultActionIsInert = (element, event) => {
17981
18008
  if (!isInertOnClick(element)) {
17982
18009
  return false;
@@ -29822,37 +29849,14 @@ const getActionResultProperties = (action) => {
29822
29849
  return actionResultPropertiesMap.get(action);
29823
29850
  };
29824
29851
 
29825
- /*
29826
- * Default autorerun behavior explanation:
29827
- * GET: false (RECOMMENDED)
29828
- * What happens:
29829
- * - GET actions are reset by DELETE operations (not rerun)
29830
- * - DELETE operation on the displayed item would display nothing in the UI (action is in IDLE state)
29831
- * - PUT/PATCH operations update UI via signals, no rerun needed
29832
- * - This approach minimizes unnecessary API calls
29833
- *
29834
- * How to handle:
29835
- * - Applications can provide custom UI for deleted items (e.g., "Item not found")
29836
- * - Or redirect users to appropriate pages (e.g., back to list view)
29837
- *
29838
- * Alternative (NOT RECOMMENDED):
29839
- * - Use GET: ["DELETE"] to rerun and display 404 error received from backend
29840
- * - Poor UX: users expect immediate feedback, not loading + error state
29841
- *
29842
- * GET_MANY: ["POST"]
29843
- * - POST: New items may or may not appear in lists (depends on filters, pagination, etc.)
29844
- * Backend determines visibility better than client-side logic
29845
- * - DELETE: Excluded by default because:
29846
- * • UI handles deletions via store signals (selectAll filters out deleted items)
29847
- * • DELETE operations rarely change list content beyond item removal
29848
- * • Avoids unnecessary API calls (can be overridden if needed)
29849
- */
29852
+ // PUT/PATCH results update the UI through the store, and DELETE resets the GET
29853
+ // instead of rerunning it, so a GET rerun would only ever cost a request.
29854
+ // A POST is the one case the client cannot decide alone: whether a new item
29855
+ // belongs to a list depends on filters/pagination the backend owns.
29856
+ // Rationale in full, plus when to override: docs/list_refresh.md
29850
29857
  const defaultRerunOn = {
29851
29858
  GET: false,
29852
- GET_MANY: [
29853
- "POST",
29854
- // "DELETE"
29855
- ],
29859
+ GET_MANY: ["POST"],
29856
29860
  };
29857
29861
 
29858
29862
  // This handles ALL resource lifecycle logic (rerun/reset) across all resources
@@ -30194,6 +30198,109 @@ const getParamScope = (params) => {
30194
30198
  return newParamScope;
30195
30199
  };
30196
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
+
30197
30304
  const resourceLifecycleManager = createResourceLifecycleManager();
30198
30305
  const debug$2 = (args) => {
30199
30306
  {
@@ -30212,13 +30319,18 @@ const debug$2 = (args) => {
30212
30319
  * - GET / POST / PUT / PATCH → the full item object, e.g. `{ id, name }`
30213
30320
  * - DELETE → the id or `{ id }` of the removed item
30214
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 })}`).
30215
30327
  *
30216
30328
  * A sub-resource of the backend (`/games/:id/candidates`) must be modelled with a
30217
30329
  * relationship method, never as an `op`/`type` discriminator dispatched inside one
30218
30330
  * verb's callback.
30219
30331
  *
30220
30332
  * @param {string} name - resource name, used in action names and error messages
30221
- * @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 }`
30222
30334
  * @param {string} [restCallbacks.idKey] - primary key property, defaults to `"id"` (or the first `uniqueKeys` entry)
30223
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
30224
30336
  * @see docs/resource.md — relationships, callback return contracts, decision table
@@ -30242,6 +30354,7 @@ const resource = (
30242
30354
 
30243
30355
  GET,
30244
30356
  GET_MANY,
30357
+ GET_PAGE,
30245
30358
  POST,
30246
30359
  POST_MANY,
30247
30360
  PUT,
@@ -30307,6 +30420,7 @@ const resource = (
30307
30420
  restCallbacks: {
30308
30421
  GET,
30309
30422
  GET_MANY,
30423
+ GET_PAGE,
30310
30424
  POST,
30311
30425
  POST_MANY,
30312
30426
  PUT,
@@ -31388,6 +31502,16 @@ ${originalActionName} source location: ${locationInfo}`,
31388
31502
  if (restCallback === undefined) {
31389
31503
  continue;
31390
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
+ }
31391
31515
  const isMany = restCallbackKey.endsWith("_MANY");
31392
31516
  const verb = isMany
31393
31517
  ? restCallbackKey.replace("_MANY", "")
@@ -43558,6 +43682,13 @@ installImportMetaCssBuild(import.meta);/**
43558
43682
  * the slides in between fly past — least of all in a tab bar, where they are
43559
43683
  * not a road one travels but places one goes straight to.
43560
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
+ *
43561
43692
  * The slides live INSIDE the box, which is what makes this work for a popup: a
43562
43693
  * dialog and a popover are both promoted to the browser's top layer, so no
43563
43694
  * container of ours could ever hold two of them side by side and translate the
@@ -43605,6 +43736,28 @@ const css$A = /* css */`
43605
43736
  outline: none;
43606
43737
  }
43607
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
+
43608
43761
  /* Outside the box, which is where an outline is drawn by default: nothing
43609
43762
  inside can paint over it (the slides are all within), and this box's own
43610
43763
  overflow does not clip it either — an element's outline is not its own
@@ -43687,31 +43840,35 @@ const css$A = /* css */`
43687
43840
  // own box, because that is what its percentages resolve to.
43688
43841
  const ratioOfOneTravel = (track, from, to, targetBefore) => {
43689
43842
  const box = track.getBoundingClientRect();
43690
- const readOffset = offset => {
43691
- if (!offset || offset === "none") {
43692
- return {
43693
- x: 0,
43694
- y: 0
43695
- };
43696
- }
43697
- const [x = "0", y = "0"] = String(offset).trim().split(/\s+/);
43698
- const toPx = (value, size) => value.endsWith("%") ? parseFloat(value) / 100 * size : parseFloat(value) || 0;
43699
- return {
43700
- x: toPx(x, box.width),
43701
- y: toPx(y, box.height)
43702
- };
43703
- };
43704
43843
  const distance = (a, b) => Math.hypot(b.x - a.x, b.y - a.y);
43705
- const target = readOffset(to);
43706
- const asked = distance(readOffset(targetBefore), target);
43844
+ const target = offsetToPx(to, box);
43845
+ const asked = distance(offsetToPx(targetBefore, box), target);
43707
43846
  if (!asked) {
43708
43847
  return 1;
43709
43848
  }
43710
- const left = distance(readOffset(from), target);
43849
+ const left = distance(offsetToPx(from, box), target);
43711
43850
  const ratio = left / asked;
43712
43851
  return ratio > 1 ? 1 : ratio;
43713
43852
  };
43714
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
+
43715
43872
  // A press landing while the track is already travelling: what is playing is
43716
43873
  // sent home in a fifth of the time it has left, and the press it could not take
43717
43874
  // yet is taken as soon as it lands. A press has to be FELT — nudging the pace
@@ -43731,6 +43888,87 @@ const hurryTravel = animation => {
43731
43888
  animation.playbackRate = rate > HURRY_RATE_MAX ? HURRY_RATE_MAX : rate;
43732
43889
  };
43733
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
+
43734
43972
  // The ways out of a slide: whatever carries a travel command, the built-in
43735
43973
  // chevrons (SlideNavButton) and anything a caller wired by hand alike. They are
43736
43974
  // the container's chrome, not its content — see rememberFocus.
@@ -43854,12 +44092,17 @@ const readArea = slideElement => slideElement.getAttribute("data-slide-area") ||
43854
44092
  * one that just travelled there. Called once the travel is over, and in the
43855
44093
  * same render as the return to rest — anything later shows the old content
43856
44094
  * for a frame.
43857
- * @param {boolean} [props.keyboardTravel=true] - whether the arrows (and
44095
+ * @param {boolean} [props.travelByKeyboard=true] - whether the arrows (and
43858
44096
  * Home/End) walk the map. On by default: a map one can see is a map one
43859
44097
  * expects to walk. Off when the arrows mean something else where these slides
43860
44098
  * are — a list of choices one moves through, a picker whose screens are
43861
44099
  * steps rather than places — so the keys keep the meaning the content gives
43862
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.
43863
44106
  * @param {string} [props.duration="300ms"] - how long a slide change takes.
43864
44107
  */
43865
44108
  const SlideContainer = ({
@@ -43869,7 +44112,8 @@ const SlideContainer = ({
43869
44112
  onCurrentChange,
43870
44113
  loop,
43871
44114
  onLoop,
43872
- keyboardTravel = true,
44115
+ travelByKeyboard = true,
44116
+ travelByDrag = true,
43873
44117
  duration = "300ms",
43874
44118
  children,
43875
44119
  ...rest
@@ -43929,8 +44173,16 @@ const SlideContainer = ({
43929
44173
  // A focus transfer read off the interaction that asked for the travel, kept
43930
44174
  // until the slide it is meant for holds its final DOM (see handOverFocus).
43931
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);
43932
44183
  const current = rollingArea ?? currentProp ?? currentAreaState;
43933
44184
  const vertical = layout === "column";
44185
+ const dragAxes = useMemo(() => travelByDrag ? dragAxesOf(layout) : null, [travelByDrag, layout]);
43934
44186
  // Which required slides have been answered (see Slide's own `required`). Held
43935
44187
  // here rather than in each slide because answering one says something about
43936
44188
  // the others: the steps after it were answered about a state that has just
@@ -44187,7 +44439,15 @@ const SlideContainer = ({
44187
44439
  // fetch, and having it in hand is also what allows the pace below.
44188
44440
  const travelInFlight = trackAnimationRef.current?.playState === "running";
44189
44441
  const offsetOnScreen = travelInFlight ? getComputedStyle(track).translate : undefined;
44190
- 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.
44191
44451
  const offsetTargetBefore = offsetRef.current;
44192
44452
  offsetRef.current = offset;
44193
44453
  // Where the track ends up, always — the animation below only covers the way
@@ -44202,9 +44462,10 @@ const SlideContainer = ({
44202
44462
  // this only ever shortens it (a longer travel is not made slower, which
44203
44463
  // would make a two-box move drag).
44204
44464
  const travelRatio = ratioOfOneTravel(track, offsetBefore, offset, offsetTargetBefore);
44205
- // Already moving, so no ease-in to play: it would stall the track for an
44206
- // instant right where the eye is following it.
44207
- 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";
44208
44469
  // Cancelled rather than layered: two animations on the same property
44209
44470
  // would blend, and what one sees then is neither of the two moves.
44210
44471
  trackAnimationRef.current?.cancel();
@@ -44229,11 +44490,12 @@ const SlideContainer = ({
44229
44490
  trackAnimationRef.current.finished.then(settleTravel, () => {
44230
44491
  // cancelled by the next travel — that one carries the stage on
44231
44492
  });
44232
- } else if (stage && trackAnimationRef.current?.playState !== "running") {
44493
+ } else if (stage && !dragRef.current?.axis && trackAnimationRef.current?.playState !== "running") {
44233
44494
  // Staged with nothing left to play: a travel that was drawn and then had
44234
44495
  // its animation taken away (a duration set to 0, a re-render landing
44235
44496
  // between the two). Struck at once rather than left standing, since the
44236
- // 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.
44237
44499
  settleTravel();
44238
44500
  }
44239
44501
  // A window waiting for its travel to be over (see goToArea's own loop
@@ -44278,6 +44540,9 @@ const SlideContainer = ({
44278
44540
  if (!stageRef.current) {
44279
44541
  drawnAreaRef.current = currentArea;
44280
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();
44281
44546
  });
44282
44547
 
44283
44548
  /**
@@ -44563,6 +44828,363 @@ const SlideContainer = ({
44563
44828
  const moveNext = event => vertical ? move(0, 1, event) || move(1, 0, event) : move(1, 0, event) || move(0, 1, event);
44564
44829
  const movePrevious = event => vertical ? move(0, -1, event) || move(-1, 0, event) : move(-1, 0, event) || move(0, -1, event);
44565
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
+
44566
45188
  // Arrows walk the map, Home/End jump to its ends — but only where those keys
44567
45189
  // mean nothing else: applyKeyboardShortcuts refuses to intercept a key the
44568
45190
  // focused element has a native use for, so an arrow inside a text field still
@@ -44589,27 +45211,27 @@ const SlideContainer = ({
44589
45211
  // `enabled` leaves the key to whatever else wants it (see
44590
45212
  // keyboard_shortcuts.js), rather than swallowing it here.
44591
45213
  arrowright: {
44592
- enabled: keyboardTravel,
45214
+ enabled: travelByKeyboard,
44593
45215
  handler: e => travelled(move(1, 0, e))
44594
45216
  },
44595
45217
  arrowleft: {
44596
- enabled: keyboardTravel,
45218
+ enabled: travelByKeyboard,
44597
45219
  handler: e => travelled(move(-1, 0, e))
44598
45220
  },
44599
45221
  arrowdown: {
44600
- enabled: keyboardTravel,
45222
+ enabled: travelByKeyboard,
44601
45223
  handler: e => travelled(move(0, 1, e))
44602
45224
  },
44603
45225
  arrowup: {
44604
- enabled: keyboardTravel,
45226
+ enabled: travelByKeyboard,
44605
45227
  handler: e => travelled(move(0, -1, e))
44606
45228
  },
44607
45229
  home: {
44608
- enabled: keyboardTravel,
45230
+ enabled: travelByKeyboard,
44609
45231
  handler: e => travelled(goToEnd(false, e))
44610
45232
  },
44611
45233
  end: {
44612
- enabled: keyboardTravel,
45234
+ enabled: travelByKeyboard,
44613
45235
  handler: e => travelled(goToEnd(true, e))
44614
45236
  }
44615
45237
  });
@@ -44622,6 +45244,16 @@ const SlideContainer = ({
44622
45244
  ref: containerRef,
44623
45245
  baseClassName: "navi_slide_container",
44624
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
+ }
44625
45257
  // The focusable one, and a Tab stop: a slide is not (see Slide), so the
44626
45258
  // keyboard lands on what the current slide holds, and on this box when it
44627
45259
  // holds nothing. It is also what makes the arrows and Home/End reachable
@@ -51059,6 +51691,12 @@ const VISIBILITY_HIDDEN_STYLE = {
51059
51691
  * carries a `signal`, aborted when the list stops wanting those rows (the
51060
51692
  * window has moved on) — pass it to fetch to call the request off.
51061
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
+ *
51062
51700
  * A collection held in memory answers synchronously: `itemsAction={() => rows}`.
51063
51701
  * What it gives back is kept, so a collection that changes as a whole (a search
51064
51702
  * reordering it) is a different collection: give the run a `key` that changes
@@ -51590,6 +52228,9 @@ const useItemStore = ({
51590
52228
  };
51591
52229
  let result;
51592
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
+ }
51593
52234
  result = itemsAction(range);
51594
52235
  } catch (e) {
51595
52236
  failed(e);