@jsenv/navi 0.29.414 → 0.29.416
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/dev/jsenv_navi.js +220 -55
- package/dist/dev/jsenv_navi.js.map +15 -13
- package/dist/jsenv_navi.js +213 -52
- package/dist/jsenv_navi.js.map +15 -13
- package/docs/autofocus.md +19 -0
- package/docs/resource.md +7 -3
- package/docs/route_transitions.md +20 -6
- package/package.json +1 -1
package/dist/jsenv_navi.js
CHANGED
|
@@ -14110,6 +14110,31 @@ const moveFocusTo = (
|
|
|
14110
14110
|
});
|
|
14111
14111
|
};
|
|
14112
14112
|
|
|
14113
|
+
/**
|
|
14114
|
+
* Gives the focus back to `target` once the commit a close is part of is over:
|
|
14115
|
+
* for a close caused by an unmount (UNMOUNT_EVENT_TYPE in
|
|
14116
|
+
* layout/open_controller.js), which runs from a cleanup while the tree leaving
|
|
14117
|
+
* with it is still in the document. Only its end tells a target leaving with
|
|
14118
|
+
* it — disconnected by then, and skipped — from one staying, and a focused
|
|
14119
|
+
* element that leaves makes the removal of its whole subtree restyle it.
|
|
14120
|
+
*
|
|
14121
|
+
* And only into a void: the close took the focus with it, so something holding
|
|
14122
|
+
* the focus by then — a page arriving that focused a field of its own — took it
|
|
14123
|
+
* after, and keeps it.
|
|
14124
|
+
*/
|
|
14125
|
+
const giveFocusBackAfterCommit = (target, giveFocus) => {
|
|
14126
|
+
queueMicrotask(() => {
|
|
14127
|
+
if (!target || !target.isConnected) {
|
|
14128
|
+
return;
|
|
14129
|
+
}
|
|
14130
|
+
const focusedNow = document.activeElement;
|
|
14131
|
+
if (focusedNow && focusedNow !== document.body) {
|
|
14132
|
+
return;
|
|
14133
|
+
}
|
|
14134
|
+
giveFocus();
|
|
14135
|
+
});
|
|
14136
|
+
};
|
|
14137
|
+
|
|
14113
14138
|
const focusTransferTarget = (target, focusVisible) => {
|
|
14114
14139
|
moveFocusTo(target, { focusVisible });
|
|
14115
14140
|
if (target.hasAttribute("navi-autofocus-select")) {
|
|
@@ -14664,6 +14689,12 @@ const createOpenController = (
|
|
|
14664
14689
|
closeEvent,
|
|
14665
14690
|
`closed by focusout -> let focus go away`,
|
|
14666
14691
|
);
|
|
14692
|
+
} else if (findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
|
|
14693
|
+
// Left the tree: what held the focus before the opening may be
|
|
14694
|
+
// leaving with it (see giveFocusBackAfterCommit).
|
|
14695
|
+
giveFocusBackAfterCommit(focusTransfer.focusedElement, () => {
|
|
14696
|
+
focusTransfer.restoreFocus(closeEvent);
|
|
14697
|
+
});
|
|
14667
14698
|
} else {
|
|
14668
14699
|
// Only the mousedown, deliberately: a popup with no backdrop is
|
|
14669
14700
|
// closed by a `pointerdown` that belongs to the page (see
|
|
@@ -17425,14 +17456,8 @@ const createCalloutManager = (
|
|
|
17425
17456
|
};
|
|
17426
17457
|
if (findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
|
|
17427
17458
|
// Closed by a popup leaving the tree: the control may be leaving
|
|
17428
|
-
//
|
|
17429
|
-
|
|
17430
|
-
// restyles the whole subtree.
|
|
17431
|
-
queueMicrotask(() => {
|
|
17432
|
-
if (focusTarget.isConnected) {
|
|
17433
|
-
giveFocusBack();
|
|
17434
|
-
}
|
|
17435
|
-
});
|
|
17459
|
+
// with it (see giveFocusBackAfterCommit).
|
|
17460
|
+
giveFocusBackAfterCommit(focusTarget, giveFocusBack);
|
|
17436
17461
|
} else {
|
|
17437
17462
|
// Given before the callout leaves the document: removed while it
|
|
17438
17463
|
// holds the focus, it sends a focusout with nowhere to go up
|
|
@@ -23604,6 +23629,21 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
|
|
|
23604
23629
|
}
|
|
23605
23630
|
return result;
|
|
23606
23631
|
};
|
|
23632
|
+
// A computed of the row with this id, one per id: what a render reads to
|
|
23633
|
+
// follow that row and no other. An upsert replaces only the items it changes
|
|
23634
|
+
// (see assign below), so a row nobody wrote keeps its object, and its
|
|
23635
|
+
// computed stays still while the rest of the store moves — read through
|
|
23636
|
+
// `select`, a render follows the whole store. Kept as long as the store,
|
|
23637
|
+
// like the rows it holds.
|
|
23638
|
+
const itemSignalById = new Map();
|
|
23639
|
+
const itemSignalForId = (id) => {
|
|
23640
|
+
let itemSignal = itemSignalById.get(id);
|
|
23641
|
+
if (!itemSignal) {
|
|
23642
|
+
itemSignal = computed(() => idMapSignal.value.get(id));
|
|
23643
|
+
itemSignalById.set(id, itemSignal);
|
|
23644
|
+
}
|
|
23645
|
+
return itemSignal;
|
|
23646
|
+
};
|
|
23607
23647
|
const upsert = (...args) => {
|
|
23608
23648
|
const mutationsMap = new Map(); // Map<itemId, propertyMutations>
|
|
23609
23649
|
const triggerPropertyMutations = () => {
|
|
@@ -23994,6 +24034,7 @@ ${[idKey, ...uniqueKeys].join(", ")}`,
|
|
|
23994
24034
|
arraySignal,
|
|
23995
24035
|
select,
|
|
23996
24036
|
selectAll,
|
|
24037
|
+
itemSignalForId,
|
|
23997
24038
|
upsert,
|
|
23998
24039
|
drop,
|
|
23999
24040
|
|
|
@@ -25159,12 +25200,34 @@ const createResource = (
|
|
|
25159
25200
|
idKey,
|
|
25160
25201
|
uniqueKeys,
|
|
25161
25202
|
|
|
25162
|
-
// Reactive reads, not hooks:
|
|
25163
|
-
//
|
|
25164
|
-
//
|
|
25203
|
+
// Reactive reads, not hooks: nothing hook-shaped inside, so a loop or a
|
|
25204
|
+
// condition is fine. Each subscribes the render that calls it to what it
|
|
25205
|
+
// returns: the rows asked for, each through a computed of that row (see
|
|
25206
|
+
// itemSignalForId in array_signal_store.js), or the whole store for
|
|
25207
|
+
// `useArray` — a card reading its own row stays still while the page next
|
|
25208
|
+
// to it loads rows of its own. `useAllByIds` skips the ids the store does
|
|
25209
|
+
// not hold.
|
|
25165
25210
|
useArray: () => store.arraySignal.value,
|
|
25166
|
-
useById: (id) => store.
|
|
25167
|
-
useAllByIds: (idArray) =>
|
|
25211
|
+
useById: (id) => store.itemSignalForId(id).value,
|
|
25212
|
+
useAllByIds: (idArray) => {
|
|
25213
|
+
const items = [];
|
|
25214
|
+
for (const idOrProps of idArray) {
|
|
25215
|
+
let id = idOrProps;
|
|
25216
|
+
if (isProps(idOrProps) && !Object.hasOwn(idOrProps, idKey)) {
|
|
25217
|
+
// Named by a unique key: which row that is can only be read off
|
|
25218
|
+
// the whole store.
|
|
25219
|
+
const item = store.select(idOrProps);
|
|
25220
|
+
id = item ? item[idKey] : undefined;
|
|
25221
|
+
} else if (isProps(idOrProps)) {
|
|
25222
|
+
id = idOrProps[idKey];
|
|
25223
|
+
}
|
|
25224
|
+
const item = store.itemSignalForId(id).value;
|
|
25225
|
+
if (item) {
|
|
25226
|
+
items.push(item);
|
|
25227
|
+
}
|
|
25228
|
+
}
|
|
25229
|
+
return items;
|
|
25230
|
+
},
|
|
25168
25231
|
|
|
25169
25232
|
withParams: undefined,
|
|
25170
25233
|
one: undefined,
|
|
@@ -31341,7 +31404,11 @@ const useContentKeepingLeavingPages = branch => {
|
|
|
31341
31404
|
// mounted with, or it would be mounted again.
|
|
31342
31405
|
key = shown.key;
|
|
31343
31406
|
page = shown.page;
|
|
31344
|
-
} else if (shown && leavingPagesHold) {
|
|
31407
|
+
} else if (shown && leavingPagesHold && !document.querySelector(":modal")) {
|
|
31408
|
+
// Never while a modal dialog is open: it keeps everything but itself out
|
|
31409
|
+
// of reach — the page arriving could not even take the focus — for as long
|
|
31410
|
+
// as it is open, and one in a page kept while leaving stays open for the
|
|
31411
|
+
// whole movement. The page is then taken down at once, as without one.
|
|
31345
31412
|
leavingRef.current.push({
|
|
31346
31413
|
vnode: shown.vnode,
|
|
31347
31414
|
page: shown.page,
|
|
@@ -31388,10 +31455,17 @@ const useContentKeepingLeavingPages = branch => {
|
|
|
31388
31455
|
}
|
|
31389
31456
|
entry.hidden = true;
|
|
31390
31457
|
entry.page.leavingSignal.value = true;
|
|
31458
|
+
const focusedElement = document.activeElement;
|
|
31391
31459
|
let node = start.nextSibling;
|
|
31392
31460
|
while (node && node !== end) {
|
|
31393
31461
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
31394
31462
|
node.setAttribute(LEAVING_ATTRIBUTE, "");
|
|
31463
|
+
// What the keyboard held in it goes where the page's removal would
|
|
31464
|
+
// have sent it: a hidden element keeps the focus, and would go on
|
|
31465
|
+
// receiving the keys pressed during the movement.
|
|
31466
|
+
if (node.contains(focusedElement)) {
|
|
31467
|
+
focusedElement.blur();
|
|
31468
|
+
}
|
|
31395
31469
|
}
|
|
31396
31470
|
node = node.nextSibling;
|
|
31397
31471
|
}
|
|
@@ -43958,8 +44032,12 @@ const useInteractiveProps = (props, {
|
|
|
43958
44032
|
}
|
|
43959
44033
|
// Read by READONLY_CONSTRAINT to say how many runs are already out, and by
|
|
43960
44034
|
// the action handlers below, which are where a run joins and leaves the
|
|
43961
|
-
// count (a labelled block away, hence through the controller).
|
|
43962
|
-
|
|
44035
|
+
// count (a labelled block away, hence through the controller). None for a
|
|
44036
|
+
// control without an action of its own, by the same rule as being held
|
|
44037
|
+
// back: what it runs (the noop every such control has, a link's
|
|
44038
|
+
// navigation) is nothing the guard counts, and would hold a slot for its
|
|
44039
|
+
// length.
|
|
44040
|
+
uiStateController.parallelGuard = props.action ? parallelGuard : null;
|
|
43963
44041
|
if (heldByParallelGuard) {
|
|
43964
44042
|
controlHostProps["data-readonly-reason"] = "parallel-guard";
|
|
43965
44043
|
}
|
|
@@ -44233,7 +44311,11 @@ const useInteractiveProps = (props, {
|
|
|
44233
44311
|
// carried by every navi_action_* event of that run).
|
|
44234
44312
|
uiStateController.pendingActionEvent = e.detail.event;
|
|
44235
44313
|
uiStateController.actionInFlight = true;
|
|
44236
|
-
|
|
44314
|
+
// Given back to the guard it was counted in, whatever the control
|
|
44315
|
+
// holds by then: one that lost its action mid-run has no guard left
|
|
44316
|
+
// to name, and the slot would stay taken.
|
|
44317
|
+
const countedIn = uiStateController.parallelGuard;
|
|
44318
|
+
countedIn?.claim(uiStateController);
|
|
44237
44319
|
if (optimistic) {
|
|
44238
44320
|
syncOptimisticAttribute(true);
|
|
44239
44321
|
}
|
|
@@ -44251,7 +44333,7 @@ const useInteractiveProps = (props, {
|
|
|
44251
44333
|
e.detail.addSideEffect((outcome, outcomeEvent) => {
|
|
44252
44334
|
uiStateController.actionInFlight = false;
|
|
44253
44335
|
uiStateController.runningActionSignal.value = null;
|
|
44254
|
-
|
|
44336
|
+
countedIn?.release(uiStateController);
|
|
44255
44337
|
// The outcome callbacks are told from here, the run's side effect,
|
|
44256
44338
|
// and not from the onnavi_action_* handlers below: the run can
|
|
44257
44339
|
// outlive this control (its own answer, written to a store the
|
|
@@ -47783,6 +47865,90 @@ const BinderItemContext = createContext(null);
|
|
|
47783
47865
|
*/
|
|
47784
47866
|
const NavContext = createContext(null);
|
|
47785
47867
|
|
|
47868
|
+
/**
|
|
47869
|
+
* Whether the page one is on is `route` with `params` — what a link or a button
|
|
47870
|
+
* pointing at a route shows as current.
|
|
47871
|
+
*
|
|
47872
|
+
* Read through a computed of that one answer, so the component reading it
|
|
47873
|
+
* re-renders when the answer flips, and not whenever the route's status moves:
|
|
47874
|
+
* a page arriving on the route with other params — one profile opening, under a
|
|
47875
|
+
* list of links to a hundred other profiles — starts the route matching and
|
|
47876
|
+
* changes its params, and leaves every one of those links exactly as it was.
|
|
47877
|
+
*
|
|
47878
|
+
* `except`: routes whose matching vetoes the answer — a route named there is
|
|
47879
|
+
* somewhere else, whatever the rest of the reading says. `also`: routes whose
|
|
47880
|
+
* matching grants it. Both take a route or a list of routes.
|
|
47881
|
+
*/
|
|
47882
|
+
const useRouteCurrent = (route, params, { except, also } = {}) => {
|
|
47883
|
+
const exceptRoutes = toRouteList(except);
|
|
47884
|
+
const alsoRoutes = toRouteList(also);
|
|
47885
|
+
const paramsKey = getParamsCacheKey(params);
|
|
47886
|
+
const exceptKey = routeListKey(exceptRoutes);
|
|
47887
|
+
const alsoKey = routeListKey(alsoRoutes);
|
|
47888
|
+
const currentSignal = useMemo(() => {
|
|
47889
|
+
if (paramsKey === null) {
|
|
47890
|
+
return null;
|
|
47891
|
+
}
|
|
47892
|
+
return computed(() =>
|
|
47893
|
+
readRouteCurrent(route, params, exceptRoutes, alsoRoutes),
|
|
47894
|
+
);
|
|
47895
|
+
}, [route, paramsKey, exceptKey, alsoKey]);
|
|
47896
|
+
if (!currentSignal) {
|
|
47897
|
+
// Params no key can be made of (a date, an object): read on the spot, the
|
|
47898
|
+
// component then re-renders with the status of every route it reads.
|
|
47899
|
+
return readRouteCurrent(route, params, exceptRoutes, alsoRoutes);
|
|
47900
|
+
}
|
|
47901
|
+
return currentSignal.value;
|
|
47902
|
+
};
|
|
47903
|
+
|
|
47904
|
+
// Every signal is read even once the answer is known: reading is what
|
|
47905
|
+
// subscribes, and a route skipped today is the one whose change has to be heard
|
|
47906
|
+
// tomorrow.
|
|
47907
|
+
const readRouteCurrent = (route, params, exceptRoutes, alsoRoutes) => {
|
|
47908
|
+
const matching = route.matchingSignal.value;
|
|
47909
|
+
const paramsMatching = route.matchesParams(params);
|
|
47910
|
+
let someExceptMatching = false;
|
|
47911
|
+
for (const exceptRoute of exceptRoutes) {
|
|
47912
|
+
if (exceptRoute.matchingSignal.value) {
|
|
47913
|
+
someExceptMatching = true;
|
|
47914
|
+
}
|
|
47915
|
+
}
|
|
47916
|
+
let someAlsoMatching = false;
|
|
47917
|
+
for (const alsoRoute of alsoRoutes) {
|
|
47918
|
+
if (alsoRoute.matchingSignal.value) {
|
|
47919
|
+
someAlsoMatching = true;
|
|
47920
|
+
}
|
|
47921
|
+
}
|
|
47922
|
+
if (someExceptMatching) {
|
|
47923
|
+
return false;
|
|
47924
|
+
}
|
|
47925
|
+
return (matching && paramsMatching) || someAlsoMatching;
|
|
47926
|
+
};
|
|
47927
|
+
|
|
47928
|
+
const toRouteList = (routes) => {
|
|
47929
|
+
if (!routes) {
|
|
47930
|
+
return [];
|
|
47931
|
+
}
|
|
47932
|
+
return Array.isArray(routes) ? routes : [routes];
|
|
47933
|
+
};
|
|
47934
|
+
|
|
47935
|
+
// A list of routes as a key, since an inline `currentAlso={[A, B]}` is a new
|
|
47936
|
+
// array at every render of the parent.
|
|
47937
|
+
const routeIdByRoute = new WeakMap();
|
|
47938
|
+
let routeIdCount = 0;
|
|
47939
|
+
const routeListKey = (routes) => {
|
|
47940
|
+
let key = "";
|
|
47941
|
+
for (const route of routes) {
|
|
47942
|
+
let id = routeIdByRoute.get(route);
|
|
47943
|
+
if (id === undefined) {
|
|
47944
|
+
id = ++routeIdCount;
|
|
47945
|
+
routeIdByRoute.set(route, id);
|
|
47946
|
+
}
|
|
47947
|
+
key += `${id},`;
|
|
47948
|
+
}
|
|
47949
|
+
return key;
|
|
47950
|
+
};
|
|
47951
|
+
|
|
47786
47952
|
/*
|
|
47787
47953
|
* Custom hook to apply semi-transparent color when an element should be dimmed.
|
|
47788
47954
|
*
|
|
@@ -48326,15 +48492,10 @@ const LinkWithRoute = ({
|
|
|
48326
48492
|
...rest
|
|
48327
48493
|
}) => {
|
|
48328
48494
|
const url = route.buildUrl(routeParams);
|
|
48329
|
-
const {
|
|
48330
|
-
|
|
48331
|
-
|
|
48332
|
-
|
|
48333
|
-
const someExceptedRouteMatching = useSomeRouteMatching(currentExcept);
|
|
48334
|
-
const someAlsoRouteMatching = useSomeRouteMatching(currentAlso);
|
|
48335
|
-
// "Except" is a veto: a route named there is somewhere else, whatever the
|
|
48336
|
-
// rest of the reading says.
|
|
48337
|
-
const linkMatching = !someExceptedRouteMatching && (matching && paramsAreMatching || someAlsoRouteMatching);
|
|
48495
|
+
const linkMatching = useRouteCurrent(route, routeParams, {
|
|
48496
|
+
except: currentExcept,
|
|
48497
|
+
also: currentAlso
|
|
48498
|
+
});
|
|
48338
48499
|
const innerCurrent = current || linkMatching;
|
|
48339
48500
|
return jsx(Link, {
|
|
48340
48501
|
href: url,
|
|
@@ -48343,22 +48504,6 @@ const LinkWithRoute = ({
|
|
|
48343
48504
|
children: children || route.buildRelativeUrl(routeParams)
|
|
48344
48505
|
});
|
|
48345
48506
|
};
|
|
48346
|
-
// A route, or a list of them, read as one answer: does any of them match.
|
|
48347
|
-
// Reading matchingSignal during the render is what subscribes the component to
|
|
48348
|
-
// it, so every route is read even once one has answered yes — stopping early
|
|
48349
|
-
// would leave the component deaf to the ones it skipped.
|
|
48350
|
-
const useSomeRouteMatching = routes => {
|
|
48351
|
-
if (!routes) {
|
|
48352
|
-
return false;
|
|
48353
|
-
}
|
|
48354
|
-
let someMatching = false;
|
|
48355
|
-
for (const route of Array.isArray(routes) ? routes : [routes]) {
|
|
48356
|
-
if (route.matchingSignal.value) {
|
|
48357
|
-
someMatching = true;
|
|
48358
|
-
}
|
|
48359
|
-
}
|
|
48360
|
-
return someMatching;
|
|
48361
|
-
};
|
|
48362
48507
|
const LinkPlain = props => {
|
|
48363
48508
|
const defaultRef = useRef();
|
|
48364
48509
|
props.ref = props.ref || defaultRef;
|
|
@@ -53442,11 +53587,7 @@ const ButtonWithRoute = props => {
|
|
|
53442
53587
|
...rest
|
|
53443
53588
|
} = props;
|
|
53444
53589
|
const url = route.buildUrl(routeParams);
|
|
53445
|
-
const
|
|
53446
|
-
matching
|
|
53447
|
-
} = useRouteStatus(route);
|
|
53448
|
-
const paramsAreMatching = route.matchesParams(routeParams);
|
|
53449
|
-
const linkMatching = matching && paramsAreMatching;
|
|
53590
|
+
const linkMatching = useRouteCurrent(route, routeParams);
|
|
53450
53591
|
|
|
53451
53592
|
// Merged into whatever the caller already holds: a button can be forced into
|
|
53452
53593
|
// a state for a demo and still learn its own current-ness from its route.
|
|
@@ -64744,10 +64885,24 @@ const useDialogProps = props => {
|
|
|
64744
64885
|
// whichever show call ran, not just when a stray authored display
|
|
64745
64886
|
// property is actually present — harmless the rest of the time.
|
|
64746
64887
|
dialogEl.setAttribute("navi-hidden", "");
|
|
64747
|
-
|
|
64748
|
-
|
|
64888
|
+
const hideNatively = () => {
|
|
64889
|
+
if (isTopLayerPopover) {
|
|
64890
|
+
dialogEl.hidePopover();
|
|
64891
|
+
} else {
|
|
64892
|
+
dialogEl.close();
|
|
64893
|
+
}
|
|
64894
|
+
};
|
|
64895
|
+
if (findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
|
|
64896
|
+
// Leaving the tree: hidden natively once the commit is over. Its
|
|
64897
|
+
// removal takes it out of the top layer and hands the focus to
|
|
64898
|
+
// nobody, where closing it now hands the focus to whatever held it
|
|
64899
|
+
// before the opening — often a trigger leaving in the same commit
|
|
64900
|
+
// (see giveFocusBackAfterCommit). What is left to close then is its
|
|
64901
|
+
// state: a <dialog> removed while open keeps its `open` attribute, and
|
|
64902
|
+
// a subtree <Suspense> parks and puts back would show it again.
|
|
64903
|
+
queueMicrotask(hideNatively);
|
|
64749
64904
|
} else {
|
|
64750
|
-
|
|
64905
|
+
hideNatively();
|
|
64751
64906
|
}
|
|
64752
64907
|
// Held at the size it has right now, for the whole way out. cleanup()
|
|
64753
64908
|
// below already stops the JS repositioning, but the size is CSS-driven
|
|
@@ -66416,7 +66571,13 @@ const usePopoverProps = props => {
|
|
|
66416
66571
|
// reliably sufficient once a consumer's own CSS also sets display.
|
|
66417
66572
|
popoverEl.setAttribute("navi-hidden", "");
|
|
66418
66573
|
if (isTopLayer) {
|
|
66419
|
-
|
|
66574
|
+
// Left to its removal when the popover leaves the tree: that hides it
|
|
66575
|
+
// and hands the focus to nobody, where hidePopover() hands the focus
|
|
66576
|
+
// to whatever held it before the opening — often a trigger leaving in
|
|
66577
|
+
// the same commit (see giveFocusBackAfterCommit).
|
|
66578
|
+
if (!findEvent(closeEvent, UNMOUNT_EVENT_TYPE)) {
|
|
66579
|
+
popoverEl.hidePopover();
|
|
66580
|
+
}
|
|
66420
66581
|
} else {
|
|
66421
66582
|
openLocalPopoverCount = Math.max(0, openLocalPopoverCount - 1);
|
|
66422
66583
|
}
|