@jsenv/navi 0.29.439 → 0.29.441

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.
@@ -12814,6 +12814,53 @@ const findControlProxy = (el) => {
12814
12814
  return null;
12815
12815
  };
12816
12816
 
12817
+ /**
12818
+ * Tells a typed field's control that the user gave up on what they typed:
12819
+ * `navi_cancel` with its reason — Escape, or leaving the field invalid. What
12820
+ * giving up means is the control's to decide (onnavi_cancel in
12821
+ * control_hooks.jsx: resetOnCancel, cancelOnEscape, cancelOnBlurInvalid).
12822
+ *
12823
+ * Leaving a field empty is not a cancel: the "change" of that same blur has
12824
+ * already sent the empty value. A field that must not be left empty says
12825
+ * `required`, and leaving it is then a blur_invalid.
12826
+ */
12827
+ const addInputCancel = (input, controller) => {
12828
+ const dispatchCancel = (detail) => {
12829
+ dispatchInternalCustomEvent(input, "navi_cancel", detail);
12830
+ };
12831
+ const onkeydown = (e) => {
12832
+ if (e.key !== "Escape") {
12833
+ return;
12834
+ }
12835
+ if (controller.rules.callout.callout) {
12836
+ // Escape closes the open callout first (callout.js listens on its anchor).
12837
+ return;
12838
+ }
12839
+ dispatchCancel({ event: e, reason: "escape_key" });
12840
+ };
12841
+ const onblur = (e) => {
12842
+ if (input.closest("[inert]")) {
12843
+ // The page took the field away (an edition ending, a side swapped out)
12844
+ // and the focus went with it: the user did not leave it.
12845
+ return;
12846
+ }
12847
+ const { failedConstraintInfo } = controller.rules.validation;
12848
+ if (failedConstraintInfo) {
12849
+ dispatchCancel({
12850
+ event: e,
12851
+ reason: "blur_invalid",
12852
+ failedConstraintInfo,
12853
+ });
12854
+ }
12855
+ };
12856
+ input.addEventListener("keydown", onkeydown);
12857
+ input.addEventListener("blur", onblur);
12858
+ return () => {
12859
+ input.removeEventListener("keydown", onkeydown);
12860
+ input.removeEventListener("blur", onblur);
12861
+ };
12862
+ };
12863
+
12817
12864
  const addInputEffect = (
12818
12865
  input,
12819
12866
  callback,
@@ -13002,11 +13049,15 @@ const listenInputStateChange = (
13002
13049
  callback(e);
13003
13050
  };
13004
13051
  input.addEventListener("input", oninput);
13005
- input.addEventListener("keydown", onkeydown);
13052
+ // Capture: the guard is set before the control reacts to the key. Its
13053
+ // reaction can end what the field is in (Enter sends, Escape cancels, an
13054
+ // edition closes and hands the focus away), and the "change" of that blur
13055
+ // arrives in the microtasks between two listeners of this keydown.
13056
+ input.addEventListener("keydown", onkeydown, { capture: true });
13006
13057
  input.addEventListener("change", onchange);
13007
13058
  addTeardown(() => {
13008
13059
  input.removeEventListener("input", oninput);
13009
- input.removeEventListener("keydown", onkeydown);
13060
+ input.removeEventListener("keydown", onkeydown, { capture: true });
13010
13061
  input.removeEventListener("change", onchange);
13011
13062
  });
13012
13063
 
@@ -45537,16 +45588,19 @@ const useControlProps = (props, {
45537
45588
  // ref so the effect always fires the current render's reaction.
45538
45589
  const applyEventReactionRef = useRef();
45539
45590
  applyEventReactionRef.current = applyEventReaction;
45591
+ const isTypedField = controlType === "input" && !isCheckable && props.type !== "range";
45540
45592
  const refCallback = useCallback(field => {
45541
- if (!hasNaviChangeEventReaction || actionEvent === "custom") {
45542
- return undefined;
45543
- }
45544
- return addInputEffect(field, e => applyEventReactionRef.current("naviChange", e), {
45593
+ const removeInputCancel = isTypedField ? addInputCancel(field, uiStateController) : undefined;
45594
+ const removeInputEffect = hasNaviChangeEventReaction && actionEvent !== "custom" ? addInputEffect(field, e => applyEventReactionRef.current("naviChange", e), {
45545
45595
  waitForChange: actionAfterChange,
45546
45596
  debounce: actionDebounce,
45547
45597
  debugInteraction
45548
- });
45549
- }, [actionEvent, actionAfterChange, actionDebounce, hasNaviChangeEventReaction, ref]);
45598
+ }) : undefined;
45599
+ return () => {
45600
+ removeInputCancel?.();
45601
+ removeInputEffect?.();
45602
+ };
45603
+ }, [isTypedField, actionEvent, actionAfterChange, actionDebounce, hasNaviChangeEventReaction, ref]);
45550
45604
  const refComposed = useComposeElementRef(refCallback, ref);
45551
45605
  const onPaste = e => {
45552
45606
  props.onPaste?.(e);
@@ -46488,7 +46542,7 @@ const useInteractiveProps = (props, {
46488
46542
  }
46489
46543
  if (
46490
46544
  // error prevent cancellation until the user closes it (or something closes it)
46491
- e.detail.failedConstraintInfo.level === "error" && e.detail.failedConstraintInfo.reportStatus !== "closed") {
46545
+ e.detail.failedConstraintInfo.status === "error" && e.detail.failedConstraintInfo.reportStatus !== "closed") {
46492
46546
  return;
46493
46547
  }
46494
46548
  }
@@ -60075,21 +60129,51 @@ const Editable = props => {
60075
60129
  editingPreviousRef.current = editing;
60076
60130
  }
60077
60131
 
60078
- // Simulate typing the initial value when editing starts with a custom value
60132
+ // The field is mounted the whole time, so edition starting is when it
60133
+ // arrives, and Editable places the focus itself: a mount-time autofocus
60134
+ // would only ever see the first `editing`. Starting edition is a press aimed
60135
+ // at typing, so the field takes the keyboard whatever asked for it — a
60136
+ // click, Enter, a letter key.
60137
+ const focusedBeforeEditionRef = useRef(null);
60079
60138
  useLayoutEffect(() => {
60139
+ const field = ref.current;
60080
60140
  if (!editing) {
60141
+ const focusedBeforeEdition = focusedBeforeEditionRef.current;
60142
+ focusedBeforeEditionRef.current = null;
60143
+ if (!focusedBeforeEdition || !focusedBeforeEdition.isConnected) {
60144
+ return;
60145
+ }
60146
+ // The field just went inert while holding the focus (Enter, Escape, the
60147
+ // action ending): the focus goes back to where edition was asked from.
60148
+ // Focus the user took away, the blur that ended edition, stays where
60149
+ // they put it — on the body included.
60150
+ if (document.activeElement !== field) {
60151
+ return;
60152
+ }
60153
+ moveFocusTo(focusedBeforeEdition);
60081
60154
  return;
60082
60155
  }
60083
- const editingEvent = editing.event;
60084
- if (editingEvent) {
60085
- const editingEventInitialValue = editingEvent.detail?.initialValue;
60086
- if (editingEventInitialValue !== undefined) {
60087
- const input = ref.current;
60088
- input.value = editingEventInitialValue;
60089
- input.dispatchEvent(new CustomEvent("input", {
60090
- bubbles: false
60091
- }));
60092
- }
60156
+ focusedBeforeEditionRef.current = document.activeElement;
60157
+ if (!valueSignal) {
60158
+ // An edition starts from the value, not from what a cancelled one left
60159
+ // typed in the field. Done before the focus: written into a focused
60160
+ // field, it would read as a change to send (see input_effect.js).
60161
+ dispatchRequestResetUIState(field, editing.event);
60162
+ }
60163
+ moveFocusTo(field);
60164
+ const initialValue = editing.event?.detail?.initialValue;
60165
+ if (initialValue !== undefined) {
60166
+ // The key that started edition is its first keystroke.
60167
+ field.value = initialValue;
60168
+ field.dispatchEvent(new CustomEvent("input", {
60169
+ bubbles: false
60170
+ }));
60171
+ return;
60172
+ }
60173
+ if (autoFocusSelect) {
60174
+ field.select();
60175
+ // Keep the beginning of the text visible instead of scrolling to the end
60176
+ field.scrollLeft = 0;
60093
60177
  }
60094
60178
  }, [editing]);
60095
60179
  const controlProps = {
@@ -60098,9 +60182,6 @@ const Editable = props => {
60098
60182
  name,
60099
60183
  value,
60100
60184
  valueSignal,
60101
- autoFocus: editing,
60102
- autoFocusVisible: true,
60103
- autoFocusSelect,
60104
60185
  cancelOnEscape: true,
60105
60186
  cancelOnBlurInvalid: true,
60106
60187
  constraints,
@@ -60120,6 +60201,11 @@ const Editable = props => {
60120
60201
  });
60121
60202
  },
60122
60203
  onBlur: e => {
60204
+ if (!editing) {
60205
+ // The focus handed back once edition ended (see the effect above):
60206
+ // the edition is already over, this blur is its consequence.
60207
+ return;
60208
+ }
60123
60209
  let inputValue;
60124
60210
  const valueWhenEditStart = valueWhenEditStartRef.current;
60125
60211
  let inputValueWhenEditStart;
@@ -60186,7 +60272,7 @@ const Editable = props => {
60186
60272
  // - input not focusable (via keyboard or anything)
60187
60273
  // - cannot be interacted with pointer (click, hover, etc)
60188
60274
  // - is ignored by screen readers
60189
- inert: editing ? undefined : "",
60275
+ inert: !editing,
60190
60276
  "data-editing": editing ? "" : undefined,
60191
60277
  children: control
60192
60278
  })]
@@ -71337,9 +71423,16 @@ document.addEventListener(
71337
71423
  * progress. A write is an instant scroll, and the browser abandons for it any
71338
71424
  * scroll it is playing on its own, short of where that one was going: a key
71339
71425
  * (Home, PageDown, Space), a smooth `scrollTo` or `scrollIntoView`, the status
71340
- * bar tap on iOS, the fling a finger leaves behind once it lifts. A scroll
71341
- * following a hand is not abandoned: the wheel's next turn carries on, and on
71342
- * most engines a finger keeps dragging from wherever the write left it.
71426
+ * bar tap on iOS. A scroll following a hand is not abandoned: the wheel's next
71427
+ * turn carries on, and on most engines a finger keeps dragging from wherever
71428
+ * the write left it, and the fling it leaves behind once it lifts carries on
71429
+ * from there too (Chrome under DevTools' touch emulation: flings written to at
71430
+ * every move of a list's window went as far as flings never written to).
71431
+ *
71432
+ * A fling must not be taken for a scroll the browser plays on its own. A list
71433
+ * then leaves uncorrected, for the whole fling, the room its fillers lose above
71434
+ * the screen as its window slides: in wematch, cards jumping half a screen
71435
+ * several times per fling.
71343
71436
  *
71344
71437
  * Not on iOS. Its finger places the content from where the drag began, so a
71345
71438
  * write under it is undone at the finger's next move — or, when the finger
@@ -71347,11 +71440,15 @@ document.addEventListener(
71347
71440
  * stops on any write during its momentum, as the status bar's scroll to the
71348
71441
  * top does (Safari 26.5 on the simulator: flings of 1700px stopped 130 to 340px
71349
71442
  * after the lift, 3 in 3; a write 50ms before the lift stopped 2 flings in 3).
71350
- * No finger there is a hand a write could hold to. Chrome flings on through a
71351
- * write, and loses nothing by not getting one.
71352
- *
71353
- * A scroll is followed from its first "scroll" event to its "scrollend", and
71354
- * is a hand's while a finger is down, or when a wheel turned around the time it
71443
+ * No finger there is a hand a write could hold to, nor the fling it leaves.
71444
+ *
71445
+ * A scroll is followed from its first "scroll" event to its "scrollend". It is
71446
+ * a finger's while a finger is down, and still once the finger lifts if a
71447
+ * finger moved it around the time it started or since: the fling goes on in
71448
+ * the drag's own scroll, or in a scroll of its own that Chrome starts right
71449
+ * after the drag's "scrollend". What counts is a scroll the finger moved, not
71450
+ * a lift: a tap on a button calling a smooth `scrollTo` lifts a finger that
71451
+ * moved nothing. A scroll is a wheel's when a wheel turned around the time it
71355
71452
  * started or since. A finger is down from its touchstart to its touchend,
71356
71453
  * heard on what it touched: a list removes the items scrolled out of its window
71357
71454
  * while the finger is still on one of them, and the touchend of a removed
@@ -71365,6 +71462,10 @@ document.addEventListener(
71365
71462
  // next one. A turn further from it moved nothing (a wheel at an edge) and is no
71366
71463
  // hand on a scroll that came from elsewhere.
71367
71464
  const WHEEL_REACH = 100;
71465
+ // The fling Chrome plays as a scroll of its own starts a frame or two after the
71466
+ // drag's last move (DevTools' touch emulation, 4× CPU: 12ms after the drag's
71467
+ // "scrollend").
71468
+ const FLING_REACH = 150;
71368
71469
  // iOS's touch handling, the one engine whose finger drops writes (see above).
71369
71470
  const FINGER_KEEPS_WRITES = !window.CSS.supports(
71370
71471
  "-webkit-touch-callout",
@@ -71374,6 +71475,7 @@ const FINGER_KEEPS_WRITES = !window.CSS.supports(
71374
71475
  const flightMap = new WeakMap();
71375
71476
  let fingersDown = 0;
71376
71477
  let wheelTimeStamp = -Infinity;
71478
+ let fingerScrollTimeStamp = -Infinity;
71377
71479
 
71378
71480
  /**
71379
71481
  * @param {Element} scrollerEl
@@ -71384,7 +71486,10 @@ const wouldInterruptScroll = (scrollerEl) => {
71384
71486
  if (!flight) {
71385
71487
  return false;
71386
71488
  }
71387
- if (fingersDown > 0) {
71489
+ if (
71490
+ fingersDown > 0 ||
71491
+ fingerScrollTimeStamp > flight.timeStamp - FLING_REACH
71492
+ ) {
71388
71493
  return !FINGER_KEEPS_WRITES;
71389
71494
  }
71390
71495
  if (wheelTimeStamp > flight.timeStamp - WHEEL_REACH) {
@@ -71411,6 +71516,9 @@ if ("onscrollend" in window) {
71411
71516
  document.addEventListener(
71412
71517
  "scroll",
71413
71518
  (e) => {
71519
+ if (fingersDown > 0) {
71520
+ fingerScrollTimeStamp = e.timeStamp;
71521
+ }
71414
71522
  const scroller = asScroller(e.target);
71415
71523
  if (flightMap.has(scroller)) {
71416
71524
  return;
@@ -71741,7 +71849,13 @@ const createListItems = () => {
71741
71849
  // One, as a rule; a child that renders several items keeps them in the order
71742
71850
  // they first rendered, which is all it can be told.
71743
71851
  const ownerIdsBySlot = new Map();
71852
+ // What each run answers about the items it holds, drawn or not: where the
71853
+ // one of an id sits, which one sits at an index.
71744
71854
  const locatorByOwner = new Map();
71855
+ // The room each item took along the scroll axis the last time it was drawn,
71856
+ // by id: what a filler holds for it once it is not (see rememberItemRooms in
71857
+ // list.jsx). A number per item drawn while the list is mounted.
71858
+ const roomById = new Map();
71745
71859
  // The slots as the tree reads, first to last, and where each stands in it.
71746
71860
  // Rebuilt once a walk has changed the tree, read to place the owners.
71747
71861
  const slotWalk = [];
@@ -72090,6 +72204,7 @@ const createListItems = () => {
72090
72204
  holdViewStill: () => {},
72091
72205
  horizontal: false,
72092
72206
  virtualItemSizeSignal: null,
72207
+ roomById,
72093
72208
  renderSkeleton: undefined,
72094
72209
  // The children a walk stands over, in order — said in one call, before any
72095
72210
  // of them renders, so that what a child asks next is answered against the
@@ -72121,22 +72236,33 @@ const createListItems = () => {
72121
72236
  // Whether any run of items lives in this list: what makes a render window
72122
72237
  // mean anything (see List's renderBudget).
72123
72238
  hasRuns: () => locatorByOwner.size > 0,
72124
- setItemLocator: (ownerId, locate) => {
72125
- locatorByOwner.set(ownerId, locate);
72239
+ setItemLocator: (ownerId, locator) => {
72240
+ locatorByOwner.set(ownerId, locator);
72126
72241
  },
72127
72242
  dropItemLocator: (ownerId) => {
72128
72243
  locatorByOwner.delete(ownerId);
72129
72244
  },
72130
72245
  // Where the item named by that id sits, asked of whoever holds it.
72131
72246
  locateItem: (id) => {
72132
- for (const locate of locatorByOwner.values()) {
72133
- const index = locate(id);
72247
+ for (const locator of locatorByOwner.values()) {
72248
+ const index = locator.indexOf(id);
72134
72249
  if (index !== null) {
72135
72250
  return index;
72136
72251
  }
72137
72252
  }
72138
72253
  return null;
72139
72254
  },
72255
+ // The room the item at that index took when it was last drawn, or
72256
+ // undefined: never drawn, not held, or not an item of a run.
72257
+ roomAt: (index) => {
72258
+ for (const locator of locatorByOwner.values()) {
72259
+ const id = locator.idAt(index);
72260
+ if (id !== undefined) {
72261
+ return roomById.get(id);
72262
+ }
72263
+ }
72264
+ return undefined;
72265
+ },
72140
72266
  // The place the owner's items start at — read from a signal, so that the
72141
72267
  // owner is rendered again when it moves (see the top of this file). Asked
72142
72268
  // on every render, and answered without a second look for as long as the
@@ -74214,6 +74340,8 @@ const useListScrollSync = ({
74214
74340
  // state: the render moves it where the list is held (holdWindow) without a
74215
74341
  // commit of its own.
74216
74342
  const renderWindowRef = useRef(null);
74343
+ // A window was asked for that no render has drawn yet (see settleWindow).
74344
+ const windowAskedRef = useRef(false);
74217
74345
  const windowLeavesItemsOut = () => {
74218
74346
  const renderWindow = renderWindowRef.current;
74219
74347
  if (!renderWindow) {
@@ -74308,6 +74436,8 @@ const useListScrollSync = ({
74308
74436
  return;
74309
74437
  }
74310
74438
  captureAnchor();
74439
+ // What was measured is the room of an item alone on its line.
74440
+ listItems.roomById.clear();
74311
74441
  itemsPerLineRef.current = itemsPerLineNow;
74312
74442
  setItemsPerLine(itemsPerLineNow);
74313
74443
  };
@@ -74389,6 +74519,25 @@ const useListScrollSync = ({
74389
74519
  }
74390
74520
  }
74391
74521
  }
74522
+ // The items about to leave the window are still drawn: the room each takes
74523
+ // is kept, and the filler holding its place from then on holds exactly that
74524
+ // (see VirtualFiller). Nothing above the screen then changes as the window
74525
+ // slides, which matters most where the anchoring cannot write: a scroll in
74526
+ // flight (see holdAnchorStill). Every item drawn is read, since any may be
74527
+ // the next to leave. A room kept is the room of the last drawing: an item
74528
+ // that changed since is measured again when drawn again, and the anchoring
74529
+ // holds the view across the difference. Items laid side by side in a grid
74530
+ // share a line, which the fillers hold at the one size.
74531
+ const rememberItemRooms = () => {
74532
+ if (!ref.current || itemsPerLineRef.current !== 1) {
74533
+ return;
74534
+ }
74535
+ const listEl = getListEl();
74536
+ if (!listEl) {
74537
+ return;
74538
+ }
74539
+ readItemRooms(listEl, horizontal, listItems.roomById);
74540
+ };
74392
74541
  const updateRenderWindow = (newStart, newEnd, reason) => {
74393
74542
  const {
74394
74543
  start,
@@ -74397,12 +74546,14 @@ const useListScrollSync = ({
74397
74546
  if (newStart === start && newEnd === end) {
74398
74547
  return null;
74399
74548
  }
74549
+ rememberItemRooms();
74400
74550
  captureAnchor();
74401
74551
  const renderWindow = {
74402
74552
  start: newStart,
74403
74553
  end: newEnd
74404
74554
  };
74405
74555
  renderWindowRef.current = renderWindow;
74556
+ windowAskedRef.current = true;
74406
74557
  setRenderWindow(renderWindow);
74407
74558
  return renderWindow;
74408
74559
  };
@@ -74812,6 +74963,20 @@ const useListScrollSync = ({
74812
74963
  }
74813
74964
  const scrollerEl = getScroller();
74814
74965
  if (openAt === "end") {
74966
+ const total = listItems.totalSignal.peek();
74967
+ const {
74968
+ start,
74969
+ end
74970
+ } = renderWindowRef.current;
74971
+ if (end < total) {
74972
+ // The window was framed before the runs said how many items there are
74973
+ // (see holdWindow): the end of the document is the end of a filler.
74974
+ // Aimed at the last items first, like a named item.
74975
+ const windowSize = end - start;
74976
+ const wantedStart = total - windowSize < 0 ? 0 : total - windowSize;
74977
+ updateRenderWindow(wantedStart, total);
74978
+ return;
74979
+ }
74815
74980
  sendHeldScroll(horizontal ? scrollerEl.scrollWidth : scrollerEl.scrollHeight);
74816
74981
  return;
74817
74982
  }
@@ -74889,6 +75054,13 @@ const useListScrollSync = ({
74889
75054
  if (stage === "steady") {
74890
75055
  return;
74891
75056
  }
75057
+ if (windowAskedRef.current) {
75058
+ // The hold has just aimed the window where the list opens — its end, an
75059
+ // item it found — and the next commit draws it. Sized now, the window
75060
+ // would weigh the items there by the ones drawn here, nowhere near them:
75061
+ // the one-line items at the start of a thread opening on its cards.
75062
+ return;
75063
+ }
74892
75064
  const initialBudget = initialBudgetRef.current;
74893
75065
  if (initialBudget && !paintedRef.current) {
74894
75066
  if (stage === "picture") {
@@ -75229,9 +75401,9 @@ const useListScrollSync = ({
75229
75401
  if (wouldInterruptScroll(scrollerEl)) {
75230
75402
  // Nobody is looking at items standing still: they are passing by, on
75231
75403
  // their way somewhere the browser takes them (a key, a smooth scrollTo,
75232
- // the status bar, a fling). The write would stop that scroll short of
75233
- // where it was going. Left alone, the items drawn above push the rest by
75234
- // `drift` while all of it moves, and the scroll lands.
75404
+ // the status bar, a fling on iOS). The write would stop that scroll short
75405
+ // of where it was going. Left alone, the items drawn above push the rest
75406
+ // by `drift` while all of it moves, and the scroll lands.
75235
75407
  return;
75236
75408
  }
75237
75409
  scrolledByListRef.current = true;
@@ -75360,8 +75532,14 @@ const useListScrollSync = ({
75360
75532
  bandEnd = walkAfter(hold.index, screenSize - hold.above, pixelsOf).index;
75361
75533
  forward = true;
75362
75534
  } else {
75363
- bandStart = findBandStart(items, bandFrom, itemSize);
75364
- bandEnd = bandTo > bandFrom ? findBandEnd(items, bandTo, itemSize) : bandStart;
75535
+ // In the room a filler holds, an item is counted at what the filler
75536
+ // gives it.
75537
+ const fillerSizeOf = itemSize > 0 ? index => {
75538
+ const room = listItems.roomAt(index);
75539
+ return room === undefined ? itemSize : room;
75540
+ } : null;
75541
+ bandStart = findBandStart(items, bandFrom, fillerSizeOf, total);
75542
+ bandEnd = bandTo > bandFrom ? findBandEnd(items, bandTo, fillerSizeOf, total) : bandStart;
75365
75543
  forward = scrollDirectionRef.current > 0;
75366
75544
  }
75367
75545
  if (bandStart > total) {
@@ -75610,6 +75788,8 @@ const useListScrollSync = ({
75610
75788
  };
75611
75789
  }, [scrollerElResolved]);
75612
75790
  holdWindow();
75791
+ // Whatever was asked for until now is what this render draws.
75792
+ windowAskedRef.current = false;
75613
75793
  const {
75614
75794
  start: windowStart,
75615
75795
  end: windowEnd
@@ -76270,12 +76450,49 @@ const readWindowGeometry = (scrollerEl, listEl, horizontal) => {
76270
76450
  items
76271
76451
  };
76272
76452
  };
76453
+ // The room each item drawn takes along the scroll axis (see
76454
+ // rememberItemRooms): up to the next item when that one is drawn too, so that
76455
+ // what stands between them — a separator, the label of a group the next one
76456
+ // opens — is counted; its own box otherwise. A stand-in is not measured: it
76457
+ // takes the room it is given, not the room of its item.
76458
+ const readItemRooms = (listEl, horizontal, roomById) => {
76459
+ let previous = null;
76460
+ const keepRoom = (item, next) => {
76461
+ if (item.id === null) {
76462
+ return;
76463
+ }
76464
+ roomById.set(item.id, next && next.index === item.index + 1 ? next.from - item.from : item.to - item.from);
76465
+ };
76466
+ for (const itemEl of listEl.querySelectorAll(`[${LIST_ITEM_INDEX_ATTRIBUTE}]`)) {
76467
+ if (!isOwnItem(itemEl, listEl)) {
76468
+ continue;
76469
+ }
76470
+ const rect = itemEl.getBoundingClientRect();
76471
+ if (rect.width === 0 && rect.height === 0) {
76472
+ continue;
76473
+ }
76474
+ const item = {
76475
+ id: getItemName(itemEl),
76476
+ index: Number(itemEl.getAttribute(LIST_ITEM_INDEX_ATTRIBUTE)),
76477
+ from: horizontal ? rect.left : rect.top,
76478
+ to: horizontal ? rect.right : rect.bottom
76479
+ };
76480
+ if (previous) {
76481
+ keepRoom(previous, item);
76482
+ }
76483
+ previous = item;
76484
+ }
76485
+ if (previous) {
76486
+ keepRoom(previous, null);
76487
+ }
76488
+ };
76273
76489
  // The first item the screen shows from `position` on, and the item after the
76274
76490
  // last one it shows up to `position` (exclusive, like the window's end). An
76275
76491
  // item drawn is read off its box; in the room a filler holds for items not
76276
- // drawn, the item is counted at the size the filler gives each of them — the
76277
- // only way a scroll position there says which item it is.
76278
- const findBandStart = (items, position, itemSize) => {
76492
+ // drawn, each item is counted at the size the filler gives it (`sizeOf`) —
76493
+ // the only way a scroll position there says which item it is. Without a size
76494
+ // (`null`), the room a filler holds says nothing.
76495
+ const findBandStart = (items, position, sizeOf, total) => {
76279
76496
  let low = 0;
76280
76497
  let high = items.length;
76281
76498
  while (low < high) {
@@ -76289,29 +76506,28 @@ const findBandStart = (items, position, itemSize) => {
76289
76506
  const next = items[low];
76290
76507
  const previous = items[low - 1];
76291
76508
  if (!next) {
76292
- if (!itemSize) {
76509
+ if (!sizeOf) {
76293
76510
  return previous.index + 1;
76294
76511
  }
76295
- return previous.index + 1 + Math.floor((position - previous.to) / itemSize);
76512
+ return findIndexAfter(previous.index + 1, previous.to, position, sizeOf, total);
76296
76513
  }
76297
76514
  if (next.from <= position) {
76298
76515
  return next.index;
76299
76516
  }
76300
76517
  if (!previous) {
76301
- if (!itemSize) {
76518
+ if (!sizeOf) {
76302
76519
  return next.index;
76303
76520
  }
76304
- const index = next.index - Math.ceil((next.from - position) / itemSize);
76521
+ const index = findIndexBefore(next.index, next.from, position, sizeOf);
76305
76522
  return index < 0 ? 0 : index;
76306
76523
  }
76307
- if (!itemSize || next.index === previous.index + 1) {
76524
+ if (!sizeOf || next.index === previous.index + 1) {
76308
76525
  // Between two items that follow each other: a separator, a group label.
76309
76526
  return next.index;
76310
76527
  }
76311
- const index = previous.index + 1 + Math.floor((position - previous.to) / itemSize);
76312
- return index < next.index ? index : next.index;
76528
+ return findIndexAfter(previous.index + 1, previous.to, position, sizeOf, next.index);
76313
76529
  };
76314
- const findBandEnd = (items, position, itemSize) => {
76530
+ const findBandEnd = (items, position, sizeOf, total) => {
76315
76531
  let low = 0;
76316
76532
  let high = items.length;
76317
76533
  while (low < high) {
@@ -76325,24 +76541,53 @@ const findBandEnd = (items, position, itemSize) => {
76325
76541
  const previous = items[low - 1];
76326
76542
  const next = items[low];
76327
76543
  if (!previous) {
76328
- if (!itemSize) {
76544
+ if (!sizeOf) {
76329
76545
  return next.index;
76330
76546
  }
76331
- const index = next.index - Math.ceil((next.from - position) / itemSize) + 1;
76332
- return index < 0 ? 0 : index;
76547
+ return findIndexBefore(next.index, next.from, position, sizeOf) + 1;
76333
76548
  }
76334
76549
  if (previous.to >= position) {
76335
76550
  return previous.index + 1;
76336
76551
  }
76337
- if (!itemSize || next && next.index === previous.index + 1) {
76552
+ if (!sizeOf || next && next.index === previous.index + 1) {
76338
76553
  return previous.index + 1;
76339
76554
  }
76340
- const index = previous.index + 2 + Math.floor((position - previous.to) / itemSize);
76555
+ const index = findIndexAfter(previous.index + 1, previous.to, position, sizeOf, next ? next.index : total) + 1;
76341
76556
  if (next && index > next.index) {
76342
76557
  return next.index;
76343
76558
  }
76344
76559
  return index;
76345
76560
  };
76561
+ // The item whose room holds `position`, the rooms laid one after the other
76562
+ // from `from` on, starting with the item at `index` — `limit` when `position`
76563
+ // is past them all.
76564
+ const findIndexAfter = (index, from, position, sizeOf, limit) => {
76565
+ let at = from;
76566
+ let itemIndex = index;
76567
+ while (itemIndex < limit) {
76568
+ const size = sizeOf(itemIndex);
76569
+ if (at + size > position) {
76570
+ return itemIndex;
76571
+ }
76572
+ at += size;
76573
+ itemIndex++;
76574
+ }
76575
+ return limit;
76576
+ };
76577
+ // The same, the rooms laid back from `to`, ending with the item before the
76578
+ // one at `index` — -1 when `position` is before them all.
76579
+ const findIndexBefore = (index, to, position, sizeOf) => {
76580
+ let at = to;
76581
+ let itemIndex = index;
76582
+ while (at > position) {
76583
+ if (itemIndex === 0) {
76584
+ return -1;
76585
+ }
76586
+ itemIndex--;
76587
+ at -= sizeOf(itemIndex);
76588
+ }
76589
+ return itemIndex;
76590
+ };
76346
76591
  // How much the window holds on each side of the screen, in the budget's unit:
76347
76592
  // read off the items' boxes where they stand, or — while the list holds itself
76348
76593
  // on an item, and the screen is where that item is going to stand (see
@@ -76858,16 +77103,19 @@ const Fallback = ({
76858
77103
  // every item it draws must not be redrawn — every item of it — because the size
76859
77104
  // settled after the first commit.
76860
77105
  // It holds lines: the items side by side on one take its room together, and a
76861
- // last line left short takes it all the same.
77106
+ // last line left short takes it all the same. An item the list drew before
77107
+ // takes the room it took then instead (see rememberItemRooms).
76862
77108
  const VirtualFiller = ({
76863
77109
  edge,
76864
77110
  itemCount,
76865
77111
  itemsPerLine,
77112
+ itemsRemembered,
77113
+ roomRemembered,
76866
77114
  findChunks
76867
77115
  }) => {
76868
77116
  const listItems = useContext(ListItemsContext);
76869
77117
  const lineSize = listItems.virtualItemSizeSignal.value;
76870
- const sizeToFill = Math.ceil(itemCount / itemsPerLine) * lineSize;
77118
+ const sizeToFill = Math.ceil(itemCount / itemsPerLine) * lineSize - itemsRemembered * lineSize / itemsPerLine + roomRemembered;
76871
77119
  // A filler resizing moves what stands below it — the items on screen, when it
76872
77120
  // holds the room of items above them — and it resizes in a commit of its own
76873
77121
  // when the item size settles after the list has rendered: the list puts its
@@ -76885,14 +77133,14 @@ const VirtualFiller = ({
76885
77133
  "navi-virtual-filler": edge,
76886
77134
  "aria-hidden": true,
76887
77135
  style: {
76888
- "--size-to-fill": `${sizeToFill}px`,
76889
- "--x-find-line-size": findChunks ? `${lineSize / itemsPerLine}px` : undefined
77136
+ "--size-to-fill": `${sizeToFill}px`
76890
77137
  },
76891
77138
  children: findChunks && findChunks.map(chunk => jsx("div", {
76892
77139
  className: "navi_list_find_stand_in",
76893
77140
  hidden: "until-found",
76894
77141
  style: {
76895
- "--x-find-line-count": chunk.to - chunk.from
77142
+ "--x-find-line-count": chunk.to - chunk.from,
77143
+ "--x-find-line-size": `${chunk.room === undefined ? lineSize / itemsPerLine : chunk.room}px`
76896
77144
  },
76897
77145
  children: chunk.text
76898
77146
  }, `${chunk.from}_${chunk.to}`))
@@ -77782,16 +78030,51 @@ const useRunItems = (store, {
77782
78030
  // Where an item named from outside actually sits. Only the run can answer:
77783
78031
  // items it holds but does not draw are nowhere else — a list only knows the
77784
78032
  // items it has drawn (they register themselves, see ListItemUI).
77785
- listItems.setItemLocator(ownerId, id => {
77786
- let found = null;
77787
- store.eachHeld((item, rank) => {
77788
- const itemIndex = indexOfRank(rank);
77789
- if (found === null && idOf(item, itemIndex) === id) {
77790
- found = itemIndex;
78033
+ const idAt = itemIndex => {
78034
+ if (itemIndex < runStart || itemIndex >= runEnd) {
78035
+ return undefined;
78036
+ }
78037
+ const item = getItemAt(itemIndex);
78038
+ return item === undefined ? undefined : idOf(item, itemIndex);
78039
+ };
78040
+ listItems.setItemLocator(ownerId, {
78041
+ indexOf: id => {
78042
+ let found = null;
78043
+ store.eachHeld((item, rank) => {
78044
+ const itemIndex = indexOfRank(rank);
78045
+ if (found === null && idOf(item, itemIndex) === id) {
78046
+ found = itemIndex;
78047
+ }
78048
+ });
78049
+ return found;
78050
+ },
78051
+ idAt
78052
+ });
78053
+ // The room an item took when the list last drew it (see rememberItemRooms),
78054
+ // or undefined: a filler holds an item it never drew at the item size.
78055
+ const roomOf = itemIndex => {
78056
+ const id = idAt(itemIndex);
78057
+ return id === undefined ? undefined : listItems.roomById.get(id);
78058
+ };
78059
+ const readRemembered = (from, to) => {
78060
+ const remembered = {
78061
+ room: 0,
78062
+ count: 0
78063
+ };
78064
+ if (listItems.roomById.size === 0) {
78065
+ return remembered;
78066
+ }
78067
+ let itemIndex = from;
78068
+ while (itemIndex < to) {
78069
+ const room = roomOf(itemIndex);
78070
+ if (room !== undefined) {
78071
+ remembered.room += room;
78072
+ remembered.count++;
77791
78073
  }
77792
- });
77793
- return found;
77794
- });
78074
+ itemIndex++;
78075
+ }
78076
+ return remembered;
78077
+ };
77795
78078
  useLayoutEffect(() => {
77796
78079
  return () => {
77797
78080
  listItems.dropItemLocator(ownerId);
@@ -77967,7 +78250,9 @@ const useRunItems = (store, {
77967
78250
  };
77968
78251
  // The text of the items a filler stands for, cut in chunks aligned on the
77969
78252
  // run's own ranks: the window sliding changes the chunk at its edge and
77970
- // leaves the others as they are.
78253
+ // leaves the others as they are. A chunk's lines are all as tall as the
78254
+ // room its items take in the filler, so it also ends where that room
78255
+ // changes: a match is then found where its item will be drawn.
77971
78256
  const getFindChunks = (from, to) => {
77972
78257
  if (!findText) {
77973
78258
  return null;
@@ -77977,10 +78262,11 @@ const useRunItems = (store, {
77977
78262
  while (chunkFrom < to) {
77978
78263
  const chunkIndex = Math.floor(rankOf(chunkFrom) / FIND_CHUNK_ITEM_COUNT);
77979
78264
  const alignedTo = indexOfRank((chunkIndex + 1) * FIND_CHUNK_ITEM_COUNT);
77980
- const chunkTo = alignedTo < to ? alignedTo : to;
78265
+ const chunkEnd = alignedTo < to ? alignedTo : to;
78266
+ const room = roomOf(chunkFrom);
77981
78267
  const lines = [];
77982
78268
  let lineIndex = chunkFrom;
77983
- while (lineIndex < chunkTo) {
78269
+ while (lineIndex < chunkEnd && roomOf(lineIndex) === room) {
77984
78270
  const item = getItemAt(lineIndex);
77985
78271
  // An item not held has nothing to find, but keeps its line: the items
77986
78272
  // after it stay at their place.
@@ -77989,10 +78275,11 @@ const useRunItems = (store, {
77989
78275
  }
77990
78276
  chunks.push({
77991
78277
  from: chunkFrom,
77992
- to: chunkTo,
78278
+ to: lineIndex,
78279
+ room,
77993
78280
  text: lines.join("\n")
77994
78281
  });
77995
- chunkFrom = chunkTo;
78282
+ chunkFrom = lineIndex;
77996
78283
  }
77997
78284
  return chunks;
77998
78285
  };
@@ -78001,10 +78288,13 @@ const useRunItems = (store, {
78001
78288
  // one run, and what sits before or after it (a header, items given one by
78002
78289
  // one) is not virtualized at all.
78003
78290
  if (windowFrom > runStart) {
78291
+ const remembered = readRemembered(runStart, windowFrom);
78004
78292
  nodes.push(jsx(VirtualFiller, {
78005
78293
  edge: "before",
78006
78294
  itemCount: windowFrom - runStart,
78007
78295
  itemsPerLine: itemsPerLine,
78296
+ itemsRemembered: remembered.count,
78297
+ roomRemembered: remembered.room,
78008
78298
  findChunks: getFindChunks(runStart, windowFrom)
78009
78299
  }, "navi-list-filler-before"));
78010
78300
  }
@@ -78106,10 +78396,13 @@ const useRunItems = (store, {
78106
78396
  }
78107
78397
  closeGroup();
78108
78398
  if (runEnd > windowTo) {
78399
+ const remembered = readRemembered(windowTo, runEnd);
78109
78400
  nodes.push(jsx(VirtualFiller, {
78110
78401
  edge: "after",
78111
78402
  itemCount: runEnd - windowTo,
78112
78403
  itemsPerLine: itemsPerLine,
78404
+ itemsRemembered: remembered.count,
78405
+ roomRemembered: remembered.room,
78113
78406
  findChunks: getFindChunks(windowTo, runEnd)
78114
78407
  }, "navi-list-filler-after"));
78115
78408
  }
@@ -79044,19 +79337,20 @@ const ListResolved = /*#__PURE__*/createComponentResolver([ListFirstResolver, Li
79044
79337
  * source for is a page (see `<List.Items pageSize>`), whatever either says,
79045
79338
  * so the first picture costs no second request.
79046
79339
  * @param {number} [props.virtualItemSize]
79047
- * The room an item not drawn is held at, in px along the scroll axis: what
79048
- * the fillers hold for each item outside the render window, what a scroll
79049
- * position inside them is read with, and the least an item on its way takes
79050
- * (see `renderSkeleton`). Left out, it is the average of the items measured
79051
- * so far — once when the list mounts, again when a popup around it opens,
79052
- * and after each commit while items are held off screen. Given, it is the
79053
- * worst case: the size every item has, or, in a list whose items differ, the
79054
- * smallest an item can be (a thread of one-line items and cards: the
79055
- * one-line size) — an item guessed too small builds one more, an item
79056
- * guessed too big leaves a blank (see docs/scroll.md, "What the list knows,
79057
- * and what it guesses"). The items drawn are measured either way: the render
79058
- * window is sized on them. In a grid (`columns`), the size is a line's: a
79059
- * card's, which the items side by side on it share.
79340
+ * The room an item never drawn is held at, in px along the scroll axis: what
79341
+ * the fillers hold for it outside the render window, what a scroll position
79342
+ * inside them is read with, and the least an item on its way takes (see
79343
+ * `renderSkeleton`). An item the list has drawn is held at the room it took
79344
+ * then. Left out, it is the average of the items measured so far — once when
79345
+ * the list mounts, again when a popup around it opens, and after each commit
79346
+ * while items are held off screen. Given, it is the worst case: the size
79347
+ * every item has, or, in a list whose items differ, the smallest an item can
79348
+ * be (a thread of one-line items and cards: the one-line size) — an item
79349
+ * guessed too small builds one more, an item guessed too big leaves a blank
79350
+ * (see docs/scroll.md, "What the list knows, and what it guesses"). The items
79351
+ * drawn are measured either way: the render window is sized on them. In a
79352
+ * grid (`columns`), the size is a line's: a card's, which the items side by
79353
+ * side on it share, and the lines drawn are held at it too.
79060
79354
  * @param {"self"|"parent"|"document"|Element|{current: Element}} [props.scroller="self"]
79061
79355
  * Which box scrolls — and with it, which box the render window follows and
79062
79356
  * which box a scroll position is read from (`onScrolledChange`). `"self"`
@@ -90553,20 +90847,33 @@ const Table = props => {
90553
90847
  columns,
90554
90848
  canChangeColumnOrder: Boolean(onColumnOrderChange)
90555
90849
  });
90850
+
90851
+ // Edition starts from a cell holding the focus itself. Once started, the
90852
+ // focus is in the cell's field and its keys are the field's: an Enter there
90853
+ // validates, it must not start edition again.
90854
+ const canStartEditing = () => dragContextValue.grabTarget === null && document.activeElement.tagName === "TD";
90855
+ // The key starting edition is its first keystroke, typed by Editable
90856
+ // (initialValue). Its default is prevented: the browser would type it a
90857
+ // second time into the field that takes the focus during this keydown.
90858
+ const startEditingWithKey = e => {
90859
+ e.preventDefault();
90860
+ document.activeElement.dispatchEvent(new CustomEvent("editrequested", {
90861
+ bubbles: false,
90862
+ detail: {
90863
+ initialValue: e.key
90864
+ }
90865
+ }));
90866
+ return true;
90867
+ };
90556
90868
  useKeyboardShortcuts(ref, [...createSelectionKeyboardShortcuts(selectionController, {
90557
90869
  toggleEnabled: true,
90558
90870
  enabled: () => dragContextValue.grabTarget === null
90559
90871
  }), {
90560
90872
  key: "enter",
90561
90873
  description: "Edit table cell content",
90562
- enabled: () => dragContextValue.grabTarget === null,
90874
+ enabled: canStartEditing,
90563
90875
  handler: () => {
90564
- // Find the currently focused cell
90565
- const activeCell = document.activeElement.closest("td");
90566
- if (!activeCell) {
90567
- return false;
90568
- }
90569
- activeCell.dispatchEvent(new CustomEvent("editrequested", {
90876
+ document.activeElement.dispatchEvent(new CustomEvent("editrequested", {
90570
90877
  bubbles: false
90571
90878
  }));
90572
90879
  return true;
@@ -90574,37 +90881,13 @@ const Table = props => {
90574
90881
  }, {
90575
90882
  key: "a-z",
90576
90883
  description: "Start editing table cell content",
90577
- enabled: () => dragContextValue.grabTarget === null,
90578
- handler: e => {
90579
- const activeCell = document.activeElement.closest("td");
90580
- if (!activeCell) {
90581
- return false;
90582
- }
90583
- activeCell.dispatchEvent(new CustomEvent("editrequested", {
90584
- bubbles: false,
90585
- detail: {
90586
- initialValue: e.key
90587
- }
90588
- }));
90589
- return true;
90590
- }
90884
+ enabled: canStartEditing,
90885
+ handler: startEditingWithKey
90591
90886
  }, {
90592
90887
  key: "0-9",
90593
90888
  description: "Start editing table cell content",
90594
- enabled: () => dragContextValue.grabTarget === null,
90595
- handler: e => {
90596
- const activeCell = document.activeElement.closest("td");
90597
- if (!activeCell) {
90598
- return false;
90599
- }
90600
- activeCell.dispatchEvent(new CustomEvent("editrequested", {
90601
- bubbles: false,
90602
- detail: {
90603
- initialValue: e.key
90604
- }
90605
- }));
90606
- return true;
90607
- }
90889
+ enabled: canStartEditing,
90890
+ handler: startEditingWithKey
90608
90891
  }]);
90609
90892
 
90610
90893
  // resizing