@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.
- package/dist/jsenv_navi.js +703 -62
- package/dist/jsenv_navi.js.map +92 -35
- package/docs/AI_INSTRUCTIONS.md +4 -0
- package/docs/actions.md +117 -250
- package/docs/list_refresh.md +131 -0
- package/docs/resource.md +90 -0
- package/docs/scroll.md +1 -1
- package/package.json +1 -1
package/dist/jsenv_navi.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
29827
|
-
|
|
29828
|
-
|
|
29829
|
-
|
|
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 =
|
|
43706
|
-
const asked = distance(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
44206
|
-
//
|
|
44207
|
-
|
|
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:
|
|
45214
|
+
enabled: travelByKeyboard,
|
|
44593
45215
|
handler: e => travelled(move(1, 0, e))
|
|
44594
45216
|
},
|
|
44595
45217
|
arrowleft: {
|
|
44596
|
-
enabled:
|
|
45218
|
+
enabled: travelByKeyboard,
|
|
44597
45219
|
handler: e => travelled(move(-1, 0, e))
|
|
44598
45220
|
},
|
|
44599
45221
|
arrowdown: {
|
|
44600
|
-
enabled:
|
|
45222
|
+
enabled: travelByKeyboard,
|
|
44601
45223
|
handler: e => travelled(move(0, 1, e))
|
|
44602
45224
|
},
|
|
44603
45225
|
arrowup: {
|
|
44604
|
-
enabled:
|
|
45226
|
+
enabled: travelByKeyboard,
|
|
44605
45227
|
handler: e => travelled(move(0, -1, e))
|
|
44606
45228
|
},
|
|
44607
45229
|
home: {
|
|
44608
|
-
enabled:
|
|
45230
|
+
enabled: travelByKeyboard,
|
|
44609
45231
|
handler: e => travelled(goToEnd(false, e))
|
|
44610
45232
|
},
|
|
44611
45233
|
end: {
|
|
44612
|
-
enabled:
|
|
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);
|