@jsenv/navi 0.29.171 → 0.29.173
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 +326 -12
- package/dist/jsenv_navi.js.map +12 -10
- package/docs/AI_INSTRUCTIONS.md +20 -2
- package/docs/interactions.md +6 -0
- package/docs/list_action.md +165 -0
- package/docs/popup_open.md +70 -6
- package/package.json +1 -1
package/dist/jsenv_navi.js
CHANGED
|
@@ -1489,6 +1489,11 @@ naviI18n.addAll({
|
|
|
1489
1489
|
fr: "Cette action attend une modification.",
|
|
1490
1490
|
en: "This action is waiting for a change.",
|
|
1491
1491
|
},
|
|
1492
|
+
// parallelGuard: the surface already has as many runs in flight as it allows
|
|
1493
|
+
"constraint.readonly.parallel_guard": {
|
|
1494
|
+
fr: "[max] action[s] déjà en cours, attendez qu'une se termine.",
|
|
1495
|
+
en: "[max] action[s] already in progress, wait for one to finish.",
|
|
1496
|
+
},
|
|
1492
1497
|
"constraint.readonly.network_policy": {
|
|
1493
1498
|
fr: "Hors ligne : ça ne peut pas partir.",
|
|
1494
1499
|
en: "Offline: this cannot be sent.",
|
|
@@ -3301,15 +3306,23 @@ const SYMBOL_OBJECT_SIGNAL = Symbol.for("navi_object_signal");
|
|
|
3301
3306
|
* resolves priorities between the four operation sets (reset > rerun > run >
|
|
3302
3307
|
* prerun) and performs them.
|
|
3303
3308
|
*
|
|
3304
|
-
* Memory design (the surprising part): nothing here keeps actions
|
|
3305
|
-
* Child actions are held through ephemerons (createJsValueWeakMap) so a
|
|
3306
|
-
* and its params are garbage-collected together, running actions live in
|
|
3307
|
-
* iterable *weak* sets, and property/signal mirroring uses weakEffect.
|
|
3309
|
+
* Memory design (the surprising part): by default nothing here keeps actions
|
|
3310
|
+
* alive. Child actions are held through ephemerons (createJsValueWeakMap) so a
|
|
3311
|
+
* child and its params are garbage-collected together, running actions live in
|
|
3312
|
+
* iterable *weak* sets, and property/signal mirroring uses weakEffect. Three
|
|
3308
3313
|
* consequences to be aware of:
|
|
3309
3314
|
* - an action can exist in several places only if everyone shares the same
|
|
3310
3315
|
* instance (the caches above are what makes lookups return it);
|
|
3311
3316
|
* - prerun actions may have no other reference yet, so
|
|
3312
|
-
* prerunProtectionRegistry pins them
|
|
3317
|
+
* prerunProtectionRegistry pins them until they answer;
|
|
3318
|
+
* - an answer outlives the screen that asked for it only where the question
|
|
3319
|
+
* declared `keep` (createKeptAnswerRegistry); everything else is asked again.
|
|
3320
|
+
*
|
|
3321
|
+
* What makes that design fragile: reading an action's signals from inside an
|
|
3322
|
+
* effect subscribes that effect to them, and the root action never dies — so
|
|
3323
|
+
* the effect, and everything its closure holds, never dies either. Stringifying
|
|
3324
|
+
* an action for a debug message is the easy way to do it by accident, which is
|
|
3325
|
+
* why toString peeks.
|
|
3313
3326
|
*
|
|
3314
3327
|
* A failing run rejects, and writes its error into errorSignal as well. The
|
|
3315
3328
|
* callers that cannot take a rejection — a run started from a signal effect, a
|
|
@@ -3424,6 +3437,58 @@ const prerunProtectionRegistry = (() => {
|
|
|
3424
3437
|
};
|
|
3425
3438
|
})();
|
|
3426
3439
|
|
|
3440
|
+
/**
|
|
3441
|
+
* The answers a root action keeps once the screen that asked for them is gone.
|
|
3442
|
+
*
|
|
3443
|
+
* An action holds its answer for exactly as long as something references it, so
|
|
3444
|
+
* without this the lifetime of an answer is whatever the garbage collector
|
|
3445
|
+
* decides: coming back to a screen re-asks the same question, or does not,
|
|
3446
|
+
* depending on when a collection happened to run.
|
|
3447
|
+
*
|
|
3448
|
+
* Age is read when someone looks an answer up, never on a timer. A screen
|
|
3449
|
+
* nobody is on must not talk to the network, and an answer expiring under a
|
|
3450
|
+
* screen someone IS on would empty it mid-read.
|
|
3451
|
+
*/
|
|
3452
|
+
const createKeptAnswerRegistry = ({ ms, max }) => {
|
|
3453
|
+
// insertion order is recency order: looking an answer up re-inserts it, so
|
|
3454
|
+
// the first key is always the least recently used one
|
|
3455
|
+
const answeredAtMap = new Map();
|
|
3456
|
+
|
|
3457
|
+
return {
|
|
3458
|
+
keep: (action) => {
|
|
3459
|
+
answeredAtMap.delete(action);
|
|
3460
|
+
answeredAtMap.set(action, Date.now());
|
|
3461
|
+
if (max === undefined) {
|
|
3462
|
+
return;
|
|
3463
|
+
}
|
|
3464
|
+
while (answeredAtMap.size > max) {
|
|
3465
|
+
const [leastRecentlyUsed] = answeredAtMap.keys();
|
|
3466
|
+
answeredAtMap.delete(leastRecentlyUsed);
|
|
3467
|
+
}
|
|
3468
|
+
},
|
|
3469
|
+
|
|
3470
|
+
drop: (action) => {
|
|
3471
|
+
answeredAtMap.delete(action);
|
|
3472
|
+
},
|
|
3473
|
+
|
|
3474
|
+
touch: (action) => {
|
|
3475
|
+
const answeredAt = answeredAtMap.get(action);
|
|
3476
|
+
if (answeredAt === undefined) {
|
|
3477
|
+
return;
|
|
3478
|
+
}
|
|
3479
|
+
answeredAtMap.delete(action);
|
|
3480
|
+
if (ms !== undefined && Date.now() - answeredAt > ms) {
|
|
3481
|
+
// reset rather than drop the instance: equal params must keep giving
|
|
3482
|
+
// the same action, otherwise whoever still holds this one would be
|
|
3483
|
+
// reading a different instance than whoever asks for it next.
|
|
3484
|
+
action.reset({ reason: "kept answer expired" });
|
|
3485
|
+
return;
|
|
3486
|
+
}
|
|
3487
|
+
answeredAtMap.set(action, answeredAt);
|
|
3488
|
+
},
|
|
3489
|
+
};
|
|
3490
|
+
};
|
|
3491
|
+
|
|
3427
3492
|
const formatActionSet = (actionSet, prefix = "") => {
|
|
3428
3493
|
let message = prefix;
|
|
3429
3494
|
for (const action of actionSet) {
|
|
@@ -3823,12 +3888,38 @@ const mergeActionParams = (currentParams, newParams) => {
|
|
|
3823
3888
|
};
|
|
3824
3889
|
|
|
3825
3890
|
const actionWeakMap = new WeakMap();
|
|
3891
|
+
/**
|
|
3892
|
+
* Wraps an async callback into an action: a question, its answer, and the
|
|
3893
|
+
* signals saying where that answer is.
|
|
3894
|
+
*
|
|
3895
|
+
* @param {Function} callback
|
|
3896
|
+
* @param {object} [rootOptions]
|
|
3897
|
+
* @param {{ ms?: number, max?: number }} [rootOptions.keep] - how long an
|
|
3898
|
+
* answer stays good once it has landed. Without it an answer lives exactly as
|
|
3899
|
+
* long as something references it: the screen that asked goes away, and
|
|
3900
|
+
* coming back asks the same question again at a moment nothing in the code
|
|
3901
|
+
* decides.
|
|
3902
|
+
*
|
|
3903
|
+
* `ms` counts from the moment the answer landed; `max` caps how many answers
|
|
3904
|
+
* are held at once, the least recently looked-at going first. Age is read
|
|
3905
|
+
* when someone looks, never on a timer — a screen nobody is on must not talk
|
|
3906
|
+
* to the network — and an expired answer is `reset()`, so the question goes
|
|
3907
|
+
* back out through whoever asks for it next.
|
|
3908
|
+
*
|
|
3909
|
+
* It is declared here rather than on each binding because the answer to "how
|
|
3910
|
+
* long is this still true?" belongs to the question: a court list is good for
|
|
3911
|
+
* the session, a count of people watching grows on its own and is worth a few
|
|
3912
|
+
* minutes, a payment status is worth nothing at all.
|
|
3913
|
+
*/
|
|
3826
3914
|
const createAction = (callback, rootOptions = {}) => {
|
|
3827
3915
|
const existing = actionWeakMap.get(callback);
|
|
3828
3916
|
if (existing) {
|
|
3829
3917
|
return existing;
|
|
3830
3918
|
}
|
|
3831
3919
|
|
|
3920
|
+
const keptAnswerRegistry = rootOptions.keep
|
|
3921
|
+
? createKeptAnswerRegistry(rootOptions.keep)
|
|
3922
|
+
: null;
|
|
3832
3923
|
let rootAction;
|
|
3833
3924
|
|
|
3834
3925
|
const createActionCore = (options, { parentAction } = {}) => {
|
|
@@ -4060,6 +4151,9 @@ const createAction = (callback, rootOptions = {}) => {
|
|
|
4060
4151
|
}
|
|
4061
4152
|
const existingChildAction = childActionWeakMap.get(newParamsOrSignal);
|
|
4062
4153
|
if (existingChildAction) {
|
|
4154
|
+
if (keptAnswerRegistry) {
|
|
4155
|
+
keptAnswerRegistry.touch(existingChildAction);
|
|
4156
|
+
}
|
|
4063
4157
|
return existingChildAction;
|
|
4064
4158
|
}
|
|
4065
4159
|
const childAction = _bindParams(
|
|
@@ -4198,7 +4292,10 @@ const createAction = (callback, rootOptions = {}) => {
|
|
|
4198
4292
|
paramsSignal.value = nextParams;
|
|
4199
4293
|
return true;
|
|
4200
4294
|
},
|
|
4201
|
-
|
|
4295
|
+
// peek: an action gets stringified into debug messages built inside
|
|
4296
|
+
// effects, and subscribing there would keep those effects — and everything
|
|
4297
|
+
// their closures hold — alive for as long as the action itself.
|
|
4298
|
+
toString: () => actionCallSourceSignal.peek(),
|
|
4202
4299
|
meta,
|
|
4203
4300
|
debug: (...args) => {
|
|
4204
4301
|
if (!meta.debug && !DEBUG$2) {
|
|
@@ -4362,6 +4459,9 @@ const createAction = (callback, rootOptions = {}) => {
|
|
|
4362
4459
|
onComplete?.(data, action);
|
|
4363
4460
|
completeSideEffectCleanup = completeSideEffect?.(action);
|
|
4364
4461
|
});
|
|
4462
|
+
if (keptAnswerRegistry) {
|
|
4463
|
+
keptAnswerRegistry.keep(action);
|
|
4464
|
+
}
|
|
4365
4465
|
if (DEBUG$2) {
|
|
4366
4466
|
console.log(`"${action}": completed`);
|
|
4367
4467
|
}
|
|
@@ -4477,6 +4577,9 @@ const createAction = (callback, rootOptions = {}) => {
|
|
|
4477
4577
|
}
|
|
4478
4578
|
|
|
4479
4579
|
prerunProtectionRegistry.unprotect(action);
|
|
4580
|
+
if (keptAnswerRegistry) {
|
|
4581
|
+
keptAnswerRegistry.drop(action);
|
|
4582
|
+
}
|
|
4480
4583
|
|
|
4481
4584
|
if (sideEffectCleanup) {
|
|
4482
4585
|
sideEffectCleanup(reason);
|
|
@@ -4726,7 +4829,7 @@ const createActionProxyFromSignal = (
|
|
|
4726
4829
|
);
|
|
4727
4830
|
},
|
|
4728
4831
|
replaceParams: null, // Will be set below
|
|
4729
|
-
toString: () =>
|
|
4832
|
+
toString: () => callSourceSignal.peek(), // peek: see the action's toString
|
|
4730
4833
|
meta: action.meta,
|
|
4731
4834
|
|
|
4732
4835
|
paramsSignal: proxyParamsSignal,
|
|
@@ -7268,6 +7371,33 @@ const openCallout = (message, {
|
|
|
7268
7371
|
preventScroll: true
|
|
7269
7372
|
});
|
|
7270
7373
|
}
|
|
7374
|
+
close_on_escape_from_anywhere: {
|
|
7375
|
+
// Escape closes a callout by reaching its anchor (above), which works while
|
|
7376
|
+
// the anchor holds the keyboard. Some do not: a callout opened with
|
|
7377
|
+
// `skipFocus` leaves the focus where it was, and a press on a blocked list
|
|
7378
|
+
// row leaves it nowhere at all — the row refuses the press, focus included.
|
|
7379
|
+
// The callout is then unreachable by keyboard, and "click somewhere else to
|
|
7380
|
+
// make it go" is not an answer for someone not using a pointer.
|
|
7381
|
+
//
|
|
7382
|
+
// Only for the ones that cannot be heard otherwise, and only while they are
|
|
7383
|
+
// open: a callout the anchor can hear about is left to the anchor, so
|
|
7384
|
+
// Escape goes on meaning what it means where the user is.
|
|
7385
|
+
if (anchorElement && anchorElement.contains(document.activeElement)) {
|
|
7386
|
+
break close_on_escape_from_anywhere;
|
|
7387
|
+
}
|
|
7388
|
+
const onDocumentKeydown = e => {
|
|
7389
|
+
if (e.key !== "Escape") {
|
|
7390
|
+
return;
|
|
7391
|
+
}
|
|
7392
|
+
requestClose(e, "escape_key");
|
|
7393
|
+
e.stopPropagation();
|
|
7394
|
+
e.preventDefault();
|
|
7395
|
+
};
|
|
7396
|
+
document.addEventListener("keydown", onDocumentKeydown, true);
|
|
7397
|
+
addTeardown(() => {
|
|
7398
|
+
document.removeEventListener("keydown", onDocumentKeydown, true);
|
|
7399
|
+
});
|
|
7400
|
+
}
|
|
7271
7401
|
{
|
|
7272
7402
|
const handleCustomCloseEvent = e => {
|
|
7273
7403
|
onRequestClose(e);
|
|
@@ -8240,7 +8370,16 @@ const createCalloutManager = (
|
|
|
8240
8370
|
// Resolved as the token is added, and kept with it: a token shown later
|
|
8241
8371
|
// (when the one covering it goes) must be drawn where it was pointing at
|
|
8242
8372
|
// the time, and the control itself is where a token pointing nowhere goes.
|
|
8243
|
-
|
|
8373
|
+
//
|
|
8374
|
+
// Unless the control has somewhere better to send it, which is why it is
|
|
8375
|
+
// given the event: a control drawn by something else — a list row whose
|
|
8376
|
+
// selection is a visually hidden checkbox — answers for a whole box, and
|
|
8377
|
+
// the sentence belongs where the press landed inside it rather than in
|
|
8378
|
+
// its middle (see getCalloutAnchorElement in list_selectable.jsx).
|
|
8379
|
+
anchorElement:
|
|
8380
|
+
anchorElement ||
|
|
8381
|
+
controller.getCalloutAnchorElement?.(event) ||
|
|
8382
|
+
controller.ref.current,
|
|
8244
8383
|
};
|
|
8245
8384
|
tokens.set(token, tokenData);
|
|
8246
8385
|
showToken(tokenData, event);
|
|
@@ -10102,6 +10241,15 @@ const readOnlyMessage = (field) => {
|
|
|
10102
10241
|
if (reason === "network-policy") {
|
|
10103
10242
|
return getNetworkPolicyReadOnlyMessage();
|
|
10104
10243
|
}
|
|
10244
|
+
// As many runs as the surface allows are already out (see parallel_guard.js):
|
|
10245
|
+
// what stops the press is the queue, and it says how long it is.
|
|
10246
|
+
if (reason === "parallel-guard") {
|
|
10247
|
+
const { max } = field.parallelGuard;
|
|
10248
|
+
return naviI18n("constraint.readonly.parallel_guard", {
|
|
10249
|
+
max,
|
|
10250
|
+
s: max > 1 ? "s" : "",
|
|
10251
|
+
});
|
|
10252
|
+
}
|
|
10105
10253
|
// A send button held back by the form above it, which holds nothing new (see
|
|
10106
10254
|
// Button's own `readOnlyWhileFormUnchanged`) — what stops the press is not the
|
|
10107
10255
|
// button, it is the form still waiting for a change, so that is what it says.
|
|
@@ -33397,6 +33545,70 @@ document.body.addEventListener(
|
|
|
33397
33545
|
{ capture: true },
|
|
33398
33546
|
);
|
|
33399
33547
|
|
|
33548
|
+
/**
|
|
33549
|
+
* How many runs a surface lets happen at once.
|
|
33550
|
+
*
|
|
33551
|
+
* A list whose rows each carry their own action is a place to act, row by row,
|
|
33552
|
+
* and nothing about it stops someone from starting a run on every row before
|
|
33553
|
+
* any of them comes back: a dozen requests in flight, a dozen rows waiting, and
|
|
33554
|
+
* a server asked to do a dozen things at once because a list happened to be
|
|
33555
|
+
* long. The guard is what makes the surface say "not yet" instead — the rows
|
|
33556
|
+
* that are not running go read-only and say what they are waiting for, and the
|
|
33557
|
+
* next press is possible again the moment one of the runs comes back.
|
|
33558
|
+
*
|
|
33559
|
+
* It counts runs, not values — which is what separates it from
|
|
33560
|
+
* `maxLengthGuard`, the one that says how many things a selection may HOLD.
|
|
33561
|
+
* Both refuse a gesture; they refuse it for unrelated reasons and a surface can
|
|
33562
|
+
* carry either, or both.
|
|
33563
|
+
*/
|
|
33564
|
+
const ParallelGuardContext = createContext(null);
|
|
33565
|
+
|
|
33566
|
+
/**
|
|
33567
|
+
* @param {number} max - how many runs may be in flight at once. `Infinity`
|
|
33568
|
+
* lifts the guard without taking it out of the tree.
|
|
33569
|
+
*/
|
|
33570
|
+
const useParallelGuard = (max) => {
|
|
33571
|
+
const guardRef = useRef(null);
|
|
33572
|
+
if (!guardRef.current) {
|
|
33573
|
+
// A Set rather than a counter: a control asks about ITSELF (its own run is
|
|
33574
|
+
// never what blocks it), and a start that arrives twice for one run must
|
|
33575
|
+
// not be counted twice.
|
|
33576
|
+
const runningSet = new Set();
|
|
33577
|
+
const runningCountSignal = signal(0);
|
|
33578
|
+
const guard = {
|
|
33579
|
+
max,
|
|
33580
|
+
runningCountSignal,
|
|
33581
|
+
claim: (controller) => {
|
|
33582
|
+
if (runningSet.has(controller)) {
|
|
33583
|
+
return;
|
|
33584
|
+
}
|
|
33585
|
+
runningSet.add(controller);
|
|
33586
|
+
runningCountSignal.value = runningSet.size;
|
|
33587
|
+
},
|
|
33588
|
+
release: (controller) => {
|
|
33589
|
+
if (!runningSet.delete(controller)) {
|
|
33590
|
+
return;
|
|
33591
|
+
}
|
|
33592
|
+
runningCountSignal.value = runningSet.size;
|
|
33593
|
+
},
|
|
33594
|
+
blocks: (controller) => {
|
|
33595
|
+
if (guard.max === Infinity) {
|
|
33596
|
+
return false;
|
|
33597
|
+
}
|
|
33598
|
+
if (runningSet.has(controller)) {
|
|
33599
|
+
return false;
|
|
33600
|
+
}
|
|
33601
|
+
// Read through the signal so a control held back re-renders — and
|
|
33602
|
+
// becomes pressable again — the moment one of the runs comes back.
|
|
33603
|
+
return runningCountSignal.value >= guard.max;
|
|
33604
|
+
},
|
|
33605
|
+
};
|
|
33606
|
+
guardRef.current = guard;
|
|
33607
|
+
}
|
|
33608
|
+
guardRef.current.max = max;
|
|
33609
|
+
return guardRef.current;
|
|
33610
|
+
};
|
|
33611
|
+
|
|
33400
33612
|
/**
|
|
33401
33613
|
* How a control tells the labels pointing at it what it is (disabled, readOnly,
|
|
33402
33614
|
* required) and when it goes away.
|
|
@@ -34312,6 +34524,9 @@ const useUIStateController = (
|
|
|
34312
34524
|
resetUIState: (e) => {
|
|
34313
34525
|
controller.setUIState(controller.state, e);
|
|
34314
34526
|
},
|
|
34527
|
+
// Read by the callout manager when it has nowhere else to point.
|
|
34528
|
+
getCalloutAnchorElement: (event) =>
|
|
34529
|
+
resolveCalloutAnchorElement(controller.ref.current, event),
|
|
34315
34530
|
// What the control shows becomes what is acknowledged: the outside said
|
|
34316
34531
|
// yes to it, so it is where a rollback goes back to. Left alone for a
|
|
34317
34532
|
// control whose value is GIVEN — the prop is already its truth — and
|
|
@@ -34535,6 +34750,44 @@ const firstDefinedChildUIState = (children) => {
|
|
|
34535
34750
|
return undefined;
|
|
34536
34751
|
};
|
|
34537
34752
|
|
|
34753
|
+
// Where a control that cannot point at itself puts what it has to say.
|
|
34754
|
+
//
|
|
34755
|
+
// A control drawn by something ELSE answers for a box bigger than itself: a
|
|
34756
|
+
// list row's selection is a visually hidden checkbox inside a whole pressable
|
|
34757
|
+
// row, so a callout anchored on the control lands in the middle of that row,
|
|
34758
|
+
// pointing at nothing. What the press landed on is what the sentence is about
|
|
34759
|
+
// — another control in the same box takes it (the button that invites, the
|
|
34760
|
+
// picker that opens), and the box itself takes what was aimed at the box.
|
|
34761
|
+
//
|
|
34762
|
+
// Answering null leaves the callout where it would have gone, which is also
|
|
34763
|
+
// what a box asks for once and for all with `data-callout-anchor="item"`.
|
|
34764
|
+
const resolveCalloutAnchorElement = (element, event) => {
|
|
34765
|
+
if (!element || !element.closest("[navi-visually-hidden]")) {
|
|
34766
|
+
return null;
|
|
34767
|
+
}
|
|
34768
|
+
const target = event?.target;
|
|
34769
|
+
if (!target || !target.closest) {
|
|
34770
|
+
return null;
|
|
34771
|
+
}
|
|
34772
|
+
const controlRoot = findControlRoot(element);
|
|
34773
|
+
if (
|
|
34774
|
+
!controlRoot ||
|
|
34775
|
+
controlRoot.getAttribute("data-callout-anchor") === "item"
|
|
34776
|
+
) {
|
|
34777
|
+
return null;
|
|
34778
|
+
}
|
|
34779
|
+
const controlHost = target.closest("[navi-control-host]");
|
|
34780
|
+
if (
|
|
34781
|
+
!controlHost ||
|
|
34782
|
+
controlHost === element ||
|
|
34783
|
+
!controlRoot.contains(controlHost) ||
|
|
34784
|
+
controlHost.closest("[navi-visually-hidden]")
|
|
34785
|
+
) {
|
|
34786
|
+
return null;
|
|
34787
|
+
}
|
|
34788
|
+
return controlHost;
|
|
34789
|
+
};
|
|
34790
|
+
|
|
34538
34791
|
// A run that came back with a yes settles what the control is worth — but only
|
|
34539
34792
|
// for the control the run BELONGS to. Anything with a `command` still fires its
|
|
34540
34793
|
// own navi_action_* events (it is given a placeholder action so they exist),
|
|
@@ -37370,6 +37623,7 @@ const useInteractiveProps = (props, {
|
|
|
37370
37623
|
const controlRequired = useContext(RequiredContext);
|
|
37371
37624
|
const controlLoadingFromAbove = useContext(LoadingContext$1);
|
|
37372
37625
|
const parentActionRequester = useContext(ActionRequesterContext);
|
|
37626
|
+
const parallelGuard = useContext(ParallelGuardContext);
|
|
37373
37627
|
const parentAction = useContext(ActionContext);
|
|
37374
37628
|
const actionStatus = useActionStatus(boundAction);
|
|
37375
37629
|
const networkPolicyReason = useNetworkPolicyReason();
|
|
@@ -37407,7 +37661,12 @@ const useInteractiveProps = (props, {
|
|
|
37407
37661
|
// why (see network_policy.js): a press that looks accepted and fails after
|
|
37408
37662
|
// is what the policy exists to avoid.
|
|
37409
37663
|
const heldByNetworkPolicy = networkPolicyReason !== null && (isWriteAction(boundAction) || Boolean(zoneStateApplies && parentAction && isWriteAction(parentAction)));
|
|
37410
|
-
|
|
37664
|
+
// Held back because the surface above already has as many runs in flight as
|
|
37665
|
+
// it allows (see parallel_guard.js). Only a control that would START one:
|
|
37666
|
+
// anything without an action of its own runs nothing and has nothing to
|
|
37667
|
+
// wait its turn for.
|
|
37668
|
+
const heldByParallelGuard = Boolean(zoneStateApplies && parallelGuard && props.action && parallelGuard.blocks(uiStateController));
|
|
37669
|
+
const readOnlyBase = readOnly || controlReadOnly || loadingBase || readOnlyFromParentMaxLengthGuard || heldByParallelGuard || controlInfo.readOnlyUncontrolled || heldByNetworkPolicy;
|
|
37411
37670
|
// An optimistic control trusts its action to succeed: the state the user
|
|
37412
37671
|
// just set stays visible and interactive while the action runs — no
|
|
37413
37672
|
// loading, no readonly. On failure resetOnError rolls the state back and
|
|
@@ -37452,6 +37711,13 @@ const useInteractiveProps = (props, {
|
|
|
37452
37711
|
} else {
|
|
37453
37712
|
controlHostProps["aria-readonly"] = readOnlyResolved ? "true" : "false";
|
|
37454
37713
|
}
|
|
37714
|
+
// Read by READONLY_CONSTRAINT to say how many runs are already out, and by
|
|
37715
|
+
// the action handlers below, which are where a run joins and leaves the
|
|
37716
|
+
// count (a labelled block away, hence through the controller).
|
|
37717
|
+
uiStateController.parallelGuard = parallelGuard;
|
|
37718
|
+
if (heldByParallelGuard) {
|
|
37719
|
+
controlHostProps["data-readonly-reason"] = "parallel-guard";
|
|
37720
|
+
}
|
|
37455
37721
|
if (heldByNetworkPolicy) {
|
|
37456
37722
|
// Read by READONLY_CONSTRAINT; wins over any other reason the control
|
|
37457
37723
|
// carries — nothing leaves, whatever else was holding it.
|
|
@@ -37695,6 +37961,7 @@ const useInteractiveProps = (props, {
|
|
|
37695
37961
|
// carried by every navi_action_* event of that run).
|
|
37696
37962
|
uiStateController.pendingActionEvent = e.detail.event;
|
|
37697
37963
|
uiStateController.actionInFlight = true;
|
|
37964
|
+
uiStateController.parallelGuard?.claim(uiStateController);
|
|
37698
37965
|
// The very instance this run uses, resolved now — while the UI state
|
|
37699
37966
|
// still holds the value the run was made for. detail.action may be a
|
|
37700
37967
|
// proxy following that state, and by the time anyone wants to abort
|
|
@@ -37709,6 +37976,7 @@ const useInteractiveProps = (props, {
|
|
|
37709
37976
|
e.detail.addSideEffect(outcome => {
|
|
37710
37977
|
uiStateController.actionInFlight = false;
|
|
37711
37978
|
uiStateController.runningAction = null;
|
|
37979
|
+
uiStateController.parallelGuard?.release(uiStateController);
|
|
37712
37980
|
const queuedEvent = uiStateController.queuedActionAllowedEvent;
|
|
37713
37981
|
if (!queuedEvent) {
|
|
37714
37982
|
return;
|
|
@@ -56983,12 +57251,22 @@ const translateOf = (axis, distance) =>
|
|
|
56983
57251
|
|
|
56984
57252
|
const PopupClose = ({
|
|
56985
57253
|
label,
|
|
57254
|
+
iconSize,
|
|
56986
57255
|
...rest
|
|
56987
57256
|
}) => {
|
|
56988
57257
|
return jsx(Button, {
|
|
56989
57258
|
command: "--navi-close",
|
|
56990
57259
|
icon: true,
|
|
56991
57260
|
variant: "discrete"
|
|
57261
|
+
// Dismissing writes nothing to whatever control the popup belongs to, so
|
|
57262
|
+
// that control's read-only and disabled are not about this cross: a
|
|
57263
|
+
// read-only picker still opens to be read (see readonly_constraint.js),
|
|
57264
|
+
// and what is opened has to be closable. Whether closing is allowed at
|
|
57265
|
+
// this instant is the popup's own question, answered by the popup — a
|
|
57266
|
+
// dialog holds the close while an action inside it runs (see
|
|
57267
|
+
// findBusyElementInside in dialog.jsx).
|
|
57268
|
+
,
|
|
57269
|
+
whenSelfInteractionsBlocked: "ignore"
|
|
56992
57270
|
// The cross is drawn at the control size, which is a few millimetres
|
|
56993
57271
|
// wide; the padding is what makes it a target a thumb can hit.
|
|
56994
57272
|
,
|
|
@@ -56997,6 +57275,7 @@ const PopupClose = ({
|
|
|
56997
57275
|
"aria-label": label === undefined ? naviI18n("button.close") : label,
|
|
56998
57276
|
...rest,
|
|
56999
57277
|
children: jsx(Icon, {
|
|
57278
|
+
size: iconSize,
|
|
57000
57279
|
children: jsx(CloseSvg, {})
|
|
57001
57280
|
})
|
|
57002
57281
|
});
|
|
@@ -63873,6 +64152,11 @@ const ListUI = props => {
|
|
|
63873
64152
|
})
|
|
63874
64153
|
});
|
|
63875
64154
|
};
|
|
64155
|
+
// How many of a list's own rows may be acting at once, when the rows are what
|
|
64156
|
+
// carry the action. Four rather than none: a list is a place where the same
|
|
64157
|
+
// gesture is available many times over, and nothing else stops a long one from
|
|
64158
|
+
// putting a request out for every row it draws.
|
|
64159
|
+
const PARALLEL_GUARD_DEFAULT = 4;
|
|
63876
64160
|
const ListFirstResolver = props => {
|
|
63877
64161
|
const Next = useNextResolver();
|
|
63878
64162
|
const refDefault = useRef(null);
|
|
@@ -63884,8 +64168,13 @@ const ListFirstResolver = props => {
|
|
|
63884
64168
|
virtualRef.current = createListVirtual();
|
|
63885
64169
|
}
|
|
63886
64170
|
props.virtual = virtualRef.current;
|
|
63887
|
-
|
|
63888
|
-
|
|
64171
|
+
const parallelGuard = useParallelGuard(props.parallelGuard ?? PARALLEL_GUARD_DEFAULT);
|
|
64172
|
+
return jsx(ParallelGuardContext.Provider, {
|
|
64173
|
+
value: parallelGuard,
|
|
64174
|
+
children: jsx(Next, {
|
|
64175
|
+
...props,
|
|
64176
|
+
parallelGuard: undefined
|
|
64177
|
+
})
|
|
63889
64178
|
});
|
|
63890
64179
|
};
|
|
63891
64180
|
|
|
@@ -63899,6 +64188,7 @@ const ListFirstResolver = props => {
|
|
|
63899
64188
|
* deselectable?: boolean,
|
|
63900
64189
|
* maxLength?: number,
|
|
63901
64190
|
* maxLengthGuard?: number,
|
|
64191
|
+
* parallelGuard?: number,
|
|
63902
64192
|
* standalone?: boolean,
|
|
63903
64193
|
* action?: (value: any) => void,
|
|
63904
64194
|
* uiAction?: (value: any) => void,
|
|
@@ -64075,6 +64365,13 @@ const ListFirstResolver = props => {
|
|
|
64075
64365
|
* taking — and `uiAction` is not called. The selected ones stay takeable
|
|
64076
64366
|
* back, so a selection that arrived too long can always be brought back
|
|
64077
64367
|
* under the limit. Implies `maxLength` for validity.
|
|
64368
|
+
* @param {number} [props.parallelGuard=4]
|
|
64369
|
+
* How many runs the rows may have in flight at once, for a list whose rows
|
|
64370
|
+
* carry their own `action` (a button per row). While that many are out, every
|
|
64371
|
+
* row that is not running goes read-only and says how many it is waiting on;
|
|
64372
|
+
* the next press is possible again as soon as one comes back. `Infinity`
|
|
64373
|
+
* lifts it. Counts runs, not values — `maxLengthGuard` above is the one that
|
|
64374
|
+
* says how many things the selection may hold.
|
|
64078
64375
|
* @param {boolean} [props.standalone]
|
|
64079
64376
|
* This list answers for itself: it does not register with the control group
|
|
64080
64377
|
* or picker around it, so its selection stays out of that value and nothing
|
|
@@ -66067,8 +66364,25 @@ const ListItemReal = props => {
|
|
|
66067
66364
|
if (event.button !== 0 || isOverlaidOnRow(event)) {
|
|
66068
66365
|
return;
|
|
66069
66366
|
}
|
|
66070
|
-
blockInteraction(event);
|
|
66071
66367
|
pressStartedHereRef.current = true;
|
|
66368
|
+
// A row that IS a control has one already able to answer this, in the same
|
|
66369
|
+
// words it uses for the keyboard, and it is the one the callout would be
|
|
66370
|
+
// anchored on either way — two answers would be two callouts on one anchor,
|
|
66371
|
+
// toggling each other off (see `reopen` in openCallout).
|
|
66372
|
+
//
|
|
66373
|
+
// Asked before the press is blocked, not after: a gate handed an event
|
|
66374
|
+
// already prevented reads it as "someone else took this one" and steps
|
|
66375
|
+
// back without a word (see onRequestInteraction).
|
|
66376
|
+
const controlHost = isControlRoot(event.currentTarget) ? findControlHost(event.currentTarget) : null;
|
|
66377
|
+
if (controlHost) {
|
|
66378
|
+
dispatchRequestInteraction(controlHost, {
|
|
66379
|
+
event,
|
|
66380
|
+
name: "press on a blocked row"
|
|
66381
|
+
});
|
|
66382
|
+
blockInteraction(event);
|
|
66383
|
+
return;
|
|
66384
|
+
}
|
|
66385
|
+
blockInteraction(event);
|
|
66072
66386
|
// One at a time, and not the one that just dismissed it (see the refs).
|
|
66073
66387
|
if (calloutRef.current && calloutRef.current.opened) {
|
|
66074
66388
|
return;
|