@jsenv/navi 0.29.360 → 0.29.362
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 +316 -113
- package/dist/dev/jsenv_navi.js.map +6 -6
- package/dist/jsenv_navi.js +315 -112
- package/dist/jsenv_navi.js.map +6 -6
- package/docs/interactions.md +8 -2
- package/docs/scroll.md +6 -0
- package/package.json +1 -1
package/dist/jsenv_navi.js
CHANGED
|
@@ -4474,9 +4474,12 @@ const findControlHost = (el) => {
|
|
|
4474
4474
|
* `findControlHost` above answers for a control's own DOM (itself, or the native
|
|
4475
4475
|
* element it wraps). This one is for something that is not part of a control but
|
|
4476
4476
|
* has to reach one — an interaction declared on a box (see
|
|
4477
|
-
* interaction/interactions.js), which may be the control, may
|
|
4478
|
-
*
|
|
4479
|
-
* control
|
|
4477
|
+
* interaction/interactions.js), which may be the control, may sit inside it, or
|
|
4478
|
+
* may be a wrapper around exactly one. Nearest wins in that order, and the answer
|
|
4479
|
+
* is null when there is no control the box belongs to: not every box lives in
|
|
4480
|
+
* one, and a box laying out several controls (a row of badges, a toolbar) is
|
|
4481
|
+
* held by none of them — its interactions are its own, and a press on its empty
|
|
4482
|
+
* part is not a press on the first control it happens to contain.
|
|
4480
4483
|
*/
|
|
4481
4484
|
const findNearestControlHost = (el) => {
|
|
4482
4485
|
// Itself, then upwards — the box is inside a button, or is one.
|
|
@@ -4484,8 +4487,22 @@ const findNearestControlHost = (el) => {
|
|
|
4484
4487
|
if (selfOrAncestor) {
|
|
4485
4488
|
return selfOrAncestor;
|
|
4486
4489
|
}
|
|
4487
|
-
// …then downwards
|
|
4488
|
-
|
|
4490
|
+
// …then downwards, only when the box wraps a single control. Counted by
|
|
4491
|
+
// control, not by host: a picker holds the hosts of its popup content, and a
|
|
4492
|
+
// box around that one picker is still around one control.
|
|
4493
|
+
let single = null;
|
|
4494
|
+
for (const host of el.querySelectorAll("[navi-control-host]")) {
|
|
4495
|
+
const controlRoot = host.closest("[navi-control]") || host;
|
|
4496
|
+
const controlAbove = controlRoot.parentElement.closest("[navi-control]");
|
|
4497
|
+
if (controlAbove && el.contains(controlAbove)) {
|
|
4498
|
+
continue;
|
|
4499
|
+
}
|
|
4500
|
+
if (single && single !== controlRoot) {
|
|
4501
|
+
return null;
|
|
4502
|
+
}
|
|
4503
|
+
single = controlRoot;
|
|
4504
|
+
}
|
|
4505
|
+
return single ? findControlHost(single) : null;
|
|
4489
4506
|
};
|
|
4490
4507
|
const isControlRoot = (el) => {
|
|
4491
4508
|
return el.hasAttribute("navi-control");
|
|
@@ -10600,9 +10617,10 @@ const interactionsDisputeThePress = (interactions) => {
|
|
|
10600
10617
|
*
|
|
10601
10618
|
* The control is not passed in: it is found from the element, which is what lets
|
|
10602
10619
|
* `interactions` live on a Box rather than on the control itself. A Box that IS a
|
|
10603
|
-
* control (a Button) is its own; a Box
|
|
10604
|
-
* with no control anywhere near it
|
|
10605
|
-
*
|
|
10620
|
+
* control (a Button) is its own; a Box inside one, or wrapping exactly one,
|
|
10621
|
+
* reaches it; a Box with no control anywhere near it — or laying out several,
|
|
10622
|
+
* which belongs to none of them — still answers with a callback of the caller's,
|
|
10623
|
+
* and only "request_action" has nothing to ask.
|
|
10606
10624
|
*
|
|
10607
10625
|
* Set up once per element rather than on every render, which is what lets a
|
|
10608
10626
|
* detector be a plain `setup`/teardown pair. So the interactions themselves are
|
|
@@ -26960,6 +26978,18 @@ const takeoverRoutingRenderingHold = () => {
|
|
|
26960
26978
|
* to a reader. Kept in the session too, so a reload lands where the browser
|
|
26961
26979
|
* would have landed — the flag above is a promise to do the whole job.
|
|
26962
26980
|
*
|
|
26981
|
+
* The document is not the only scrollport of a page. A list scrolling itself
|
|
26982
|
+
* (a `<List expandY>` under a search field that stays put) is left and come
|
|
26983
|
+
* back to the same way, and the browser never knew it was a scrollport at
|
|
26984
|
+
* all: those say where they are by name (rememberScrollerPosition), under the
|
|
26985
|
+
* same URL and for the same session, and ask it back when they mount again
|
|
26986
|
+
* (recallScrollerPosition). What a push means for them is what it means for
|
|
26987
|
+
* the document — an arrival opens at the top (see startAtTop): the page
|
|
26988
|
+
* arrived at has its named positions dropped before its lists render, so a
|
|
26989
|
+
* list recalls only on the way back. A scroller that goes while its page
|
|
26990
|
+
* stays — a popup closing over the same address — has nothing to come back
|
|
26991
|
+
* to, and says so as it leaves (forgetScrollerUnlessPageLeft).
|
|
26992
|
+
*
|
|
26963
26993
|
* What is NOT covered, and cannot be from here: a page whose height depends on
|
|
26964
26994
|
* something still loading. Its content is not there at the moment it is put
|
|
26965
26995
|
* back, so a position beyond what has arrived is clamped as before. Only the
|
|
@@ -26977,7 +27007,23 @@ const takeoverRoutingRenderingHold = () => {
|
|
|
26977
27007
|
const STORAGE_KEY = "navi_scroll_positions";
|
|
26978
27008
|
|
|
26979
27009
|
const positionByUrl = new Map();
|
|
26980
|
-
|
|
27010
|
+
// url -> (scroller name -> position). The position is whatever the scroller
|
|
27011
|
+
// handed in: what it can put itself back on, in its own terms.
|
|
27012
|
+
const scrollerPositionsByUrl = new Map();
|
|
27013
|
+
// The url each named scroller last spoke under: what tells a scroller leaving
|
|
27014
|
+
// a page that stays from one leaving with its page.
|
|
27015
|
+
const urlByScrollerName = new Map();
|
|
27016
|
+
|
|
27017
|
+
// Read once, the first time anyone needs the positions: the document's
|
|
27018
|
+
// restoration is installed by the routing, and a list remembering itself may
|
|
27019
|
+
// mount in an app that never routes.
|
|
27020
|
+
let storeLoaded = false;
|
|
27021
|
+
const loadStore = () => {
|
|
27022
|
+
if (storeLoaded) {
|
|
27023
|
+
return;
|
|
27024
|
+
}
|
|
27025
|
+
storeLoaded = true;
|
|
27026
|
+
window.addEventListener("pagehide", storePositions);
|
|
26981
27027
|
let stored;
|
|
26982
27028
|
try {
|
|
26983
27029
|
stored = window.sessionStorage.getItem(STORAGE_KEY);
|
|
@@ -26990,18 +27036,31 @@ const readStoredPositions = () => {
|
|
|
26990
27036
|
return;
|
|
26991
27037
|
}
|
|
26992
27038
|
try {
|
|
26993
|
-
|
|
27039
|
+
const { document: documentPositions, scrollers } = JSON.parse(stored);
|
|
27040
|
+
for (const [url, position] of Object.entries(documentPositions || {})) {
|
|
26994
27041
|
positionByUrl.set(url, position);
|
|
26995
27042
|
}
|
|
27043
|
+
for (const [url, positionByName] of Object.entries(scrollers || {})) {
|
|
27044
|
+
scrollerPositionsByUrl.set(url, new Map(Object.entries(positionByName)));
|
|
27045
|
+
}
|
|
26996
27046
|
} catch {
|
|
26997
27047
|
// Something else wrote there, or it was truncated.
|
|
26998
27048
|
}
|
|
26999
27049
|
};
|
|
27000
27050
|
const storePositions = () => {
|
|
27051
|
+
const scrollers = {};
|
|
27052
|
+
for (const [url, positionByName] of scrollerPositionsByUrl) {
|
|
27053
|
+
if (positionByName.size > 0) {
|
|
27054
|
+
scrollers[url] = Object.fromEntries(positionByName);
|
|
27055
|
+
}
|
|
27056
|
+
}
|
|
27001
27057
|
try {
|
|
27002
27058
|
window.sessionStorage.setItem(
|
|
27003
27059
|
STORAGE_KEY,
|
|
27004
|
-
JSON.stringify(
|
|
27060
|
+
JSON.stringify({
|
|
27061
|
+
document: Object.fromEntries(positionByUrl),
|
|
27062
|
+
scrollers,
|
|
27063
|
+
}),
|
|
27005
27064
|
);
|
|
27006
27065
|
} catch {
|
|
27007
27066
|
// Full, or refused: the session is the only thing lost.
|
|
@@ -27041,7 +27100,7 @@ const installScrollRestoration = () => {
|
|
|
27041
27100
|
return;
|
|
27042
27101
|
}
|
|
27043
27102
|
window.history.scrollRestoration = "manual";
|
|
27044
|
-
|
|
27103
|
+
loadStore();
|
|
27045
27104
|
// Read as it happens rather than when leaving: a traverse changes the url
|
|
27046
27105
|
// before anything here is told, so a position read then would be read for
|
|
27047
27106
|
// the wrong page.
|
|
@@ -27058,7 +27117,6 @@ const installScrollRestoration = () => {
|
|
|
27058
27117
|
},
|
|
27059
27118
|
{ passive: true },
|
|
27060
27119
|
);
|
|
27061
|
-
window.addEventListener("pagehide", storePositions);
|
|
27062
27120
|
// What a reload asks for, now that the browser has been told not to do it.
|
|
27063
27121
|
// Once, and at the first render of a route: the position is only meaningful
|
|
27064
27122
|
// once there is a page under it.
|
|
@@ -27101,18 +27159,35 @@ const restoreScrollPosition = (url) => {
|
|
|
27101
27159
|
// The document, because the document is the scrollport in the common case. An
|
|
27102
27160
|
// app that scrolls an element of its own scrolls it itself.
|
|
27103
27161
|
const startAtTop = (url, { from } = {}) => {
|
|
27162
|
+
if (!isArrival(url, { from })) {
|
|
27163
|
+
return;
|
|
27164
|
+
}
|
|
27165
|
+
window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
|
27166
|
+
};
|
|
27167
|
+
const isArrival = (url, { from }) => {
|
|
27104
27168
|
const urlObject = new URL(url, window.location.href);
|
|
27105
27169
|
// A fragment names where to land, and the browser is the one that finds it.
|
|
27106
27170
|
if (urlObject.hash) {
|
|
27107
|
-
return;
|
|
27171
|
+
return false;
|
|
27108
27172
|
}
|
|
27109
27173
|
if (
|
|
27110
27174
|
from !== undefined &&
|
|
27111
27175
|
new URL(from, window.location.href).pathname === urlObject.pathname
|
|
27112
27176
|
) {
|
|
27177
|
+
return false;
|
|
27178
|
+
}
|
|
27179
|
+
return true;
|
|
27180
|
+
};
|
|
27181
|
+
|
|
27182
|
+
// The same arrival, for the page's own scrollers. The document is scrolled to
|
|
27183
|
+
// its top once the page is there; a list opens where it decides to in its
|
|
27184
|
+
// first render, so what it must not find is dropped before the routing
|
|
27185
|
+
// renders anything.
|
|
27186
|
+
const forgetScrollersOnArrival = (url, { from } = {}) => {
|
|
27187
|
+
if (!isArrival(url, { from })) {
|
|
27113
27188
|
return;
|
|
27114
27189
|
}
|
|
27115
|
-
|
|
27190
|
+
scrollerPositionsByUrl.delete(new URL(url, window.location.href).href);
|
|
27116
27191
|
};
|
|
27117
27192
|
|
|
27118
27193
|
// An arrival at a page whose scrollport is already showing another one: the
|
|
@@ -27131,6 +27206,49 @@ const scrollTo = ({ x, y }) => {
|
|
|
27131
27206
|
window.scrollTo({ top: y, left: x, behavior: "instant" });
|
|
27132
27207
|
};
|
|
27133
27208
|
|
|
27209
|
+
const rememberScrollerPosition = (name, position) => {
|
|
27210
|
+
loadStore();
|
|
27211
|
+
const url = window.location.href;
|
|
27212
|
+
let positionByName = scrollerPositionsByUrl.get(url);
|
|
27213
|
+
if (!positionByName) {
|
|
27214
|
+
positionByName = new Map();
|
|
27215
|
+
scrollerPositionsByUrl.set(url, positionByName);
|
|
27216
|
+
}
|
|
27217
|
+
positionByName.set(name, position);
|
|
27218
|
+
urlByScrollerName.set(name, url);
|
|
27219
|
+
};
|
|
27220
|
+
|
|
27221
|
+
const recallScrollerPosition = (name) => {
|
|
27222
|
+
loadStore();
|
|
27223
|
+
const positionByName = scrollerPositionsByUrl.get(window.location.href);
|
|
27224
|
+
if (!positionByName) {
|
|
27225
|
+
return undefined;
|
|
27226
|
+
}
|
|
27227
|
+
return positionByName.get(name);
|
|
27228
|
+
};
|
|
27229
|
+
|
|
27230
|
+
// Said by a scroller as it unmounts. Its page is being left when the url is
|
|
27231
|
+
// already another one — the history is written before the page it names is
|
|
27232
|
+
// taken down — and then its position is kept for the way back. The url still
|
|
27233
|
+
// being the one it spoke under means the page stays and the scroller alone
|
|
27234
|
+
// goes (a popup closing, a section folding): there is no coming back to a
|
|
27235
|
+
// place that was not left, and a position kept would greet the next mount
|
|
27236
|
+
// under this address as a return.
|
|
27237
|
+
const forgetScrollerUnlessPageLeft = (name) => {
|
|
27238
|
+
const url = urlByScrollerName.get(name);
|
|
27239
|
+
if (url === undefined) {
|
|
27240
|
+
return;
|
|
27241
|
+
}
|
|
27242
|
+
urlByScrollerName.delete(name);
|
|
27243
|
+
if (url !== window.location.href) {
|
|
27244
|
+
return;
|
|
27245
|
+
}
|
|
27246
|
+
const positionByName = scrollerPositionsByUrl.get(url);
|
|
27247
|
+
if (positionByName) {
|
|
27248
|
+
positionByName.delete(name);
|
|
27249
|
+
}
|
|
27250
|
+
};
|
|
27251
|
+
|
|
27134
27252
|
/**
|
|
27135
27253
|
* A navigation is ABOUT to be applied — said before its very first write.
|
|
27136
27254
|
*
|
|
@@ -27493,6 +27611,13 @@ const setupBrowserIntegrationViaHistory = ({
|
|
|
27493
27611
|
return undefined;
|
|
27494
27612
|
}
|
|
27495
27613
|
|
|
27614
|
+
// The page's own scrollers are told of an arrival before the routing
|
|
27615
|
+
// renders anything: a list arriving decides where it opens in its first
|
|
27616
|
+
// render (see scroll_restoration.js). The document itself is moved once
|
|
27617
|
+
// the page is there, below.
|
|
27618
|
+
if (navigationType === "push") {
|
|
27619
|
+
forgetScrollersOnArrival(url, { from: urlLeft });
|
|
27620
|
+
}
|
|
27496
27621
|
if (abortController) {
|
|
27497
27622
|
abortController.abort(`navigating to ${url}`);
|
|
27498
27623
|
}
|
|
@@ -67187,10 +67312,11 @@ const ListUI = props => {
|
|
|
67187
67312
|
onListVisibleItemsChange,
|
|
67188
67313
|
virtualItemSize,
|
|
67189
67314
|
scrolled,
|
|
67190
|
-
defaultScrolled = "start",
|
|
67315
|
+
defaultScrolled: defaultScrolledProp = "start",
|
|
67191
67316
|
onScrolledChange,
|
|
67192
67317
|
scroller = "self",
|
|
67193
67318
|
hoverWhileScrolling = false,
|
|
67319
|
+
scrollResetOnNavigation = false,
|
|
67194
67320
|
lockSize,
|
|
67195
67321
|
columns,
|
|
67196
67322
|
itemColumns,
|
|
@@ -67209,6 +67335,20 @@ const ListUI = props => {
|
|
|
67209
67335
|
listRows,
|
|
67210
67336
|
...rest
|
|
67211
67337
|
} = props;
|
|
67338
|
+
// Remembered by name, and a name made up at render (see ListFirstResolver)
|
|
67339
|
+
// names no list a later mount would recognize.
|
|
67340
|
+
const rememberScroll = !scrollResetOnNavigation && !isLikelyPreactGeneratedId(rest.id);
|
|
67341
|
+
// Where the list was when its screen was left, when this is the way back
|
|
67342
|
+
// (see scroll_restoration.js). Read once: `defaultScrolled` is held by
|
|
67343
|
+
// reference, and a place read again each render would be a list moved each
|
|
67344
|
+
// render. A list held by its caller (`scrolled`) is where the caller says.
|
|
67345
|
+
const [scrolledRemembered] = useState(() => {
|
|
67346
|
+
if (!rememberScroll || scrolled !== undefined && scrolled !== null) {
|
|
67347
|
+
return undefined;
|
|
67348
|
+
}
|
|
67349
|
+
return recallScrollerPosition(rest.id);
|
|
67350
|
+
});
|
|
67351
|
+
const defaultScrolled = scrolledRemembered || defaultScrolledProp;
|
|
67212
67352
|
const scrollBoxPaddingProps = {};
|
|
67213
67353
|
for (const name of LIST_PADDING_PROP_SET) {
|
|
67214
67354
|
if (name in rest) {
|
|
@@ -67303,6 +67443,8 @@ const ListUI = props => {
|
|
|
67303
67443
|
scrolled,
|
|
67304
67444
|
defaultScrolled,
|
|
67305
67445
|
onScrolledChange,
|
|
67446
|
+
rememberScroll,
|
|
67447
|
+
listId: rest.id,
|
|
67306
67448
|
scroller,
|
|
67307
67449
|
searchText,
|
|
67308
67450
|
horizontal
|
|
@@ -67335,7 +67477,7 @@ const ListUI = props => {
|
|
|
67335
67477
|
// locator). Both answer here, so a row is reachable whether or not the
|
|
67336
67478
|
// window happens to frame it.
|
|
67337
67479
|
const getItemById = itemId => {
|
|
67338
|
-
const itemDrawn = listRows.itemsSignal.peek().find(item => item.
|
|
67480
|
+
const itemDrawn = listRows.itemsSignal.peek().find(item => item.itemId === itemId);
|
|
67339
67481
|
if (itemDrawn) {
|
|
67340
67482
|
return itemDrawn;
|
|
67341
67483
|
}
|
|
@@ -67345,6 +67487,7 @@ const ListUI = props => {
|
|
|
67345
67487
|
}
|
|
67346
67488
|
return {
|
|
67347
67489
|
id: itemId,
|
|
67490
|
+
itemId,
|
|
67348
67491
|
index: rowIndex
|
|
67349
67492
|
};
|
|
67350
67493
|
};
|
|
@@ -67627,6 +67770,8 @@ const useListScrollSync = ({
|
|
|
67627
67770
|
scrolled,
|
|
67628
67771
|
defaultScrolled,
|
|
67629
67772
|
onScrolledChange,
|
|
67773
|
+
rememberScroll,
|
|
67774
|
+
listId,
|
|
67630
67775
|
scroller,
|
|
67631
67776
|
searchText,
|
|
67632
67777
|
horizontal
|
|
@@ -67839,15 +67984,18 @@ const useListScrollSync = ({
|
|
|
67839
67984
|
`"${event.type}" on ${getElementSignature(event.target)} (${reason})`;
|
|
67840
67985
|
// When we display the list we prefer to have selected item at the center
|
|
67841
67986
|
// otherwise, usually when focused by arrow nav, we want to keep it into view close to the nearest edge
|
|
67842
|
-
const
|
|
67843
|
-
`${getElementSignature(itemEl)}.scrollIntoView({ block: "${
|
|
67987
|
+
const align = blockRequested || (event.type === "navi_displayed" ? "center" : "nearest");
|
|
67988
|
+
`${getElementSignature(itemEl)}.scrollIntoView({ block: "${align}", inline: "${align}", container: "nearest" })`;
|
|
67844
67989
|
// The list is going somewhere on purpose, so there is no view to hold
|
|
67845
67990
|
// still any more: an anchor captured before this drop it, or it would
|
|
67846
67991
|
// put the list back where it was the moment the rows move under it.
|
|
67847
67992
|
anchorRef.current = null;
|
|
67993
|
+
// One alignment, said on both axes: the axis the list scrolls on is the
|
|
67994
|
+
// one that reads it, and the other has nothing to move.
|
|
67848
67995
|
scrollIntoViewScoped(itemEl, {
|
|
67849
67996
|
container: getScroller(),
|
|
67850
|
-
block
|
|
67997
|
+
block: align,
|
|
67998
|
+
inline: align
|
|
67851
67999
|
});
|
|
67852
68000
|
const listEl = getListEl();
|
|
67853
68001
|
dispatchPublicCustomEvent(listEl, "navi_scroll", {
|
|
@@ -67859,7 +68007,7 @@ const useListScrollSync = ({
|
|
|
67859
68007
|
// Whether the row is drawn is asked of the dom, not of the render window:
|
|
67860
68008
|
// the window says what a run draws, and a list whose rows are declared one
|
|
67861
68009
|
// by one has them all in the dom whatever the window says.
|
|
67862
|
-
const itemEl = findRowElement(getListEl(), item.
|
|
68010
|
+
const itemEl = findRowElement(getListEl(), item.itemId);
|
|
67863
68011
|
if (itemEl) {
|
|
67864
68012
|
scrollItemIntoView(itemEl);
|
|
67865
68013
|
return;
|
|
@@ -67867,7 +68015,7 @@ const useListScrollSync = ({
|
|
|
67867
68015
|
// Not in DOM — shift the render window. The item will read
|
|
67868
68016
|
// pendingScrollRef on mount and scroll into view.
|
|
67869
68017
|
pendingScrollRef.current = {
|
|
67870
|
-
id: item.
|
|
68018
|
+
id: item.itemId,
|
|
67871
68019
|
resolve: itemEl => {
|
|
67872
68020
|
pendingScrollRef.current = null;
|
|
67873
68021
|
scrollItemIntoView(itemEl);
|
|
@@ -68218,6 +68366,8 @@ const useListScrollSync = ({
|
|
|
68218
68366
|
// one was looking at is then somewhere else.
|
|
68219
68367
|
const onScrolledChangeRef = useRef(null);
|
|
68220
68368
|
onScrolledChangeRef.current = onScrolledChange;
|
|
68369
|
+
const rememberScrollRef = useRef(false);
|
|
68370
|
+
rememberScrollRef.current = rememberScroll;
|
|
68221
68371
|
// Where the list was at the last thing that moved it. Kept whether anyone
|
|
68222
68372
|
// asked for it or not: it is what a resize needs to put things back.
|
|
68223
68373
|
const positionRef = useRef(null);
|
|
@@ -68235,16 +68385,32 @@ const useListScrollSync = ({
|
|
|
68235
68385
|
return;
|
|
68236
68386
|
}
|
|
68237
68387
|
positionRef.current = position;
|
|
68238
|
-
|
|
68388
|
+
const remember = rememberScrollRef.current;
|
|
68389
|
+
const onScrolledChange = onScrolledChangeRef.current;
|
|
68390
|
+
if (!remember && !onScrolledChange) {
|
|
68239
68391
|
return;
|
|
68240
68392
|
}
|
|
68241
68393
|
const rowEl = findRowElement(getListEl(), position.id);
|
|
68242
|
-
|
|
68394
|
+
const scrolledNow = {
|
|
68243
68395
|
id: position.id,
|
|
68244
68396
|
index: position.index,
|
|
68245
68397
|
offset: position.offset - getRowScrollInset(getScroller(), rowEl, horizontal)
|
|
68246
|
-
}
|
|
68398
|
+
};
|
|
68399
|
+
if (remember) {
|
|
68400
|
+
rememberScrollerPosition(listId, scrolledNow);
|
|
68401
|
+
}
|
|
68402
|
+
if (onScrolledChange) {
|
|
68403
|
+
onScrolledChange(scrolledNow);
|
|
68404
|
+
}
|
|
68247
68405
|
};
|
|
68406
|
+
// Leaving: with its page, or alone (see forgetScrollerUnlessPageLeft).
|
|
68407
|
+
useLayoutEffect(() => {
|
|
68408
|
+
return () => {
|
|
68409
|
+
if (rememberScrollRef.current) {
|
|
68410
|
+
forgetScrollerUnlessPageLeft(listId);
|
|
68411
|
+
}
|
|
68412
|
+
};
|
|
68413
|
+
}, []);
|
|
68248
68414
|
|
|
68249
68415
|
// A list that gets narrower rewraps every row it holds, so everything below
|
|
68250
68416
|
// moves and the reader loses their place — the very thing scrolling a long
|
|
@@ -68317,7 +68483,7 @@ const useListScrollSync = ({
|
|
|
68317
68483
|
return;
|
|
68318
68484
|
}
|
|
68319
68485
|
const items = listRows.visibleItemsSignal.peek();
|
|
68320
|
-
const itemNow = items.find(i => i.
|
|
68486
|
+
const itemNow = items.find(i => i.itemId === anchor.id);
|
|
68321
68487
|
if (!itemNow) {
|
|
68322
68488
|
anchorRef.current = null;
|
|
68323
68489
|
return;
|
|
@@ -68920,17 +69086,22 @@ const resolveScrollInset = (value, viewportSize) => {
|
|
|
68920
69086
|
return number;
|
|
68921
69087
|
};
|
|
68922
69088
|
|
|
68923
|
-
// The row
|
|
68924
|
-
//
|
|
68925
|
-
//
|
|
68926
|
-
//
|
|
68927
|
-
//
|
|
69089
|
+
// The row of that name, IN THIS LIST — by the name the list knows it under
|
|
69090
|
+
// (see ListItemUI), not the element's id. Not document.getElementById either:
|
|
69091
|
+
// a name is only ever unique within a list — two lists on the same page can be
|
|
69092
|
+
// showing the same collection — and a list acting on a row that belongs to
|
|
69093
|
+
// another one is a spectacular kind of wrong (it scrolls to hold still
|
|
69094
|
+
// something it is not even showing).
|
|
68928
69095
|
const findRowElement = (listEl, id) => {
|
|
68929
|
-
return listEl.querySelector(`[
|
|
69096
|
+
return listEl.querySelector(`[navi-list-item-real="${CSS.escape(id)}"]`);
|
|
68930
69097
|
};
|
|
69098
|
+
const getRowName = rowEl => rowEl.getAttribute("navi-list-item-real");
|
|
68931
69099
|
|
|
68932
69100
|
// The row the user is looking at, and where it sits: what must not move when
|
|
68933
|
-
// the list is rebuilt around it.
|
|
69101
|
+
// the list is rebuilt around it. Read off the rows' own boxes, not by
|
|
69102
|
+
// hit-testing the screen: a scrolling list takes its rows out of hit-testing
|
|
69103
|
+
// (see the navi-scrolling rule in the css above), and the scroll event is
|
|
69104
|
+
// precisely when this is asked.
|
|
68934
69105
|
const captureScrollAnchor = ({
|
|
68935
69106
|
scrollerEl,
|
|
68936
69107
|
listEl,
|
|
@@ -68942,30 +69113,31 @@ const captureScrollAnchor = ({
|
|
|
68942
69113
|
}
|
|
68943
69114
|
const viewportRect = getScrollerViewportRect(scrollerEl);
|
|
68944
69115
|
const listRect = listEl.getBoundingClientRect();
|
|
68945
|
-
const
|
|
68946
|
-
if (!
|
|
69116
|
+
const range = getListVisibleRange(viewportRect, listRect, horizontal);
|
|
69117
|
+
if (!range) {
|
|
68947
69118
|
return null;
|
|
68948
69119
|
}
|
|
69120
|
+
const viewportFrom = horizontal ? viewportRect.left : viewportRect.top;
|
|
69121
|
+
const {
|
|
69122
|
+
rowEls,
|
|
69123
|
+
index
|
|
69124
|
+
} = findRowsFrom(listEl, range.from, horizontal);
|
|
68949
69125
|
let fallbackAnchor = null;
|
|
68950
|
-
for (let
|
|
68951
|
-
const
|
|
68952
|
-
const
|
|
68953
|
-
const
|
|
68954
|
-
if (
|
|
68955
|
-
|
|
68956
|
-
}
|
|
68957
|
-
const itemEl = el.closest(REAL_LIST_ITEM_SELECTOR);
|
|
68958
|
-
if (!itemEl) {
|
|
68959
|
-
continue;
|
|
69126
|
+
for (let i = index; i < rowEls.length; i++) {
|
|
69127
|
+
const rowEl = rowEls[i];
|
|
69128
|
+
const rowRect = rowEl.getBoundingClientRect();
|
|
69129
|
+
const rowStart = horizontal ? rowRect.left : rowRect.top;
|
|
69130
|
+
if (rowStart >= range.to) {
|
|
69131
|
+
break;
|
|
68960
69132
|
}
|
|
68961
|
-
const
|
|
69133
|
+
const rowName = getRowName(rowEl);
|
|
69134
|
+
const item = items.find(i => i.itemId === rowName);
|
|
68962
69135
|
if (!item) {
|
|
68963
69136
|
continue;
|
|
68964
69137
|
}
|
|
68965
|
-
const
|
|
68966
|
-
const offset = horizontal ? itemRect.left - viewportRect.left : itemRect.top - viewportRect.top;
|
|
69138
|
+
const offset = rowStart - viewportFrom;
|
|
68967
69139
|
const anchor = {
|
|
68968
|
-
id: item.
|
|
69140
|
+
id: item.itemId,
|
|
68969
69141
|
index: item.index,
|
|
68970
69142
|
offset
|
|
68971
69143
|
};
|
|
@@ -68985,43 +69157,61 @@ const captureScrollAnchor = ({
|
|
|
68985
69157
|
// The part of the list that is on screen, along the scrolling axis. Both edges
|
|
68986
69158
|
// matter: the scroller may be larger than the list (scroller="parent") as well
|
|
68987
69159
|
// as smaller (the list scrolls inside its own box).
|
|
68988
|
-
const
|
|
68989
|
-
// The screen has a say too: what is asked here is answered by
|
|
68990
|
-
// elementFromPoint, which only knows about points that are actually on it. A
|
|
68991
|
-
// list whose scroll box hangs below the fold of the page would otherwise be
|
|
68992
|
-
// probed where nothing can be hit — and would silently stop keeping its rows
|
|
68993
|
-
// still, which is exactly when it matters.
|
|
68994
|
-
const screenTo = horizontal ? document.documentElement.clientWidth : document.documentElement.clientHeight;
|
|
69160
|
+
const getListVisibleRange = (viewportRect, listRect, horizontal) => {
|
|
68995
69161
|
const viewportFrom = horizontal ? viewportRect.left : viewportRect.top;
|
|
68996
69162
|
const viewportTo = horizontal ? viewportRect.right : viewportRect.bottom;
|
|
68997
69163
|
const listFrom = horizontal ? listRect.left : listRect.top;
|
|
68998
69164
|
const listTo = horizontal ? listRect.right : listRect.bottom;
|
|
68999
|
-
|
|
69000
|
-
|
|
69001
|
-
if (from < 0) {
|
|
69002
|
-
from = 0;
|
|
69003
|
-
}
|
|
69004
|
-
if (to > screenTo) {
|
|
69005
|
-
to = screenTo;
|
|
69006
|
-
}
|
|
69165
|
+
const from = listFrom > viewportFrom ? listFrom : viewportFrom;
|
|
69166
|
+
const to = listTo < viewportTo ? listTo : viewportTo;
|
|
69007
69167
|
if (to - from < 2) {
|
|
69008
69168
|
return null;
|
|
69009
69169
|
}
|
|
69010
|
-
// Where to put the probe on the other axis: inside the list, inside the
|
|
69011
|
-
// viewport.
|
|
69012
|
-
const crossFrom = horizontal ? listRect.top : listRect.left;
|
|
69013
|
-
const crossViewportFrom = horizontal ? viewportRect.top : viewportRect.left;
|
|
69014
|
-
const crossPos = (crossFrom > crossViewportFrom ? crossFrom : crossViewportFrom) + 1;
|
|
69015
69170
|
return {
|
|
69016
69171
|
from,
|
|
69017
|
-
to
|
|
69018
|
-
crossPos
|
|
69172
|
+
to
|
|
69019
69173
|
};
|
|
69020
69174
|
};
|
|
69175
|
+
// The real rows of the list, and the first of them reaching past `from` along
|
|
69176
|
+
// the scroll axis. A binary search over their boxes: the rows stand in
|
|
69177
|
+
// document order along that axis, so their far edges only grow.
|
|
69178
|
+
const findRowsFrom = (listEl, from, horizontal) => {
|
|
69179
|
+
const rowEls = listEl.querySelectorAll(REAL_LIST_ITEM_SELECTOR);
|
|
69180
|
+
let low = 0;
|
|
69181
|
+
let high = rowEls.length;
|
|
69182
|
+
while (low < high) {
|
|
69183
|
+
const mid = low + high >> 1;
|
|
69184
|
+
const rect = rowEls[mid].getBoundingClientRect();
|
|
69185
|
+
const end = horizontal ? rect.right : rect.bottom;
|
|
69186
|
+
if (end > from) {
|
|
69187
|
+
high = mid;
|
|
69188
|
+
} else {
|
|
69189
|
+
low = mid + 1;
|
|
69190
|
+
}
|
|
69191
|
+
}
|
|
69192
|
+
return {
|
|
69193
|
+
rowEls,
|
|
69194
|
+
index: low
|
|
69195
|
+
};
|
|
69196
|
+
};
|
|
69197
|
+
// Whether a filler (the room held for rows outside the window) is what stands
|
|
69198
|
+
// at that position along the scroll axis.
|
|
69199
|
+
const isFillerAt = (listEl, position, horizontal) => {
|
|
69200
|
+
for (const fillerEl of listEl.querySelectorAll("[navi-virtual-filler]")) {
|
|
69201
|
+
const rect = fillerEl.getBoundingClientRect();
|
|
69202
|
+
const from = horizontal ? rect.left : rect.top;
|
|
69203
|
+
const to = horizontal ? rect.right : rect.bottom;
|
|
69204
|
+
if (position >= from && position < to) {
|
|
69205
|
+
return true;
|
|
69206
|
+
}
|
|
69207
|
+
}
|
|
69208
|
+
return false;
|
|
69209
|
+
};
|
|
69021
69210
|
|
|
69022
|
-
// Which row of the collection sits at the current scroll position.
|
|
69023
|
-
//
|
|
69024
|
-
// on screen is only
|
|
69211
|
+
// Which row of the collection sits at the current scroll position. Read off
|
|
69212
|
+
// the rows' boxes when a real row is there (see captureScrollAnchor for why
|
|
69213
|
+
// not hit-testing), and from the row size when what is on screen is only
|
|
69214
|
+
// reserved room.
|
|
69025
69215
|
// Returns { index, item, reason } or null if nothing can be determined.
|
|
69026
69216
|
const getScrollInfo = ({
|
|
69027
69217
|
scrollValues,
|
|
@@ -69035,34 +69225,31 @@ const getScrollInfo = ({
|
|
|
69035
69225
|
const items = listRows.itemsSignal.peek();
|
|
69036
69226
|
const viewportRect = getScrollerViewportRect(scrollerEl);
|
|
69037
69227
|
const listRect = listEl.getBoundingClientRect();
|
|
69038
|
-
|
|
69039
|
-
|
|
69040
|
-
const scanRange = getListVisibleScanRange(viewportRect, listRect, horizontal);
|
|
69041
|
-
if (!scanRange) {
|
|
69228
|
+
const range = getListVisibleRange(viewportRect, listRect, horizontal);
|
|
69229
|
+
if (!range) {
|
|
69042
69230
|
return null;
|
|
69043
69231
|
}
|
|
69044
|
-
//
|
|
69045
|
-
//
|
|
69046
|
-
//
|
|
69047
|
-
//
|
|
69048
|
-
const scanStart = (
|
|
69049
|
-
|
|
69050
|
-
|
|
69051
|
-
|
|
69052
|
-
|
|
69053
|
-
|
|
69054
|
-
|
|
69055
|
-
|
|
69056
|
-
|
|
69057
|
-
|
|
69058
|
-
|
|
69059
|
-
|
|
69060
|
-
|
|
69061
|
-
|
|
69062
|
-
|
|
69063
|
-
|
|
69064
|
-
|
|
69065
|
-
break;
|
|
69232
|
+
// Read from the center of the visible part of the list along the main axis.
|
|
69233
|
+
// The render window places half its budget before and half after the hit
|
|
69234
|
+
// index. Anchoring to the center maximises how many rendered items fall
|
|
69235
|
+
// within the visible area.
|
|
69236
|
+
const scanStart = (range.from + range.to) / 2;
|
|
69237
|
+
let hitEl = null;
|
|
69238
|
+
const hitFiller = isFillerAt(listEl, scanStart, horizontal);
|
|
69239
|
+
if (!hitFiller) {
|
|
69240
|
+
// The first real row from the center down, the way a probe walking down
|
|
69241
|
+
// from it would meet one — past a separator or a group label in between.
|
|
69242
|
+
const {
|
|
69243
|
+
rowEls,
|
|
69244
|
+
index
|
|
69245
|
+
} = findRowsFrom(listEl, scanStart, horizontal);
|
|
69246
|
+
const rowEl = rowEls[index];
|
|
69247
|
+
if (rowEl) {
|
|
69248
|
+
const rowRect = rowEl.getBoundingClientRect();
|
|
69249
|
+
const rowStart = horizontal ? rowRect.left : rowRect.top;
|
|
69250
|
+
if (rowStart < range.to) {
|
|
69251
|
+
hitEl = rowEl;
|
|
69252
|
+
}
|
|
69066
69253
|
}
|
|
69067
69254
|
}
|
|
69068
69255
|
// Shared by the "hit a filler" and "hit nothing at all" cases below: both
|
|
@@ -69093,8 +69280,8 @@ const getScrollInfo = ({
|
|
|
69093
69280
|
return estimateFromScrollPos("hit filler");
|
|
69094
69281
|
}
|
|
69095
69282
|
if (hitEl) {
|
|
69096
|
-
const
|
|
69097
|
-
const item = items.find(i => i.
|
|
69283
|
+
const hitName = getRowName(hitEl);
|
|
69284
|
+
const item = items.find(i => i.itemId === hitName);
|
|
69098
69285
|
if (!item) {
|
|
69099
69286
|
return null;
|
|
69100
69287
|
}
|
|
@@ -69104,13 +69291,11 @@ const getScrollInfo = ({
|
|
|
69104
69291
|
reason: `hit item at ${item.index} (${item.value})`
|
|
69105
69292
|
};
|
|
69106
69293
|
}
|
|
69107
|
-
//
|
|
69108
|
-
// the
|
|
69109
|
-
//
|
|
69110
|
-
//
|
|
69111
|
-
//
|
|
69112
|
-
// estimate as the hitFiller case is a safe fallback: it only needs the
|
|
69113
|
-
// scroll position, not a successful hit-test.
|
|
69294
|
+
// No real row stands between the center and the end of what is visible.
|
|
69295
|
+
// Keeping the stale renderWindow here means the DOM never gets asked to
|
|
69296
|
+
// catch up with a scrollTop that may have jumped far away — the user ends
|
|
69297
|
+
// up staring at blank space. Same estimate as the hitFiller case is a safe
|
|
69298
|
+
// fallback: it only needs the scroll position.
|
|
69114
69299
|
const estimated = estimateFromScrollPos("no hit");
|
|
69115
69300
|
if (estimated) {
|
|
69116
69301
|
return estimated;
|
|
@@ -69545,6 +69730,12 @@ const ListItemUI = props => {
|
|
|
69545
69730
|
// gave the row its place and decided it is inside the render window.
|
|
69546
69731
|
const row = useContext(ListRowContext);
|
|
69547
69732
|
const slotId = useContext(ListSlotContext);
|
|
69733
|
+
// What the row is called in the list — the run's name for a row it draws,
|
|
69734
|
+
// whatever `id` the caller put on the element (a run row may need a DOM id of
|
|
69735
|
+
// its own, to keep clear of another element's). A position names a row by
|
|
69736
|
+
// this (see captureScrollAnchor), and a row asked for by name is found by
|
|
69737
|
+
// this (locateRow, findRowElement); the DOM id is the caller's.
|
|
69738
|
+
props.itemId = row ? row.id : props.id;
|
|
69548
69739
|
// There is no standalone match/matchScore/highlight prop — participation
|
|
69549
69740
|
// in a matching system (search, filter…) only goes through `matchInfo`
|
|
69550
69741
|
// (e.g. useSearchText's getItemMatchInfo(item): { match, matchScore,
|
|
@@ -69625,6 +69816,7 @@ const ListItemReal = props => {
|
|
|
69625
69816
|
const {
|
|
69626
69817
|
ref,
|
|
69627
69818
|
id,
|
|
69819
|
+
itemId,
|
|
69628
69820
|
hidden,
|
|
69629
69821
|
muted,
|
|
69630
69822
|
loading,
|
|
@@ -69644,7 +69836,7 @@ const ListItemReal = props => {
|
|
|
69644
69836
|
// see the state change, which only the caller can arrange).
|
|
69645
69837
|
const pendingScrollRef = useContext(PendingScrollRefContext);
|
|
69646
69838
|
const pendingScroll = pendingScrollRef.current;
|
|
69647
|
-
const needScrollOnMount = pendingScroll && pendingScroll.id ===
|
|
69839
|
+
const needScrollOnMount = pendingScroll && pendingScroll.id === itemId;
|
|
69648
69840
|
useLayoutEffect(() => {
|
|
69649
69841
|
if (!needScrollOnMount) {
|
|
69650
69842
|
return;
|
|
@@ -69743,7 +69935,7 @@ const ListItemReal = props => {
|
|
|
69743
69935
|
baseClassName: "navi_list_item",
|
|
69744
69936
|
styleCSSVars: LIST_ITEM_STYLE_CSS_VARS,
|
|
69745
69937
|
id: id,
|
|
69746
|
-
"navi-list-item-real":
|
|
69938
|
+
"navi-list-item-real": itemId,
|
|
69747
69939
|
...rest,
|
|
69748
69940
|
...itemColumnsOverrideProps,
|
|
69749
69941
|
index: undefined,
|
|
@@ -71115,6 +71307,7 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
|
|
|
71115
71307
|
* onScrolledChange?: (scrolled: {id: string, index: number, offset: number}) => void,
|
|
71116
71308
|
* scroller?: "self" | "parent" | "document" | Element | {current: Element},
|
|
71117
71309
|
* hoverWhileScrolling?: boolean,
|
|
71310
|
+
* scrollResetOnNavigation?: boolean,
|
|
71118
71311
|
* fallback?: import("ignore:preact").ComponentChildren,
|
|
71119
71312
|
* searchFallback?: import("ignore:preact").ComponentChildren,
|
|
71120
71313
|
* searchText?: string,
|
|
@@ -71206,7 +71399,8 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
|
|
|
71206
71399
|
* list is already known to be empty: the empty `fallback` shows right away
|
|
71207
71400
|
* rather than an empty frame, so nothing moves when the response arrives.
|
|
71208
71401
|
* @param {"start"|"end"|number|{id: string, offset?: number}} [props.defaultScrolled="start"]
|
|
71209
|
-
* Where the list opens, after which the user owns the scroll
|
|
71402
|
+
* Where the list opens, after which the user owns the scroll — unless it is
|
|
71403
|
+
* being come back to (see `scrollResetOnNavigation`). `"end"` is a
|
|
71210
71404
|
* thread read backwards — the last rows are the ones to show, and the ones
|
|
71211
71405
|
* asked for first. A number opens on that row of the collection. `{id,
|
|
71212
71406
|
* offset}` — what `onScrolledChange` hands out — opens on a NAMED row,
|
|
@@ -71289,6 +71483,15 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
|
|
|
71289
71483
|
* Pass `true` for a list whose rows must stay live under the pointer while
|
|
71290
71484
|
* it scrolls. The trade of the default is the mirror one: right after a
|
|
71291
71485
|
* scroll, the row under the pointer lights up only once the pointer moves.
|
|
71486
|
+
* @param {boolean} [props.scrollResetOnNavigation=false]
|
|
71487
|
+
* A list that opens the same way every time. Without it the list comes back
|
|
71488
|
+
* where it was when its screen is left and come back to — the way the page
|
|
71489
|
+
* does, and for a list that scrolls itself the page's own restoration cannot
|
|
71490
|
+
* see. The position is kept under the list's `id` and the page's url, for
|
|
71491
|
+
* the session (a reload comes back too); a list without an `id` of its own
|
|
71492
|
+
* has nothing to be remembered by. A fresh arrival at the page opens at
|
|
71493
|
+
* `defaultScrolled` either way, and so does a list the caller holds through
|
|
71494
|
+
* `scrolled`.
|
|
71292
71495
|
* @param {boolean} [props.deselectable]
|
|
71293
71496
|
* A single-select list allowed to hold nothing: the selected row, pressed
|
|
71294
71497
|
* again, lets go. Without it the list is a radio group — a choice, once
|