@jsenv/navi 0.29.109 → 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, 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
  }
@@ -30042,6 +30216,13 @@ registerNaviCommand("--navi-unselect", (source, event) => {
30042
30216
  * observeAncestorOpenState for exactly how that's detected, and why it
30043
30217
  * matters that it happens before the browser paints.
30044
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.
30045
30226
  *
30046
30227
  * The callback's second argument is always a `navi_displayed` CustomEvent,
30047
30228
  * with `detail: { ancestor, ancestorType, becauseAncestorOpened }`:
@@ -30074,6 +30255,10 @@ const useDisplayedLayoutEffect = (ref, callback, deps) => {
30074
30255
  const callbackRef = useRef(callback);
30075
30256
  callbackRef.current = callback;
30076
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
+
30077
30262
  // Run on mount (or when deps change) — but only if the element is visible.
30078
30263
  useLayoutEffect(() => {
30079
30264
  const el = ref.current;
@@ -30086,9 +30271,16 @@ const useDisplayedLayoutEffect = (ref, callback, deps) => {
30086
30271
  return;
30087
30272
  }
30088
30273
  if (!isAncestorOpen(ancestor)) {
30089
- // Ancestor is closed — skip now; the observeAncestorOpenState call
30090
- // below will fire once it opens.
30091
- 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;
30092
30284
  }
30093
30285
  callbackRef.current(el, createDisplayedEvent(ancestor, false));
30094
30286
  }, deps);
@@ -30107,6 +30299,12 @@ const useDisplayedLayoutEffect = (ref, callback, deps) => {
30107
30299
  if (!isOpen) {
30108
30300
  return;
30109
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
+ }
30110
30308
  const lastEl = ref.current;
30111
30309
  callbackRef.current(lastEl, createDisplayedEvent(ancestor, true));
30112
30310
  });
@@ -30966,11 +31164,22 @@ const isTypingIntent = (e) =>
30966
31164
 
30967
31165
  const s = (n) => (n > 1 ? "s" : "");
30968
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
+
30969
31174
  // Keydown: block only single printable characters that don't match the class.
30970
31175
  // Multi-character key names (Delete, ArrowLeft…) are always allowed.
30971
31176
  const getInvalidCharMessage = (char, { charClass, messageKey }) => {
30972
- if (char.length !== 1) return null;
30973
- 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;
30974
31183
  return naviI18n(messageKey);
30975
31184
  };
30976
31185
 
@@ -30990,7 +31199,7 @@ const getMaxLengthInsertionMessage = (el, { maxLength }) => {
30990
31199
  // Paste / set: block when value contains disallowed chars.
30991
31200
  const getInvalidCharsMessage = (uiState, { charClass, messageKey }) => {
30992
31201
  const str = uiState === undefined ? "" : String(uiState);
30993
- if (new RegExp(`^(?:${charClass})*$`).test(str)) return null;
31202
+ if (compileCharClassAnchored(charClass).test(str)) return null;
30994
31203
  return naviI18n(messageKey);
30995
31204
  };
30996
31205
 
@@ -37217,6 +37426,7 @@ const TextAnchor = ({
37217
37426
  ref: anchorRef,
37218
37427
  className: "navi_text_anchor",
37219
37428
  "aria-hidden": "true",
37429
+ hidden: true,
37220
37430
  children: "\u200B"
37221
37431
  })]
37222
37432
  });
@@ -37884,8 +38094,10 @@ const shouldInjectSpacingBetween = (left, right) => {
37884
38094
  * @param {boolean} [emojiAsIcon]
37885
38095
  * Renders every emoji found in the string children as an `Icon`, so it sits
37886
38096
  * in the line like a character and never makes the line taller than the
37887
- * text. For free text a user typed (a message, a description) — the only
37888
- * 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.
37889
38101
  *
37890
38102
  * @param {boolean} [preventSpaceUnderlines]
37891
38103
  * Replaces real space characters between children with padding-based spaces.
@@ -46380,7 +46592,9 @@ installImportMetaCssBuild(import.meta);/**
46380
46592
  * "postal" → postal code (digits, letters, space, hyphen)
46381
46593
  * "iban" → IBAN (uppercase and digits)
46382
46594
  * "slug" → URL slug (lowercase, digits, hyphens)
46383
- * "[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.
46384
46598
  * inputMode and pattern are auto-derived from the preset when not explicitly set.
46385
46599
  *
46386
46600
  * - maxLengthGuard — combines maxLength + overflow guard in one prop.
@@ -59043,10 +59257,11 @@ const PendingScrollRefContext = createContext(null);
59043
59257
  // "muted" — keep in DOM, visible but opacified and still interactive
59044
59258
  const SearchNoMatchModeContext = createContext("remove");
59045
59259
 
59046
- // When total rendered items exceeds renderBudget, a render window [start, end)
59047
- // is activated to cap the number of DOM nodes. Items outside the window return
59048
- // null. The window slides as the user scrolls, using actual DOM positions
59049
- // (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.
59050
59265
  const RENDER_BUDGET_DEFAULT = 100;
59051
59266
 
59052
59267
  // Attribute used on <li> elements rendered by ListItemReal so the scroll listener
@@ -59055,8 +59270,9 @@ const REAL_LIST_ITEM_SELECTOR = `[navi-list-item-real]`;
59055
59270
  // Rows standing in for content that has not arrived (see List's renderSkeleton).
59056
59271
  const SKELETON_LIST_ITEM_CLASS = "navi_list_item_skeleton";
59057
59272
 
59058
- // Carries the render window {start, end} (or null = render all) from
59059
- // 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.
59060
59276
  const RenderWindowContext = createContext(null);
59061
59277
  // Carries List's own `columns` prop (a grid-template-columns value, e.g.
59062
59278
  // "1fr auto auto") down to each ListItem/filler/fallback so they can render
@@ -59893,12 +60109,46 @@ const ListUI = props => {
59893
60109
  searchText,
59894
60110
  horizontal
59895
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
+ });
59896
60129
  virtual.captureAnchor = captureAnchor;
59897
60130
  virtual.virtualItemSizeSignal = virtualItemSizeSignal;
59898
60131
  virtual.horizontal = Boolean(horizontal);
59899
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.
59900
60139
  const getItemById = itemId => {
59901
- 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
+ };
59902
60152
  };
59903
60153
  const noMatchCount = tracker.noMatchCountSignal.value;
59904
60154
  // What the list stands for, which is not always what it holds: a run saying
@@ -62438,6 +62688,9 @@ const createListVirtual = () => {
62438
62688
  passId++;
62439
62689
  nextIndex = 0;
62440
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,
62441
62694
  setRowLocator: (ownerId, locate) => {
62442
62695
  locatorByOwner.set(ownerId, locate);
62443
62696
  },
@@ -62484,12 +62737,17 @@ const VISIBILITY_HIDDEN_STYLE = {
62484
62737
  * for them — which is what makes an infinitely scrolled list nothing more than
62485
62738
  * a list that says how many rows it has.
62486
62739
  *
62487
- * The rows come from `itemsAction(range)`: the run asks for what it is about to
62488
- * draw and keeps what it gets. The range says the same thing three ways, so a
62489
- * source can read it however it paginates — `{ start, end }` (places in the
62490
- * collection, a negative `start` counting back from the end like
62491
- * `Range: items=-25`, which is what a list opening on its last rows asks for
62492
- * 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
62493
62751
  * `before`/`after`/`around` (the id of a row to count from, for a source
62494
62752
  * paginating by cursor). Answer with the rows (an array — that is all of
62495
62753
  * them), or with a range the way a Content-Range does: `{ items, start, count }`
@@ -62503,11 +62761,11 @@ const VISIBILITY_HIDDEN_STYLE = {
62503
62761
  * copies of the JSON. The list holds the slices, the store holds the objects
62504
62762
  * (see docs/resource.md).
62505
62763
  *
62506
- * A collection held in memory answers synchronously: `itemsAction={() => rows}`.
62507
- * What it gives back is kept, so a collection that changes as a whole (a search
62508
- * reordering it) is a different collection: give the run a `key` that changes
62509
- * with it, the way one does for anything else that is not the same thing
62510
- * 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.
62511
62769
  *
62512
62770
  * A row says what it is where it is drawn: `renderItem` returns a
62513
62771
  * `<List.Item>` carrying its own props (`selectable`, `value`, `selected`…),
@@ -62519,7 +62777,8 @@ const VISIBILITY_HIDDEN_STYLE = {
62519
62777
  *
62520
62778
  * @type {import("ignore:preact").FunctionComponent<{
62521
62779
  * renderItem: (item: any, index: number, state: {refreshing: boolean}) => import("ignore:preact").ComponentChildren,
62522
- * 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,
62523
62782
  * count?: number,
62524
62783
  * groupBy?: (item: any, index: number) => any,
62525
62784
  * renderGroupLabel?: (item: any, index: number) => import("ignore:preact").ComponentChildren,
@@ -62534,6 +62793,13 @@ const VISIBILITY_HIDDEN_STYLE = {
62534
62793
  * What one row is, given the item and where it sits. `state.refreshing` says
62535
62794
  * the rows drawn are the ones from before while the run reads the collection
62536
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(...)`).
62537
62803
  * @param {(item: any, index: number) => any} [props.groupBy]
62538
62804
  * What tells rows that belong together apart from the others — the day of a
62539
62805
  * message, the month of a game. Consecutive rows sharing it are wrapped in a
@@ -62556,7 +62822,8 @@ const VISIBILITY_HIDDEN_STYLE = {
62556
62822
  * How many rows the run keeps in memory. Past that, the ones far from what is
62557
62823
  * on screen are dropped (and asked for again if the user goes back) — the
62558
62824
  * same trade the render window makes with the DOM, one order of magnitude
62559
- * 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.
62560
62827
  * @param {false|(index: number) => any} [props.renderSkeleton]
62561
62828
  * What to draw for a row the run does not hold. Defaults to List's own
62562
62829
  * `renderSkeleton`, then to a bare `<List.Item skeleton>`; `false` leaves the
@@ -62577,6 +62844,7 @@ const VISIBILITY_HIDDEN_STYLE = {
62577
62844
  */
62578
62845
  const ListItems = ({
62579
62846
  renderItem,
62847
+ items,
62580
62848
  itemsAction,
62581
62849
  count,
62582
62850
  pageSize,
@@ -62593,6 +62861,7 @@ const ListItems = ({
62593
62861
  const renderWindow = useContext(RenderWindowContext);
62594
62862
  const separator = useContext(SeparatorContext);
62595
62863
  const store = useItemStore({
62864
+ items,
62596
62865
  count,
62597
62866
  itemsAction,
62598
62867
  memoryBudget,
@@ -62883,11 +63152,17 @@ const rangeIsSame = (a, b) => {
62883
63152
  // are in all is enough to place it, so the pages need not be contiguous nor
62884
63153
  // arrive in order.
62885
63154
  const useItemStore = ({
63155
+ items,
62886
63156
  count,
62887
63157
  itemsAction,
62888
63158
  memoryBudget,
62889
63159
  onRequestStateChange
62890
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;
62891
63166
  // The run's asking, on the same channel as the window it asks for — they are
62892
63167
  // one subject: what the list is about to draw is what it goes to fetch (see
62893
63168
  // `useRequestMissing`, and `updateRenderWindow` which logs the other half).
@@ -62909,8 +63184,28 @@ const useItemStore = ({
62909
63184
  // the reader holds for it (see resource_range_reader.js).
62910
63185
  useRef(false);
62911
63186
  const pagesRef = useRef(null);
63187
+ const itemsRef = useRef(items);
63188
+ const itemsHeldRef = useRef(false);
62912
63189
  let restored = false;
62913
- 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) {
62914
63209
  const composition = typeof itemsAction === "function" && itemsAction.readComposition ? itemsAction.readComposition() : null;
62915
63210
  if (composition && composition.count !== undefined) {
62916
63211
  pagesRef.current = composition;
@@ -62957,6 +63252,16 @@ const useItemStore = ({
62957
63252
  // range askable again (see the request memory just above).
62958
63253
  const [failure, setFailure] = useState(null);
62959
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
+ });
62960
63265
  // Before the first answer a run does not know how many rows it stands for.
62961
63266
  // It stands for a windowful of them: a list that is about to be filled looks
62962
63267
  // like rows on their way, not like an empty list.
@@ -63029,10 +63334,12 @@ const useItemStore = ({
63029
63334
  // dropped and simply asked for again if the user goes back — the same
63030
63335
  // trade the render window makes, one order of magnitude further out.
63031
63336
  forget: (windowFrom, windowTo) => {
63032
- const budget = memoryBudget === undefined ? ITEM_STORE_MAX_DEFAULT : memoryBudget;
63033
- 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.
63034
63340
  return;
63035
63341
  }
63342
+ const budget = memoryBudget === undefined ? ITEM_STORE_MAX_DEFAULT : memoryBudget;
63036
63343
  const keepFrom = windowFrom - ITEM_STORE_KEEP_AROUND;
63037
63344
  const keepTo = windowTo + ITEM_STORE_KEEP_AROUND;
63038
63345
  if (pages.byIndex.size > budget) {