@jsenv/navi 0.29.171 → 0.29.172
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 +159 -8
- package/dist/jsenv_navi.js.map +5 -5
- package/docs/AI_INSTRUCTIONS.md +18 -2
- package/docs/interactions.md +6 -0
- package/docs/list_action.md +121 -0
- package/docs/popup_open.md +70 -6
- package/package.json +1 -1
package/dist/jsenv_navi.js
CHANGED
|
@@ -3301,15 +3301,23 @@ const SYMBOL_OBJECT_SIGNAL = Symbol.for("navi_object_signal");
|
|
|
3301
3301
|
* resolves priorities between the four operation sets (reset > rerun > run >
|
|
3302
3302
|
* prerun) and performs them.
|
|
3303
3303
|
*
|
|
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.
|
|
3304
|
+
* Memory design (the surprising part): by default nothing here keeps actions
|
|
3305
|
+
* alive. Child actions are held through ephemerons (createJsValueWeakMap) so a
|
|
3306
|
+
* child and its params are garbage-collected together, running actions live in
|
|
3307
|
+
* iterable *weak* sets, and property/signal mirroring uses weakEffect. Three
|
|
3308
3308
|
* consequences to be aware of:
|
|
3309
3309
|
* - an action can exist in several places only if everyone shares the same
|
|
3310
3310
|
* instance (the caches above are what makes lookups return it);
|
|
3311
3311
|
* - prerun actions may have no other reference yet, so
|
|
3312
|
-
* prerunProtectionRegistry pins them
|
|
3312
|
+
* prerunProtectionRegistry pins them until they answer;
|
|
3313
|
+
* - an answer outlives the screen that asked for it only where the question
|
|
3314
|
+
* declared `keep` (createKeptAnswerRegistry); everything else is asked again.
|
|
3315
|
+
*
|
|
3316
|
+
* What makes that design fragile: reading an action's signals from inside an
|
|
3317
|
+
* effect subscribes that effect to them, and the root action never dies — so
|
|
3318
|
+
* the effect, and everything its closure holds, never dies either. Stringifying
|
|
3319
|
+
* an action for a debug message is the easy way to do it by accident, which is
|
|
3320
|
+
* why toString peeks.
|
|
3313
3321
|
*
|
|
3314
3322
|
* A failing run rejects, and writes its error into errorSignal as well. The
|
|
3315
3323
|
* callers that cannot take a rejection — a run started from a signal effect, a
|
|
@@ -3424,6 +3432,58 @@ const prerunProtectionRegistry = (() => {
|
|
|
3424
3432
|
};
|
|
3425
3433
|
})();
|
|
3426
3434
|
|
|
3435
|
+
/**
|
|
3436
|
+
* The answers a root action keeps once the screen that asked for them is gone.
|
|
3437
|
+
*
|
|
3438
|
+
* An action holds its answer for exactly as long as something references it, so
|
|
3439
|
+
* without this the lifetime of an answer is whatever the garbage collector
|
|
3440
|
+
* decides: coming back to a screen re-asks the same question, or does not,
|
|
3441
|
+
* depending on when a collection happened to run.
|
|
3442
|
+
*
|
|
3443
|
+
* Age is read when someone looks an answer up, never on a timer. A screen
|
|
3444
|
+
* nobody is on must not talk to the network, and an answer expiring under a
|
|
3445
|
+
* screen someone IS on would empty it mid-read.
|
|
3446
|
+
*/
|
|
3447
|
+
const createKeptAnswerRegistry = ({ ms, max }) => {
|
|
3448
|
+
// insertion order is recency order: looking an answer up re-inserts it, so
|
|
3449
|
+
// the first key is always the least recently used one
|
|
3450
|
+
const answeredAtMap = new Map();
|
|
3451
|
+
|
|
3452
|
+
return {
|
|
3453
|
+
keep: (action) => {
|
|
3454
|
+
answeredAtMap.delete(action);
|
|
3455
|
+
answeredAtMap.set(action, Date.now());
|
|
3456
|
+
if (max === undefined) {
|
|
3457
|
+
return;
|
|
3458
|
+
}
|
|
3459
|
+
while (answeredAtMap.size > max) {
|
|
3460
|
+
const [leastRecentlyUsed] = answeredAtMap.keys();
|
|
3461
|
+
answeredAtMap.delete(leastRecentlyUsed);
|
|
3462
|
+
}
|
|
3463
|
+
},
|
|
3464
|
+
|
|
3465
|
+
drop: (action) => {
|
|
3466
|
+
answeredAtMap.delete(action);
|
|
3467
|
+
},
|
|
3468
|
+
|
|
3469
|
+
touch: (action) => {
|
|
3470
|
+
const answeredAt = answeredAtMap.get(action);
|
|
3471
|
+
if (answeredAt === undefined) {
|
|
3472
|
+
return;
|
|
3473
|
+
}
|
|
3474
|
+
answeredAtMap.delete(action);
|
|
3475
|
+
if (ms !== undefined && Date.now() - answeredAt > ms) {
|
|
3476
|
+
// reset rather than drop the instance: equal params must keep giving
|
|
3477
|
+
// the same action, otherwise whoever still holds this one would be
|
|
3478
|
+
// reading a different instance than whoever asks for it next.
|
|
3479
|
+
action.reset({ reason: "kept answer expired" });
|
|
3480
|
+
return;
|
|
3481
|
+
}
|
|
3482
|
+
answeredAtMap.set(action, answeredAt);
|
|
3483
|
+
},
|
|
3484
|
+
};
|
|
3485
|
+
};
|
|
3486
|
+
|
|
3427
3487
|
const formatActionSet = (actionSet, prefix = "") => {
|
|
3428
3488
|
let message = prefix;
|
|
3429
3489
|
for (const action of actionSet) {
|
|
@@ -3823,12 +3883,38 @@ const mergeActionParams = (currentParams, newParams) => {
|
|
|
3823
3883
|
};
|
|
3824
3884
|
|
|
3825
3885
|
const actionWeakMap = new WeakMap();
|
|
3886
|
+
/**
|
|
3887
|
+
* Wraps an async callback into an action: a question, its answer, and the
|
|
3888
|
+
* signals saying where that answer is.
|
|
3889
|
+
*
|
|
3890
|
+
* @param {Function} callback
|
|
3891
|
+
* @param {object} [rootOptions]
|
|
3892
|
+
* @param {{ ms?: number, max?: number }} [rootOptions.keep] - how long an
|
|
3893
|
+
* answer stays good once it has landed. Without it an answer lives exactly as
|
|
3894
|
+
* long as something references it: the screen that asked goes away, and
|
|
3895
|
+
* coming back asks the same question again at a moment nothing in the code
|
|
3896
|
+
* decides.
|
|
3897
|
+
*
|
|
3898
|
+
* `ms` counts from the moment the answer landed; `max` caps how many answers
|
|
3899
|
+
* are held at once, the least recently looked-at going first. Age is read
|
|
3900
|
+
* when someone looks, never on a timer — a screen nobody is on must not talk
|
|
3901
|
+
* to the network — and an expired answer is `reset()`, so the question goes
|
|
3902
|
+
* back out through whoever asks for it next.
|
|
3903
|
+
*
|
|
3904
|
+
* It is declared here rather than on each binding because the answer to "how
|
|
3905
|
+
* long is this still true?" belongs to the question: a court list is good for
|
|
3906
|
+
* the session, a count of people watching grows on its own and is worth a few
|
|
3907
|
+
* minutes, a payment status is worth nothing at all.
|
|
3908
|
+
*/
|
|
3826
3909
|
const createAction = (callback, rootOptions = {}) => {
|
|
3827
3910
|
const existing = actionWeakMap.get(callback);
|
|
3828
3911
|
if (existing) {
|
|
3829
3912
|
return existing;
|
|
3830
3913
|
}
|
|
3831
3914
|
|
|
3915
|
+
const keptAnswerRegistry = rootOptions.keep
|
|
3916
|
+
? createKeptAnswerRegistry(rootOptions.keep)
|
|
3917
|
+
: null;
|
|
3832
3918
|
let rootAction;
|
|
3833
3919
|
|
|
3834
3920
|
const createActionCore = (options, { parentAction } = {}) => {
|
|
@@ -4060,6 +4146,9 @@ const createAction = (callback, rootOptions = {}) => {
|
|
|
4060
4146
|
}
|
|
4061
4147
|
const existingChildAction = childActionWeakMap.get(newParamsOrSignal);
|
|
4062
4148
|
if (existingChildAction) {
|
|
4149
|
+
if (keptAnswerRegistry) {
|
|
4150
|
+
keptAnswerRegistry.touch(existingChildAction);
|
|
4151
|
+
}
|
|
4063
4152
|
return existingChildAction;
|
|
4064
4153
|
}
|
|
4065
4154
|
const childAction = _bindParams(
|
|
@@ -4198,7 +4287,10 @@ const createAction = (callback, rootOptions = {}) => {
|
|
|
4198
4287
|
paramsSignal.value = nextParams;
|
|
4199
4288
|
return true;
|
|
4200
4289
|
},
|
|
4201
|
-
|
|
4290
|
+
// peek: an action gets stringified into debug messages built inside
|
|
4291
|
+
// effects, and subscribing there would keep those effects — and everything
|
|
4292
|
+
// their closures hold — alive for as long as the action itself.
|
|
4293
|
+
toString: () => actionCallSourceSignal.peek(),
|
|
4202
4294
|
meta,
|
|
4203
4295
|
debug: (...args) => {
|
|
4204
4296
|
if (!meta.debug && !DEBUG$2) {
|
|
@@ -4362,6 +4454,9 @@ const createAction = (callback, rootOptions = {}) => {
|
|
|
4362
4454
|
onComplete?.(data, action);
|
|
4363
4455
|
completeSideEffectCleanup = completeSideEffect?.(action);
|
|
4364
4456
|
});
|
|
4457
|
+
if (keptAnswerRegistry) {
|
|
4458
|
+
keptAnswerRegistry.keep(action);
|
|
4459
|
+
}
|
|
4365
4460
|
if (DEBUG$2) {
|
|
4366
4461
|
console.log(`"${action}": completed`);
|
|
4367
4462
|
}
|
|
@@ -4477,6 +4572,9 @@ const createAction = (callback, rootOptions = {}) => {
|
|
|
4477
4572
|
}
|
|
4478
4573
|
|
|
4479
4574
|
prerunProtectionRegistry.unprotect(action);
|
|
4575
|
+
if (keptAnswerRegistry) {
|
|
4576
|
+
keptAnswerRegistry.drop(action);
|
|
4577
|
+
}
|
|
4480
4578
|
|
|
4481
4579
|
if (sideEffectCleanup) {
|
|
4482
4580
|
sideEffectCleanup(reason);
|
|
@@ -4726,7 +4824,7 @@ const createActionProxyFromSignal = (
|
|
|
4726
4824
|
);
|
|
4727
4825
|
},
|
|
4728
4826
|
replaceParams: null, // Will be set below
|
|
4729
|
-
toString: () =>
|
|
4827
|
+
toString: () => callSourceSignal.peek(), // peek: see the action's toString
|
|
4730
4828
|
meta: action.meta,
|
|
4731
4829
|
|
|
4732
4830
|
paramsSignal: proxyParamsSignal,
|
|
@@ -7268,6 +7366,33 @@ const openCallout = (message, {
|
|
|
7268
7366
|
preventScroll: true
|
|
7269
7367
|
});
|
|
7270
7368
|
}
|
|
7369
|
+
close_on_escape_from_anywhere: {
|
|
7370
|
+
// Escape closes a callout by reaching its anchor (above), which works while
|
|
7371
|
+
// the anchor holds the keyboard. Some do not: a callout opened with
|
|
7372
|
+
// `skipFocus` leaves the focus where it was, and a press on a blocked list
|
|
7373
|
+
// row leaves it nowhere at all — the row refuses the press, focus included.
|
|
7374
|
+
// The callout is then unreachable by keyboard, and "click somewhere else to
|
|
7375
|
+
// make it go" is not an answer for someone not using a pointer.
|
|
7376
|
+
//
|
|
7377
|
+
// Only for the ones that cannot be heard otherwise, and only while they are
|
|
7378
|
+
// open: a callout the anchor can hear about is left to the anchor, so
|
|
7379
|
+
// Escape goes on meaning what it means where the user is.
|
|
7380
|
+
if (anchorElement && anchorElement.contains(document.activeElement)) {
|
|
7381
|
+
break close_on_escape_from_anywhere;
|
|
7382
|
+
}
|
|
7383
|
+
const onDocumentKeydown = e => {
|
|
7384
|
+
if (e.key !== "Escape") {
|
|
7385
|
+
return;
|
|
7386
|
+
}
|
|
7387
|
+
requestClose(e, "escape_key");
|
|
7388
|
+
e.stopPropagation();
|
|
7389
|
+
e.preventDefault();
|
|
7390
|
+
};
|
|
7391
|
+
document.addEventListener("keydown", onDocumentKeydown, true);
|
|
7392
|
+
addTeardown(() => {
|
|
7393
|
+
document.removeEventListener("keydown", onDocumentKeydown, true);
|
|
7394
|
+
});
|
|
7395
|
+
}
|
|
7271
7396
|
{
|
|
7272
7397
|
const handleCustomCloseEvent = e => {
|
|
7273
7398
|
onRequestClose(e);
|
|
@@ -56989,6 +57114,15 @@ const PopupClose = ({
|
|
|
56989
57114
|
command: "--navi-close",
|
|
56990
57115
|
icon: true,
|
|
56991
57116
|
variant: "discrete"
|
|
57117
|
+
// Dismissing writes nothing to whatever control the popup belongs to, so
|
|
57118
|
+
// that control's read-only and disabled are not about this cross: a
|
|
57119
|
+
// read-only picker still opens to be read (see readonly_constraint.js),
|
|
57120
|
+
// and what is opened has to be closable. Whether closing is allowed at
|
|
57121
|
+
// this instant is the popup's own question, answered by the popup — a
|
|
57122
|
+
// dialog holds the close while an action inside it runs (see
|
|
57123
|
+
// findBusyElementInside in dialog.jsx).
|
|
57124
|
+
,
|
|
57125
|
+
whenSelfInteractionsBlocked: "ignore"
|
|
56992
57126
|
// The cross is drawn at the control size, which is a few millimetres
|
|
56993
57127
|
// wide; the padding is what makes it a target a thumb can hit.
|
|
56994
57128
|
,
|
|
@@ -66067,8 +66201,25 @@ const ListItemReal = props => {
|
|
|
66067
66201
|
if (event.button !== 0 || isOverlaidOnRow(event)) {
|
|
66068
66202
|
return;
|
|
66069
66203
|
}
|
|
66070
|
-
blockInteraction(event);
|
|
66071
66204
|
pressStartedHereRef.current = true;
|
|
66205
|
+
// A row that IS a control has one already able to answer this, in the same
|
|
66206
|
+
// words it uses for the keyboard, and it is the one the callout would be
|
|
66207
|
+
// anchored on either way — two answers would be two callouts on one anchor,
|
|
66208
|
+
// toggling each other off (see `reopen` in openCallout).
|
|
66209
|
+
//
|
|
66210
|
+
// Asked before the press is blocked, not after: a gate handed an event
|
|
66211
|
+
// already prevented reads it as "someone else took this one" and steps
|
|
66212
|
+
// back without a word (see onRequestInteraction).
|
|
66213
|
+
const controlHost = isControlRoot(event.currentTarget) ? findControlHost(event.currentTarget) : null;
|
|
66214
|
+
if (controlHost) {
|
|
66215
|
+
dispatchRequestInteraction(controlHost, {
|
|
66216
|
+
event,
|
|
66217
|
+
name: "press on a blocked row"
|
|
66218
|
+
});
|
|
66219
|
+
blockInteraction(event);
|
|
66220
|
+
return;
|
|
66221
|
+
}
|
|
66222
|
+
blockInteraction(event);
|
|
66072
66223
|
// One at a time, and not the one that just dismissed it (see the refs).
|
|
66073
66224
|
if (calloutRef.current && calloutRef.current.opened) {
|
|
66074
66225
|
return;
|