@jsenv/navi 0.29.108 → 0.29.110

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.
@@ -4,9 +4,9 @@
4
4
  */
5
5
  import { installImportMetaCssBuild, windowHeightSignal, windowWidthSignal, visualViewportHeightSignal, visualViewportWidthSignal, getAppHeight, getAppWidth, coarsePointerSignal, smallTouchScreenSignal } from "./jsenv_navi_side_effects.js";
6
6
  export { disableVirtualKeyboardOverlay } from "./jsenv_navi_side_effects.js";
7
- import { elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, createPubSub, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, clickIsSuppressed, watchWheelTravel, scrollRoomTowards, closestOpenableAncestor, isAncestorOpen, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, initFocusGroup, scrollIntoViewScoped, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, isTouchDrivenEvent, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
7
+ import { elementIsFocusable, createIterableWeakSet, dispatchInternalCustomEvent, dispatchCustomEvent, getVisuallyVisibleInfo, getFirstVisuallyVisibleAncestor, getElementSignature, createPubSub, findEvent, createValueEffect, findFocusDelegateTarget, findFocusable, allowWheelThrough, dispatchPublicCustomEvent, resolveCSSColor, ELEMENT_SIZE_CHANGE, findSelfOrAncestorFixedPosition, visibleRectEffect, pickPositionRelativeTo, getBorderSizes, getPaddingSizes, applyNewPosition, measureLongestVisualLineWidth, chainEvent, waitForPressHeld, suppressClickAfterGesture, startDragToTravel, markDragSource, startDragTo, createEventGroupLogger, getKeyboardEventDefaultAction, activeElementSignal, normalizeStyle, mergeOneStyle, getPositionedParent, normalizeStyles, createGroupTransitionController, getBorderRadius, preventIntermediateScrollbar, createOpacityTransition, watchWheelTravel, scrollRoomTowards, closestOpenableAncestor, isAncestorOpen, isDisplayedDespiteClosedAncestor, observeAncestorOpenState, getAncestorOpenType, findBefore, findAfter, resolveCSSSize, hasCSSSizeUnit, initFocusGroup, scrollIntoViewScoped, stringifyStyle as stringifyStyle$1, resolveOklchLightness, contrastColor, isTouchDrivenEvent, parsePositionArea, snapToPixel, trapFocusInside, trapScrollInside, onAncestorReopen, getScrollContainer, canScroll, measureWidestChildRow, performTabNavigation, wheelGestureIsTakenFrom, releaseWheelGesture, claimWheelGesture, dragAfterIntent, stickyAsRelativeCoords, createDragToMoveGestureController, getDropTargetInfo, setStyles, useActiveElement } from "@jsenv/dom";
8
8
  export { clickIsSuppressed, contrastColor, findEvent, startDragTo } from "@jsenv/dom";
9
- import { signal, computed, effect, batch, untracked, useSignal } from "@preact/signals";
9
+ import { signal, computed, effect, batch, useComputed, untracked, useSignal } from "@preact/signals";
10
10
  import { isValidElement, createContext, render, h, Fragment, toChildArray, options, cloneElement } from "preact";
11
11
  import { useErrorBoundary, useLayoutEffect, useContext, useCallback, useRef, useState, useEffect, useMemo, useId } from "preact/hooks";
12
12
  import { jsxs, jsx, Fragment as Fragment$1 } from "preact/jsx-runtime";
@@ -3689,6 +3689,7 @@ const CONSTRAINT_NAME_TO_PROP = {
3689
3689
  min: "minMessage",
3690
3690
  max: "maxMessage",
3691
3691
  single_space: "singleSpaceMessage",
3692
+ displayable: "displayableMessage",
3692
3693
  same_as: "sameAsMessage",
3693
3694
  min_lower_letter: "minLowerLetterMessage",
3694
3695
  min_upper_letter: "minUpperLetterMessage",
@@ -3820,6 +3821,11 @@ const CONTROL_PROP_SET = new Set([
3820
3821
 
3821
3822
  "loading",
3822
3823
  "basePseudoState",
3824
+ // App constraints this control must satisfy, on top of the ones navi ships:
3825
+ // `constraints={[MY_CONSTRAINT]}`. A constraint is an object, so it carries
3826
+ // its own parameters — nothing has to travel through an attribute. Read on
3827
+ // every check in control_validation.js; `registerGlobalConstraint` is the
3828
+ // same thing for every control at once.
3823
3829
  "constraints",
3824
3830
 
3825
3831
  // A real target inside a zone that belongs to another control — see
@@ -7245,6 +7251,18 @@ naviI18n.addAll({
7245
7251
  fr: "Ce champ ne doit pas contenir plusieurs espaces consécutifs.",
7246
7252
  en: "This field must not contain consecutive spaces.",
7247
7253
  },
7254
+ "constraint.displayable.stacked_marks.default": {
7255
+ fr: "Ce champ ne doit pas empiler plus de <strong>[max]</strong> signes sur un même caractère.",
7256
+ en: "This field must not stack more than <strong>[max]</strong> marks on a single character.",
7257
+ },
7258
+ "constraint.displayable.invisible.default": {
7259
+ fr: "Ce champ doit contenir au moins un caractère visible.",
7260
+ en: "This field must contain at least one visible character.",
7261
+ },
7262
+ "constraint.displayable.blank_lines.default": {
7263
+ fr: "Ce champ ne doit pas contenir plusieurs lignes vides consécutives.",
7264
+ en: "This field must not contain consecutive blank lines.",
7265
+ },
7248
7266
  "constraint.min_lower_letter.password.singular": {
7249
7267
  fr: "Ce mot de passe doit contenir au moins une lettre minuscule.",
7250
7268
  en: "This password must contain at least one lowercase letter.",
@@ -10969,6 +10987,91 @@ const isFunctionButNotAnActionFunction = (action) => {
10969
10987
  return typeof action === "function" && !action.isAction;
10970
10988
  };
10971
10989
 
10990
+ /**
10991
+ * `data-displayable` — the value must be something the layout can actually
10992
+ * draw. Three shapes break a row, a card or a list even though every character
10993
+ * taken alone is legitimate, so no character class can express them:
10994
+ *
10995
+ * - marks stacked on one base character ("zalgo"): a diacritic is a normal
10996
+ * character — a decomposed Vietnamese letter carries two, a vocalized Hebrew
10997
+ * one three — what is not normal is the count in a row. Thirty of them draw
10998
+ * far above the line, over the row above.
10999
+ * - a value that is not empty and yet shows nothing: only spaces, only marks,
11000
+ * only format characters. An empty-looking line in the middle of a list
11001
+ * reads as a bug.
11002
+ * - blank lines in series: forty newlines make a card as tall as the screen.
11003
+ *
11004
+ * These are display rules, not app rules: they hold for every field, whatever
11005
+ * it holds — which is why they ship here rather than being rewritten per app.
11006
+ *
11007
+ * Note what is deliberately NOT refused: U+200D (ZWJ) and U+200C (ZWNJ) are
11008
+ * invisible characters, but the first assembles 👨‍👩‍👧 and 🏳️‍🌈 and the second
11009
+ * separates two letters in Persian. Banning invisible characters outright
11010
+ * would mean banning composed emoji. They only make a value fail here when
11011
+ * nothing visible is left once they are removed.
11012
+ */
11013
+
11014
+
11015
+ // Above what any writing system needs on one base character, far below what
11016
+ // zalgo uses. Raise it with data-max-stacked-marks when a language needs more.
11017
+ const DEFAULT_MAX_STACKED_MARKS = 5;
11018
+
11019
+ // Everything that occupies no ink of its own: spaces, control and format
11020
+ // characters, and combining marks (which draw on a base character, so a value
11021
+ // made only of them has nothing to draw on).
11022
+ const INK_LESS_REGEX = /[\p{White_Space}\p{Cc}\p{Cf}\p{M}]/gu;
11023
+ // Two newlines are one blank line — a paragraph break; three are two.
11024
+ const BLANK_LINES_REGEX = /\n[^\S\n]*\n[^\S\n]*\n/;
11025
+
11026
+ const stackedMarksRegexCache = new Map();
11027
+ const getStackedMarksRegex = (maxStackedMarks) => {
11028
+ const fromCache = stackedMarksRegexCache.get(maxStackedMarks);
11029
+ if (fromCache) {
11030
+ return fromCache;
11031
+ }
11032
+ const regex = new RegExp(`\\p{M}{${maxStackedMarks + 1},}`, "u");
11033
+ stackedMarksRegexCache.set(maxStackedMarks, regex);
11034
+ return regex;
11035
+ };
11036
+
11037
+ const DISPLAYABLE_CONSTRAINT = {
11038
+ name: "displayable",
11039
+ messageAttribute: "data-displayable-message",
11040
+ check: (field) => {
11041
+ const displayable = field.controlHostProps["data-displayable"];
11042
+ if (displayable === undefined) {
11043
+ return null;
11044
+ }
11045
+ const valueAsString =
11046
+ field.uiState === undefined ? "" : String(field.uiState);
11047
+ if (valueAsString === "") {
11048
+ // An empty field is `required`'s business, not this one's.
11049
+ return null;
11050
+ }
11051
+
11052
+ const maxStackedMarksAttribute =
11053
+ field.controlHostProps["data-max-stacked-marks"];
11054
+ const maxStackedMarks =
11055
+ maxStackedMarksAttribute === undefined
11056
+ ? DEFAULT_MAX_STACKED_MARKS
11057
+ : parseInt(maxStackedMarksAttribute, 10);
11058
+ if (getStackedMarksRegex(maxStackedMarks).test(valueAsString)) {
11059
+ return naviI18n("constraint.displayable.stacked_marks.default", {
11060
+ max: maxStackedMarks,
11061
+ });
11062
+ }
11063
+ if (valueAsString.replace(INK_LESS_REGEX, "") === "") {
11064
+ return naviI18n("constraint.displayable.invisible.default");
11065
+ }
11066
+ if (BLANK_LINES_REGEX.test(valueAsString)) {
11067
+ return naviI18n("constraint.displayable.blank_lines.default");
11068
+ }
11069
+ return null;
11070
+ },
11071
+ };
11072
+ CONSTRAINT_ATTRIBUTE_SET.add("data-displayable");
11073
+ CONSTRAINT_ATTRIBUTE_SET.add("data-max-stacked-marks");
11074
+
10972
11075
  const MIN_LOWER_LETTER_CONSTRAINT = {
10973
11076
  name: "min_lower_letter",
10974
11077
  messageAttribute: "data-min-lower-letter-message",
@@ -11386,6 +11489,7 @@ const STANDARD_CONSTRAINT_SET = new Set([
11386
11489
  const NAVI_CONSTRAINT_SET = new Set([
11387
11490
  MIN_SPECIAL_CHAR_CONSTRAINT,
11388
11491
  SINGLE_SPACE_CONSTRAINT,
11492
+ DISPLAYABLE_CONSTRAINT,
11389
11493
  MIN_DIGIT_CONSTRAINT,
11390
11494
  MIN_UPPER_LETTER_CONSTRAINT,
11391
11495
  MIN_LOWER_LETTER_CONSTRAINT,
@@ -11398,8 +11502,29 @@ const DEFAULT_CONSTRAINT_SET = new Set([
11398
11502
  ...NAVI_CONSTRAINT_SET,
11399
11503
  ]);
11400
11504
  const registerGlobalConstraint = (customConstraint) => {
11401
- NAVI_CONSTRAINT_SET.add(customConstraint);
11402
- DEFAULT_CONSTRAINT_SET.add(customConstraint);
11505
+ const constraint = normalizeConstraint(customConstraint);
11506
+ NAVI_CONSTRAINT_SET.add(constraint);
11507
+ DEFAULT_CONSTRAINT_SET.add(constraint);
11508
+ };
11509
+
11510
+ // A constraint may be written as a bare check function; the rest of the code
11511
+ // wants the object shape. The wrapper is cached so a function passed on every
11512
+ // render keeps one identity across checks.
11513
+ const constraintFromFunctionMap = new WeakMap();
11514
+ const normalizeConstraint = (constraint) => {
11515
+ if (typeof constraint !== "function") {
11516
+ return constraint;
11517
+ }
11518
+ const existing = constraintFromFunctionMap.get(constraint);
11519
+ if (existing) {
11520
+ return existing;
11521
+ }
11522
+ const constraintObject = {
11523
+ name: constraint.name || "custom_function",
11524
+ check: constraint,
11525
+ };
11526
+ constraintFromFunctionMap.set(constraint, constraintObject);
11527
+ return constraintObject;
11403
11528
  };
11404
11529
 
11405
11530
  const createControlValidation = (
@@ -11415,15 +11540,10 @@ const createControlValidation = (
11415
11540
  const dynamicConstraintSet = new Set();
11416
11541
  {
11417
11542
  controlValidity.registerConstraint = (constraint) => {
11418
- if (typeof constraint === "function") {
11419
- constraint = {
11420
- name: constraint.name || "custom_function",
11421
- check: constraint,
11422
- };
11423
- }
11424
- dynamicConstraintSet.add(constraint);
11543
+ const constraintObject = normalizeConstraint(constraint);
11544
+ dynamicConstraintSet.add(constraintObject);
11425
11545
  return () => {
11426
- dynamicConstraintSet.delete(constraint);
11546
+ dynamicConstraintSet.delete(constraintObject);
11427
11547
  };
11428
11548
  };
11429
11549
  }
@@ -11480,6 +11600,16 @@ const createControlValidation = (
11480
11600
  ...DEFAULT_CONSTRAINT_SET,
11481
11601
  ...dynamicConstraintSet,
11482
11602
  ]);
11603
+ // An app constraint declared at the call site: `constraints={[MY_CONSTRAINT]}`.
11604
+ // Read from the raw props on every check so a constraint whose parameters
11605
+ // are closed over is re-created freely, and last so the constraints navi
11606
+ // ships are the ones reported first (see pickConstraintFailureInfo).
11607
+ const constraintsFromProps = controller.props.constraints;
11608
+ if (constraintsFromProps) {
11609
+ for (const constraintFromProps of constraintsFromProps) {
11610
+ constraintSet.add(normalizeConstraint(constraintFromProps));
11611
+ }
11612
+ }
11483
11613
  const elementSig = getElementSignature(controller.ref.current);
11484
11614
  // Not logged: every control checks its constraints on every interaction and
11485
11615
  // almost always passes, so this line alone was most of the debug output —
@@ -12807,12 +12937,18 @@ const useArraySignalMembership = (...args) => {
12807
12937
  "useArraySignalMembership requires at least 2 arguments: [arraySignal, id]",
12808
12938
  );
12809
12939
  }
12940
+ const [arraySignal, id] = args;
12810
12941
 
12811
- return useMemo(() => {
12812
- const [useIsMember, add, remove] = arraySignalMembership(...args);
12813
- const isMember = useIsMember();
12814
- return [isMember, add, remove];
12815
- }, args);
12942
+ // Through a computed so the component re-renders when its own membership
12943
+ // changes, not every time anything else is added to or removed from the
12944
+ // array: a list of 200 rows each watching the same array would otherwise all
12945
+ // re-render (and each re-scan the array) when one row is toggled.
12946
+ const isMember = useComputed(() => arraySignal.value.includes(id)).value;
12947
+ const [, add, remove] = useMemo(
12948
+ () => arraySignalMembership(arraySignal, id),
12949
+ [arraySignal, id],
12950
+ );
12951
+ return [isMember, add, remove];
12816
12952
  };
12817
12953
 
12818
12954
  const arraySignalMembership = (...args) => {
@@ -14169,7 +14305,7 @@ const createRangeReader = (
14169
14305
  // dropped here is one that gets asked for again after a remount.
14170
14306
  readRange.trimComposition = (keepFrom, keepTo, budget) => {
14171
14307
  const composition = findComposition(currentParams());
14172
- if (!composition || !budget || composition.idByIndex.size <= budget) {
14308
+ if (!composition || composition.idByIndex.size <= budget) {
14173
14309
  return;
14174
14310
  }
14175
14311
  for (const index of composition.idByIndex.keys()) {
@@ -19726,6 +19862,21 @@ import.meta.css = [/* css */`
19726
19862
  }
19727
19863
  }
19728
19864
 
19865
+ /* A corner claim travels down (see group.jsx) because the member joined to
19866
+ its neighbours is not always the thing that draws the frame: it can be a
19867
+ bare wrapper, a tooltip, a link, and the control inside is what has to
19868
+ square. That only holds while the wrapper adds nothing of its own. A box
19869
+ that paints a background or a border, or that insets what it holds, IS the
19870
+ frame at that spot — what is inside it sits on padding or on that
19871
+ background, never on the corner the group squared — so the claim stops
19872
+ here, exactly as a control stops it once it has answered. */
19873
+ [navi-box-frame] > * {
19874
+ --x-corner-top-left-radius: initial;
19875
+ --x-corner-top-right-radius: initial;
19876
+ --x-corner-bottom-right-radius: initial;
19877
+ --x-corner-bottom-left-radius: initial;
19878
+ }
19879
+
19729
19880
  @layer navi {
19730
19881
  /*
19731
19882
  When using square/circle/aspectRatio prop we expect box to respect the aspect ratio.
@@ -20012,10 +20163,27 @@ const computeBox = (props, parentBoxFlow) => {
20012
20163
  rest["data-body"] = "";
20013
20164
  // Padding is what decides whether the content reaches the body's own
20014
20165
  // corners — see the corner claims in this file's CSS.
20015
- if (!PADDING_PROP_NAMES.some(name => isNonZeroSpacing(rest[name]))) {
20166
+ let flush = true;
20167
+ for (const name of PADDING_PROP_SET) {
20168
+ if (declaresSomething(rest[name])) {
20169
+ flush = false;
20170
+ break;
20171
+ }
20172
+ }
20173
+ if (flush) {
20016
20174
  rest["data-body-flush"] = "";
20017
20175
  }
20018
20176
  }
20177
+ // A box that paints something of its own, or that insets what it holds, is
20178
+ // the frame at that spot: the corner claims coming from a Group are about
20179
+ // ITS corners and nothing inside reaches them, so they stop here — see this
20180
+ // file's CSS.
20181
+ for (const name of FRAME_PROP_SET) {
20182
+ if (declaresSomething(rest[name])) {
20183
+ rest["navi-box-frame"] = "";
20184
+ break;
20185
+ }
20186
+ }
20019
20187
  const defaultDisplay = getDefaultDisplay(TagName);
20020
20188
  let {
20021
20189
  inline,
@@ -20446,8 +20614,14 @@ const shouldInjectSeparatorBetween = (left, right) => {
20446
20614
  }
20447
20615
  return true;
20448
20616
  };
20449
- const PADDING_PROP_NAMES = ["padding", "paddingX", "paddingY", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft"];
20450
- const isNonZeroSpacing = value => {
20617
+ const PADDING_PROP_SET = new Set(["padding", "paddingX", "paddingY", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft"]);
20618
+ /* What makes a box the frame at its spot: what it paints of its own, and the
20619
+ padding holding its content away from its corners. Deliberately without the
20620
+ radius props: a radius alone paints nothing — it only says how a background
20621
+ or a border already there is cut — so a box carrying just a radius is still
20622
+ a wrapper around whatever draws. */
20623
+ const FRAME_PROP_SET = new Set(["background", "backgroundColor", "backgroundImage", "border", "borderTop", "borderRight", "borderBottom", "borderLeft", "borderWidth", "borderColor", "borderStyle", ...PADDING_PROP_SET]);
20624
+ const declaresSomething = value => {
20451
20625
  if (value === undefined || value === null || value === false) {
20452
20626
  return false;
20453
20627
  }
@@ -25330,75 +25504,85 @@ const setupBrowserIntegrationViaHistory = ({
25330
25504
  return requestedResult;
25331
25505
  };
25332
25506
 
25333
- // Browser event handlers
25507
+ // A click on a link is answered on the link itself, after the link's own
25508
+ // listeners: a navi control decides there whether the press is allowed — it
25509
+ // refuses with preventDefault from its click reaction — and runs its command
25510
+ // and its action; only then does the routing take the browser's place. This
25511
+ // is the order the Navigation API integration gets for free, where the
25512
+ // navigation starts once the click has been dispatched.
25513
+ const onLinkClick = (e) => {
25514
+ if (e.button !== 0) {
25515
+ // Ignore non-left clicks
25516
+ return;
25517
+ }
25518
+ if (e.metaKey) {
25519
+ // Ignore clicks with meta key (e.g. open in new tab)
25520
+ return;
25521
+ }
25522
+ if (e.defaultPrevented) {
25523
+ // Refused — by the link itself, or by whoever came before it.
25524
+ return;
25525
+ }
25526
+ const linkElement = e.currentTarget;
25527
+ if (linkElement.hasAttribute("data-readonly")) {
25528
+ return;
25529
+ }
25530
+ const href = linkElement.href;
25531
+ const { isEmpty, isCurrent, isSameOrigin, isAnchor } =
25532
+ getHrefTargetInfo(href);
25533
+ if (isEmpty || !isSameOrigin) {
25534
+ // Let link to other origins be handled by the browser
25535
+ return;
25536
+ }
25537
+ if (isAnchor) {
25538
+ // Fragment navigation belongs to the browser: it owns the indicated
25539
+ // part of the document, and taking it over would cost `:target` and the
25540
+ // focus handling that come with it.
25541
+ if (isCurrent) {
25542
+ // Except this one, which the browser answers with a scroll and
25543
+ // nothing else: same pathname, same hash, so no event and no url
25544
+ // change reaches whoever is waiting on the designated element.
25545
+ rearmUrlTarget();
25546
+ }
25547
+ return;
25548
+ }
25549
+ // Nothing here declared a route, so there is nothing to route to: the
25550
+ // page is a plain document and a link in it is a plain link. Taking it
25551
+ // over anyway would push the url and then have nothing to show for it —
25552
+ // the address bar moves and the page does not (see applyRouting's own
25553
+ // "not called yet" branch, which is where that used to end up).
25554
+ if (!isRouting()) {
25555
+ return;
25556
+ }
25557
+ e.preventDefault();
25558
+ handleRoutingTask(href, {
25559
+ reason: `"click" on a[href="${href}"]`,
25560
+ // A link that takes the place of the current entry instead of stacking
25561
+ // on it says so on itself (see link_replace.js).
25562
+ navigationType: linkAsksForReplace(linkElement) ? "replace" : "push",
25563
+ // Who started it. Announced with the navigation because a press
25564
+ // carries things the url does not: what a link asks of a route
25565
+ // transition is the first of them (see route_transition.jsx). Read by
25566
+ // whoever knows what to do with it, and it is the anchor itself —
25567
+ // resolved here, where it already is.
25568
+ element: linkElement,
25569
+ });
25570
+ };
25571
+ // Wired from the capture phase on window, the first place a click is seen,
25572
+ // so that a link is answered whatever handler stops the click on its way up
25573
+ // (a card's own onClick). Wired on every click: a listener an element
25574
+ // already has is not added twice, and a link seen for the first time is
25575
+ // wired before the click reaches it. The click a gesture leaves behind
25576
+ // never gets this far — its suppressor (click_suppression.js in
25577
+ // @jsenv/dom) stops it on window, before anything below.
25334
25578
  window.addEventListener(
25335
25579
  "click",
25336
25580
  (e) => {
25337
- if (e.button !== 0) {
25338
- // Ignore non-left clicks
25339
- return;
25340
- }
25341
- if (e.metaKey) {
25342
- // Ignore clicks with meta key (e.g. open in new tab)
25343
- return;
25344
- }
25345
- if (e.defaultPrevented) {
25346
- return;
25347
- }
25348
- if (clickIsSuppressed()) {
25349
- // The click that ends a gesture (click_suppression.js in @jsenv/dom).
25350
- // Its suppressor also listens on window in capture, so whichever
25351
- // module registered first runs first — asked explicitly, the order
25352
- // stops mattering.
25353
- return;
25354
- }
25355
25581
  const linkElement = e.target.closest("a");
25356
25582
  if (!linkElement) {
25357
25583
  return;
25358
25584
  }
25359
- if (linkElement.hasAttribute("data-readonly")) {
25360
- return;
25361
- }
25362
- const href = linkElement.href;
25363
- const { isEmpty, isCurrent, isSameOrigin, isAnchor } =
25364
- getHrefTargetInfo(href);
25365
- if (isEmpty || !isSameOrigin) {
25366
- // Let link to other origins be handled by the browser
25367
- return;
25368
- }
25369
- if (isAnchor) {
25370
- // Fragment navigation belongs to the browser: it owns the indicated
25371
- // part of the document, and taking it over would cost `:target` and the
25372
- // focus handling that come with it.
25373
- if (isCurrent) {
25374
- // Except this one, which the browser answers with a scroll and
25375
- // nothing else: same pathname, same hash, so no event and no url
25376
- // change reaches whoever is waiting on the designated element.
25377
- rearmUrlTarget();
25378
- }
25379
- return;
25380
- }
25381
- // Nothing here declared a route, so there is nothing to route to: the
25382
- // page is a plain document and a link in it is a plain link. Taking it
25383
- // over anyway would push the url and then have nothing to show for it —
25384
- // the address bar moves and the page does not (see applyRouting's own
25385
- // "not called yet" branch, which is where that used to end up).
25386
- if (!isRouting()) {
25387
- return;
25388
- }
25389
- e.preventDefault();
25390
- handleRoutingTask(href, {
25391
- reason: `"click" on a[href="${href}"]`,
25392
- // A link that takes the place of the current entry instead of stacking
25393
- // on it says so on itself (see link_replace.js).
25394
- navigationType: linkAsksForReplace(linkElement) ? "replace" : "push",
25395
- // Who started it. Announced with the navigation because a press
25396
- // carries things the url does not: what a link asks of a route
25397
- // transition is the first of them (see route_transition.jsx). Read by
25398
- // whoever knows what to do with it, and it is the anchor itself —
25399
- // resolved here, where it already is.
25400
- element: linkElement,
25401
- });
25585
+ linkElement.addEventListener("click", onLinkClick);
25402
25586
  },
25403
25587
  { capture: true },
25404
25588
  );
@@ -29838,7 +30022,11 @@ registerNaviCommand("--navi-open", (source, event, { anchor, value } = {}) => {
29838
30022
  },
29839
30023
  };
29840
30024
  });
29841
- registerNaviCommand("--navi-close", (source, event) => {
30025
+ // "--navi-close:all" closes every expandable above the source, nearest first
30026
+ // a link leaving from a badge shown over a sheet leaves both. A surface that
30027
+ // refuses (a form asking about its changes) keeps what is above it open too:
30028
+ // one cannot be out of the sheet while still in the badge.
30029
+ registerNaviCommand("--navi-close", (source, event, { argument }) => {
29842
30030
  const target =
29843
30031
  resolveExplicitTarget(source) || resolveClosestExpandable(source);
29844
30032
  if (!target) {
@@ -29847,13 +30035,30 @@ registerNaviCommand("--navi-close", (source, event) => {
29847
30035
  return {
29848
30036
  target,
29849
30037
  implementation: () => {
29850
- return dispatchCustomEvent(target, "navi_request_close", {
29851
- event,
29852
- source: resolveCommandProxySource(source),
29853
- });
30038
+ const detail = { event, source: resolveCommandProxySource(source) };
30039
+ if (argument === "all") {
30040
+ return requestCloseUpward(target, detail);
30041
+ }
30042
+ return dispatchCustomEvent(target, "navi_request_close", detail);
29854
30043
  },
29855
30044
  };
29856
30045
  });
30046
+ const requestCloseUpward = (target, detail) => {
30047
+ let expandable = target;
30048
+ while (expandable) {
30049
+ const closing = dispatchCustomEvent(
30050
+ expandable,
30051
+ "navi_request_close",
30052
+ detail,
30053
+ );
30054
+ if (!closing) {
30055
+ return false;
30056
+ }
30057
+ const parent = expandable.parentElement;
30058
+ expandable = parent ? parent.closest("[aria-expanded]") : null;
30059
+ }
30060
+ return true;
30061
+ };
29857
30062
  registerNaviCommand("--navi-cancel", (source, event) => {
29858
30063
  const target =
29859
30064
  resolveExplicitTarget(source) || resolveClosestExpandable(source);
@@ -30011,6 +30216,13 @@ registerNaviCommand("--navi-unselect", (source, event) => {
30011
30216
  * observeAncestorOpenState for exactly how that's detected, and why it
30012
30217
  * matters that it happens before the browser paints.
30013
30218
  * - Inside an open ancestor → runs on mount AND every subsequent open.
30219
+ * - Inside the always-on-screen part of a *closed* one — a picker's façade,
30220
+ * an expandable's header, a <summary>: those elements are displayed the
30221
+ * whole time their ancestor reads as closed (aria-expanded on a trigger
30222
+ * describes the popup it controls, not its own contents). They run on
30223
+ * mount like anything else on screen, and the ancestor opening later
30224
+ * reveals nothing about them, so it does not re-run them either. See
30225
+ * isDisplayedDespiteClosedAncestor in @jsenv/dom.
30014
30226
  *
30015
30227
  * The callback's second argument is always a `navi_displayed` CustomEvent,
30016
30228
  * with `detail: { ancestor, ancestorType, becauseAncestorOpened }`:
@@ -30043,6 +30255,10 @@ const useDisplayedLayoutEffect = (ref, callback, deps) => {
30043
30255
  const callbackRef = useRef(callback);
30044
30256
  callbackRef.current = callback;
30045
30257
 
30258
+ // Set by the mount effect below for an element that lives in its openable
30259
+ // ancestor's façade rather than in what that ancestor opens.
30260
+ const displayedWhileAncestorClosedRef = useRef(false);
30261
+
30046
30262
  // Run on mount (or when deps change) — but only if the element is visible.
30047
30263
  useLayoutEffect(() => {
30048
30264
  const el = ref.current;
@@ -30055,9 +30271,16 @@ const useDisplayedLayoutEffect = (ref, callback, deps) => {
30055
30271
  return;
30056
30272
  }
30057
30273
  if (!isAncestorOpen(ancestor)) {
30058
- // Ancestor is closed — skip now; the observeAncestorOpenState call
30059
- // below will fire once it opens.
30060
- return;
30274
+ if (!isDisplayedDespiteClosedAncestor(el)) {
30275
+ // Ancestor is closed and took this element off screen with it — skip
30276
+ // now; the observeAncestorOpenState call below will fire once it
30277
+ // opens.
30278
+ return;
30279
+ }
30280
+ // Closed, yet on screen: the ancestor is the trigger of what is
30281
+ // closed, not the thing itself, and this element belongs to the façade
30282
+ // it keeps showing.
30283
+ displayedWhileAncestorClosedRef.current = true;
30061
30284
  }
30062
30285
  callbackRef.current(el, createDisplayedEvent(ancestor, false));
30063
30286
  }, deps);
@@ -30076,6 +30299,12 @@ const useDisplayedLayoutEffect = (ref, callback, deps) => {
30076
30299
  if (!isOpen) {
30077
30300
  return;
30078
30301
  }
30302
+ if (displayedWhileAncestorClosedRef.current) {
30303
+ // Façade content: on screen the whole time, so this opening reveals
30304
+ // nothing here — and `becauseAncestorOpened: true` about it would be
30305
+ // false in a way consumers act on (see use_auto_focus.js).
30306
+ return;
30307
+ }
30079
30308
  const lastEl = ref.current;
30080
30309
  callbackRef.current(lastEl, createDisplayedEvent(ancestor, true));
30081
30310
  });
@@ -30935,11 +31164,22 @@ const isTypingIntent = (e) =>
30935
31164
 
30936
31165
  const s = (n) => (n > 1 ? "s" : "");
30937
31166
 
31167
+ // The `u` flag is what lets a char class speak about characters: `\p{...}` is
31168
+ // only recognized under it, and a range covers whole code points instead of
31169
+ // the two halves an astral character (an emoji) is made of.
31170
+ const compileCharClass = (charClass) => new RegExp(charClass, "u");
31171
+ const compileCharClassAnchored = (charClass) =>
31172
+ new RegExp(`^(?:${charClass})*$`, "u");
31173
+
30938
31174
  // Keydown: block only single printable characters that don't match the class.
30939
31175
  // Multi-character key names (Delete, ArrowLeft…) are always allowed.
30940
31176
  const getInvalidCharMessage = (char, { charClass, messageKey }) => {
30941
- if (char.length !== 1) return null;
30942
- if (new RegExp(charClass).test(char)) return null;
31177
+ // Counted in code points: an astral character is one character typed, not two.
31178
+ const codePointCount = [...char].length;
31179
+ if (codePointCount !== 1) {
31180
+ return null;
31181
+ }
31182
+ if (compileCharClass(charClass).test(char)) return null;
30943
31183
  return naviI18n(messageKey);
30944
31184
  };
30945
31185
 
@@ -30959,7 +31199,7 @@ const getMaxLengthInsertionMessage = (el, { maxLength }) => {
30959
31199
  // Paste / set: block when value contains disallowed chars.
30960
31200
  const getInvalidCharsMessage = (uiState, { charClass, messageKey }) => {
30961
31201
  const str = uiState === undefined ? "" : String(uiState);
30962
- if (new RegExp(`^(?:${charClass})*$`).test(str)) return null;
31202
+ if (compileCharClassAnchored(charClass).test(str)) return null;
30963
31203
  return naviI18n(messageKey);
30964
31204
  };
30965
31205
 
@@ -33573,6 +33813,13 @@ const useControlProps = (props, {
33573
33813
  }
33574
33814
  };
33575
33815
  }
33816
+ if (getKeyboardEventDefaultAction(e) === "activate") {
33817
+ // Enter: the browser presses the link itself, with a click that
33818
+ // follows this keydown — and the click reaction below is where
33819
+ // the press is answered, once. A tab for a slide (no href) gets
33820
+ // no such click; Link answers Enter on its own there.
33821
+ return null;
33822
+ }
33576
33823
  return keyDownDefault(e);
33577
33824
  },
33578
33825
  click: e => {
@@ -37179,6 +37426,7 @@ const TextAnchor = ({
37179
37426
  ref: anchorRef,
37180
37427
  className: "navi_text_anchor",
37181
37428
  "aria-hidden": "true",
37429
+ hidden: true,
37182
37430
  children: "\u200B"
37183
37431
  })]
37184
37432
  });
@@ -37846,8 +38094,10 @@ const shouldInjectSpacingBetween = (left, right) => {
37846
38094
  * @param {boolean} [emojiAsIcon]
37847
38095
  * Renders every emoji found in the string children as an `Icon`, so it sits
37848
38096
  * in the line like a character and never makes the line taller than the
37849
- * text. For free text a user typed (a message, a description) — the only
37850
- * place an emoji is expected; see `docs/typography.md`.
38097
+ * text. For free text a user typed (a message, a description); see
38098
+ * `docs/typography.md`. Only the strings this `Text` receives are rewritten —
38099
+ * a string a child component renders is out of reach, and that component
38100
+ * calls `renderEmojiAsIcon()` itself instead.
37851
38101
  *
37852
38102
  * @param {boolean} [preventSpaceUnderlines]
37853
38103
  * Replaces real space characters between children with padding-based spaces.
@@ -39127,9 +39377,12 @@ const LinkPlain = props => {
39127
39377
  replace: undefined,
39128
39378
  "data-navi-route-transition-request": routeTransitionRequest,
39129
39379
  ...replaceRequest,
39380
+ // The control's own handlers first — the interaction gate, the caller's
39381
+ // onClick/onKeyDown, the command and the action — then what only a link
39382
+ // does. Written over the spread above, so they have to be called here.
39130
39383
  onClick: e => {
39131
- onClick?.(e);
39132
- if (slide) {
39384
+ controlHostProps.onClick(e);
39385
+ if (slide && !e.defaultPrevented) {
39133
39386
  goToSlide(e.currentTarget, e);
39134
39387
  }
39135
39388
  if (preventDefault) {
@@ -39142,7 +39395,7 @@ const LinkPlain = props => {
39142
39395
  ,
39143
39396
 
39144
39397
  onKeyDown: e => {
39145
- props.onKeyDown?.(e);
39398
+ controlHostProps.onKeyDown(e);
39146
39399
  if (!slide || e.defaultPrevented) {
39147
39400
  return;
39148
39401
  }
@@ -45772,6 +46025,9 @@ const COMMAND_DEFAULT_PROPS_FACTORIES = {
45772
46025
  * `--navi-nav-to` command — by TAKING THE PLACE of the current history entry
45773
46026
  * rather than stacking on it: what `<Link replace>` says, for a press drawn
45774
46027
  * as a button.
46028
+ * @param {Function} [action] On a button with an `href` or a `route`, the
46029
+ * same order as a Link's: it runs on the press, before the navigation, and
46030
+ * the navigation does not wait for it (see Link's `action`).
45775
46031
  * @param {boolean} [emojiAsIcon=true] Renders the emoji of the label as icons
45776
46032
  * so the button keeps the height of its text — `Text`'s prop, on by default
45777
46033
  * here. Pass `false` to let an emoji draw at its natural size.
@@ -46336,7 +46592,9 @@ installImportMetaCssBuild(import.meta);/**
46336
46592
  * "postal" → postal code (digits, letters, space, hyphen)
46337
46593
  * "iban" → IBAN (uppercase and digits)
46338
46594
  * "slug" → URL slug (lowercase, digits, hyphens)
46339
- * "[A-Z0-9]" → any custom regex character class
46595
+ * "[A-Z0-9]" → any custom regex character class, compiled with the `u`
46596
+ * flag: `\p{...}` is available, and an emoji counts as one
46597
+ * character rather than two halves.
46340
46598
  * inputMode and pattern are auto-derived from the preset when not explicitly set.
46341
46599
  *
46342
46600
  * - maxLengthGuard — combines maxLength + overflow guard in one prop.
@@ -51781,7 +52039,8 @@ const FOCUS_DELAY_ON_KEYBOARD_MS = 250;
51781
52039
  * - `open()`: requests opening — calls the caller's `onOpen` (see below), then
51782
52040
  * `mountContent`/`openEffect`, then `openHandler`.
51783
52041
  * - `requestClose()`: requests closing — calls `onRequestClose` then `onClose`,
51784
- * stopping after the first if denied. The popup may choose to stay open.
52042
+ * stopping after the first if denied. The popup may choose to stay open,
52043
+ * which is what a `false` return says (`true`: closed, or closed already).
51785
52044
  * - `close()`: closes for real — calls only `onClose`, skipping
51786
52045
  * `onRequestClose` entirely. Used when there really is no choice (e.g. the
51787
52046
  * popup unmounting).
@@ -52074,7 +52333,7 @@ const createOpenController = (
52074
52333
  detail,
52075
52334
  ) => {
52076
52335
  if (!controller.opened) {
52077
- return;
52336
+ return true;
52078
52337
  }
52079
52338
  const requestCloseEvent = new CustomEvent("navi_request_close", {
52080
52339
  detail: { event: e, ...detail },
@@ -52089,9 +52348,10 @@ const createOpenController = (
52089
52348
  if (nativeCancelEvent) {
52090
52349
  nativeCancelEvent.preventDefault();
52091
52350
  }
52092
- return;
52351
+ return false;
52093
52352
  }
52094
52353
  performClose(requestCloseEvent);
52354
+ return true;
52095
52355
  },
52096
52356
  close: (e = new CustomEvent("programmatic", { detail: {} }), detail) => {
52097
52357
  if (!controller.opened) {
@@ -53658,10 +53918,14 @@ const UncontrolledDialog = props => {
53658
53918
  });
53659
53919
  },
53660
53920
  onnavi_request_close: e => {
53661
- openController.requestClose(e, {
53921
+ const closing = openController.requestClose(e, {
53662
53922
  isCancel: e.detail?.isCancel,
53663
53923
  requester: e.detail?.source
53664
53924
  });
53925
+ if (!closing) {
53926
+ // Said back to whoever asked: --navi-close:all stops climbing here.
53927
+ e.preventDefault();
53928
+ }
53665
53929
  }
53666
53930
  });
53667
53931
  };
@@ -55108,9 +55372,13 @@ const UncontrolledPopover = props => {
55108
55372
  });
55109
55373
  },
55110
55374
  onnavi_request_close: e => {
55111
- openController.requestClose(e, {
55375
+ const closing = openController.requestClose(e, {
55112
55376
  isCancel: e.detail?.isCancel
55113
55377
  });
55378
+ if (!closing) {
55379
+ // Said back to whoever asked: --navi-close:all stops climbing here.
55380
+ e.preventDefault();
55381
+ }
55114
55382
  }
55115
55383
  });
55116
55384
  };
@@ -56433,6 +56701,9 @@ const PickerCustomResolver = props => {
56433
56701
  if (circle) {
56434
56702
  props.variant = "icon";
56435
56703
  }
56704
+ // A door, never a field (see `allowNameless`): the form around it expects
56705
+ // no value from it, and no name.
56706
+ props.allowNameless = true;
56436
56707
  // A word in a sentence asks for a plain tooltip — no icon in the callout,
56437
56708
  // no status color; an icon one presses is the callout's own status icon,
56438
56709
  // and says "info" like the callout it opens.
@@ -56782,7 +57053,10 @@ const PickerCustom = props => {
56782
57053
  dispatchCustomEvent(popupRef.current, "navi_request_open", e.detail);
56783
57054
  },
56784
57055
  "onnavi_request_close": e => {
56785
- dispatchCustomEvent(popupRef.current, "navi_request_close", e.detail);
57056
+ const closing = dispatchCustomEvent(popupRef.current, "navi_request_close", e.detail);
57057
+ if (!closing) {
57058
+ e.preventDefault();
57059
+ }
56786
57060
  },
56787
57061
  children
56788
57062
  });
@@ -56834,12 +57108,17 @@ const PickerCustom = props => {
56834
57108
  event: e,
56835
57109
  intent: "read",
56836
57110
  allowed: () => {
56837
- requestClose(e, {
57111
+ const closing = requestClose(e, {
56838
57112
  isCancel: e.detail.isCancel
56839
57113
  });
57114
+ if (!closing) {
57115
+ e.preventDefault();
57116
+ }
56840
57117
  },
56841
57118
  prevented: () => {
56842
57119
  confirmEventRef.current = null;
57120
+ // Not closing either way; said back to whoever asked.
57121
+ e.preventDefault();
56843
57122
  }
56844
57123
  });
56845
57124
  },
@@ -58978,10 +59257,11 @@ const PendingScrollRefContext = createContext(null);
58978
59257
  // "muted" — keep in DOM, visible but opacified and still interactive
58979
59258
  const SearchNoMatchModeContext = createContext("remove");
58980
59259
 
58981
- // When total rendered items exceeds renderBudget, a render window [start, end)
58982
- // is activated to cap the number of DOM nodes. Items outside the window return
58983
- // null. The window slides as the user scrolls, using actual DOM positions
58984
- // (getBoundingClientRect) to find the first visible item no height estimation.
59260
+ // How many rows the list draws at once. Past that, a render window [start, end)
59261
+ // caps the number of DOM nodes: the window slides as the user scrolls, using
59262
+ // actual DOM positions (getBoundingClientRect) to find the first visible item
59263
+ // no height estimation. It frames the rows a run draws (see ListItems); items
59264
+ // declared one by one (<List.Item>) are all drawn, whatever the budget.
58985
59265
  const RENDER_BUDGET_DEFAULT = 100;
58986
59266
 
58987
59267
  // Attribute used on <li> elements rendered by ListItemReal so the scroll listener
@@ -58990,8 +59270,9 @@ const REAL_LIST_ITEM_SELECTOR = `[navi-list-item-real]`;
58990
59270
  // Rows standing in for content that has not arrived (see List's renderSkeleton).
58991
59271
  const SKELETON_LIST_ITEM_CLASS = "navi_list_item_skeleton";
58992
59272
 
58993
- // Carries the render window {start, end} (or null = render all) from
58994
- // List down to each ListItem.
59273
+ // Carries the render window {start, end} from List down to the runs of rows
59274
+ // inside it (see ListItems): a run draws the rows it frames and holds the room
59275
+ // of the others.
58995
59276
  const RenderWindowContext = createContext(null);
58996
59277
  // Carries List's own `columns` prop (a grid-template-columns value, e.g.
58997
59278
  // "1fr auto auto") down to each ListItem/filler/fallback so they can render
@@ -59828,12 +60109,46 @@ const ListUI = props => {
59828
60109
  searchText,
59829
60110
  horizontal
59830
60111
  });
60112
+
60113
+ // renderBudget frames the rows of a run; a list whose items are all declared
60114
+ // one by one draws every one of them, and the prop looks exactly like it is
60115
+ // doing something. Said once per list, when there is something drawn to
60116
+ // judge it on — a run mounting later (rows behind a loading state) is not a
60117
+ // list without one.
60118
+ const renderBudgetWarnedRef = useRef(false);
60119
+ useLayoutEffect(() => {
60120
+ if (props.renderBudget === undefined || renderBudgetWarnedRef.current) {
60121
+ return;
60122
+ }
60123
+ if (virtual.hasRuns() || tracker.itemsSignal.peek().length === 0) {
60124
+ return;
60125
+ }
60126
+ renderBudgetWarnedRef.current = true;
60127
+ console.warn(`List: renderBudget=${renderBudget} has no effect here. The render window frames the rows a run draws (<List.Items itemsAction>); items declared one by one (<List.Item>) are all rendered. Move the items to <List.Items> to cap the number of DOM nodes, or drop the prop.`);
60128
+ });
59831
60129
  virtual.captureAnchor = captureAnchor;
59832
60130
  virtual.virtualItemSizeSignal = virtualItemSizeSignal;
59833
60131
  virtual.horizontal = Boolean(horizontal);
59834
60132
  virtual.renderSkeleton = renderSkeleton;
60133
+
60134
+ // A row is addressed by id from outside (--navi-scroll, --navi-select): the
60135
+ // ones drawn have registered themselves with the tracker, and the ones a run
60136
+ // holds without drawing are known only to that run (see List.Items' row
60137
+ // locator). Both answer here, so a row is reachable whether or not the
60138
+ // window happens to frame it.
59835
60139
  const getItemById = itemId => {
59836
- return tracker.itemsSignal.peek().find(item => item.id === itemId);
60140
+ const itemDrawn = tracker.itemsSignal.peek().find(item => item.id === itemId);
60141
+ if (itemDrawn) {
60142
+ return itemDrawn;
60143
+ }
60144
+ const rowIndex = virtual.locateRow(itemId);
60145
+ if (rowIndex === null) {
60146
+ return undefined;
60147
+ }
60148
+ return {
60149
+ id: itemId,
60150
+ index: rowIndex
60151
+ };
59837
60152
  };
59838
60153
  const noMatchCount = tracker.noMatchCountSignal.value;
59839
60154
  // What the list stands for, which is not always what it holds: a run saying
@@ -62373,6 +62688,9 @@ const createListVirtual = () => {
62373
62688
  passId++;
62374
62689
  nextIndex = 0;
62375
62690
  },
62691
+ // Whether any run of rows lives in this list: what makes a render window
62692
+ // mean anything (see List's renderBudget).
62693
+ hasRuns: () => locatorByOwner.size > 0,
62376
62694
  setRowLocator: (ownerId, locate) => {
62377
62695
  locatorByOwner.set(ownerId, locate);
62378
62696
  },
@@ -62419,12 +62737,17 @@ const VISIBILITY_HIDDEN_STYLE = {
62419
62737
  * for them — which is what makes an infinitely scrolled list nothing more than
62420
62738
  * a list that says how many rows it has.
62421
62739
  *
62422
- * The rows come from `itemsAction(range)`: the run asks for what it is about to
62423
- * draw and keeps what it gets. The range says the same thing three ways, so a
62424
- * source can read it however it paginates — `{ start, end }` (places in the
62425
- * collection, a negative `start` counting back from the end like
62426
- * `Range: items=-25`, which is what a list opening on its last rows asks for
62427
- * before it knows how many there are), `limit` (how many rows), and
62740
+ * A collection held in memory is given whole: `items={rows}`. The run holds all
62741
+ * of them from the first render and never asks for anything it is still the
62742
+ * render window that decides how many are drawn.
62743
+ *
62744
+ * A collection read a slice at a time comes from `itemsAction(range)`: the run
62745
+ * asks for what it is about to draw and keeps what it gets. The range says the
62746
+ * same thing three ways, so a source can read it however it paginates —
62747
+ * `{ start, end }` (places in the collection, a negative `start` counting back
62748
+ * from the end like `Range: items=-25`, which is what a list opening on its
62749
+ * last rows asks for before it knows how many there are), `limit` (how many
62750
+ * rows), and
62428
62751
  * `before`/`after`/`around` (the id of a row to count from, for a source
62429
62752
  * paginating by cursor). Answer with the rows (an array — that is all of
62430
62753
  * them), or with a range the way a Content-Range does: `{ items, start, count }`
@@ -62438,11 +62761,11 @@ const VISIBILITY_HIDDEN_STYLE = {
62438
62761
  * copies of the JSON. The list holds the slices, the store holds the objects
62439
62762
  * (see docs/resource.md).
62440
62763
  *
62441
- * A collection held in memory answers synchronously: `itemsAction={() => rows}`.
62442
- * What it gives back is kept, so a collection that changes as a whole (a search
62443
- * reordering it) is a different collection: give the run a `key` that changes
62444
- * with it, the way one does for anything else that is not the same thing
62445
- * anymore.
62764
+ * A collection that changes as a whole (a search reordering it) is a different
62765
+ * collection: with `items`, another array is another collection and the run
62766
+ * draws it from its first row; with `itemsAction`, give the run a `key` that
62767
+ * changes with it, the way one does for anything else that is not the same
62768
+ * thing anymore.
62446
62769
  *
62447
62770
  * A row says what it is where it is drawn: `renderItem` returns a
62448
62771
  * `<List.Item>` carrying its own props (`selectable`, `value`, `selected`…),
@@ -62454,7 +62777,8 @@ const VISIBILITY_HIDDEN_STYLE = {
62454
62777
  *
62455
62778
  * @type {import("ignore:preact").FunctionComponent<{
62456
62779
  * renderItem: (item: any, index: number, state: {refreshing: boolean}) => import("ignore:preact").ComponentChildren,
62457
- * itemsAction: (range: {start: number, end: number, limit: number, before?: string, after?: string, around?: string, count?: number, signal: AbortSignal}) => any,
62780
+ * items?: any[],
62781
+ * itemsAction?: (range: {start: number, end: number, limit: number, before?: string, after?: string, around?: string, count?: number, signal: AbortSignal}) => any,
62458
62782
  * count?: number,
62459
62783
  * groupBy?: (item: any, index: number) => any,
62460
62784
  * renderGroupLabel?: (item: any, index: number) => import("ignore:preact").ComponentChildren,
@@ -62469,6 +62793,13 @@ const VISIBILITY_HIDDEN_STYLE = {
62469
62793
  * What one row is, given the item and where it sits. `state.refreshing` says
62470
62794
  * the rows drawn are the ones from before while the run reads the collection
62471
62795
  * again — the list carries `navi-refreshing` for the same reason.
62796
+ * @param {any[]} [props.items]
62797
+ * The collection, when it is held in memory: all of it, in order. Nothing is
62798
+ * ever asked for — `itemsAction`, `count`, `pageSize` and `memoryBudget` have
62799
+ * no part to play, and no row is ever a skeleton.
62800
+ * @param {(range: object) => any} [props.itemsAction]
62801
+ * Where the rows come from when the collection is read a slice at a time:
62802
+ * a resource's range reader (`RESOURCE.GET_RANGE.bindParams(...)`).
62472
62803
  * @param {(item: any, index: number) => any} [props.groupBy]
62473
62804
  * What tells rows that belong together apart from the others — the day of a
62474
62805
  * message, the month of a game. Consecutive rows sharing it are wrapped in a
@@ -62491,7 +62822,8 @@ const VISIBILITY_HIDDEN_STYLE = {
62491
62822
  * How many rows the run keeps in memory. Past that, the ones far from what is
62492
62823
  * on screen are dropped (and asked for again if the user goes back) — the
62493
62824
  * same trade the render window makes with the DOM, one order of magnitude
62494
- * further out. `0` keeps everything.
62825
+ * further out. `Infinity` keeps every row the run ever received; `0` keeps
62826
+ * only the ones around the window.
62495
62827
  * @param {false|(index: number) => any} [props.renderSkeleton]
62496
62828
  * What to draw for a row the run does not hold. Defaults to List's own
62497
62829
  * `renderSkeleton`, then to a bare `<List.Item skeleton>`; `false` leaves the
@@ -62512,6 +62844,7 @@ const VISIBILITY_HIDDEN_STYLE = {
62512
62844
  */
62513
62845
  const ListItems = ({
62514
62846
  renderItem,
62847
+ items,
62515
62848
  itemsAction,
62516
62849
  count,
62517
62850
  pageSize,
@@ -62528,6 +62861,7 @@ const ListItems = ({
62528
62861
  const renderWindow = useContext(RenderWindowContext);
62529
62862
  const separator = useContext(SeparatorContext);
62530
62863
  const store = useItemStore({
62864
+ items,
62531
62865
  count,
62532
62866
  itemsAction,
62533
62867
  memoryBudget,
@@ -62818,11 +63152,17 @@ const rangeIsSame = (a, b) => {
62818
63152
  // are in all is enough to place it, so the pages need not be contiguous nor
62819
63153
  // arrive in order.
62820
63154
  const useItemStore = ({
63155
+ items,
62821
63156
  count,
62822
63157
  itemsAction,
62823
63158
  memoryBudget,
62824
63159
  onRequestStateChange
62825
63160
  }) => {
63161
+ // A collection given whole (`items`) is held from the first render: nothing
63162
+ // to ask for, nothing to keep across mounts, nothing to invalidate. The rest
63163
+ // of the store then never has a hole to fill, so it stays inert on its own —
63164
+ // the asking below finds nothing missing.
63165
+ const inMemory = items !== undefined;
62826
63166
  // The run's asking, on the same channel as the window it asks for — they are
62827
63167
  // one subject: what the list is about to draw is what it goes to fetch (see
62828
63168
  // `useRequestMissing`, and `updateRenderWindow` which logs the other half).
@@ -62844,8 +63184,28 @@ const useItemStore = ({
62844
63184
  // the reader holds for it (see resource_range_reader.js).
62845
63185
  useRef(false);
62846
63186
  const pagesRef = useRef(null);
63187
+ const itemsRef = useRef(items);
63188
+ const itemsHeldRef = useRef(false);
62847
63189
  let restored = false;
62848
- if (!pagesRef.current) {
63190
+ if (inMemory) {
63191
+ // The array as a whole is the collection: another array is another
63192
+ // collection, drawn from its first row (which is also why a run reading a
63193
+ // collection that changes as a whole takes a key).
63194
+ if (!pagesRef.current || itemsRef.current !== items) {
63195
+ itemsRef.current = items;
63196
+ const byIndex = new Map();
63197
+ let index = 0;
63198
+ while (index < items.length) {
63199
+ byIndex.set(index, items[index]);
63200
+ index++;
63201
+ }
63202
+ pagesRef.current = {
63203
+ byIndex,
63204
+ count: items.length
63205
+ };
63206
+ itemsHeldRef.current = false;
63207
+ }
63208
+ } else if (!pagesRef.current) {
62849
63209
  const composition = typeof itemsAction === "function" && itemsAction.readComposition ? itemsAction.readComposition() : null;
62850
63210
  if (composition && composition.count !== undefined) {
62851
63211
  pagesRef.current = composition;
@@ -62892,6 +63252,16 @@ const useItemStore = ({
62892
63252
  // range askable again (see the request memory just above).
62893
63253
  const [failure, setFailure] = useState(null);
62894
63254
  const virtual = useContext(ListVirtualContext);
63255
+ // The rows are there, which is what the list waits for to place itself on the
63256
+ // row it is held at (see placeWhereHeld). Said from an effect: a signal read
63257
+ // during this very render must not be written during it.
63258
+ useLayoutEffect(() => {
63259
+ if (!inMemory || itemsHeldRef.current) {
63260
+ return;
63261
+ }
63262
+ itemsHeldRef.current = true;
63263
+ virtual.pagesSignal.value = virtual.pagesSignal.peek() + 1;
63264
+ });
62895
63265
  // Before the first answer a run does not know how many rows it stands for.
62896
63266
  // It stands for a windowful of them: a list that is about to be filled looks
62897
63267
  // like rows on their way, not like an empty list.
@@ -62964,10 +63334,12 @@ const useItemStore = ({
62964
63334
  // dropped and simply asked for again if the user goes back — the same
62965
63335
  // trade the render window makes, one order of magnitude further out.
62966
63336
  forget: (windowFrom, windowTo) => {
62967
- const budget = memoryBudget === undefined ? ITEM_STORE_MAX_DEFAULT : memoryBudget;
62968
- if (!budget) {
63337
+ if (inMemory) {
63338
+ // Dropping a row here would drop it for good: there is no source to
63339
+ // ask it back from.
62969
63340
  return;
62970
63341
  }
63342
+ const budget = memoryBudget === undefined ? ITEM_STORE_MAX_DEFAULT : memoryBudget;
62971
63343
  const keepFrom = windowFrom - ITEM_STORE_KEEP_AROUND;
62972
63344
  const keepTo = windowTo + ITEM_STORE_KEEP_AROUND;
62973
63345
  if (pages.byIndex.size > budget) {