@jsenv/navi 0.29.434 → 0.29.436

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.
@@ -7,11 +7,11 @@ import { createPubSub, elementIsFocusable, createIterableWeakSet, dispatchIntern
7
7
  export { chainEvent, clickIsSuppressed, contrastColor, createDragGestureController, dragAfterIntent, findEvent, markDragSource, startDragTo } from "@jsenv/dom";
8
8
  import { signal, computed, effect, untracked, batch, useComputed, useSignal } from "@preact/signals";
9
9
  import { options, isValidElement, createContext, render as render$1, h, toChildArray, createRef, cloneElement, createElement, Fragment as Fragment$1 } from "preact";
10
- import { useErrorBoundary, useLayoutEffect, useContext, useState, useMemo, useRef, useEffect, useCallback, useReducer, useId } from "preact/hooks";
10
+ import { useErrorBoundary, useLayoutEffect, useContext, useState, useMemo, useRef, useEffect, useReducer, useCallback, useId } from "preact/hooks";
11
11
  import { humanizeI18n, prefixFirstAndIndentRemainingLines, setRuntimeLangSource, formatDuration, formatMonth, formatDay, resolveTimeRangePrecision, formatDatePlaceholder, toDate, getRelativeDay, formatDayRelative, formatMonthPlaceholder, formatWeekPlaceholder, formatDatetimePlaceholder, formatDatetime, toTimeOfDay, formatTimeOfDay, formatTime, formatMinuteDuration, formatSecondDuration, formatHourDuration, formatTimeRelative, formatNumber, interpolateText, installInterpolateJsx } from "@jsenv/humanize";
12
12
  export { createI18n, formatDatetime, formatDay, formatDayRelative, formatDuration, formatHourDuration, formatMinuteDuration, formatMonth, formatNumber, formatSecondDuration, formatTime, formatTimeOfDay, formatTimeRange, formatTimeRelative, interpolateText } from "@jsenv/humanize";
13
13
  import { jsxs, jsx, Fragment } from "preact/jsx-runtime";
14
- import { CHAR_CLASS_PRESETS, createValidity, TYPE_RULE, durationContainsNaN, compareTwoDurations, durationToSeconds, DISPLAYABLE_RULE, MAX_LINE_BREAKS_RULE, NO_EMOJI_RULE, SINGLE_SPACE_RULE, resolveCharClass, getCharClassMessageKey, compileCharClassAnchored, compileCharClass, parseDuration, durationToISOString } from "@jsenv/validity";
14
+ import { CHAR_CLASS_PRESETS, createValidity, durationContainsNaN, compareTwoDurations, TYPE_RULE, durationToSeconds, DISPLAYABLE_RULE, MAX_LINE_BREAKS_RULE, NO_EMOJI_RULE, SINGLE_SPACE_RULE, resolveCharClass, getCharClassMessageKey, compileCharClassAnchored, compileCharClass, parseDuration, durationToISOString } from "@jsenv/validity";
15
15
  export { compareTwoDurations, durationContainsNaN, durationToHours, durationToISOString, durationToMinutes, durationToNumber, durationToSeconds, durationToString, parseDuration } from "@jsenv/validity";
16
16
  import { Suspense, createPortal, forwardRef } from "preact/compat";
17
17
 
@@ -971,8 +971,10 @@ naviI18n.addAll({
971
971
  },
972
972
  });
973
973
 
974
- // Spin messages — the ends of what one steps through, said without naming
975
- // what it is made of: the same words fit days, months, pages or sizes.
974
+ // Spin messages — the ends of what one steps through when the control holding
975
+ // it has no sentence of its own for a bound (a day or a number says its
976
+ // constraint.min/max one, see picker_spin.jsx): said without naming what it is
977
+ // made of, so they fit pages, sizes or any list of items.
976
978
  naviI18n.addAll({
977
979
  "spin.previous": {
978
980
  en: "Previous",
@@ -11207,6 +11209,42 @@ const urlWithoutSearchAndHash = (url) => {
11207
11209
  return `${urlObject.origin}${urlObject.pathname}`;
11208
11210
  };
11209
11211
 
11212
+ /**
11213
+ * What a component can learn about the page it is rendered in, from the route
11214
+ * container rendering that page (see useContentKeepingLeavingPages in
11215
+ * route.jsx). `null` outside of any route container.
11216
+ *
11217
+ * - `isShown()` — whether the container would still show the page. False as
11218
+ * soon as the address leads elsewhere: the page is about to be taken down, or
11219
+ * hidden until the route transition leaving it is over, and nothing it reads
11220
+ * is worth a render any more. False for good once it is kept while leaving: a
11221
+ * return to its route mounts the page anew. Asked outside any render — by a
11222
+ * subscription deciding whether to re-render — and it reads the routes
11223
+ * untracked, so asking subscribes nobody to them.
11224
+ * - `leavingSignal` — true once the page is hidden and kept while its movement
11225
+ * plays: still mounted, so what it fills outside its own nodes (a slot in a
11226
+ * bar) steps out rather than showing with the page arriving.
11227
+ */
11228
+ const RoutePageContext = createContext(null);
11229
+
11230
+ /**
11231
+ * A container has put its page on screen — or as much of it as it can.
11232
+ *
11233
+ * A route matching is a signal changing, and the page it selects reaches the
11234
+ * DOM only once Preact has rendered — an unknown number of passes later, in an
11235
+ * unknown number of microtasks. Anyone who needs the page as it IS rather than
11236
+ * as it has been decided (a travel about to have its picture taken by the
11237
+ * browser, see route_travel.jsx) waits for this instead of counting.
11238
+ *
11239
+ * A page waiting on data is announced too, by the boundary showing its loading
11240
+ * state (see Loading in use_async_data.jsx): what the container could put on
11241
+ * screen is what the browser is about to take a picture of, and a page that
11242
+ * cannot render yet would otherwise be waited on until the transition dies of
11243
+ * it. It lives in a module of its own for that: the async layer says it as much
11244
+ * as the router does, and neither can import the other.
11245
+ */
11246
+ const [publishRouteRender, observeRouteRender] = createPubSub();
11247
+
11210
11248
  const documentStateSignal = signal(null);
11211
11249
  const useDocumentState = () => {
11212
11250
  return documentStateSignal.value;
@@ -11466,24 +11504,6 @@ const readTopInWindow = element => {
11466
11504
  return element.getBoundingClientRect().top;
11467
11505
  };
11468
11506
 
11469
- /**
11470
- * A container has put its page on screen — or as much of it as it can.
11471
- *
11472
- * A route matching is a signal changing, and the page it selects reaches the
11473
- * DOM only once Preact has rendered — an unknown number of passes later, in an
11474
- * unknown number of microtasks. Anyone who needs the page as it IS rather than
11475
- * as it has been decided (a travel about to have its picture taken by the
11476
- * browser, see route_travel.jsx) waits for this instead of counting.
11477
- *
11478
- * A page waiting on data is announced too, by the boundary showing its loading
11479
- * state (see Loading in use_async_data.jsx): what the container could put on
11480
- * screen is what the browser is about to take a picture of, and a page that
11481
- * cannot render yet would otherwise be waited on until the transition dies of
11482
- * it. It lives in a module of its own for that: the async layer says it as much
11483
- * as the router does, and neither can import the other.
11484
- */
11485
- const [publishRouteRender, observeRouteRender] = createPubSub();
11486
-
11487
11507
  /**
11488
11508
  * Where a page was left, given back when one comes back to it.
11489
11509
  *
@@ -12938,15 +12958,12 @@ const useNavStateWithWarnings = (id, options) => {
12938
12958
  ownerRef.current = {};
12939
12959
  }
12940
12960
  const owner = ownerRef.current;
12941
- // Claimed during render so warnAboutOrphanGeneratedKeys sees every id of
12942
- // the commit, including the ones whose effect has not run yet. The conflict
12943
- // is judged in the effect: when a screen replaces another in one commit,
12944
- // preact renders the new children before unmounting the old ones, so at
12945
- // render time the id may still be held by a component about to go.
12946
- if (id !== undefined && !idUsageMap.has(id)) {
12947
- idUsageMap.set(id, { owner, stackTrace: new Error().stack });
12948
- }
12949
12961
 
12962
+ // Claimed in the effect, never during render: only the effect has a cleanup
12963
+ // to give the id back, and a render is not always followed by its effects.
12964
+ // A page parked by a Suspense boundary (every hook cleanup run), rendered
12965
+ // again and taken down before the paint, leaves a render-time claim holding
12966
+ // the id for nobody — and the next mount reads a conflict.
12950
12967
  useEffect(() => {
12951
12968
  if (id === undefined) {
12952
12969
  return undefined;
@@ -12960,12 +12977,9 @@ This can cause UI state conflicts and unexpected behavior.
12960
12977
  Consider using unique IDs for each component instance.`,
12961
12978
  );
12962
12979
  } else {
12963
- // Also set when absent: preact/compat's Suspense parks a subtree by
12964
- // running every hook cleanup in it, and the render resuming it carries
12965
- // the same id.
12966
12980
  idUsageMap.set(id, { owner, stackTrace: new Error().stack });
12967
12981
  }
12968
- warnAboutOrphanGeneratedKeys();
12982
+ scheduleOrphanGeneratedKeysCheck();
12969
12983
  return () => {
12970
12984
  if (idUsageMap.get(id)?.owner === owner) {
12971
12985
  idUsageMap.delete(id);
@@ -12981,6 +12995,20 @@ Consider using unique IDs for each component instance.`,
12981
12995
  // and the mount coming back to the entry generates another id: the state is
12982
12996
  // there, and nothing reads it. Reported when a component mounting on the entry
12983
12997
  // finds such a key, the moment someone expected the state back.
12998
+ //
12999
+ // Checked once every effect of the flush has claimed its id: a component
13000
+ // whose effect comes later in the same flush is not an orphan.
13001
+ let orphanGeneratedKeysCheckScheduled = false;
13002
+ const scheduleOrphanGeneratedKeysCheck = () => {
13003
+ if (orphanGeneratedKeysCheckScheduled) {
13004
+ return;
13005
+ }
13006
+ orphanGeneratedKeysCheckScheduled = true;
13007
+ queueMicrotask(() => {
13008
+ orphanGeneratedKeysCheckScheduled = false;
13009
+ warnAboutOrphanGeneratedKeys();
13010
+ });
13011
+ };
12984
13012
  const warnAboutOrphanGeneratedKeys = () => {
12985
13013
  const state = browserIntegration.getDocumentState();
12986
13014
  if (!state) {
@@ -13003,6 +13031,7 @@ const warnAboutOrphanGeneratedKeys = () => {
13003
13031
 
13004
13032
  const NO_OP = () => {};
13005
13033
  const NO_ID_GIVEN = [undefined, NO_OP, NO_OP];
13034
+ const countRenders$1 = (count) => count + 1;
13006
13035
  // What the computed below answers for a key the document state does not hold:
13007
13036
  // key presence is the flag, and the value under a present key may be anything,
13008
13037
  // undefined included.
@@ -13031,7 +13060,36 @@ const useNavStateBasic = (
13031
13060
  [id],
13032
13061
  );
13033
13062
  const keyValue = keyValueComputed.value;
13034
- const keyInState = keyValue !== KEY_NOT_IN_STATE;
13063
+ // A page the address has left keeps reading the entry it was shown on until
13064
+ // it is taken down (kept hidden while a route transition plays): the new
13065
+ // entry would close what was open there — a Picker as a cancel — where the
13066
+ // same page taken down at once is only unmounted. Its render still happens:
13067
+ // the state is written before the routes say the page is left.
13068
+ const routePage = useContext(RoutePageContext);
13069
+ const keyValueReadRef = useRef(keyValue);
13070
+ const keyValueWithheld =
13071
+ keyValue !== keyValueReadRef.current &&
13072
+ routePage !== null &&
13073
+ !routePage.isShown();
13074
+ if (!keyValueWithheld) {
13075
+ keyValueReadRef.current = keyValue;
13076
+ }
13077
+ const keyValueRead = keyValueReadRef.current;
13078
+ // Owed rather than dropped, as in useAsyncData: the address can come back
13079
+ // before the container renders (a redirect straight back), and the page then
13080
+ // stays on screen.
13081
+ const [, rerender] = useReducer(countRenders$1, 0);
13082
+ useEffect(() => {
13083
+ if (!keyValueWithheld) {
13084
+ return undefined;
13085
+ }
13086
+ return observeRouteRender(() => {
13087
+ if (routePage.isShown()) {
13088
+ rerender();
13089
+ }
13090
+ });
13091
+ }, [keyValueWithheld]);
13092
+ const keyInState = keyValueRead !== KEY_NOT_IN_STATE;
13035
13093
  const onLeaveRef = useRef(onLeave);
13036
13094
  onLeaveRef.current = onLeave;
13037
13095
  const prevKeyInStateRef = useRef(keyInState);
@@ -13066,7 +13124,7 @@ const useNavStateBasic = (
13066
13124
  effectiveType = "replace";
13067
13125
  }
13068
13126
 
13069
- const currentValue = keyInState ? keyValue : defaultValue;
13127
+ const currentValue = keyInState ? keyValueRead : defaultValue;
13070
13128
 
13071
13129
  if (debug) {
13072
13130
  console.debug(`useNavState(${id}) current value is ${currentValue}`);
@@ -13134,6 +13192,8 @@ const useNavStateBasic = (
13134
13192
  /**
13135
13193
  * Stores a named value in the browser's document state and returns it reactively.
13136
13194
  * The component re-renders whenever the value changes (navigation, back/forward button).
13195
+ * In a page the address has left — kept hidden while a route transition plays —
13196
+ * the value stays the one of the entry that page was shown on.
13137
13197
  *
13138
13198
  * @param {string} id
13139
13199
  * Unique key used to store the value in document state. Must be stable across renders.
@@ -20285,18 +20345,17 @@ const MIN_CONSTRAINT = {
20285
20345
  if (cmp === null || cmp >= 0) {
20286
20346
  return null;
20287
20347
  }
20288
-
20289
- return naviI18n("constraint.min.duration.default", {
20290
- min: formatDuration(min),
20291
- });
20348
+ return boundMessage(field, "min");
20292
20349
  }
20293
20350
  if (field.controlType !== "input" && field.controlType !== "picker") {
20294
20351
  return null;
20295
20352
  }
20296
- const minString = field.controlHostProps.min;
20297
- if (!minString) {
20353
+ const min = field.controlHostProps.min;
20354
+ // 0 is a bound like any other: only a bound nobody gave is skipped.
20355
+ if (min === undefined || min === null || min === "") {
20298
20356
  return null;
20299
20357
  }
20358
+ const minString = String(min);
20300
20359
  const type = field.controlHostProps.type;
20301
20360
  const naviInputType = field.controlHostProps["navi-input-type"];
20302
20361
  const valueAsString = uiStateAsText(field.uiState);
@@ -20312,40 +20371,16 @@ const MIN_CONSTRAINT = {
20312
20371
  if (isNaN(numericValue)) {
20313
20372
  return null;
20314
20373
  }
20315
- if (numericValue < minNumber) {
20316
- if (naviInputType === "hour") {
20317
- return naviI18n(`constraint.min.hour.default`, {
20318
- min: minString,
20319
- });
20320
- }
20321
- if (naviInputType === "minute") {
20322
- return naviI18n(`constraint.min.minute.default`, {
20323
- min: minString,
20324
- });
20325
- }
20326
- if (naviInputType === "second") {
20327
- return naviI18n(`constraint.min.second.default`, {
20328
- min: minString,
20329
- });
20330
- }
20331
- if (naviInputType === "percentage") {
20332
- return naviI18n(`constraint.min.percentage.default`, {
20333
- min: minString,
20334
- });
20335
- }
20336
- return naviI18n(`constraint.min.number.default`, {
20337
- min: minString,
20338
- });
20374
+ if (numericValue >= minNumber) {
20375
+ return null;
20339
20376
  }
20340
- return null;
20377
+ return boundMessage(field, "min");
20341
20378
  }
20342
20379
  if (type === "time") {
20343
20380
  const [minHours, minMinutes] = minString.split(":").map(Number);
20344
20381
  const [hours, minutes] = valueAsString.split(":").map(Number);
20345
20382
  if (hours < minHours || (hours === minHours && minutes < minMinutes)) {
20346
- return naviI18n("constraint.min.time.default", {
20347
- min: minString,
20348
- });
20383
+ return boundMessage(field, "min");
20349
20384
  }
20350
20385
  return null;
20351
20386
  }
@@ -20353,18 +20388,13 @@ const MIN_CONSTRAINT = {
20353
20388
  // so they never need a min/max validation message.
20354
20389
  if (DATE_INPUT_TYPE_SET.has(type)) {
20355
20390
  if (valueAsString < minString) {
20356
- const todayIso = getTodayIso(type);
20357
- if (minString === todayIso) {
20358
- return naviI18n("constraint.min.date.today.default");
20359
- }
20360
- return naviI18n("constraint.min.date.default", {
20361
- min: formatDateIso(minString, type),
20362
- });
20391
+ return boundMessage(field, "min");
20363
20392
  }
20364
20393
  return null;
20365
20394
  }
20366
20395
  return null;
20367
20396
  },
20397
+ messageAtBound: (field) => boundMessage(field, "min"),
20368
20398
  };
20369
20399
  CONSTRAINT_ATTRIBUTE_SET.add("min");
20370
20400
 
@@ -20384,18 +20414,17 @@ const MAX_CONSTRAINT = {
20384
20414
  if (cmp === null || cmp <= 0) {
20385
20415
  return null;
20386
20416
  }
20387
-
20388
- return naviI18n("constraint.max.duration.default", {
20389
- max: formatDuration(max),
20390
- });
20417
+ return boundMessage(field, "max");
20391
20418
  }
20392
20419
  if (field.controlType !== "input" && field.controlType !== "picker") {
20393
20420
  return null;
20394
20421
  }
20395
- const maxString = field.controlHostProps.max;
20396
- if (!maxString) {
20422
+ const max = field.controlHostProps.max;
20423
+ // 0 is a bound like any other: only a bound nobody gave is skipped.
20424
+ if (max === undefined || max === null || max === "") {
20397
20425
  return null;
20398
20426
  }
20427
+ const maxString = String(max);
20399
20428
  const type = field.controlHostProps.type;
20400
20429
  const naviInputType = field.controlHostProps["navi-input-type"];
20401
20430
  const valueAsString = uiStateAsText(field.uiState);
@@ -20414,38 +20443,13 @@ const MAX_CONSTRAINT = {
20414
20443
  if (numericValue <= maxNumber) {
20415
20444
  return null;
20416
20445
  }
20417
-
20418
- if (naviInputType === "hour") {
20419
- return naviI18n(`constraint.max.hour.default`, {
20420
- max: maxString,
20421
- });
20422
- }
20423
- if (naviInputType === "minute") {
20424
- return naviI18n(`constraint.max.minute.default`, {
20425
- max: maxString,
20426
- });
20427
- }
20428
- if (naviInputType === "second") {
20429
- return naviI18n(`constraint.max.second.default`, {
20430
- max: maxString,
20431
- });
20432
- }
20433
- if (naviInputType === "percentage") {
20434
- return naviI18n(`constraint.max.percentage.default`, {
20435
- max: maxString,
20436
- });
20437
- }
20438
- return naviI18n(`constraint.max.number.default`, {
20439
- max: maxString,
20440
- });
20446
+ return boundMessage(field, "max");
20441
20447
  }
20442
20448
  if (type === "time") {
20443
20449
  const [maxHours, maxMinutes] = maxString.split(":").map(Number);
20444
20450
  const [hours, minutes] = valueAsString.split(":").map(Number);
20445
20451
  if (hours > maxHours || (hours === maxHours && minutes > maxMinutes)) {
20446
- return naviI18n("constraint.max.time.default", {
20447
- max: maxString,
20448
- });
20452
+ return boundMessage(field, "max");
20449
20453
  }
20450
20454
  return null;
20451
20455
  }
@@ -20453,17 +20457,11 @@ const MAX_CONSTRAINT = {
20453
20457
  if (valueAsString <= maxString) {
20454
20458
  return null;
20455
20459
  }
20456
-
20457
- const todayIso = getTodayIso(type);
20458
- if (maxString === todayIso) {
20459
- return naviI18n("constraint.max.date.today.default");
20460
- }
20461
- return naviI18n("constraint.max.date.default", {
20462
- max: formatDateIso(maxString, type),
20463
- });
20460
+ return boundMessage(field, "max");
20464
20461
  }
20465
20462
  return null;
20466
20463
  },
20464
+ messageAtBound: (field) => boundMessage(field, "max"),
20467
20465
  };
20468
20466
  CONSTRAINT_ATTRIBUTE_SET.add("max");
20469
20467
 
@@ -20658,6 +20656,51 @@ const formatMsToTime = (ms, showSeconds) => {
20658
20656
  return `${hh}:${mm}:${ss}`;
20659
20657
  };
20660
20658
 
20659
+ // What a bound says, whatever the value: the sentence `check` gives a value
20660
+ // past it, and the one a spin gives when its chevron is refused there
20661
+ // (picker_spin.jsx) — one sentence, so the two refuse in the same words. null
20662
+ // for a type that has nothing to say about a bound.
20663
+ const boundMessage = (field, bound) => {
20664
+ const boundValue = field.controlHostProps[bound];
20665
+ if (field.controlType === "duration_group") {
20666
+ return naviI18n(`constraint.${bound}.duration.default`, {
20667
+ [bound]: formatDuration(boundValue),
20668
+ });
20669
+ }
20670
+ const type = field.controlHostProps.type;
20671
+ const naviInputType = field.controlHostProps["navi-input-type"];
20672
+ if (isNumberInput(type, naviInputType)) {
20673
+ const numberKind = NUMBER_BOUND_KIND_SET.has(naviInputType)
20674
+ ? naviInputType
20675
+ : "number";
20676
+ return naviI18n(`constraint.${bound}.${numberKind}.default`, {
20677
+ [bound]: String(boundValue),
20678
+ });
20679
+ }
20680
+ if (type === "time") {
20681
+ return naviI18n(`constraint.${bound}.time.default`, {
20682
+ [bound]: boundValue,
20683
+ });
20684
+ }
20685
+ if (DATE_INPUT_TYPE_SET.has(type)) {
20686
+ if (boundValue === getTodayIso(type)) {
20687
+ return naviI18n(`constraint.${bound}.date.today.default`);
20688
+ }
20689
+ return naviI18n(`constraint.${bound}.date.default`, {
20690
+ [bound]: formatDateIso(boundValue, type),
20691
+ });
20692
+ }
20693
+ return null;
20694
+ };
20695
+ // The numbers whose bound is said with what they count ("Max 23 heures");
20696
+ // any other is "Ce nombre".
20697
+ const NUMBER_BOUND_KIND_SET = new Set([
20698
+ "hour",
20699
+ "minute",
20700
+ "second",
20701
+ "percentage",
20702
+ ]);
20703
+
20661
20704
  const getTodayIso = (inputType) => {
20662
20705
  const now = new Date();
20663
20706
  const yyyy = now.getFullYear();
@@ -29150,24 +29193,6 @@ const TYPE_CONVERTERS = {
29150
29193
  },
29151
29194
  };
29152
29195
 
29153
- /**
29154
- * What a component can learn about the page it is rendered in, from the route
29155
- * container rendering that page (see useContentKeepingLeavingPages in
29156
- * route.jsx). `null` outside of any route container.
29157
- *
29158
- * - `isShown()` — whether the container would still show the page. False as
29159
- * soon as the address leads elsewhere: the page is about to be taken down, or
29160
- * hidden until the route transition leaving it is over, and nothing it reads
29161
- * is worth a render any more. False for good once it is kept while leaving: a
29162
- * return to its route mounts the page anew. Asked outside any render — by a
29163
- * subscription deciding whether to re-render — and it reads the routes
29164
- * untracked, so asking subscribes nobody to them.
29165
- * - `leavingSignal` — true once the page is hidden and kept while its movement
29166
- * plays: still mounted, so what it fills outside its own nodes (a slot in a
29167
- * bar) steps out rather than showing with the page arriving.
29168
- */
29169
- const RoutePageContext = createContext(null);
29170
-
29171
29196
  const promiseStateWeakMap = new WeakMap();
29172
29197
  const usePromiseAsyncData = (
29173
29198
  promise,
@@ -34068,11 +34093,17 @@ const useContentKeepingLeavingPages = (branch, readShownBranch, parentPage) => {
34068
34093
  }
34069
34094
  }
34070
34095
  });
34096
+ // Also run when a Suspense boundary above parks this container — a page
34097
+ // arriving that suspends to a `<Loading>` around both pages: every hook of
34098
+ // the pages kept while leaving has then been cleaned up, which is all that
34099
+ // keeping them saved. Dropped, so the render resuming the container takes
34100
+ // them down, rather than re-running every effect of a hidden page.
34071
34101
  useLayoutEffect(() => {
34072
34102
  return () => {
34073
34103
  for (const entry of leavingRef.current) {
34074
34104
  entry.hold.containers.delete(rerender);
34075
34105
  }
34106
+ leavingRef.current = [];
34076
34107
  };
34077
34108
  }, []);
34078
34109
  if (leaving.length === 0) {
@@ -54662,6 +54693,26 @@ const Expandable = props => {
54662
54693
  return stopRide;
54663
54694
  };
54664
54695
 
54696
+ // A silent change is a state the page was already in (see openEffect): it
54697
+ // lands at once. Not playing a reveal is not enough — the stylesheet's own
54698
+ // transition plays it whenever the previous state's style was resolved
54699
+ // before the render: an expandable mounted closed and measured in the same
54700
+ // commit (a List sizing its items) before its deferred mount-time open, or
54701
+ // one kept on screen across the page change.
54702
+ const renderWithoutMovement = (contentContainer, render) => {
54703
+ if (!animation || !contentContainer) {
54704
+ flushSyncRendering(render);
54705
+ return;
54706
+ }
54707
+ contentContainer.style.transitionProperty = "none";
54708
+ flushSyncRendering(render);
54709
+ // The new style must be resolved while transitions are off — re-enabled
54710
+ // in the same recalc, the change would transition after all.
54711
+ // eslint-disable-next-line no-unused-expressions
54712
+ getComputedStyle(contentContainer).transitionProperty;
54713
+ contentContainer.style.transitionProperty = "";
54714
+ };
54715
+
54665
54716
  // What opening LOOKS like here, and how to undo it — the one thing an
54666
54717
  // expandable owns that a popup does not (see open_controller.js). Reassigned
54667
54718
  // on every render so it always closes over the latest props.
@@ -54682,10 +54733,15 @@ const Expandable = props => {
54682
54733
  // reopening during a collapse — read while the DOM still says closed.
54683
54734
  const startRect = revealing ? contentContainer.getBoundingClientRect() : null;
54684
54735
  cancelSettleWatch();
54685
- flushSyncRendering(() => {
54736
+ const renderOpened = () => {
54686
54737
  setOpened(true);
54687
54738
  setSettled(!revealing);
54688
- });
54739
+ };
54740
+ if (silent) {
54741
+ renderWithoutMovement(contentContainer, renderOpened);
54742
+ } else {
54743
+ flushSyncRendering(renderOpened);
54744
+ }
54689
54745
  if (hasAction) {
54690
54746
  runUnwatched(() => effectiveAction.run());
54691
54747
  }
@@ -54724,16 +54780,22 @@ const Expandable = props => {
54724
54780
  cancelSettleWatch();
54725
54781
  // `silent`: closed by the navigation that puts another page on screen,
54726
54782
  // which is its movement (see page_change.js).
54727
- const collapsing = animation && !closeEvent.detail.silent && Boolean(contentContainerAtClose);
54783
+ const silentClose = Boolean(closeEvent.detail.silent);
54784
+ const collapsing = animation && !silentClose && Boolean(contentContainerAtClose);
54728
54785
  if (collapsing) {
54729
54786
  // Now, while the content is still fully laid out — the collapsing
54730
54787
  // track uncovers a content frozen at that size.
54731
54788
  freezeContentSize();
54732
54789
  }
54733
- flushSyncRendering(() => {
54790
+ const renderClosed = () => {
54734
54791
  setOpened(false);
54735
54792
  setSettled(!collapsing);
54736
- });
54793
+ };
54794
+ if (silentClose) {
54795
+ renderWithoutMovement(contentContainerAtClose, renderClosed);
54796
+ } else {
54797
+ flushSyncRendering(renderClosed);
54798
+ }
54737
54799
  if (restoreFocus) {
54738
54800
  restoreFocus(closeEvent);
54739
54801
  } else if (focusedBeforeClose && contentContainerAtClose && contentContainerAtClose.contains(focusedBeforeClose)) {
@@ -74651,6 +74713,45 @@ const LIST_STYLE_CSS_VARS = {
74651
74713
  backgroundColor: "--list-background-color"
74652
74714
  };
74653
74715
  const LIST_PSEUDO_CLASSES = [":hover", ":focus", ":focus-visible", ":focus-within", ":read-only", ":disabled", ":-navi-void", ":-navi-expanded"];
74716
+ // Two lists mounted at once under one `id` are one entry to the scroll memory:
74717
+ // each overwrites the other's position (see docs/scroll.md). Said the moment
74718
+ // the second one mounts, once per id — on a route transition, that is the page
74719
+ // arriving while the page being left is still mounted, hidden.
74720
+ const createSharedRememberedIdWarning = () => {
74721
+ const mountedListsById = new Map();
74722
+ const warnedIdSet = new Set();
74723
+ return (listId, rememberScroll, ref) => {
74724
+ useLayoutEffect(() => {
74725
+ if (!rememberScroll) {
74726
+ return undefined;
74727
+ }
74728
+ let mountedLists = mountedListsById.get(listId);
74729
+ if (!mountedLists) {
74730
+ mountedLists = new Set();
74731
+ mountedListsById.set(listId, mountedLists);
74732
+ }
74733
+ const mounted = {
74734
+ element: ref.current
74735
+ };
74736
+ if (mountedLists.size > 0 && !warnedIdSet.has(listId)) {
74737
+ warnedIdSet.add(listId);
74738
+ const [other] = mountedLists;
74739
+ console.warn(`<List id="${listId}"> mounted while another list with this id is mounted. The position a list comes back to is kept under its id, so the two overwrite each other's: the one arriving can open where the other one was, or come back at its top. Lists are mounted together on one page, under a popup, in a bar every page keeps, and during a route transition, whose page being left stays mounted until the movement ends. Give each list an id naming it in the app (a component drawing a list on several pages takes its id from the page), or set scrollResetOnNavigation on a list with no use for coming back. See docs/scroll.md.`, {
74740
+ list: mounted.element,
74741
+ otherList: other.element
74742
+ });
74743
+ }
74744
+ mountedLists.add(mounted);
74745
+ return () => {
74746
+ mountedLists.delete(mounted);
74747
+ if (mountedLists.size === 0) {
74748
+ mountedListsById.delete(listId);
74749
+ }
74750
+ };
74751
+ }, [listId, rememberScroll]);
74752
+ };
74753
+ };
74754
+ const useWarnSharedRememberedId = createSharedRememberedIdWarning() ;
74654
74755
  const useListScrollSync = ({
74655
74756
  ref,
74656
74757
  listItems,
@@ -75557,6 +75658,7 @@ const useListScrollSync = ({
75557
75658
  }
75558
75659
  };
75559
75660
  }, []);
75661
+ useWarnSharedRememberedId(listId, rememberScroll, ref);
75560
75662
 
75561
75663
  // A list that gets narrower rewraps every item it holds, so everything below
75562
75664
  // moves and the reader loses their place — the very thing scrolling a long
@@ -83093,6 +83195,8 @@ const TRAVELS_NONE = [];
83093
83195
  * name?: string,
83094
83196
  * min?: any,
83095
83197
  * max?: any,
83198
+ * minMessage?: any,
83199
+ * maxMessage?: any,
83096
83200
  * step?: number,
83097
83201
  * type?: string,
83098
83202
  * editable?: boolean,
@@ -83131,7 +83235,15 @@ const TRAVELS_NONE = [];
83131
83235
  * @param {number} [step=1] How many steps a press covers.
83132
83236
  * @param {number} [duration=250] How long a travel takes, in milliseconds.
83133
83237
  * @param {any} [min] The first value one can reach; `max` is the last. Beyond
83134
- * them the travel simply does not happen and the chevron that way says so.
83238
+ * them the travel simply does not happen and the chevron that way says so,
83239
+ * in the words the control in the middle uses for a value past that end ("La
83240
+ * date doit être au plus tard le 2 avril 2027", "Max 5"). A value the control
83241
+ * has no such sentence for — what a plain `Spin` steps through — is "nothing
83242
+ * after this one".
83243
+ * @param {any} [maxMessage] What is said at `max` instead: by the chevron
83244
+ * refused there, by a travel asked past it, and by the control in the middle
83245
+ * for a value past it (picked in the calendar, typed). `minMessage` is its
83246
+ * twin at `min`.
83135
83247
  * @param {string} [padding] The room around the value, `paddingX`/`paddingY`
83136
83248
  * and the four sides included. It goes on what is inside the box rather than
83137
83249
  * on the box: above and below it is taken by the value AND by the two
@@ -83161,6 +83273,8 @@ const Spin = ({
83161
83273
  name,
83162
83274
  min,
83163
83275
  max,
83276
+ minMessage,
83277
+ maxMessage,
83164
83278
  step = 1,
83165
83279
  duration = 250,
83166
83280
  type = "text",
@@ -83286,15 +83400,31 @@ const Spin = ({
83286
83400
  const readOnlyResolved = Boolean(readOnly || readOnlyFromAbove || loadingResolved);
83287
83401
  const disabledResolved = Boolean(disabled || disabledFromAbove);
83288
83402
 
83403
+ // The end of what one may reach, said the way the control in the middle
83404
+ // says it about a value past that end — its `minMessage`/`maxMessage`, or the
83405
+ // sentence its type has for the bound — so a chevron, the calendar and a
83406
+ // typed value refuse in the same words. Asked of the control at the press:
83407
+ // it is not there yet on the first render. `goingNext` is towards the bigger
83408
+ // values, whichever end of the box that is.
83409
+ const boundMessage = goingNext => {
83410
+ const constraint = goingNext ? MAX_CONSTRAINT : MIN_CONSTRAINT;
83411
+ const controlEl = controlRef.current;
83412
+ const controller = (findControlHost(controlEl) || controlEl).__uiStateController__;
83413
+ const {
83414
+ message
83415
+ } = getConstraintMessage(controller, constraint, constraint.messageAtBound(controller), {});
83416
+ return message ?? naviI18n(goingNext ? "spin.nothing_after" : "spin.nothing_before");
83417
+ };
83418
+
83289
83419
  // Why a chevron refuses, in its own words — and only when the reason is
83290
83420
  // ours: the end of what one may reach is something this control knows and
83291
83421
  // navi cannot guess. For everything else the reason belongs to the state the
83292
83422
  // whole control is in, and it is said about the control ("read-only",
83293
83423
  // "busy") rather than about the button, which is not what the user was
83294
83424
  // pressing.
83295
- const wayOutMessage = (allowed, endKey) => {
83425
+ const wayOutMessage = (allowed, isNext) => {
83296
83426
  if (!allowed) {
83297
- return naviI18n(endKey);
83427
+ return boundMessage(isNext);
83298
83428
  }
83299
83429
  if (loadingResolved) {
83300
83430
  return naviI18n("constraint.busy.default");
@@ -83406,7 +83536,7 @@ const Spin = ({
83406
83536
  // answer belongs.
83407
83537
  e.preventDefault();
83408
83538
  e.stopPropagation();
83409
- openCallout(naviI18n(goingNext ? "spin.nothing_after" : "spin.nothing_before"), {
83539
+ openCallout(boundMessage(goingNext), {
83410
83540
  anchorElement: middleRef.current,
83411
83541
  status: "info",
83412
83542
  openingEvent: e.detail.event ?? e
@@ -83465,12 +83595,14 @@ const Spin = ({
83465
83595
  const valueDrawnAtEnd = travelPending?.area === "end" ? travelPending.value : valueAtEnd;
83466
83596
  const wayOut = atStart => {
83467
83597
  const isNext = atStart ? startIsNext : !startIsNext;
83598
+ const allowed = atStart ? startAllowed : endAllowed;
83468
83599
  return jsx(WayOut, {
83469
83600
  atStart: atStart,
83470
83601
  onPointerDown: e => {
83471
83602
  wayOutPointerTypeRef.current = e.pointerType;
83472
83603
  },
83473
- unavailableMessage: wayOutMessage(atStart ? startAllowed : endAllowed, isNext ? "spin.nothing_after" : "spin.nothing_before"),
83604
+ unavailable: !allowed || loadingResolved || readOnlyResolved,
83605
+ unavailableMessage: () => wayOutMessage(allowed, isNext),
83474
83606
  label: isNext ? nextLabel ?? naviI18n("spin.next") : previousLabel ?? naviI18n("spin.previous"),
83475
83607
  onPress: e => {
83476
83608
  focusMiddle();
@@ -83517,6 +83649,8 @@ const Spin = ({
83517
83649
  ...valueProps,
83518
83650
  min: min,
83519
83651
  max: max,
83652
+ minMessage: minMessage,
83653
+ maxMessage: maxMessage,
83520
83654
  step: step,
83521
83655
  readOnly: readOnly,
83522
83656
  disabled: disabled,
@@ -83550,6 +83684,8 @@ const Spin = ({
83550
83684
  ...valueProps,
83551
83685
  min: min,
83552
83686
  max: max,
83687
+ minMessage: minMessage,
83688
+ maxMessage: maxMessage,
83553
83689
  readOnly: readOnly,
83554
83690
  disabled: disabled,
83555
83691
  loading: loading,
@@ -83660,6 +83796,7 @@ const Spin = ({
83660
83796
  const WayOut = ({
83661
83797
  atStart,
83662
83798
  commandFor,
83799
+ unavailable,
83663
83800
  unavailableMessage,
83664
83801
  label,
83665
83802
  onPress,
@@ -83670,10 +83807,10 @@ const WayOut = ({
83670
83807
  // onPointerUp below).
83671
83808
  const pointerDownRef = useRef(null);
83672
83809
  const press = e => {
83673
- if (unavailableMessage) {
83810
+ if (unavailable) {
83674
83811
  // Why it does nothing, said where one pressed: a control would have
83675
83812
  // done this through its own interaction gate, and this one has none.
83676
- openCallout(unavailableMessage, {
83813
+ openCallout(unavailableMessage(), {
83677
83814
  anchorElement: e.currentTarget,
83678
83815
  status: "info",
83679
83816
  openingEvent: e
@@ -83700,8 +83837,8 @@ const WayOut = ({
83700
83837
  ,
83701
83838
  role: "button",
83702
83839
  "aria-label": label,
83703
- "aria-disabled": unavailableMessage ? "true" : undefined,
83704
- "data-unavailable": unavailableMessage ? "" : undefined
83840
+ "aria-disabled": unavailable ? "true" : undefined,
83841
+ "data-unavailable": unavailable ? "" : undefined
83705
83842
  // At the chevron, not beside it: a callout aims its arrow at where the
83706
83843
  // anchor's text starts, and there is no text here — only a glyph in the
83707
83844
  // middle of the box, which is what one pressed and what the answer is
@@ -83934,6 +84071,8 @@ SpinGroup.Separator = SpinGroupSeparator;
83934
84071
  * defaultValue?: number|string,
83935
84072
  * min?: number,
83936
84073
  * max?: number,
84074
+ * minMessage?: any,
84075
+ * maxMessage?: any,
83937
84076
  * step?: number,
83938
84077
  * pad?: number,
83939
84078
  * loop?: boolean,
@@ -84039,6 +84178,8 @@ const numberAtStep = (value, count, {
84039
84178
  * name?: string,
84040
84179
  * min?: string,
84041
84180
  * max?: string,
84181
+ * minMessage?: any,
84182
+ * maxMessage?: any,
84042
84183
  * step?: number,
84043
84184
  * lang?: string,
84044
84185
  * renderDay?: (day: string) => import("ignore:preact").ComponentChildren,